Upgrading / switching plans does not cancel original subscription when isTrial #1560

Closed
opened 2026-03-13 08:48:30 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @nowy on GitHub (Jul 23, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Setup better-auth with 2 Stripe plans.
  2. Subscribe to the 1st Stripe plan.
  3. Upgrade to the 2nd Stripe plan.

Current vs. Expected behavior

Following the steps of the previous section, I would expect there to be one entry in the database subscription table, and 1 entry in the Stripe subscription page for the user.

What actually happens

  • There is one entry in the database subscription table with the updated plan
Image
  • There are 2 subscriptions in Stripe
Image

What version of Better Auth are you using?

1.3.3

Provide environment information

- OS: Mac OS
- Browser: Irrelevant
- Using next.js

Which area(s) are affected? (Select all that apply)

Package

Auth config (if applicable)

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  session: {
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // Cache duration in seconds
    },
  },
  emailVerification: {
    // ...
  },
  user: {
    // ...
  },
  account: {
    // ...
  },
  plugins: [
    admin(),
    organization({
      // ...
    }),

    emailOTP({
      // ...
    }),

    stripe({
      stripeClient: stripeInstance,
      stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
      createCustomerOnSignUp: true,

      subscription: {
        enabled: true,
        plans: pricingData
          .filter((plan) => !!plan.stripeIds)
          .map((plan) => ({
            name: plan.name === "Semi-Pro" ? "Enthusiast" : plan.name,
            priceId: plan.stripeIds!.priceId,
            freeTrial: {
              days: 30,
            },
          })),
        getCheckoutSessionParams: async ({ user }) => {
          return {
            params: {
              allow_promotion_codes: true,
            },
          };
        },

        onSubscriptionComplete: async ({ subscription, plan }) => {
          try {
            const user = subscription.stripeCustomerId
              ? await prisma.user.findFirst({
                  where: {
                    stripeCustomerId: subscription.stripeCustomerId,
                  },
                })
              : null;

            if (user) {
              await resend.emails.send({
                from,
                to: user.email,
                subject: `Welcome to ${plan.name} - Your subscription is active!`,
                react: reactSubscriptionCompleteEmail({
                  username: user.email,
                  planName: plan.name,
                  dashboardLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.competition.root}`,
                }),
              });
            }
          } catch (error) {
            console.error("Error sending subscription complete email:", error);
          }
        },
        onSubscriptionCancel: async ({ subscription }) => {
          try {
            const user = subscription.stripeCustomerId
              ? await prisma.user.findFirst({
                  where: {
                    stripeCustomerId: subscription.stripeCustomerId.toString(),
                  },
                })
              : null;

            if (user) {
              const endDate = subscription.periodEnd
                ? subscription.periodEnd.toLocaleDateString()
                : "N/A";

              await resend.emails.send({
                from,
                to: user.email,
                subject: `Your ${subscription?.plan || ""} subscription has been canceled`,
                react: reactSubscriptionCancelEmail({
                  username: user.email,
                  planName: subscription?.plan || "Premium",
                  endDate,
                  reactivateLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.account.billing}`,
                }),
              });
            }
          } catch (error) {
            console.error("Error sending subscription cancel email:", error);
          }
        },
        onSubscriptionDeleted: async ({ subscription }) => {
          try {
            const user = subscription.stripeCustomerId
              ? await prisma.user.findFirst({
                  where: {
                    stripeCustomerId: subscription.stripeCustomerId.toString(),
                  },
                })
              : null;

            if (user) {
              await resend.emails.send({
                from,
                to: user.email,
                subject: `Your ${subscription?.plan || "Premium"} subscription has ended`,
                react: reactSubscriptionDeletedEmail({
                  username: user.email,
                  planName: subscription?.plan || "Premium",
                  signupLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.pricing}`,
                }),
              });
            }
          } catch (error) {
            console.error("Error sending subscription deleted email:", error);
          }
        },
      },
    }),
    // make sure this is the last plugin in the array
    nextCookies(),
  ],

  emailAndPassword: {
    // ...
  },
  socialProviders: {
    // ...
   },
});

Additional context

Note that this happens for trialing users. I'm not sure if it happens if the subscription is already active.

Originally created by @nowy on GitHub (Jul 23, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Setup `better-auth` with 2 Stripe plans. 2. Subscribe to the 1st Stripe plan. 3. Upgrade to the 2nd Stripe plan. ### Current vs. Expected behavior Following the steps of the previous section, I would expect there to be one entry in the database `subscription` table, and 1 entry in the Stripe subscription page for the user. **What actually happens** - There is one entry in the database `subscription` table with the updated `plan` ✅ <img width="1196" height="135" alt="Image" src="https://github.com/user-attachments/assets/2089d3c8-18ca-4cd8-bc78-535290d23658" /> - There are 2 subscriptions in Stripe <img width="901" height="107" alt="Image" src="https://github.com/user-attachments/assets/5da32008-4da4-4ff8-ae22-f61cb2917877" /> ### What version of Better Auth are you using? 1.3.3 ### Provide environment information ```bash - OS: Mac OS - Browser: Irrelevant - Using next.js ``` ### Which area(s) are affected? (Select all that apply) Package ### Auth config (if applicable) ```typescript export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "postgresql", }), session: { cookieCache: { enabled: true, maxAge: 5 * 60, // Cache duration in seconds }, }, emailVerification: { // ... }, user: { // ... }, account: { // ... }, plugins: [ admin(), organization({ // ... }), emailOTP({ // ... }), stripe({ stripeClient: stripeInstance, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, subscription: { enabled: true, plans: pricingData .filter((plan) => !!plan.stripeIds) .map((plan) => ({ name: plan.name === "Semi-Pro" ? "Enthusiast" : plan.name, priceId: plan.stripeIds!.priceId, freeTrial: { days: 30, }, })), getCheckoutSessionParams: async ({ user }) => { return { params: { allow_promotion_codes: true, }, }; }, onSubscriptionComplete: async ({ subscription, plan }) => { try { const user = subscription.stripeCustomerId ? await prisma.user.findFirst({ where: { stripeCustomerId: subscription.stripeCustomerId, }, }) : null; if (user) { await resend.emails.send({ from, to: user.email, subject: `Welcome to ${plan.name} - Your subscription is active!`, react: reactSubscriptionCompleteEmail({ username: user.email, planName: plan.name, dashboardLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.competition.root}`, }), }); } } catch (error) { console.error("Error sending subscription complete email:", error); } }, onSubscriptionCancel: async ({ subscription }) => { try { const user = subscription.stripeCustomerId ? await prisma.user.findFirst({ where: { stripeCustomerId: subscription.stripeCustomerId.toString(), }, }) : null; if (user) { const endDate = subscription.periodEnd ? subscription.periodEnd.toLocaleDateString() : "N/A"; await resend.emails.send({ from, to: user.email, subject: `Your ${subscription?.plan || ""} subscription has been canceled`, react: reactSubscriptionCancelEmail({ username: user.email, planName: subscription?.plan || "Premium", endDate, reactivateLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.account.billing}`, }), }); } } catch (error) { console.error("Error sending subscription cancel email:", error); } }, onSubscriptionDeleted: async ({ subscription }) => { try { const user = subscription.stripeCustomerId ? await prisma.user.findFirst({ where: { stripeCustomerId: subscription.stripeCustomerId.toString(), }, }) : null; if (user) { await resend.emails.send({ from, to: user.email, subject: `Your ${subscription?.plan || "Premium"} subscription has ended`, react: reactSubscriptionDeletedEmail({ username: user.email, planName: subscription?.plan || "Premium", signupLink: `${process.env.NEXT_PUBLIC_APP_URL}${routes.pricing}`, }), }); } } catch (error) { console.error("Error sending subscription deleted email:", error); } }, }, }), // make sure this is the last plugin in the array nextCookies(), ], emailAndPassword: { // ... }, socialProviders: { // ... }, }); ``` ### Additional context Note that this happens for `trialing` users. I'm not sure if it happens if the subscription is already `active`.
GiteaMirror added the bug label 2026-03-13 08:48:30 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 23, 2025):

This is a known limitation in Better Auth's Stripe integration: when upgrading or switching plans, the old Stripe subscription is not automatically canceled, which can result in multiple active subscriptions for the same user in Stripe, even though the database only shows one subscription entry with the updated plan. This mismatch is especially common with trialing or incomplete subscriptions, but can also affect active ones depending on the upgrade path and Stripe's default behavior (sourcecode reference).

Recent PRs have improved duplicate subscription checks and priceId synchronization, but there is still no built-in guarantee that only one active Stripe subscription exists per user after an upgrade (PR #2967, PR #3239). The upgrade logic tries to update the existing subscription, but if a new one is created (especially for trialing users), the old one is not canceled automatically.

To work around this, you can add custom logic in your onSubscriptionComplete or onSubscriptionUpdate hooks to check for and cancel any previous active/trialing subscriptions for the user via the Stripe API. Alternatively, you can manually cancel old subscriptions in Stripe when you detect duplicates. If you use different billing intervals (monthly/yearly), Stripe recommends using separate products to avoid related issues (discussion).

There is no config option in v1.3.3 to enforce single active Stripe subscriptions per user, so custom handling is currently required. Keep an eye on future releases for improvements in this area.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

@dosubot[bot] commented on GitHub (Jul 23, 2025): <!-- Answer --> This is a known limitation in Better Auth's Stripe integration: when upgrading or switching plans, the old Stripe subscription is not automatically canceled, which can result in multiple active subscriptions for the same user in Stripe, even though the database only shows one subscription entry with the updated plan. This mismatch is especially common with trialing or incomplete subscriptions, but can also affect active ones depending on the upgrade path and Stripe's default behavior ([source](https://github.com/better-auth/better-auth/issues/3545), [code reference](https://github.com/better-auth/better-auth/blob/602ab7c4c6913d601994de0ae679be6645a58d69/packages/stripe/src/index.ts)). Recent PRs have improved duplicate subscription checks and priceId synchronization, but there is still no built-in guarantee that only one active Stripe subscription exists per user after an upgrade ([PR #2967](https://github.com/better-auth/better-auth/pull/2967), [PR #3239](https://github.com/better-auth/better-auth/pull/3239)). The upgrade logic tries to update the existing subscription, but if a new one is created (especially for trialing users), the old one is not canceled automatically. To work around this, you can add custom logic in your `onSubscriptionComplete` or `onSubscriptionUpdate` hooks to check for and cancel any previous active/trialing subscriptions for the user via the Stripe API. Alternatively, you can manually cancel old subscriptions in Stripe when you detect duplicates. If you use different billing intervals (monthly/yearly), Stripe recommends using separate products to avoid related issues ([discussion](https://github.com/better-auth/better-auth/issues/3393)). There is no config option in v1.3.3 to enforce single active Stripe subscriptions per user, so custom handling is currently required. Keep an eye on future releases for improvements in this area. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/cd5a38fc-1483-4006-b050-20f572b8637b?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3560)
Author
Owner

@ping-maxwell commented on GitHub (Jul 24, 2025):

@nowy Can you show me how you're calling the upgrade method?

@ping-maxwell commented on GitHub (Jul 24, 2025): @nowy Can you show me how you're calling the upgrade method?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1560