Skip to main content

Fix BUG-051 — Upgrade flow created duplicate Stripe subscriptions

Status

DECIDED

Context

The admin dashboard's Upgrade tab (/admin/[token]#upgrade) surfaced "Upgrade to X" buttons that linked to /onboard/checkout?token=<admin_token>&tier=<X>. That page mounted UpgradeCheckoutForm, which POSTed to /api/stripe/checkout-embedded.

The upgrade branch of that route looked like this:

// Upgrade flow (existing customer)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: resolved.premium.admin_email, // no customer id
// …no reference to existing subscription either
});

Three compounding problems:

  1. mode: 'subscription' always creates a new subscription — there is no Stripe concept of "upgrade an existing sub via checkout session." To upgrade an active sub you call stripe.subscriptions.update().
  2. Only customer_email was passed, not customer: stripe_customer_id. Stripe creates a brand-new Customer record for the "upgrade," so the existing sub and the new sub live on different customers — they never collide via idempotency.
  3. The webhook's activateChurch() only checks stripe_subscription_id equality for idempotency. On an upgrade, the new sub id is different, so the handler proceeds and simply overwrites the old stripe_subscription_id in premium_churches. The OLD subscription continues to bill in Stripe indefinitely — we lose all reference to it from the DB side.

Grep confirmed zero calls to stripe.subscriptions.cancel() anywhere in the codebase, so there's no mechanism that ever would have cleaned it up.

Customer impact if a Pro Website customer had clicked Upgrade to Bundle Starter: $19.95/mo Pro Website + $54.95/mo Bundle Starter = $74.90/mo, billed indefinitely, with no in-product record of the duplicate.

No customer has hit this yet. The Upgrade tab shows Suite as the only Pro Website upgrade in pre-2026-04-20 code and we only have 3 real paying customers (none on starter→pro upgrades either). The bug was dormant.

The right pattern already existed in /api/stripe/church-checkout (referenced in code comments there as "BUG-051 fix"). It:

  1. Detects an active existing sub via premium.status === 'active' && stripe_subscription_id.
  2. Calls stripe.subscriptions.retrieve() to confirm it's still active or trialing in Stripe.
  3. Calls stripe.subscriptions.update() in-place with proration_behavior: 'create_prorations'.
  4. Redirects to /admin/{token}?upgraded=true.

The fix was applied to church-checkout but never propagated to checkout-embedded, which is the route the Upgrade tab actually used.

Decision

Two-part fix, both shipped in PR #91:

1. Redirect Upgrade-tab buttons to church-checkout. All "Upgrade to X" links in UpgradeTab.tsx (both the new Pro Website → Bundle cards from PR #89 and the pre-existing starter → pro cards) now go to /api/stripe/church-checkout?token=<t>&tier=<X> — the route that already handles in-place upgrades correctly.

2. Backfill checkout-embedded with the same protection. checkout-embedded now applies the same "detect active sub → update in-place → return { redirectUrl }" pattern as church-checkout. UpgradeCheckoutForm.tsx was updated to handle the new response shape and navigate to the dashboard.

This is belt-and-suspenders: even if a future caller (or a manual URL) hits checkout-embedded in upgrade mode, it can no longer create a duplicate subscription.

Rationale

  • Reuse over reinvent. church-checkout already has the right logic with battle-tested error handling and the correct proration configuration. Pointing buttons at it is a 1-line UI change.
  • Defense in depth. The checkout-embedded patch means the bug class is actually gone — not just the UI doesn't trigger it.
  • Passes customer card forward. In-place updates use the saved payment method, so the customer clicks "Upgrade" and is immediately back on the dashboard with ?upgraded=true. No re-entry of card info. Better UX than the broken flow it replaces.
  • Prorating is correct. proration_behavior: 'create_prorations' charges the customer the pro-rated difference immediately and resets billing for the new cycle to the new price. Matches how every other SaaS tool handles mid-cycle upgrades.

Consequences

  • Good: No more duplicate-subscription risk. Upgrade UX is now one-click (no card re-entry). BUG-051 is fully closed across both routes.
  • Good: UpgradeCheckoutForm.tsx now correctly handles the { redirectUrl } response shape, so any caller that routes through it for an upgrade works.
  • Bad (minor): If a future upgrade path needs EMBEDDED checkout (card re-entry, new customer), the current code short-circuits it when an active sub exists. That's the right default — if we ever need the old behavior for a specific flow, we can gate it behind a forceEmbedded: true flag in the request body.
  • Reversible? Yes — revert PR #91.

Verification after deploy

  1. Real Pro Website customer clicks Upgrade to Bundle Starter:
    • Dashboard redirects to /admin/{token}?upgraded=true
    • Stripe dashboard shows ONE subscription on the customer, with the new price
    • Next invoice shows a prorated line item for the difference, not two separate subscription invoices
  2. Existing starter customer clicks Upgrade to Pro:
    • Same behavior — single sub updated in-place
  3. New signup flow (no token) still works via embedded Checkout
  4. stripe_webhook_inbox processes customer.subscription.updated correctly — plan and stripe_subscription_id reflect the new tier in premium_churches with no orphaned old-sub reference

Alternatives considered

  • Fix only checkout-embedded, leave UpgradeTab buttons pointing at /onboard/checkout. — rejected. Going through UpgradeCheckoutForm.tsx adds a page render + form mount just to redirect back to the dashboard. church-checkout redirects directly.
  • Schedule the old subscription for cancellation in the webhook. — rejected. Reactive, not preventive. By the time the webhook fires the customer's already been charged. Also fragile: if the webhook fails once, we double-bill silently.
  • Block the upgrade buttons behind a feature flag until we can run an end-to-end Playwright test. — rejected. The two-part fix is small and verifiable by code review; pausing the Upgrade tab when we want the exact opposite (more customers upgrading) would harm conversion.
  • Code: src/app/api/stripe/church-checkout/route.ts (canonical pattern)
  • Code: src/app/api/stripe/checkout-embedded/route.ts (backfilled)
  • Code: src/app/admin/[token]/components/UpgradeTab.tsx (UI redirect)
  • Code: src/app/onboard/checkout/UpgradeCheckoutForm.tsx (response shape)
  • Prior decision: 2026-04-18-pro-website-decouple — surfaced Option B (bundles include Pro Website) which is what exposed this bug class in PR #89 when it finally pointed customers at the broken route.