233 lines
8.5 KiB
TypeScript
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)"
|
|
}`
|
|
);
|
|
}
|