mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -53,6 +53,7 @@ interface BillingOverview {
|
|||||||
status: string;
|
status: string;
|
||||||
label: string;
|
label: string;
|
||||||
hasActiveSubscription: boolean;
|
hasActiveSubscription: boolean;
|
||||||
|
hasRecoverableSubscription: boolean;
|
||||||
hasActiveTrial: boolean;
|
hasActiveTrial: boolean;
|
||||||
hasBillingAccess: boolean;
|
hasBillingAccess: boolean;
|
||||||
isTrialEligible: boolean;
|
isTrialEligible: boolean;
|
||||||
@@ -400,6 +401,14 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{billing.subscription.hasRecoverableSubscription &&
|
||||||
|
!billing.subscription.hasActiveSubscription ? (
|
||||||
|
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
|
||||||
|
Your latest payment didn't go through. Update your payment method to keep
|
||||||
|
your subscription — starting a new one would create a duplicate.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{billing.subscription.hasActiveTrial &&
|
{billing.subscription.hasActiveTrial &&
|
||||||
billing.subscription.trialEndsAt &&
|
billing.subscription.trialEndsAt &&
|
||||||
hasScheduledCancellation ? (
|
hasScheduledCancellation ? (
|
||||||
@@ -444,7 +453,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
{billing.subscription.hasActiveSubscription && billing.portalAvailable ? (
|
{billing.subscription.hasRecoverableSubscription && billing.portalAvailable ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBillingRedirect('/api/billing/portal')}
|
onClick={() => handleBillingRedirect('/api/billing/portal')}
|
||||||
disabled={billingAction !== null}
|
disabled={billingAction !== null}
|
||||||
@@ -454,8 +463,10 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Opening Portal...
|
Opening Portal...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : billing.subscription.hasActiveSubscription ? (
|
||||||
'Manage Subscription'
|
'Manage Subscription'
|
||||||
|
) : (
|
||||||
|
'Update Payment Method'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -46,8 +46,15 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkoutState = await getStripeCheckoutState(session.user.id);
|
const checkoutState = await getStripeCheckoutState(session.user.id);
|
||||||
if (checkoutState.hasActiveSubscription) {
|
// Block a fresh checkout whenever the customer already has a live subscription
|
||||||
return apiErrors.badRequest('An active subscription already exists for this account');
|
// (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();
|
const stripe = getStripe();
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ export async function GET() {
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
|
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
|
||||||
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasActiveSubscription,
|
checkoutAvailable: isStripeConfigured() && !billing.subscription.hasRecoverableSubscription,
|
||||||
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
|
portalAvailable: isStripeConfigured() && Boolean(billing.subscription.stripeCustomerId),
|
||||||
subscription: {
|
subscription: {
|
||||||
status: billing.subscription.status,
|
status: billing.subscription.status,
|
||||||
label: billing.subscription.label,
|
label: billing.subscription.label,
|
||||||
hasActiveSubscription: billing.subscription.hasActiveSubscription,
|
hasActiveSubscription: billing.subscription.hasActiveSubscription,
|
||||||
|
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
|
||||||
hasActiveTrial: billing.subscription.hasActiveTrial,
|
hasActiveTrial: billing.subscription.hasActiveTrial,
|
||||||
hasBillingAccess: billing.subscription.hasBillingAccess,
|
hasBillingAccess: billing.subscription.hasBillingAccess,
|
||||||
isTrialEligible: billing.subscription.isTrialEligible,
|
isTrialEligible: billing.subscription.isTrialEligible,
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { markSubscriptionCanceledByCustomerId, syncStripeSubscriptionToUser } from '@/lib/billing';
|
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
|
||||||
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
function getCustomerId(
|
||||||
const customerId =
|
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null
|
||||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
): string | null {
|
||||||
|
if (!customer) return null;
|
||||||
const currentPeriodEnd =
|
return typeof customer === 'string' ? customer : customer.id;
|
||||||
'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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -43,30 +31,28 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
if (session.mode === 'subscription' && session.subscription) {
|
if (session.mode === 'subscription') {
|
||||||
const subscriptionId =
|
const customerId = getCustomerId(session.customer);
|
||||||
typeof session.subscription === 'string'
|
if (customerId) {
|
||||||
? session.subscription
|
await syncStripeCustomerSubscriptions(customerId);
|
||||||
: session.subscription.id;
|
}
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
await syncStripeSubscriptionToUser(subscription);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
case 'customer.subscription.updated': {
|
case 'customer.subscription.updated':
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
|
||||||
await syncStripeSubscriptionToUser(subscription);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'customer.subscription.deleted': {
|
case 'customer.subscription.deleted': {
|
||||||
const subscription = event.data.object as Stripe.Subscription;
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
await handleSubscriptionDeleted(subscription);
|
const customerId = getCustomerId(subscription.customer);
|
||||||
|
if (customerId) {
|
||||||
|
await syncStripeCustomerSubscriptions(customerId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
|||||||
BillingSubscriptionStatus.TRIALING,
|
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>([
|
||||||
|
BillingSubscriptionStatus.ACTIVE,
|
||||||
|
BillingSubscriptionStatus.TRIALING,
|
||||||
|
BillingSubscriptionStatus.PAST_DUE,
|
||||||
|
BillingSubscriptionStatus.UNPAID,
|
||||||
|
BillingSubscriptionStatus.INCOMPLETE,
|
||||||
|
]);
|
||||||
|
|
||||||
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
||||||
const STORAGE_CLEANUP_GRACE_DAYS = 15;
|
const STORAGE_CLEANUP_GRACE_DAYS = 15;
|
||||||
|
|
||||||
@@ -35,6 +47,14 @@ export function hasActiveSubscription(status: BillingSubscriptionStatus | null |
|
|||||||
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
|
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()) {
|
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
||||||
if (!isStripeFeatureEnabled()) {
|
if (!isStripeFeatureEnabled()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -170,6 +190,7 @@ export async function getStripeCheckoutState(userId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
||||||
|
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||||
isTrialEligible: !user.billingTrialConsumedAt,
|
isTrialEligible: !user.billingTrialConsumedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -253,6 +274,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
|||||||
status: user.subscriptionStatus,
|
status: user.subscriptionStatus,
|
||||||
label: getBillingStatusLabel(user.subscriptionStatus),
|
label: getBillingStatusLabel(user.subscriptionStatus),
|
||||||
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
||||||
|
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||||
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
|
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
|
||||||
hasBillingAccess: billingAccess,
|
hasBillingAccess: billingAccess,
|
||||||
isTrialEligible: !user.billingTrialConsumedAt,
|
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<Stripe.Subscription.Status, number> = {
|
||||||
|
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(
|
export async function markSubscriptionCanceledByCustomerId(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
|
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
|
||||||
|
|||||||
Reference in New Issue
Block a user