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
- 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.
154 lines
6.7 KiB
TypeScript
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 });
|
|
}
|