import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { getPaymentMethodDisplay, getStripeClient, getWebhookSecret, } from "@/lib/stripe"; import { getInvoiceByStripePaymentIntent, getInvoiceDetail, getOrgIdByStripeCustomerId, getTenantRequestForSetupFlow, isStripeRefundRecorded, linkTenantRequestSetupPayment, markInvoicePaid, markStripeEventProcessed, setInvoiceStripePaymentIntent, setSavedPaymentMethod, tryRecordStripeEvent, } from "@/lib/db"; import { sendAdminNotificationEmail } from "@/lib/email"; 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 { // Phase 9: setup-mode sessions don't pay anything — they // authorize a card for off-session future charges. The // PaymentMethod is attached to the customer and the session's // setup_intent.payment_method holds the id we save. if (session.mode === "setup") { await handleSetupCompleted(session); return; } // 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}).` ); // Phase 9b: if this Checkout was the setup-fee flow for a tenant // order, flip the linked tenant_request row from 'pending_payment' // to 'pending' so admin sees it in the queue. The invoice line's // tenant_name has the derived name; we also stamp it on the // request row so admin can act on it. linkTenantRequestSetupPayment // is idempotent (no-op if status already advanced). const flow = session.metadata?.flow; const tenantRequestId = session.metadata?.tenant_request_id; if (flow === "setup_fee" && tenantRequestId) { try { // The derived tenant_name lives on the invoice line we just // marked paid. Fetch via getInvoiceDetail (existing helper). const detail = await getInvoiceDetail(invoiceId); const setupLine = detail?.lines.find( (l) => l.kind === "tenant_setup" && l.tenantName ); if (!setupLine || !setupLine.tenantName) { console.error( `Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.` ); } else { const linked = await linkTenantRequestSetupPayment({ requestId: tenantRequestId, tenantName: setupLine.tenantName, setupInvoiceId: invoiceId, }); if (linked) { console.log( `Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).` ); // Notify admin now that the payment cleared. Best-effort — // a failure here doesn't undo the linkage. try { const req = await getTenantRequestForSetupFlow(tenantRequestId); if (req) { await sendAdminNotificationEmail( req.contactEmail, req.contactName, req.instanceName ? `${req.companyName} (${req.instanceName})` : req.companyName ); } } catch (e) { console.error( `Failed to send admin notification for tenant request ${tenantRequestId}:`, e ); } } else { console.log( `Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.` ); } } } catch (e) { console.error( `Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`, e ); } } // Phase 9b: any payment-mode Checkout that set setup_future_usage // attaches the resulting PaymentMethod to the customer. Read it // back and save the display fields against the org's config — // same behaviour as the setup-mode webhook does. This is what // makes the setup-fee Checkout also "refresh saved card" without // an extra step, and it's also what Phase 9b-2's manual-pay // with setup_future_usage will rely on. try { if (paymentIntentId) { const stripe = getStripeClient(); const pi = await stripe.paymentIntents.retrieve(paymentIntentId); const pmId = typeof pi.payment_method === "string" ? pi.payment_method : pi.payment_method?.id; const customerId = typeof pi.customer === "string" ? pi.customer : pi.customer?.id; // setup_future_usage on the PI tells us this payment also // saved the card. If it's not set, this was a one-off pay // and we shouldn't overwrite anything. if (pmId && customerId && pi.setup_future_usage === "off_session") { const orgId = await getOrgIdByStripeCustomerId(customerId); if (orgId) { const display = await getPaymentMethodDisplay(pmId); await setSavedPaymentMethod({ zitadelOrgId: orgId, stripeCustomerId: customerId, paymentMethodId: pmId, brand: display.brand, last4: display.last4, expMonth: display.expMonth, expYear: display.expYear, }); // Also tell Stripe this PM is the customer's default for // future invoice charges. Best-effort. try { await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: pmId }, }); } catch (e) { console.warn( `Failed to set default_payment_method on customer ${customerId}:`, e ); } console.log( `Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.` ); } } } } catch (e) { console.error( `Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`, e ); } } /** * Phase 9: handle setup-mode Checkout completion. The customer * authorized a card for future off-session charges; persist the * display fields against their org so the portal can show the * saved card and use it for auto-charge. * * The session carries: * - mode: 'setup' * - customer: 'cus_xxx' (the Stripe customer id we created) * - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method) * * We look up which org owns the customer (via * org_billing_config.stripe_customer_id), fetch the SetupIntent * to find the resulting PaymentMethod id, then fetch the PM for * its display fields. Three Stripe round-trips total — acceptable * for a one-off setup event. */ async function handleSetupCompleted( session: Stripe.Checkout.Session ): Promise { const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id; if (!customerId) { console.error( `Setup session ${session.id} completed without a customer; cannot link to org.` ); return; } const orgId = await getOrgIdByStripeCustomerId(customerId); if (!orgId) { console.error( `Setup session ${session.id} for customer ${customerId} has no matching org.` ); return; } const setupIntentId = typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent?.id; if (!setupIntentId) { console.error( `Setup session ${session.id} completed without a setup_intent id.` ); return; } // Read the SetupIntent for the resulting PaymentMethod id. const stripe = getStripeClient(); const setupIntent = await stripe.setupIntents.retrieve(setupIntentId); const paymentMethodId = typeof setupIntent.payment_method === "string" ? setupIntent.payment_method : setupIntent.payment_method?.id; if (!paymentMethodId) { console.error( `Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.` ); return; } // Fetch the PM details for display columns. const display = await getPaymentMethodDisplay(paymentMethodId); await setSavedPaymentMethod({ zitadelOrgId: orgId, stripeCustomerId: customerId, paymentMethodId, brand: display.brand, last4: display.last4, expMonth: display.expMonth, expYear: display.expYear, }); // Also tell Stripe this PM is the customer's default for invoice // payments — so a future stripe.paymentIntents.create against // this customer without an explicit payment_method picks it up. // Best-effort: a failure here doesn't undo the save (we have the // pm id, we can pass it explicitly when charging in Phase 9b). try { await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethodId }, }); } catch (e) { console.warn( `Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`, e ); } console.log( `Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.` ); } 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)" }` ); }