import { NextResponse } from 'next/server'; import { clerkClient } from '@clerk/nextjs/server'; import Stripe from 'stripe'; /** * /api/webhooks/stripe — Stripe webhook receiver (Sesija E §5.3 Phase C). * * Verifies signature against STRIPE_WEBHOOK_SECRET, then mirrors subscription * state from Stripe → Clerk user.publicMetadata so the Next.js app can gate * features on tier/status without round-tripping to Stripe on every request. * * Handled events: * checkout.session.completed → set tier + status='active' * customer.subscription.updated → map status, refresh tier * customer.subscription.deleted → status='canceled' * * Linkage strategy: every Checkout Session and Subscription gets * `metadata.clerkUserId` set by the checkout route. As a fallback, the Stripe * Customer also carries `metadata.clerkUserId` (set during lazy-create), so * subscription events that lack metadata can still resolve the user. * * Returns 200 quickly so Stripe's retry queue stays clean. Handler errors * surface as 500 (Stripe will retry up to its standard backoff schedule). */ // New checkout is TEAMS-only, but this webhook still accepts legacy 'pro' // subscription events so existing subscribers keep getting status updates // (renewals, cancellations). Pro is no longer a sold tier — the app coerces // it to the free Solo label at display time (parseTier('PRO') → 'FREE'). type Tier = 'pro' | 'teams'; interface ClerkPublicMetadata { readonly stripeCustomerId?: string; readonly subscriptionTier?: Tier; readonly subscriptionStatus?: | 'active' | 'past_due' | 'canceled' | 'trialing' | 'incomplete'; } function isValidStripeKey(key: string | undefined): key is string { return ( typeof key === 'string' && (key.startsWith('sk_test_') || key.startsWith('sk_live_')) ); } function isValidWebhookSecret(value: string | undefined): value is string { return typeof value === 'string' && value.startsWith('whsec_'); } function asTier(value: unknown): Tier | undefined { return value === 'pro' || value === 'teams' ? value : undefined; } function mapStatus( status: Stripe.Subscription.Status, ): NonNullable { // Collapse Stripe's 8 statuses into the 5 we expose to the app: // active | past_due | canceled | trialing | incomplete switch (status) { case 'active': case 'past_due': case 'canceled': case 'trialing': case 'incomplete': return status; case 'unpaid': case 'incomplete_expired': return 'past_due'; case 'paused': return 'canceled'; default: return 'incomplete'; } } async function findClerkUserIdFromCustomer( customerId: string, stripe: Stripe, ): Promise { const customer = await stripe.customers.retrieve(customerId); if ('deleted' in customer && customer.deleted) return null; const meta = (customer as Stripe.Customer).metadata; return meta?.clerkUserId ?? null; } async function patchClerkPublicMetadata( userId: string, patch: Partial, ): Promise { const cc = await clerkClient(); const user = await cc.users.getUser(userId); const current = (user.publicMetadata ?? {}) as ClerkPublicMetadata; await cc.users.updateUserMetadata(userId, { publicMetadata: { ...current, ...patch } satisfies ClerkPublicMetadata, }); } async function handleCheckoutCompleted( event: Stripe.CheckoutSessionCompletedEvent, ): Promise { const session = event.data.object; const userId = session.metadata?.clerkUserId; const tier = asTier(session.metadata?.tier); // R1-002 (webhook path): only grant once payment has settled. // checkout.session.completed also fires for unpaid/async sessions. const paid = session.payment_status === 'paid' || session.payment_status === 'no_payment_required'; if (!userId || !tier || !paid) return; await patchClerkPublicMetadata(userId, { subscriptionTier: tier, subscriptionStatus: 'active', }); } async function handleSubscriptionUpdated( event: Stripe.CustomerSubscriptionUpdatedEvent, stripe: Stripe, ): Promise { const sub = event.data.object; const userId = sub.metadata?.clerkUserId ?? (typeof sub.customer === 'string' ? await findClerkUserIdFromCustomer(sub.customer, stripe) : null); if (!userId) return; const tier = asTier(sub.metadata?.tier); const patch: ClerkPublicMetadata = { subscriptionStatus: mapStatus(sub.status), ...(tier ? { subscriptionTier: tier } : {}), }; await patchClerkPublicMetadata(userId, patch); } async function handleSubscriptionDeleted( event: Stripe.CustomerSubscriptionDeletedEvent, stripe: Stripe, ): Promise { const sub = event.data.object; const userId = sub.metadata?.clerkUserId ?? (typeof sub.customer === 'string' ? await findClerkUserIdFromCustomer(sub.customer, stripe) : null); if (!userId) return; await patchClerkPublicMetadata(userId, { subscriptionStatus: 'canceled' }); } export async function POST(req: Request): Promise { const secretKey = process.env.STRIPE_SECRET_KEY; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!isValidStripeKey(secretKey)) { return NextResponse.json( { message: 'Stripe not configured (STRIPE_SECRET_KEY missing/invalid)' }, { status: 503 }, ); } if (!isValidWebhookSecret(webhookSecret)) { return NextResponse.json( { message: 'Webhook secret not configured (STRIPE_WEBHOOK_SECRET missing/invalid)', }, { status: 503 }, ); } const sig = req.headers.get('stripe-signature'); if (!sig) { return NextResponse.json( { message: 'Missing stripe-signature header' }, { status: 400 }, ); } const rawBody = await req.text(); const stripe = new Stripe(secretKey); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Invalid signature'; return NextResponse.json( { message: `Signature verification failed: ${msg}` }, { status: 400 }, ); } try { switch (event.type) { case 'checkout.session.completed': await handleCheckoutCompleted(event); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event, stripe); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event, stripe); break; default: // Unhandled events ack with 200 — Stripe will keep delivering them // even if we don't act, so just no-op rather than returning an error. break; } } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Handler error'; return NextResponse.json( { message: `Handler error: ${msg}`, eventType: event.type }, { status: 500 }, ); } return NextResponse.json({ received: true, eventType: event.type }); }