Skip to content

Commit 5a1a885

Browse files
authored
Merge pull request #45 from dtrunk90/master
feat: add decoupled TAN and CAMT.052 support
2 parents dbe79a5 + e239208 commit 5a1a885

14 files changed

Lines changed: 670 additions & 26 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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.openingBalance?.date).toEqual(new Date("2024-01-01"));
72+
expect(statement.closingBalance?.value).toBe(950.0);
73+
expect(statement.closingBalance?.date).toEqual(new Date("2024-01-02"));
74+
});
75+
76+
test("parses correct number of transactions", () => {
77+
const [statement] = parse(MINIMAL_XML);
78+
expect(statement.transactions).toHaveLength(2);
79+
});
80+
81+
describe("credit transaction", () => {
82+
test("sets isCredit and isExpense correctly", () => {
83+
const [{ transactions }] = parse(MINIMAL_XML);
84+
const tx = transactions[0];
85+
expect(tx.isCredit).toBe(true);
86+
expect(tx.isExpense).toBe(false);
87+
});
88+
89+
test("sets amount and currency", () => {
90+
const [{ transactions }] = parse(MINIMAL_XML);
91+
const tx = transactions[0];
92+
expect(tx.amount).toBe(100.0);
93+
expect(tx.currency).toBe("EUR");
94+
});
95+
96+
test("sets id from AcctSvcrRef", () => {
97+
const [{ transactions }] = parse(MINIMAL_XML);
98+
expect(transactions[0].id).toBe("TXN-CREDIT");
99+
});
100+
101+
test("sets name from debtor for credit transactions", () => {
102+
const [{ transactions }] = parse(MINIMAL_XML);
103+
expect(transactions[0].name).toBe("Jane Sender");
104+
});
105+
106+
test("sets description from Ustrd", () => {
107+
const [{ transactions }] = parse(MINIMAL_XML);
108+
expect(transactions[0].description).toBe("Invoice 42");
109+
});
110+
111+
test("sets bankReference equal to description", () => {
112+
const [{ transactions }] = parse(MINIMAL_XML);
113+
const tx = transactions[0];
114+
expect(tx.bankReference).toBe(tx.description);
115+
});
116+
117+
test("populates descriptionStructured", () => {
118+
const [{ transactions }] = parse(MINIMAL_XML);
119+
const structured = transactions[0].descriptionStructured;
120+
expect(structured.name).toBe("Jane Sender");
121+
expect(structured.text).toBe("Invoice 42");
122+
expect(structured.iban).toBe("DE12345678901234567890");
123+
expect(structured.bic).toBe("TESTBIC0");
124+
expect(structured.reference.endToEndRef).toBe("E2E-001");
125+
expect(structured.reference.mandateRef).toBe("MNDT-001");
126+
});
127+
});
128+
129+
describe("debit transaction", () => {
130+
test("sets isCredit and isExpense correctly", () => {
131+
const [{ transactions }] = parse(MINIMAL_XML);
132+
const tx = transactions[1];
133+
expect(tx.isCredit).toBe(false);
134+
expect(tx.isExpense).toBe(true);
135+
});
136+
137+
test("sets name from creditor for debit transactions", () => {
138+
const [{ transactions }] = parse(MINIMAL_XML);
139+
expect(transactions[1].name).toBe("ACME Corp");
140+
});
141+
142+
test("sets counterparty IBAN from creditor account", () => {
143+
const [{ transactions }] = parse(MINIMAL_XML);
144+
const structured = transactions[1].descriptionStructured;
145+
expect(structured.iban).toBe("DE98765432109876543210");
146+
expect(structured.bic).toBe("ACMEBIC0");
147+
});
148+
});
149+
150+
test("handles namespaced XML tags", () => {
151+
const xml = `<ns2:Document xmlns:ns2="urn:iso:std:iso:20022:tech:xsd:camt.052.001.08">
152+
<ns2:BkToCstmrAcctRpt>
153+
<ns2:Rpt>
154+
<ns2:Acct><ns2:Id><ns2:IBAN>DE99000000000000000000</ns2:IBAN></ns2:Id></ns2:Acct>
155+
<ns2:Ntry>
156+
<ns2:Amt Ccy="EUR">50.00</ns2:Amt>
157+
<ns2:CdtDbtInd>CRDT</ns2:CdtDbtInd>
158+
<ns2:AcctSvcrRef>NS-TXN</ns2:AcctSvcrRef>
159+
<ns2:BookgDt><ns2:Dt>2024-03-01</ns2:Dt></ns2:BookgDt>
160+
</ns2:Ntry>
161+
</ns2:Rpt>
162+
</ns2:BkToCstmrAcctRpt>
163+
</ns2:Document>`;
164+
const [statement] = parse(xml);
165+
expect(statement.accountIdentification).toBe("DE99000000000000000000");
166+
expect(statement.transactions).toHaveLength(1);
167+
expect(statement.transactions[0].amount).toBe(50.0);
168+
expect(statement.transactions[0].id).toBe("NS-TXN");
169+
});
170+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { Statement, Transaction, StructuredDescription } from "./types";
2+
import { XMLParser } from "fast-xml-parser";
3+
4+
const parserOptions = {
5+
ignoreAttributes: false,
6+
attributeNamePrefix: "@_",
7+
parseAttributeValue: false,
8+
parseTagValue: false,
9+
trimValues: true,
10+
removeNSPrefix: true,
11+
isArray: (name: string) => ["Rpt", "Ntry", "Bal", "Ustrd", "TxDtls"].includes(name),
12+
};
13+
14+
function getVal(obj: unknown, path: string): unknown {
15+
const parts = path.split(".");
16+
let cur: unknown = obj;
17+
for (const p of parts) {
18+
if (cur == null || typeof cur !== "object") return undefined;
19+
cur = (cur as Record<string, unknown>)[p];
20+
}
21+
return cur;
22+
}
23+
24+
function getStr(obj: unknown, path: string): string | undefined {
25+
const v = getVal(obj, path);
26+
return v == null ? undefined : String(v);
27+
}
28+
29+
function parseDate(dateStr: string | undefined): Date {
30+
if (!dateStr) return new Date(0);
31+
const d = new Date(dateStr.trim());
32+
return isNaN(d.getTime()) ? new Date(0) : d;
33+
}
34+
35+
function parseAmount(amtNode: unknown): { value: number; currency: string } {
36+
if (amtNode == null) return { value: 0, currency: "EUR" };
37+
if (typeof amtNode === "object") {
38+
const obj = amtNode as Record<string, unknown>;
39+
return {
40+
value: parseFloat(String(obj["#text"] ?? 0)) || 0,
41+
currency: String(obj["@_Ccy"] ?? "EUR"),
42+
};
43+
}
44+
return { value: parseFloat(String(amtNode)) || 0, currency: "EUR" };
45+
}
46+
47+
function ensureArray<T>(value: T | T[] | undefined | null): T[] {
48+
if (value == null) return [];
49+
return Array.isArray(value) ? value : [value];
50+
}
51+
52+
export function parseCamt052(xml: string): Statement[] {
53+
if (!xml?.trim()) return [];
54+
55+
const parser = new XMLParser(parserOptions);
56+
const parsed = parser.parse(xml);
57+
const doc = parsed.Document || parsed;
58+
const report = doc.BkToCstmrAcctRpt || doc;
59+
const reports: Record<string, unknown>[] = ensureArray(report.Rpt);
60+
61+
if (reports.length === 0) return [];
62+
63+
return reports.map((rpt) => {
64+
const accountIdentification = getStr(rpt, "Acct.Id.IBAN") || getStr(rpt, "Acct.Id.Id") || "";
65+
66+
const balances: Record<string, unknown>[] = ensureArray(rpt.Bal as Record<string, unknown>[]);
67+
const openingBal = balances.find((b) => /OPBD|PRCD/.test(getStr(b, "Tp.CdOrPrtry.Cd") || ""));
68+
const closingBal = balances.find((b) => /CLBD|CLAV/.test(getStr(b, "Tp.CdOrPrtry.Cd") || ""));
69+
70+
const toBalance = (bal: Record<string, unknown> | undefined) => {
71+
if (!bal) return undefined;
72+
const { value, currency } = parseAmount(getVal(bal, "Amt"));
73+
const dateStr = getStr(bal, "Dt.Dt") || getStr(bal, "Dt.DtTm");
74+
return { date: parseDate(dateStr), currency, value };
75+
};
76+
77+
const entries: Record<string, unknown>[] = ensureArray(rpt.Ntry as Record<string, unknown>[]);
78+
79+
const transactions: Transaction[] = entries.map((ntry) => {
80+
const { value: amount, currency } = parseAmount(getVal(ntry, "Amt"));
81+
const cdtDbtInd = (getStr(ntry, "CdtDbtInd") || "").trim().toUpperCase();
82+
const isCredit = cdtDbtInd === "CRDT";
83+
const isReversal = (getStr(ntry, "RvslInd") || "").trim().toLowerCase() === "true";
84+
85+
const date = parseDate(getStr(ntry, "BookgDt.Dt") || getStr(ntry, "BookgDt.DtTm"));
86+
const valueDate = parseDate(getStr(ntry, "ValDt.Dt") || getStr(ntry, "ValDt.DtTm"));
87+
const acctSvcrRef = getStr(ntry, "AcctSvcrRef") || "";
88+
89+
const ntryDtls = getVal(ntry, "NtryDtls") as Record<string, unknown> | undefined;
90+
const txDtlsList: Record<string, unknown>[] = ntryDtls
91+
? ensureArray(ntryDtls.TxDtls as Record<string, unknown>[])
92+
: [];
93+
const txDtls = txDtlsList[0] as Record<string, unknown> | undefined;
94+
95+
const debtorName =
96+
getStr(txDtls, "RltdPties.Dbtr.Pty.Nm") ||
97+
getStr(txDtls, "RltdPties.Dbtr.Nm") ||
98+
getStr(txDtls, "Dbtr.Nm");
99+
const creditorName =
100+
getStr(txDtls, "RltdPties.Cdtr.Pty.Nm") ||
101+
getStr(txDtls, "RltdPties.Cdtr.Nm") ||
102+
getStr(txDtls, "Cdtr.Nm");
103+
const name = (isCredit ? debtorName : creditorName) || "";
104+
105+
const dbtrIban = getStr(txDtls, "RltdPties.DbtrAcct.Id.IBAN") || getStr(txDtls, "DbtrAcct.Id.IBAN");
106+
const cdtrIban = getStr(txDtls, "RltdPties.CdtrAcct.Id.IBAN") || getStr(txDtls, "CdtrAcct.Id.IBAN");
107+
const counterpartyIban = (isCredit ? dbtrIban : cdtrIban) || "";
108+
109+
const dbtrBic =
110+
getStr(txDtls, "RltdAgts.DbtrAgt.FinInstnId.BICFI") ||
111+
getStr(txDtls, "RltdAgts.DbtrAgt.FinInstnId.BIC") ||
112+
getStr(txDtls, "DbtrAgt.FinInstnId.BICFI") ||
113+
getStr(txDtls, "DbtrAgt.FinInstnId.BIC");
114+
const cdtrBic =
115+
getStr(txDtls, "RltdAgts.CdtrAgt.FinInstnId.BICFI") ||
116+
getStr(txDtls, "RltdAgts.CdtrAgt.FinInstnId.BIC") ||
117+
getStr(txDtls, "CdtrAgt.FinInstnId.BICFI") ||
118+
getStr(txDtls, "CdtrAgt.FinInstnId.BIC");
119+
const counterpartyBic = (isCredit ? dbtrBic : cdtrBic) || "";
120+
121+
const rmtInf = getVal(txDtls, "RmtInf") as Record<string, unknown> | undefined;
122+
const ustrdArr: string[] = rmtInf ? ensureArray(rmtInf.Ustrd as string[]).map(String) : [];
123+
const ustrd = ustrdArr.join(" ");
124+
const addtlNtryInf = getStr(ntry, "AddtlNtryInf") || getStr(ntry, "AddtlTxInf") || "";
125+
const description = ustrd || addtlNtryInf;
126+
127+
const endToEndId = getStr(txDtls, "Refs.EndToEndId") || "";
128+
const mandateId = getStr(txDtls, "Refs.MndtId") || "";
129+
const creditorId =
130+
getStr(txDtls, "RltdPties.Cdtr.Pty.Id.PrvtId.Othr.Id") ||
131+
getStr(txDtls, "RltdPties.Cdtr.Id.PrvtId.Othr.Id") ||
132+
"";
133+
134+
const descriptionStructured: Partial<StructuredDescription> = {
135+
name,
136+
text: description,
137+
iban: counterpartyIban,
138+
bic: counterpartyBic,
139+
primaNota: "",
140+
reference: {
141+
raw: description,
142+
endToEndRef: endToEndId || undefined,
143+
mandateRef: mandateId || undefined,
144+
creditorId: creditorId || undefined,
145+
},
146+
};
147+
148+
return {
149+
id: acctSvcrRef,
150+
date,
151+
valueDate,
152+
amount,
153+
isCredit,
154+
isExpense: !isCredit,
155+
isReversal,
156+
currency,
157+
name,
158+
description,
159+
bankReference: description,
160+
descriptionStructured,
161+
details: [name, description, endToEndId, mandateId].filter(Boolean).join("\n"),
162+
} as unknown as Transaction;
163+
});
164+
165+
return {
166+
accountIdentification,
167+
openingBalance: toBalance(openingBal),
168+
closingBalance: toBalance(closingBal),
169+
transactions,
170+
} as unknown as Statement;
171+
});
172+
}

0 commit comments

Comments
 (0)