From b9e2b5d3c5f804cc48ebb7b4644131f634527df7 Mon Sep 17 00:00:00 2001 From: phkoenig Date: Tue, 28 Jul 2026 18:23:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20HKEKA/HIEKA=20=E2=80=94=20elektroni?= =?UTF-8?q?sche=20Kontoauszuege=20(Elektronischer=20Kontoauszug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetches the statement document the bank files in the customer's electronic mailbox (a PDF for most banks) instead of a list of transactions. For accounts where the bank has withdrawn DKKKU this is the only remaining automated way to get at credit card statements. HKEKA v5 request (SEG "Kontoauszug5") HIEKA v5 response (SEG "KontoauszugRes5") HIEKAS v5 parameters (SEG "KontoauszugPar5") Element order follows the FinTS 3.0 specification. It is worth spelling out because it is easy to get wrong: in HIEKA v5 `booked` sits SIXTH, after format/TimeRange/date/year/number — whereas in HIEKP v2 the same field comes first. Ordering it wrongly makes the segment silently unparseable. The request element is deliberately named `offset` and not `continuationMark`: for HKEKA the bank does not split one oversized response across messages, it announces with code 3040 that a *further document* is waiting. Reusing the `continuationMark` name would enlist the generic parted-message handling, which splices two complete HIEKA segments into one corrupt segment. Callers page through the mailbox with `nextOffset` instead. Verified against a live bank (Berliner Volksbank, HKEKA v5, tanRequired=false): six credit card statements and two current account documents retrieved, every PDF intact from %PDF header to %%EOF, paging terminating on the bank's last document. Co-Authored-By: Claude Opus 5 --- src/client.ts | 56 ++++++++ src/electronicStatement.ts | 51 +++++++ src/index.ts | 6 + .../electronicStatementInteraction.ts | 129 ++++++++++++++++++ src/segments/HIEKA.ts | 62 +++++++++ src/segments/HIEKAS.ts | 36 +++++ src/segments/HKEKA.ts | 66 +++++++++ src/segments/registry.ts | 6 + src/tests/HKEKA.test.ts | 104 ++++++++++++++ 9 files changed, 516 insertions(+) create mode 100644 src/electronicStatement.ts create mode 100644 src/interactions/electronicStatementInteraction.ts create mode 100644 src/segments/HIEKA.ts create mode 100644 src/segments/HIEKAS.ts create mode 100644 src/segments/HKEKA.ts create mode 100644 src/tests/HKEKA.test.ts diff --git a/src/client.ts b/src/client.ts index bb539a7..b1d6cea 100644 --- a/src/client.ts +++ b/src/client.ts @@ -10,6 +10,11 @@ import type { CustomerOrderInteraction, StatementResponse, } from './interactions/customerInteraction.js'; +import { + ElectronicStatementInteraction, + type ElectronicStatementOptions, + type ElectronicStatementResponse, +} from './interactions/electronicStatementInteraction.js'; import type { InitResponse } from './interactions/initDialogInteraction.js'; import { PortfolioInteraction, @@ -19,6 +24,7 @@ import { StatementInteractionCAMT } from './interactions/statementInteractionCAM import { StatementInteractionMT940 } from './interactions/statementInteractionMT940.js'; import { DKKKU } from './segments/DKKKU.js'; import { HKCAZ } from './segments/HKCAZ.js'; +import { HKEKA } from './segments/HKEKA.js'; import { HKIDN } from './segments/HKIDN.js'; import { HKKAZ } from './segments/HKKAZ.js'; import { HKSAL } from './segments/HKSAL.js'; @@ -284,6 +290,56 @@ export class FinTSClient { )) as StatementResponse; } + /** + * Checks if the bank supports fetching electronic account statements in general or for the given account number + * @param accountNumber when the account number is provided, checks if the account supports fetching of electronic statements + * @returns true if the bank (and account) supports fetching electronic account statements + */ + canGetElectronicStatements(accountNumber?: string): boolean { + return accountNumber + ? this.config.isAccountTransactionSupported(accountNumber, HKEKA.Id) + : this.config.isTransactionSupported(HKEKA.Id); + } + + /** + * Fetches an electronic account statement (Elektronischer Kontoauszug) for the given account number + * + * This returns the statement document the bank files in the customer's electronic mailbox, + * usually a PDF, not a list of transactions. The bank hands out one statement per call and + * announces a waiting successor in `nextOffset`; pass that value back in `options.offset` to + * fetch the next one. Banks that set `receiptRequired` in their HIEKAS parameters keep + * offering a statement until it has been acknowledged with its receipt. + * + * @param accountNumber - the account number to fetch the statement for, must be an account available in the config.bankingInformation.upd.accounts + * @param options - optional format, statement number and year, entry limit and offset + * @returns a response containing the statement documents and the offset of a waiting successor + */ + async getElectronicStatements( + accountNumber: string, + options?: ElectronicStatementOptions, + ): Promise { + return (await this.startCustomerOrderInteraction( + new ElectronicStatementInteraction(accountNumber, options), + )) as ElectronicStatementResponse; + } + + /** + * Continues the electronic account statement fetching when a TAN is required + * @param tanReference The TAN reference provided in the first call's response + * @param tan The TAN entered by the user, can be omitted if a decoupled TAN method is used + * @returns a response containing the statement documents + */ + async getElectronicStatementsWithTan( + tanReference: string, + tan?: string, + ): Promise { + return (await this.continueCustomerInteractionWithTan( + [HKEKA.Id], + tanReference, + tan, + )) as ElectronicStatementResponse; + } + private async startCustomerOrderInteraction( interaction: CustomerOrderInteraction, ): Promise { diff --git a/src/electronicStatement.ts b/src/electronicStatement.ts new file mode 100644 index 0000000..699bce9 --- /dev/null +++ b/src/electronicStatement.ts @@ -0,0 +1,51 @@ +/** + * An electronic account statement (Elektronischer Kontoauszug) as handed out by the bank. + * + * Unlike {@link Statement} this is not a list of parsed transactions but the statement + * document itself — the same document the bank files in the customer's electronic mailbox, + * usually a PDF. + */ +export type ElectronicStatement = { + /** The format of {@link document}, as announced by the bank in the HIEKAS parameters */ + format: string; + + /** Start of the period the statement covers */ + from?: Date; + + /** End of the period the statement covers */ + to?: Date; + + /** The date the statement was created by the bank */ + date?: Date; + + /** The year the statement number refers to, statement numbers restart every year */ + year?: number; + + /** The sequential number of the statement within its year */ + number?: number; + + /** The statement document itself */ + document: Uint8Array; + + /** Information about the closing of the accounting period, when the bank provides it */ + closingInfo?: string; + + /** Information about the conditions of the account, when the bank provides it */ + conditionsInfo?: string; + + /** Advertising text, when the bank provides it */ + advertisement?: string; + + iban?: string; + bic?: string; + + /** The account holder's name, joined from the up to three name lines the bank sends */ + accountName?: string; + + /** + * The receipt for this statement. When the bank requires acknowledgement + * (`receiptRequired` in the HIEKAS parameters), it only stops handing out a statement + * once it has been acknowledged with this receipt. + */ + receipt?: string; +}; diff --git a/src/index.ts b/src/index.ts index cd09275..66c53cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,19 @@ export * from './bpd.js'; export * from './client.js'; export * from './config.js'; export * from './dialog.js'; +export * from './electronicStatement.js'; export * from './httpClient.js'; export { AccountBalanceResponse } from './interactions/balanceInteraction.js'; export { ClientResponse, StatementResponse } from './interactions/customerInteraction.js'; +export { + ElectronicStatementOptions, + ElectronicStatementResponse, +} from './interactions/electronicStatementInteraction.js'; export { PortfolioResponse } from './interactions/portfolioInteraction.js'; export * from './message.js'; export * from './mt535parser.js'; export * from './mt940parser.js'; export * from './segment.js'; +export { StatementFormat } from './segments/HKEKA.js'; export * from './statement.js'; export * from './upd.js'; diff --git a/src/interactions/electronicStatementInteraction.ts b/src/interactions/electronicStatementInteraction.ts new file mode 100644 index 0000000..adc23ca --- /dev/null +++ b/src/interactions/electronicStatementInteraction.ts @@ -0,0 +1,129 @@ +import type { FinTSConfig } from '../config.js'; +import type { ElectronicStatement } from '../electronicStatement.js'; +import type { Message } from '../message.js'; +import type { Segment } from '../segment.js'; +import { HIEKA, type HIEKASegment } from '../segments/HIEKA.js'; +import type { HIEKASParameter } from '../segments/HIEKAS.js'; +import { HKEKA, type HKEKASegment, type StatementFormat } from '../segments/HKEKA.js'; +import { type ClientResponse, CustomerOrderInteraction } from './customerInteraction.js'; + +export interface ElectronicStatementResponse extends ClientResponse { + statements: ElectronicStatement[]; + /** + * The offset to pass to the next call when the bank announced further documents + * (answer code 3040), undefined when no more statements are waiting. + */ + nextOffset?: string; +} + +export interface ElectronicStatementOptions { + /** The format to request, defaults to the first format the bank announces in HIEKAS */ + format?: StatementFormat; + /** Fetch one specific statement, only allowed when the bank sets `indexAllowed` */ + number?: number; + /** The year the statement number refers to */ + year?: number; + maxEntries?: number; + /** The offset from a previous response's `nextOffset` */ + offset?: string; +} + +/** + * Turns the latin1 string the parser produced back into the bytes the bank sent. + */ +function toBytes(binary: string): Uint8Array { + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) & 0xff; + } + return bytes; +} + +const PDF_MAGIC = '%PDF'; + +/** + * Some banks base64-encode the document although the field is declared binary — a known + * quirk of HIEKP v1 that may apply here as well. + * + * This only unwraps when it can prove the result: the payload must consist of base64 + * characters only AND decode to something that actually starts with a PDF header. + * Anything else is passed through untouched, so a document is never silently mangled. + */ +function unwrapBase64(bytes: Uint8Array): Uint8Array { + const text = new TextDecoder('latin1').decode(bytes); + + if (text.startsWith(PDF_MAGIC) || !/^[A-Za-z0-9+/\s]+={0,2}\s*$/.test(text)) { + return bytes; + } + + try { + const decoded = Buffer.from(text, 'base64'); + return decoded.subarray(0, PDF_MAGIC.length).toString('latin1') === PDF_MAGIC + ? new Uint8Array(decoded) + : bytes; + } catch { + return bytes; + } +} + +export class ElectronicStatementInteraction extends CustomerOrderInteraction { + constructor( + public accountNumber: string, + public options: ElectronicStatementOptions = {}, + ) { + super(HKEKA.Id, HIEKA.Id); + } + + createSegments(init: FinTSConfig): Segment[] { + const bankAccount = init.getBankAccount(this.accountNumber); + const version = init.getMaxSupportedTransactionVersion(HKEKA.Id); + if (!version) { + throw Error(`There is no supported version for business transaction '${HKEKA.Id}'`); + } + + const params = init.getTransactionParameters(HKEKA.Id); + const format = + this.options.format ?? (params?.supportedFormats?.[0] as StatementFormat | undefined); + + const hkeka: HKEKASegment = { + header: { segId: HKEKA.Id, segNr: 0, version: version }, + account: bankAccount, + statementFormat: format, + statementNumber: this.options.number, + statementYear: this.options.year, + maxEntries: this.options.maxEntries, + offset: this.options.offset, + }; + + return [hkeka]; + } + + handleResponse(response: Message, clientResponse: ElectronicStatementResponse) { + const segments = response.findAllSegments(HIEKA.Id); + + clientResponse.statements = segments.map((hieka) => { + const names = [hieka.name, hieka.name2, hieka.name3].filter((name) => !!name); + + return { + format: hieka.format, + from: hieka.timeRange?.from, + to: hieka.timeRange?.to, + date: hieka.date, + year: hieka.year, + number: hieka.number, + document: unwrapBase64(toBytes(hieka.booked ?? '')), + closingInfo: hieka.closingInfo, + conditionsInfo: hieka.conditionsInfo, + advertisement: hieka.advertisement, + iban: hieka.iban, + bic: hieka.bic, + accountName: names.length > 0 ? names.join(' ') : undefined, + receipt: hieka.receipt, + }; + }); + + clientResponse.nextOffset = clientResponse.bankAnswers.find( + (answer) => answer.code === 3040, + )?.params?.[0]; + } +} diff --git a/src/segments/HIEKA.ts b/src/segments/HIEKA.ts new file mode 100644 index 0000000..21f4fad --- /dev/null +++ b/src/segments/HIEKA.ts @@ -0,0 +1,62 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { Binary } from '../dataElements/Binary.js'; +import { Dat } from '../dataElements/Dat.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { DataGroup } from '../dataGroups/DataGroup.js'; +import type { Segment } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; +import type { StatementFormat } from './HKEKA.js'; + +export type HIEKASegment = Segment & { + format: StatementFormat; + timeRange?: { from?: Date; to?: Date }; + date?: Date; + year?: number; + number?: number; + /** The statement document itself — a PDF when format is '3' */ + booked: string; + closingInfo?: string; + conditionsInfo?: string; + advertisement?: string; + iban?: string; + bic?: string; + name?: string; + name2?: string; + name3?: string; + /** Receipt to acknowledge the statement with, when the bank requires acknowledgement */ + receipt?: string; +}; + +/** + * Electronic account statement response (Elektronischer Kontoauszug) + * + * The element order follows segment "KontoauszugRes5" of the FinTS 3.0 specification: + * `booked` sits AFTER date/year/number, not before them. HIEKP v2 orders the same fields + * differently (there `booked` comes first) — the order is specific to each segment and + * cannot be carried over from one to the other. + */ +export class HIEKA extends SegmentDefinition { + static Id = 'HIEKA'; + static Version = 5; + constructor() { + super(HIEKA.Id); + } + version = HIEKA.Version; + elements = [ + new AlphaNumeric('format', 1, 1, 1), + new DataGroup('timeRange', [new Dat('from', 0, 1), new Dat('to', 0, 1)], 1, 1), + new Dat('date', 0, 1), + new Numeric('year', 0, 1, 4), + new Numeric('number', 0, 1, 5), + new Binary('booked', 1, 1), + new AlphaNumeric('closingInfo', 0, 1, 65536), + new AlphaNumeric('conditionsInfo', 0, 1, 65536), + new AlphaNumeric('advertisement', 0, 1, 65536), + new AlphaNumeric('iban', 0, 1, 34), + new AlphaNumeric('bic', 0, 1, 11), + new AlphaNumeric('name', 0, 1, 35), + new AlphaNumeric('name2', 0, 1, 35), + new AlphaNumeric('name3', 0, 1, 35), + new Binary('receipt', 0, 1), + ]; +} diff --git a/src/segments/HIEKAS.ts b/src/segments/HIEKAS.ts new file mode 100644 index 0000000..eed726a --- /dev/null +++ b/src/segments/HIEKAS.ts @@ -0,0 +1,36 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { YesNo } from '../dataElements/YesNo.js'; +import { + BusinessTransactionParameter, + type BusinessTransactionParameterSegment, +} from './businessTransactionParameter.js'; + +export type HIEKASSegment = BusinessTransactionParameterSegment; + +export type HIEKASParameter = { + /** Whether a specific statement may be requested by number and year */ + indexAllowed: boolean; + /** Whether the bank expects each fetched statement to be acknowledged with its receipt */ + receiptRequired: boolean; + /** Whether the number of entries may be limited */ + maxEntryCountAllowed: boolean; + /** The statement formats the bank supports, see StatementFormat */ + supportedFormats: string[]; +}; + +/** + * Parameters for the HKEKA business transaction (electronic account statements) + */ +export class HIEKAS extends BusinessTransactionParameter { + static Id = 'HIEKAS'; + version = 5; + + constructor() { + super(HIEKAS.Id, [ + new YesNo('indexAllowed', 1, 1), + new YesNo('receiptRequired', 1, 1), + new YesNo('maxEntryCountAllowed', 1, 1), + new AlphaNumeric('supportedFormats', 1, 9, 1), + ]); + } +} diff --git a/src/segments/HKEKA.ts b/src/segments/HKEKA.ts new file mode 100644 index 0000000..83d300f --- /dev/null +++ b/src/segments/HKEKA.ts @@ -0,0 +1,66 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { + type InternationalAccount, + InternationalAccountGroup, +} from '../dataGroups/InternationalAccount.js'; +import type { Segment } from '../segment.js'; +import { SegmentDefinition } from '../segmentDefinition.js'; + +/** + * Format of the electronic account statement. + * The bank announces the formats it supports in the HIEKAS parameters. + */ +export enum StatementFormat { + MT940 = '1', + ISO8583 = '2', + PDF = '3', +} + +export type HKEKASegment = Segment & { + account: InternationalAccount; + statementFormat?: StatementFormat; + /** The sequential number of the statement to fetch, only allowed when the bank sets `indexAllowed` */ + statementNumber?: number; + /** The year the statement number refers to */ + statementYear?: number; + maxEntries?: number; + /** + * The offset ("Aufsetzpunkt") the bank returned with answer code 3040, to fetch the + * next statement. + * + * This is deliberately NOT named `continuationMark`: for HKEKA the bank does not split + * one oversized response across messages, it announces that a *further document* is + * waiting. Naming it `continuationMark` would make the generic parted-message handling + * splice two complete HIEKA segments into one corrupt segment. + */ + offset?: string; +}; + +/** + * Request an electronic account statement (Elektronischer Kontoauszug) + * + * Unlike HKKAZ/HKCAZ this does not return individual transactions but the statement + * document the bank files in the customer's electronic mailbox — for most banks a PDF. + * + * A statement is handed out once: the bank keeps track of which statements have already + * been fetched and announces remaining ones with answer code 3040 plus an offset. Banks + * that set `receiptRequired` in their HIEKAS parameters expect each statement to be + * acknowledged before they consider it delivered. + */ +export class HKEKA extends SegmentDefinition { + static Id = 'HKEKA'; + static Version = 5; + constructor() { + super(HKEKA.Id); + } + version = HKEKA.Version; + elements = [ + new InternationalAccountGroup('account', 1, 1), + new AlphaNumeric('statementFormat', 0, 1, 1), + new Numeric('statementNumber', 0, 1, 5), + new Numeric('statementYear', 0, 1, 4), + new Numeric('maxEntries', 0, 1, 4), + new AlphaNumeric('offset', 0, 1, 35), + ]; +} diff --git a/src/segments/registry.ts b/src/segments/registry.ts index 9ba2dcf..2c6cd83 100644 --- a/src/segments/registry.ts +++ b/src/segments/registry.ts @@ -6,6 +6,8 @@ import { DKKKU } from './DKKKU.js'; import { HIBPA } from './HIBPA.js'; import { HICAZ } from './HICAZ.js'; import { HICAZS } from './HICAZS.js'; +import { HIEKA } from './HIEKA.js'; +import { HIEKAS } from './HIEKAS.js'; import { HIKAZ } from './HIKAZ.js'; import { HIKAZS } from './HIKAZS.js'; import { HIKIM } from './HIKIM.js'; @@ -24,6 +26,7 @@ import { HIUPA } from './HIUPA.js'; import { HIUPD } from './HIUPD.js'; import { HIWPD } from './HIWPD.js'; import { HKCAZ } from './HKCAZ.js'; +import { HKEKA } from './HKEKA.js'; import { HKEND } from './HKEND.js'; import { HKIDN } from './HKIDN.js'; import { HKKAZ } from './HKKAZ.js'; @@ -78,6 +81,9 @@ export function registerSegments() { registerSegmentDefinition(new HKCAZ()); registerSegmentDefinition(new HICAZ()); registerSegmentDefinition(new HICAZS()); + registerSegmentDefinition(new HKEKA()); + registerSegmentDefinition(new HIEKA()); + registerSegmentDefinition(new HIEKAS()); registerSegmentDefinition(new HKWPD()); registerSegmentDefinition(new HIWPD()); registerSegmentDefinition(new HKWPD()); diff --git a/src/tests/HKEKA.test.ts b/src/tests/HKEKA.test.ts new file mode 100644 index 0000000..c9f97ef --- /dev/null +++ b/src/tests/HKEKA.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { decode, encode } from '../segment.js'; +import type { HIEKASegment } from '../segments/HIEKA.js'; +import type { HIEKASSegment } from '../segments/HIEKAS.js'; +import { HKEKA, type HKEKASegment, StatementFormat } from '../segments/HKEKA.js'; +import { registerSegments } from '../segments/registry.js'; + +registerSegments(); + +describe('HKEKA v5', () => { + it('encode', () => { + const segment: HKEKASegment = { + header: { segId: HKEKA.Id, segNr: 3, version: 5 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + }, + statementFormat: StatementFormat.PDF, + }; + + expect(encode(segment)).toBe("HKEKA:3:5+DE991234567123456:BANK12+3'"); + }); + + it('encode with statement number, year and offset', () => { + const segment: HKEKASegment = { + header: { segId: HKEKA.Id, segNr: 3, version: 5 }, + account: { + iban: 'DE991234567123456', + bic: 'BANK12', + }, + statementFormat: StatementFormat.PDF, + statementNumber: 7, + statementYear: 2026, + offset: '20260530120235141058000_0', + }; + + expect(encode(segment)).toBe( + "HKEKA:3:5+DE991234567123456:BANK12+3+7+2026++20260530120235141058000_0'", + ); + }); + + it('decode and encode roundtrip matches', () => { + const text = "HKEKA:0:5+DE991234567123456:BANK12+3+7+2026++20260530120235141058000_0'"; + const segment = decode(text); + expect(encode(segment)).toBe(text); + }); +}); + +describe('HIEKA v5', () => { + // The element order is the whole risk of this segment: `booked` comes AFTER + // date/year/number, unlike HIEKP v2 where it comes first. + const text = + "HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+GmbH+i?:G?:+@4@abcd'"; + + it('decode', () => { + const segment = decode(text) as HIEKASegment; + + expect(segment.format).toBe(StatementFormat.PDF); + expect(segment.timeRange?.from).toEqual(new Date('2026-06-01')); + expect(segment.timeRange?.to).toEqual(new Date('2026-06-30')); + expect(segment.date).toEqual(new Date('2026-07-01')); + expect(segment.year).toBe(2026); + expect(segment.number).toBe(7); + expect(segment.booked).toBe('%PDF-1.7 '); + expect(segment.closingInfo).toBe('Abschluss'); + expect(segment.conditionsInfo).toBe('Konditionen'); + expect(segment.advertisement).toBe('Werbung'); + expect(segment.iban).toBe('DE991234567123456'); + expect(segment.bic).toBe('BANK12'); + expect(segment.name).toBe('Muster'); + expect(segment.name2).toBe('GmbH'); + expect(segment.name3).toBe('i:G:'); + expect(segment.receipt).toBe('abcd'); + }); + + it('decode and encode roundtrip matches', () => { + expect(encode(decode(text))).toBe(text); + }); + + it('decode without the optional elements', () => { + const segment = decode("HIEKA:5:5:3+3+20260601:20260630++++@9@%PDF-1.7 '") as HIEKASegment; + + expect(segment.format).toBe(StatementFormat.PDF); + expect(segment.timeRange?.from).toEqual(new Date('2026-06-01')); + expect(segment.date).toBeUndefined(); + expect(segment.number).toBeUndefined(); + expect(segment.booked).toBe('%PDF-1.7 '); + expect(segment.receipt).toBeUndefined(); + }); +}); + +describe('HIEKAS v5', () => { + it('decode and encode roundtrip matches', () => { + const text = "HIEKAS:4:5:4+1+1+0+J:N:J:1:3'"; + const segment = decode(text) as HIEKASSegment; + + expect(segment.params.indexAllowed).toBe(true); + expect(segment.params.receiptRequired).toBe(false); + expect(segment.params.maxEntryCountAllowed).toBe(true); + expect(segment.params.supportedFormats).toEqual(['1', '3']); + + expect(encode(segment)).toBe(text); + }); +}); From 6c856e446bc07cefa6eb5e4098d644b743852aa0 Mon Sep 17 00:00:00 2001 From: phkoenig Date: Tue, 28 Jul 2026 21:02:18 +0200 Subject: [PATCH 2/2] fix: gate HKEKA/HIEKA elements by version, honour declared binary length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by adversarial review of the HKEKA feature, both of the silent kind: no exception, success: true, wrong data. Version gating. The definitions described version 5 only, while getMaxSupportedTransactionVersion happily negotiates whatever the bank announces below that. Per the FinTS 3.0 segment catalogue, versions 1-3 of the request carry the national account connection (KTV3) rather than the international one, `year` does not exist before version 3, only version 5 of the response carries date/year/number, and version 1 has no iban/bic/name. A version 4 response decoded with the version 5 layout shifts every field by three positions and hands out the advertisement text as the statement document. The elements are now gated the way HKSAL and HKKAZ do it. Binary length. Binary.decode returned everything after the second '@' instead of the declared number of characters. The length is the only thing that tells payload apart from the separators and escape characters a PDF is full of. Fields without a length prefix keep their previous behaviour. That second fix also settled an open question: the fetched statements carry one 0x00 after their %%EOF, and it survives length-exact decoding — so the byte is inside the length the bank declares. It is the bank's, not ours. Tests cover encoding version 3 vs version 5, decoding version 1 and version 4, a payload containing +, ', @ and ?, a padded binary field, and the interaction itself: mapping, nextOffset, several statements per response, and the base64 unwrapping in both directions. Co-Authored-By: Claude Opus 5 --- README.md | 2 + src/dataElements/Binary.ts | 22 +++++- src/segments/HIEKA.ts | 30 +++++--- src/segments/HKEKA.ts | 11 ++- src/tests/HKEKA.test.ts | 148 +++++++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7825950..9949444 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ The following table shows all transactions supported by the FinTSClient interfac | **Account Statements** | `getAccountStatements(accountNumber, from?, to?)` | Fetches account transactions/statements for a date range (MT940 or CAMT format) | HKKAZ, HKCAZ | ✓ | ✓ | | **Portfolio** | `getPortfolio(accountNumber, currency?, priceQuality?, maxEntries?)` | Fetches securities portfolio information for depot accounts | HKWPD | ✓ | ✓ | | **Credit Card Statements** | `getCreditCardStatements(accountNumber, from?)` | Fetches credit card statements for credit card accounts | DKKKU | ✓ | ✓ | +| **Electronic Statements** | `getElectronicStatements(accountNumber, options?)` | Fetches the statement document from the electronic mailbox, usually a PDF | HKEKA | ✓ | ✓ | | **TAN Method Selection** | `selectTanMethod(tanMethodId)` | Selects a TAN method by ID from available methods | - | ❌ | ❌ | | **TAN Media Selection** | `selectTanMedia(tanMediaName)` | Selects a specific TAN media device by name | - | ❌ | ❌ | @@ -214,6 +215,7 @@ For each account-specific transaction, the client provides corresponding `can*` | `canGetAccountStatements(accountNumber?)` | Checks if account statements fetching is supported (MT940/CAMT) | | `canGetPortfolio(accountNumber?)` | Checks if portfolio information fetching is supported | | `canGetCreditCardStatements(accountNumber?)` | Checks if credit card statements fetching is supported | +| `canGetElectronicStatements(accountNumber?)` | Checks if electronic account statements fetching is supported | ### Transaction Parameters diff --git a/src/dataElements/Binary.ts b/src/dataElements/Binary.ts index 6f222c6..9b4c08f 100644 --- a/src/dataElements/Binary.ts +++ b/src/dataElements/Binary.ts @@ -24,7 +24,27 @@ export class Binary extends DataElement { return `@${value.length}@${value}`; } + /** + * A binary value arrives as `@@`. The length is authoritative: it is the + * only thing that tells data apart from the separators and escape characters that a + * binary payload — a PDF, for instance — is full of. Returning everything after the + * second `@` instead would hand out whatever the bank appended between the end of the + * data and the next separator. + */ decode(text: string) { - return text.slice(text.indexOf('@', 1) + 1); + if (text[0] !== '@') { + // Not length-prefixed — nothing to go by, take it as it is. + return text; + } + + const lengthEnd = text.indexOf('@', 1); + if (lengthEnd < 0) { + return text; + } + + const dataStart = lengthEnd + 1; + const length = Number.parseInt(text.slice(1, lengthEnd), 10); + + return Number.isNaN(length) ? text.slice(dataStart) : text.slice(dataStart, dataStart + length); } } diff --git a/src/segments/HIEKA.ts b/src/segments/HIEKA.ts index 21f4fad..d1d0a7f 100644 --- a/src/segments/HIEKA.ts +++ b/src/segments/HIEKA.ts @@ -30,10 +30,16 @@ export type HIEKASegment = Segment & { /** * Electronic account statement response (Elektronischer Kontoauszug) * - * The element order follows segment "KontoauszugRes5" of the FinTS 3.0 specification: - * `booked` sits AFTER date/year/number, not before them. HIEKP v2 orders the same fields - * differently (there `booked` comes first) — the order is specific to each segment and - * cannot be carried over from one to the other. + * The element order follows the FinTS 3.0 specification, and two details of it are easy + * to get wrong: + * + * - `booked` sits AFTER date/year/number, not before them. HIEKP v2 orders the same + * fields the other way round — the order is specific to each segment and cannot be + * carried over from one to the other. + * - Only version 5 carries date/year/number at all; up to version 4 `booked` follows + * the time range directly, and version 1 has no iban/bic/name either. Decoding an + * older response with the version 5 layout does not fail, it silently shifts every + * field by three positions and hands out the advertisement text as the document. */ export class HIEKA extends SegmentDefinition { static Id = 'HIEKA'; @@ -45,18 +51,18 @@ export class HIEKA extends SegmentDefinition { elements = [ new AlphaNumeric('format', 1, 1, 1), new DataGroup('timeRange', [new Dat('from', 0, 1), new Dat('to', 0, 1)], 1, 1), - new Dat('date', 0, 1), - new Numeric('year', 0, 1, 4), - new Numeric('number', 0, 1, 5), + new Dat('date', 0, 1, 5), + new Numeric('year', 0, 1, 4, 5), + new Numeric('number', 0, 1, 5, 5), new Binary('booked', 1, 1), new AlphaNumeric('closingInfo', 0, 1, 65536), new AlphaNumeric('conditionsInfo', 0, 1, 65536), new AlphaNumeric('advertisement', 0, 1, 65536), - new AlphaNumeric('iban', 0, 1, 34), - new AlphaNumeric('bic', 0, 1, 11), - new AlphaNumeric('name', 0, 1, 35), - new AlphaNumeric('name2', 0, 1, 35), - new AlphaNumeric('name3', 0, 1, 35), + new AlphaNumeric('iban', 0, 1, 34, 2), + new AlphaNumeric('bic', 0, 1, 11, 2), + new AlphaNumeric('name', 0, 1, 35, 2), + new AlphaNumeric('name2', 0, 1, 35, 2), + new AlphaNumeric('name3', 0, 1, 35, 2), new Binary('receipt', 0, 1), ]; } diff --git a/src/segments/HKEKA.ts b/src/segments/HKEKA.ts index 83d300f..f759fbf 100644 --- a/src/segments/HKEKA.ts +++ b/src/segments/HKEKA.ts @@ -1,5 +1,6 @@ import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; import { Numeric } from '../dataElements/Numeric.js'; +import { type Account, AccountGroup } from '../dataGroups/Account.js'; import { type InternationalAccount, InternationalAccountGroup, @@ -18,11 +19,12 @@ export enum StatementFormat { } export type HKEKASegment = Segment & { - account: InternationalAccount; + /** National account connection up to version 3, international from version 4 on */ + account: Account | InternationalAccount; statementFormat?: StatementFormat; /** The sequential number of the statement to fetch, only allowed when the bank sets `indexAllowed` */ statementNumber?: number; - /** The year the statement number refers to */ + /** The year the statement number refers to, not available before version 3 */ statementYear?: number; maxEntries?: number; /** @@ -56,10 +58,11 @@ export class HKEKA extends SegmentDefinition { } version = HKEKA.Version; elements = [ - new InternationalAccountGroup('account', 1, 1), + new AccountGroup('account', 1, 1, 1, 3), + new InternationalAccountGroup('account', 1, 1, 4), new AlphaNumeric('statementFormat', 0, 1, 1), new Numeric('statementNumber', 0, 1, 5), - new Numeric('statementYear', 0, 1, 4), + new Numeric('statementYear', 0, 1, 4, 3), new Numeric('maxEntries', 0, 1, 4), new AlphaNumeric('offset', 0, 1, 35), ]; diff --git a/src/tests/HKEKA.test.ts b/src/tests/HKEKA.test.ts index c9f97ef..7a233c9 100644 --- a/src/tests/HKEKA.test.ts +++ b/src/tests/HKEKA.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from 'vitest'; +import { + ElectronicStatementInteraction, + type ElectronicStatementResponse, +} from '../interactions/electronicStatementInteraction.js'; +import { Message } from '../message.js'; import { decode, encode } from '../segment.js'; import type { HIEKASegment } from '../segments/HIEKA.js'; import type { HIEKASSegment } from '../segments/HIEKAS.js'; @@ -89,6 +94,62 @@ describe('HIEKA v5', () => { }); }); +describe('HKEKA / HIEKA older versions', () => { + // Up to version 3 the request carries the national account connection, from version 4 + // on the international one; `year` does not exist before version 3. Getting this wrong + // puts an IBAN into the slot the bank reads as an account number. + it('encodes version 3 with the national account connection and no year', () => { + const segment: HKEKASegment = { + header: { segId: HKEKA.Id, segNr: 3, version: 3 }, + account: { accountNumber: '2759161761', bank: { country: 280, bankId: '10090000' } }, + statementFormat: StatementFormat.PDF, + statementNumber: 7, + }; + + expect(encode(segment)).toBe("HKEKA:3:3+2759161761::280:10090000+3+7'"); + }); + + it('encodes version 5 with the international account connection', () => { + const segment: HKEKASegment = { + header: { segId: HKEKA.Id, segNr: 3, version: 5 }, + account: { iban: 'DE991234567123456', bic: 'BANK12' }, + statementFormat: StatementFormat.PDF, + statementNumber: 7, + }; + + expect(encode(segment)).toBe("HKEKA:3:5+DE991234567123456:BANK12+3+7'"); + }); + + // Up to version 4 the response has no date/year/number — `booked` follows the time + // range directly. Decoded with the version 5 layout this does not throw, it hands out + // the advertisement text as the statement document. + it('decodes a version 4 response without shifting the document', () => { + const segment = decode( + "HIEKA:5:4+3+20260601:20260630+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+++@4@abcd'", + ) as HIEKASegment; + + expect(segment.booked).toBe('%PDF-1.7 '); + expect(segment.closingInfo).toBe('Abschluss'); + expect(segment.advertisement).toBe('Werbung'); + expect(segment.iban).toBe('DE991234567123456'); + expect(segment.date).toBeUndefined(); + expect(segment.year).toBeUndefined(); + expect(segment.number).toBeUndefined(); + expect(segment.receipt).toBe('abcd'); + }); + + it('decodes a version 1 response, which has no iban/bic/name', () => { + const segment = decode( + "HIEKA:5:1+3+20260601:20260630+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+@4@abcd'", + ) as HIEKASegment; + + expect(segment.booked).toBe('%PDF-1.7 '); + expect(segment.advertisement).toBe('Werbung'); + expect(segment.iban).toBeUndefined(); + expect(segment.receipt).toBe('abcd'); + }); +}); + describe('HIEKAS v5', () => { it('decode and encode roundtrip matches', () => { const text = "HIEKAS:4:5:4+1+1+0+J:N:J:1:3'"; @@ -102,3 +163,90 @@ describe('HIEKAS v5', () => { expect(encode(segment)).toBe(text); }); }); + +describe('binary payloads', () => { + // A PDF contains every character FinTS uses structurally: + and ' as separators, + // @ for binary markers, ? for escaping. Only the declared length keeps them apart + // from real syntax. + it('carries a payload containing FinTS control characters through unharmed', () => { + const pdf = "%PDF-1.7\n+ ' @ ? +++ '''\nstartxref\n%%EOF"; + const text = `HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${pdf.length}@${pdf}+++++++++`; + + const segment = decode(`${text}'`) as HIEKASegment; + + expect(segment.booked).toBe(pdf); + expect(segment.number).toBe(7); + }); + + it('honours the declared length instead of reading to the end of the field', () => { + // Some banks pad a binary field; the declared length is what counts. + const pdf = '%PDF-1.7 '; + const segment = decode( + `HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${pdf.length}@${pdf}\u0000+++++++++'`, + ) as HIEKASegment; + + expect(segment.booked).toBe(pdf); + expect(segment.booked).not.toContain('\u0000'); + }); +}); + +describe('ElectronicStatementInteraction', () => { + const pdf = '%PDF-1.7 fake'; + + function messageWith(hieka: string, continuation?: string): Message { + const answers = continuation + ? `HIRMG:3:2+0010::Entgegengenommen.+3040::Es liegen weitere Dokumente vor.:${continuation}'` + : "HIRMG:3:2+0010::Entgegengenommen.'"; + return Message.decode(`${answers}${hieka}`); + } + + function hiekaText(booked: string): string { + return ( + `HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${booked.length}@${booked}` + + `+Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+GmbH++@4@abcd'` + ); + } + + function handle(message: Message) { + const interaction = new ElectronicStatementInteraction('123'); + const clientResponse = { bankAnswers: message.getBankAnswers() } as never; + interaction.handleResponse(message, clientResponse); + return clientResponse as unknown as ElectronicStatementResponse; + } + + it('maps the response onto a statement', () => { + const response = handle(messageWith(hiekaText(pdf))); + + expect(response.statements).toHaveLength(1); + const statement = response.statements[0]; + expect(statement.format).toBe(StatementFormat.PDF); + expect(statement.year).toBe(2026); + expect(statement.number).toBe(7); + expect(statement.from).toEqual(new Date('2026-06-01')); + expect(statement.iban).toBe('DE991234567123456'); + expect(statement.accountName).toBe('Muster GmbH'); + expect(statement.receipt).toBe('abcd'); + expect(Buffer.from(statement.document).toString('latin1')).toBe(pdf); + }); + + it('reports the offset of a waiting successor and nothing otherwise', () => { + expect(handle(messageWith(hiekaText(pdf), 'AUFSETZ_1')).nextOffset).toBe('AUFSETZ_1'); + expect(handle(messageWith(hiekaText(pdf))).nextOffset).toBeUndefined(); + }); + + it('collects every statement the response carries', () => { + const message = messageWith(`${hiekaText(pdf)}${hiekaText('%PDF-1.7 second')}`); + expect(handle(message).statements).toHaveLength(2); + }); + + it('unwraps a base64 wrapped document, but only when it proves to be one', () => { + const wrapped = Buffer.from(pdf, 'latin1').toString('base64'); + const unwrapped = handle(messageWith(hiekaText(wrapped))).statements[0].document; + expect(Buffer.from(unwrapped).toString('latin1')).toBe(pdf); + + // Base64-looking text that does NOT decode to a document must survive untouched. + const notADocument = 'SGVsbG8gV29ybGQ='; + const kept = handle(messageWith(hiekaText(notADocument))).statements[0].document; + expect(Buffer.from(kept).toString('latin1')).toBe(notADocument); + }); +});