Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/bankAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
81 changes: 41 additions & 40 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describeAccount, type AccountRef } from './bankAccount.js';
import { FinTSConfig } from './config.js';
import { Dialog } from './dialog.js';
import {
Expand Down Expand Up @@ -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<AccountBalanceResponse> {
async getAccountBalance(account: AccountRef): Promise<AccountBalanceResponse> {
const response = await this.startCustomerOrderInteraction(
new BalanceInteraction(accountNumber),
new BalanceInteraction(account),
);
return response as AccountBalanceResponse;
}
Expand All @@ -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
Expand All @@ -152,36 +153,36 @@ 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<StatementResponse> {
// 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
const useCAMT = (preferCamt && camtSupported) || (!mt940Supported && camtSupported);

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;
}
}
Expand All @@ -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<PortfolioResponse> {
return (await this.startCustomerOrderInteraction(
new PortfolioInteraction(accountNumber, currency, priceQuality, maxEntries),
new PortfolioInteraction(account, currency, priceQuality, maxEntries),
)) as PortfolioResponse;
}

Expand All @@ -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<StatementResponse> {
async getCreditCardStatements(account: AccountRef, from?: Date): Promise<StatementResponse> {
return (await this.startCustomerOrderInteraction(
new CreditCardStatementInteraction(accountNumber, from),
new CreditCardStatementInteraction(account, from),
)) as StatementResponse;
}

Expand All @@ -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);
}

Expand All @@ -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<ElectronicStatementResponse> {
return (await this.startCustomerOrderInteraction(
new ElectronicStatementInteraction(accountNumber, options),
new ElectronicStatementInteraction(account, options),
)) as ElectronicStatementResponse;
}

Expand Down
92 changes: 79 additions & 13 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}

Expand All @@ -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];
}
}
13 changes: 7 additions & 6 deletions src/interactions/balanceInteraction.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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}'`,
);
}

Expand All @@ -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,
};

Expand Down
Loading