From 38a2ac24f91fe65e0eb8bcf2c40e71b1d5ded21a Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:09:00 +0200 Subject: [PATCH 1/3] 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 | 13 +- .../creditcardStatementInteraction.ts | 11 +- .../electronicStatementInteraction.ts | 5 +- src/interactions/portfolioInteraction.ts | 9 +- src/interactions/statementInteractionCAMT.ts | 5 +- src/interactions/statementInteractionMT940.ts | 9 +- src/tests/accountReference.test.ts | 117 ++++++++++++++++++ 10 files changed, 255 insertions(+), 76 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 6e669d1..811742b 100644 --- a/src/interactions/balanceInteraction.ts +++ b/src/interactions/balanceInteraction.ts @@ -1,5 +1,6 @@ import type { AccountBalance } from '../accountBalance.js'; import { CreditDebit } from '../codes.js'; +import { describeAccount, type AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Balance } from '../dataGroups/Balance.js'; import type { Message } from '../message.js'; @@ -13,15 +14,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}'`, ); } @@ -31,12 +32,12 @@ export class BalanceInteraction extends CustomerOrderInteraction { throw Error(`There is no supported version for business transaction '${HKSAL.Id}`); } - const account = + const descriptor = version <= 6 ? { ...bankAccount, iban: undefined, bic: undefined } : bankAccount; const hksal: HKSALSegment = { header: { segId: HKSAL.Id, segNr: 0, version: version }, - account, + account: descriptor, 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 adc23ca..dba34d9 100644 --- a/src/interactions/electronicStatementInteraction.ts +++ b/src/interactions/electronicStatementInteraction.ts @@ -1,3 +1,4 @@ +import { describeAccount, type AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { ElectronicStatement } from '../electronicStatement.js'; import type { Message } from '../message.js'; @@ -68,14 +69,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 50fbe65..0a4c52b 100644 --- a/src/interactions/portfolioInteraction.ts +++ b/src/interactions/portfolioInteraction.ts @@ -1,3 +1,4 @@ +import { describeAccount, type AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import { type Holding, Mt535Parser, type StatementOfHoldings } from '../mt535parser.js'; @@ -34,7 +35,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, @@ -44,10 +45,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 435cea0..a2ac52b 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -1,4 +1,5 @@ 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'; @@ -10,7 +11,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, ) { @@ -18,7 +19,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 dadaf54..2544171 100644 --- a/src/interactions/statementInteractionMT940.ts +++ b/src/interactions/statementInteractionMT940.ts @@ -1,3 +1,4 @@ +import { describeAccount, type AccountRef } from '../bankAccount.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import { Mt940Parser } from '../mt940parser.js'; @@ -8,7 +9,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, ) { @@ -16,8 +17,8 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { } createSegments(init: FinTSConfig): Segment[] { - const bankAccount = init.getBankAccount(this.accountNumber); - const account = { ...bankAccount, iban: undefined }; + const bankAccount = init.getBankAccount(this.account); + const descriptor = { ...bankAccount, iban: undefined }; const version = init.getMaxSupportedTransactionVersion(HKKAZ.Id); if (!version) { @@ -26,7 +27,7 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { const hkkaz: HKKAZSegment = { header: { segId: HKKAZ.Id, segNr: 0, version: version }, - account, + account: descriptor, 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 3767e596bc92ba2514b8923c261d67e589970ccd Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:22:52 +0200 Subject: [PATCH 2/3] 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 399d95ff65fcc21589e9b0a5a0e41adcbd901c7a Mon Sep 17 00:00:00 2001 From: Roland Date: Wed, 19 Aug 2026 23:22:52 +0200 Subject: [PATCH 3/3] 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; }