diff --git a/src/dialog.ts b/src/dialog.ts index 99d54ab..1ab6a63 100644 --- a/src/dialog.ts +++ b/src/dialog.ts @@ -264,56 +264,80 @@ export class Dialog { return this.config.tanMediaName; } + /** + * Collects a response that the bank spreads over several messages. + * + * When the bank cannot fit a response into one message it answers with code 3040 plus + * a continuation mark. Repeating the order with that mark yields the next portion — + * as a COMPLETE, self-contained response segment, not as a byte-wise continuation of + * the previous one. A HICAZ follow-up, for example, repeats the account and the CAMT + * descriptor before carrying its own share of the statements. + * + * Every portion is therefore decoded on its own and all of them are placed into the + * response message the caller holds. Combining their payloads needs to know what the + * payload means — one MT940 stream continues, a list of CAMT documents is appended — + * so that step belongs to the interaction, which does it via `findAllSegments`. + */ private async handlePartedMessages( message: CustomerMessage, responseMessage: Message, interaction: CustomerInteraction, ) { - let partedSegment = responseMessage.findSegment(PARTED.Id); - - if (partedSegment) { - while (responseMessage.hasReturnCode(3040)) { - const answers = responseMessage.getBankAnswers(); - const segmentWithContinuation = message.segments.find( - (s) => s.header.segId === interaction.segId, - ) as SegmentWithContinuationMark; - if (!segmentWithContinuation) { - throw new Error( - `Response contains segment with further information, but corresponding segment could not be found or is not specified`, - ); - } + // ALL of them, not just the first: one bank message may well carry several + // response segments. Taking only the first left the rest sitting in the tree as + // PARTED, where `findAllSegments` cannot see them — lost without a trace. + const partedSegments = responseMessage.findAllSegments(PARTED.Id); - const answer = answers.find((a) => a.code === 3040); + if (partedSegments.length === 0) { + return; + } - if (!answer || !answer.params || answer.params.length === 0) { - throw new Error( - 'Expected bank answer to contain continuation mark parameters (code 3040)', - ); - } + // The message the caller holds — every portion has to end up in THIS one, not in + // the last one we happen to receive. + const callersMessage = responseMessage; + const rawPortions = partedSegments.map((segment) => segment.rawData); + + while (responseMessage.hasReturnCode(3040)) { + const answers = responseMessage.getBankAnswers(); + const segmentWithContinuation = message.segments.find( + (s) => s.header.segId === interaction.segId, + ) as SegmentWithContinuationMark; + if (!segmentWithContinuation) { + throw new Error( + `Response contains segment with further information, but corresponding segment could not be found or is not specified`, + ); + } - segmentWithContinuation.continuationMark = answer.params[0]; - const hnhbkSegment = message.findSegment(HNHBK.Id); - if (!hnhbkSegment) { - throw new Error('HNHBK segment not found in message'); - } - hnhbkSegment.msgNr = ++this.lastMessageNumber; - const nextResponseMessage = await this.httpClient.sendMessage(message); - const nextPartedSegment = nextResponseMessage.findSegment(PARTED.Id); - - if (nextPartedSegment) { - nextPartedSegment.rawData = - partedSegment.rawData + - nextPartedSegment.rawData.slice(nextPartedSegment.rawData.indexOf('+') + 1); - partedSegment = nextPartedSegment; - } + const answer = answers.find((a) => a.code === 3040); + + if (!answer || !answer.params || answer.params.length === 0) { + throw new Error('Expected bank answer to contain continuation mark parameters (code 3040)'); + } - responseMessage = nextResponseMessage; + segmentWithContinuation.continuationMark = answer.params[0]; + const hnhbkSegment = message.findSegment(HNHBK.Id); + if (!hnhbkSegment) { + throw new Error('HNHBK segment not found in message'); } + hnhbkSegment.msgNr = ++this.lastMessageNumber; + const nextResponseMessage = await this.httpClient.sendMessage(message); + rawPortions.push( + ...nextResponseMessage + .findAllSegments(PARTED.Id) + .map((segment) => segment.rawData), + ); - const completeSegment = decode(partedSegment.rawData); - const index = responseMessage.segments.indexOf(partedSegment); - responseMessage.segments.splice(index, 1, completeSegment); + responseMessage = nextResponseMessage; } + + // Every PARTED placeholder gives way to the decoded portions, at the position of + // the first one so the segment order stays intact. + const index = callersMessage.segments.indexOf(partedSegments[0]); + const withoutPlaceholders = callersMessage.segments.filter( + (segment) => segment.header.segId !== PARTED.Id, + ); + withoutPlaceholders.splice(index, 0, ...rawPortions.map((raw) => decode(raw))); + callersMessage.segments = withoutPlaceholders; } private checkEnded(response: ClientResponse) { diff --git a/src/interactions/creditcardStatementInteraction.ts b/src/interactions/creditcardStatementInteraction.ts index b8d122c..2a26b72 100644 --- a/src/interactions/creditcardStatementInteraction.ts +++ b/src/interactions/creditcardStatementInteraction.ts @@ -49,7 +49,16 @@ export class CreditCardStatementInteraction extends CustomerOrderInteraction { return parseFloat(valueFloatStr); } - const dikku = response.findSegment(DIKKU.Id); + // A response the bank spread over several messages arrives as several DIKKU + // segments. The balance is the same in each, the transactions are not. + const dikkuSegments = response.findAllSegments(DIKKU.Id); + const dikku = dikkuSegments[0] + ? { + ...dikkuSegments[0], + transactions: dikkuSegments.flatMap((segment) => segment.transactions ?? []), + } + : undefined; + if (dikku) { const creditDebit = dikku.balance.creditDebit; const balanceAmount = dikku.balance.amount.value * (creditDebit === 'D' ? -1 : 1); diff --git a/src/interactions/portfolioInteraction.ts b/src/interactions/portfolioInteraction.ts index ce74a13..50fbe65 100644 --- a/src/interactions/portfolioInteraction.ts +++ b/src/interactions/portfolioInteraction.ts @@ -72,17 +72,23 @@ export class PortfolioInteraction extends CustomerOrderInteraction { } handleResponse(response: Message, clientResponse: PortfolioResponse): void { - const hiwpdSegment = response.findSegment(HIWPD.Id); + // A response the bank spread over several messages arrives as several HIWPD + // segments carrying one continuous MT535 stream, so their payloads are joined. + const portfolioStatement = response + .findAllSegments(HIWPD.Id) + .map((segment) => segment.portfolioStatement) + .filter((statement) => !!statement) + .join(''); - if (hiwpdSegment?.portfolioStatement) { + if (portfolioStatement) { try { // Parse the MT535 data - const parser = new Mt535Parser(hiwpdSegment.portfolioStatement); + const parser = new Mt535Parser(portfolioStatement); clientResponse.portfolioStatement = parser.parse(); } catch (error) { console.warn('Failed to parse MT535 portfolio statement:', error); // Fallback: provide raw data if parsing fails - clientResponse.rawMT535Data = hiwpdSegment.portfolioStatement; + clientResponse.rawMT535Data = portfolioStatement; } } } diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts index 26bd133..224c342 100644 --- a/src/interactions/sepaAccountInteraction.ts +++ b/src/interactions/sepaAccountInteraction.ts @@ -43,9 +43,11 @@ export class SepaAccountInteraction extends CustomerOrderInteraction { } handleResponse(response: Message, clientResponse: SepaAccountResponse) { - const hispa = response.findSegment(HISPA.Id); - if (hispa) { - clientResponse.sepaAccounts = hispa.sepaAccounts || []; + // A response the bank spread over several messages arrives as several HISPA + // segments, each carrying its own share of the accounts. + const hispaSegments = response.findAllSegments(HISPA.Id); + if (hispaSegments.length > 0) { + clientResponse.sepaAccounts = hispaSegments.flatMap((segment) => segment.sepaAccounts ?? []); this.dialog?.config.bankingInformation.upd?.bankAccounts.forEach((bankAccount) => { bankAccount.isSepaAccount = false; diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index f0f0bfa..435cea0 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -47,12 +47,18 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction { } handleResponse(response: Message, clientResponse: StatementResponse) { - const hicaz = response.findSegment(HICAZ.Id); - if (hicaz?.bookedTransactions && hicaz.bookedTransactions.length > 0) { + // A response the bank spread over several messages arrives as several HICAZ + // segments, each carrying its own share of the CAMT documents. Taking only the + // first one would silently drop everything after it. + const camtMessages = response + .findAllSegments(HICAZ.Id) + .flatMap((segment) => segment.bookedTransactions ?? []); + + if (camtMessages.length > 0) { try { // Parse all CAMT messages (one per booking day) and combine statements const allStatements: Statement[] = []; - for (const camtMessage of hicaz.bookedTransactions) { + for (const camtMessage of camtMessages) { // The regex looks for the XML declaration `` // and checks if it contains the attribute encoding="UTF-8". // The 'i' flag makes the match case-insensitive (e.g., for "utf-8"). diff --git a/src/interactions/statementInteractionMT940.ts b/src/interactions/statementInteractionMT940.ts index 9209361..dadaf54 100644 --- a/src/interactions/statementInteractionMT940.ts +++ b/src/interactions/statementInteractionMT940.ts @@ -36,10 +36,18 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { } handleResponse(response: Message, clientResponse: StatementResponse) { - const hikaz = response.findSegment(HIKAZ.Id); - if (hikaz?.bookedTransactions) { + // A response the bank spread over several messages arrives as several HIKAZ + // segments. Unlike CAMT these carry one continuous MT940 stream, so their + // payloads are joined rather than listed. + const bookedTransactions = response + .findAllSegments(HIKAZ.Id) + .map((segment) => segment.bookedTransactions) + .filter((booked) => !!booked) + .join(''); + + if (bookedTransactions) { try { - const parser = new Mt940Parser(hikaz.bookedTransactions); + const parser = new Mt940Parser(bookedTransactions); clientResponse.statements = parser.parse(); } catch (error) { console.warn('MT940 parsing failed:', error); diff --git a/src/message.ts b/src/message.ts index 2ede9c7..4b4dfe7 100644 --- a/src/message.ts +++ b/src/message.ts @@ -105,7 +105,11 @@ export class Message { } static decodeSegment(text: string, partedResponseSegId?: string): Segment { - if (partedResponseSegId && text.startsWith(partedResponseSegId)) { + // The colon matters: a segment starts with `SEGID:number:version`, so a plain + // `startsWith` would also catch the parameter segment whose id merely begins the + // same way — HIEKAS when looking for HIEKA, HICAZS for HICAZ. Those would then be + // held back as PARTED and never decoded. + if (partedResponseSegId && text.startsWith(`${partedResponseSegId}:`)) { const partedSegment: PartedSegment = { header: { ...(SegmentDefinition.header.decode(text, 1) as SegmentHeader), diff --git a/src/tests/partedResponse.test.ts b/src/tests/partedResponse.test.ts new file mode 100644 index 0000000..5b0bdd9 --- /dev/null +++ b/src/tests/partedResponse.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BankingInformation } from '../bankingInformation.js'; +import type { BankTransaction } from '../bankTransaction.js'; +import { FinTSConfig } from '../config.js'; +import { Dialog } from '../dialog.js'; +import { StatementInteractionCAMT } from '../interactions/statementInteractionCAMT.js'; +import { CustomerOrderMessage, Message } from '../message.js'; +import { HICAZ, type HICAZSegment } from '../segments/HICAZ.js'; +import { HKCAZ, type HKCAZSegment } from '../segments/HKCAZ.js'; +import { registerSegments } from '../segments/registry.js'; + +vi.mock('../httpClient.js', () => ({ + HttpClient: class MockHttpClient { + constructor( + public url: string, + public debug = false, + public debugRaw = false, + ) {} + sendMessage = vi.fn(); + }, +})); + +registerSegments(); + +const CAMT_DESCRIPTOR = 'urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08'; + +/** + * A HICAZ segment as the bank sends it. Every portion of a parted response is a + * COMPLETE segment — it repeats account and descriptor before carrying its own share + * of the CAMT documents. + */ +function hicazText(...camtDocuments: string[]): string { + const booked = camtDocuments.map((doc) => `@${doc.length}@${doc}`).join(':'); + return `HICAZ:5:1+DE991234567123456:BANK12+${CAMT_DESCRIPTOR}+${booked}'`; +} + +function responseMessage(hicaz: string, withContinuation: boolean): Message { + const answers = withContinuation + ? "HIRMG:3:2+0010::Entgegengenommen.+3040::Es liegen weitere Umsaetze vor.:AUFSETZ_1'" + : "HIRMG:3:2+0010::Entgegengenommen.+0020::Abfrage erfolgreich.'"; + return Message.decode(`${answers}${hicaz}`, HICAZ.Id); +} + +describe('parted responses (bank answer code 3040)', () => { + let config: FinTSConfig; + let dialog: Dialog; + + beforeEach(() => { + const bankingInformation: BankingInformation = { + systemId: 'MOCK_SYSTEM_ID', + bankMessages: [], + bpd: { + version: 1, + bankId: '12030000', + bankName: 'Mock Bank', + countryCode: 280, + url: 'http://mock.bank.url', + allowedTransactions: [ + { transId: 'HKCAZ', tanRequired: false, versions: [1] } as BankTransaction, + ], + supportedTanMethods: [], + availableTanMethodIds: [], + maxTransactionsPerMessage: 1, + supportedLanguages: [], + supportedHbciVersions: [300], + }, + } as unknown as BankingInformation; + + config = FinTSConfig.fromBankingInformation( + 'PRODUCT', + '1.0', + bankingInformation, + 'user', + 'pin', + ); + dialog = new Dialog(config); + }); + + it('delivers every portion into the message the caller holds', async () => { + const first = responseMessage(hicazText('one'), true); + const second = responseMessage(hicazText('two', 'three'), false); + + vi.mocked(dialog.httpClient.sendMessage).mockResolvedValueOnce(second); + + const interaction = new StatementInteractionCAMT('123'); + const request = new CustomerOrderMessage(HKCAZ.Id, HICAZ.Id); + request.addSegment({ + header: { segId: HKCAZ.Id, segNr: 0, version: 1 }, + account: { iban: 'DE991234567123456', bic: 'BANK12' }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], + allAccounts: false, + } as HKCAZSegment); + + // biome-ignore lint/suspicious/noExplicitAny: reaching into the private collector on purpose + await (dialog as any).handlePartedMessages(request, first, interaction); + + // Before the fix this was a single unresolved PARTED segment and everything after + // the first portion was lost without a trace. + const segments = first.findAllSegments(HICAZ.Id); + expect(first.findAllSegments('PARTED')).toHaveLength(0); + expect(segments).toHaveLength(2); + expect(segments.flatMap((s) => s.bookedTransactions)).toEqual([ + 'one', + 'two', + 'three', + ]); + }); + + it('leaves an unparted response untouched', async () => { + const only = responseMessage(hicazText('one'), false); + + const interaction = new StatementInteractionCAMT('123'); + const request = new CustomerOrderMessage(HKCAZ.Id, HICAZ.Id); + request.addSegment({ + header: { segId: HKCAZ.Id, segNr: 0, version: 1 }, + account: { iban: 'DE991234567123456', bic: 'BANK12' }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], + allAccounts: false, + } as HKCAZSegment); + + // biome-ignore lint/suspicious/noExplicitAny: reaching into the private collector on purpose + await (dialog as any).handlePartedMessages(request, only, interaction); + + expect(dialog.httpClient.sendMessage).not.toHaveBeenCalled(); + const segments = only.findAllSegments(HICAZ.Id); + expect(segments).toHaveLength(1); + expect(segments[0].bookedTransactions).toEqual(['one']); + }); +}); + +describe('StatementInteractionCAMT with a parted response', () => { + it('parses the CAMT documents of every segment, not just the first', () => { + const camt = (id: string, amount: string) => + `` + + `${id}2026-07-01T10:00:00+02:00` + + `${id}DE991234567123456EUR` + + `PRCD1000.00` + + `CRDT
2026-06-30
` + + `CLBD990.00` + + `CRDT
2026-07-01
` + + `${amount}DBIT` + + `
2026-07-01
2026-07-01
` + + `TXN${id}` + + `Test ${id}` + + `
`; + + const message = Message.decode( + `${hicazText(camt('A', '10.00'))}${hicazText(camt('B', '20.00'))}`, + ); + expect(message.findAllSegments(HICAZ.Id)).toHaveLength(2); + + const interaction = new StatementInteractionCAMT('123'); + const clientResponse = { statements: [] } as never; + interaction.handleResponse(message, clientResponse); + + const transactions = ( + clientResponse as unknown as { statements: { transactions: unknown[] }[] } + ).statements.flatMap((s) => s.transactions); + expect(transactions).toHaveLength(2); + }); +}); + +describe('several response segments in one bank message', () => { + it('resolves every portion, not just the first', async () => { + // Eine Botschaft mit ZWEI HICAZ-Segmenten. Vorher wurde nur das erste aufgeloest; + // das zweite blieb als PARTED im Baum und war fuer findAllSegments unsichtbar. + const answers = "HIRMG:3:2+0010::Entgegengenommen.+0020::Abfrage erfolgreich.'"; + const message = Message.decode( + `${answers}${hicazText('one')}${hicazText('two')}`, + HICAZ.Id, + ); + expect(message.findAllSegments('PARTED')).toHaveLength(2); + + const dialog = new Dialog( + FinTSConfig.fromBankingInformation( + 'PRODUCT', + '1.0', + { + systemId: 'X', + bankMessages: [], + bpd: { + version: 1, + bankId: '12030000', + bankName: 'Mock', + countryCode: 280, + url: 'http://mock.bank.url', + allowedTransactions: [{ transId: 'HKCAZ', tanRequired: false, versions: [1] }], + supportedTanMethods: [], + availableTanMethodIds: [], + maxTransactionsPerMessage: 1, + supportedLanguages: [], + supportedHbciVersions: [300], + }, + // biome-ignore lint/suspicious/noExplicitAny: schlanker Mock + } as any, + 'user', + 'pin', + ), + ); + + const request = new CustomerOrderMessage(HKCAZ.Id, HICAZ.Id); + request.addSegment({ + header: { segId: HKCAZ.Id, segNr: 0, version: 1 }, + account: { iban: 'DE991234567123456', bic: 'BANK12' }, + acceptedCamtFormats: ['urn:iso:std:iso:20022:tech:xsd:camt.052.001.08'], + allAccounts: false, + } as HKCAZSegment); + + // biome-ignore lint/suspicious/noExplicitAny: private Sammelroutine, absichtlich + await (dialog as any).handlePartedMessages( + request, + message, + new StatementInteractionCAMT('123'), + ); + + expect(message.findAllSegments('PARTED')).toHaveLength(0); + const segments = message.findAllSegments(HICAZ.Id); + expect(segments).toHaveLength(2); + expect(segments.flatMap((s) => s.bookedTransactions)).toEqual([ + 'one', + 'two', + ]); + }); + + it('does not mistake a parameter segment for a response segment', () => { + // HICAZS begins like HICAZ. Without the colon in the comparison it would be held + // back as PARTED and never decoded — the same for HIEKAS/HIEKA, HIKAZS/HIKAZ. + const hicazs = + "HICAZS:16:1:4+1+1+0+450:N:N:urn?:iso?:std?:iso?:20022?:tech?:xsd?:camt.052.001.08'"; + const message = Message.decode(hicazs, HICAZ.Id); + expect(message.findAllSegments('PARTED')).toHaveLength(0); + expect(message.findAllSegments('HICAZS')).toHaveLength(1); + }); +});