[GH-ISSUE #3562] Upgrading / switching plans sets status to past_due #26971

Closed
opened 2026-04-17 17:44:04 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @nowy on GitHub (Jul 23, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3562

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a stripe config with 2 plans.
  2. Subscribe to one plan.
  3. Upgrade to the other plan.

Current vs. Expected behavior

It works on stripe, but the database upgrades the status to past_due:

What version of Better Auth are you using?

1.3.3

Provide environment information

- MacOS

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
    },
  },
  account: {
    accountLinking: {
      trustedProviders: ["google"],
    },
  },
  plugins: [
    admin(),
    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,
            priceId: plan.stripeIds!.priceId,
          })),
        getCheckoutSessionParams: async ({ user }) => {
          return {
            params: {
              allow_promotion_codes: true,
              metadata: {
                userId: user.id,
                email: user.email,
              },
              tax_id_collection: {
                enabled: true,
              },
              billing_address_collection: "auto",
            },
          };
        },

        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(),
  ],
});

Additional context

Related to https://github.com/better-auth/better-auth/issues/3560 but this time with no isTrial

Originally created by @nowy on GitHub (Jul 23, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3562 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a stripe config with 2 plans. 2. Subscribe to one plan. 3. Upgrade to the other plan. ### Current vs. Expected behavior It works on stripe, but the database upgrades the `status` to `past_due`: ### What version of Better Auth are you using? 1.3.3 ### Provide environment information ```bash - MacOS ``` ### 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 }, }, account: { accountLinking: { trustedProviders: ["google"], }, }, plugins: [ admin(), 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, priceId: plan.stripeIds!.priceId, })), getCheckoutSessionParams: async ({ user }) => { return { params: { allow_promotion_codes: true, metadata: { userId: user.id, email: user.email, }, tax_id_collection: { enabled: true, }, billing_address_collection: "auto", }, }; }, 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(), ], }); ``` ### Additional context Related to https://github.com/better-auth/better-auth/issues/3560 but this time with no `isTrial`
GiteaMirror added the locked label 2026-04-17 17:44:04 -05:00
Author
Owner

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

This is a known issue in Better Auth v1.3.3: upgrading or switching Stripe plans can incorrectly set the subscription status to past_due in your database, even though everything looks fine in Stripe. The root cause is that the plugin previously updated incomplete or transitioning subscriptions in the DB without verifying their real status with Stripe, especially during plan changes or multiple checkout sessions. This could result in mismatches like past_due or incomplete statuses locally, even when the Stripe subscription is actually active or trialing (discussion, PR #2930).

A fix for this was merged in PR #3554 on 2025-07-23, which prevents updating incomplete Stripe sessions in the DB and ensures the local status matches Stripe's actual status (PR #3554). This fix is not included in v1.3.3. To resolve the issue, update to the latest version of Better Auth that includes this fix.

If you must stay on v1.3.3, a workaround is to manually verify the subscription status with Stripe before updating your local DB, especially after plan upgrades or switches. You can adapt the logic suggested in this comment to only update the DB if Stripe confirms the subscription is truly active or trialing.

Also, make sure you always provide the subscriptionId parameter when switching plans to avoid duplicate subscriptions and status confusion (docs).

Let me know if you need a code snippet or more details on implementing the workaround!

To reply, just mention @dosu.


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

<!-- gh-comment-id:3107742206 --> @dosubot[bot] commented on GitHub (Jul 23, 2025): <!-- Answer --> This is a known issue in Better Auth v1.3.3: upgrading or switching Stripe plans can incorrectly set the subscription status to `past_due` in your database, even though everything looks fine in Stripe. The root cause is that the plugin previously updated incomplete or transitioning subscriptions in the DB without verifying their real status with Stripe, especially during plan changes or multiple checkout sessions. This could result in mismatches like `past_due` or `incomplete` statuses locally, even when the Stripe subscription is actually active or trialing ([discussion](https://github.com/better-auth/better-auth/issues/3545), [PR #2930](https://github.com/better-auth/better-auth/pull/2930)). A fix for this was merged in PR #3554 on 2025-07-23, which prevents updating incomplete Stripe sessions in the DB and ensures the local status matches Stripe's actual status ([PR #3554](https://github.com/better-auth/better-auth/pull/3554)). This fix is not included in v1.3.3. To resolve the issue, update to the latest version of Better Auth that includes this fix. If you must stay on v1.3.3, a workaround is to manually verify the subscription status with Stripe before updating your local DB, especially after plan upgrades or switches. You can adapt the logic suggested in [this comment](https://github.com/better-auth/better-auth/issues/3545#issuecomment-2159649441) to only update the DB if Stripe confirms the subscription is truly active or trialing. Also, make sure you always provide the `subscriptionId` parameter when switching plans to avoid duplicate subscriptions and status confusion ([docs](https://github.com/better-auth/better-auth/pull/2862)). Let me know if you need a code snippet or more details on implementing the workaround! <!-- 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/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/32cf4bbc-0a5b-4efe-a442-41370e062e2a?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/3562)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#26971