From 9f7a62da8ad3f32a9b240c0f4215323f55f8058f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 18 Aug 2026 22:00:10 +0200 Subject: [PATCH 1/4] fix: honour nationalAccountAllowed when building an international account connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FinTS has two forms of account connection. The national one (KTV) carries account number, sub-account and bank; the international one (KTZ) adds IBAN and BIC and makes every field optional. A KTZ may carry the national fields as well, but only where the bank permits it, and the bank says so in its HISPAS parameters via `nationalAccountAllowed`. That field was parsed and stored, and never read. Every interaction using a KTZ filled both halves, which banks setting the flag to false reject. Measured against comdirect (BLZ 20041144), which declares nationalAccountAllowed false. Six HKCAZ orders, one account, same 9-day range, same session, repeated on a second account with a different account number: IBAN + BIC + number + sub-account + bank -> 3010 "Kontonummer ist ungültig", 0 IBAN + BIC -> 0020 "Auftrag ausgeführt", 19 IBAN -> 0020 "Auftrag ausgeführt", 19 number + sub-account + bank -> 3010 "Kontonummer ist ungültig", 0 The fourth line is why this is a rule about the national fields rather than about the combination: they are rejected in a KTZ even with no IBAN beside them. The same bank also declares a malformed camt format, so that was varied independently and ruled out — dropping it changes nothing. HKCAZ was where this surfaced, but it is not CAMT-specific. HKSAL and HKKAZ use a KTZ from version 7 and HKEKA from version 4; comdirect announces HKSAL 5 and HKKAZ 5, which is the only reason balance and MT940 worked. Any bank on the newer versions would have hit the same refusal. - new accountDescriptor.ts with nationalAccount() and internationalAccount() - both build the descriptor field by field instead of spreading the whole account and blanking what does not belong, so a data group cannot silently pick up a field that happens to share a name - an account without an IBAN keeps the national fields whatever the flag says: a securities account has nothing else to identify it with - no HISPAS at all is treated as no permission, which is what a "Kann"-field means - HKKAZSegment.account typed as Account | InternationalAccount, matching the segment definition, which has declared an international group for version 7+ all along Tests: nothing covered createSegments() before — the segment tests build their account by hand and the client tests mock Dialog.start, which discards the built request. The new interaction tests cover that layer for all five interactions and both sides of every version boundary. All nine fail without this change. 148 tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/accountDescriptor.ts | 74 ++++++++ src/interactions/balanceInteraction.ts | 5 +- .../electronicStatementInteraction.ts | 4 +- src/interactions/portfolioInteraction.ts | 3 +- src/interactions/statementInteractionCAMT.ts | 3 +- src/interactions/statementInteractionMT940.ts | 7 +- src/segments/HKKAZ.ts | 7 +- src/tests/accountDescriptor.test.ts | 100 +++++++++++ .../interactionAccountDescriptor.test.ts | 169 ++++++++++++++++++ 9 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 src/accountDescriptor.ts create mode 100644 src/tests/accountDescriptor.test.ts create mode 100644 src/tests/interactionAccountDescriptor.test.ts diff --git a/src/accountDescriptor.ts b/src/accountDescriptor.ts new file mode 100644 index 0000000..fa51382 --- /dev/null +++ b/src/accountDescriptor.ts @@ -0,0 +1,74 @@ +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 (KTZ, 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 KTZ 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. + * + * When the bank announces no HISPAS at all the national fields are left out, which + * is what the specification means by a "Kann"-field: absent permission is not + * permission. + */ +export function internationalAccount( + config: FinTSConfig, + account: BankAccount, +): InternationalAccount { + if (!account.iban) { + return nationalAccount(account); + } + + const hispas = config.getTransactionParameters(HKSPA.Id); + + return hispas?.nationalAccountAllowed + ? { + iban: account.iban, + bic: account.bic, + accountNumber: account.accountNumber, + subAccountId: account.subAccountId, + bank: account.bank, + } + : { iban: account.iban, bic: account.bic }; +} 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..9e32c59 --- /dev/null +++ b/src/tests/accountDescriptor.test.ts @@ -0,0 +1,100 @@ +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('leaves them out when the bank announces no HISPAS at all', () => { + // A "Kann"-field: no permission is not permission. + const descriptor = internationalAccount(configWith([]), account); + + expect(descriptor).toEqual({ iban: 'DE89370400440532013000', bic: 'BANKDEFFXXX' }); + }); + + 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..fe13789 --- /dev/null +++ b/src/tests/interactionAccountDescriptor.test.ts @@ -0,0 +1,169 @@ +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, + }); + }); +}); From 069df87f181d5da93232ebc89da6e3b90eaedc1f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 18 Aug 2026 22:41:27 +0200 Subject: [PATCH 2/4] docs: add comdirect to the tested banks Dialog, TAN method discovery, balance, MT940 and CAMT all work there with the account descriptor fix in this branch. Before it, CAMT returned success with zero statements. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From d88653f20f306095ba83149fe8125cf2a7b998b7 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 18 Aug 2026 23:07:45 +0200 Subject: [PATCH 3/4] docs: the data group is KTI, not KTZ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KTI is the Kontoverbindung international — IBAN, BIC, account number, sub-account, bank identifier — which is what InternationalAccountGroup defines. KTZ is the Kontoverbindung ZV international, which carries the SEPA flag in front and corresponds to SepaAccountGroup. Comments only. Co-Authored-By: Claude Opus 5 (1M context) --- src/accountDescriptor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/accountDescriptor.ts b/src/accountDescriptor.ts index fa51382..f6579b3 100644 --- a/src/accountDescriptor.ts +++ b/src/accountDescriptor.ts @@ -9,7 +9,7 @@ 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 (KTZ, which adds + * 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`. @@ -23,7 +23,7 @@ import { HKSPA } from './segments/HKSPA.js'; * 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 KTZ even when no IBAN accompanies + * the combination: the bank rejects them in a KTI even when no IBAN accompanies * them. */ From 50631d93ec650affebbf59447e4d6b15f8989269 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 18 Aug 2026 23:30:05 +0200 Subject: [PATCH 4/4] Treat a silent bank as unchanged, not as a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version read an absent HISPAS as absent permission and shortened the descriptor. That is the specification's reading, and it is the wrong one here: this library has already been round this loop. #20 reduced the CAMT descriptor to IBAN and BIC because comdirect rejects anything more. #25 then reported Postbank answering "Angaben zur nationalen Kontoverbindung für Identifikation erforderlich", and the reduction was reverted — which fixed Postbank and broke comdirect again, which is the state 1.5.0 ships in. Neither bank was wrong and neither fix could hold, because the choice was hard-coded either way. Reading the flag makes it data. But a bank that announces nothing has stated no rule, and shortening its descriptor would repeat exactly the move that had to be taken back in June. So: only an explicit false shortens. Adds two tests naming the conflict, so the next person to touch this sees why it is a switch rather than a constant. 150 tests. Co-Authored-By: Claude Opus 5 (1M context) --- src/accountDescriptor.ts | 18 ++++++++----- src/tests/accountDescriptor.test.ts | 14 +++++++--- .../interactionAccountDescriptor.test.ts | 26 +++++++++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/accountDescriptor.ts b/src/accountDescriptor.ts index f6579b3..8cf1f31 100644 --- a/src/accountDescriptor.ts +++ b/src/accountDescriptor.ts @@ -48,9 +48,13 @@ export function nationalAccount(account: BankAccount): Account { * 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. * - * When the bank announces no HISPAS at all the national fields are left out, which - * is what the specification means by a "Kann"-field: absent permission is not - * permission. + * 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, @@ -62,13 +66,13 @@ export function internationalAccount( const hispas = config.getTransactionParameters(HKSPA.Id); - return hispas?.nationalAccountAllowed - ? { + 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, - } - : { iban: account.iban, bic: account.bic }; + }; } diff --git a/src/tests/accountDescriptor.test.ts b/src/tests/accountDescriptor.test.ts index 9e32c59..fc3fa1a 100644 --- a/src/tests/accountDescriptor.test.ts +++ b/src/tests/accountDescriptor.test.ts @@ -80,11 +80,19 @@ describe('internationalAccount', () => { }); }); - it('leaves them out when the bank announces no HISPAS at all', () => { - // A "Kann"-field: no permission is not permission. + 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' }); + 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', () => { diff --git a/src/tests/interactionAccountDescriptor.test.ts b/src/tests/interactionAccountDescriptor.test.ts index fe13789..a78f0d6 100644 --- a/src/tests/interactionAccountDescriptor.test.ts +++ b/src/tests/interactionAccountDescriptor.test.ts @@ -167,3 +167,29 @@ describe('HKWPD — national at every version', () => { }); }); }); + +// 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, + }); + }); +});