From 3f2c65af6b6d844232765c9ef0fd00e83aab2b8b Mon Sep 17 00:00:00 2001 From: Anton Schegg Date: Thu, 22 Jan 2026 13:25:23 +0100 Subject: [PATCH 1/3] Fix HKCAZ encoding and camtParser --- src/camtParser.ts | 25 +++- src/dataGroups/CamtAccount.ts | 23 ++++ src/segments/HKCAZ.ts | 9 +- src/tests/HKCAZ.test.ts | 10 +- src/tests/camtParser.test.ts | 237 +++++++++++++++++++++++++++------- 5 files changed, 239 insertions(+), 65 deletions(-) create mode 100644 src/dataGroups/CamtAccount.ts diff --git a/src/camtParser.ts b/src/camtParser.ts index 4e286dd..57be21b 100644 --- a/src/camtParser.ts +++ b/src/camtParser.ts @@ -353,7 +353,7 @@ export class CamtParser { return String(current); } if (Array.isArray(current)) { - return String(current.join('')); + return String(current.join('\n')); } if (current && typeof current === 'object' && current !== null && '#text' in current) { return String((current as { '#text': unknown })['#text']); @@ -488,9 +488,9 @@ export class CamtParser { // Extract dates const bookingDate = - this.getValueFromPath(entry, 'BookgDt.Dt') || this.getValueFromPath(entry, 'BookgDt'); + this.getValueFromPath(entry, 'BookgDt.DtTm') || this.getValueFromPath(entry, 'BookgDt.Dt') || this.getValueFromPath(entry, 'BookgDt'); const valueDate = - this.getValueFromPath(entry, 'ValDt.Dt') || this.getValueFromPath(entry, 'ValDt'); + this.getValueFromPath(entry, 'ValDt.DtTm') || this.getValueFromPath(entry, 'ValDt.Dt') || this.getValueFromPath(entry, 'ValDt'); const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date(); const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate; @@ -633,7 +633,24 @@ export class CamtParser { } private parseDate(dateStr: string): Date { - // Parse ISO date format (YYYY-MM-DD) + let processedDateStr = dateStr; + // Handle date-only with timezone, e.g., "2026-01-22+01:00" + // The Date constructor may not parse this correctly, so we add a time part. + if (/^\d{4}-\d{2}-\d{2}[+-]\d{2}:\d{2}$/.test(dateStr)) { + processedDateStr = `${dateStr.substring(0, 10)}T00:00:00${dateStr.substring(10)}`; + } + + // Attempt to parse as a full ISO 8601 string first, which `new Date()` handles well. + // This will correctly handle formats like "2023-10-26T10:00:00+02:00". + const isoDate = new Date(processedDateStr); + if (!isNaN(isoDate.getTime())) { + // Check if the date string contains time or timezone information to avoid misinterpreting YYYY-MM-DD + if (processedDateStr.includes('T') || /[-+]\d{2}:\d{2}$/.test(processedDateStr)) { + return isoDate; + } + } + + // Fallback for date-only ISO format (YYYY-MM-DD) if (dateStr.length === 10 && dateStr.includes('-')) { return new Date(`${dateStr}T12:00:00`); // Set time to noon to avoid timezone issues } diff --git a/src/dataGroups/CamtAccount.ts b/src/dataGroups/CamtAccount.ts new file mode 100644 index 0000000..a295297 --- /dev/null +++ b/src/dataGroups/CamtAccount.ts @@ -0,0 +1,23 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { DataGroup } from './DataGroup.js'; + +export type CamtAccount = { + iban?: string; + bic?: string; +}; + +export class CamtAccountGroup extends DataGroup { + constructor(name: string, minCount = 0, maxCount = 1, minVersion?: number, maxVersion?: number) { + super( + name, + [ + new AlphaNumeric('iban', 0, 1, 34), + new AlphaNumeric('bic', 0, 1, 11), + ], + minCount, + maxCount, + minVersion, + maxVersion, + ); + } +} diff --git a/src/segments/HKCAZ.ts b/src/segments/HKCAZ.ts index cc15151..916bbf7 100644 --- a/src/segments/HKCAZ.ts +++ b/src/segments/HKCAZ.ts @@ -4,15 +4,12 @@ import { Numeric } from '../dataElements/Numeric.js'; import { Text } from '../dataElements/Text.js'; import { YesNo } from '../dataElements/YesNo.js'; import { DataGroup } from '../dataGroups/DataGroup.js'; -import { - type InternationalAccount, - InternationalAccountGroup, -} from '../dataGroups/InternationalAccount.js'; import type { SegmentWithContinuationMark } from '../segment.js'; import { SegmentDefinition } from '../segmentDefinition.js'; +import {CamtAccount, CamtAccountGroup} from "../dataGroups/CamtAccount.js"; export type HKCAZSegment = SegmentWithContinuationMark & { - account: InternationalAccount; + account: CamtAccount; acceptedCamtFormats: string[]; allAccounts: boolean; from?: Date; @@ -31,7 +28,7 @@ export class HKCAZ extends SegmentDefinition { } version = HKCAZ.Version; elements = [ - new InternationalAccountGroup('account', 1, 1), + new CamtAccountGroup('account', 1, 1), new DataGroup('acceptedCamtFormats', [new Text('camtFormat', 1, 99)], 1, 1), // Support multiple camt-formats new YesNo('allAccounts', 1, 1), new Dat('from', 0, 1), diff --git a/src/tests/HKCAZ.test.ts b/src/tests/HKCAZ.test.ts index d0c14d7..f63148a 100644 --- a/src/tests/HKCAZ.test.ts +++ b/src/tests/HKCAZ.test.ts @@ -12,8 +12,6 @@ describe('HKCAZ v1', () => { account: { iban: 'DE991234567123456', bic: 'BANK12', - accountNumber: '123456', - bank: { country: 280, bankId: '12030000' }, }, acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], allAccounts: false, @@ -22,7 +20,7 @@ describe('HKCAZ v1', () => { }; expect(encode(segment)).toBe( - "HKCAZ:1:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'", + "HKCAZ:1:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'", ); }); @@ -32,21 +30,19 @@ describe('HKCAZ v1', () => { account: { iban: 'DE991234567123456', bic: 'BANK12', - accountNumber: '123456', - bank: { country: 280, bankId: '12030000' }, }, acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], allAccounts: true, }; expect(encode(segment)).toBe( - "HKCAZ:2:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'", + "HKCAZ:2:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'", ); }); it('decode and encode roundtrip matches', () => { const text = - "HKCAZ:0:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'"; + "HKCAZ:0:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'"; const segment = decode(text); expect(encode(segment)).toBe(text); }); diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts index f745f63..2b06c87 100644 --- a/src/tests/camtParser.test.ts +++ b/src/tests/camtParser.test.ts @@ -729,8 +729,8 @@ describe('CamtParser', () => { expect(transaction.amount).toBe(200.0); }); - it('should handle multiple entries in RmtInf (Ustrd)', () => { - const camtXml = ` + it('should handle multiple entries in RmtInf (Ustrd)', () => { + const camtXml = ` @@ -878,50 +878,191 @@ describe('CamtParser', () => { `; - const parser = new CamtParser(camtXml); - const statements = parser.parse(); - - expect(statements).toHaveLength(1); - const statement = statements[0]; - expect(statement.transactions).toHaveLength(1); - - const transaction = statement.transactions[0]; - - // Check all Transaction fields filled by the parser - expect(transaction.amount).toBe(-179.46); - expect(transaction.customerReference).toBe('VG 2025 QUARTAL IV'); - expect(transaction.bankReference).toBe('TXN003'); - expect(transaction.purpose).toBe( - '28,65EUR EREF: VG 2025 QUARTAL IV IBAN: DE12345678901234567891 BIC: BANKABC1XXX', - ); - expect(transaction.remoteName).toBe('ABC Bank'); - expect(transaction.remoteAccountNumber).toBe('DE12345678901234567891'); - expect(transaction.remoteBankId).toBe('BANKABC1XXX'); - expect(transaction.e2eReference).toBe('VG 2025 QUARTAL IV'); - - // Check date fields - expect(transaction.valueDate).toBeInstanceOf(Date); - expect(transaction.valueDate.getFullYear()).toBe(2026); - expect(transaction.valueDate.getMonth()).toBe(0); // November (0-based) - expect(transaction.valueDate.getDate()).toBe(5); - expect(transaction.entryDate).toBeInstanceOf(Date); - expect(transaction.entryDate.getFullYear()).toBe(2026); - expect(transaction.entryDate.getMonth()).toBe(0); // November (0-based) - expect(transaction.entryDate.getDate()).toBe(5); - - // Check transaction type and code fields - expect(transaction.fundsCode).toBe('PMNT'); - expect(transaction.transactionType).toBe('ICDT'); - expect(transaction.transactionCode).toBe('ESCT'); - - // Check additional information fields - expect(transaction.additionalInformation).toBe('ENTGELT gem. Vereinbarung'); - expect(transaction.bookingText).toBe('ENTGELT gem. Vereinbarung'); // Should match additionalInformation - - // Verify optional fields not set in this test - expect(transaction.primeNotesNr).toBeUndefined(); - expect(transaction.remoteIdentifier).toBeUndefined(); - expect(transaction.client).toBeUndefined(); - expect(transaction.textKeyExtension).toBeUndefined(); - }); + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + const statement = statements[0]; + expect(statement.transactions).toHaveLength(1); + + const transaction = statement.transactions[0]; + + // Check all Transaction fields filled by the parser + expect(transaction.amount).toBe(-179.46); + expect(transaction.customerReference).toBe('VG 2025 QUARTAL IV'); + expect(transaction.bankReference).toBe('TXN003'); + expect(transaction.purpose).toBe( + '28,65EUR EREF: VG 2025 QUARTAL IV IBAN\n: DE12345678901234567891 BIC: BANKABC1XXX', + ); + expect(transaction.remoteName).toBe('ABC Bank'); + expect(transaction.remoteAccountNumber).toBe('DE12345678901234567891'); + expect(transaction.remoteBankId).toBe('BANKABC1XXX'); + expect(transaction.e2eReference).toBe('VG 2025 QUARTAL IV'); + + // Check date fields + expect(transaction.valueDate).toBeInstanceOf(Date); + expect(transaction.valueDate.getFullYear()).toBe(2026); + expect(transaction.valueDate.getMonth()).toBe(0); // November (0-based) + expect(transaction.valueDate.getDate()).toBe(5); + expect(transaction.entryDate).toBeInstanceOf(Date); + expect(transaction.entryDate.getFullYear()).toBe(2026); + expect(transaction.entryDate.getMonth()).toBe(0); // November (0-based) + expect(transaction.entryDate.getDate()).toBe(5); + + // Check transaction type and code fields + expect(transaction.fundsCode).toBe('PMNT'); + expect(transaction.transactionType).toBe('ICDT'); + expect(transaction.transactionCode).toBe('ESCT'); + + // Check additional information fields + expect(transaction.additionalInformation).toBe('ENTGELT gem. Vereinbarung'); + expect(transaction.bookingText).toBe('ENTGELT gem. Vereinbarung'); // Should match additionalInformation + + // Verify optional fields not set in this test + expect(transaction.primeNotesNr).toBeUndefined(); + expect(transaction.remoteIdentifier).toBeUndefined(); + expect(transaction.client).toBeUndefined(); + expect(transaction.textKeyExtension).toBeUndefined(); + }); + + it('should handle full iso date time in value date', () => { + // this is an example from comdirect bank in 2026-01 + const camtXml = ` + + + + BD5F4D36X95740C4B89D967367217C16 + 2026-01-22T10:35:25.369+01:00 + + 0 + true + + + + 563916B991DD4EB18894EF4ABB730A5C + + 2025-12-10T00:00:00.000+01:00 + 2026-01-22T00:00:00.000+01:00 + + + + DE06940594210000027227 + + + + + + OPBD + + + 94.010000000021 + CRDT +
+ 2025-12-10T00:00:00.000+01:00 +
+
+ + + + CLBD + + + 101.960000000017 + CRDT +
+ 2026-01-22T00:00:00.000+01:00 +
+
+ + 5J3C21XL0470L56V/39761 + 101.5 + DBIT + BOOK + +
2025-12-10+01:00
+
+ + 2025-12-10T00:00:00.000+01:00 + + 5J2C21XL0470L56V/39761 + + + 005 + + + + + + + + AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND + + + + + + + 028-1234567-XXXXXXX Amazon.de 2ABCD + EF9GFP28 + End-to-End-Ref.: + 2ABCDEF9GHIJKL28 + CORE / Mandatsref.: + 7829857lkklag + Gläubiger-ID: + DE24ABC00000123456 + + + +
+
+
+
+`; + + const parser = new CamtParser(camtXml); + const statements = parser.parse(); + + expect(statements).toHaveLength(1); + const statement = statements[0]; + expect(statement.transactions).toHaveLength(1); + + const transaction = statement.transactions[0]; + + // Check all Transaction fields filled by the parser + expect(transaction.amount).toBe(-101.5); + expect(transaction.customerReference).toBe(''); + expect(transaction.bankReference).toBe('5J2C21XL0470L56V/39761'); + expect(transaction.purpose).toBe( + '028-1234567-XXXXXXX Amazon.de 2ABCD\nEF9GFP28\nEnd-to-End-Ref.:\n2ABCDEF9GHIJKL28\nCORE / Mandatsref.:\n7829857lkklag\nGläubiger-ID:\nDE24ABC00000123456', + ); + expect(transaction.remoteName).toBe('AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND'); + expect(transaction.remoteAccountNumber).toBe(''); + expect(transaction.remoteBankId).toBe(''); + expect(transaction.e2eReference).toBe(''); + + // Check date fields + expect(transaction.valueDate).toBeInstanceOf(Date); + expect(transaction.valueDate.getFullYear()).toBe(2025); + expect(transaction.valueDate.getMonth()).toBe(11); // November (0-based) + expect(transaction.valueDate.getDate()).toBe(10); + expect(transaction.entryDate).toBeInstanceOf(Date); + expect(transaction.entryDate.getFullYear()).toBe(2025); + expect(transaction.entryDate.getMonth()).toBe(11); // November (0-based) + expect(transaction.entryDate.getDate()).toBe(10); + + // Check transaction type and code fields + expect(transaction.fundsCode).toBe('DBIT'); + expect(transaction.transactionType).toBe(''); + expect(transaction.transactionCode).toBe(''); + + // Check additional information fields + expect(transaction.additionalInformation).toBe(''); + expect(transaction.bookingText).toBe(''); // Should match additionalInformation + + // Verify optional fields not set in this test + expect(transaction.primeNotesNr).toBeUndefined(); + expect(transaction.remoteIdentifier).toBeUndefined(); + expect(transaction.client).toBeUndefined(); + expect(transaction.textKeyExtension).toBeUndefined(); + }); }); From 702411e24262e433779ae1f6e57bcacce04f695d Mon Sep 17 00:00:00 2001 From: Anton Schegg Date: Thu, 22 Jan 2026 15:29:51 +0100 Subject: [PATCH 2/3] re-decode camtMessage as UTF-8 --- src/interactions/statementInteractionCAMT.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index a6dcb67..d2a85e9 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -53,7 +53,13 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction { // Parse all CAMT messages (one per booking day) and combine statements const allStatements: Statement[] = []; for (const camtMessage of hicaz.bookedTransactions) { - const parser = new CamtParser(camtMessage); + + // camtMessage is initially encoded as 'latin1' (ISO-8859-1), but actually contains UTF-8 data. + // Therefore, we need to first convert it back to a buffer using 'latin1', and then decode it as 'utf8'. + const intermediateBuffer = Buffer.from(camtMessage, 'latin1'); + const utf8String = intermediateBuffer.toString('utf8'); + + const parser = new CamtParser(utf8String); const statements = parser.parse(); allStatements.push(...statements); } From 075184287035e2e3579391eb9ccab6bb036a5046 Mon Sep 17 00:00:00 2001 From: Anton Schegg Date: Thu, 22 Jan 2026 15:33:03 +0100 Subject: [PATCH 3/3] Corrected wrongly converted umlaut in test --- src/tests/camtParser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/camtParser.test.ts b/src/tests/camtParser.test.ts index 2b06c87..22d0775 100644 --- a/src/tests/camtParser.test.ts +++ b/src/tests/camtParser.test.ts @@ -1008,7 +1008,7 @@ describe('CamtParser', () => { 2ABCDEF9GHIJKL28 CORE / Mandatsref.: 7829857lkklag - Gläubiger-ID: + Gläubiger-ID: DE24ABC00000123456 @@ -1033,7 +1033,7 @@ describe('CamtParser', () => { expect(transaction.customerReference).toBe(''); expect(transaction.bankReference).toBe('5J2C21XL0470L56V/39761'); expect(transaction.purpose).toBe( - '028-1234567-XXXXXXX Amazon.de 2ABCD\nEF9GFP28\nEnd-to-End-Ref.:\n2ABCDEF9GHIJKL28\nCORE / Mandatsref.:\n7829857lkklag\nGläubiger-ID:\nDE24ABC00000123456', + '028-1234567-XXXXXXX Amazon.de 2ABCD\nEF9GFP28\nEnd-to-End-Ref.:\n2ABCDEF9GHIJKL28\nCORE / Mandatsref.:\n7829857lkklag\nGläubiger-ID:\nDE24ABC00000123456', ); expect(transaction.remoteName).toBe('AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND'); expect(transaction.remoteAccountNumber).toBe('');