PR E: SWIFT gateway (MT760, pacs.009, MT202, camt.025/054) — arch step 6
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.
This commit is contained in:
129
orchestrator/src/services/swift/camt.ts
Normal file
129
orchestrator/src/services/swift/camt.ts
Normal file
@@ -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}[^>]*>([^<]*)</${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})"[^>]*>([^<]*)</${tag}>`);
|
||||
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;
|
||||
}
|
||||
36
orchestrator/src/services/swift/index.ts
Normal file
36
orchestrator/src/services/swift/index.ts
Normal file
@@ -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";
|
||||
78
orchestrator/src/services/swift/mt202.ts
Normal file
78
orchestrator/src/services/swift/mt202.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
"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,
|
||||
};
|
||||
}
|
||||
112
orchestrator/src/services/swift/mt760.ts
Normal file
112
orchestrator/src/services/swift/mt760.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
"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();
|
||||
}
|
||||
94
orchestrator/src/services/swift/pacs009.ts
Normal file
94
orchestrator/src/services/swift/pacs009.ts
Normal file
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.009.001.08">
|
||||
<FICdtTrf>
|
||||
<GrpHdr>
|
||||
<MsgId>${escapeXml(messageId)}</MsgId>
|
||||
<CreDtTm>${escapeXml(creDtTm)}</CreDtTm>
|
||||
<NbOfTxs>1</NbOfTxs>
|
||||
<SttlmInf><SttlmMtd>INGA</SttlmMtd></SttlmInf>
|
||||
<InstgAgt><FinInstnId><BICFI>${opts.instructingAgentBIC}</BICFI></FinInstnId></InstgAgt>
|
||||
<InstdAgt><FinInstnId><BICFI>${opts.instructedAgentBIC}</BICFI></FinInstnId></InstdAgt>
|
||||
</GrpHdr>
|
||||
<CdtTrfTxInf>
|
||||
<PmtId>
|
||||
<InstrId>${escapeXml(messageId)}</InstrId>
|
||||
<EndToEndId>${escapeXml(endToEndId)}</EndToEndId>
|
||||
<TxId>${escapeXml(messageId)}</TxId>
|
||||
</PmtId>
|
||||
<IntrBkSttlmAmt Ccy="${ccy}">${amount}</IntrBkSttlmAmt>
|
||||
<IntrBkSttlmDt>${settleDate}</IntrBkSttlmDt>
|
||||
<Dbtr><FinInstnId><BICFI>${opts.debtorAgentBIC}</BICFI></FinInstnId></Dbtr>
|
||||
<DbtrAgt><FinInstnId><BICFI>${opts.debtorAgentBIC}</BICFI></FinInstnId></DbtrAgt>
|
||||
<CdtrAgt><FinInstnId><BICFI>${opts.creditorAgentBIC}</BICFI></FinInstnId></CdtrAgt>
|
||||
<Cdtr><FinInstnId><BICFI>${opts.creditorAgentBIC}</BICFI></FinInstnId></Cdtr>
|
||||
</CdtTrfTxInf>
|
||||
</FICdtTrf>
|
||||
</Document>`;
|
||||
|
||||
return { messageId, endToEndId, xml };
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c]!));
|
||||
}
|
||||
169
orchestrator/tests/unit/swift.test.ts
Normal file
169
orchestrator/tests/unit/swift.test.ts
Normal file
@@ -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("<IntrBkSttlmAmt Ccy=\"USD\">1500000.00</IntrBkSttlmAmt>");
|
||||
expect(result.xml).toContain("<BICFI>EBILAEAD</BICFI>");
|
||||
expect(result.xml).toContain("<BICFI>EMBKAEAD</BICFI>");
|
||||
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 = `<?xml version="1.0"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.025.001.05"><Rct><MsgId>R1</MsgId><OrgnlMsgId>MSG-1</OrgnlMsgId><Cd>ACSC</Cd><CreDtTm>2026-04-18T10:01:00Z</CreDtTm></Rct></Document>`;
|
||||
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 = `<?xml version="1.0"?><Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.054.001.08"><BkToCstmrDbtCdtNtfctn><MsgId>N1</MsgId><Ntfctn><Ntry><Amt Ccy="USD">1500000.00</Amt><CdtDbtInd>CRDT</CdtDbtInd><BookgDt><Dt>2026-04-18</Dt></BookgDt><ValDt><Dt>2026-04-18</Dt></ValDt><NtryDtls><TxDtls><Refs><EndToEndId>E2E-plan-1</EndToEndId></Refs></TxDtls></NtryDtls></Ntry></Ntfctn></BkToCstmrDbtCdtNtfctn></Document>`;
|
||||
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 = `<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.025.001.05"><Rct><MsgId>R</MsgId><OrgnlMsgId>O</OrgnlMsgId><Cd>ACSC</Cd></Rct></Document>`;
|
||||
expect(parseCamt(xml025).type).toBe("camt.025");
|
||||
});
|
||||
|
||||
it("parseCamt rejects unknown xmlns", () => {
|
||||
expect(() => parseCamt('<Document xmlns="urn:other"/>')).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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user