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
100 changes: 62 additions & 38 deletions src/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PartedSegment>(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<PartedSegment>(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<HNHBKSegment>(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<PartedSegment>(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<HNHBKSegment>(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<PartedSegment>(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) {
Expand Down
11 changes: 10 additions & 1 deletion src/interactions/creditcardStatementInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,16 @@ export class CreditCardStatementInteraction extends CustomerOrderInteraction {
return parseFloat(valueFloatStr);
}

const dikku = response.findSegment<DIKKUSegment>(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<DIKKUSegment>(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);
Expand Down
14 changes: 10 additions & 4 deletions src/interactions/portfolioInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,23 @@ export class PortfolioInteraction extends CustomerOrderInteraction {
}

handleResponse(response: Message, clientResponse: PortfolioResponse): void {
const hiwpdSegment = response.findSegment<HIWPDSegment>(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<HIWPDSegment>(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;
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/interactions/sepaAccountInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ export class SepaAccountInteraction extends CustomerOrderInteraction {
}

handleResponse(response: Message, clientResponse: SepaAccountResponse) {
const hispa = response.findSegment<HISPASegment>(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<HISPASegment>(HISPA.Id);
if (hispaSegments.length > 0) {
clientResponse.sepaAccounts = hispaSegments.flatMap((segment) => segment.sepaAccounts ?? []);

this.dialog?.config.bankingInformation.upd?.bankAccounts.forEach((bankAccount) => {
bankAccount.isSepaAccount = false;
Expand Down
12 changes: 9 additions & 3 deletions src/interactions/statementInteractionCAMT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction {
}

handleResponse(response: Message, clientResponse: StatementResponse) {
const hicaz = response.findSegment<HICAZSegment>(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<HICAZSegment>(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 `<?xml ... ?>`
// and checks if it contains the attribute encoding="UTF-8".
// The 'i' flag makes the match case-insensitive (e.g., for "utf-8").
Expand Down
14 changes: 11 additions & 3 deletions src/interactions/statementInteractionMT940.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction {
}

handleResponse(response: Message, clientResponse: StatementResponse) {
const hikaz = response.findSegment<HIKAZSegment>(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<HIKAZSegment>(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);
Expand Down
6 changes: 5 additions & 1 deletion src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading
Loading