Skip to content

Commit 828bf79

Browse files
authored
Merge pull request #20 from as19git67:main
Fix HKCAZ segment encoding and camtParser
2 parents 318f391 + 4ee7274 commit 828bf79

6 files changed

Lines changed: 207 additions & 19 deletions

File tree

src/camtParser.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export class CamtParser {
353353
return String(current);
354354
}
355355
if (Array.isArray(current)) {
356-
return String(current.join(''));
356+
return String(current.join('\n'));
357357
}
358358
if (current && typeof current === 'object' && current !== null && '#text' in current) {
359359
return String((current as { '#text': unknown })['#text']);
@@ -488,9 +488,13 @@ export class CamtParser {
488488

489489
// Extract dates
490490
const bookingDate =
491-
this.getValueFromPath(entry, 'BookgDt.Dt') || this.getValueFromPath(entry, 'BookgDt');
491+
this.getValueFromPath(entry, 'BookgDt.DtTm') ||
492+
this.getValueFromPath(entry, 'BookgDt.Dt') ||
493+
this.getValueFromPath(entry, 'BookgDt');
492494
const valueDate =
493-
this.getValueFromPath(entry, 'ValDt.Dt') || this.getValueFromPath(entry, 'ValDt');
495+
this.getValueFromPath(entry, 'ValDt.DtTm') ||
496+
this.getValueFromPath(entry, 'ValDt.Dt') ||
497+
this.getValueFromPath(entry, 'ValDt');
494498

495499
const entryDate = bookingDate ? this.parseDate(bookingDate) : new Date();
496500
const parsedValueDate = valueDate ? this.parseDate(valueDate) : entryDate;
@@ -633,7 +637,24 @@ export class CamtParser {
633637
}
634638

635639
private parseDate(dateStr: string): Date {
636-
// Parse ISO date format (YYYY-MM-DD)
640+
let processedDateStr = dateStr;
641+
// Handle date-only with timezone, e.g., "2026-01-22+01:00"
642+
// The Date constructor may not parse this correctly, so we add a time part.
643+
if (/^\d{4}-\d{2}-\d{2}[+-]\d{2}:\d{2}$/.test(dateStr)) {
644+
processedDateStr = `${dateStr.substring(0, 10)}T00:00:00${dateStr.substring(10)}`;
645+
}
646+
647+
// Attempt to parse as a full ISO 8601 string first, which `new Date()` handles well.
648+
// This will correctly handle formats like "2023-10-26T10:00:00+02:00".
649+
const isoDate = new Date(processedDateStr);
650+
if (!Number.isNaN(isoDate.getTime())) {
651+
// Check if the date string contains time or timezone information to avoid misinterpreting YYYY-MM-DD
652+
if (processedDateStr.includes('T') || /[-+]\d{2}:\d{2}$/.test(processedDateStr)) {
653+
return isoDate;
654+
}
655+
}
656+
657+
// Fallback for date-only ISO format (YYYY-MM-DD)
637658
if (dateStr.length === 10 && dateStr.includes('-')) {
638659
return new Date(`${dateStr}T12:00:00`); // Set time to noon to avoid timezone issues
639660
}

src/dataGroups/CamtAccount.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { AlphaNumeric } from '../dataElements/AlphaNumeric.js';
2+
import { DataGroup } from './DataGroup.js';
3+
4+
export type CamtAccount = {
5+
iban?: string;
6+
bic?: string;
7+
};
8+
9+
export class CamtAccountGroup extends DataGroup {
10+
constructor(name: string, minCount = 0, maxCount = 1, minVersion?: number, maxVersion?: number) {
11+
super(
12+
name,
13+
[new AlphaNumeric('iban', 0, 1, 34), new AlphaNumeric('bic', 0, 1, 11)],
14+
minCount,
15+
maxCount,
16+
minVersion,
17+
maxVersion,
18+
);
19+
}
20+
}

src/interactions/statementInteractionCAMT.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,20 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction {
5353
// Parse all CAMT messages (one per booking day) and combine statements
5454
const allStatements: Statement[] = [];
5555
for (const camtMessage of hicaz.bookedTransactions) {
56-
const parser = new CamtParser(camtMessage);
56+
// The regex looks for the XML declaration `<?xml ... ?>`
57+
// and checks if it contains the attribute encoding="UTF-8".
58+
// The 'i' flag makes the match case-insensitive (e.g., for "utf-8").
59+
const isUtf8Encoded = /<\?xml[^>]*encoding="UTF-8"[^>]*\?>/i.test(camtMessage);
60+
61+
let xmlString: string = camtMessage;
62+
if (isUtf8Encoded) {
63+
// camtMessage is initially encoded as 'latin1' (ISO-8859-1), but actually contains UTF-8 data.
64+
// Therefore, we need to first convert it back to a buffer using 'latin1', and then decode it as 'utf8'.
65+
const intermediateBuffer = Buffer.from(camtMessage, 'latin1');
66+
xmlString = intermediateBuffer.toString('utf8');
67+
}
68+
69+
const parser = new CamtParser(xmlString);
5770
const statements = parser.parse();
5871
allStatements.push(...statements);
5972
}

src/segments/HKCAZ.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,13 @@ import { Dat } from '../dataElements/Dat.js';
33
import { Numeric } from '../dataElements/Numeric.js';
44
import { Text } from '../dataElements/Text.js';
55
import { YesNo } from '../dataElements/YesNo.js';
6+
import { type CamtAccount, CamtAccountGroup } from '../dataGroups/CamtAccount.js';
67
import { DataGroup } from '../dataGroups/DataGroup.js';
7-
import {
8-
type InternationalAccount,
9-
InternationalAccountGroup,
10-
} from '../dataGroups/InternationalAccount.js';
118
import type { SegmentWithContinuationMark } from '../segment.js';
129
import { SegmentDefinition } from '../segmentDefinition.js';
1310

1411
export type HKCAZSegment = SegmentWithContinuationMark & {
15-
account: InternationalAccount;
12+
account: CamtAccount;
1613
acceptedCamtFormats: string[];
1714
allAccounts: boolean;
1815
from?: Date;
@@ -31,7 +28,7 @@ export class HKCAZ extends SegmentDefinition {
3128
}
3229
version = HKCAZ.Version;
3330
elements = [
34-
new InternationalAccountGroup('account', 1, 1),
31+
new CamtAccountGroup('account', 1, 1),
3532
new DataGroup('acceptedCamtFormats', [new Text('camtFormat', 1, 99)], 1, 1), // Support multiple camt-formats
3633
new YesNo('allAccounts', 1, 1),
3734
new Dat('from', 0, 1),

src/tests/HKCAZ.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ describe('HKCAZ v1', () => {
1212
account: {
1313
iban: 'DE991234567123456',
1414
bic: 'BANK12',
15-
accountNumber: '123456',
16-
bank: { country: 280, bankId: '12030000' },
1715
},
1816
acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'],
1917
allAccounts: false,
@@ -22,7 +20,7 @@ describe('HKCAZ v1', () => {
2220
};
2321

2422
expect(encode(segment)).toBe(
25-
"HKCAZ:1:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'",
23+
"HKCAZ:1:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'",
2624
);
2725
});
2826

@@ -32,21 +30,19 @@ describe('HKCAZ v1', () => {
3230
account: {
3331
iban: 'DE991234567123456',
3432
bic: 'BANK12',
35-
accountNumber: '123456',
36-
bank: { country: 280, bankId: '12030000' },
3733
},
3834
acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'],
3935
allAccounts: true,
4036
};
4137

4238
expect(encode(segment)).toBe(
43-
"HKCAZ:2:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'",
39+
"HKCAZ:2:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+J'",
4440
);
4541
});
4642

4743
it('decode and encode roundtrip matches', () => {
4844
const text =
49-
"HKCAZ:0:1+DE991234567123456:BANK12:123456::280:12030000+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'";
45+
"HKCAZ:0:1+DE991234567123456:BANK12+urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08+N+20230101+20231231'";
5046
const segment = decode(text);
5147
expect(encode(segment)).toBe(text);
5248
});

src/tests/camtParser.test.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,7 @@ describe('CamtParser', () => {
892892
expect(transaction.customerReference).toBe('VG 2025 QUARTAL IV');
893893
expect(transaction.bankReference).toBe('TXN003');
894894
expect(transaction.purpose).toBe(
895-
'28,65EUR EREF: VG 2025 QUARTAL IV IBAN: DE12345678901234567891 BIC: BANKABC1XXX',
895+
'28,65EUR EREF: VG 2025 QUARTAL IV IBAN\n: DE12345678901234567891 BIC: BANKABC1XXX',
896896
);
897897
expect(transaction.remoteName).toBe('ABC Bank');
898898
expect(transaction.remoteAccountNumber).toBe('DE12345678901234567891');
@@ -924,4 +924,145 @@ describe('CamtParser', () => {
924924
expect(transaction.client).toBeUndefined();
925925
expect(transaction.textKeyExtension).toBeUndefined();
926926
});
927+
928+
it('should handle full iso date time in value date', () => {
929+
// this is an example from comdirect bank in 2026-01
930+
const camtXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
931+
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02">
932+
<BkToCstmrAcctRpt>
933+
<GrpHdr>
934+
<MsgId>BD5F4D36X95740C4B89D967367217C16</MsgId>
935+
<CreDtTm>2026-01-22T10:35:25.369+01:00</CreDtTm>
936+
<MsgPgntn>
937+
<PgNb>0</PgNb>
938+
<LastPgInd>true</LastPgInd>
939+
</MsgPgntn>
940+
</GrpHdr>
941+
<Rpt>
942+
<Id>563916B991DD4EB18894EF4ABB730A5C</Id>
943+
<FrToDt>
944+
<FrDtTm>2025-12-10T00:00:00.000+01:00</FrDtTm>
945+
<ToDtTm>2026-01-22T00:00:00.000+01:00</ToDtTm>
946+
</FrToDt>
947+
<Acct>
948+
<Id>
949+
<IBAN>DE06940594210000027227</IBAN>
950+
</Id>
951+
</Acct>
952+
<Bal>
953+
<Tp>
954+
<CdOrPrtry>
955+
<Cd>OPBD</Cd>
956+
</CdOrPrtry>
957+
</Tp>
958+
<Amt Ccy="EUR">94.010000000021</Amt>
959+
<CdtDbtInd>CRDT</CdtDbtInd>
960+
<Dt>
961+
<DtTm>2025-12-10T00:00:00.000+01:00</DtTm>
962+
</Dt>
963+
</Bal>
964+
<Bal>
965+
<Tp>
966+
<CdOrPrtry>
967+
<Cd>CLBD</Cd>
968+
</CdOrPrtry>
969+
</Tp>
970+
<Amt Ccy="EUR">101.960000000017</Amt>
971+
<CdtDbtInd>CRDT</CdtDbtInd>
972+
<Dt>
973+
<DtTm>2026-01-22T00:00:00.000+01:00</DtTm>
974+
</Dt>
975+
</Bal>
976+
<Ntry>
977+
<NtryRef>5J3C21XL0470L56V/39761</NtryRef>
978+
<Amt Ccy="EUR">101.5</Amt>
979+
<CdtDbtInd>DBIT</CdtDbtInd>
980+
<Sts>BOOK</Sts>
981+
<BookgDt>
982+
<Dt>2025-12-08-01:00</Dt>
983+
</BookgDt>
984+
<ValDt>
985+
<DtTm>2025-12-10T00:00:00.000-01:00</DtTm>
986+
</ValDt>
987+
<AcctSvcrRef>5J2C21XL0470L56V/39761</AcctSvcrRef>
988+
<BkTxCd>
989+
<Prtry>
990+
<Cd>005</Cd>
991+
<Issr></Issr>
992+
</Prtry>
993+
</BkTxCd>
994+
<NtryDtls>
995+
<TxDtls>
996+
<RltdPties>
997+
<Cdtr>
998+
<Nm>AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND</Nm>
999+
</Cdtr>
1000+
<CdtrAcct>
1001+
<Id/>
1002+
</CdtrAcct>
1003+
</RltdPties>
1004+
<RmtInf>
1005+
<Ustrd>028-1234567-XXXXXXX Amazon.de 2ABCD</Ustrd>
1006+
<Ustrd>EF9GFP28</Ustrd>
1007+
<Ustrd>End-to-End-Ref.:</Ustrd>
1008+
<Ustrd>2ABCDEF9GHIJKL28</Ustrd>
1009+
<Ustrd>CORE / Mandatsref.:</Ustrd>
1010+
<Ustrd>7829857lkklag</Ustrd>
1011+
<Ustrd>Gläubiger-ID:</Ustrd>
1012+
<Ustrd>DE24ABC00000123456</Ustrd>
1013+
</RmtInf>
1014+
</TxDtls>
1015+
</NtryDtls>
1016+
</Ntry>
1017+
</Rpt>
1018+
</BkToCstmrAcctRpt>
1019+
</Document>
1020+
`;
1021+
1022+
const parser = new CamtParser(camtXml);
1023+
const statements = parser.parse();
1024+
1025+
expect(statements).toHaveLength(1);
1026+
const statement = statements[0];
1027+
expect(statement.transactions).toHaveLength(1);
1028+
1029+
const transaction = statement.transactions[0];
1030+
1031+
// Check all Transaction fields filled by the parser
1032+
expect(transaction.amount).toBe(-101.5);
1033+
expect(transaction.customerReference).toBe('');
1034+
expect(transaction.bankReference).toBe('5J2C21XL0470L56V/39761');
1035+
expect(transaction.purpose).toBe(
1036+
'028-1234567-XXXXXXX Amazon.de 2ABCD\nEF9GFP28\nEnd-to-End-Ref.:\n2ABCDEF9GHIJKL28\nCORE / Mandatsref.:\n7829857lkklag\nGläubiger-ID:\nDE24ABC00000123456',
1037+
);
1038+
expect(transaction.remoteName).toBe('AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND');
1039+
expect(transaction.remoteAccountNumber).toBe('');
1040+
expect(transaction.remoteBankId).toBe('');
1041+
expect(transaction.e2eReference).toBe('');
1042+
1043+
// Check date fields
1044+
expect(transaction.valueDate).toBeInstanceOf(Date);
1045+
expect(transaction.valueDate.getFullYear()).toBe(2025);
1046+
expect(transaction.valueDate.getMonth()).toBe(11); // November (0-based)
1047+
expect(transaction.valueDate.getUTCDate()).toBe(10);
1048+
expect(transaction.entryDate).toBeInstanceOf(Date);
1049+
expect(transaction.entryDate.getFullYear()).toBe(2025);
1050+
expect(transaction.entryDate.getMonth()).toBe(11); // November (0-based)
1051+
expect(transaction.entryDate.getUTCDate()).toBe(8);
1052+
1053+
// Check transaction type and code fields
1054+
expect(transaction.fundsCode).toBe('DBIT');
1055+
expect(transaction.transactionType).toBe('');
1056+
expect(transaction.transactionCode).toBe('');
1057+
1058+
// Check additional information fields
1059+
expect(transaction.additionalInformation).toBe('');
1060+
expect(transaction.bookingText).toBe(''); // Should match additionalInformation
1061+
1062+
// Verify optional fields not set in this test
1063+
expect(transaction.primeNotesNr).toBeUndefined();
1064+
expect(transaction.remoteIdentifier).toBeUndefined();
1065+
expect(transaction.client).toBeUndefined();
1066+
expect(transaction.textKeyExtension).toBeUndefined();
1067+
});
9271068
});

0 commit comments

Comments
 (0)