Skip to content

Commit bb8bae9

Browse files
committed
fix: address PR review comments for CAMT.052
- Rewrite camt052-parser to use fast-xml-parser instead of regex, consistent with the existing v4 camt-parser; fixes balance date extraction for nested <Dt><Dt>YYYY-MM-DD</Dt></Dt> structure - Inject HKCAZ touchdown into each loop iteration in sendCamtStatementRequest to correctly handle paginated responses - Guard TanRequiredError handling with isDecoupledTan() so non-decoupled TANs are not silently consumed as push TAN challenges - Add balance date assertions to camt052-parser tests
1 parent 378b860 commit bb8bae9

3 files changed

Lines changed: 118 additions & 78 deletions

File tree

packages/fints/src/__tests__/test-camt052-parser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ describe("parseCamt052", () => {
6868
const [statement] = parse(MINIMAL_XML);
6969
expect(statement.openingBalance?.value).toBe(1000.0);
7070
expect(statement.openingBalance?.currency).toBe("EUR");
71+
expect(statement.openingBalance?.date).toEqual(new Date("2024-01-01"));
7172
expect(statement.closingBalance?.value).toBe(950.0);
73+
expect(statement.closingBalance?.date).toEqual(new Date("2024-01-02"));
7274
});
7375

7476
test("parses correct number of transactions", () => {

packages/fints/src/camt052-parser.ts

Lines changed: 114 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,137 @@
11
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());
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];
1420
}
15-
return results;
21+
return cur;
1622
}
1723

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];
24+
function getStr(obj: unknown, path: string): string | undefined {
25+
const v = getVal(obj, path);
26+
return v == null ? undefined : String(v);
2127
}
2228

2329
function parseDate(dateStr: string | undefined): Date {
2430
if (!dateStr) return new Date(0);
25-
return new Date(dateStr.trim());
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];
2650
}
2751

28-
/**
29-
* Parse CAMT.052 XML into Statement objects compatible with the mt940-js Statement interface.
30-
*/
3152
export function parseCamt052(xml: string): Statement[] {
32-
const reports = getAllTags(xml, "Rpt");
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+
3361
if (reports.length === 0) return [];
3462

3563
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");
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");
81103
const name = (isCredit ? debtorName : creditorName) || "";
82104

83-
const dbtrIban = getTag(getTag(txDtls, "DbtrAcct") || "", "IBAN");
84-
const cdtrIban = getTag(getTag(txDtls, "CdtrAcct") || "", "IBAN");
105+
const dbtrIban =
106+
getStr(txDtls, "RltdPties.DbtrAcct.Id.IBAN") || getStr(txDtls, "DbtrAcct.Id.IBAN");
107+
const cdtrIban =
108+
getStr(txDtls, "RltdPties.CdtrAcct.Id.IBAN") || getStr(txDtls, "CdtrAcct.Id.IBAN");
85109
const counterpartyIban = (isCredit ? dbtrIban : cdtrIban) || "";
86110

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");
111+
const dbtrBic =
112+
getStr(txDtls, "RltdAgts.DbtrAgt.FinInstnId.BICFI") ||
113+
getStr(txDtls, "RltdAgts.DbtrAgt.FinInstnId.BIC") ||
114+
getStr(txDtls, "DbtrAgt.FinInstnId.BICFI") ||
115+
getStr(txDtls, "DbtrAgt.FinInstnId.BIC");
116+
const cdtrBic =
117+
getStr(txDtls, "RltdAgts.CdtrAgt.FinInstnId.BICFI") ||
118+
getStr(txDtls, "RltdAgts.CdtrAgt.FinInstnId.BIC") ||
119+
getStr(txDtls, "CdtrAgt.FinInstnId.BICFI") ||
120+
getStr(txDtls, "CdtrAgt.FinInstnId.BIC");
89121
const counterpartyBic = (isCredit ? dbtrBic : cdtrBic) || "";
90122

91-
const ustrd = getAllTags(txDtls, "Ustrd").join(" ");
92-
const addtlNtryInf = getTag(ntry, "AddtlNtryInf") || getTag(ntry, "AddtlTxInf") || "";
123+
const rmtInf = getVal(txDtls, "RmtInf") as Record<string, unknown> | undefined;
124+
const ustrdArr: string[] = rmtInf ? ensureArray(rmtInf.Ustrd as string[]).map(String) : [];
125+
const ustrd = ustrdArr.join(" ");
126+
const addtlNtryInf = getStr(ntry, "AddtlNtryInf") || getStr(ntry, "AddtlTxInf") || "";
93127
const description = ustrd || addtlNtryInf;
94128

95-
const endToEndId = getTag(txDtls, "EndToEndId") || "";
96-
const mandateId = getTag(txDtls, "MndtId") || "";
97-
const creditorId = getTag(txDtls, "CdtrId") || "";
129+
const endToEndId = getStr(txDtls, "Refs.EndToEndId") || "";
130+
const mandateId = getStr(txDtls, "Refs.MndtId") || "";
131+
const creditorId =
132+
getStr(txDtls, "RltdPties.Cdtr.Pty.Id.PrvtId.Othr.Id") ||
133+
getStr(txDtls, "RltdPties.Cdtr.Id.PrvtId.Othr.Id") ||
134+
"";
98135

99136
const descriptionStructured: Partial<StructuredDescription> = {
100137
name,
@@ -129,8 +166,8 @@ export function parseCamt052(xml: string): Statement[] {
129166

130167
return {
131168
accountIdentification,
132-
openingBalance,
133-
closingBalance,
169+
openingBalance: toBalance(openingBal),
170+
closingBalance: toBalance(closingBal),
134171
transactions,
135172
} as unknown as Statement;
136173
});

packages/fints/src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,12 +397,13 @@ export abstract class Client {
397397
let touchdown: string;
398398
const responses: Response[] = [];
399399
do {
400+
(segments[0] as HKCAZ).touchdown = touchdown;
400401
const request = this.createRequest(dialog, segments);
401402
let response: Response;
402403
try {
403404
response = await dialog.send(request);
404405
} catch (error) {
405-
if (error instanceof TanRequiredError) {
406+
if (error instanceof TanRequiredError && error.isDecoupledTan()) {
406407
// 0030: standard decoupled TAN challenge
407408
response = await dialog.handleDecoupledTan(
408409
error.transactionReference,

0 commit comments

Comments
 (0)