Files
OpenFrame/app/api/billing/route.ts
T
yusufipk 0faa4b4e2a 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.
2026-07-25 14:22:32 +07:00

50 lines
2.3 KiB
TypeScript

import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { getBillingOverview } from '@/lib/billing';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { hasStripeRuntimeConfig, isStripeConfigured } from '@/lib/stripe';
import { logError } from '@/lib/logger';
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const billing = await getBillingOverview(session.user.id);
const isEnabled = isStripeFeatureEnabled();
const isConfigured = hasStripeRuntimeConfig();
const response = successResponse({
isEnabled,
isConfigured,
status: !isEnabled ? 'disabled' : isStripeConfigured() ? 'ready' : 'misconfigured',
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,
priceId: billing.subscription.stripePriceId,
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
cancelAt: billing.subscription.cancelAt?.toISOString() ?? null,
trialEndsAt: billing.subscription.trialEndsAt?.toISOString() ?? null,
billingAccessEndedAt: billing.subscription.billingAccessEndedAt?.toISOString() ?? null,
storageCleanupEligibleAt:
billing.subscription.storageCleanupEligibleAt?.toISOString() ?? null,
},
workspaceCreation: billing.workspaceCreation,
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error fetching billing overview:', error);
return apiErrors.internalError('Failed to fetch billing overview');
}
}