diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index cf0847d8dc..f7705e6676 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -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 { @@ -304,12 +304,16 @@ export const stripe = (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) { diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index 81b81ae697..d1a7b7bab7 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -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({ diff --git a/packages/stripe/src/utils.ts b/packages/stripe/src/utils.ts index 73d165ac5f..37be850742 100644 --- a/packages/stripe/src/utils.ts +++ b/packages/stripe/src/utils.ts @@ -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. diff --git a/packages/stripe/test/seat-based-billing.test.ts b/packages/stripe/test/seat-based-billing.test.ts index 12ac42fa14..3f31a0dd96 100644 --- a/packages/stripe/test/seat-based-billing.test.ts +++ b/packages/stripe/test/seat-based-billing.test.ts @@ -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 () => { diff --git a/packages/stripe/test/stripe-organization.test.ts b/packages/stripe/test/stripe-organization.test.ts index e1d7fe4b2e..b22ce85833 100644 --- a/packages/stripe/test/stripe-organization.test.ts +++ b/packages/stripe/test/stripe-organization.test.ts @@ -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", diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index 1de92dc10b..ad01053ba9 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -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", diff --git a/packages/stripe/test/utils.test.ts b/packages/stripe/test/utils.test.ts index fa4413995d..1a19e7889d 100644 --- a/packages/stripe/test/utils.test.ts +++ b/packages/stripe/test/utils.test.ts @@ -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 = {