/** * FIN-link sandbox (gap-analysis v2 §7.1 / §10.6). * * The SWIFT generators under `services/swift/` produce strings — but * the architecture note §4.3 requires an actual transport. Until a * production FIN-link / Alliance Access integration ships, this * sandbox service stands in as the outbound transport so the full * lifecycle (dispatch → ack → accept → settle) can be exercised end * to end in dev + E2E. * * The sandbox: * * 1. Accepts an outbound SWIFT/ISO payload via POST /dispatch. * 2. Assigns a FIN reference, stores the message in memory, and * returns a synchronous ack (200). * 3. Advances the message through a deterministic lifecycle: * received -> acknowledged -> accepted -> settled * on each tick of an internal clock (configurable via * setTickIntervalMs for tests). * 4. Exposes GET /messages/:reference + GET /messages for polling. * 5. Optionally POSTs a webhook on each state change when a caller * supplies `webhookUrl` in the dispatch request. * * The sandbox is intentionally process-local. Production transports * should back this interface with a real FIN queue / Alliance Web * Platform gateway. */ import { createHmac, randomBytes } from "crypto"; import express, { Router, type Request, type Response } from "express"; export type FinMessageState = | "received" | "acknowledged" | "accepted" | "settled" | "rejected"; export type FinMessageType = | "MT760" | "MT202" | "pacs.009" | "pacs.008" | "camt.025" | "camt.054" | "unknown"; export interface FinMessage { reference: string; messageType: FinMessageType; payload: string; state: FinMessageState; receivedAt: string; updatedAt: string; stateHistory: Array<{ state: FinMessageState; at: string }>; webhookUrl?: string; planId?: string; endToEndId?: string; } export interface DispatchRequest { messageType: FinMessageType; payload: string; planId?: string; endToEndId?: string; webhookUrl?: string; } export interface DispatchResponse { reference: string; state: FinMessageState; ackedAt: string; } const store = new Map(); // Deterministic lifecycle progression. const ORDER: FinMessageState[] = [ "received", "acknowledged", "accepted", "settled", ]; function nextState(current: FinMessageState): FinMessageState | null { const idx = ORDER.indexOf(current); if (idx < 0 || idx === ORDER.length - 1) return null; return ORDER[idx + 1]; } function genReference(): string { return `FIN-${randomBytes(6).toString("hex").toUpperCase()}`; } export function finSignature(payload: string): string { const secret = process.env.FIN_SANDBOX_SECRET || "fin-sandbox-dev-secret"; return createHmac("sha256", secret).update(payload).digest("hex"); } export function recordDispatch(req: DispatchRequest): FinMessage { const reference = genReference(); const now = new Date().toISOString(); const msg: FinMessage = { reference, messageType: req.messageType, payload: req.payload, state: "received", receivedAt: now, updatedAt: now, stateHistory: [{ state: "received", at: now }], webhookUrl: req.webhookUrl, planId: req.planId, endToEndId: req.endToEndId, }; store.set(reference, msg); return msg; } export async function advance(reference: string): Promise { const msg = store.get(reference); if (!msg) return null; const next = nextState(msg.state); if (!next) return msg; const at = new Date().toISOString(); msg.state = next; msg.updatedAt = at; msg.stateHistory.push({ state: next, at }); if (msg.webhookUrl) { await emitWebhook(msg).catch(() => undefined); } return msg; } export function rejectMessage( reference: string, reason: string, ): FinMessage | null { const msg = store.get(reference); if (!msg) return null; const at = new Date().toISOString(); msg.state = "rejected"; msg.updatedAt = at; msg.stateHistory.push({ state: "rejected", at }); (msg as FinMessage & { rejectionReason?: string }).rejectionReason = reason; return msg; } export function getMessage(reference: string): FinMessage | undefined { return store.get(reference); } export function listMessages(filter?: { planId?: string }): FinMessage[] { const all = Array.from(store.values()); if (!filter?.planId) return all; return all.filter((m) => m.planId === filter.planId); } export function resetSandboxForTests(): void { store.clear(); } async function emitWebhook(msg: FinMessage): Promise { if (!msg.webhookUrl) return; const body = JSON.stringify({ reference: msg.reference, messageType: msg.messageType, state: msg.state, updatedAt: msg.updatedAt, planId: msg.planId, endToEndId: msg.endToEndId, }); const signature = finSignature(body); try { await fetch(msg.webhookUrl, { method: "POST", headers: { "content-type": "application/json", "x-fin-sandbox-signature": signature, }, body, }); } catch { // swallow — the sandbox is best-effort in dev } } // --------------------------------------------------------------------------- // HTTP router // --------------------------------------------------------------------------- export function buildSandboxRouter(): Router { const r = Router(); r.use(express.json({ limit: "5mb" })); r.post("/dispatch", (req: Request, res: Response) => { const body = req.body as Partial; if ( !body || typeof body.payload !== "string" || typeof body.messageType !== "string" ) { return res.status(400).json({ error: "messageType and payload are required", }); } const msg = recordDispatch({ messageType: body.messageType as FinMessageType, payload: body.payload, planId: body.planId, endToEndId: body.endToEndId, webhookUrl: body.webhookUrl, }); const response: DispatchResponse = { reference: msg.reference, state: msg.state, ackedAt: msg.updatedAt, }; return res.status(202).json(response); }); r.post("/advance/:reference", async (req: Request, res: Response) => { const msg = await advance(req.params.reference); if (!msg) return res.status(404).json({ error: "not found" }); return res.json(msg); }); r.post("/reject/:reference", (req: Request, res: Response) => { const reason = typeof req.body?.reason === "string" ? req.body.reason : "rejected"; const msg = rejectMessage(req.params.reference, reason); if (!msg) return res.status(404).json({ error: "not found" }); return res.json(msg); }); r.get("/messages/:reference", (req: Request, res: Response) => { const msg = getMessage(req.params.reference); if (!msg) return res.status(404).json({ error: "not found" }); return res.json(msg); }); r.get("/messages", (req: Request, res: Response) => { const planId = typeof req.query.planId === "string" ? req.query.planId : undefined; return res.json({ messages: listMessages({ planId }) }); }); return r; } // --------------------------------------------------------------------------- // Timer-driven auto-progress (optional; off by default in tests) // --------------------------------------------------------------------------- let tickTimer: NodeJS.Timeout | null = null; export function startAutoProgress(intervalMs = 2_000): void { stopAutoProgress(); tickTimer = setInterval(() => { for (const msg of store.values()) { if (msg.state !== "settled" && msg.state !== "rejected") { void advance(msg.reference); } } }, intervalMs); // Allow the Node process to exit while this timer is pending. if (typeof tickTimer.unref === "function") tickTimer.unref(); } export function stopAutoProgress(): void { if (tickTimer) { clearInterval(tickTimer); tickTimer = null; } }