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/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/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/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..d1d0a7f --- /dev/null +++ b/src/segments/HIEKA.ts @@ -0,0 +1,68 @@ +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 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'; + 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, 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, 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/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..f759fbf --- /dev/null +++ b/src/segments/HKEKA.ts @@ -0,0 +1,69 @@ +import { AlphaNumeric } from '../dataElements/AlphaNumeric.js'; +import { Numeric } from '../dataElements/Numeric.js'; +import { type Account, AccountGroup } from '../dataGroups/Account.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 & { + /** 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, not available before version 3 */ + 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 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, 3), + 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..7a233c9 --- /dev/null +++ b/src/tests/HKEKA.test.ts @@ -0,0 +1,252 @@ +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'; +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('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'"; + 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); + }); +}); + +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); + }); +});