diff --git a/app/(dashboard)/settings/settings-page-client.tsx b/app/(dashboard)/settings/settings-page-client.tsx index 5ac4905..212c8d1 100644 --- a/app/(dashboard)/settings/settings-page-client.tsx +++ b/app/(dashboard)/settings/settings-page-client.tsx @@ -53,6 +53,7 @@ interface BillingOverview { status: string; label: string; hasActiveSubscription: boolean; + hasRecoverableSubscription: boolean; hasActiveTrial: boolean; hasBillingAccess: boolean; isTrialEligible: boolean; @@ -400,6 +401,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo + {billing.subscription.hasRecoverableSubscription && + !billing.subscription.hasActiveSubscription ? ( +

+ Your latest payment didn't go through. Update your payment method to keep + your subscription — starting a new one would create a duplicate. +

+ ) : null} + {billing.subscription.hasActiveTrial && billing.subscription.trialEndsAt && hasScheduledCancellation ? ( @@ -444,7 +453,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo ) : null}
- {billing.subscription.hasActiveSubscription && billing.portalAvailable ? ( + {billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? ( ) : ( diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index db2dd8f..b28cb83 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -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(); diff --git a/app/api/billing/route.ts b/app/api/billing/route.ts index 24796e1..3cde6e9 100644 --- a/app/api/billing/route.ts +++ b/app/api/billing/route.ts @@ -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, diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 07bd2f5..8fed380 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -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: diff --git a/lib/billing.ts b/lib/billing.ts index 30f9f21..a56a07d 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -10,6 +10,18 @@ const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.TRIALING, ]); +// Statuses that mean the customer already has a live Stripe subscription that +// should be recovered (via the billing portal / dunning) rather than duplicated +// with a fresh checkout. Everything else (FREE, CANCELED, INCOMPLETE_EXPIRED) +// has no recoverable subscription, so a new checkout is appropriate. +const RECOVERABLE_SUBSCRIPTION_STATUSES = new Set([ + BillingSubscriptionStatus.ACTIVE, + BillingSubscriptionStatus.TRIALING, + BillingSubscriptionStatus.PAST_DUE, + BillingSubscriptionStatus.UNPAID, + BillingSubscriptionStatus.INCOMPLETE, +]); + export const DEFAULT_TRIAL_PERIOD_DAYS = 7; const STORAGE_CLEANUP_GRACE_DAYS = 15; @@ -35,6 +47,14 @@ export function hasActiveSubscription(status: BillingSubscriptionStatus | null | return ACTIVE_SUBSCRIPTION_STATUSES.has(status); } +// True when the customer already has a live subscription (active/trialing OR a +// recoverable one like past_due/unpaid/incomplete). Used to route them to the +// billing portal instead of letting a new checkout create a duplicate. +export function hasRecoverableSubscription(status: BillingSubscriptionStatus | null | undefined) { + if (!status) return false; + return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status); +} + export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) { if (!isStripeFeatureEnabled()) { return true; @@ -170,6 +190,7 @@ export async function getStripeCheckoutState(userId: string) { return { hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus), + hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus), isTrialEligible: !user.billingTrialConsumedAt, }; } @@ -253,6 +274,7 @@ export async function getWorkspaceCreationEligibility(userId: string) { status: user.subscriptionStatus, label: getBillingStatusLabel(user.subscriptionStatus), hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus), + hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus), hasActiveTrial: hasActiveTrial(user.trialEndsAt), hasBillingAccess: billingAccess, isTrialEligible: !user.billingTrialConsumedAt, @@ -410,6 +432,67 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip }); } +// A single Stripe customer can own several subscriptions at once (e.g. after +// going past_due and re-subscribing). Higher priority = more authoritative for +// deciding the user's entitlement. +const SUBSCRIPTION_STATUS_PRIORITY: Record = { + active: 100, + trialing: 90, + past_due: 80, + unpaid: 70, + paused: 60, + incomplete: 50, + incomplete_expired: 20, + canceled: 10, +}; + +// Picks the subscription that should drive the user's billing state when a +// customer has more than one. Prefers subscriptions that carry the entitled +// price, then the most "alive" status, then the most recently created. +export function selectAuthoritativeSubscription( + subscriptions: Stripe.Subscription[] +): Stripe.Subscription | null { + if (subscriptions.length === 0) { + return null; + } + + return [...subscriptions].sort((a, b) => { + const aEntitled = Boolean(getEntitledStripePriceId(a)); + const bEntitled = Boolean(getEntitledStripePriceId(b)); + if (aEntitled !== bEntitled) { + return aEntitled ? -1 : 1; + } + + const aStatus = SUBSCRIPTION_STATUS_PRIORITY[a.status] ?? 0; + const bStatus = SUBSCRIPTION_STATUS_PRIORITY[b.status] ?? 0; + if (aStatus !== bStatus) { + return bStatus - aStatus; + } + + return (getStripeTimestamp(b.created) ?? 0) - (getStripeTimestamp(a.created) ?? 0); + })[0]; +} + +// Source-of-truth sync: instead of trusting a single subscription from a webhook +// event body (which may be an OLD subscription being deleted while a NEWER one is +// active), re-list ALL of the customer's subscriptions from Stripe and sync the +// authoritative one. This is order-independent and self-healing. +export async function syncStripeCustomerSubscriptions(customerId: string) { + const stripe = getStripe(); + const { data: subscriptions } = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100, + }); + + const authoritative = selectAuthoritativeSubscription(subscriptions); + if (!authoritative) { + return markSubscriptionCanceledByCustomerId(customerId); + } + + return syncStripeSubscriptionToUser(authoritative); +} + export async function markSubscriptionCanceledByCustomerId( customerId: string, options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }