CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (pull_request) Successful in 3s
masterplan-lock / check (push) Successful in 3s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / quality (pull_request) Successful in 31s
CI (SIAX Cloud) / quality (push) Successful in 40s
CI (SIAX Cloud) / security (pull_request) Successful in 42s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
getSqlForDurableStore throws SiaxError(DEPENDENCY_UNAVAILABLE, httpStatus 503, retryable) when a money-bearing store would fall back to an in-memory Map in a production runtime (the durable-store guard). The /api/0pt/* surface already maps it via src/http/siax-error-response.ts; the seven legacy routes outside the middleware matcher did not — the documented 503 leaked as Next's opaque 500 with no body. - app/api/siax-error-mapping.ts: shared isSiaxError + siaxErrorResponseForLegacy - orders (POST/GET), orders/[id] (GET/PATCH), subscriptions (GET/POST), payments/deposit, payments/release, admin/metrics: try/catch -> mapper, each route's own typed error contract preserved (idempotency conflict 409 etc.) - webhooks/stripe: store outage -> 503 received:false reason:store_unavailable so Stripe's delivery retry path stays open (never a silent received:true) - tests/api/legacy-routes-siax-error-mapping.test.ts: pins the 503 + body
90 lines
4.1 KiB
TypeScript
90 lines
4.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import type Stripe from "stripe";
|
|
import { getStripe } from "@/lib/stripe";
|
|
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
|
|
import { isSiaxError } from "../../siax-error-mapping";
|
|
|
|
// POST /api/webhooks/stripe (docs/04) — verify signature, advance order status.
|
|
export async function POST(req: NextRequest) {
|
|
const stripe = getStripe();
|
|
const whSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
const sig = req.headers.get("stripe-signature");
|
|
if (!stripe || !whSecret) {
|
|
// Never acknowledge an event that was not signature-verified and processed.
|
|
// A non-2xx response keeps Stripe's delivery retry/reconciliation path open.
|
|
return NextResponse.json({ received: false, reason: "not_configured" }, { status: 503 });
|
|
}
|
|
if (!sig) {
|
|
return NextResponse.json({ error: "missing_signature" }, { status: 400 });
|
|
}
|
|
const body = await req.text();
|
|
let event;
|
|
try {
|
|
event = stripe.webhooks.constructEvent(body, sig, whSecret);
|
|
} catch {
|
|
return NextResponse.json({ error: "bad_signature" }, { status: 400 });
|
|
}
|
|
if (event.type === "checkout.session.completed") {
|
|
const s = event.data.object as Stripe.Checkout.Session;
|
|
const oid = s.metadata?.order_id;
|
|
const kind = s.metadata?.kind;
|
|
if (!oid || (kind !== "deposit" && kind !== "balance")) {
|
|
return NextResponse.json({ error: "unmatched_session" }, { status: 422 });
|
|
}
|
|
if (s.payment_status !== "paid") {
|
|
return NextResponse.json({ error: "payment_not_confirmed" }, { status: 503 });
|
|
}
|
|
// 0pt#53: durable-store failure -> documented non-2xx (never an opaque 500,
|
|
// and never a silent received:true for a paid event that was NOT processed —
|
|
// the non-2xx keeps Stripe's delivery retry path open).
|
|
let order;
|
|
try {
|
|
order = await getOrder(oid);
|
|
} catch (err: unknown) {
|
|
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
|
throw err;
|
|
}
|
|
if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 });
|
|
if (!order.tenant_id || !order.buyer_subject) {
|
|
// A legacy anonymous draft is not a payable order. Do not silently
|
|
// acknowledge a paid event until an audited owner-remediation path exists.
|
|
return NextResponse.json({ error: "order_owner_missing" }, { status: 503 });
|
|
}
|
|
const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore;
|
|
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") {
|
|
return NextResponse.json({ error: "payment_mismatch" }, { status: 422 });
|
|
}
|
|
if (kind === "balance") {
|
|
if (order.status !== "accepted" && order.status !== "delivered") {
|
|
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
|
}
|
|
let delivered = false;
|
|
try {
|
|
delivered = order.status === "accepted" && !!(await setStatusIfCurrent(oid, "accepted", "delivered"));
|
|
} catch (err: unknown) {
|
|
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
|
throw err;
|
|
}
|
|
if (order.status === "accepted" && !delivered) {
|
|
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
|
}
|
|
} else {
|
|
const alreadyAdvanced = ["in_production", "review", "revision", "accepted", "delivered"].includes(order.status);
|
|
if (order.status !== "draft" && order.status !== "deposit_pending" && !alreadyAdvanced) {
|
|
return NextResponse.json({ error: "invalid_order_state" }, { status: 409 });
|
|
}
|
|
let advanced = true;
|
|
try {
|
|
advanced = alreadyAdvanced || !!(await setStatusIfCurrent(oid, order.status, "in_production"));
|
|
} catch (err: unknown) {
|
|
if (isSiaxError(err)) return NextResponse.json({ received: false, reason: "store_unavailable" }, { status: 503 });
|
|
throw err;
|
|
}
|
|
if (!advanced) {
|
|
return NextResponse.json({ error: "order_update_failed" }, { status: 503 });
|
|
}
|
|
}
|
|
}
|
|
return NextResponse.json({ received: true });
|
|
}
|