Files
pieced-portal/src/app/api/stripe/webhook/route.ts
admin a680d6de9f
Some checks failed
Build and Push / build (push) Failing after 38s
Phase4: Stripe
2026-05-24 23:37:48 +02:00

233 lines
8.5 KiB
TypeScript

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<void> {
// 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<void> {
// 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<void> {
// 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)"
}`
);
}