diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index 56f276a363..9e9b103a14 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -347,7 +347,7 @@ export const upgradeSubscription = (options: StripeOptions) => { let stripeCustomer: Stripe.Customer | undefined; try { const result = await client.customers.search({ - query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}"`, + query: `metadata["${customerMetadata.keys.organizationId}"]:"${org.id}" AND metadata["${customerMetadata.keys.customerType}"]:"organization"`, limit: 1, }); stripeCustomer = result.data[0]; @@ -362,7 +362,9 @@ export const upgradeSubscription = (options: StripeOptions) => { if ( customer.metadata?.[ customerMetadata.keys.organizationId - ] === org.id + ] === org.id && + customer.metadata?.[customerMetadata.keys.customerType] === + "organization" ) { stripeCustomer = customer; break; diff --git a/packages/stripe/test/stripe-organization.test.ts b/packages/stripe/test/stripe-organization.test.ts index e1d7fe4b2e..a7035eeb06 100644 --- a/packages/stripe/test/stripe-organization.test.ts +++ b/packages/stripe/test/stripe-organization.test.ts @@ -1664,6 +1664,172 @@ describe("stripe - organization customer", () => { }), ); }); + + it("should not match user customer with organizationId in metadata during org customer lookup", async () => { + const userCustomerId = "cus_user_with_org_metadata"; + const orgCustomerId = "cus_org_new"; + + const mockStripeCustomerType = { + prices: { + list: vi.fn().mockResolvedValue({ data: [{ id: "price_lookup_123" }] }), + }, + customers: { + create: vi.fn().mockResolvedValue({ + id: orgCustomerId, + metadata: { customerType: "organization" }, + }), + list: vi.fn().mockResolvedValue({ data: [] }), + // First call (user upgrade): no existing customer + // Second call (org upgrade): returns user customer only if query + // lacks customerType filter — verifies the fix actually matters + search: vi.fn().mockResolvedValueOnce({ data: [] }), + retrieve: vi.fn(), + update: vi.fn(), + }, + checkout: { + sessions: { + create: vi.fn().mockResolvedValue({ + url: "https://checkout.stripe.com/mock", + id: "cs_mock123", + }), + }, + }, + billingPortal: { + sessions: { + create: vi + .fn() + .mockResolvedValue({ url: "https://billing.stripe.com/mock" }), + }, + }, + subscriptions: { + retrieve: vi.fn(), + list: vi.fn().mockResolvedValue({ data: [] }), + update: vi.fn(), + }, + webhooks: { constructEventAsync: vi.fn() }, + }; + + const stripeOptionsCustomerType: StripeOptions = { + ...baseOrgStripeOptions, + stripeClient: mockStripeCustomerType as unknown as Stripe, + }; + + const { client, auth, sessionSetter } = await getTestInstance( + { + plugins: [organization(), stripe(stripeOptionsCustomerType)], + }, + { + disableTestUser: true, + clientOptions: { + plugins: [organizationClient(), stripeClient({ subscription: true })], + }, + }, + ); + const ctx = await auth.$context; + + // User A signs up + await client.signUp.email( + { ...testUser, email: "user-a@email.com" }, + { throw: true }, + ); + const userAHeaders = new Headers(); + await client.signIn.email( + { ...testUser, email: "user-a@email.com" }, + { throw: true, onSuccess: sessionSetter(userAHeaders) }, + ); + + // User B signs up and creates an organization + await client.signUp.email( + { ...testUser, email: "user-b@email.com" }, + { throw: true }, + ); + const userBHeaders = new Headers(); + await client.signIn.email( + { ...testUser, email: "user-b@email.com" }, + { throw: true, onSuccess: sessionSetter(userBHeaders) }, + ); + + const org = await client.organization.create({ + name: "Test Org", + slug: "customer-type-filter-org", + fetchOptions: { headers: userBHeaders }, + }); + const orgId = org.data?.id as string; + + // User A upgrades with metadata containing the org's ID + mockStripeCustomerType.customers.create.mockResolvedValueOnce({ + id: userCustomerId, + metadata: { + userId: "user-a", + customerType: "user", + organizationId: orgId, + }, + }); + + await client.subscription.upgrade({ + plan: "starter", + metadata: { organizationId: orgId }, + fetchOptions: { headers: userAHeaders }, + }); + + // Organization upgrades + mockStripeCustomerType.customers.search.mockImplementationOnce( + (params: { query?: string }) => { + const query = params?.query ?? ""; + if (query.includes(orgId) && !query.includes("customerType")) { + // Without the fix: user customer would be returned + return Promise.resolve({ + data: [ + { + id: userCustomerId, + metadata: { + userId: "user-a", + customerType: "user", + organizationId: orgId, + }, + }, + ], + }); + } + // With the fix: customerType filter excludes user customer + return Promise.resolve({ data: [] }); + }, + ); + + mockStripeCustomerType.customers.create.mockResolvedValueOnce({ + id: orgCustomerId, + metadata: { organizationId: orgId, customerType: "organization" }, + }); + + await client.subscription.upgrade({ + plan: "starter", + customerType: "organization", + referenceId: orgId, + fetchOptions: { headers: userBHeaders }, + }); + + // Organization should have its own customer, not User A's + const updatedOrg = await ctx.adapter.findOne<{ + id: string; + stripeCustomerId?: string; + }>({ + model: "organization", + where: [{ field: "id", value: orgId }], + }); + expect(updatedOrg?.stripeCustomerId).toBe(orgCustomerId); + + // Find the org lookup search call and verify the full query + const orgSearchCall = + mockStripeCustomerType.customers.search.mock.calls.find( + ([arg]) => + typeof arg?.query === "string" && + arg.query.includes(orgId) && + arg.query.includes("organizationId"), + ); + expect(orgSearchCall?.[0]?.query).toContain( + `metadata["organizationId"]:"${orgId}" AND metadata["customerType"]:"organization"`, + ); + }); }); describe("stripe - organizationHooks integration", () => {