mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
fix(stripe): improve organization customer search by adding customerType check (#8609)
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user