fix(stripe): preserve freeTrial and internal metadata in getCheckoutSessionParams merge (#9474)

This commit is contained in:
Taesu
2026-05-06 15:36:52 +00:00
committed by GitHub
parent 8d71f42421
commit 07b52cbb79
3 changed files with 125 additions and 4 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/stripe": patch
---
Returning a custom `subscription_data` from `getCheckoutSessionParams` no longer hides the plan's free trial in Stripe Checkout or creates duplicate local subscription rows when the `customer.subscription.created` webhook fires.
+4 -4
View File
@@ -1124,8 +1124,12 @@ export const upgradeSubscription = (options: StripeOptions) => {
// Additional line items (metered prices, add-ons, etc.)
...(plan.lineItems ?? []),
],
mode: "subscription",
client_reference_id: referenceId,
...params?.params,
subscription_data: {
...freeTrial,
...params?.params?.subscription_data,
metadata: subscriptionMetadata.set(
{
userId: user.id,
@@ -1136,10 +1140,6 @@ export const upgradeSubscription = (options: StripeOptions) => {
params?.params?.subscription_data?.metadata,
),
},
mode: "subscription",
client_reference_id: referenceId,
...params?.params,
// metadata should come after spread to protect internal fields
metadata: subscriptionMetadata.set(
{
userId: user.id,
+116
View File
@@ -8666,4 +8666,120 @@ describe("stripe", () => {
expect(meteredItem).not.toHaveProperty("quantity");
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/9129
* @see https://github.com/better-auth/better-auth/issues/9130
*/
describe("getCheckoutSessionParams subscription_data merge", () => {
const trialOptions = {
...stripeOptions,
subscription: {
enabled: true,
plans: [
{
priceId: process.env.STRIPE_PRICE_ID_1!,
name: "starter",
lookupKey: "lookup_key_123",
freeTrial: { days: 14 },
},
],
getCheckoutSessionParams: async () => ({
params: {
payment_method_collection: "if_required" as const,
subscription_data: {
trial_settings: {
end_behavior: {
missing_payment_method: "cancel" as const,
},
},
},
},
}),
},
} satisfies StripeOptions;
it("preserves plan freeTrial when getCheckoutSessionParams returns custom subscription_data", async () => {
const { client, sessionSetter } = await getTestInstance(
{
database: memory,
plugins: [stripe(trialOptions)],
},
{
disableTestUser: true,
clientOptions: {
plugins: [stripeClient({ subscription: true })],
},
},
);
const email = "trial-merge@email.com";
await client.signUp.email({ ...testUser, email }, { throw: true });
const headers = new Headers();
await client.signIn.email(
{ ...testUser, email },
{ throw: true, onSuccess: sessionSetter(headers) },
);
mockStripe.checkout.sessions.create.mockClear();
await client.subscription.upgrade({
plan: "starter",
fetchOptions: { headers },
});
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
expect.objectContaining({
subscription_data: expect.objectContaining({
trial_period_days: 14,
trial_settings: {
end_behavior: { missing_payment_method: "cancel" },
},
}),
}),
undefined,
);
});
it("preserves internal subscription metadata when getCheckoutSessionParams returns custom subscription_data", async () => {
const { client, sessionSetter } = await getTestInstance(
{
database: memory,
plugins: [stripe(trialOptions)],
},
{
disableTestUser: true,
clientOptions: {
plugins: [stripeClient({ subscription: true })],
},
},
);
const email = "metadata-merge@email.com";
await client.signUp.email({ ...testUser, email }, { throw: true });
const headers = new Headers();
await client.signIn.email(
{ ...testUser, email },
{ throw: true, onSuccess: sessionSetter(headers) },
);
mockStripe.checkout.sessions.create.mockClear();
await client.subscription.upgrade({
plan: "starter",
fetchOptions: { headers },
});
expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
expect.objectContaining({
subscription_data: expect.objectContaining({
metadata: expect.objectContaining({
subscriptionId: expect.any(String),
userId: expect.any(String),
referenceId: expect.any(String),
}),
}),
}),
undefined,
);
});
});
});