diff --git a/README.md b/README.md index d51c106..db6b7d3 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,7 @@ Implementing further transactions should be straight forward and contributions a ### Successfully tested with the following banks +- comdirect - DKB - ING-DiBa - Renault Bank Direkt diff --git a/src/accountDescriptor.ts b/src/accountDescriptor.ts new file mode 100644 index 0000000..8cf1f31 --- /dev/null +++ b/src/accountDescriptor.ts @@ -0,0 +1,78 @@ +import type { BankAccount } from './bankAccount.js'; +import type { FinTSConfig } from './config.js'; +import type { Account } from './dataGroups/Account.js'; +import type { InternationalAccount } from './dataGroups/InternationalAccount.js'; +import type { HISPASParameter } from './segments/HISPAS.js'; +import { HKSPA } from './segments/HKSPA.js'; + +/** + * Builds the account connection ("Kontoverbindung") a segment carries. + * + * FinTS has two forms, and segments pick one by version: the national form (KTV, + * account number + sub-account + bank) and the international one (KTI, which adds + * IBAN and BIC and makes every field optional). The international form allows the + * national fields to be present as well, but only where the bank permits it — and + * the bank says so in the HISPAS parameters, in `nationalAccountAllowed`. + * + * Filling both halves regardless is rejected by banks that set the flag to false. + * Measured at comdirect (BLZ 2004xxxx), same account, same range, same session: + * + * IBAN + BIC + number + sub-account + bank → 3010 "Kontonummer ist ungültig", 0 statements + * IBAN + BIC → 0020 "Auftrag ausgeführt", 19 statements + * IBAN → 0020 "Auftrag ausgeführt", 19 statements + * number + sub-account + bank → 3010 "Kontonummer ist ungültig", 0 statements + * + * The last line is why this is a rule about the national *fields* rather than about + * the combination: the bank rejects them in a KTI even when no IBAN accompanies + * them. + */ + +/** + * The national form. Built field by field rather than by spreading the account and + * blanking what does not belong: the data group has exactly these three fields, and + * saying so is clearer than relying on the encoder to ignore the rest. + */ +export function nationalAccount(account: BankAccount): Account { + return { + accountNumber: account.accountNumber, + subAccountId: account.subAccountId, + bank: account.bank, + }; +} + +/** + * The international form. IBAN and BIC always; the national fields only where the + * bank's HISPAS parameters allow them. + * + * An account without an IBAN — a securities account, typically — has nothing else + * to identify it with, so it keeps the national fields whatever the flag says. A + * request the bank refuses is more useful than one it cannot resolve at all. + * + * A bank that announces no HISPAS at all keeps both halves, exactly as before. The + * specification would read absent permission as no permission, but this library has + * already been round that loop: #20 reduced the CAMT descriptor to IBAN and BIC for + * comdirect, #25 reported Postbank answering "Angaben zur nationalen Kontoverbindung + * für Identifikation erforderlich", and the reduction was reverted. Only a bank that + * says `false` gets the shorter form, so no bank that works today can regress on a + * rule it never stated. + */ +export function internationalAccount( + config: FinTSConfig, + account: BankAccount, +): InternationalAccount { + if (!account.iban) { + return nationalAccount(account); + } + + const hispas = config.getTransactionParameters(HKSPA.Id); + + return hispas?.nationalAccountAllowed === false + ? { iban: account.iban, bic: account.bic } + : { + iban: account.iban, + bic: account.bic, + accountNumber: account.accountNumber, + subAccountId: account.subAccountId, + bank: account.bank, + }; +} diff --git a/src/interactions/balanceInteraction.ts b/src/interactions/balanceInteraction.ts index 6e669d1..583852f 100644 --- a/src/interactions/balanceInteraction.ts +++ b/src/interactions/balanceInteraction.ts @@ -1,6 +1,7 @@ import type { AccountBalance } from '../accountBalance.js'; import { CreditDebit } from '../codes.js'; import type { FinTSConfig } from '../config.js'; +import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; import type { Balance } from '../dataGroups/Balance.js'; import type { Message } from '../message.js'; import type { Segment } from '../segment.js'; @@ -32,7 +33,9 @@ export class BalanceInteraction extends CustomerOrderInteraction { } const account = - version <= 6 ? { ...bankAccount, iban: undefined, bic: undefined } : bankAccount; + version <= 6 + ? nationalAccount(bankAccount) + : internationalAccount(init, bankAccount); const hksal: HKSALSegment = { header: { segId: HKSAL.Id, segNr: 0, version: version }, diff --git a/src/interactions/electronicStatementInteraction.ts b/src/interactions/electronicStatementInteraction.ts index adc23ca..b43eb26 100644 --- a/src/interactions/electronicStatementInteraction.ts +++ b/src/interactions/electronicStatementInteraction.ts @@ -1,4 +1,5 @@ import type { FinTSConfig } from '../config.js'; +import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; import type { ElectronicStatement } from '../electronicStatement.js'; import type { Message } from '../message.js'; import type { Segment } from '../segment.js'; @@ -87,7 +88,8 @@ export class ElectronicStatementInteraction extends CustomerOrderInteraction { const hkeka: HKEKASegment = { header: { segId: HKEKA.Id, segNr: 0, version: version }, - account: bankAccount, + account: + version <= 3 ? nationalAccount(bankAccount) : internationalAccount(init, bankAccount), statementFormat: format, statementNumber: this.options.number, statementYear: this.options.year, diff --git a/src/interactions/portfolioInteraction.ts b/src/interactions/portfolioInteraction.ts index 50fbe65..df2fb67 100644 --- a/src/interactions/portfolioInteraction.ts +++ b/src/interactions/portfolioInteraction.ts @@ -1,4 +1,5 @@ import type { FinTSConfig } from '../config.js'; +import { nationalAccount } from '../accountDescriptor.js'; import type { Message } from '../message.js'; import { type Holding, Mt535Parser, type StatementOfHoldings } from '../mt535parser.js'; import type { Segment } from '../segment.js'; @@ -51,7 +52,7 @@ export class PortfolioInteraction extends CustomerOrderInteraction { ); } - const depotAccount = { ...bankAccount, iban: undefined }; // HKWPD uses KTV which doesn't have IBAN + const depotAccount = nationalAccount(bankAccount); // HKWPD uses KTV, which has no IBAN const version = config.getMaxSupportedTransactionVersion(HKWPD.Id); diff --git a/src/interactions/statementInteractionCAMT.ts b/src/interactions/statementInteractionCAMT.ts index 435cea0..7ca90fd 100644 --- a/src/interactions/statementInteractionCAMT.ts +++ b/src/interactions/statementInteractionCAMT.ts @@ -1,4 +1,5 @@ import { CamtParser } from '../camtParser.js'; +import { internationalAccount } from '../accountDescriptor.js'; import type { FinTSConfig } from '../config.js'; import type { Message } from '../message.js'; import type { Segment } from '../segment.js'; @@ -36,7 +37,7 @@ export class StatementInteractionCAMT extends CustomerOrderInteraction { const hkcaz: HKCAZSegment = { header: { segId: HKCAZ.Id, segNr: 0, version: version }, - account: bankAccount, + account: internationalAccount(init, bankAccount), acceptedCamtFormats: acceptedCamtFormats, allAccounts: false, from: this.from, diff --git a/src/interactions/statementInteractionMT940.ts b/src/interactions/statementInteractionMT940.ts index dadaf54..d9412c1 100644 --- a/src/interactions/statementInteractionMT940.ts +++ b/src/interactions/statementInteractionMT940.ts @@ -1,4 +1,5 @@ import type { FinTSConfig } from '../config.js'; +import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; import type { Message } from '../message.js'; import { Mt940Parser } from '../mt940parser.js'; import type { Segment } from '../segment.js'; @@ -17,13 +18,17 @@ export class StatementInteractionMT940 extends CustomerOrderInteraction { createSegments(init: FinTSConfig): Segment[] { const bankAccount = init.getBankAccount(this.accountNumber); - const account = { ...bankAccount, iban: undefined }; const version = init.getMaxSupportedTransactionVersion(HKKAZ.Id); if (!version) { throw Error(`There is no supported version for business transaction '${HKKAZ.Id}'`); } + const account = + version <= 6 + ? nationalAccount(bankAccount) + : internationalAccount(init, bankAccount); + const hkkaz: HKKAZSegment = { header: { segId: HKKAZ.Id, segNr: 0, version: version }, account, diff --git a/src/segments/HKKAZ.ts b/src/segments/HKKAZ.ts index c0f78dd..0a11423 100644 --- a/src/segments/HKKAZ.ts +++ b/src/segments/HKKAZ.ts @@ -3,12 +3,15 @@ import { Dat } from '../dataElements/Dat.js'; import { Numeric } from '../dataElements/Numeric.js'; import { YesNo } from '../dataElements/YesNo.js'; import { type Account, AccountGroup } from '../dataGroups/Account.js'; -import { InternationalAccountGroup } from '../dataGroups/InternationalAccount.js'; +import { + type InternationalAccount, + InternationalAccountGroup, +} from '../dataGroups/InternationalAccount.js'; import type { SegmentWithContinuationMark } from '../segment.js'; import { SegmentDefinition } from '../segmentDefinition.js'; export type HKKAZSegment = SegmentWithContinuationMark & { - account: Account; + account: Account | InternationalAccount; allAccounts: boolean; from?: Date; to?: Date; diff --git a/src/tests/accountDescriptor.test.ts b/src/tests/accountDescriptor.test.ts new file mode 100644 index 0000000..fc3fa1a --- /dev/null +++ b/src/tests/accountDescriptor.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { internationalAccount, nationalAccount } from '../accountDescriptor.js'; +import { AccountType, type BankAccount } from '../bankAccount.js'; +import { Language } from '../codes.js'; +import { FinTSConfig } from '../config.js'; +import type { BankTransaction } from '../bankTransaction.js'; + +const account: BankAccount = { + accountNumber: '1234567890', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + iban: 'DE89370400440532013000', + bic: 'BANKDEFFXXX', + customerId: 'customer1', + accountType: AccountType.CheckingAccount, + currency: 'EUR', + holder1: 'Test User', +}; + +const depot: BankAccount = { ...account, accountNumber: '9876543210', iban: undefined, bic: undefined }; + +function configWith(allowedTransactions: BankTransaction[]): 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: [account, depot] }, + bankMessages: [], + }); +} + +const hispas = (nationalAccountAllowed: boolean): BankTransaction => ({ + transId: 'HKSPA', + versions: [1], + tanRequired: false, + params: { + individualAccountRetrievalAllowed: false, + nationalAccountAllowed, + structuredPurposeAllowed: false, + }, +}); + +describe('nationalAccount', () => { + it('carries the three fields the data group has, and nothing else', () => { + expect(nationalAccount(account)).toEqual({ + accountNumber: '1234567890', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + }); + }); +}); + +describe('internationalAccount', () => { + it('leaves the national fields out when the bank forbids them', () => { + const descriptor = internationalAccount(configWith([hispas(false)]), account); + + expect(descriptor).toEqual({ iban: 'DE89370400440532013000', bic: 'BANKDEFFXXX' }); + }); + + it('includes the national fields when the bank allows them', () => { + const descriptor = internationalAccount(configWith([hispas(true)]), account); + + expect(descriptor).toEqual({ + iban: 'DE89370400440532013000', + bic: 'BANKDEFFXXX', + accountNumber: '1234567890', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + }); + }); + + it('keeps them when the bank announces no HISPAS at all', () => { + // Silence is not a refusal. Only a bank that says false gets the shorter form, + // so a bank working today cannot regress on a rule it never stated — see #25, + // where Postbank required the national fields for identification. + const descriptor = internationalAccount(configWith([]), account); + + expect(descriptor).toEqual({ + iban: 'DE89370400440532013000', + bic: 'BANKDEFFXXX', + accountNumber: '1234567890', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + }); + }); + + it('keeps the national fields for an account without an IBAN, whatever the flag says', () => { + // A securities account typically has none, and nothing else identifies it. + const descriptor = internationalAccount(configWith([hispas(false)]), depot); + + expect(descriptor).toEqual({ + accountNumber: '9876543210', + subAccountId: 'Girokonto', + bank: { country: 280, bankId: '10020030' }, + }); + }); +}); diff --git a/src/tests/interactionAccountDescriptor.test.ts b/src/tests/interactionAccountDescriptor.test.ts new file mode 100644 index 0000000..a78f0d6 --- /dev/null +++ b/src/tests/interactionAccountDescriptor.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; +import { AccountType, type BankAccount } from '../bankAccount.js'; +import type { BankTransaction } from '../bankTransaction.js'; +import { Language } from '../codes.js'; +import { FinTSConfig } from '../config.js'; +import { BalanceInteraction } from '../interactions/balanceInteraction.js'; +import { ElectronicStatementInteraction } from '../interactions/electronicStatementInteraction.js'; +import { PortfolioInteraction } from '../interactions/portfolioInteraction.js'; +import { StatementInteractionCAMT } from '../interactions/statementInteractionCAMT.js'; +import { StatementInteractionMT940 } from '../interactions/statementInteractionMT940.js'; +import { registerSegments } from '../segments/registry.js'; + +registerSegments(); + +// The layer between "the segment encodes correctly" and "the client picks the right +// interaction": what an interaction puts into the account connection. Nothing +// covered it before — the segment tests build their account by hand, and the client +// tests mock `Dialog.start`, which throws the built request away. + +const GIRO = '1234567890'; +const DEPOT = '9876543210'; +const BANK = { country: 280, bankId: '10020030' }; +const IBAN = 'DE89370400440532013000'; + +const konto = (accountNumber: string, transIds: string[], iban?: string): BankAccount => ({ + accountNumber, + subAccountId: 'Girokonto', + bank: BANK, + iban, + bic: iban ? 'BANKDEFFXXX' : undefined, + customerId: 'customer1', + accountType: AccountType.CheckingAccount, + currency: 'EUR', + holder1: 'Test User', + allowedTransactions: transIds.map((transId) => ({ transId, numSignatures: 1 })), +}); + +function configFor( + transactions: Record, + nationalAccountAllowed?: boolean, +): FinTSConfig { + const allowedTransactions: BankTransaction[] = Object.entries(transactions).map( + ([transId, versions]) => ({ transId, versions, tanRequired: false }), + ); + if (nationalAccountAllowed !== undefined) { + allowedTransactions.push({ + transId: 'HKSPA', + versions: [1], + tanRequired: false, + params: { + individualAccountRetrievalAllowed: false, + nationalAccountAllowed, + structuredPurposeAllowed: false, + }, + }); + } + + 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: [ + konto(GIRO, ['HKSAL', 'HKKAZ', 'HKCAZ', 'HKEKA'], IBAN), + konto(DEPOT, ['HKWPD'], undefined), + ], + }, + bankMessages: [], + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: reading one field off a built segment +const account = (segment: any) => segment.account; + +describe('HKCAZ — international at every version', () => { + it('sends IBAN and BIC only when the bank forbids the national fields', () => { + const config = configFor({ HKCAZ: [1] }, false); + + const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(config); + + expect(account(hkcaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' }); + }); + + it('sends both halves when the bank allows them', () => { + const config = configFor({ HKCAZ: [1] }, true); + + const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(config); + + expect(account(hkcaz)).toEqual({ + iban: IBAN, + bic: 'BANKDEFFXXX', + accountNumber: GIRO, + subAccountId: 'Girokonto', + bank: BANK, + }); + }); +}); + +describe('HKSAL — national up to version 6, international from 7', () => { + it('sends the national form at version 6, with no IBAN', () => { + const [hksal] = new BalanceInteraction(GIRO).createSegments(configFor({ HKSAL: [6] }, false)); + + expect(account(hksal)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK }); + }); + + it('honours the flag at version 7 instead of filling both halves', () => { + const [hksal] = new BalanceInteraction(GIRO).createSegments(configFor({ HKSAL: [7] }, false)); + + expect(account(hksal)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' }); + }); +}); + +describe('HKKAZ — national up to version 6, international from 7', () => { + it('sends the national form at version 6', () => { + const [hkkaz] = new StatementInteractionMT940(GIRO).createSegments(configFor({ HKKAZ: [6] }, false)); + + expect(account(hkkaz)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK }); + }); + + it('honours the flag at version 7', () => { + const [hkkaz] = new StatementInteractionMT940(GIRO).createSegments(configFor({ HKKAZ: [7] }, false)); + + expect(account(hkkaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' }); + }); +}); + +describe('HKEKA — national up to version 3, international from 4', () => { + it('sends the national form at version 3', () => { + const [hkeka] = new ElectronicStatementInteraction(GIRO, {}).createSegments( + configFor({ HKEKA: [3] }, false), + ); + + expect(account(hkeka)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK }); + }); + + it('honours the flag at version 4', () => { + const [hkeka] = new ElectronicStatementInteraction(GIRO, {}).createSegments( + configFor({ HKEKA: [4] }, false), + ); + + expect(account(hkeka)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' }); + }); +}); + +describe('HKWPD — national at every version', () => { + it('sends the national form, and the depot has no IBAN to send anyway', () => { + const [hkwpd] = new PortfolioInteraction(DEPOT).createSegments(configFor({ HKWPD: [5] }, false)); + + // biome-ignore lint/suspicious/noExplicitAny: reading one field off a built segment + expect((hkwpd as any).depot).toEqual({ + accountNumber: DEPOT, + subAccountId: 'Girokonto', + bank: BANK, + }); + }); +}); + +// The two banks that pulled this in opposite directions. #19/#20 reduced the CAMT +// descriptor to IBAN and BIC because comdirect rejects anything more; #25 reported +// Postbank answering "Angaben zur nationalen Kontoverbindung für Identifikation +// erforderlich", and the reduction was reverted. Neither bank was wrong, and neither +// fix could hold, because the choice was hard-coded either way. It is data now. + +describe('the two banks that pulled this in opposite directions', () => { + it('a bank refusing the national fields gets IBAN and BIC only', () => { + const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(configFor({ HKCAZ: [1] }, false)); + + expect(account(hkcaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' }); + }); + + it('a bank that says nothing keeps them, so nothing working today regresses', () => { + const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(configFor({ HKCAZ: [1] })); + + expect(account(hkcaz)).toEqual({ + iban: IBAN, + bic: 'BANKDEFFXXX', + accountNumber: GIRO, + subAccountId: 'Girokonto', + bank: BANK, + }); + }); +});