import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { getStripeClient, getWebhookSecret } from "@/lib/stripe"; import { getInvoiceByStripePaymentIntent, isStripeRefundRecorded, markInvoicePaid, markStripeEventProcessed, setInvoiceStripePaymentIntent, tryRecordStripeEvent, } from "@/lib/db"; import { refundInvoice, RefundNotAllowedError } from "@/lib/billing"; /** * 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 { // Phase 7: mirror Stripe refunds into the portal so credit notes // are issued for refunds initiated in the Stripe Dashboard. For // refunds initiated via /api/admin/.../refund, this handler is a // no-op (each refund's stripe_refund_id is already recorded // before the webhook lands — refundInvoice records it // synchronously after the Stripe API call). // // A charge can have multiple refund objects (multiple partial // refunds against the same charge accumulate here). We iterate // and process any that aren't yet recorded in our DB. const paymentIntentId = typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id; if (!paymentIntentId) { console.error( `charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.` ); return; } const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId); if (!invoice) { console.error( `charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.` ); return; } const refundsList = charge.refunds?.data ?? []; if (refundsList.length === 0) { // Some charge.refunded events fire with the refunds list // collapsed (the object includes the aggregated amount_refunded // but the data array can be omitted depending on Stripe's // expansion choices). In that case there's nothing for us to // iterate over here; the actual `refund.created` / // `refund.updated` events carry per-refund detail and we'd need // to enable those in Stripe to handle them. For v1 we log and // rely on the in-portal admin path (refundInvoice) being the // only refund initiator. console.log( `charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.` ); return; } for (const refund of refundsList) { try { // Idempotency: skip refunds we already recorded (either via // portal admin action or a prior webhook delivery). if (await isStripeRefundRecorded(refund.id)) { continue; } const amountChf = (refund.amount ?? 0) / 100; if (amountChf <= 0) continue; // Map Stripe refund status to ours. Anything other than the // canonical four falls through to 'pending' so we don't lose // the record entirely. let status: "pending" | "succeeded" | "failed" | "canceled" = "pending"; if (refund.status === "succeeded") status = "succeeded"; else if (refund.status === "failed") status = "failed"; else if (refund.status === "canceled") status = "canceled"; // For refunds that originated in Stripe Dashboard we don't // have a reason to display. Use a sentinel string so the // credit note PDF has something to print. Admin can edit // post-hoc if needed (no UI for that today, but the DB row // is reachable). const reason = refund.reason ? `Stripe Dashboard: ${refund.reason}` : "Refund issued via Stripe Dashboard"; // refundInvoice with existingStripeRefund: don't call Stripe // again (we'd error since the refund already exists), just // mirror the record into our DB and issue the credit note. await refundInvoice({ invoiceId: invoice.id, amountChf, reason, refundedBy: "stripe-webhook", existingStripeRefund: { id: refund.id, status }, }); console.log( `Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).` ); } catch (e) { if (e instanceof RefundNotAllowedError) { // The invoice was already fully refunded by an earlier // webhook delivery or by an in-portal action. That's fine. console.log( `Stripe refund ${refund.id}: ${e.message} (already accounted for).` ); continue; } // For any other error, log but continue to the next refund — // we don't want one bad refund to block the rest. console.error( `Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`, e ); } } } 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)" }` ); }