import { headers } from 'next/headers'; import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/stripe'; import { syncSubscriptionFromStripe, handleSubscriptionDeleted, resolveUserIdFromStripeEvent, } from '@/lib/billing/sync-subscription-from-stripe'; import { addPurchasedCredits } from '@/lib/credits'; import { getCreditPack, isCreditPackId, resolvePackFromPriceId, } from '@/lib/billing/credit-packs'; import type Stripe from 'stripe'; export const runtime = 'nodejs'; async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) { if (session.mode !== 'payment') return; if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') { console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status); return; } const userId = session.metadata?.userId as string | undefined; if (!userId) { console.warn('[billing/webhook] credit pack: no userId', session.id); return; } let packId = session.metadata?.packId as string | undefined; let credits = Number(session.metadata?.credits ?? 0); if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) { try { const full = await stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items.data.price'], }); const priceId = full.line_items?.data?.[0]?.price?.id; if (priceId) { const resolved = await resolvePackFromPriceId(priceId); if (resolved) { packId = resolved.packId; credits = resolved.credits; } } } catch (err) { console.error('[billing/webhook] expand line_items failed', err); } } if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) { credits = getCreditPack(packId).credits; } if (!Number.isFinite(credits) || credits <= 0) { console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits }); return; } const result = await addPurchasedCredits(userId, credits, { stripeSessionId: session.id, packId: packId ?? null, type: 'credit_pack', paymentIntent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id ?? null, }); if (result.applied) { console.info('[billing/webhook] credited pack', { userId, credits: result.units, packId, sessionId: session.id, }); } } export async function POST(req: NextRequest) { const body = await req.text(); const headersList = await headers(); const sig = headersList.get('stripe-signature'); if (!sig) { return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 }); } if (!process.env.STRIPE_WEBHOOK_SECRET) { console.error('[billing/webhook] STRIPE_WEBHOOK_SECRET not configured'); return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { console.error('[billing/webhook] Signature verification failed:', err); return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } try { 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); const userId = (session.metadata?.userId as string | undefined) ?? (subscription.metadata?.userId as string | undefined); if (userId) { await syncSubscriptionFromStripe(subscription, userId); } else { console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id); } } else if ( session.mode === 'payment' && (session.metadata?.type === 'credit_pack' || session.metadata?.packId) ) { await fulfillCreditPackPurchase(session); } break; } case 'customer.subscription.created': case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription; const userId = await resolveUserIdFromStripeEvent(subscription); if (userId) { await syncSubscriptionFromStripe(subscription, userId); } else { console.warn('[billing/webhook] subscription event: no userId found', subscription.id); } break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionDeleted(subscription); break; } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice & { subscription?: string | { id: string } }; if (invoice.subscription) { const subscriptionId = typeof invoice.subscription === 'string' ? invoice.subscription : invoice.subscription.id; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const userId = await resolveUserIdFromStripeEvent(subscription); if (userId) { await syncSubscriptionFromStripe(subscription, userId); } } break; } default: break; } return NextResponse.json({ received: true }); } catch (error) { console.error('[billing/webhook] Handler error:', error); return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 }); } }