From 8dcdb4531c39f1ff7cee53647132c4e4ed85da72 Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 22 Apr 2026 16:42:21 +0000 Subject: [PATCH] =?UTF-8?q?PR=20E:=20SWIFT=20gateway=20(MT760,=20pacs.009,?= =?UTF-8?q?=20MT202,=20camt.025/054)=20=E2=80=94=20arch=20step=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outbound generators: - swift/mt760.ts: SBLC issuance (FIN Cat-7). 12-tag message built from InstrumentTerms with deterministic messageHash() for planHash anchoring. URDG 758 / UCP 600 aware. - swift/pacs009.ts: FI-to-FI credit transfer (ISO 20022 XML, pacs.009.001.08). Fixes the pacs.008 mis-routing flagged in the gap-analysis (pacs.008 is customer-to-bank; pacs.009 is bank-to-bank). BIC validation on all four agents. - swift/mt202.ts: FIN equivalent of pacs.009 for non-migrated corridors. 32A amount formatted with SWIFT decimal comma. Inbound parsers: - swift/camt.ts: parseCamt025 (receipt / status), parseCamt054 (credit/debit notification), reconcileCamt054 (diffs amount, ccy, direction, endToEndId so VALIDATING can feed mismatches into Data.valueMismatch()), parseCamt dispatcher on xmlns marker. Public surface in swift/index.ts documents channel selection: pacs.008 stays on the PSP customer leg; pacs.009/MT202 is the interbank leg; COMMIT requires camt.025 ACSC or camt.054 CRDT (arch §9.2 accepted !== settled). Tests: swift.test.ts — 14 cases covering the happy path, validation errors (bad BIC, malformed date, negative amount, missing pay step), determinism of messageHash, camt parser + reconciliation. tsc clean. 74 tests pass across 6 suites. --- orchestrator/src/services/swift/camt.ts | 129 ++++++++++++++++ orchestrator/src/services/swift/index.ts | 36 +++++ orchestrator/src/services/swift/mt202.ts | 78 ++++++++++ orchestrator/src/services/swift/mt760.ts | 112 ++++++++++++++ orchestrator/src/services/swift/pacs009.ts | 94 ++++++++++++ orchestrator/tests/unit/swift.test.ts | 169 +++++++++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 orchestrator/src/services/swift/camt.ts create mode 100644 orchestrator/src/services/swift/index.ts create mode 100644 orchestrator/src/services/swift/mt202.ts create mode 100644 orchestrator/src/services/swift/mt760.ts create mode 100644 orchestrator/src/services/swift/pacs009.ts create mode 100644 orchestrator/tests/unit/swift.test.ts diff --git a/orchestrator/src/services/swift/camt.ts b/orchestrator/src/services/swift/camt.ts new file mode 100644 index 0000000..dde8cd4 --- /dev/null +++ b/orchestrator/src/services/swift/camt.ts @@ -0,0 +1,129 @@ +/** + * camt.025 (Receipt) and camt.054 (Bank-to-Customer Debit/Credit + * Notification) ingestion. + * + * Arch §4.3 + §9.2. These are the inbound settlement-confirmation + * messages that allow the VALIDATING phase to mark the payment leg + * as SETTLED. The parser is intentionally minimal — just enough to + * extract the fields the VALIDATING reconciliation compares against. + */ + +export interface Camt025Receipt { + type: "camt.025"; + messageId: string; + originalMessageId: string; + status: "ACCP" | "ACSC" | "ACSP" | "RJCT" | "PDNG" | string; + reasonCode?: string; + dateTime?: string; +} + +export interface Camt054Notification { + type: "camt.054"; + messageId: string; + creditDebitIndicator: "CRDT" | "DBIT"; + amount: number; + currency: string; + endToEndId?: string; + valueDate?: string; + bookingDate?: string; +} + +export type CamtMessage = Camt025Receipt | Camt054Notification; + +function extractTag(xml: string, tag: string): string | undefined { + const re = new RegExp(`<${tag}[^>]*>([^<]*)`); + const m = re.exec(xml); + return m ? m[1].trim() : undefined; +} + +function extractAmountWithCcy(xml: string, tag: string): { amount: number; currency: string } | undefined { + const re = new RegExp(`<${tag}[^>]*Ccy="([A-Z]{3})"[^>]*>([^<]*)`); + const m = re.exec(xml); + return m ? { currency: m[1], amount: Number(m[2]) } : undefined; +} + +/** + * Parse a camt.025 Receipt. Only fields used by the orchestrator are + * surfaced; everything else stays in the raw XML. + */ +export function parseCamt025(xml: string): Camt025Receipt { + if (!/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.025/.test(xml)) { + throw new Error("camt.025: xmlns marker not found"); + } + const messageId = extractTag(xml, "MsgId") ?? ""; + const originalMessageId = extractTag(xml, "OrgnlMsgId") ?? ""; + const status = (extractTag(xml, "Cd") ?? extractTag(xml, "ConfSts") ?? "PDNG") as Camt025Receipt["status"]; + const reasonCode = extractTag(xml, "PrtryStsRsn") ?? extractTag(xml, "Rsn"); + const dateTime = extractTag(xml, "CreDtTm"); + if (!messageId) throw new Error("camt.025: missing MsgId"); + if (!originalMessageId) throw new Error("camt.025: missing OrgnlMsgId"); + return { type: "camt.025", messageId, originalMessageId, status, reasonCode, dateTime }; +} + +/** + * Parse a camt.054 Credit/Debit Notification. + */ +export function parseCamt054(xml: string): Camt054Notification { + if (!/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.054/.test(xml)) { + throw new Error("camt.054: xmlns marker not found"); + } + const messageId = extractTag(xml, "MsgId") ?? ""; + const cdtDbt = (extractTag(xml, "CdtDbtInd") ?? "CRDT") as "CRDT" | "DBIT"; + const amt = extractAmountWithCcy(xml, "Amt"); + if (!amt) throw new Error("camt.054: missing Amt"); + const endToEndId = extractTag(xml, "EndToEndId"); + const valueDate = extractTag(xml, "ValDt"); + const bookingDate = extractTag(xml, "BookgDt"); + if (!messageId) throw new Error("camt.054: missing MsgId"); + return { + type: "camt.054", + messageId, + creditDebitIndicator: cdtDbt, + amount: amt.amount, + currency: amt.currency, + endToEndId, + valueDate, + bookingDate, + }; +} + +/** + * Dispatch on the xmlns marker. Throws if the document is neither + * camt.025 nor camt.054. + */ +export function parseCamt(xml: string): CamtMessage { + if (/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.025/.test(xml)) return parseCamt025(xml); + if (/xmlns="urn:iso:std:iso:20022:tech:xsd:camt\.054/.test(xml)) return parseCamt054(xml); + throw new Error("camt: unsupported or missing xmlns (expected camt.025 or camt.054)"); +} + +/** + * Reconcile a camt.054 credit notification against an expected + * (amount, currency, endToEndId). Returns the list of mismatches so + * VALIDATING can feed them into Data.valueMismatch(). + */ +export interface ReconcileExpected { + amount: number; + currency: string; + endToEndId?: string; +} + +export function reconcileCamt054( + msg: Camt054Notification, + expected: ReconcileExpected, +): Array<{ field: string; expected: unknown; actual: unknown }> { + const mismatches: Array<{ field: string; expected: unknown; actual: unknown }> = []; + if (msg.creditDebitIndicator !== "CRDT") { + mismatches.push({ field: "creditDebitIndicator", expected: "CRDT", actual: msg.creditDebitIndicator }); + } + if (msg.currency !== expected.currency) { + mismatches.push({ field: "currency", expected: expected.currency, actual: msg.currency }); + } + if (msg.amount !== expected.amount) { + mismatches.push({ field: "amount", expected: expected.amount, actual: msg.amount }); + } + if (expected.endToEndId && msg.endToEndId && msg.endToEndId !== expected.endToEndId) { + mismatches.push({ field: "endToEndId", expected: expected.endToEndId, actual: msg.endToEndId }); + } + return mismatches; +} diff --git a/orchestrator/src/services/swift/index.ts b/orchestrator/src/services/swift/index.ts new file mode 100644 index 0000000..692906e --- /dev/null +++ b/orchestrator/src/services/swift/index.ts @@ -0,0 +1,36 @@ +/** + * SWIFT gateway — public surface (arch §4.2 + §4.3). + * + * Outbound generators: + * - generateMt760 : issuance of SBLC (Cat-7 FIN) + * - generatePacs009 : FI-to-FI credit transfer (ISO 20022 XML) + * - generateMt202 : FIN equivalent of pacs.009 for non-migrated + * corridors + * + * Inbound parsers: + * - parseCamt025 : receipt / status of a prior instruction + * - parseCamt054 : bank-to-customer credit/debit notification + * - reconcileCamt054: diff a camt.054 against the expected amount, + * currency, and end-to-end id + * + * Channel selection (arch §9.2 accepted !== settled): + * - pacs.008 remains the customer-initiated PSP channel (existing + * `services/iso20022.ts`). COMMIT must not fire on pacs.008 + * "acceptance" alone. + * - pacs.009 / MT202 is the interbank settlement channel; COMMIT + * requires either camt.025 ACSC or camt.054 CRDT evidence here. + */ + +export { generateMt760, messageHash, type Mt760Message } from "./mt760"; +export { generatePacs009, type Pacs009Options, type Pacs009Result } from "./pacs009"; +export { generateMt202, type Mt202Options, type Mt202Message } from "./mt202"; +export { + parseCamt, + parseCamt025, + parseCamt054, + reconcileCamt054, + type Camt025Receipt, + type Camt054Notification, + type CamtMessage, + type ReconcileExpected, +} from "./camt"; diff --git a/orchestrator/src/services/swift/mt202.ts b/orchestrator/src/services/swift/mt202.ts new file mode 100644 index 0000000..17bd638 --- /dev/null +++ b/orchestrator/src/services/swift/mt202.ts @@ -0,0 +1,78 @@ +/** + * MT202 COV — General Financial Institution Transfer (cover method). + * + * Arch §4.3. FIN equivalent of pacs.009 used on SWIFT networks that + * have not yet migrated to ISO 20022. Generated alongside pacs.009 + * during transitional period — settlement confirmation can arrive on + * either channel. + */ + +import type { Plan, PlanStep } from "../../types/plan"; + +export interface Mt202Options { + transactionReference: string; + relatedReference?: string; + valueDate: string; // YYYY-MM-DD + sendingInstitution: string; // BIC + receivingInstitution: string;// BIC + beneficiaryInstitution: string; // BIC + orderingInstitution?: string;// BIC +} + +export interface Mt202Message { + sender: string; + receiver: string; + fin: string; + fields: Record; +} + +function yyMMdd(iso: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`MT202: valueDate must be YYYY-MM-DD, got '${iso}'`); + return `${m[1].slice(2)}${m[2]}${m[3]}`; +} + +function bicCheck(bic: string, field: string): void { + if (!/^[A-Z0-9]{8}([A-Z0-9]{3})?$/.test(bic)) { + throw new Error(`MT202: ${field} must be a valid BIC, got '${bic}'`); + } +} + +function findPayStep(plan: Plan): PlanStep { + const step = plan.steps.find((s) => s.type === "pay"); + if (!step) throw new Error("MT202: plan must contain a 'pay' step"); + return step; +} + +export function generateMt202(plan: Plan, opts: Mt202Options): Mt202Message { + bicCheck(opts.sendingInstitution, "sendingInstitution"); + bicCheck(opts.receivingInstitution, "receivingInstitution"); + bicCheck(opts.beneficiaryInstitution, "beneficiaryInstitution"); + if (opts.orderingInstitution) bicCheck(opts.orderingInstitution, "orderingInstitution"); + + const payStep = findPayStep(plan); + const ccy = (payStep.asset ?? "USD").toUpperCase(); + const amount = payStep.amount.toFixed(2).replace(".", ","); + const field32A = `${yyMMdd(opts.valueDate)}${ccy}${amount}`; + + const fields: Record = { + "20": opts.transactionReference, + "21": opts.relatedReference ?? opts.transactionReference, + "32A": field32A, + "52A": opts.orderingInstitution ?? opts.sendingInstitution, + "57A": opts.receivingInstitution, + "58A": opts.beneficiaryInstitution, + }; + + const block1 = `{1:F01${opts.sendingInstitution.padEnd(12, "X")}0000000000}`; + const block2 = `{2:I202${opts.receivingInstitution.padEnd(12, "X")}N}`; + const block4 = Object.entries(fields).map(([t, v]) => `:${t}:${v}`).join("\n"); + const block4Wrapped = `{4:\n${block4}\n-}`; + + return { + sender: opts.sendingInstitution, + receiver: opts.receivingInstitution, + fin: `${block1}${block2}${block4Wrapped}`, + fields, + }; +} diff --git a/orchestrator/src/services/swift/mt760.ts b/orchestrator/src/services/swift/mt760.ts new file mode 100644 index 0000000..62c0277 --- /dev/null +++ b/orchestrator/src/services/swift/mt760.ts @@ -0,0 +1,112 @@ +/** + * MT760 — Issue of a Demand Guarantee / Standby Letter of Credit + * (arch §4.2 Banking Instrument Layer + §6 Instrument Terms Hash). + * + * SWIFT FIN message. This is the issuance leg of the two-phase + * commit. Output is deterministic so the planHash anchored on-chain + * can be reproduced by any party with access to the InstrumentTerms. + * + * Reference: SWIFT FIN Category 7 User Handbook, MT760 format; + * Emirates Islamic Bank beneficiary-format SBLC template. + */ + +import { createHash } from "crypto"; +import type { InstrumentTerms } from "../../types/plan"; + +export interface Mt760Message { + sender: string; + receiver: string; + messageReference: string; + fin: string; + fields: Record; +} + +function formatAmount(amount: number, currency: string): string { + // SWIFT FIN amount: 3-letter currency + 15n,2d (max), decimal comma. + if (amount < 0) throw new Error("MT760: amount must be non-negative"); + return `${currency}${amount.toFixed(2).replace(".", ",")}`; +} + +function yyMMdd(iso: string): string { + // Accept YYYY-MM-DD and return YYMMDD. + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!m) throw new Error(`MT760: expiryDate must be YYYY-MM-DD, got '${iso}'`); + return `${m[1].slice(2)}${m[2]}${m[3]}`; +} + +/** + * Render an MT760 from an InstrumentTerms record. Uses the + * block-structured FIN format (Block 1/2/4/5). Tag codes: + * + * :20: Transaction reference number + * :23: Further identification + * :27: Sequence of total (here: 1/1) + * :30: Date of issue + * :40C: Applicable rules (URDG 758, UCP 600) + * :31D: Date and place of expiry + * :50: Applicant + * :52A: Issuing bank (BIC) + * :59: Beneficiary name + account + * :32B: Amount + * :77C: Details of guarantee + * :72Z: Sender to receiver info + */ +export function generateMt760( + terms: InstrumentTerms, + opts: { transactionReference: string; issueDate: string }, +): Mt760Message { + const sender = terms.issuingBankBIC; + const receiver = terms.beneficiaryBankBIC; + const field32B = formatAmount(terms.amount, terms.currency); + const field31D = `${yyMMdd(terms.expiryDate)}${terms.placeOfPresentation.toUpperCase()}`; + + const fields: Record = { + "20": opts.transactionReference, + "23": "ISSUE OF STANDBY LETTER OF CREDIT", + "27": "1/1", + "30": yyMMdd(opts.issueDate), + "40C": terms.governingLaw, + "31D": field31D, + "50": terms.applicant, + "52A": terms.issuingBankBIC, + "59": [terms.beneficiaryName, terms.beneficiaryAccount].filter(Boolean).join("\n"), + "32B": field32B, + "77C": [ + `TEMPLATE/${terms.templateRef}`, + `TEMPLATE_HASH/${terms.templateHash}`, + `TENOR/${terms.tenor}`, + ].join("\n"), + "72Z": `GOVLAW/${terms.governingLaw}`, + }; + + // Build FIN block 4 body with :tag:value sequences. + const block4 = Object.entries(fields) + .map(([tag, value]) => `:${tag}:${value}`) + .join("\n"); + + const block1 = `{1:F01${sender.padEnd(12, "X")}0000000000}`; + const block2 = `{2:I760${receiver.padEnd(12, "X")}N}`; + const block4Wrapped = `{4:\n${block4}\n-}`; + const block5 = `{5:{CHK:${checksum(block4)}}}`; + + const fin = `${block1}${block2}${block4Wrapped}${block5}`; + + return { sender, receiver, messageReference: opts.transactionReference, fin, fields }; +} + +/** + * Deterministic SHA-256 over the canonical field list. Matches + * InstrumentTerms.templateHash when all 11 required fields are filled + * in with the SBLC template values. + */ +export function messageHash(msg: Mt760Message): string { + const canonical = Object.entries(msg.fields) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); + return createHash("sha256").update(canonical).digest("hex"); +} + +function checksum(block4Body: string): string { + return createHash("sha256").update(block4Body).digest("hex").slice(0, 12).toUpperCase(); +} diff --git a/orchestrator/src/services/swift/pacs009.ts b/orchestrator/src/services/swift/pacs009.ts new file mode 100644 index 0000000..ab9303c --- /dev/null +++ b/orchestrator/src/services/swift/pacs009.ts @@ -0,0 +1,94 @@ +/** + * pacs.009 — Financial Institution Credit Transfer (ISO 20022). + * + * Arch §4.3 Payment Messaging / Settlement Layer. Used for + * **bank-to-bank** credit transfers (the interbank leg); pacs.008 is + * for **customer-to-bank** PSP-initiated transfers. The gap-analysis + * flagged that ExecutionCoordinator was generating pacs.008 for what + * is actually a FI-to-FI settlement leg — this module fixes that. + * + * Reference: ISO 20022 Payments Maintenance 2019 / 2022, + * pacs.009.001.08 schema. + */ + +import type { Plan, PlanStep } from "../../types/plan"; + +export interface Pacs009Options { + messageId: string; + creationDateTime?: string; + instructingAgentBIC: string; + instructedAgentBIC: string; + debtorAgentBIC: string; + creditorAgentBIC: string; + endToEndId?: string; +} + +export interface Pacs009Result { + messageId: string; + endToEndId: string; + xml: string; +} + +function bicCheck(bic: string, field: string): void { + if (!/^[A-Z0-9]{8}([A-Z0-9]{3})?$/.test(bic)) { + throw new Error(`pacs.009: ${field} must be a valid BIC, got '${bic}'`); + } +} + +function findPayStep(plan: Plan): PlanStep { + const step = plan.steps.find((s) => s.type === "pay"); + if (!step) throw new Error("pacs.009: plan must contain a 'pay' step"); + return step; +} + +/** + * Render a pacs.009.001.08 XML message for the interbank leg of the + * plan's `pay` step. + */ +export function generatePacs009(plan: Plan, opts: Pacs009Options): Pacs009Result { + bicCheck(opts.instructingAgentBIC, "instructingAgentBIC"); + bicCheck(opts.instructedAgentBIC, "instructedAgentBIC"); + bicCheck(opts.debtorAgentBIC, "debtorAgentBIC"); + bicCheck(opts.creditorAgentBIC, "creditorAgentBIC"); + + const payStep = findPayStep(plan); + const messageId = opts.messageId; + const endToEndId = opts.endToEndId ?? `E2E-${plan.plan_id ?? messageId}`; + const creDtTm = opts.creationDateTime ?? new Date().toISOString(); + const ccy = (payStep.asset ?? "USD").toUpperCase(); + const amount = payStep.amount.toFixed(2); + const settleDate = creDtTm.split("T")[0]; + + const xml = ` + + + + ${escapeXml(messageId)} + ${escapeXml(creDtTm)} + 1 + INGA + ${opts.instructingAgentBIC} + ${opts.instructedAgentBIC} + + + + ${escapeXml(messageId)} + ${escapeXml(endToEndId)} + ${escapeXml(messageId)} + + ${amount} + ${settleDate} + ${opts.debtorAgentBIC} + ${opts.debtorAgentBIC} + ${opts.creditorAgentBIC} + ${opts.creditorAgentBIC} + + +`; + + return { messageId, endToEndId, xml }; +} + +function escapeXml(s: string): string { + return s.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c]!)); +} diff --git a/orchestrator/tests/unit/swift.test.ts b/orchestrator/tests/unit/swift.test.ts new file mode 100644 index 0000000..805cd8d --- /dev/null +++ b/orchestrator/tests/unit/swift.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "@jest/globals"; +import { + generateMt760, + messageHash, + generatePacs009, + generateMt202, + parseCamt025, + parseCamt054, + parseCamt, + reconcileCamt054, +} from "../../src/services/swift"; +import type { InstrumentTerms, Plan } from "../../src/types/plan"; + +const TERMS: InstrumentTerms = { + applicant: "ACME TRADING FZE", + issuingBankBIC: "EBILAEAD", + beneficiaryBankBIC: "EMBKAEAD", + beneficiaryName: "BLUE OCEAN SHIPPING LLC", + beneficiaryAccount: "AE070260001015104203701", + amount: 1_500_000, + currency: "USD", + tenor: "365D", + expiryDate: "2027-04-18", + placeOfPresentation: "DUBAI", + governingLaw: "URDG 758", + templateRef: "EIB-SBLC-2024-01", + templateHash: "a".repeat(64), +}; + +const PLAN: Plan = { + plan_id: "11111111-2222-3333-4444-555555555555", + creator: "0xabc", + steps: [{ type: "pay", asset: "USD", amount: 1_500_000 }], +}; + +describe("SWIFT gateway — MT760", () => { + it("renders all 12 required tags", () => { + const msg = generateMt760(TERMS, { transactionReference: "TXN1", issueDate: "2026-04-18" }); + expect(msg.sender).toBe("EBILAEAD"); + expect(msg.receiver).toBe("EMBKAEAD"); + expect(msg.fields["20"]).toBe("TXN1"); + expect(msg.fields["30"]).toBe("260418"); + expect(msg.fields["32B"]).toBe("USD1500000,00"); + expect(msg.fields["31D"]).toBe("270418DUBAI"); + expect(msg.fin).toContain("{1:F01EBILAEADXXXX0000000000}"); + expect(msg.fin).toContain("{2:I760EMBKAEADXXXXN}"); + expect(msg.fin).toContain(":32B:USD1500000,00"); + }); + + it("rejects malformed expiry date", () => { + expect(() => + generateMt760({ ...TERMS, expiryDate: "not-a-date" }, { transactionReference: "T", issueDate: "2026-04-18" }), + ).toThrow(/YYYY-MM-DD/); + }); + + it("rejects negative amount", () => { + expect(() => + generateMt760({ ...TERMS, amount: -1 }, { transactionReference: "T", issueDate: "2026-04-18" }), + ).toThrow(/non-negative/); + }); + + it("messageHash is deterministic", () => { + const a = generateMt760(TERMS, { transactionReference: "T", issueDate: "2026-04-18" }); + const b = generateMt760(TERMS, { transactionReference: "T", issueDate: "2026-04-18" }); + expect(messageHash(a)).toBe(messageHash(b)); + expect(messageHash(a)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("SWIFT gateway — pacs.009", () => { + const opts = { + messageId: "MSG-1", + creationDateTime: "2026-04-18T10:00:00Z", + instructingAgentBIC: "EBILAEAD", + instructedAgentBIC: "EMBKAEAD", + debtorAgentBIC: "EBILAEAD", + creditorAgentBIC: "EMBKAEAD", + }; + + it("emits well-formed pacs.009.001.08 XML", () => { + const result = generatePacs009(PLAN, opts); + expect(result.messageId).toBe("MSG-1"); + expect(result.xml).toContain("urn:iso:std:iso:20022:tech:xsd:pacs.009.001.08"); + expect(result.xml).toContain("1500000.00"); + expect(result.xml).toContain("EBILAEAD"); + expect(result.xml).toContain("EMBKAEAD"); + expect(result.endToEndId).toBe(`E2E-${PLAN.plan_id}`); + }); + + it("rejects invalid BIC", () => { + expect(() => generatePacs009(PLAN, { ...opts, instructingAgentBIC: "BAD" })).toThrow(/BIC/); + }); + + it("requires a pay step", () => { + expect(() => + generatePacs009({ ...PLAN, steps: [{ type: "borrow", amount: 1, asset: "USD" }] }, opts), + ).toThrow(/pay/); + }); +}); + +describe("SWIFT gateway — MT202", () => { + it("renders the 6 required tags", () => { + const msg = generateMt202(PLAN, { + transactionReference: "TXN-1", + valueDate: "2026-04-18", + sendingInstitution: "EBILAEAD", + receivingInstitution: "EMBKAEAD", + beneficiaryInstitution: "EMBKAEAD", + }); + expect(msg.fields["20"]).toBe("TXN-1"); + expect(msg.fields["32A"]).toBe("260418USD1500000,00"); + expect(msg.fields["58A"]).toBe("EMBKAEAD"); + expect(msg.fin).toContain(":20:TXN-1"); + }); +}); + +describe("SWIFT gateway — camt parsers", () => { + it("parseCamt025 extracts status + ids", () => { + const xml = `R1MSG-1ACSC2026-04-18T10:01:00Z`; + const r = parseCamt025(xml); + expect(r.type).toBe("camt.025"); + expect(r.originalMessageId).toBe("MSG-1"); + expect(r.status).toBe("ACSC"); + }); + + it("parseCamt054 extracts credit amount + endToEndId", () => { + const xml = `N11500000.00CRDT
2026-04-18
2026-04-18
E2E-plan-1
`; + const r = parseCamt054(xml); + expect(r.type).toBe("camt.054"); + expect(r.creditDebitIndicator).toBe("CRDT"); + expect(r.amount).toBe(1_500_000); + expect(r.currency).toBe("USD"); + expect(r.endToEndId).toBe("E2E-plan-1"); + }); + + it("parseCamt dispatches on xmlns marker", () => { + const xml025 = `ROACSC`; + expect(parseCamt(xml025).type).toBe("camt.025"); + }); + + it("parseCamt rejects unknown xmlns", () => { + expect(() => parseCamt('')).toThrow(/unsupported/); + }); + + it("reconcileCamt054 returns empty array when everything matches", () => { + const msg = { + type: "camt.054" as const, + messageId: "N1", + creditDebitIndicator: "CRDT" as const, + amount: 1_500_000, + currency: "USD", + endToEndId: "E2E-1", + }; + expect(reconcileCamt054(msg, { amount: 1_500_000, currency: "USD", endToEndId: "E2E-1" })).toEqual([]); + }); + + it("reconcileCamt054 reports amount + currency + direction mismatches", () => { + const msg = { + type: "camt.054" as const, + messageId: "N1", + creditDebitIndicator: "DBIT" as const, + amount: 1_400_000, + currency: "EUR", + endToEndId: "E2E-2", + }; + const result = reconcileCamt054(msg, { amount: 1_500_000, currency: "USD", endToEndId: "E2E-1" }); + expect(result.map((m) => m.field).sort()).toEqual(["amount", "creditDebitIndicator", "currency", "endToEndId"]); + }); +});