fix(stripe): replace customers.search with customers.list for regional compatibility

The Stripe Search API (/v1/customers/search) is unavailable in India and
  other regions. This replaces all 3 usages of customers.search with
  customers.list + client-side filtering, which is universally available.

  - User lookups: filter by email via customers.list({email}), then exclude
    organization customers client-side
  - Org lookups: list all customers, filter by organizationId metadata client-side
  - Remove now-unused escapeStripeSearchValue utility

  Closes #7959
This commit is contained in:
Bhagya Mudgal
2026-02-14 02:04:24 +05:30
parent b1f917e5e6
commit 1acec8deda
7 changed files with 69 additions and 75 deletions
+9 -5
View File
@@ -22,7 +22,7 @@ import type {
Subscription,
WithStripeCustomerId,
} from "./types";
import { escapeStripeSearchValue, getPlans, isActiveOrTrialing } from "./utils";
import { getPlans, isActiveOrTrialing } from "./utils";
declare module "@better-auth/core" {
interface BetterAuthPluginRegistry<AuthOptions, Options> {
@@ -304,12 +304,16 @@ export const stripe = <O extends StripeOptions>(options: O) => {
try {
// Check if user customer already exists in Stripe by email
const existingCustomers = await client.customers.search({
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
limit: 1,
const existingCustomers = await client.customers.list({
email: user.email,
limit: 100,
});
let stripeCustomer = existingCustomers.data[0];
let stripeCustomer = existingCustomers.data.find(
(c) =>
c.metadata?.[customerMetadata.keys.customerType] !==
"organization",
);
// If user customer exists, link it to prevent duplicate creation
if (stripeCustomer) {
+14 -9
View File
@@ -26,7 +26,6 @@ import type {
WithStripeCustomerId,
} from "./types";
import {
escapeStripeSearchValue,
getPlanByName,
getPlans,
isActiveOrTrialing,
@@ -335,12 +334,14 @@ export const upgradeSubscription = (options: StripeOptions) => {
if (!customerId) {
try {
// First, search for existing organization customer by organizationId
const existingOrgCustomers = await client.customers.search({
query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`,
limit: 1,
const existingOrgCustomers = await client.customers.list({
limit: 100,
});
let stripeCustomer = existingOrgCustomers.data[0];
let stripeCustomer = existingOrgCustomers.data.find(
(c) =>
c.metadata?.[customerMetadata.keys.organizationId] === org.id,
);
if (!stripeCustomer) {
// Get custom params if provided
@@ -415,12 +416,16 @@ export const upgradeSubscription = (options: StripeOptions) => {
if (!customerId) {
try {
// Try to find existing user Stripe customer by email
const existingCustomers = await client.customers.search({
query: `email:"${escapeStripeSearchValue(user.email)}" AND -metadata["${customerMetadata.keys.customerType}"]:"organization"`,
limit: 1,
const existingCustomers = await client.customers.list({
email: user.email,
limit: 100,
});
let stripeCustomer = existingCustomers.data[0];
let stripeCustomer = existingCustomers.data.find(
(c) =>
c.metadata?.[customerMetadata.keys.customerType] !==
"organization",
);
if (!stripeCustomer) {
stripeCustomer = await client.customers.create({
-11
View File
@@ -41,17 +41,6 @@ export function isStripePendingCancel(stripeSub: Stripe.Subscription): boolean {
return !!(stripeSub.cancel_at_period_end || stripeSub.cancel_at);
}
/**
* Escapes a value for use in Stripe search queries.
* Stripe search query uses double quotes for string values,
* and double quotes within the value need to be escaped with backslash.
*
* @see https://docs.stripe.com/search#search-query-language
*/
export function escapeStripeSearchValue(value: string): string {
return value.replace(/"/g, '\\"');
}
/**
* Resolve the quantity for a subscription by checking the seat item first,
* then falling back to the plan item's quantity.
@@ -15,7 +15,6 @@ describe("seat-based billing", () => {
customers: {
create: vi.fn().mockResolvedValue({ id: "cus_seat_org" }),
list: vi.fn().mockResolvedValue({ data: [] }),
search: vi.fn().mockResolvedValue({ data: [] }),
retrieve: vi.fn().mockResolvedValue({
id: "cus_seat_org",
name: "Seat Org",
@@ -74,7 +73,7 @@ describe("seat-based billing", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStripe.subscriptions.list.mockResolvedValue({ data: [] });
mockStripe.customers.search.mockResolvedValue({ data: [] });
mockStripe.customers.list.mockResolvedValue({ data: [] });
});
describe("checkout with auto-managed seats", async () => {
@@ -15,7 +15,6 @@ describe("stripe - organization customer", () => {
customers: {
create: vi.fn().mockResolvedValue({ id: "cus_org_mock123" }),
list: vi.fn().mockResolvedValue({ data: [] }),
search: vi.fn().mockResolvedValue({ data: [] }),
retrieve: vi.fn().mockResolvedValue({
id: "cus_org_mock123",
email: "org@email.com",
+44 -32
View File
@@ -152,7 +152,6 @@ describe("stripe", () => {
customers: {
create: vi.fn().mockResolvedValue({ id: "cus_mock123" }),
list: vi.fn().mockResolvedValue({ data: [] }),
search: vi.fn().mockResolvedValue({ data: [] }),
retrieve: vi.fn().mockResolvedValue({
id: "cus_mock123",
email: "test@email.com",
@@ -2539,8 +2538,8 @@ describe("stripe", () => {
},
);
// Mock customers.search to find existing user customer
mockStripe.customers.search.mockResolvedValueOnce({
// Mock customers.list to find existing user customer
mockStripe.customers.list.mockResolvedValueOnce({
data: [{ id: "cus_test_123" }],
});
@@ -3621,7 +3620,7 @@ describe("stripe", () => {
const existingEmail = "duplicate-email@example.com";
const existingCustomerId = "cus_stripe_existing_456";
mockStripe.customers.search.mockResolvedValueOnce({
mockStripe.customers.list.mockResolvedValueOnce({
data: [
{
id: existingCustomerId,
@@ -3662,10 +3661,10 @@ describe("stripe", () => {
{ throw: true },
);
// Should check for existing user customer by email (excluding organization customers)
expect(mockStripe.customers.search).toHaveBeenCalledWith({
query: `email:"${existingEmail}" AND -metadata["customerType"]:"organization"`,
limit: 1,
// Should check for existing user customer by email
expect(mockStripe.customers.list).toHaveBeenCalledWith({
email: existingEmail,
limit: 100,
});
// Should NOT create duplicate customer
@@ -3684,7 +3683,7 @@ describe("stripe", () => {
it("should CREATE customer only when user has no stripeCustomerId and none exists in Stripe", async () => {
const newEmail = "brand-new@example.com";
mockStripe.customers.search.mockResolvedValueOnce({
mockStripe.customers.list.mockResolvedValueOnce({
data: [],
});
@@ -3724,10 +3723,10 @@ describe("stripe", () => {
{ throw: true },
);
// Should check for existing user customer first (excluding organization customers)
expect(mockStripe.customers.search).toHaveBeenCalledWith({
query: `email:"${newEmail}" AND -metadata["customerType"]:"organization"`,
limit: 1,
// Should check for existing user customer first by email
expect(mockStripe.customers.list).toHaveBeenCalledWith({
email: newEmail,
limit: 100,
});
// Should create new customer (this is correct behavior)
@@ -3760,10 +3759,15 @@ describe("stripe", () => {
const orgCustomerId = "cus_org_123";
// Mock: Only organization customer exists with this email
// The search query includes `-metadata['customerType']:'organization'`
// so this should NOT be returned
mockStripe.customers.search.mockResolvedValueOnce({
data: [], // Organization customer is excluded by the search query
// The client-side filter excludes organization customers
mockStripe.customers.list.mockResolvedValueOnce({
data: [
{
id: orgCustomerId,
email: sharedEmail,
metadata: { customerType: "organization" },
},
],
});
mockStripe.customers.create.mockResolvedValueOnce({
@@ -3802,10 +3806,10 @@ describe("stripe", () => {
{ throw: true },
);
// Should search with query that EXCLUDES organization customers
expect(mockStripe.customers.search).toHaveBeenCalledWith({
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
limit: 1,
// Should list customers by email and filter client-side
expect(mockStripe.customers.list).toHaveBeenCalledWith({
email: sharedEmail,
limit: 100,
});
// Should create NEW user customer (not use org customer)
@@ -3836,9 +3840,18 @@ describe("stripe", () => {
const sharedEmail = "both-exist@example.com";
const existingUserCustomerId = "cus_user_existing_789";
// Mock: Search returns ONLY user customer (org customer excluded by query)
mockStripe.customers.search.mockResolvedValueOnce({
// Mock: List returns both org and user customers; client-side filter picks user
mockStripe.customers.list.mockResolvedValueOnce({
data: [
{
id: "cus_org_same_email",
email: sharedEmail,
name: "Org Customer",
metadata: {
customerType: "organization",
organizationId: "org_123",
},
},
{
id: existingUserCustomerId,
email: sharedEmail,
@@ -3882,10 +3895,10 @@ describe("stripe", () => {
{ throw: true },
);
// Should search excluding organization customers
expect(mockStripe.customers.search).toHaveBeenCalledWith({
query: `email:"${sharedEmail}" AND -metadata["customerType"]:"organization"`,
limit: 1,
// Should list customers by email and filter client-side
expect(mockStripe.customers.list).toHaveBeenCalledWith({
email: sharedEmail,
limit: 100,
});
// Should NOT create new customer - use existing user customer
@@ -3906,7 +3919,7 @@ describe("stripe", () => {
const orgEmail = "org@example.com";
const orgId = "org_test_123";
mockStripe.customers.search.mockResolvedValueOnce({
mockStripe.customers.list.mockResolvedValueOnce({
data: [],
});
@@ -3949,12 +3962,11 @@ describe("stripe", () => {
// Manually trigger the organization customer creation flow
// by calling the internal function (simulating what hooks do)
const stripeClient = stripeOptions.stripeClient;
const searchResult = await stripeClient.customers.search({
query: `email:'${orgEmail}' AND metadata['customerType']:'organization'`,
limit: 1,
const listResult = await stripeClient.customers.list({
limit: 100,
});
if (searchResult.data.length === 0) {
if (listResult.data.length === 0) {
await stripeClient.customers.create({
email: orgEmail,
name: "Test Organization",
+1 -15
View File
@@ -1,21 +1,7 @@
import type Stripe from "stripe";
import { describe, expect, it } from "vitest";
import type { StripeOptions } from "../src/types";
import { escapeStripeSearchValue, resolvePlanItem } from "../src/utils";
describe("escapeStripeSearchValue", () => {
it("should escape double quotes", () => {
expect(escapeStripeSearchValue('test"value')).toBe('test\\"value');
});
it("should handle strings without quotes", () => {
expect(escapeStripeSearchValue("simple")).toBe("simple");
});
it("should escape multiple quotes", () => {
expect(escapeStripeSearchValue('"a" and "b"')).toBe('\\"a\\" and \\"b\\"');
});
});
import { resolvePlanItem } from "../src/utils";
describe("resolvePlanItem", () => {
const options = {