mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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.
71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import type Stripe from 'stripe';
|
|
import { syncStripeCustomerSubscriptions } from '@/lib/billing';
|
|
import { getStripe, getStripeWebhookSecret } from '@/lib/stripe';
|
|
import { logError } from '@/lib/logger';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
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) {
|
|
const signature = request.headers.get('stripe-signature');
|
|
if (!signature) {
|
|
return new Response('Missing Stripe signature', { status: 400 });
|
|
}
|
|
|
|
let event: Stripe.Event;
|
|
|
|
try {
|
|
const stripe = getStripe();
|
|
const body = await request.text();
|
|
event = stripe.webhooks.constructEvent(body, signature, getStripeWebhookSecret());
|
|
} catch (error) {
|
|
logError('Failed to verify Stripe webhook:', error);
|
|
return new Response('Invalid webhook signature', { status: 400 });
|
|
}
|
|
|
|
try {
|
|
// 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') {
|
|
const customerId = getCustomerId(session.customer);
|
|
if (customerId) {
|
|
await syncStripeCustomerSubscriptions(customerId);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case 'customer.subscription.created':
|
|
case 'customer.subscription.updated':
|
|
case 'customer.subscription.deleted': {
|
|
const subscription = event.data.object as Stripe.Subscription;
|
|
const customerId = getCustomerId(subscription.customer);
|
|
if (customerId) {
|
|
await syncStripeCustomerSubscriptions(customerId);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return new Response(JSON.stringify({ received: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (error) {
|
|
logError('Failed to process Stripe webhook:', error);
|
|
return new Response('Webhook processing failed', { status: 500 });
|
|
}
|
|
}
|