import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; import { db, users, processedStripeEvents, eq } from "@epicure/db"; // Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256. // Handles: // - checkout.session.completed → upgrade user to pro // - customer.subscription.deleted → downgrade user to free const STRIPE_TOLERANCE_SECONDS = 300; // 5 minutes function verifyStripeSignature( rawBody: string, sigHeader: string, secret: string ): { valid: boolean; payload: string | null } { // sigHeader format: "t=,v1=[,v1=...]" const parts = sigHeader.split(","); const tPart = parts.find((p) => p.startsWith("t=")); const v1Parts = parts.filter((p) => p.startsWith("v1=")); if (!tPart || v1Parts.length === 0) { return { valid: false, payload: null }; } const timestamp = tPart.slice(2); const tsNum = parseInt(timestamp, 10); if (isNaN(tsNum)) return { valid: false, payload: null }; // Reject stale webhooks const nowSec = Math.floor(Date.now() / 1000); if (Math.abs(nowSec - tsNum) > STRIPE_TOLERANCE_SECONDS) { return { valid: false, payload: null }; } const signedPayload = `${timestamp}.${rawBody}`; const expected = crypto .createHmac("sha256", secret) .update(signedPayload, "utf8") .digest(); const matched = v1Parts.some((v1Part) => { const provided = v1Part.slice(3); // strip "v1=" let providedBuf: Buffer; try { providedBuf = Buffer.from(provided, "hex"); } catch { return false; } if (providedBuf.length !== expected.length) return false; return crypto.timingSafeEqual(expected, providedBuf); }); return { valid: matched, payload: matched ? rawBody : null }; } export async function POST(req: NextRequest) { const body = await req.text(); const sig = req.headers.get("stripe-signature"); const webhookSecret = process.env["STRIPE_WEBHOOK_SECRET"]; if (!sig || !webhookSecret) { return NextResponse.json({ error: "Stripe not configured" }, { status: 400 }); } const { valid } = verifyStripeSignature(body, sig, webhookSecret); if (!valid) { return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } let event: { id: string; type: string; data: { object: Record } }; try { event = JSON.parse(body) as typeof event; } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } if (!event.id) { return NextResponse.json({ error: "Missing event id" }, { status: 400 }); } // Dedup: Stripe may redeliver the same event within its retry/tolerance // window. Record the event id before processing; if it's already been // recorded, skip processing (but still ack with 200 so Stripe stops // retrying). 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": { // client_reference_id is set to our internal userId when the Checkout Session is created. const userId = event.data.object["client_reference_id"]; const customerId = event.data.object["customer"]; if (typeof userId === "string" && typeof customerId === "string") { await db.update(users).set({ tier: "pro", stripeCustomerId: customerId }).where(eq(users.id, userId)); } break; } case "customer.subscription.deleted": { const customerId = event.data.object["customer"]; if (typeof customerId === "string") { await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId)); } break; } default: // ignore unhandled event types break; } return NextResponse.json({ received: true }); }