558 lines
21 KiB
TypeScript
558 lines
21 KiB
TypeScript
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<void> {
|
|
// 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<void> {
|
|
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<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)"
|
|
}`
|
|
);
|
|
}
|