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..e05421f 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'; @@ -227,13 +227,44 @@ 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; + + // 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; + } + /** * 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 +289,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 (!bankAccount) { - throw Error(`Account ${accountNumber} not found in UPD`); + 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 (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/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/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..5e9e9d2 --- /dev/null +++ b/src/tests/accountReference.test.ts @@ -0,0 +1,141 @@ +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)'); + }); +}); + +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(); + }); +});