Files

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 });
}