From 0d19b2e22d313ce3740d34539c82541cbe3fe216 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:09:00 +0200 Subject: [PATCH 1/6] Address an account by the account, and refuse to guess when a number is shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getBankAccount` resolved a number with `.find()` and returned the first match. An account number is not unique: FinTS identifies an account by number *and* sub-account id, and banks use that — a securities account and the current account it settles through commonly share a number. Where that happens the second account was unreachable, and the failure was silent. Measured against a bank that does it, asking the securities account for its balance returned the current account's balance with return code 20 and nothing in the response to say which account had answered; asking for its transactions returned the current account's 190 transactions. Only the portfolio request failed loudly, and only because the account it resolved to does not declare HKWPD. Two changes, both additive: - Every method that took `accountNumber: string` now takes `AccountRef`, which is a number or a `BankAccount` from `bankingInformation.upd.bankAccounts`. Passing the account is what makes the second one reachable. It is resolved against the UPD rather than trusted as handed over, so a caller holding an account from a persisted earlier session still gets the entry with the current allowed transactions. - A number that matches more than one account now throws, naming the sub-account ids and what to pass instead. This is the one behaviour change: callers whose numbers are unique see nothing different, and callers whose numbers are not were getting another account's data. Co-Authored-By: Claude Opus 5 (1M context) --- src/bankAccount.ts | 20 +++ src/client.ts | 81 ++++++------ src/config.ts | 61 +++++++-- src/interactions/balanceInteraction.ts | 11 +- .../creditcardStatementInteraction.ts | 11 +- .../electronicStatementInteraction.ts | 5 +- src/interactions/portfolioInteraction.ts | 9 +- src/interactions/statementInteractionCAMT.ts | 5 +- src/interactions/statementInteractionMT940.ts | 7 +- src/tests/accountReference.test.ts | 117 ++++++++++++++++++ 10 files changed, 253 insertions(+), 74 deletions(-) create mode 100644 src/tests/accountReference.test.ts diff --git a/src/bankAccount.ts b/src/bankAccount.ts index f42a436..86b03a6 100644 --- a/src/bankAccount.ts +++ b/src/bankAccount.ts @@ -25,6 +25,26 @@ export type BankAccount = SepaAccount & { allowedTransactions?: AllowedTransactions[]; }; +/** + * How a caller names an account. + * + * An account number is not by itself unique: FinTS identifies an account by number + * *and* sub-account id together, and banks use that — a securities account and the + * current account it settles through commonly share a number and differ only in the + * sub-account id. Where that happens, a number alone cannot say which one is meant, + * so the account itself can be passed instead. Take it from + * `config.bankingInformation.upd.bankAccounts`. + */ +export type AccountRef = string | BankAccount; + +/** How an account reference reads in an error message. */ +export function describeAccount(account: AccountRef): string { + if (typeof account === 'string') return account; + return account.subAccountId + ? `${account.accountNumber} (${account.subAccountId})` + : account.accountNumber; +} + export function finTsAccountTypeToEnum(accountType: number): AccountType { if (accountType >= 1 && accountType <= 9) return AccountType.CheckingAccount; if (accountType >= 10 && accountType <= 19) return AccountType.SavingsAccount; diff --git a/src/client.ts b/src/client.ts index b1d6cea..7cec2b7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,3 +1,4 @@ +import { describeAccount, type AccountRef } from './bankAccount.js'; import { FinTSConfig } from './config.js'; import { Dialog } from './dialog.js'; import { @@ -92,23 +93,23 @@ export class FinTSClient { /** * Checks if the bank supports fetching an account balance in general or for the given account number when provided - * @param accountNumber when the account number is provided, checks if the account supports fetching the balance + * @param account when the account number is provided, checks if the account supports fetching the balance * @returns true if the bank (and account) supports fetching the account balance */ - canGetAccountBalance(accountNumber?: string): boolean { - return accountNumber - ? this.config.isAccountTransactionSupported(accountNumber, HKSAL.Id) + canGetAccountBalance(account?: AccountRef): boolean { + return account + ? this.config.isAccountTransactionSupported(account, HKSAL.Id) : this.config.isTransactionSupported(HKSAL.Id); } /** * Fetches the account balance for the given account number - * @param accountNumber - the account number to fetch the balance for, must be an account available in the config.baningInformation.UPD.accounts + * @param account - the account number to fetch the balance for, must be an account available in the config.baningInformation.UPD.accounts * @returns the account balance response */ - async getAccountBalance(accountNumber: string): Promise { + async getAccountBalance(account: AccountRef): Promise { const response = await this.startCustomerOrderInteraction( - new BalanceInteraction(accountNumber), + new BalanceInteraction(account), ); return response as AccountBalanceResponse; } @@ -132,15 +133,15 @@ export class FinTSClient { /** * Checks if the bank supports fetching account statements in general or for the given account number when provided - * @param accountNumber when the account number is provided, checks if the account supports fetching of statements + * @param account when the account number is provided, checks if the account supports fetching of statements * @returns true if the bank (and account) supports fetching account statements */ - canGetAccountStatements(accountNumber?: string): boolean { - if (accountNumber) { + canGetAccountStatements(account?: AccountRef): boolean { + if (account) { // Check if either CAMT or MT940 is supported for this account return ( - this.config.isAccountTransactionSupported(accountNumber, HKCAZ.Id) || - this.config.isAccountTransactionSupported(accountNumber, HKKAZ.Id) + this.config.isAccountTransactionSupported(account, HKCAZ.Id) || + this.config.isAccountTransactionSupported(account, HKKAZ.Id) ); } else { // Check if either CAMT or MT940 is supported by the bank @@ -152,24 +153,24 @@ export class FinTSClient { /** * Fetches the account statements for the given account number - * @param accountNumber - the account number to fetch the statements for, must be an account available in the config.baningInformation.UPD.accounts + * @param account - the account number to fetch the statements for, must be an account available in the config.baningInformation.UPD.accounts * @param from - an optional start date of the period to fetch the statements for * @param to - an optional end date of the period to fetch the statements for * @param preferCamt - whether to prefer CAMT format over MT940 when both are supported (default: true) * @returns an account statements response containing an array of statements */ async getAccountStatements( - accountNumber: string, + account: AccountRef, from?: Date, to?: Date, preferCamt: boolean = true, ): Promise { // Check what formats the bank supports - const camtSupported = this.config.isAccountTransactionSupported(accountNumber, 'HKCAZ'); - const mt940Supported = this.config.isAccountTransactionSupported(accountNumber, 'HKKAZ'); + const camtSupported = this.config.isAccountTransactionSupported(account, 'HKCAZ'); + const mt940Supported = this.config.isAccountTransactionSupported(account, 'HKKAZ'); if (!camtSupported && !mt940Supported) { - throw Error(`Account ${accountNumber} does not support account statements`); + throw Error(`Account ${describeAccount(account)} does not support account statements`); } // Choose format based on support and preference @@ -177,11 +178,11 @@ export class FinTSClient { if (useCAMT) { return (await this.startCustomerOrderInteraction( - new StatementInteractionCAMT(accountNumber, from, to), + new StatementInteractionCAMT(account, from, to), )) as StatementResponse; } else { return (await this.startCustomerOrderInteraction( - new StatementInteractionMT940(accountNumber, from, to), + new StatementInteractionMT940(account, from, to), )) as StatementResponse; } } @@ -205,31 +206,31 @@ export class FinTSClient { /** * Checks if the bank supports fetching portfolio information in general or for the given account number when provided - * @param accountNumber when the account number is provided, checks if the account supports fetching of portfolio information + * @param account when the account number is provided, checks if the account supports fetching of portfolio information * @returns true if the bank (and account) supports fetching portfolio information */ - canGetPortfolio(accountNumber?: string): boolean { - return accountNumber - ? this.config.isAccountTransactionSupported(accountNumber, HKWPD.Id) + canGetPortfolio(account?: AccountRef): boolean { + return account + ? this.config.isAccountTransactionSupported(account, HKWPD.Id) : this.config.isTransactionSupported(HKWPD.Id); } /** * Fetches the portfolio information for the given depot account number - * @param accountNumber - the depot account number to fetch the portfolio for, must be an account available in the config.bankingInformation.UPD.accounts + * @param account - the depot account number to fetch the portfolio for, must be an account available in the config.bankingInformation.UPD.accounts * @param currency - optional currency filter for the portfolio statement * @param priceQuality - optional price quality filter ('1' for real-time, '2' for delayed) * @param maxEntries - optional maximum number of entries to retrieve * @returns a portfolio response containing holdings and total value */ async getPortfolio( - accountNumber: string, + account: AccountRef, currency?: string, priceQuality?: '1' | '2', maxEntries?: number, ): Promise { return (await this.startCustomerOrderInteraction( - new PortfolioInteraction(accountNumber, currency, priceQuality, maxEntries), + new PortfolioInteraction(account, currency, priceQuality, maxEntries), )) as PortfolioResponse; } @@ -250,26 +251,26 @@ export class FinTSClient { /** * Checks if the bank supports fetching credit card statements in general or for the given account number - * @param accountNumber when the account number is provided, checks if the account supports fetching of statements + * @param account when the account number is provided, checks if the account supports fetching of statements * @returns true if the bank (and account) supports fetching credit card statements */ - canGetCreditCardStatements(accountNumber?: string): boolean { - return accountNumber - ? this.config.isAccountTransactionSupported(accountNumber, DKKKU.Id) + canGetCreditCardStatements(account?: AccountRef): boolean { + return account + ? this.config.isAccountTransactionSupported(account, DKKKU.Id) : this.config.isTransactionSupported(DKKKU.Id); } /** * Fetches the credit card statements for the given account number - * @param accountNumber - the account number to fetch the statements for, must be a credit card account available + * @param account - the account number to fetch the statements for, must be a credit card account available * in the config.baningInformation.UPD.accounts * @param from - an optional start date of the period to fetch the statements for * @param to - an optional end date of the period to fetch the statements for * @returns an account statements response containing an array of statements */ - async getCreditCardStatements(accountNumber: string, from?: Date): Promise { + async getCreditCardStatements(account: AccountRef, from?: Date): Promise { return (await this.startCustomerOrderInteraction( - new CreditCardStatementInteraction(accountNumber, from), + new CreditCardStatementInteraction(account, from), )) as StatementResponse; } @@ -292,12 +293,12 @@ export class FinTSClient { /** * 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 + * @param account 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) + canGetElectronicStatements(account?: AccountRef): boolean { + return account + ? this.config.isAccountTransactionSupported(account, HKEKA.Id) : this.config.isTransactionSupported(HKEKA.Id); } @@ -310,16 +311,16 @@ export class FinTSClient { * 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 account - 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, + account: AccountRef, options?: ElectronicStatementOptions, ): Promise { return (await this.startCustomerOrderInteraction( - new ElectronicStatementInteraction(accountNumber, options), + new ElectronicStatementInteraction(account, options), )) as ElectronicStatementResponse; } diff --git a/src/config.ts b/src/config.ts index 69f9fb6..4b8cd0a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import type { BankAccount } from './bankAccount.js'; +import type { AccountRef, BankAccount } from './bankAccount.js'; import type { BankingInformation } from './bankingInformation.js'; import { getSegmentDefinition } from './segments/registry.js'; import type { TanMethod } from './tanMethod.js'; @@ -229,11 +229,11 @@ export class FinTSConfig { /** * Checks if a transaction is supported for a specific account - * @param accountNumber The account number + * @param account An account number, or an account from `bankingInformation.upd.bankAccounts` * @param transId The transaction ID */ - isAccountTransactionSupported(accountNumber: string, transId: string): boolean { - const bankAccount = this.getBankAccount(accountNumber); + isAccountTransactionSupported(account: AccountRef, transId: string): boolean { + const bankAccount = this.getBankAccount(account); return !!bankAccount.allowedTransactions?.find((t) => t.transId === transId); } @@ -258,18 +258,53 @@ export class FinTSConfig { } /** - * Gets the bank account information for a specific account number - * @param accountNumber The account number + * Resolves an account reference against the accounts the bank reported. + * + * A number alone is enough wherever it is unique, which is the usual case. Where + * it is not, this throws instead of picking one: FinTS identifies an account by + * number *and* sub-account id, so a number that matches two accounts does not say + * which one is meant, and answering for the wrong one produces a balance or a list + * of transactions that belongs to a different account with nothing to indicate it. + * + * @param account An account number, or an account from `bankingInformation.upd.bankAccounts` */ - getBankAccount(accountNumber: string): BankAccount { - const bankAccount = this.bankingInformation.upd?.bankAccounts.find( - (a) => a.accountNumber === accountNumber, - ); + getBankAccount(account: AccountRef): BankAccount { + const konten = this.bankingInformation.upd?.bankAccounts ?? []; + + if (typeof account !== 'string') { + // Resolved against the UPD rather than trusted as given: the caller may hold + // an account from an earlier session, and the entry the bank sent this time + // is the one carrying the current allowed transactions. + const gefunden = konten.find( + (a) => + a.accountNumber === account.accountNumber && + a.subAccountId === account.subAccountId, + ); + + if (!gefunden) { + throw Error( + `Account ${account.accountNumber}${account.subAccountId ? ` (${account.subAccountId})` : ''} not found in UPD`, + ); + } + + return gefunden; + } + + const passend = konten.filter((a) => a.accountNumber === account); - if (!bankAccount) { - throw Error(`Account ${accountNumber} not found in UPD`); + if (passend.length === 0) { + throw Error(`Account ${account} not found in UPD`); + } + + if (passend.length > 1) { + const merkmale = passend.map((a) => a.subAccountId ?? '(none)').join(', '); + throw Error( + `Account number ${account} is not unique in UPD: ${passend.length} accounts share it, ` + + `with sub-account ids ${merkmale}. Pass the account itself instead of its number, ` + + `from bankingInformation.upd.bankAccounts.`, + ); } - return bankAccount; + return passend[0]; } } diff --git a/src/interactions/balanceInteraction.ts b/src/interactions/balanceInteraction.ts index 077d7af..ec19898 100644 --- a/src/interactions/balanceInteraction.ts +++ b/src/interactions/balanceInteraction.ts @@ -1,5 +1,6 @@ import type { AccountBalance } from '../accountBalance.js'; import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; +import { type AccountRef, describeAccount } from '../bankAccount.js'; import { CreditDebit } from '../codes.js'; import type { FinTSConfig } from '../config.js'; import type { Balance } from '../dataGroups/Balance.js'; @@ -14,15 +15,15 @@ export interface AccountBalanceResponse extends ClientResponse { } export class BalanceInteraction extends CustomerOrderInteraction { - constructor(public accountNumber: string) { + constructor(public account: AccountRef) { super(HKSAL.Id, HISAL.Id); } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); - if (!init.isAccountTransactionSupported(this.accountNumber, this.segId)) { + const bankAccount = init.getBankAccount(this.account); + if (!init.isAccountTransactionSupported(this.account, this.segId)) { throw Error( - `Account ${this.accountNumber} does not support business transaction '${this.segId}'`, + `Account ${describeAccount(this.account)} does not support business transaction '${this.segId}'`, ); } @@ -37,7 +38,7 @@ export class BalanceInteraction extends CustomerOrderInteraction { const hksal: HKSALSegment = { header: { segId: HKSAL.Id, segNr: 0, version: version }, - account, + account: account, allAccounts: false, }; diff --git a/src/interactions/creditcardStatementInteraction.ts b/src/interactions/creditcardStatementInteraction.ts index 2a26b72..91c48c7 100644 --- a/src/interactions/creditcardStatementInteraction.ts +++ b/src/interactions/creditcardStatementInteraction.ts @@ -1,4 +1,5 @@ import type { AccountBalance } from '../accountBalance.js'; +import { describeAccount, type AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { CreditCardStatement } from '../creditCardStatement.js'; import type { Message } from '../message.js'; @@ -14,17 +15,17 @@ export interface CreditCardStatementResponse extends ClientResponse { export class CreditCardStatementInteraction extends CustomerOrderInteraction { constructor( - public accountNumber: string, + public account: AccountRef, public from?: Date, ) { super(DKKKU.Id, DIKKU.Id); } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); - if (!init.isAccountTransactionSupported(this.accountNumber, this.segId)) { + const bankAccount = init.getBankAccount(this.account); + if (!init.isAccountTransactionSupported(this.account, this.segId)) { throw Error( - `Account ${this.accountNumber} does not support business transaction '${this.segId}'`, + `Account ${describeAccount(this.account)} does not support business transaction '${this.segId}'`, ); } @@ -78,7 +79,7 @@ export class CreditCardStatementInteraction extends CustomerOrderInteraction { if (dikku.transactions) { for (let i = 0; i < dikku.transactions.length; i++) { const parts = dikku.transactions[i].split(':'); - // const accountNumber = parts[0]; + // const account = parts[0]; const transactionDateStr = parts[1]; const valueDateStr = parts[2]; const currencyOrig = parts[5]; diff --git a/src/interactions/electronicStatementInteraction.ts b/src/interactions/electronicStatementInteraction.ts index 1521dcf..c287e60 100644 --- a/src/interactions/electronicStatementInteraction.ts +++ b/src/interactions/electronicStatementInteraction.ts @@ -1,4 +1,5 @@ import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; +import type { AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { ElectronicStatement } from '../electronicStatement.js'; import type { Message } from '../message.js'; @@ -69,14 +70,14 @@ function unwrapBase64(bytes: Uint8Array): Uint8Array { export class ElectronicStatementInteraction extends CustomerOrderInteraction { constructor( - public accountNumber: string, + public account: AccountRef, public options: ElectronicStatementOptions = {}, ) { super(HKEKA.Id, HIEKA.Id); } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); + const bankAccount = init.getBankAccount(this.account); const version = init.getMaxSupportedTransactionVersion(HKEKA.Id); if (!version) { throw Error(`There is no supported version for business transaction '${HKEKA.Id}'`); diff --git a/src/interactions/portfolioInteraction.ts b/src/interactions/portfolioInteraction.ts index 317ca31..336e395 100644 --- a/src/interactions/portfolioInteraction.ts +++ b/src/interactions/portfolioInteraction.ts @@ -1,4 +1,5 @@ import { nationalAccount } from '../accountDescriptor.js'; +import { type AccountRef, describeAccount } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import { type Holding, Mt535Parser, type StatementOfHoldings } from '../mt535parser.js'; @@ -35,7 +36,7 @@ export interface PortfolioResponse extends ClientResponse { */ export class PortfolioInteraction extends CustomerOrderInteraction { constructor( - public accountNumber: string, + public account: AccountRef, private currency?: string, private priceQuality?: '1' | '2', private maxEntries?: number, @@ -45,10 +46,10 @@ export class PortfolioInteraction extends CustomerOrderInteraction { } createSegments(config: FinTSConfig): Segment[] { - const bankAccount = config.getBankAccount(this.accountNumber); - if (!config.isAccountTransactionSupported(this.accountNumber, this.segId)) { + const bankAccount = config.getBankAccount(this.account); + if (!config.isAccountTransactionSupported(this.account, this.segId)) { throw Error( - `Account ${this.accountNumber} does not support business transaction '${this.segId}'`, + `Account ${describeAccount(this.account)} does not support business transaction '${this.segId}'`, ); } diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index 2e60f69..0f106ac 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -1,5 +1,6 @@ import { internationalAccount } from '../accountDescriptor.js'; import { CamtParser } from '../camtParser.js'; +import type { AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import type { Segment } from '../segment.js'; @@ -11,7 +12,7 @@ import { CustomerOrderInteraction, type StatementResponse } from './customerInte export class StatementInteractionCAMT extends CustomerOrderInteraction { constructor( - public accountNumber: string, + public account: AccountRef, public from?: Date, public to?: Date, ) { @@ -19,7 +20,7 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction { } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); + const bankAccount = init.getBankAccount(this.account); const version = init.getMaxSupportedTransactionVersion(HKCAZ.Id); if (!version) { throw Error(`There is no supported version for business transaction '${HKCAZ.Id}'`); diff --git a/src/interactions/statementInteractionMT940.ts b/src/interactions/statementInteractionMT940.ts index aeb5ad2..5c9c9da 100644 --- a/src/interactions/statementInteractionMT940.ts +++ b/src/interactions/statementInteractionMT940.ts @@ -1,4 +1,5 @@ import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; +import type { AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import { Mt940Parser } from '../mt940parser.js'; @@ -9,7 +10,7 @@ import { CustomerOrderInteraction, type StatementResponse } from './customerInte export class StatementInteractionMT940 extends CustomerOrderInteraction { constructor( - public accountNumber: string, + public account: AccountRef, public from?: Date, public to?: Date, ) { @@ -17,7 +18,7 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); + const bankAccount = init.getBankAccount(this.account); const version = init.getMaxSupportedTransactionVersion(HKKAZ.Id); if (!version) { @@ -29,7 +30,7 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { const hkkaz: HKKAZSegment = { header: { segId: HKKAZ.Id, segNr: 0, version: version }, - account, + account: account, allAccounts: false, from: this.from, to: this.to, diff --git a/src/tests/accountReference.test.ts b/src/tests/accountReference.test.ts new file mode 100644 index 0000000..656484e --- /dev/null +++ b/src/tests/accountReference.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { AccountType, type BankAccount, describeAccount } from '../bankAccount.js'; +import { Language } from '../codes.js'; +import { FinTSConfig } from '../config.js'; +import { HKSAL } from '../segments/HKSAL.js'; +import { HKWPD } from '../segments/HKWPD.js'; + +// A bank that gives a securities account and the current account it settles through +// the same number, distinguishing them only by sub-account id. That is within the +// specification: FinTS identifies an account by both together. +const giro: BankAccount = { + accountNumber: '1234567890', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + iban: 'DE89370400440532013000', + bic: 'BANKDEFFXXX', + customerId: 'customer1', + accountType: AccountType.Miscellaneous, + currency: 'EUR', + holder1: 'Test User', + allowedTransactions: [{ transId: HKSAL.Id, numSignatures: 0 }], +}; + +const depot: BankAccount = { + ...giro, + subAccountId: 'Depot', + iban: undefined, + bic: undefined, + allowedTransactions: [{ transId: HKWPD.Id, numSignatures: 0 }], +}; + +const einzeln: BankAccount = { ...giro, accountNumber: '5555555555', subAccountId: undefined }; + +function configWith(konten: BankAccount[]): FinTSConfig { + return FinTSConfig.fromBankingInformation('product', '1.0', { + systemId: 'SYSTEM01', + bpd: { + version: 1, + url: 'https://bank.example.com/fints', + countryCode: 280, + bankId: '10020030', + bankName: 'Example Bank', + allowedTransactions: [], + maxTransactionsPerMessage: 1, + supportedLanguages: [Language.German], + supportedHbciVersions: [300], + supportedTanMethods: [], + availableTanMethodIds: [], + }, + upd: { version: 1, usage: 0, bankAccounts: konten }, + bankMessages: [], + }); +} + +describe('addressing an account by number', () => { + it('resolves a number that only one account has', () => { + const config = configWith([giro, einzeln]); + expect(config.getBankAccount('5555555555').subAccountId).toBeUndefined(); + }); + + it('refuses a number two accounts share, instead of picking one', () => { + // Picking the first is what makes the failure invisible: a balance comes back, + // it is the other account's, and nothing in the response says so. + const config = configWith([giro, depot]); + expect(() => config.getBankAccount('1234567890')).toThrow(/not unique/); + }); + + it('names the sub-account ids, so the caller can tell them apart', () => { + const config = configWith([giro, depot]); + expect(() => config.getBankAccount('1234567890')).toThrow(/Girokonto, Depot/); + }); + + it('still says so when the number matches nothing', () => { + expect(() => configWith([giro]).getBankAccount('0000000000')).toThrow(/not found in UPD/); + }); +}); + +describe('addressing an account by the account itself', () => { + it('reaches the one a shared number cannot', () => { + const config = configWith([giro, depot]); + expect(config.getBankAccount(depot).subAccountId).toBe('Depot'); + expect(config.getBankAccount(giro).subAccountId).toBe('Girokonto'); + }); + + it('decides what that account may do, not what the other one may', () => { + const config = configWith([giro, depot]); + expect(config.isAccountTransactionSupported(depot, HKWPD.Id)).toBe(true); + expect(config.isAccountTransactionSupported(giro, HKWPD.Id)).toBe(false); + expect(config.isAccountTransactionSupported(giro, HKSAL.Id)).toBe(true); + }); + + it('resolves against the UPD rather than trusting what it was handed', () => { + // A caller may hold an account from a persisted earlier session. The entry the + // bank sent this time is the one carrying the current allowed transactions. + const veraltet: BankAccount = { ...depot, allowedTransactions: [] }; + const config = configWith([giro, depot]); + expect(config.isAccountTransactionSupported(veraltet, HKWPD.Id)).toBe(true); + }); + + it('refuses an account the bank did not report', () => { + const config = configWith([giro]); + const fremd: BankAccount = { ...giro, subAccountId: 'Sparkonto' }; + expect(() => config.getBankAccount(fremd)).toThrow(/not found in UPD/); + }); +}); + +describe('naming an account in an error', () => { + it('reads as the number alone where that is all there is', () => { + expect(describeAccount('1234567890')).toBe('1234567890'); + expect(describeAccount(einzeln)).toBe('5555555555'); + }); + + it('adds the sub-account id where there is one', () => { + // Otherwise an account passed as an object prints as [object Object]. + expect(describeAccount(depot)).toBe('1234567890 (Depot)'); + }); +}); From 51a59651a3fe58919854b21e2a1f845ea8efc418 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:22:52 +0200 Subject: [PATCH 2/6] Match the accounts a bank names; only refuse to guess for the ones a caller names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trying the change against a real bank found it at once: every operation failed with "account number is not unique", including ones that had never asked for an ambiguous account. `HKSPA` travels with every dialog, and its response handler looked each SEPA account the bank listed up by number in order to copy the IBAN across. At an institution where two accounts share a number, that now threw — so no request reached its order. The distinction the first version missed: a caller who names an ambiguous account has made a mistake worth an exception, and a bank listing its own accounts has not. `matchBankAccount` is for the second case. It uses the sub-account id where the bank repeated it, falls back to the number where only one account has it, and returns undefined rather than throwing where it cannot tell. Co-Authored-By: Claude Opus 5 (1M context) --- src/config.ts | 29 ++++++++++++++++++++++ src/interactions/sepaAccountInteraction.ts | 12 +++++---- src/tests/accountReference.test.ts | 24 ++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/config.ts b/src/config.ts index 4b8cd0a..870aa95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -227,6 +227,35 @@ export class FinTSConfig { ); } + /** + * The account the bank meant, without demanding that it be unambiguous. + * + * For entries the *bank* supplied — a SEPA account from HISPA, say — rather than + * ones a caller asked for. A caller who names an ambiguous account has made a + * mistake worth an exception; a bank listing its own accounts has not, and + * throwing there would break every dialog at an institution that shares numbers. + * + * @param account An account number with, where the bank gave one, its sub-account id + */ + matchBankAccount(account: { + accountNumber: string; + subAccountId?: string; + }): BankAccount | undefined { + const konten = this.bankingInformation.upd?.bankAccounts ?? []; + + const genau = konten.find( + (a) => + a.accountNumber === account.accountNumber && a.subAccountId === account.subAccountId, + ); + if (genau) return genau; + + // Banks are not consistent about repeating the sub-account id, so a number that + // only one account has still identifies it. One that several share does not, + // and guessing is what this whole change exists to stop. + const passend = konten.filter((a) => a.accountNumber === account.accountNumber); + return passend.length === 1 ? passend[0] : undefined; + } + /** * Checks if a transaction is supported for a specific account * @param account An account number, or an account from `bankingInformation.upd.bankAccounts` diff --git a/src/interactions/sepaAccountInteraction.ts b/src/interactions/sepaAccountInteraction.ts index 224c342..505d80b 100644 --- a/src/interactions/sepaAccountInteraction.ts +++ b/src/interactions/sepaAccountInteraction.ts @@ -1,3 +1,4 @@ +import type { AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { SepaAccount } from '../dataGroups/SepaAccount.js'; import type { Message } from '../message.js'; @@ -12,7 +13,7 @@ export interface SepaAccountResponse extends ClientResponse { export class SepaAccountInteraction extends CustomerOrderInteraction { constructor( - public accounts?: string[], // optional specific account numbers + public accounts?: AccountRef[], // optional: only these accounts public maxEntries?: number, ) { super(HKSPA.Id, HISPA.Id); @@ -29,9 +30,7 @@ export class SepaAccountInteraction extends CustomerOrderInteraction { throw Error(`There is no supported version for business transaction '${HKSPA.Id}'`); } - const accounts = this.accounts?.map((accountNumber) => { - return init.getBankAccount(accountNumber); - }); + const accounts = this.accounts?.map((account) => init.getBankAccount(account)); const hkspa: HKSPASegment = { header: { segId: HKSPA.Id, segNr: 0, version: version }, @@ -54,7 +53,10 @@ export class SepaAccountInteraction extends CustomerOrderInteraction { }); clientResponse.sepaAccounts.forEach((sepaAccount) => { - const bankAccount = this.dialog?.config.getBankAccount(sepaAccount.accountNumber); + // Matched, not resolved: this is the bank listing its own accounts, and at an + // institution where two of them share a number, demanding an unambiguous + // answer here would fail every dialog before it reached its order. + const bankAccount = this.dialog?.config.matchBankAccount(sepaAccount); if (bankAccount && !bankAccount.isSepaAccount) { bankAccount.isSepaAccount = sepaAccount.isSepaAccount; bankAccount.iban = sepaAccount.iban; diff --git a/src/tests/accountReference.test.ts b/src/tests/accountReference.test.ts index 656484e..5e9e9d2 100644 --- a/src/tests/accountReference.test.ts +++ b/src/tests/accountReference.test.ts @@ -115,3 +115,27 @@ describe('naming an account in an error', () => { expect(describeAccount(depot)).toBe('1234567890 (Depot)'); }); }); + +describe('matching an account the bank itself named', () => { + it('uses the sub-account id where the bank repeated it', () => { + const config = configWith([giro, depot]); + expect(config.matchBankAccount({ accountNumber: '1234567890', subAccountId: 'Depot' })?.subAccountId) + .toBe('Depot'); + }); + + it('still finds an account whose number only it has, sub-account id or not', () => { + // Banks are not consistent about repeating it, and a number only one account + // has identifies that account either way. + const config = configWith([giro, einzeln]); + expect(config.matchBankAccount({ accountNumber: '5555555555' })?.accountNumber) + .toBe('5555555555'); + }); + + it('gives up quietly where it cannot tell, rather than throwing', () => { + // This runs for entries the bank supplied — HISPA travels with every dialog — + // so throwing here would fail every request at a bank that shares numbers, + // before any of them reached its order. That is exactly what happened once. + const config = configWith([giro, depot]); + expect(config.matchBankAccount({ accountNumber: '1234567890' })).toBeUndefined(); + }); +}); From 02de746af07bb76fcc71be7950aecd3c05974747 Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:22:52 +0200 Subject: [PATCH 3/6] Name the sub-account fallback as the tolerance it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B.3.1 requires a sub-account id to appear the same way in the UPD and in HKSPA/HISPA. A bank that omits it in one of them has not kept to that, and the fallback exists for those banks — not as the rule. Co-Authored-By: Claude Opus 5 (1M context) --- src/config.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 870aa95..e05421f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -249,9 +249,11 @@ export class FinTSConfig { ); if (genau) return genau; - // Banks are not consistent about repeating the sub-account id, so a number that - // only one account has still identifies it. One that several share does not, - // and guessing is what this whole change exists to stop. + // A tolerance, not a rule: B.3.1 requires the sub-account id to appear the same + // way in the UPD and in HKSPA/HISPA, and a bank that omits it here has not kept + // to that. Refusing would cost the IBAN for an account that is otherwise + // perfectly identified, so a number only one account has still identifies it. + // One that several share does not, and guessing is what this change exists to stop. const passend = konten.filter((a) => a.accountNumber === account.accountNumber); return passend.length === 1 ? passend[0] : undefined; } From c55f3201b15bc0bc3268b329d4ca79669aaf9716 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 13 Sep 2026 10:23:49 +0200 Subject: [PATCH 4/6] fixed biome issues and formatting, renamed some german variables to english --- src/client.ts | 6 +- src/config.ts | 39 +++++----- .../creditcardStatementInteraction.ts | 2 +- src/interactions/statementInteractionCAMT.ts | 2 +- src/tests/accountReference.test.ts | 71 ++++++++++--------- 5 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7cec2b7..bafffb9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import { describeAccount, type AccountRef } from './bankAccount.js'; +import { type AccountRef, describeAccount } from './bankAccount.js'; import { FinTSConfig } from './config.js'; import { Dialog } from './dialog.js'; import { @@ -108,9 +108,7 @@ export class FinTSClient { * @returns the account balance response */ async getAccountBalance(account: AccountRef): Promise { - const response = await this.startCustomerOrderInteraction( - new BalanceInteraction(account), - ); + const response = await this.startCustomerOrderInteraction(new BalanceInteraction(account)); return response as AccountBalanceResponse; } diff --git a/src/config.ts b/src/config.ts index e05421f..209b671 100644 --- a/src/config.ts +++ b/src/config.ts @@ -241,21 +241,20 @@ export class FinTSConfig { accountNumber: string; subAccountId?: string; }): BankAccount | undefined { - const konten = this.bankingInformation.upd?.bankAccounts ?? []; + const accounts = this.bankingInformation.upd?.bankAccounts ?? []; - const genau = konten.find( - (a) => - a.accountNumber === account.accountNumber && a.subAccountId === account.subAccountId, + const exactMatch = accounts.find( + (a) => a.accountNumber === account.accountNumber && a.subAccountId === account.subAccountId, ); - if (genau) return genau; + if (exactMatch) return exactMatch; // A tolerance, not a rule: B.3.1 requires the sub-account id to appear the same // way in the UPD and in HKSPA/HISPA, and a bank that omits it here has not kept // to that. Refusing would cost the IBAN for an account that is otherwise // perfectly identified, so a number only one account has still identifies it. // One that several share does not, and guessing is what this change exists to stop. - const passend = konten.filter((a) => a.accountNumber === account.accountNumber); - return passend.length === 1 ? passend[0] : undefined; + const matches = accounts.filter((a) => a.accountNumber === account.accountNumber); + return matches.length === 1 ? matches[0] : undefined; } /** @@ -300,42 +299,40 @@ export class FinTSConfig { * @param account An account number, or an account from `bankingInformation.upd.bankAccounts` */ getBankAccount(account: AccountRef): BankAccount { - const konten = this.bankingInformation.upd?.bankAccounts ?? []; + const accounts = this.bankingInformation.upd?.bankAccounts ?? []; if (typeof account !== 'string') { // Resolved against the UPD rather than trusted as given: the caller may hold // an account from an earlier session, and the entry the bank sent this time // is the one carrying the current allowed transactions. - const gefunden = konten.find( - (a) => - a.accountNumber === account.accountNumber && - a.subAccountId === account.subAccountId, + const matchedAccount = accounts.find( + (a) => a.accountNumber === account.accountNumber && a.subAccountId === account.subAccountId, ); - if (!gefunden) { + if (!matchedAccount) { throw Error( `Account ${account.accountNumber}${account.subAccountId ? ` (${account.subAccountId})` : ''} not found in UPD`, ); } - return gefunden; + return matchedAccount; } - const passend = konten.filter((a) => a.accountNumber === account); + const matches = accounts.filter((a) => a.accountNumber === account); - if (passend.length === 0) { + if (matches.length === 0) { throw Error(`Account ${account} not found in UPD`); } - if (passend.length > 1) { - const merkmale = passend.map((a) => a.subAccountId ?? '(none)').join(', '); + if (matches.length > 1) { + const subAccountIds = matches.map((a) => a.subAccountId ?? '(none)').join(', '); throw Error( - `Account number ${account} is not unique in UPD: ${passend.length} accounts share it, ` + - `with sub-account ids ${merkmale}. Pass the account itself instead of its number, ` + + `Account number ${account} is not unique in UPD: ${matches.length} accounts share it, ` + + `with sub-account ids ${subAccountIds}. Pass the account itself instead of its number, ` + `from bankingInformation.upd.bankAccounts.`, ); } - return passend[0]; + return matches[0]; } } diff --git a/src/interactions/creditcardStatementInteraction.ts b/src/interactions/creditcardStatementInteraction.ts index 91c48c7..e1db6aa 100644 --- a/src/interactions/creditcardStatementInteraction.ts +++ b/src/interactions/creditcardStatementInteraction.ts @@ -1,5 +1,5 @@ import type { AccountBalance } from '../accountBalance.js'; -import { describeAccount, type AccountRef } from '../bankAccount.js'; +import { type AccountRef, describeAccount } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { CreditCardStatement } from '../creditCardStatement.js'; import type { Message } from '../message.js'; diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index 0f106ac..3644f23 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -1,6 +1,6 @@ import { internationalAccount } from '../accountDescriptor.js'; -import { CamtParser } from '../camtParser.js'; import type { AccountRef } from '../bankAccount.js'; +import { CamtParser } from '../camtParser.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import type { Segment } from '../segment.js'; diff --git a/src/tests/accountReference.test.ts b/src/tests/accountReference.test.ts index 5e9e9d2..8dca54f 100644 --- a/src/tests/accountReference.test.ts +++ b/src/tests/accountReference.test.ts @@ -8,7 +8,8 @@ import { HKWPD } from '../segments/HKWPD.js'; // A bank that gives a securities account and the current account it settles through // the same number, distinguishing them only by sub-account id. That is within the // specification: FinTS identifies an account by both together. -const giro: BankAccount = { + +const checkingAccount: BankAccount = { accountNumber: '1234567890', subAccountId: 'Girokonto', bank: { country: 280, bankId: '10020030' }, @@ -21,17 +22,21 @@ const giro: BankAccount = { allowedTransactions: [{ transId: HKSAL.Id, numSignatures: 0 }], }; -const depot: BankAccount = { - ...giro, +const securitiesAccount: BankAccount = { + ...checkingAccount, subAccountId: 'Depot', iban: undefined, bic: undefined, allowedTransactions: [{ transId: HKWPD.Id, numSignatures: 0 }], }; -const einzeln: BankAccount = { ...giro, accountNumber: '5555555555', subAccountId: undefined }; +const singleAccount: BankAccount = { + ...checkingAccount, + accountNumber: '5555555555', + subAccountId: undefined, +}; -function configWith(konten: BankAccount[]): FinTSConfig { +function configWith(accounts: BankAccount[]): FinTSConfig { return FinTSConfig.fromBankingInformation('product', '1.0', { systemId: 'SYSTEM01', bpd: { @@ -47,95 +52,97 @@ function configWith(konten: BankAccount[]): FinTSConfig { supportedTanMethods: [], availableTanMethodIds: [], }, - upd: { version: 1, usage: 0, bankAccounts: konten }, + upd: { version: 1, usage: 0, bankAccounts: accounts }, bankMessages: [], }); } describe('addressing an account by number', () => { it('resolves a number that only one account has', () => { - const config = configWith([giro, einzeln]); + const config = configWith([checkingAccount, singleAccount]); expect(config.getBankAccount('5555555555').subAccountId).toBeUndefined(); }); it('refuses a number two accounts share, instead of picking one', () => { // Picking the first is what makes the failure invisible: a balance comes back, // it is the other account's, and nothing in the response says so. - const config = configWith([giro, depot]); + const config = configWith([checkingAccount, securitiesAccount]); expect(() => config.getBankAccount('1234567890')).toThrow(/not unique/); }); it('names the sub-account ids, so the caller can tell them apart', () => { - const config = configWith([giro, depot]); + const config = configWith([checkingAccount, securitiesAccount]); expect(() => config.getBankAccount('1234567890')).toThrow(/Girokonto, Depot/); }); it('still says so when the number matches nothing', () => { - expect(() => configWith([giro]).getBankAccount('0000000000')).toThrow(/not found in UPD/); + expect(() => configWith([checkingAccount]).getBankAccount('0000000000')).toThrow(/not found in UPD/); }); }); describe('addressing an account by the account itself', () => { it('reaches the one a shared number cannot', () => { - const config = configWith([giro, depot]); - expect(config.getBankAccount(depot).subAccountId).toBe('Depot'); - expect(config.getBankAccount(giro).subAccountId).toBe('Girokonto'); + const config = configWith([checkingAccount, securitiesAccount]); + expect(config.getBankAccount(securitiesAccount).subAccountId).toBe('Depot'); + expect(config.getBankAccount(checkingAccount).subAccountId).toBe('Girokonto'); }); it('decides what that account may do, not what the other one may', () => { - const config = configWith([giro, depot]); - expect(config.isAccountTransactionSupported(depot, HKWPD.Id)).toBe(true); - expect(config.isAccountTransactionSupported(giro, HKWPD.Id)).toBe(false); - expect(config.isAccountTransactionSupported(giro, HKSAL.Id)).toBe(true); + const config = configWith([checkingAccount, securitiesAccount]); + expect(config.isAccountTransactionSupported(securitiesAccount, HKWPD.Id)).toBe(true); + expect(config.isAccountTransactionSupported(checkingAccount, HKWPD.Id)).toBe(false); + expect(config.isAccountTransactionSupported(checkingAccount, HKSAL.Id)).toBe(true); }); it('resolves against the UPD rather than trusting what it was handed', () => { // A caller may hold an account from a persisted earlier session. The entry the // bank sent this time is the one carrying the current allowed transactions. - const veraltet: BankAccount = { ...depot, allowedTransactions: [] }; - const config = configWith([giro, depot]); - expect(config.isAccountTransactionSupported(veraltet, HKWPD.Id)).toBe(true); + const staleAccount: BankAccount = { ...securitiesAccount, allowedTransactions: [] }; + const config = configWith([checkingAccount, securitiesAccount]); + expect(config.isAccountTransactionSupported(staleAccount, HKWPD.Id)).toBe(true); }); it('refuses an account the bank did not report', () => { - const config = configWith([giro]); - const fremd: BankAccount = { ...giro, subAccountId: 'Sparkonto' }; - expect(() => config.getBankAccount(fremd)).toThrow(/not found in UPD/); + const config = configWith([checkingAccount]); + const unreportedAccount: BankAccount = { ...checkingAccount, subAccountId: 'Sparkonto' }; + expect(() => config.getBankAccount(unreportedAccount)).toThrow(/not found in UPD/); }); }); describe('naming an account in an error', () => { it('reads as the number alone where that is all there is', () => { expect(describeAccount('1234567890')).toBe('1234567890'); - expect(describeAccount(einzeln)).toBe('5555555555'); + expect(describeAccount(singleAccount)).toBe('5555555555'); }); it('adds the sub-account id where there is one', () => { // Otherwise an account passed as an object prints as [object Object]. - expect(describeAccount(depot)).toBe('1234567890 (Depot)'); + expect(describeAccount(securitiesAccount)).toBe('1234567890 (Depot)'); }); }); describe('matching an account the bank itself named', () => { it('uses the sub-account id where the bank repeated it', () => { - const config = configWith([giro, depot]); - expect(config.matchBankAccount({ accountNumber: '1234567890', subAccountId: 'Depot' })?.subAccountId) - .toBe('Depot'); + const config = configWith([checkingAccount, securitiesAccount]); + expect( + config.matchBankAccount({ accountNumber: '1234567890', subAccountId: 'Depot' })?.subAccountId, + ).toBe('Depot'); }); it('still finds an account whose number only it has, sub-account id or not', () => { // Banks are not consistent about repeating it, and a number only one account // has identifies that account either way. - const config = configWith([giro, einzeln]); - expect(config.matchBankAccount({ accountNumber: '5555555555' })?.accountNumber) - .toBe('5555555555'); + const config = configWith([checkingAccount, singleAccount]); + expect(config.matchBankAccount({ accountNumber: '5555555555' })?.accountNumber).toBe( + '5555555555', + ); }); it('gives up quietly where it cannot tell, rather than throwing', () => { // This runs for entries the bank supplied — HISPA travels with every dialog — // so throwing here would fail every request at a bank that shares numbers, // before any of them reached its order. That is exactly what happened once. - const config = configWith([giro, depot]); + const config = configWith([checkingAccount, securitiesAccount]); expect(config.matchBankAccount({ accountNumber: '1234567890' })).toBeUndefined(); }); }); From be305d37a1c30808f7f507fec9b8d12a1571ea35 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 13 Sep 2026 10:26:43 +0200 Subject: [PATCH 5/6] updated README --- README.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index db6b7d3..60ebb5b 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,17 @@ Finally you can start fetching balances or statements: // for simplicity, use the first account const account = syncResponse.bankingInformation.upd.bankAccounts[0]; +// Account-specific methods accept either an account number or a BankAccount. +// Pass the account object when multiple accounts share the same number. + // fetch the current balance -const balanceResponse = await client.getAccountBalance(account.accountNumber); +const balanceResponse = await client.getAccountBalance(account); // fetch all available statements -const statementResponse = await client.getAccountStatements(account.accountNumber); +const statementResponse = await client.getAccountStatements(account); // or fetch portfolio from a securities account -client.getPortfolio(account.accountNumber); +client.getPortfolio(account); ``` These are only the most basic steps needed to retrieve information from the bank. There are still some unanswered questions like "how to handle TANs" or "how to avoid synchronizations every time you start a new session". These are explained in the corresponding sections below. @@ -110,7 +113,7 @@ const rl = readline.createInterface({ output: process.stdout, }); -let response = await client.getAccountStatements(account.accountNumber); +let response = await client.getAccountStatements(account); if (!response.success) { return; @@ -197,11 +200,11 @@ The following table shows all transactions supported by the FinTSClient interfac | Transaction | Method | Description | FinTS Segment(s) | TAN Support | Account-Specific | | -------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------- | ----------- | ---------------- | | **Synchronization** | `synchronize()` | Synchronizes bank and account information, updating config.bankingInformation | HKIDN, HKVVB, HKSYN, HKTAB | ✓ | ❌ | -| **Account Balance** | `getAccountBalance(accountNumber)` | Fetches the current balance for a specific account | HKSAL | ✓ | ✓ | -| **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 | ✓ | ✓ | +| **Account Balance** | `getAccountBalance(account: AccountRef)` | Fetches the current balance for a specific account | HKSAL | ✓ | ✓ | +| **Account Statements** | `getAccountStatements(account: AccountRef, from?, to?)` | Fetches account transactions/statements for a date range (MT940 or CAMT format) | HKKAZ, HKCAZ | ✓ | ✓ | +| **Portfolio** | `getPortfolio(account: AccountRef, currency?, priceQuality?, maxEntries?)` | Fetches securities portfolio information for depot accounts | HKWPD | ✓ | ✓ | +| **Credit Card Statements** | `getCreditCardStatements(account: AccountRef, from?)` | Fetches credit card statements for credit card accounts | DKKKU | ✓ | ✓ | +| **Electronic Statements** | `getElectronicStatements(account: AccountRef, 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 | - | ❌ | ❌ | @@ -211,11 +214,11 @@ For each account-specific transaction, the client provides corresponding `can*` | Support Check Method | Purpose | | -------------------------------------------- | --------------------------------------------------------------- | -| `canGetAccountBalance(accountNumber?)` | Checks if account balance fetching is supported | -| `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 | +| `canGetAccountBalance(account?: AccountRef)` | Checks if account balance fetching is supported | +| `canGetAccountStatements(account?: AccountRef)` | Checks if account statements fetching is supported (MT940/CAMT) | +| `canGetPortfolio(account?: AccountRef)` | Checks if portfolio information fetching is supported | +| `canGetCreditCardStatements(account?: AccountRef)` | Checks if credit card statements fetching is supported | +| `canGetElectronicStatements(account?: AccountRef)` | Checks if electronic account statements fetching is supported | ### Transaction Parameters @@ -246,12 +249,12 @@ if (config.isTransactionSupported('HKWPD')) { } ``` -#### `config.isAccountTransactionSupported(accountNumber: string, transId: string): boolean` +#### `config.isAccountTransactionSupported(account: AccountRef, transId: string): boolean` Checks whether a specific transaction type is supported for a particular account. ```typescript -if (config.isAccountTransactionSupported('1234567890', 'HKWPD')) { +if (config.isAccountTransactionSupported(account, 'HKWPD')) { console.log('Account supports portfolio requests'); } ``` From f38b1de1343a3d9aa184ca4ddc58818930e1bcf9 Mon Sep 17 00:00:00 2001 From: Robert Weber Date: Sun, 13 Sep 2026 11:08:15 +0200 Subject: [PATCH 6/6] biome fixes --- src/tests/accountReference.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tests/accountReference.test.ts b/src/tests/accountReference.test.ts index 8dca54f..ae9d1da 100644 --- a/src/tests/accountReference.test.ts +++ b/src/tests/accountReference.test.ts @@ -76,7 +76,9 @@ describe('addressing an account by number', () => { }); it('still says so when the number matches nothing', () => { - expect(() => configWith([checkingAccount]).getBankAccount('0000000000')).toThrow(/not found in UPD/); + expect(() => configWith([checkingAccount]).getBankAccount('0000000000')).toThrow( + /not found in UPD/, + ); }); });