lib-fints 1.4.8 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -15
- package/dist/accountDescriptor.js +65 -0
- package/dist/bankAccount.js +8 -0
- package/dist/client.js +71 -33
- package/dist/config.js +57 -10
- package/dist/dataElements/Binary.js +18 -1
- package/dist/dialog.js +52 -31
- package/dist/electronicStatement.js +1 -0
- package/dist/httpClient.js +1 -1
- package/dist/index.js +2 -0
- package/dist/interactions/balanceInteraction.js +10 -8
- package/dist/interactions/creditcardStatementInteraction.js +17 -8
- package/dist/interactions/electronicStatementInteraction.js +89 -0
- package/dist/interactions/portfolioInteraction.js +19 -11
- package/dist/interactions/sepaAccountInteraction.js +11 -8
- package/dist/interactions/statementInteractionCAMT.js +14 -8
- package/dist/interactions/statementInteractionMT940.js +17 -9
- package/dist/message.js +10 -2
- package/dist/segments/HIEKA.js +45 -0
- package/dist/segments/HIEKAS.js +18 -0
- package/dist/segments/HKEKA.js +43 -0
- package/dist/segments/HKKAZ.js +1 -1
- package/dist/segments/registry.js +6 -0
- package/dist/tests/HKEKA.test.js +198 -0
- package/dist/tests/accountDescriptor.test.js +99 -0
- package/dist/tests/accountReference.test.js +126 -0
- package/dist/tests/interactionAccountDescriptor.test.js +153 -0
- package/dist/tests/message.test.js +18 -0
- package/dist/tests/partedResponse.test.js +181 -0
- package/dist/types/accountDescriptor.d.ts +49 -0
- package/dist/types/accountDescriptor.d.ts.map +1 -0
- package/dist/types/bankAccount.d.ts +13 -0
- package/dist/types/bankAccount.d.ts.map +1 -1
- package/dist/types/client.d.ts +45 -16
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/config.d.ts +27 -6
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/dataElements/Binary.d.ts +7 -0
- package/dist/types/dataElements/Binary.d.ts.map +1 -1
- package/dist/types/dialog.d.ts +14 -0
- package/dist/types/dialog.d.ts.map +1 -1
- package/dist/types/electronicStatement.d.ts +40 -0
- package/dist/types/electronicStatement.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/interactions/balanceInteraction.d.ts +3 -2
- package/dist/types/interactions/balanceInteraction.d.ts.map +1 -1
- package/dist/types/interactions/creditcardStatementInteraction.d.ts +3 -2
- package/dist/types/interactions/creditcardStatementInteraction.d.ts.map +1 -1
- package/dist/types/interactions/electronicStatementInteraction.d.ts +34 -0
- package/dist/types/interactions/electronicStatementInteraction.d.ts.map +1 -0
- package/dist/types/interactions/portfolioInteraction.d.ts +3 -2
- package/dist/types/interactions/portfolioInteraction.d.ts.map +1 -1
- package/dist/types/interactions/sepaAccountInteraction.d.ts +3 -2
- package/dist/types/interactions/sepaAccountInteraction.d.ts.map +1 -1
- package/dist/types/interactions/statementInteractionCAMT.d.ts +3 -2
- package/dist/types/interactions/statementInteractionCAMT.d.ts.map +1 -1
- package/dist/types/interactions/statementInteractionMT940.d.ts +3 -2
- package/dist/types/interactions/statementInteractionMT940.d.ts.map +1 -1
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/segments/HIEKA.d.ts +52 -0
- package/dist/types/segments/HIEKA.d.ts.map +1 -0
- package/dist/types/segments/HIEKAS.d.ts +21 -0
- package/dist/types/segments/HIEKAS.d.ts.map +1 -0
- package/dist/types/segments/HKEKA.d.ts +54 -0
- package/dist/types/segments/HKEKA.d.ts.map +1 -0
- package/dist/types/segments/HKKAZ.d.ts +2 -2
- package/dist/types/segments/HKKAZ.d.ts.map +1 -1
- package/dist/types/segments/registry.d.ts.map +1 -1
- package/dist/types/tests/HKEKA.test.d.ts +2 -0
- package/dist/types/tests/HKEKA.test.d.ts.map +1 -0
- package/dist/types/tests/accountDescriptor.test.d.ts +2 -0
- package/dist/types/tests/accountDescriptor.test.d.ts.map +1 -0
- package/dist/types/tests/accountReference.test.d.ts +2 -0
- package/dist/types/tests/accountReference.test.d.ts.map +1 -0
- package/dist/types/tests/interactionAccountDescriptor.test.d.ts +2 -0
- package/dist/types/tests/interactionAccountDescriptor.test.d.ts.map +1 -0
- package/dist/types/tests/partedResponse.test.d.ts +2 -0
- package/dist/types/tests/partedResponse.test.d.ts.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ElectronicStatementInteraction, } from '../interactions/electronicStatementInteraction.js';
|
|
3
|
+
import { Message } from '../message.js';
|
|
4
|
+
import { decode, encode } from '../segment.js';
|
|
5
|
+
import { HKEKA, StatementFormat } from '../segments/HKEKA.js';
|
|
6
|
+
import { registerSegments } from '../segments/registry.js';
|
|
7
|
+
registerSegments();
|
|
8
|
+
describe('HKEKA v5', () => {
|
|
9
|
+
it('encode', () => {
|
|
10
|
+
const segment = {
|
|
11
|
+
header: { segId: HKEKA.Id, segNr: 3, version: 5 },
|
|
12
|
+
account: {
|
|
13
|
+
iban: 'DE991234567123456',
|
|
14
|
+
bic: 'BANK12',
|
|
15
|
+
},
|
|
16
|
+
statementFormat: StatementFormat.PDF,
|
|
17
|
+
};
|
|
18
|
+
expect(encode(segment)).toBe("HKEKA:3:5+DE991234567123456:BANK12+3'");
|
|
19
|
+
});
|
|
20
|
+
it('encode with statement number, year and offset', () => {
|
|
21
|
+
const segment = {
|
|
22
|
+
header: { segId: HKEKA.Id, segNr: 3, version: 5 },
|
|
23
|
+
account: {
|
|
24
|
+
iban: 'DE991234567123456',
|
|
25
|
+
bic: 'BANK12',
|
|
26
|
+
},
|
|
27
|
+
statementFormat: StatementFormat.PDF,
|
|
28
|
+
statementNumber: 7,
|
|
29
|
+
statementYear: 2026,
|
|
30
|
+
offset: '20260530120235141058000_0',
|
|
31
|
+
};
|
|
32
|
+
expect(encode(segment)).toBe("HKEKA:3:5+DE991234567123456:BANK12+3+7+2026++20260530120235141058000_0'");
|
|
33
|
+
});
|
|
34
|
+
it('decode and encode roundtrip matches', () => {
|
|
35
|
+
const text = "HKEKA:0:5+DE991234567123456:BANK12+3+7+2026++20260530120235141058000_0'";
|
|
36
|
+
const segment = decode(text);
|
|
37
|
+
expect(encode(segment)).toBe(text);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('HIEKA v5', () => {
|
|
41
|
+
// The element order is the whole risk of this segment: `booked` comes AFTER
|
|
42
|
+
// date/year/number, unlike HIEKP v2 where it comes first.
|
|
43
|
+
const text = "HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+GmbH+i?:G?:+@4@abcd'";
|
|
44
|
+
it('decode', () => {
|
|
45
|
+
const segment = decode(text);
|
|
46
|
+
expect(segment.format).toBe(StatementFormat.PDF);
|
|
47
|
+
expect(segment.timeRange?.from).toEqual(new Date('2026-06-01'));
|
|
48
|
+
expect(segment.timeRange?.to).toEqual(new Date('2026-06-30'));
|
|
49
|
+
expect(segment.date).toEqual(new Date('2026-07-01'));
|
|
50
|
+
expect(segment.year).toBe(2026);
|
|
51
|
+
expect(segment.number).toBe(7);
|
|
52
|
+
expect(segment.booked).toBe('%PDF-1.7 ');
|
|
53
|
+
expect(segment.closingInfo).toBe('Abschluss');
|
|
54
|
+
expect(segment.conditionsInfo).toBe('Konditionen');
|
|
55
|
+
expect(segment.advertisement).toBe('Werbung');
|
|
56
|
+
expect(segment.iban).toBe('DE991234567123456');
|
|
57
|
+
expect(segment.bic).toBe('BANK12');
|
|
58
|
+
expect(segment.name).toBe('Muster');
|
|
59
|
+
expect(segment.name2).toBe('GmbH');
|
|
60
|
+
expect(segment.name3).toBe('i:G:');
|
|
61
|
+
expect(segment.receipt).toBe('abcd');
|
|
62
|
+
});
|
|
63
|
+
it('decode and encode roundtrip matches', () => {
|
|
64
|
+
expect(encode(decode(text))).toBe(text);
|
|
65
|
+
});
|
|
66
|
+
it('decode without the optional elements', () => {
|
|
67
|
+
const segment = decode("HIEKA:5:5:3+3+20260601:20260630++++@9@%PDF-1.7 '");
|
|
68
|
+
expect(segment.format).toBe(StatementFormat.PDF);
|
|
69
|
+
expect(segment.timeRange?.from).toEqual(new Date('2026-06-01'));
|
|
70
|
+
expect(segment.date).toBeUndefined();
|
|
71
|
+
expect(segment.number).toBeUndefined();
|
|
72
|
+
expect(segment.booked).toBe('%PDF-1.7 ');
|
|
73
|
+
expect(segment.receipt).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('HKEKA / HIEKA older versions', () => {
|
|
77
|
+
// Up to version 3 the request carries the national account connection, from version 4
|
|
78
|
+
// on the international one; `year` does not exist before version 3. Getting this wrong
|
|
79
|
+
// puts an IBAN into the slot the bank reads as an account number.
|
|
80
|
+
it('encodes version 3 with the national account connection and no year', () => {
|
|
81
|
+
const segment = {
|
|
82
|
+
header: { segId: HKEKA.Id, segNr: 3, version: 3 },
|
|
83
|
+
account: { accountNumber: '2759161761', bank: { country: 280, bankId: '10090000' } },
|
|
84
|
+
statementFormat: StatementFormat.PDF,
|
|
85
|
+
statementNumber: 7,
|
|
86
|
+
};
|
|
87
|
+
expect(encode(segment)).toBe("HKEKA:3:3+2759161761::280:10090000+3+7'");
|
|
88
|
+
});
|
|
89
|
+
it('encodes version 5 with the international account connection', () => {
|
|
90
|
+
const segment = {
|
|
91
|
+
header: { segId: HKEKA.Id, segNr: 3, version: 5 },
|
|
92
|
+
account: { iban: 'DE991234567123456', bic: 'BANK12' },
|
|
93
|
+
statementFormat: StatementFormat.PDF,
|
|
94
|
+
statementNumber: 7,
|
|
95
|
+
};
|
|
96
|
+
expect(encode(segment)).toBe("HKEKA:3:5+DE991234567123456:BANK12+3+7'");
|
|
97
|
+
});
|
|
98
|
+
// Up to version 4 the response has no date/year/number — `booked` follows the time
|
|
99
|
+
// range directly. Decoded with the version 5 layout this does not throw, it hands out
|
|
100
|
+
// the advertisement text as the statement document.
|
|
101
|
+
it('decodes a version 4 response without shifting the document', () => {
|
|
102
|
+
const segment = decode("HIEKA:5:4+3+20260601:20260630+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+++@4@abcd'");
|
|
103
|
+
expect(segment.booked).toBe('%PDF-1.7 ');
|
|
104
|
+
expect(segment.closingInfo).toBe('Abschluss');
|
|
105
|
+
expect(segment.advertisement).toBe('Werbung');
|
|
106
|
+
expect(segment.iban).toBe('DE991234567123456');
|
|
107
|
+
expect(segment.date).toBeUndefined();
|
|
108
|
+
expect(segment.year).toBeUndefined();
|
|
109
|
+
expect(segment.number).toBeUndefined();
|
|
110
|
+
expect(segment.receipt).toBe('abcd');
|
|
111
|
+
});
|
|
112
|
+
it('decodes a version 1 response, which has no iban/bic/name', () => {
|
|
113
|
+
const segment = decode("HIEKA:5:1+3+20260601:20260630+@9@%PDF-1.7 +Abschluss+Konditionen+Werbung+@4@abcd'");
|
|
114
|
+
expect(segment.booked).toBe('%PDF-1.7 ');
|
|
115
|
+
expect(segment.advertisement).toBe('Werbung');
|
|
116
|
+
expect(segment.iban).toBeUndefined();
|
|
117
|
+
expect(segment.receipt).toBe('abcd');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('HIEKAS v5', () => {
|
|
121
|
+
it('decode and encode roundtrip matches', () => {
|
|
122
|
+
const text = "HIEKAS:4:5:4+1+1+0+J:N:J:1:3'";
|
|
123
|
+
const segment = decode(text);
|
|
124
|
+
expect(segment.params.indexAllowed).toBe(true);
|
|
125
|
+
expect(segment.params.receiptRequired).toBe(false);
|
|
126
|
+
expect(segment.params.maxEntryCountAllowed).toBe(true);
|
|
127
|
+
expect(segment.params.supportedFormats).toEqual(['1', '3']);
|
|
128
|
+
expect(encode(segment)).toBe(text);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('binary payloads', () => {
|
|
132
|
+
// A PDF contains every character FinTS uses structurally: + and ' as separators,
|
|
133
|
+
// @ for binary markers, ? for escaping. Only the declared length keeps them apart
|
|
134
|
+
// from real syntax.
|
|
135
|
+
it('carries a payload containing FinTS control characters through unharmed', () => {
|
|
136
|
+
const pdf = "%PDF-1.7\n+ ' @ ? +++ '''\nstartxref\n%%EOF";
|
|
137
|
+
const text = `HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${pdf.length}@${pdf}+++++++++`;
|
|
138
|
+
const segment = decode(`${text}'`);
|
|
139
|
+
expect(segment.booked).toBe(pdf);
|
|
140
|
+
expect(segment.number).toBe(7);
|
|
141
|
+
});
|
|
142
|
+
it('honours the declared length instead of reading to the end of the field', () => {
|
|
143
|
+
// Some banks pad a binary field; the declared length is what counts.
|
|
144
|
+
const pdf = '%PDF-1.7 ';
|
|
145
|
+
const segment = decode(`HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${pdf.length}@${pdf}\u0000+++++++++'`);
|
|
146
|
+
expect(segment.booked).toBe(pdf);
|
|
147
|
+
expect(segment.booked).not.toContain('\u0000');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('ElectronicStatementInteraction', () => {
|
|
151
|
+
const pdf = '%PDF-1.7 fake';
|
|
152
|
+
function messageWith(hieka, continuation) {
|
|
153
|
+
const answers = continuation
|
|
154
|
+
? `HIRMG:3:2+0010::Entgegengenommen.+3040::Es liegen weitere Dokumente vor.:${continuation}'`
|
|
155
|
+
: "HIRMG:3:2+0010::Entgegengenommen.'";
|
|
156
|
+
return Message.decode(`${answers}${hieka}`);
|
|
157
|
+
}
|
|
158
|
+
function hiekaText(booked) {
|
|
159
|
+
return (`HIEKA:5:5:3+3+20260601:20260630+20260701+2026+7+@${booked.length}@${booked}` +
|
|
160
|
+
`+Abschluss+Konditionen+Werbung+DE991234567123456+BANK12+Muster+GmbH++@4@abcd'`);
|
|
161
|
+
}
|
|
162
|
+
function handle(message) {
|
|
163
|
+
const interaction = new ElectronicStatementInteraction('123');
|
|
164
|
+
const clientResponse = { bankAnswers: message.getBankAnswers() };
|
|
165
|
+
interaction.handleResponse(message, clientResponse);
|
|
166
|
+
return clientResponse;
|
|
167
|
+
}
|
|
168
|
+
it('maps the response onto a statement', () => {
|
|
169
|
+
const response = handle(messageWith(hiekaText(pdf)));
|
|
170
|
+
expect(response.statements).toHaveLength(1);
|
|
171
|
+
const statement = response.statements[0];
|
|
172
|
+
expect(statement.format).toBe(StatementFormat.PDF);
|
|
173
|
+
expect(statement.year).toBe(2026);
|
|
174
|
+
expect(statement.number).toBe(7);
|
|
175
|
+
expect(statement.from).toEqual(new Date('2026-06-01'));
|
|
176
|
+
expect(statement.iban).toBe('DE991234567123456');
|
|
177
|
+
expect(statement.accountName).toBe('Muster GmbH');
|
|
178
|
+
expect(statement.receipt).toBe('abcd');
|
|
179
|
+
expect(Buffer.from(statement.document).toString('latin1')).toBe(pdf);
|
|
180
|
+
});
|
|
181
|
+
it('reports the offset of a waiting successor and nothing otherwise', () => {
|
|
182
|
+
expect(handle(messageWith(hiekaText(pdf), 'AUFSETZ_1')).nextOffset).toBe('AUFSETZ_1');
|
|
183
|
+
expect(handle(messageWith(hiekaText(pdf))).nextOffset).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
it('collects every statement the response carries', () => {
|
|
186
|
+
const message = messageWith(`${hiekaText(pdf)}${hiekaText('%PDF-1.7 second')}`);
|
|
187
|
+
expect(handle(message).statements).toHaveLength(2);
|
|
188
|
+
});
|
|
189
|
+
it('unwraps a base64 wrapped document, but only when it proves to be one', () => {
|
|
190
|
+
const wrapped = Buffer.from(pdf, 'latin1').toString('base64');
|
|
191
|
+
const unwrapped = handle(messageWith(hiekaText(wrapped))).statements[0].document;
|
|
192
|
+
expect(Buffer.from(unwrapped).toString('latin1')).toBe(pdf);
|
|
193
|
+
// Base64-looking text that does NOT decode to a document must survive untouched.
|
|
194
|
+
const notADocument = 'SGVsbG8gV29ybGQ=';
|
|
195
|
+
const kept = handle(messageWith(hiekaText(notADocument))).statements[0].document;
|
|
196
|
+
expect(Buffer.from(kept).toString('latin1')).toBe(notADocument);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { internationalAccount, nationalAccount } from '../accountDescriptor.js';
|
|
3
|
+
import { AccountType } from '../bankAccount.js';
|
|
4
|
+
import { Language } from '../codes.js';
|
|
5
|
+
import { FinTSConfig } from '../config.js';
|
|
6
|
+
const account = {
|
|
7
|
+
accountNumber: '1234567890',
|
|
8
|
+
subAccountId: 'Girokonto',
|
|
9
|
+
bank: { country: 280, bankId: '10020030' },
|
|
10
|
+
iban: 'DE89370400440532013000',
|
|
11
|
+
bic: 'BANKDEFFXXX',
|
|
12
|
+
customerId: 'customer1',
|
|
13
|
+
accountType: AccountType.CheckingAccount,
|
|
14
|
+
currency: 'EUR',
|
|
15
|
+
holder1: 'Test User',
|
|
16
|
+
};
|
|
17
|
+
const depot = {
|
|
18
|
+
...account,
|
|
19
|
+
accountNumber: '9876543210',
|
|
20
|
+
iban: undefined,
|
|
21
|
+
bic: undefined,
|
|
22
|
+
};
|
|
23
|
+
function configWith(allowedTransactions) {
|
|
24
|
+
return FinTSConfig.fromBankingInformation('product', '1.0', {
|
|
25
|
+
systemId: 'SYSTEM01',
|
|
26
|
+
bpd: {
|
|
27
|
+
version: 1,
|
|
28
|
+
url: 'https://bank.example.com/fints',
|
|
29
|
+
countryCode: 280,
|
|
30
|
+
bankId: '10020030',
|
|
31
|
+
bankName: 'Example Bank',
|
|
32
|
+
allowedTransactions,
|
|
33
|
+
maxTransactionsPerMessage: 1,
|
|
34
|
+
supportedLanguages: [Language.German],
|
|
35
|
+
supportedHbciVersions: [300],
|
|
36
|
+
supportedTanMethods: [],
|
|
37
|
+
availableTanMethodIds: [],
|
|
38
|
+
},
|
|
39
|
+
upd: { version: 1, usage: 0, bankAccounts: [account, depot] },
|
|
40
|
+
bankMessages: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const hispas = (nationalAccountAllowed) => ({
|
|
44
|
+
transId: 'HKSPA',
|
|
45
|
+
versions: [1],
|
|
46
|
+
tanRequired: false,
|
|
47
|
+
params: {
|
|
48
|
+
individualAccountRetrievalAllowed: false,
|
|
49
|
+
nationalAccountAllowed,
|
|
50
|
+
structuredPurposeAllowed: false,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
describe('nationalAccount', () => {
|
|
54
|
+
it('carries the three fields the data group has, and nothing else', () => {
|
|
55
|
+
expect(nationalAccount(account)).toEqual({
|
|
56
|
+
accountNumber: '1234567890',
|
|
57
|
+
subAccountId: 'Girokonto',
|
|
58
|
+
bank: { country: 280, bankId: '10020030' },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('internationalAccount', () => {
|
|
63
|
+
it('leaves the national fields out when the bank forbids them', () => {
|
|
64
|
+
const descriptor = internationalAccount(configWith([hispas(false)]), account);
|
|
65
|
+
expect(descriptor).toEqual({ iban: 'DE89370400440532013000', bic: 'BANKDEFFXXX' });
|
|
66
|
+
});
|
|
67
|
+
it('includes the national fields when the bank allows them', () => {
|
|
68
|
+
const descriptor = internationalAccount(configWith([hispas(true)]), account);
|
|
69
|
+
expect(descriptor).toEqual({
|
|
70
|
+
iban: 'DE89370400440532013000',
|
|
71
|
+
bic: 'BANKDEFFXXX',
|
|
72
|
+
accountNumber: '1234567890',
|
|
73
|
+
subAccountId: 'Girokonto',
|
|
74
|
+
bank: { country: 280, bankId: '10020030' },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
it('keeps them when the bank announces no HISPAS at all', () => {
|
|
78
|
+
// Silence is not a refusal. Only a bank that says false gets the shorter form,
|
|
79
|
+
// so a bank working today cannot regress on a rule it never stated — see #25,
|
|
80
|
+
// where Postbank required the national fields for identification.
|
|
81
|
+
const descriptor = internationalAccount(configWith([]), account);
|
|
82
|
+
expect(descriptor).toEqual({
|
|
83
|
+
iban: 'DE89370400440532013000',
|
|
84
|
+
bic: 'BANKDEFFXXX',
|
|
85
|
+
accountNumber: '1234567890',
|
|
86
|
+
subAccountId: 'Girokonto',
|
|
87
|
+
bank: { country: 280, bankId: '10020030' },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
it('keeps the national fields for an account without an IBAN, whatever the flag says', () => {
|
|
91
|
+
// A securities account typically has none, and nothing else identifies it.
|
|
92
|
+
const descriptor = internationalAccount(configWith([hispas(false)]), depot);
|
|
93
|
+
expect(descriptor).toEqual({
|
|
94
|
+
accountNumber: '9876543210',
|
|
95
|
+
subAccountId: 'Girokonto',
|
|
96
|
+
bank: { country: 280, bankId: '10020030' },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AccountType, describeAccount } from '../bankAccount.js';
|
|
3
|
+
import { Language } from '../codes.js';
|
|
4
|
+
import { FinTSConfig } from '../config.js';
|
|
5
|
+
import { HKSAL } from '../segments/HKSAL.js';
|
|
6
|
+
import { HKWPD } from '../segments/HKWPD.js';
|
|
7
|
+
// A bank that gives a securities account and the current account it settles through
|
|
8
|
+
// the same number, distinguishing them only by sub-account id. That is within the
|
|
9
|
+
// specification: FinTS identifies an account by both together.
|
|
10
|
+
const checkingAccount = {
|
|
11
|
+
accountNumber: '1234567890',
|
|
12
|
+
subAccountId: 'Girokonto',
|
|
13
|
+
bank: { country: 280, bankId: '10020030' },
|
|
14
|
+
iban: 'DE89370400440532013000',
|
|
15
|
+
bic: 'BANKDEFFXXX',
|
|
16
|
+
customerId: 'customer1',
|
|
17
|
+
accountType: AccountType.Miscellaneous,
|
|
18
|
+
currency: 'EUR',
|
|
19
|
+
holder1: 'Test User',
|
|
20
|
+
allowedTransactions: [{ transId: HKSAL.Id, numSignatures: 0 }],
|
|
21
|
+
};
|
|
22
|
+
const securitiesAccount = {
|
|
23
|
+
...checkingAccount,
|
|
24
|
+
subAccountId: 'Depot',
|
|
25
|
+
iban: undefined,
|
|
26
|
+
bic: undefined,
|
|
27
|
+
allowedTransactions: [{ transId: HKWPD.Id, numSignatures: 0 }],
|
|
28
|
+
};
|
|
29
|
+
const singleAccount = {
|
|
30
|
+
...checkingAccount,
|
|
31
|
+
accountNumber: '5555555555',
|
|
32
|
+
subAccountId: undefined,
|
|
33
|
+
};
|
|
34
|
+
function configWith(accounts) {
|
|
35
|
+
return FinTSConfig.fromBankingInformation('product', '1.0', {
|
|
36
|
+
systemId: 'SYSTEM01',
|
|
37
|
+
bpd: {
|
|
38
|
+
version: 1,
|
|
39
|
+
url: 'https://bank.example.com/fints',
|
|
40
|
+
countryCode: 280,
|
|
41
|
+
bankId: '10020030',
|
|
42
|
+
bankName: 'Example Bank',
|
|
43
|
+
allowedTransactions: [],
|
|
44
|
+
maxTransactionsPerMessage: 1,
|
|
45
|
+
supportedLanguages: [Language.German],
|
|
46
|
+
supportedHbciVersions: [300],
|
|
47
|
+
supportedTanMethods: [],
|
|
48
|
+
availableTanMethodIds: [],
|
|
49
|
+
},
|
|
50
|
+
upd: { version: 1, usage: 0, bankAccounts: accounts },
|
|
51
|
+
bankMessages: [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
describe('addressing an account by number', () => {
|
|
55
|
+
it('resolves a number that only one account has', () => {
|
|
56
|
+
const config = configWith([checkingAccount, singleAccount]);
|
|
57
|
+
expect(config.getBankAccount('5555555555').subAccountId).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
it('refuses a number two accounts share, instead of picking one', () => {
|
|
60
|
+
// Picking the first is what makes the failure invisible: a balance comes back,
|
|
61
|
+
// it is the other account's, and nothing in the response says so.
|
|
62
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
63
|
+
expect(() => config.getBankAccount('1234567890')).toThrow(/not unique/);
|
|
64
|
+
});
|
|
65
|
+
it('names the sub-account ids, so the caller can tell them apart', () => {
|
|
66
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
67
|
+
expect(() => config.getBankAccount('1234567890')).toThrow(/Girokonto, Depot/);
|
|
68
|
+
});
|
|
69
|
+
it('still says so when the number matches nothing', () => {
|
|
70
|
+
expect(() => configWith([checkingAccount]).getBankAccount('0000000000')).toThrow(/not found in UPD/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
describe('addressing an account by the account itself', () => {
|
|
74
|
+
it('reaches the one a shared number cannot', () => {
|
|
75
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
76
|
+
expect(config.getBankAccount(securitiesAccount).subAccountId).toBe('Depot');
|
|
77
|
+
expect(config.getBankAccount(checkingAccount).subAccountId).toBe('Girokonto');
|
|
78
|
+
});
|
|
79
|
+
it('decides what that account may do, not what the other one may', () => {
|
|
80
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
81
|
+
expect(config.isAccountTransactionSupported(securitiesAccount, HKWPD.Id)).toBe(true);
|
|
82
|
+
expect(config.isAccountTransactionSupported(checkingAccount, HKWPD.Id)).toBe(false);
|
|
83
|
+
expect(config.isAccountTransactionSupported(checkingAccount, HKSAL.Id)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('resolves against the UPD rather than trusting what it was handed', () => {
|
|
86
|
+
// A caller may hold an account from a persisted earlier session. The entry the
|
|
87
|
+
// bank sent this time is the one carrying the current allowed transactions.
|
|
88
|
+
const staleAccount = { ...securitiesAccount, allowedTransactions: [] };
|
|
89
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
90
|
+
expect(config.isAccountTransactionSupported(staleAccount, HKWPD.Id)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('refuses an account the bank did not report', () => {
|
|
93
|
+
const config = configWith([checkingAccount]);
|
|
94
|
+
const unreportedAccount = { ...checkingAccount, subAccountId: 'Sparkonto' };
|
|
95
|
+
expect(() => config.getBankAccount(unreportedAccount)).toThrow(/not found in UPD/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('naming an account in an error', () => {
|
|
99
|
+
it('reads as the number alone where that is all there is', () => {
|
|
100
|
+
expect(describeAccount('1234567890')).toBe('1234567890');
|
|
101
|
+
expect(describeAccount(singleAccount)).toBe('5555555555');
|
|
102
|
+
});
|
|
103
|
+
it('adds the sub-account id where there is one', () => {
|
|
104
|
+
// Otherwise an account passed as an object prints as [object Object].
|
|
105
|
+
expect(describeAccount(securitiesAccount)).toBe('1234567890 (Depot)');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
describe('matching an account the bank itself named', () => {
|
|
109
|
+
it('uses the sub-account id where the bank repeated it', () => {
|
|
110
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
111
|
+
expect(config.matchBankAccount({ accountNumber: '1234567890', subAccountId: 'Depot' })?.subAccountId).toBe('Depot');
|
|
112
|
+
});
|
|
113
|
+
it('still finds an account whose number only it has, sub-account id or not', () => {
|
|
114
|
+
// Banks are not consistent about repeating it, and a number only one account
|
|
115
|
+
// has identifies that account either way.
|
|
116
|
+
const config = configWith([checkingAccount, singleAccount]);
|
|
117
|
+
expect(config.matchBankAccount({ accountNumber: '5555555555' })?.accountNumber).toBe('5555555555');
|
|
118
|
+
});
|
|
119
|
+
it('gives up quietly where it cannot tell, rather than throwing', () => {
|
|
120
|
+
// This runs for entries the bank supplied — HISPA travels with every dialog —
|
|
121
|
+
// so throwing here would fail every request at a bank that shares numbers,
|
|
122
|
+
// before any of them reached its order. That is exactly what happened once.
|
|
123
|
+
const config = configWith([checkingAccount, securitiesAccount]);
|
|
124
|
+
expect(config.matchBankAccount({ accountNumber: '1234567890' })).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { AccountType } from '../bankAccount.js';
|
|
3
|
+
import { Language } from '../codes.js';
|
|
4
|
+
import { FinTSConfig } from '../config.js';
|
|
5
|
+
import { BalanceInteraction } from '../interactions/balanceInteraction.js';
|
|
6
|
+
import { ElectronicStatementInteraction } from '../interactions/electronicStatementInteraction.js';
|
|
7
|
+
import { PortfolioInteraction } from '../interactions/portfolioInteraction.js';
|
|
8
|
+
import { StatementInteractionCAMT } from '../interactions/statementInteractionCAMT.js';
|
|
9
|
+
import { StatementInteractionMT940 } from '../interactions/statementInteractionMT940.js';
|
|
10
|
+
import { registerSegments } from '../segments/registry.js';
|
|
11
|
+
registerSegments();
|
|
12
|
+
// The layer between "the segment encodes correctly" and "the client picks the right
|
|
13
|
+
// interaction": what an interaction puts into the account connection. Nothing
|
|
14
|
+
// covered it before — the segment tests build their account by hand, and the client
|
|
15
|
+
// tests mock `Dialog.start`, which throws the built request away.
|
|
16
|
+
const GIRO = '1234567890';
|
|
17
|
+
const DEPOT = '9876543210';
|
|
18
|
+
const BANK = { country: 280, bankId: '10020030' };
|
|
19
|
+
const IBAN = 'DE89370400440532013000';
|
|
20
|
+
const konto = (accountNumber, transIds, iban) => ({
|
|
21
|
+
accountNumber,
|
|
22
|
+
subAccountId: 'Girokonto',
|
|
23
|
+
bank: BANK,
|
|
24
|
+
iban,
|
|
25
|
+
bic: iban ? 'BANKDEFFXXX' : undefined,
|
|
26
|
+
customerId: 'customer1',
|
|
27
|
+
accountType: AccountType.CheckingAccount,
|
|
28
|
+
currency: 'EUR',
|
|
29
|
+
holder1: 'Test User',
|
|
30
|
+
allowedTransactions: transIds.map((transId) => ({ transId, numSignatures: 1 })),
|
|
31
|
+
});
|
|
32
|
+
function configFor(transactions, nationalAccountAllowed) {
|
|
33
|
+
const allowedTransactions = Object.entries(transactions).map(([transId, versions]) => ({ transId, versions, tanRequired: false }));
|
|
34
|
+
if (nationalAccountAllowed !== undefined) {
|
|
35
|
+
allowedTransactions.push({
|
|
36
|
+
transId: 'HKSPA',
|
|
37
|
+
versions: [1],
|
|
38
|
+
tanRequired: false,
|
|
39
|
+
params: {
|
|
40
|
+
individualAccountRetrievalAllowed: false,
|
|
41
|
+
nationalAccountAllowed,
|
|
42
|
+
structuredPurposeAllowed: false,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return FinTSConfig.fromBankingInformation('product', '1.0', {
|
|
47
|
+
systemId: 'SYSTEM01',
|
|
48
|
+
bpd: {
|
|
49
|
+
version: 1,
|
|
50
|
+
url: 'https://bank.example.com/fints',
|
|
51
|
+
countryCode: 280,
|
|
52
|
+
bankId: '10020030',
|
|
53
|
+
bankName: 'Example Bank',
|
|
54
|
+
allowedTransactions,
|
|
55
|
+
maxTransactionsPerMessage: 1,
|
|
56
|
+
supportedLanguages: [Language.German],
|
|
57
|
+
supportedHbciVersions: [300],
|
|
58
|
+
supportedTanMethods: [],
|
|
59
|
+
availableTanMethodIds: [],
|
|
60
|
+
},
|
|
61
|
+
upd: {
|
|
62
|
+
version: 1,
|
|
63
|
+
usage: 0,
|
|
64
|
+
bankAccounts: [
|
|
65
|
+
konto(GIRO, ['HKSAL', 'HKKAZ', 'HKCAZ', 'HKEKA'], IBAN),
|
|
66
|
+
konto(DEPOT, ['HKWPD'], undefined),
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
bankMessages: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// biome-ignore lint/suspicious/noExplicitAny: reading one field off a built segment
|
|
73
|
+
const account = (segment) => segment.account;
|
|
74
|
+
describe('HKCAZ — international at every version', () => {
|
|
75
|
+
it('sends IBAN and BIC only when the bank forbids the national fields', () => {
|
|
76
|
+
const config = configFor({ HKCAZ: [1] }, false);
|
|
77
|
+
const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(config);
|
|
78
|
+
expect(account(hkcaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' });
|
|
79
|
+
});
|
|
80
|
+
it('sends both halves when the bank allows them', () => {
|
|
81
|
+
const config = configFor({ HKCAZ: [1] }, true);
|
|
82
|
+
const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(config);
|
|
83
|
+
expect(account(hkcaz)).toEqual({
|
|
84
|
+
iban: IBAN,
|
|
85
|
+
bic: 'BANKDEFFXXX',
|
|
86
|
+
accountNumber: GIRO,
|
|
87
|
+
subAccountId: 'Girokonto',
|
|
88
|
+
bank: BANK,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('HKSAL — national up to version 6, international from 7', () => {
|
|
93
|
+
it('sends the national form at version 6, with no IBAN', () => {
|
|
94
|
+
const [hksal] = new BalanceInteraction(GIRO).createSegments(configFor({ HKSAL: [6] }, false));
|
|
95
|
+
expect(account(hksal)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK });
|
|
96
|
+
});
|
|
97
|
+
it('honours the flag at version 7 instead of filling both halves', () => {
|
|
98
|
+
const [hksal] = new BalanceInteraction(GIRO).createSegments(configFor({ HKSAL: [7] }, false));
|
|
99
|
+
expect(account(hksal)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('HKKAZ — national up to version 6, international from 7', () => {
|
|
103
|
+
it('sends the national form at version 6', () => {
|
|
104
|
+
const [hkkaz] = new StatementInteractionMT940(GIRO).createSegments(configFor({ HKKAZ: [6] }, false));
|
|
105
|
+
expect(account(hkkaz)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK });
|
|
106
|
+
});
|
|
107
|
+
it('honours the flag at version 7', () => {
|
|
108
|
+
const [hkkaz] = new StatementInteractionMT940(GIRO).createSegments(configFor({ HKKAZ: [7] }, false));
|
|
109
|
+
expect(account(hkkaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('HKEKA — national up to version 3, international from 4', () => {
|
|
113
|
+
it('sends the national form at version 3', () => {
|
|
114
|
+
const [hkeka] = new ElectronicStatementInteraction(GIRO, {}).createSegments(configFor({ HKEKA: [3] }, false));
|
|
115
|
+
expect(account(hkeka)).toEqual({ accountNumber: GIRO, subAccountId: 'Girokonto', bank: BANK });
|
|
116
|
+
});
|
|
117
|
+
it('honours the flag at version 4', () => {
|
|
118
|
+
const [hkeka] = new ElectronicStatementInteraction(GIRO, {}).createSegments(configFor({ HKEKA: [4] }, false));
|
|
119
|
+
expect(account(hkeka)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('HKWPD — national at every version', () => {
|
|
123
|
+
it('sends the national form, and the depot has no IBAN to send anyway', () => {
|
|
124
|
+
const [hkwpd] = new PortfolioInteraction(DEPOT).createSegments(configFor({ HKWPD: [5] }, false));
|
|
125
|
+
// biome-ignore lint/suspicious/noExplicitAny: reading one field off a built segment
|
|
126
|
+
expect(hkwpd.depot).toEqual({
|
|
127
|
+
accountNumber: DEPOT,
|
|
128
|
+
subAccountId: 'Girokonto',
|
|
129
|
+
bank: BANK,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
// The two banks that pulled this in opposite directions. #19/#20 reduced the CAMT
|
|
134
|
+
// descriptor to IBAN and BIC because comdirect rejects anything more; #25 reported
|
|
135
|
+
// Postbank answering "Angaben zur nationalen Kontoverbindung für Identifikation
|
|
136
|
+
// erforderlich", and the reduction was reverted. Neither bank was wrong, and neither
|
|
137
|
+
// fix could hold, because the choice was hard-coded either way. It is data now.
|
|
138
|
+
describe('the two banks that pulled this in opposite directions', () => {
|
|
139
|
+
it('a bank refusing the national fields gets IBAN and BIC only', () => {
|
|
140
|
+
const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(configFor({ HKCAZ: [1] }, false));
|
|
141
|
+
expect(account(hkcaz)).toEqual({ iban: IBAN, bic: 'BANKDEFFXXX' });
|
|
142
|
+
});
|
|
143
|
+
it('a bank that says nothing keeps them, so nothing working today regresses', () => {
|
|
144
|
+
const [hkcaz] = new StatementInteractionCAMT(GIRO).createSegments(configFor({ HKCAZ: [1] }));
|
|
145
|
+
expect(account(hkcaz)).toEqual({
|
|
146
|
+
iban: IBAN,
|
|
147
|
+
bic: 'BANKDEFFXXX',
|
|
148
|
+
accountNumber: GIRO,
|
|
149
|
+
subAccountId: 'Girokonto',
|
|
150
|
+
bank: BANK,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -77,4 +77,22 @@ describe('CustomerMessage', () => {
|
|
|
77
77
|
const encodedMessage = customerMessage.encode();
|
|
78
78
|
const _message = Message.decode(encodedMessage);
|
|
79
79
|
});
|
|
80
|
+
it('encrypts with the security profile version of the signature (two-step: PIN:2)', () => {
|
|
81
|
+
const customerMessage = new CustomerMessage('0', 1);
|
|
82
|
+
customerMessage.addSegment(hkidn);
|
|
83
|
+
customerMessage.addSegment(hkvvb);
|
|
84
|
+
customerMessage.sign(280, '12030000', '12345678', '123', '0', 900, '12345');
|
|
85
|
+
const encodedMessage = customerMessage.encode();
|
|
86
|
+
expect(encodedMessage).toContain("'HNVSK:998:3+PIN:2+998+");
|
|
87
|
+
expect(encodedMessage).toContain('HNSHK:2:4+PIN:2+900+');
|
|
88
|
+
});
|
|
89
|
+
it('encrypts with the security profile version of the signature (one-step: PIN:1)', () => {
|
|
90
|
+
const customerMessage = new CustomerMessage('0', 1);
|
|
91
|
+
customerMessage.addSegment(hkidn);
|
|
92
|
+
customerMessage.addSegment(hkvvb);
|
|
93
|
+
customerMessage.sign(280, '12030000', '12345678', '123', '0');
|
|
94
|
+
const encodedMessage = customerMessage.encode();
|
|
95
|
+
expect(encodedMessage).toContain("'HNVSK:998:3+PIN:1+998+");
|
|
96
|
+
expect(encodedMessage).toContain('HNSHK:2:4+PIN:1+999+');
|
|
97
|
+
});
|
|
80
98
|
});
|