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:
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 callstripe.subscriptions.update().- Only
customer_emailwas passed, notcustomer: 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. - The webhook's
activateChurch()only checksstripe_subscription_idequality for idempotency. On an upgrade, the new sub id is different, so the handler proceeds and simply overwrites the oldstripe_subscription_idinpremium_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:
- Detects an active existing sub via
premium.status === 'active' && stripe_subscription_id. - Calls
stripe.subscriptions.retrieve()to confirm it's stillactiveortrialingin Stripe. - Calls
stripe.subscriptions.update()in-place withproration_behavior: 'create_prorations'. - 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-checkoutalready 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-embeddedpatch 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.tsxnow 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: trueflag in the request body. - Reversible? Yes — revert PR #91.
Verification after deploy
- 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
- Dashboard redirects to
- Existing starter customer clicks Upgrade to Pro:
- Same behavior — single sub updated in-place
- New signup flow (no token) still works via embedded Checkout
stripe_webhook_inboxprocessescustomer.subscription.updatedcorrectly —planandstripe_subscription_idreflect the new tier inpremium_churcheswith no orphaned old-sub reference
Alternatives considered
- Fix only
checkout-embedded, leave UpgradeTab buttons pointing at/onboard/checkout. — rejected. Going throughUpgradeCheckoutForm.tsxadds a page render + form mount just to redirect back to the dashboard.church-checkoutredirects 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.
Links
- 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.