Single paid subscription per account
The plans page changes the existing recurring subscription. Starting checkout must not create an additional recurring charge, even when the local Stripe ID is missing, multiple tabs are open, or an old checkout URL is used.
Protection
- Check all of the customer's Stripe subscriptions. Active, trialing, overdue, unpaid, paused, and incomplete subscriptions block another membership checkout. Stripe lookup errors return a retryable error rather than authorize a purchase.
- Reuse an open checkout for the same tier/payment method. Expire conflicting membership checkouts, preserving mushie-pack checkouts. Pending asynchronous membership payments block another purchase.
- A customer-scoped PostgreSQL advisory transaction lock covers checkout creation and webhook processing through both subscription ownership and wallet updates. Contention returns a retryable error; it never acknowledges an unprocessed event. PGlite uses an in-process equivalent because it has one connection.
- Recover missing subscription ownership from Stripe; preserve an existing live owner. If an old checkout nevertheless creates a second paid subscription, cancel that new duplicate and refund its checkout payment with durable retry metadata. Historical overlaps are not bulk-canceled or refunded.
- A duplicate's cancellation cannot remove the retained membership. Missing or canceled migration IDs and unpaid manual-invoice artifacts cannot preserve access after the real subscription ends. Lookup errors retry the event.
- WeChat fulfillment expires old card checkout URLs and refunds the one-time payment if a recurring membership already exists. Renewal ledger references make retries safe if fulfillment-marker persistence fails after the grant.
- Plans-page conflicts explain that a tier already exists or payment is still processing, instead of offering another checkout retry. All five locales include these messages.
Verification
Run the isolated server runner (it removes credentials and blocks outbound HTTP):
powershell
pnpm --filter @yumina/server test src/lib/stripe-single-subscription.test.ts src/lib/stripe-single-subscription.integration.test.ts src/lib/stripe-webhook-logic.test.ts src/lib/account-deletion-stripe.test.ts src/lib/plan-change.test.ts src/lib/plan-change.integration.test.ts src/lib/billing-coverage.test.ts src/lib/free-credit-policy.integration.test.ts src/lib/plan-drops.integration.test.ts
pnpm typecheck
pnpm build
pnpm oss:verifyThe integration tests exercise wallet/ledger idempotency and local coordination. They do not exercise multiple PostgreSQL connections, browser checkout, or real Stripe charges/refunds. Those paths have been inspected, not live-payment tested. No schema migration is required. Deployment does not repair historical overlaps; their billing and refunds require separate operator review.
