import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; import { markInvoicePaid, markStripeEventProcessed, setInvoiceStripePaymentIntent, tryRecordStripeEvent, } from "@/lib/db"; /** * POST /api/stripe/webhook * * Receives signed events from Stripe. The lifecycle: * * 1. Read RAW body (request.text(), NOT request.json() — Stripe's * HMAC is computed over the raw bytes and any JSON re-parse * will subtly mangle whitespace or property ordering and the * signature will fail). * 2. Verify signature against the configured webhook secret. If * verification fails → 400. An attacker forging webhook calls * could otherwise mark our invoices paid. * 3. Idempotency: INSERT the event id into stripe_events. If the * INSERT conflicts (duplicate delivery, which is normal — Stripe * retries failed deliveries for up to 72h), return 200 immediately * so Stripe doesn't keep retrying. * 4. Process the event based on type. Currently we care about: * - checkout.session.completed → flip invoice to paid * - charge.refunded → log; void/credit handling is Phase 7 * - payment_intent.payment_failed → log only; the failure is * already shown to the user on * the Stripe page, no action. * Unknown event types are ack'd with 200 (we may have other * events enabled at the Stripe end that we don't yet care about, * and 200 + log is cheaper than 404 + Stripe retries). * 5. Stamp processed_at on success. * * Return contract: 2xx ack → Stripe stops retrying. Any non-2xx → * Stripe retries with exponential backoff up to 72h. We aim for * 200 on every reachable path (verified, deduplicated, or processed), * and only 400 for signature failures (those would never succeed * on retry anyway, so retrying is wasted effort). * * Performance: handlers run synchronously here because PieCed's * event volume is tiny. If/when that changes, the obvious refactor * is to enqueue (Phase 7) and ack first — but at v1 the inline * model is simpler to reason about and harder to lose events with. */ // Next.js: explicitly disable static optimization; this route MUST // run on every request and must not be cached. export const dynamic = "force-dynamic"; export async function POST(request: Request) { // 1. Raw body — Stripe verifies the signature over these exact bytes. const rawBody = await request.text(); const signature = request.headers.get("stripe-signature"); if (!signature) { return new NextResponse("Missing stripe-signature header", { status: 400, }); } // 2. Verify signature. let event: Stripe.Event; try { const stripe = getStripeClient(); const secret = getWebhookSecret(); event = stripe.webhooks.constructEvent(rawBody, signature, secret); } catch (err) { console.error("Stripe webhook signature verification failed:", err); // 400 — never retry. The webhook configuration is wrong on // either end (rotated secret, wrong endpoint, etc.); retries // won't fix it. return new NextResponse("Invalid signature", { status: 400 }); } // 3. Idempotency. INSERT event.id → fail-fast on duplicate. let firstDelivery: boolean; try { firstDelivery = await tryRecordStripeEvent( event.id, event.type, event ); } catch (err) { console.error( `Failed to record stripe event ${event.id} (${event.type}):`, err ); // 5xx so Stripe retries — this is a DB hiccup, not a logic error. return new NextResponse("DB error", { status: 500 }); } if (!firstDelivery) { // Already processed; ack happily. return new NextResponse("Duplicate delivery; acknowledged.", { status: 200, }); } // 4. Process. Each handler is responsible for being safe to run // exactly once (we already deduplicated by event.id above). try { switch (event.type) { case "checkout.session.completed": await handleCheckoutCompleted( event.data.object as Stripe.Checkout.Session ); break; case "charge.refunded": await handleChargeRefunded(event.data.object as Stripe.Charge); break; case "payment_intent.payment_failed": await handlePaymentFailed( event.data.object as Stripe.PaymentIntent ); break; default: // Unknown event — log so we notice if Stripe starts sending // something we should handle, but ack so we don't accumulate // retries forever. console.log( `Stripe webhook: ignoring event type ${event.type} (id ${event.id})` ); } } catch (err) { console.error( `Stripe webhook handler failed for ${event.type} (id ${event.id}):`, err ); // 5xx → Stripe retries. The handler is idempotent because the // stripe_events row already exists, so on the next attempt we'd // short-circuit at step 3. To actually retry the work we'd need // to DELETE the stripe_events row first; for v1 we don't bother // and let a human investigate the logs. return new NextResponse("Handler error", { status: 500 }); } // 5. Mark processed. try { await markStripeEventProcessed(event.id); } catch (err) { // Non-fatal — the event was already processed, this is just the // bookkeeping flag. Log and move on. console.error( `Failed to mark stripe event ${event.id} processed:`, err ); } return new NextResponse("OK", { status: 200 }); } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- async function handleCheckoutCompleted( session: Stripe.Checkout.Session ): Promise { // Defensive: paid sessions are what we want; sessions can also // complete in "unpaid" state (rare for mode=payment, more common // for async/delayed methods like SEPA). Only flip the invoice // when payment actually cleared. if (session.payment_status !== "paid") { console.log( `Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.` ); return; } const invoiceId = session.metadata?.invoice_id ?? session.client_reference_id ?? null; if (!invoiceId) { console.error( `Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.` ); return; } const paymentIntentId = typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id; // Persist the PaymentIntent id on the invoice for traceability + // future refund correlation. if (paymentIntentId) { await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId); } // Flip status. markInvoicePaid is idempotent — re-running on an // already-paid invoice returns null and we log + skip. const updated = await markInvoicePaid(invoiceId, { paidBy: "stripe", paidMethodDetail: paymentIntentId ? `Stripe Checkout (${paymentIntentId})` : "Stripe Checkout", paidAt: session.created ? new Date(session.created * 1000) : undefined, }); if (!updated) { // Already paid or void/draft — fine, nothing to do. console.log( `Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).` ); return; } console.log( `Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).` ); } async function handleChargeRefunded(charge: Stripe.Charge): Promise { // v1 scope: log only. Refunds always go through Stripe → admin // initiates them in the dashboard. Updating our invoice status // to 'void' or partial-credit needs more product thinking // (partial refunds? credit notes? VAT corrections?). Phase 7. console.log( `Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.` ); } async function handlePaymentFailed( intent: Stripe.PaymentIntent ): Promise { // The Stripe-hosted page already shows the failure to the user. // We log here for support visibility and to surface in Workbench. // No invoice state change — it stays 'open' until paid. console.log( `PaymentIntent ${intent.id} failed: ${ intent.last_payment_error?.message ?? "(no message)" }` ); }