import { NextRequest, NextResponse } from "next/server"; import type Stripe from "stripe"; import { activateProPlan, applyMonthlyCreditRenewal, downgradeToFreePlan, grantExtraCredits, } from "@/lib/billing"; import { monthlyCreditsForPlan } from "@/lib/credits"; import { prisma } from "@/lib/db"; import { getNextMonthlyQuotaReset } from "@/lib/plans"; import { getStripe } from "@/lib/stripe"; export const runtime = "nodejs"; /** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */ function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null { const legacy = (invoice as Stripe.Invoice & { subscription?: string | Stripe.Subscription | null; }).subscription; if (typeof legacy === "string" && legacy) return legacy; if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) { return String(legacy.id); } const parent = ( invoice as Stripe.Invoice & { parent?: { type?: string | null; subscription_details?: { subscription?: string | Stripe.Subscription | null } | null; } | null; } ).parent; const fromParent = parent?.subscription_details?.subscription; if (typeof fromParent === "string" && fromParent) return fromParent; if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) { return String(fromParent.id); } return null; } async function fulfillFreeTopUp(session: Stripe.Checkout.Session) { const purchaseId = session.metadata?.purchaseId; const userId = session.metadata?.userId; if (!purchaseId || !userId) { console.error("[stripe] free_top_up missing metadata", session.id); return; } const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } }); if (!purchase || purchase.userId !== userId) { throw new Error(`Purchase not found: ${purchaseId}`); } if (purchase.status === "COMPLETED") return; await grantExtraCredits(userId, purchase.credits); await prisma.creditPurchase.update({ where: { id: purchaseId }, data: { status: "COMPLETED", completedAt: new Date(), stripeSessionId: session.id, stripePaymentIntentId: typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id ?? null, }, }); } async function fulfillProCheckout(session: Stripe.Checkout.Session) { const userId = session.metadata?.userId; if (!userId) { console.error("[stripe] pro_subscription missing userId", session.id); return; } const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id ?? null; const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id ?? null; await activateProPlan(userId, { stripeCustomerId: customerId, stripeSubscriptionId: subscriptionId, }); } async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) { const subscriptionId = subscriptionIdFromInvoice(invoice); if (!subscriptionId) { console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id); return; } // Skip the first invoice if checkout already activated Pro (billing_reason subscription_create) const user = await prisma.user.findFirst({ where: { stripeSubscriptionId: subscriptionId }, }); if (!user) { // Fallback: metadata on subscription via Stripe retrieve is optional; try customer const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; if (!customerId) return; const byCustomer = await prisma.user.findFirst({ where: { stripeCustomerId: customerId }, }); if (!byCustomer) return; await applyMonthlyCreditRenewal(byCustomer.id, { plan: "PREMIUM", newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), quotaResetAt: getNextMonthlyQuotaReset(), }); await prisma.user.update({ where: { id: byCustomer.id }, data: { stripeSubscriptionId: subscriptionId, subscribedAt: byCustomer.subscribedAt ?? new Date(), }, }); return; } // Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30) await applyMonthlyCreditRenewal(user.id, { plan: "PREMIUM", newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"), quotaResetAt: getNextMonthlyQuotaReset(), }); } async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { const user = await prisma.user.findFirst({ where: { stripeSubscriptionId: subscription.id }, }); if (!user) return; await downgradeToFreePlan(user.id); } /** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */ async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]); if (!terminal.has(subscription.status)) return; const user = await prisma.user.findFirst({ where: { stripeSubscriptionId: subscription.id }, }); if (!user || user.plan !== "PREMIUM") return; await downgradeToFreePlan(user.id); } async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { const subscriptionId = subscriptionIdFromInvoice(invoice); if (!subscriptionId) return; const user = await prisma.user.findFirst({ where: { OR: [ { stripeSubscriptionId: subscriptionId }, ...(typeof invoice.customer === "string" ? [{ stripeCustomerId: invoice.customer }] : invoice.customer?.id ? [{ stripeCustomerId: invoice.customer.id }] : []), ], }, }); if (!user) return; // Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure. console.warn( "[stripe] invoice.payment_failed", invoice.id, "user", user.id, "attempt", invoice.attempt_count, ); } export async function POST(req: NextRequest) { const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; if (!webhookSecret) { return NextResponse.json({ error: "Webhook not configured" }, { status: 503 }); } const signature = req.headers.get("stripe-signature"); if (!signature) { return NextResponse.json({ error: "Missing signature" }, { status: 400 }); } const rawBody = await req.text(); let event: Stripe.Event; try { const stripe = getStripe(); event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch (err) { const message = err instanceof Error ? err.message : "Invalid signature"; return NextResponse.json({ error: message }, { status: 400 }); } try { if (event.type === "checkout.session.completed") { const session = event.data.object as Stripe.Checkout.Session; if (session.payment_status === "paid" || session.status === "complete") { const type = session.metadata?.type; if (type === "pro_subscription" || session.mode === "subscription") { await fulfillProCheckout(session); } else if (type === "free_top_up") { await fulfillFreeTopUp(session); } else if (session.metadata?.purchaseId) { // Legacy PAYG purchases: grant metadata credits as extras const userId = session.metadata.userId; const credits = Number(session.metadata.credits); const purchaseId = session.metadata.purchaseId; if (userId && purchaseId && Number.isFinite(credits) && credits > 0) { const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId }, }); if (purchase && purchase.status !== "COMPLETED") { await grantExtraCredits(userId, credits); await prisma.creditPurchase.update({ where: { id: purchaseId }, data: { status: "COMPLETED", completedAt: new Date(), stripeSessionId: session.id, }, }); } } } } } else if (event.type === "invoice.payment_succeeded") { await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice); } else if (event.type === "invoice.payment_failed") { await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); } else if (event.type === "customer.subscription.updated") { await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); } else if (event.type === "customer.subscription.deleted") { await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); } } catch (err) { console.error("[stripe] webhook handler error", event.type, event.id, err); return NextResponse.json({ error: "Handler failed" }, { status: 500 }); } return NextResponse.json({ received: true }); }