fix(stripe): improve organization customer search by adding customerType check (#8609)

This commit is contained in:
Taesu
2026-03-19 12:20:44 -07:00
committed by Bereket Engida
parent 98c8e4e65c
commit 74ec71cae1
2 changed files with 170 additions and 2 deletions
+4 -2
View File
@@ -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;
@@ -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", () => {