Files
0pt/app/api/webhooks/stripe/route.ts
admin b3239487b7
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 13s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m26s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m33s
feat(document-workbench): wire customer upload, structuring, credits and readiness
- POST /api/0pt/documents/upload: real multipart bytes into DOC0 through the
  durable attempt journal and the existing coordinator; verified tenant/actor
  only, project authorization fail-closed.
- POST /api/0pt/documents/structure: DOC0 segments -> INF0 -> evidence-bound
  validation -> optional JSON/CSV export, with credit reserve/settle.
- Credits: balance, server-owned price book, Stripe Checkout and idempotent
  webhook reconciliation via a Stripe re-read of session + payment intent.
- /portal/dokument and /portal/krediter: SIAX-styled workspace.
- @siax/access: universal, framework-neutral OpenFGA project relationship
  primitive reusable by DOC0/N0D/other products.
- Readiness: real Redis PING probe instead of presence-only; queue is now a
  live dependency when configured.
- Dockerfile: copy every workspace manifest so --frozen-lockfile works.
- Format/build hygiene: .prettierignore, .worktrees ignored, valid manifest YAML.
2026-11-02 14:46:10 +01:00

154 lines
6.7 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';
import { PostgresCreditLedger } from '@/src/document-workbench/credit-ledger';
import { PostgresCreditPurchaseStore } from '@/src/document-workbench/credit-purchase-store';
import {
StripeCreditVerificationError,
verifyStripeCreditPurchase,
} from '@/src/document-workbench/stripe-credit-verification';
import { getRepositorySql, repositoryDatabaseUrl } from '@/src/repositories/db';
export const runtime = 'nodejs';
/**
* Credit purchases are granted ONLY from Stripe's own re-read of the session
* and payment intent. The webhook body is a trigger, never proof of price or
* payment. The ledger command is idempotent, so duplicate deliveries are safe.
*/
async function handleCreditPurchase(
stripe: NonNullable<ReturnType<typeof getStripe>>,
session: Stripe.Checkout.Session,
) {
const tenantId = session.metadata?.tenant_id;
const purchaseId = session.metadata?.purchase_id;
if (!tenantId || !purchaseId) {
return NextResponse.json({ error: 'unmatched_session' }, { status: 422 });
}
const url = repositoryDatabaseUrl();
if (!url) {
return NextResponse.json({ received: false, reason: 'credits_not_configured' }, { status: 503 });
}
try {
const store = new PostgresCreditPurchaseStore(getRepositorySql(url));
const snapshot = await store.get(tenantId, purchaseId);
if (!snapshot) {
// Do not acknowledge a paid purchase we cannot bind to a server snapshot.
return NextResponse.json({ received: false, reason: 'purchase_not_found' }, { status: 503 });
}
if (snapshot.checkoutSessionId !== session.id) {
return NextResponse.json({ error: 'session_mismatch' }, { status: 422 });
}
const command = await verifyStripeCreditPurchase(stripe, snapshot, { tenantId });
const result = await new PostgresCreditLedger(getRepositorySql(url)).apply(command);
return NextResponse.json({ received: true, credited: !result.replay, balance: result.balance });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Credit reconciliation failed';
if (error instanceof StripeCreditVerificationError) {
const terminal = ['mismatch', 'not_paid', 'refunded', 'unsupported'].includes(error.reason);
return NextResponse.json(
{ received: false, reason: error.reason, message },
{ status: terminal ? 422 : 503 },
);
}
return NextResponse.json({ received: false, reason: 'ledger_unavailable', message }, { status: 503 });
}
}
// 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' ||
event.type === 'checkout.session.async_payment_succeeded'
) {
const s = event.data.object as Stripe.Checkout.Session;
const oid = s.metadata?.order_id;
const kind = s.metadata?.kind;
if (kind === '0pt_credit_purchase') {
return handleCreditPurchase(stripe, s);
}
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 });
}