/** * 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; }