326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
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<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> {
|
|
// 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<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)"
|
|
}`
|
|
);
|
|
}
|