Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/camtParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -488,9 +488,13 @@ 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;
Expand Down Expand Up @@ -633,7 +637,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 (!Number.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
}
Expand Down
20 changes: 20 additions & 0 deletions src/dataGroups/CamtAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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,
);
}
}
15 changes: 14 additions & 1 deletion src/interactions/statementInteractionCAMT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,20 @@ 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);
// The regex looks for the XML declaration `<?xml ... ?>`
// and checks if it contains the attribute encoding="UTF-8".
// The 'i' flag makes the match case-insensitive (e.g., for "utf-8").
const isUtf8Encoded = /<\?xml[^>]*encoding="UTF-8"[^>]*\?>/i.test(camtMessage);

let xmlString: string = camtMessage;
if (isUtf8Encoded) {
// 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');
xmlString = intermediateBuffer.toString('utf8');
}

const parser = new CamtParser(xmlString);
const statements = parser.parse();
allStatements.push(...statements);
}
Expand Down
9 changes: 3 additions & 6 deletions src/segments/HKCAZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import { Dat } from '../dataElements/Dat.js';
import { Numeric } from '../dataElements/Numeric.js';
import { Text } from '../dataElements/Text.js';
import { YesNo } from '../dataElements/YesNo.js';
import { type CamtAccount, CamtAccountGroup } from '../dataGroups/CamtAccount.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';

export type HKCAZSegment = SegmentWithContinuationMark & {
account: InternationalAccount;
account: CamtAccount;
acceptedCamtFormats: string[];
allAccounts: boolean;
from?: Date;
Expand All @@ -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),
Expand Down
10 changes: 3 additions & 7 deletions src/tests/HKCAZ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'",
);
});

Expand All @@ -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);
});
Expand Down
143 changes: 142 additions & 1 deletion src/tests/camtParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,7 @@ describe('CamtParser', () => {
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',
'28,65EUR EREF: VG 2025 QUARTAL IV IBAN\n: DE12345678901234567891 BIC: BANKABC1XXX',
);
expect(transaction.remoteName).toBe('ABC Bank');
expect(transaction.remoteAccountNumber).toBe('DE12345678901234567891');
Expand Down Expand Up @@ -924,4 +924,145 @@ describe('CamtParser', () => {
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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.052.001.02">
<BkToCstmrAcctRpt>
<GrpHdr>
<MsgId>BD5F4D36X95740C4B89D967367217C16</MsgId>
<CreDtTm>2026-01-22T10:35:25.369+01:00</CreDtTm>
<MsgPgntn>
<PgNb>0</PgNb>
<LastPgInd>true</LastPgInd>
</MsgPgntn>
</GrpHdr>
<Rpt>
<Id>563916B991DD4EB18894EF4ABB730A5C</Id>
<FrToDt>
<FrDtTm>2025-12-10T00:00:00.000+01:00</FrDtTm>
<ToDtTm>2026-01-22T00:00:00.000+01:00</ToDtTm>
</FrToDt>
<Acct>
<Id>
<IBAN>DE06940594210000027227</IBAN>
</Id>
</Acct>
<Bal>
<Tp>
<CdOrPrtry>
<Cd>OPBD</Cd>
</CdOrPrtry>
</Tp>
<Amt Ccy="EUR">94.010000000021</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Dt>
<DtTm>2025-12-10T00:00:00.000+01:00</DtTm>
</Dt>
</Bal>
<Bal>
<Tp>
<CdOrPrtry>
<Cd>CLBD</Cd>
</CdOrPrtry>
</Tp>
<Amt Ccy="EUR">101.960000000017</Amt>
<CdtDbtInd>CRDT</CdtDbtInd>
<Dt>
<DtTm>2026-01-22T00:00:00.000+01:00</DtTm>
</Dt>
</Bal>
<Ntry>
<NtryRef>5J3C21XL0470L56V/39761</NtryRef>
<Amt Ccy="EUR">101.5</Amt>
<CdtDbtInd>DBIT</CdtDbtInd>
<Sts>BOOK</Sts>
<BookgDt>
<Dt>2025-12-08-01:00</Dt>
</BookgDt>
<ValDt>
<DtTm>2025-12-10T00:00:00.000-01:00</DtTm>
</ValDt>
<AcctSvcrRef>5J2C21XL0470L56V/39761</AcctSvcrRef>
<BkTxCd>
<Prtry>
<Cd>005</Cd>
<Issr></Issr>
</Prtry>
</BkTxCd>
<NtryDtls>
<TxDtls>
<RltdPties>
<Cdtr>
<Nm>AMAZON EU S.A R.L., NIEDERL ASSUNG DEUTSCHLAND</Nm>
</Cdtr>
<CdtrAcct>
<Id/>
</CdtrAcct>
</RltdPties>
<RmtInf>
<Ustrd>028-1234567-XXXXXXX Amazon.de 2ABCD</Ustrd>
<Ustrd>EF9GFP28</Ustrd>
<Ustrd>End-to-End-Ref.:</Ustrd>
<Ustrd>2ABCDEF9GHIJKL28</Ustrd>
<Ustrd>CORE / Mandatsref.:</Ustrd>
<Ustrd>7829857lkklag</Ustrd>
<Ustrd>Gläubiger-ID:</Ustrd>
<Ustrd>DE24ABC00000123456</Ustrd>
</RmtInf>
</TxDtls>
</NtryDtls>
</Ntry>
</Rpt>
</BkToCstmrAcctRpt>
</Document>
`;

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.getUTCDate()).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.getUTCDate()).toBe(8);

// 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();
});
});