Skip to content

Commit 378b860

Browse files
committed
feat: add CAMT.052 account statement support (HKCAZ/HICAZ)
Adds HKCAZ request segment, HICAZ/HICAZS response segments, and a regex-based CAMT.052 XML parser. The parser maps CAMT fields to the mt940-js Transaction interface including name, description, bankReference, isCredit, isExpense, currency, counterparty IBAN/BIC, EndToEndId, mandate reference, and creditor ID. Includes unit tests for all new segments and the parser (namespaced XML, credit/debit, descriptionStructured).
1 parent ad12f5f commit 378b860

10 files changed

Lines changed: 566 additions & 1 deletion

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { parseCamt052 } from "../camt052-parser";
2+
3+
const parse = (xml: string) => parseCamt052(xml) as any[];
4+
5+
const MINIMAL_XML = `<Document>
6+
<BkToCstmrAcctRpt>
7+
<Rpt>
8+
<Acct><Id><IBAN>DE27100777770209299700</IBAN></Id></Acct>
9+
<Bal>
10+
<Tp><CdOrPrtry><Cd>OPBD</Cd></CdOrPrtry></Tp>
11+
<Amt Ccy="EUR">1000.00</Amt>
12+
<Dt><Dt>2024-01-01</Dt></Dt>
13+
</Bal>
14+
<Bal>
15+
<Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp>
16+
<Amt Ccy="EUR">950.00</Amt>
17+
<Dt><Dt>2024-01-02</Dt></Dt>
18+
</Bal>
19+
<Ntry>
20+
<Amt Ccy="EUR">100.00</Amt>
21+
<CdtDbtInd>CRDT</CdtDbtInd>
22+
<AcctSvcrRef>TXN-CREDIT</AcctSvcrRef>
23+
<BookgDt><Dt>2024-01-02</Dt></BookgDt>
24+
<ValDt><Dt>2024-01-02</Dt></ValDt>
25+
<NtryDtls>
26+
<TxDtls>
27+
<Dbtr><Nm>Jane Sender</Nm></Dbtr>
28+
<DbtrAcct><Id><IBAN>DE12345678901234567890</IBAN></Id></DbtrAcct>
29+
<DbtrAgt><FinInstnId><BICFI>TESTBIC0</BICFI></FinInstnId></DbtrAgt>
30+
<RmtInf><Ustrd>Invoice 42</Ustrd></RmtInf>
31+
<Refs>
32+
<EndToEndId>E2E-001</EndToEndId>
33+
<MndtId>MNDT-001</MndtId>
34+
</Refs>
35+
</TxDtls>
36+
</NtryDtls>
37+
</Ntry>
38+
<Ntry>
39+
<Amt Ccy="EUR">150.00</Amt>
40+
<CdtDbtInd>DBIT</CdtDbtInd>
41+
<AcctSvcrRef>TXN-DEBIT</AcctSvcrRef>
42+
<BookgDt><Dt>2024-01-02</Dt></BookgDt>
43+
<ValDt><Dt>2024-01-02</Dt></ValDt>
44+
<NtryDtls>
45+
<TxDtls>
46+
<Cdtr><Nm>ACME Corp</Nm></Cdtr>
47+
<CdtrAcct><Id><IBAN>DE98765432109876543210</IBAN></Id></CdtrAcct>
48+
<CdtrAgt><FinInstnId><BICFI>ACMEBIC0</BICFI></FinInstnId></CdtrAgt>
49+
<RmtInf><Ustrd>Monthly fee</Ustrd></RmtInf>
50+
</TxDtls>
51+
</NtryDtls>
52+
</Ntry>
53+
</Rpt>
54+
</BkToCstmrAcctRpt>
55+
</Document>`;
56+
57+
describe("parseCamt052", () => {
58+
test("returns empty array for XML without Rpt elements", () => {
59+
expect(parse("<Document></Document>")).toEqual([]);
60+
});
61+
62+
test("parses account identification from IBAN tag", () => {
63+
const [statement] = parse(MINIMAL_XML) as any[];
64+
expect(statement.accountIdentification).toBe("DE27100777770209299700");
65+
});
66+
67+
test("parses opening and closing balances", () => {
68+
const [statement] = parse(MINIMAL_XML);
69+
expect(statement.openingBalance?.value).toBe(1000.0);
70+
expect(statement.openingBalance?.currency).toBe("EUR");
71+
expect(statement.closingBalance?.value).toBe(950.0);
72+
});
73+
74+
test("parses correct number of transactions", () => {
75+
const [statement] = parse(MINIMAL_XML);
76+
expect(statement.transactions).toHaveLength(2);
77+
});
78+
79+
describe("credit transaction", () => {
80+
test("sets isCredit and isExpense correctly", () => {
81+
const [{ transactions }] = parse(MINIMAL_XML);
82+
const tx = transactions[0];
83+
expect(tx.isCredit).toBe(true);
84+
expect(tx.isExpense).toBe(false);
85+
});
86+
87+
test("sets amount and currency", () => {
88+
const [{ transactions }] = parse(MINIMAL_XML);
89+
const tx = transactions[0];
90+
expect(tx.amount).toBe(100.0);
91+
expect(tx.currency).toBe("EUR");
92+
});
93+
94+
test("sets id from AcctSvcrRef", () => {
95+
const [{ transactions }] = parse(MINIMAL_XML);
96+
expect(transactions[0].id).toBe("TXN-CREDIT");
97+
});
98+
99+
test("sets name from debtor for credit transactions", () => {
100+
const [{ transactions }] = parse(MINIMAL_XML);
101+
expect(transactions[0].name).toBe("Jane Sender");
102+
});
103+
104+
test("sets description from Ustrd", () => {
105+
const [{ transactions }] = parse(MINIMAL_XML);
106+
expect(transactions[0].description).toBe("Invoice 42");
107+
});
108+
109+
test("sets bankReference equal to description", () => {
110+
const [{ transactions }] = parse(MINIMAL_XML);
111+
const tx = transactions[0];
112+
expect(tx.bankReference).toBe(tx.description);
113+
});
114+
115+
test("populates descriptionStructured", () => {
116+
const [{ transactions }] = parse(MINIMAL_XML);
117+
const structured = transactions[0].descriptionStructured;
118+
expect(structured.name).toBe("Jane Sender");
119+
expect(structured.text).toBe("Invoice 42");
120+
expect(structured.iban).toBe("DE12345678901234567890");
121+
expect(structured.bic).toBe("TESTBIC0");
122+
expect(structured.reference.endToEndRef).toBe("E2E-001");
123+
expect(structured.reference.mandateRef).toBe("MNDT-001");
124+
});
125+
});
126+
127+
describe("debit transaction", () => {
128+
test("sets isCredit and isExpense correctly", () => {
129+
const [{ transactions }] = parse(MINIMAL_XML);
130+
const tx = transactions[1];
131+
expect(tx.isCredit).toBe(false);
132+
expect(tx.isExpense).toBe(true);
133+
});
134+
135+
test("sets name from creditor for debit transactions", () => {
136+
const [{ transactions }] = parse(MINIMAL_XML);
137+
expect(transactions[1].name).toBe("ACME Corp");
138+
});
139+
140+
test("sets counterparty IBAN from creditor account", () => {
141+
const [{ transactions }] = parse(MINIMAL_XML);
142+
const structured = transactions[1].descriptionStructured;
143+
expect(structured.iban).toBe("DE98765432109876543210");
144+
expect(structured.bic).toBe("ACMEBIC0");
145+
});
146+
});
147+
148+
test("handles namespaced XML tags", () => {
149+
const xml = `<ns2:Document xmlns:ns2="urn:iso:std:iso:20022:tech:xsd:camt.052.001.08">
150+
<ns2:BkToCstmrAcctRpt>
151+
<ns2:Rpt>
152+
<ns2:Acct><ns2:Id><ns2:IBAN>DE99000000000000000000</ns2:IBAN></ns2:Id></ns2:Acct>
153+
<ns2:Ntry>
154+
<ns2:Amt Ccy="EUR">50.00</ns2:Amt>
155+
<ns2:CdtDbtInd>CRDT</ns2:CdtDbtInd>
156+
<ns2:AcctSvcrRef>NS-TXN</ns2:AcctSvcrRef>
157+
<ns2:BookgDt><ns2:Dt>2024-03-01</ns2:Dt></ns2:BookgDt>
158+
</ns2:Ntry>
159+
</ns2:Rpt>
160+
</ns2:BkToCstmrAcctRpt>
161+
</ns2:Document>`;
162+
const [statement] = parse(xml);
163+
expect(statement.accountIdentification).toBe("DE99000000000000000000");
164+
expect(statement.transactions).toHaveLength(1);
165+
expect(statement.transactions[0].amount).toBe(50.0);
166+
expect(statement.transactions[0].id).toBe("NS-TXN");
167+
});
168+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Statement, Transaction, StructuredDescription } from "./types";
2+
3+
function getTag(xml: string, tag: string): string | undefined {
4+
const match = xml.match(new RegExp(`<(?:[^:>]+:)?${tag}[^>]*>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tag}>`, "i"));
5+
return match?.[1]?.trim();
6+
}
7+
8+
function getAllTags(xml: string, tag: string): string[] {
9+
const results: string[] = [];
10+
const regex = new RegExp(`<(?:[^:>]+:)?${tag}[^>]*>([\\s\\S]*?)<\\/(?:[^:>]+:)?${tag}>`, "gi");
11+
let match: RegExpExecArray | null;
12+
while ((match = regex.exec(xml)) !== null) {
13+
results.push(match[1].trim());
14+
}
15+
return results;
16+
}
17+
18+
function getAttr(xml: string, tag: string, attr: string): string | undefined {
19+
const match = xml.match(new RegExp(`<(?:[^:>]+:)?${tag}[^>]*\\s${attr}="([^"]*)"`, "i"));
20+
return match?.[1];
21+
}
22+
23+
function parseDate(dateStr: string | undefined): Date {
24+
if (!dateStr) return new Date(0);
25+
return new Date(dateStr.trim());
26+
}
27+
28+
/**
29+
* Parse CAMT.052 XML into Statement objects compatible with the mt940-js Statement interface.
30+
*/
31+
export function parseCamt052(xml: string): Statement[] {
32+
const reports = getAllTags(xml, "Rpt");
33+
if (reports.length === 0) return [];
34+
35+
return reports.map((rpt) => {
36+
const ibanTag = getTag(rpt, "IBAN");
37+
const accountIdentification = ibanTag || getTag(rpt, "Id") || "";
38+
39+
const openingBalanceXml = getAllTags(rpt, "Bal").find(
40+
(b) => getTag(b, "Tp") && /OPBD|PRCD/.test(getTag(b, "Cd") || ""),
41+
);
42+
const closingBalanceXml = getAllTags(rpt, "Bal").find(
43+
(b) => getTag(b, "Tp") && /CLBD|CLAV/.test(getTag(b, "Cd") || ""),
44+
);
45+
46+
const openingBalance = openingBalanceXml
47+
? {
48+
date: parseDate(getTag(openingBalanceXml, "Dt")),
49+
currency: getAttr(openingBalanceXml, "Amt", "Ccy") || "EUR",
50+
value: parseFloat(getTag(openingBalanceXml, "Amt") || "0"),
51+
}
52+
: undefined;
53+
54+
const closingBalance = closingBalanceXml
55+
? {
56+
date: parseDate(getTag(closingBalanceXml, "Dt")),
57+
currency: getAttr(closingBalanceXml, "Amt", "Ccy") || "EUR",
58+
value: parseFloat(getTag(closingBalanceXml, "Amt") || "0"),
59+
}
60+
: undefined;
61+
62+
const ntryElements = getAllTags(rpt, "Ntry");
63+
const transactions: Transaction[] = ntryElements.map((ntry) => {
64+
const amtStr = getTag(ntry, "Amt");
65+
const currency = getAttr(ntry, "Amt", "Ccy") || "EUR";
66+
const amount = parseFloat(amtStr || "0");
67+
const cdtDbtInd = getTag(ntry, "CdtDbtInd") || "";
68+
const isCredit = cdtDbtInd.trim().toUpperCase() === "CRDT";
69+
const isReversal = (getTag(ntry, "RvslInd") || "").trim().toLowerCase() === "true";
70+
71+
const bookgDt = getTag(ntry, "BookgDt");
72+
const valDt = getTag(ntry, "ValDt");
73+
const date = parseDate(getTag(bookgDt || "", "Dt") || bookgDt);
74+
const valueDate = parseDate(getTag(valDt || "", "Dt") || valDt);
75+
76+
const acctSvcrRef = getTag(ntry, "AcctSvcrRef") || "";
77+
const txDtls = getTag(ntry, "TxDtls") || ntry;
78+
79+
const debtorName = getTag(getTag(txDtls, "Dbtr") || "", "Nm");
80+
const creditorName = getTag(getTag(txDtls, "Cdtr") || "", "Nm");
81+
const name = (isCredit ? debtorName : creditorName) || "";
82+
83+
const dbtrIban = getTag(getTag(txDtls, "DbtrAcct") || "", "IBAN");
84+
const cdtrIban = getTag(getTag(txDtls, "CdtrAcct") || "", "IBAN");
85+
const counterpartyIban = (isCredit ? dbtrIban : cdtrIban) || "";
86+
87+
const dbtrBic = getTag(getTag(txDtls, "DbtrAgt") || "", "BICFI") || getTag(getTag(txDtls, "DbtrAgt") || "", "BIC");
88+
const cdtrBic = getTag(getTag(txDtls, "CdtrAgt") || "", "BICFI") || getTag(getTag(txDtls, "CdtrAgt") || "", "BIC");
89+
const counterpartyBic = (isCredit ? dbtrBic : cdtrBic) || "";
90+
91+
const ustrd = getAllTags(txDtls, "Ustrd").join(" ");
92+
const addtlNtryInf = getTag(ntry, "AddtlNtryInf") || getTag(ntry, "AddtlTxInf") || "";
93+
const description = ustrd || addtlNtryInf;
94+
95+
const endToEndId = getTag(txDtls, "EndToEndId") || "";
96+
const mandateId = getTag(txDtls, "MndtId") || "";
97+
const creditorId = getTag(txDtls, "CdtrId") || "";
98+
99+
const descriptionStructured: Partial<StructuredDescription> = {
100+
name,
101+
text: description,
102+
iban: counterpartyIban,
103+
bic: counterpartyBic,
104+
primaNota: "",
105+
reference: {
106+
raw: description,
107+
endToEndRef: endToEndId || undefined,
108+
mandateRef: mandateId || undefined,
109+
creditorId: creditorId || undefined,
110+
},
111+
};
112+
113+
return {
114+
id: acctSvcrRef,
115+
date,
116+
valueDate,
117+
amount,
118+
isCredit,
119+
isExpense: !isCredit,
120+
isReversal,
121+
currency,
122+
name,
123+
description,
124+
bankReference: description,
125+
descriptionStructured,
126+
details: [name, description, endToEndId, mandateId].filter(Boolean).join("\n"),
127+
} as unknown as Transaction;
128+
});
129+
130+
return {
131+
accountIdentification,
132+
openingBalance,
133+
closingBalance,
134+
transactions,
135+
} as unknown as Statement;
136+
});
137+
}

0 commit comments

Comments
 (0)