fix(billing): prevent duplicate subscriptions and make webhook sync authoritative

A Stripe customer can own several subscriptions. Two defects let that happen
and corrupt the user's billing state:

1. Checkout allowed a fresh subscription whenever the user was not ACTIVE/
   TRIALING, so a PAST_DUE user started a brand-new subscription (Stripe
   Checkout always creates one) instead of recovering the existing one.
   Add hasRecoverableSubscription() (ACTIVE/TRIALING/PAST_DUE/UNPAID/
   INCOMPLETE); block checkout and route these users to the billing portal
   ('Update Payment Method') both in the API guard and the settings UI.

2. Subscription webhooks trusted the event's single subscription, so an old
   subscription's deletion could clobber a newer active one (marking the user
   CANCELED / No access). Every subscription event now re-derives state from
   the full set of the customer's Stripe subscriptions via
   syncStripeCustomerSubscriptions() + selectAuthoritativeSubscription(),
   making the sync order-independent and self-healing.
This commit is contained in:
yusufipk
2026-07-25 14:22:32 +07:00
parent b5fd73dcf2
commit 0faa4b4e2a
5 changed files with 126 additions and 38 deletions
+9 -2
View File
@@ -46,8 +46,15 @@ export async function POST(request: NextRequest) {
}
const checkoutState = await getStripeCheckoutState(session.user.id);
if (checkoutState.hasActiveSubscription) {
return apiErrors.badRequest('An active subscription already exists for this account');
// Block a fresh checkout whenever the customer already has a live subscription
// (active/trialing OR a recoverable one like past_due/unpaid/incomplete).
// Stripe Checkout in subscription mode always creates a NEW subscription, so
// letting a past_due user through here duplicates their subscription instead
// of recovering it. They should manage the existing one via the billing portal.
if (checkoutState.hasRecoverableSubscription) {
return apiErrors.badRequest(
'A subscription already exists for this account. Manage it from the billing portal.'
);
}
const stripe = getStripe();
+2 -1
View File
@@ -19,12 +19,13 @@ export async function GET() {
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
subscription: {
status: billing.subscription.status,
label: billing.subscription.label,
hasActiveSubscription: billing.subscription.hasActiveSubscription,
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
hasActiveTrial: billing.subscription.hasActiveTrial,
hasBillingAccess: billing.subscription.hasBillingAccess,
isTrialEligible: billing.subscription.isTrialEligible,
+19 -33
View File
@@ -1,28 +1,16 @@
import { NextRequest } from 'next/server';
import type Stripe from 'stripe';
import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
import { logError } from '@/lib/logger';
export const runtime = 'nodejs';
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const currentPeriodEnd =
'current_period_end' in subscription && typeof subscription.current_period_end === 'number'
? new Date(subscription.current_period_end * 1000)
: null;
const endedAt =
'ended_at' in subscription && typeof subscription.ended_at === 'number'
? new Date(subscription.ended_at * 1000)
: currentPeriodEnd;
await markSubscriptionCanceledByCustomerId(customerId, {
currentPeriodEnd,
endedAt,
});
function getCustomerId(
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null
): string | null {
if (!customer) return null;
return typeof customer === 'string' ? customer : customer.id;
}
export async function POST(request: NextRequest) {
@@ -43,30 +31,28 @@ export async function POST(request: NextRequest) {
}
try {
const stripe = getStripe();
// Every subscription-related event re-derives the user's state from the
// full set of the customer's Stripe subscriptions, so a stale event (e.g.
// an old subscription being deleted) can never clobber a newer active one.
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'subscription' && session.subscription) {
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await syncStripeSubscriptionToUser(subscription);
if (session.mode === 'subscription') {
const customerId = getCustomerId(session.customer);
if (customerId) {
await syncStripeCustomerSubscriptions(customerId);
}
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncStripeSubscriptionToUser(subscription);
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
const customerId = getCustomerId(subscription.customer);
if (customerId) {
await syncStripeCustomerSubscriptions(customerId);
}
break;
}
default: