import { NextRequest, NextResponse } from "next/server"; import { randomUUID } from "crypto"; import type Stripe from "stripe"; import { db, users, tierDefinitions, processedStripeEvents, auditLogs, eq, or } from "@epicure/db"; import { getStripeClient } from "@/lib/stripe"; import { getSiteSetting } from "@/lib/site-settings"; type SubscriptionStatus = "active" | "trialing" | "past_due" | "canceled" | "incomplete"; async function resolveTierFromPriceId(priceId: string | null | undefined): Promise<"pro" | "family" | null> { if (!priceId) return null; const row = await db.query.tierDefinitions.findFirst({ where: or(eq(tierDefinitions.stripePriceIdMonthly, priceId), eq(tierDefinitions.stripePriceIdYearly, priceId)), }); return row?.tier === "pro" || row?.tier === "family" ? row.tier : null; } function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus { switch (status) { case "trialing": return "trialing"; case "past_due": return "past_due"; case "canceled": case "unpaid": return "canceled"; case "incomplete": case "incomplete_expired": return "incomplete"; default: return "active"; } } // The billing-period fields moved from the Subscription object onto its // items in a 2025 Stripe API revision — read the item first, fall back to // the top-level field for older API versions. Verify against whichever // API version the configured account is actually pinned to before // switching on real (non-test) keys, per the plan's Stripe-CLI test note. function currentPeriodEnd(subscription: Stripe.Subscription): Date | null { const itemPeriodEnd = subscription.items.data[0]?.current_period_end; const legacyPeriodEnd = (subscription as unknown as { current_period_end?: number }).current_period_end; const unix = itemPeriodEnd ?? legacyPeriodEnd; return unix ? new Date(unix * 1000) : null; } async function logBillingEvent(userId: string, action: string, metadata: unknown): Promise { await db.insert(auditLogs).values({ id: randomUUID(), userId, action: `billing.${action}`, targetType: "user", targetId: userId, metadata: JSON.stringify(metadata), createdAt: new Date(), }); } async function findUserByCustomerId(customerId: string) { const [user] = await db.select({ id: users.id, subscriptionStatus: users.subscriptionStatus }).from(users).where(eq(users.stripeCustomerId, customerId)); return user; } export async function POST(req: NextRequest) { const stripe = await getStripeClient(); const webhookSecret = await getSiteSetting("STRIPE_WEBHOOK_SECRET"); if (!stripe || !webhookSecret) { return NextResponse.json({ error: "Stripe not configured" }, { status: 400 }); } const rawBody = await req.text(); const signature = req.headers.get("stripe-signature"); if (!signature) { return NextResponse.json({ error: "Missing signature" }, { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch { return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } // Dedup: Stripe may redeliver the same event within its retry window. const [inserted] = await db .insert(processedStripeEvents) .values({ id: event.id, type: event.type }) .onConflictDoNothing() .returning({ id: processedStripeEvents.id }); if (!inserted) { return NextResponse.json({ received: true, duplicate: true }); } switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; const userId = session.client_reference_id ?? (session.metadata?.["userId"] ?? null); const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id; const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id; if (!userId || !customerId || !subscriptionId) break; const subscription = await stripe.subscriptions.retrieve(subscriptionId); const tier = await resolveTierFromPriceId(subscription.items.data[0]?.price.id); if (!tier) break; await db.update(users).set({ tier, stripeCustomerId: customerId, stripeSubscriptionId: subscriptionId, subscriptionStatus: mapStripeStatus(subscription.status), currentPeriodEnd: currentPeriodEnd(subscription), updatedAt: new Date(), }).where(eq(users.id, userId)); await logBillingEvent(userId, "checkout_completed", { tier, subscriptionId }); break; } case "customer.subscription.updated": { const subscription = event.data.object as Stripe.Subscription; const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; const user = await findUserByCustomerId(customerId); if (!user) break; const tier = await resolveTierFromPriceId(subscription.items.data[0]?.price.id); await db.update(users).set({ ...(tier ? { tier } : {}), subscriptionStatus: mapStripeStatus(subscription.status), currentPeriodEnd: currentPeriodEnd(subscription), updatedAt: new Date(), }).where(eq(users.id, user.id)); await logBillingEvent(user.id, "subscription_updated", { tier, status: subscription.status }); break; } case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id; const user = await findUserByCustomerId(customerId); if (!user) break; await db.update(users).set({ tier: "free", subscriptionStatus: "canceled", updatedAt: new Date() }).where(eq(users.id, user.id)); await logBillingEvent(user.id, "subscription_deleted", {}); break; } case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; if (!customerId) break; const user = await findUserByCustomerId(customerId); if (!user) break; // Deliberately does NOT downgrade tier — Stripe retries the card // automatically, recovering via invoice.paid or eventually giving up // via customer.subscription.deleted. await db.update(users).set({ subscriptionStatus: "past_due", updatedAt: new Date() }).where(eq(users.id, user.id)); await logBillingEvent(user.id, "payment_failed", {}); break; } case "invoice.paid": { const invoice = event.data.object as Stripe.Invoice; const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; if (!customerId) break; const user = await findUserByCustomerId(customerId); if (user?.subscriptionStatus === "past_due") { await db.update(users).set({ subscriptionStatus: "active", updatedAt: new Date() }).where(eq(users.id, user.id)); await logBillingEvent(user.id, "payment_recovered", {}); } break; } default: break; } return NextResponse.json({ received: true }); }