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 }