From 07b52cbb795ff74fcd3747036eefd0c780cf8d58 Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Thu, 7 May 2026 00:36:52 +0900 Subject: [PATCH] fix(stripe): preserve freeTrial and internal metadata in `getCheckoutSessionParams` merge (#9474) --- .../fix-stripe-subscription-data-merge.md | 5 + packages/stripe/src/routes.ts | 8 +- packages/stripe/test/stripe.test.ts | 116 ++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-stripe-subscription-data-merge.md diff --git a/.changeset/fix-stripe-subscription-data-merge.md b/.changeset/fix-stripe-subscription-data-merge.md new file mode 100644 index 0000000000..62e507ee93 --- /dev/null +++ b/.changeset/fix-stripe-subscription-data-merge.md @@ -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. diff --git a/packages/stripe/src/routes.ts b/packages/stripe/src/routes.ts index fe88250ef7..f88763f57e 100644 --- a/packages/stripe/src/routes.ts +++ b/packages/stripe/src/routes.ts @@ -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, diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index 9949d5539c..3f660d696c 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -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, + ); + }); + }); });