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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions src/accountDescriptor.ts
Original file line number Diff line number Diff line change
@@ -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<HISPASParameter>(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,
};
}
5 changes: 4 additions & 1 deletion src/interactions/balanceInteraction.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
4 changes: 3 additions & 1 deletion src/interactions/electronicStatementInteraction.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/interactions/portfolioInteraction.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion src/interactions/statementInteractionCAMT.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion src/interactions/statementInteractionMT940.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/segments/HKKAZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 108 additions & 0 deletions src/tests/accountDescriptor.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
});
});
Loading