@coderoftime/fints 0.2.0 → 0.3.0
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/CHANGELOG.md +377 -0
- package/README.md +31 -23
- package/dist/client.d.ts +3 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +52 -9
- package/dist/client.js.map +1 -1
- package/dist/core/account.d.ts +15 -4
- package/dist/core/account.d.ts.map +1 -1
- package/dist/core/account.js +84 -36
- package/dist/core/account.js.map +1 -1
- package/dist/core/amount.d.ts +13 -3
- package/dist/core/amount.d.ts.map +1 -1
- package/dist/core/amount.js +42 -8
- package/dist/core/amount.js.map +1 -1
- package/dist/core/balance.d.ts +3 -0
- package/dist/core/balance.d.ts.map +1 -1
- package/dist/core/balance.js +5 -0
- package/dist/core/balance.js.map +1 -1
- package/dist/core/bank-info.d.ts +20 -3
- package/dist/core/bank-info.d.ts.map +1 -1
- package/dist/core/bank-info.js +25 -4
- package/dist/core/bank-info.js.map +1 -1
- package/dist/core/client.d.ts +42 -15
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/config.d.ts +15 -7
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +29 -14
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts +17 -5
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +25 -7
- package/dist/core/errors.js.map +1 -1
- package/dist/core/feedback.d.ts +7 -0
- package/dist/core/feedback.d.ts.map +1 -1
- package/dist/core/feedback.js +10 -1
- package/dist/core/feedback.js.map +1 -1
- package/dist/core/transaction.d.ts +5 -0
- package/dist/core/transaction.d.ts.map +1 -1
- package/dist/core/transaction.js +12 -0
- package/dist/core/transaction.js.map +1 -1
- package/dist/core/transport.d.ts +22 -7
- package/dist/core/transport.d.ts.map +1 -1
- package/dist/core/transport.js +51 -11
- package/dist/core/transport.js.map +1 -1
- package/dist/formats/camt.d.ts +7 -4
- package/dist/formats/camt.d.ts.map +1 -1
- package/dist/formats/camt.js +53 -54
- package/dist/formats/camt.js.map +1 -1
- package/dist/formats/mt940.d.ts +18 -6
- package/dist/formats/mt940.d.ts.map +1 -1
- package/dist/formats/mt940.js +83 -66
- package/dist/formats/mt940.js.map +1 -1
- package/dist/formats/readers.d.ts.map +1 -1
- package/dist/formats/readers.js +6 -2
- package/dist/formats/readers.js.map +1 -1
- package/dist/formats/sepa-purpose.d.ts +7 -4
- package/dist/formats/sepa-purpose.d.ts.map +1 -1
- package/dist/formats/sepa-purpose.js +18 -9
- package/dist/formats/sepa-purpose.js.map +1 -1
- package/dist/session/dialog-runner.d.ts +5 -2
- package/dist/session/dialog-runner.d.ts.map +1 -1
- package/dist/session/dialog-runner.js +23 -7
- package/dist/session/dialog-runner.js.map +1 -1
- package/dist/session/login-guard.d.ts +4 -4
- package/dist/session/login-guard.d.ts.map +1 -1
- package/dist/session/login-guard.js +29 -14
- package/dist/session/login-guard.js.map +1 -1
- package/dist/session/session-setup.d.ts +14 -5
- package/dist/session/session-setup.d.ts.map +1 -1
- package/dist/session/session-setup.js +30 -9
- package/dist/session/session-setup.js.map +1 -1
- package/dist/session/session-state.d.ts +4 -3
- package/dist/session/session-state.d.ts.map +1 -1
- package/dist/session/session-state.js +28 -9
- package/dist/session/session-state.js.map +1 -1
- package/dist/session/signed-dialog.d.ts +19 -13
- package/dist/session/signed-dialog.d.ts.map +1 -1
- package/dist/session/signed-dialog.js +45 -30
- package/dist/session/signed-dialog.js.map +1 -1
- package/dist/session/user-prompts.d.ts +6 -1
- package/dist/session/user-prompts.d.ts.map +1 -1
- package/dist/session/user-prompts.js +19 -4
- package/dist/session/user-prompts.js.map +1 -1
- package/dist/tan/challenge.d.ts +15 -13
- package/dist/tan/challenge.d.ts.map +1 -1
- package/dist/tan/challenge.js +32 -26
- package/dist/tan/challenge.js.map +1 -1
- package/dist/tan/flicker.d.ts +5 -1
- package/dist/tan/flicker.d.ts.map +1 -1
- package/dist/tan/flicker.js +42 -30
- package/dist/tan/flicker.js.map +1 -1
- package/dist/tan/tan-image.d.ts +4 -1
- package/dist/tan/tan-image.d.ts.map +1 -1
- package/dist/tan/tan-image.js +8 -2
- package/dist/tan/tan-image.js.map +1 -1
- package/dist/tan/tan-models.d.ts +9 -1
- package/dist/tan/tan-models.d.ts.map +1 -1
- package/dist/tan/tan-models.js +14 -2
- package/dist/tan/tan-models.js.map +1 -1
- package/dist/util/arguments.d.ts +13 -0
- package/dist/util/arguments.d.ts.map +1 -0
- package/dist/util/arguments.js +40 -0
- package/dist/util/arguments.js.map +1 -0
- package/dist/util/async.d.ts +4 -1
- package/dist/util/async.d.ts.map +1 -1
- package/dist/util/async.js +6 -3
- package/dist/util/async.js.map +1 -1
- package/dist/util/bytes.d.ts +1 -1
- package/dist/util/bytes.d.ts.map +1 -1
- package/dist/util/bytes.js +3 -3
- package/dist/util/bytes.js.map +1 -1
- package/dist/util/dates.d.ts +3 -10
- package/dist/util/dates.d.ts.map +1 -1
- package/dist/util/dates.js +49 -39
- package/dist/util/dates.js.map +1 -1
- package/dist/util/errors.d.ts +5 -3
- package/dist/util/errors.d.ts.map +1 -1
- package/dist/util/errors.js +8 -4
- package/dist/util/errors.js.map +1 -1
- package/dist/util/immutable-map.d.ts +5 -2
- package/dist/util/immutable-map.d.ts.map +1 -1
- package/dist/util/immutable-map.js +15 -12
- package/dist/util/immutable-map.js.map +1 -1
- package/dist/util/inspect.d.ts +11 -0
- package/dist/util/inspect.d.ts.map +1 -0
- package/dist/util/inspect.js +14 -0
- package/dist/util/inspect.js.map +1 -0
- package/dist/util/text.d.ts +18 -2
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +50 -5
- package/dist/util/text.js.map +1 -1
- package/dist/util/xml.d.ts +6 -4
- package/dist/util/xml.d.ts.map +1 -1
- package/dist/util/xml.js +106 -44
- package/dist/util/xml.js.map +1 -1
- package/dist/v3/bank-connection.d.ts +23 -3
- package/dist/v3/bank-connection.d.ts.map +1 -1
- package/dist/v3/bank-connection.js +66 -6
- package/dist/v3/bank-connection.js.map +1 -1
- package/dist/v3/dialog.d.ts +6 -2
- package/dist/v3/dialog.d.ts.map +1 -1
- package/dist/v3/dialog.js +20 -9
- package/dist/v3/dialog.js.map +1 -1
- package/dist/v3/message.d.ts +18 -10
- package/dist/v3/message.d.ts.map +1 -1
- package/dist/v3/message.js +92 -75
- package/dist/v3/message.js.map +1 -1
- package/dist/v3/parameter-store.d.ts +43 -7
- package/dist/v3/parameter-store.d.ts.map +1 -1
- package/dist/v3/parameter-store.js +69 -15
- package/dist/v3/parameter-store.js.map +1 -1
- package/dist/v3/parameters.d.ts +12 -11
- package/dist/v3/parameters.d.ts.map +1 -1
- package/dist/v3/parameters.js +57 -35
- package/dist/v3/parameters.js.map +1 -1
- package/dist/v3/protocol.d.ts +16 -9
- package/dist/v3/protocol.d.ts.map +1 -1
- package/dist/v3/protocol.js +57 -26
- package/dist/v3/protocol.js.map +1 -1
- package/dist/v3/queries.d.ts +2 -2
- package/dist/v3/queries.d.ts.map +1 -1
- package/dist/v3/queries.js +24 -16
- package/dist/v3/queries.js.map +1 -1
- package/dist/v3/segments.d.ts +18 -11
- package/dist/v3/segments.d.ts.map +1 -1
- package/dist/v3/segments.js +30 -21
- package/dist/v3/segments.js.map +1 -1
- package/dist/v3/syntax.d.ts +15 -11
- package/dist/v3/syntax.d.ts.map +1 -1
- package/dist/v3/syntax.js +30 -15
- package/dist/v3/syntax.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +58 -17
- package/src/core/account.ts +91 -35
- package/src/core/amount.ts +42 -9
- package/src/core/balance.ts +6 -0
- package/src/core/bank-info.ts +28 -4
- package/src/core/client.ts +42 -15
- package/src/core/config.ts +37 -18
- package/src/core/errors.ts +27 -7
- package/src/core/feedback.ts +12 -1
- package/src/core/transaction.ts +13 -0
- package/src/core/transport.ts +62 -16
- package/src/formats/camt.ts +62 -51
- package/src/formats/mt940.ts +114 -78
- package/src/formats/readers.ts +6 -2
- package/src/formats/sepa-purpose.ts +17 -9
- package/src/session/dialog-runner.ts +34 -7
- package/src/session/login-guard.ts +29 -13
- package/src/session/session-setup.ts +27 -9
- package/src/session/session-state.ts +25 -8
- package/src/session/signed-dialog.ts +60 -30
- package/src/session/user-prompts.ts +28 -3
- package/src/tan/challenge.ts +50 -36
- package/src/tan/flicker.ts +40 -29
- package/src/tan/tan-image.ts +9 -2
- package/src/tan/tan-models.ts +16 -2
- package/src/util/arguments.ts +40 -0
- package/src/util/async.ts +10 -4
- package/src/util/bytes.ts +3 -3
- package/src/util/dates.ts +52 -38
- package/src/util/errors.ts +9 -4
- package/src/util/immutable-map.ts +15 -12
- package/src/util/inspect.ts +14 -0
- package/src/util/text.ts +47 -5
- package/src/util/xml.ts +105 -38
- package/src/v3/bank-connection.ts +65 -6
- package/src/v3/dialog.ts +18 -8
- package/src/v3/message.ts +81 -72
- package/src/v3/parameter-store.ts +82 -16
- package/src/v3/parameters.ts +68 -41
- package/src/v3/protocol.ts +61 -28
- package/src/v3/queries.ts +26 -18
- package/src/v3/segments.ts +32 -20
- package/src/v3/syntax.ts +31 -14
package/src/formats/camt.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { Amount } from '../core/amount.js';
|
|
2
|
-
import { Transaction, type TransactionStatus } from '../core/transaction.js';
|
|
3
|
-
import {
|
|
1
|
+
import { addsUpTo, Amount } from '../core/amount.js';
|
|
2
|
+
import { requireStatus, Transaction, type TransactionStatus } from '../core/transaction.js';
|
|
3
|
+
import { parseIsoDate } from '../util/dates.js';
|
|
4
4
|
import { fail, FormatError } from '../util/errors.js';
|
|
5
|
+
import { frozenWithInspect } from '../util/inspect.js';
|
|
6
|
+
import { counted, maskedId, nonBlank, trimBlanks } from '../util/text.js';
|
|
5
7
|
import { parseXml, type XmlElement } from '../util/xml.js';
|
|
8
|
+
import { maxFieldLength } from './mt940.js';
|
|
6
9
|
import { RecordBudget } from './record-budget.js';
|
|
7
10
|
import { providedReference, SepaPurpose } from './sepa-purpose.js';
|
|
8
11
|
|
|
@@ -28,24 +31,32 @@ export const Camt = Object.freeze({
|
|
|
28
31
|
* statements of several accounts, which `parse` keeps apart. Throws as `parse` does.
|
|
29
32
|
*/
|
|
30
33
|
parseTransactions(xml: string, status?: TransactionStatus): Transaction[] {
|
|
31
|
-
return Camt.parse(xml, status).flatMap((s) =>
|
|
34
|
+
return Camt.parse(xml, status).flatMap((s) => s.transactions);
|
|
32
35
|
},
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
* Parses all statements, each with the account it is for. Throws `FormatError` for malformed XML, for
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* Parses all statements, each with the account it is for. Throws `FormatError` for malformed XML, for XML nested more
|
|
39
|
+
* than 100 levels deep or of more than 500,000 nodes besides text or one per 16 characters, whichever is more, for a
|
|
40
|
+
* document without any statement, for an entry without amount, currency, credit/debit indicator or date, for a date
|
|
41
|
+
* it cannot read, for remittance information of more than 65,536 characters, and for more than 200,000 statements and
|
|
42
|
+
* transactions.
|
|
38
43
|
*
|
|
39
|
-
* `status` overrides the booking status stated in the entries
|
|
44
|
+
* `status` overrides the booking status stated in the entries, unless it is `undefined` or `null`; throws `TypeError`
|
|
45
|
+
* for one other than `booked` and `pending`.
|
|
40
46
|
*/
|
|
41
47
|
parse(xml: string, status?: TransactionStatus): CamtStatement[] {
|
|
42
|
-
return parseCamt(xml, status, new RecordBudget());
|
|
48
|
+
return parseCamt(xml, forcedStatus(status), new RecordBudget());
|
|
43
49
|
},
|
|
44
50
|
});
|
|
45
51
|
|
|
52
|
+
/** The `status` of `Camt.parse`; `undefined` for `null`, the status the entries state. */
|
|
53
|
+
function forcedStatus(status: unknown): TransactionStatus | undefined {
|
|
54
|
+
return status === undefined || status === null ? undefined : requireStatus(status);
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/** `Camt.parse`, which takes each statement and transaction from `budget`, before it builds it. */
|
|
47
58
|
export function parseCamt(xml: string, status: TransactionStatus | undefined, budget: RecordBudget): CamtStatement[] {
|
|
48
|
-
const root = parseXml(xml
|
|
59
|
+
const root = parseXml(trimBlanks(xml));
|
|
49
60
|
const statements = root.childElements.flatMap((message) =>
|
|
50
61
|
message.childElements.filter((c) => statementNames.has(c.localName)),
|
|
51
62
|
);
|
|
@@ -54,25 +65,21 @@ export function parseCamt(xml: string, status: TransactionStatus | undefined, bu
|
|
|
54
65
|
budget.take();
|
|
55
66
|
const account = statement.child('Acct');
|
|
56
67
|
const entries = statement.childrenNamed('Ntry');
|
|
57
|
-
return
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
return frozenWithInspect(
|
|
69
|
+
{
|
|
70
|
+
reference: statement.textAt(['Id']),
|
|
71
|
+
accountId: account?.textAt(['Id', 'IBAN']) ?? account?.textAt(['Id', 'Othr', 'Id']),
|
|
72
|
+
currency: account?.textAt(['Ccy']),
|
|
73
|
+
transactions: Object.freeze(entries.flatMap((entry) => new CamtEntry(entry, status, budget).transactions())),
|
|
74
|
+
},
|
|
75
|
+
(s) => `CamtStatement(${maskedId(s.accountId ?? '')}, ${counted(s.transactions.length, 'transaction')})`,
|
|
76
|
+
);
|
|
63
77
|
});
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/** Width of the purpose lines banks cut the remittance information into, as in MT940 and DTAUS. */
|
|
67
81
|
const cutLineLength = 35;
|
|
68
82
|
|
|
69
|
-
/**
|
|
70
|
-
* The SEPA keywords of `purposeCodes` that count only where the bank states them apart, as `endToEndId`, `mandateId`
|
|
71
|
-
* and `creditorId`: in the remittance text, which the payer or the payee writes, a creditor ID or mandate reference
|
|
72
|
-
* could be that of a creditor the app trusts.
|
|
73
|
-
*/
|
|
74
|
-
const keywordsStatedApart = new Set(['EREF', 'MREF', 'CRED']);
|
|
75
|
-
|
|
76
83
|
/**
|
|
77
84
|
* One booking (`Ntry`) of a report; a batch booking yields one transaction per transaction detail (`TxDtls`) when the
|
|
78
85
|
* details state their amounts and these add up to the booked amount.
|
|
@@ -134,7 +141,8 @@ class CamtEntry {
|
|
|
134
141
|
debitIndicator(detail.textAt(['CdtDbtInd'])) ?? this.debit,
|
|
135
142
|
),
|
|
136
143
|
);
|
|
137
|
-
|
|
144
|
+
const parts = split.map((t) => t.amount);
|
|
145
|
+
return addsUpTo(parts, booking.amount) ? split : [booking];
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
/** A transaction of `amount`, booked as a debit if `debit`, with what `details` tell about it. */
|
|
@@ -148,25 +156,24 @@ class CamtEntry {
|
|
|
148
156
|
const counterparty = originalDebit ? 'Cdtr' : 'Dbtr';
|
|
149
157
|
const lines = (details?.child('RmtInf')?.childrenNamed('Ustrd') ?? [])
|
|
150
158
|
.map((line) => line.innerText)
|
|
151
|
-
.filter((text) => text
|
|
159
|
+
.filter((text) => trimBlanks(text) !== '');
|
|
152
160
|
// A line of 35 characters goes on in the next one as it is, maybe in the middle of a word or before a blank; any
|
|
153
161
|
// other line ends a phrase, which the next one follows after a blank.
|
|
154
162
|
const cut = (i: number): boolean => lines[i]?.length === cutLineLength;
|
|
155
163
|
const purposeParts = lines.map((line, i) => {
|
|
156
|
-
const part = cut(i) ? line : line
|
|
157
|
-
return i === 0 || cut(i - 1) ? part : ` ${part
|
|
164
|
+
const part = cut(i) ? line : trimBlanks(line, { start: false });
|
|
165
|
+
return i === 0 || cut(i - 1) ? part : ` ${trimBlanks(part, { end: false })}`;
|
|
158
166
|
});
|
|
159
|
-
const
|
|
167
|
+
const remittance = purposeParts.join('');
|
|
168
|
+
// As long as an MT940 field: the keywords of megabytes of text would take memory without limit.
|
|
169
|
+
if (remittance.length > maxFieldLength) throw new FormatError('camt remittance information too long');
|
|
160
170
|
const keywords = SepaPurpose.parseParts(purposeParts);
|
|
161
171
|
const endToEndId = providedReference(details?.textAt(['Refs', 'EndToEndId']));
|
|
162
172
|
const mandateId = details?.textAt(['Refs', 'MndtId']);
|
|
163
173
|
const creditorId = creditorIdOf(parties?.child('Cdtr'));
|
|
164
174
|
const purposeCode = details?.textAt(['Purp', 'Cd']);
|
|
165
175
|
const typeId = this.proprietaryCode[0]?.length === 4 ? this.proprietaryCode[0] : undefined;
|
|
166
|
-
const purposeCodes =
|
|
167
|
-
if (endToEndId !== undefined) purposeCodes.set('EREF', endToEndId);
|
|
168
|
-
if (mandateId !== undefined) purposeCodes.set('MREF', mandateId);
|
|
169
|
-
if (creditorId !== undefined) purposeCodes.set('CRED', creditorId);
|
|
176
|
+
const purposeCodes = withKeywordsStatedApart(keywords, { EREF: endToEndId, MREF: mandateId, CRED: creditorId });
|
|
170
177
|
if (purposeCode !== undefined) purposeCodes.set('PURP', purposeCode);
|
|
171
178
|
return new Transaction({
|
|
172
179
|
status: this.status,
|
|
@@ -175,7 +182,11 @@ class CamtEntry {
|
|
|
175
182
|
amount: debit ? amount.negate() : amount,
|
|
176
183
|
isReversal: this.reversal,
|
|
177
184
|
bookingText: this.bookingText ?? details?.textAt(['AddtlTxInf']),
|
|
178
|
-
purpose:
|
|
185
|
+
purpose:
|
|
186
|
+
keywords.get('SVWZ') ??
|
|
187
|
+
nonBlank(remittance) ??
|
|
188
|
+
details?.textAt(['RmtInf', 'Strd', 'CdtrRefInf', 'Ref']) ??
|
|
189
|
+
details?.textAt(['AddtlTxInf']),
|
|
179
190
|
counterpartyName: partyName(parties?.child(counterparty)),
|
|
180
191
|
counterpartyIban: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'IBAN']),
|
|
181
192
|
counterpartyAccountNumber: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'Othr', 'Id']),
|
|
@@ -206,18 +217,20 @@ function amountOf(element: XmlElement, currency: string): Amount {
|
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
220
|
+
* `keywords` with the values of EREF, MREF and CRED that the bank `stated` apart, as `endToEndId`, `mandateId` and
|
|
221
|
+
* `creditorId`, in the place the text gives them, and without those it does not state: in the remittance text, which
|
|
222
|
+
* the payer or the payee writes, a creditor ID or mandate reference could be that of a creditor the app trusts.
|
|
211
223
|
*/
|
|
212
|
-
function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
224
|
+
function withKeywordsStatedApart(
|
|
225
|
+
keywords: ReadonlyMap<string, string>,
|
|
226
|
+
stated: Readonly<Record<'EREF' | 'MREF' | 'CRED', string | undefined>>,
|
|
227
|
+
): Map<string, string> {
|
|
228
|
+
const codes = new Map(keywords);
|
|
229
|
+
for (const [keyword, value] of Object.entries(stated)) {
|
|
230
|
+
if (value === undefined) codes.delete(keyword);
|
|
231
|
+
else codes.set(keyword, value);
|
|
220
232
|
}
|
|
233
|
+
return codes;
|
|
221
234
|
}
|
|
222
235
|
|
|
223
236
|
/** `true` for `DBIT`, `false` for `CRDT`, `undefined` if `indicator` is missing; throws for any other value. */
|
|
@@ -235,16 +248,14 @@ function debitIndicator(indicator: string | undefined): boolean | undefined {
|
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
/**
|
|
238
|
-
* The calendar date of `e`: its date (`Dt`), or
|
|
239
|
-
* UTC or another offset
|
|
240
|
-
* pass for a missing one otherwise, such as a value
|
|
251
|
+
* The calendar date of `e`: its date (`Dt`), or else its date and time (`DtTm`), both read by `parseIsoDate`, so that
|
|
252
|
+
* a date and time, which may be stated in UTC or another offset, counts on its German day; `undefined` if it states
|
|
253
|
+
* neither. Throws `FormatError` for one it cannot read, which would pass for a missing one otherwise, such as a value
|
|
254
|
+
* date the booking date stands in for.
|
|
241
255
|
*/
|
|
242
256
|
function dateOf(e: XmlElement | undefined): Date | undefined {
|
|
243
|
-
const date = e?.textAt(['Dt']);
|
|
244
|
-
|
|
245
|
-
const dateTime = e?.textAt(['DtTm']);
|
|
246
|
-
if (dateTime === undefined) return undefined;
|
|
247
|
-
return germanDay(parseIsoMoment(dateTime) ?? fail('Unreadable camt date', dateTime));
|
|
257
|
+
const date = e?.textAt(['Dt']) ?? e?.textAt(['DtTm']);
|
|
258
|
+
return date === undefined ? undefined : (parseIsoDate(date) ?? fail('Unreadable camt date', date));
|
|
248
259
|
}
|
|
249
260
|
|
|
250
261
|
function partyName(party: XmlElement | undefined): string | undefined {
|
package/src/formats/mt940.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Amount } from '../core/amount.js';
|
|
2
|
-
import { Transaction, type TransactionStatus } from '../core/transaction.js';
|
|
1
|
+
import { addsUpTo, Amount } from '../core/amount.js';
|
|
2
|
+
import { requireStatus, Transaction, type TransactionStatus } from '../core/transaction.js';
|
|
3
|
+
import { optionalString } from '../util/arguments.js';
|
|
3
4
|
import { clampedDate, parseSwiftDate } from '../util/dates.js';
|
|
4
|
-
import { FormatError } from '../util/errors.js';
|
|
5
|
-
import {
|
|
5
|
+
import { fail, FormatError } from '../util/errors.js';
|
|
6
|
+
import { frozenWithInspect } from '../util/inspect.js';
|
|
7
|
+
import { counted, maskedId, nonBlank, trimBlanks } from '../util/text.js';
|
|
6
8
|
import { RecordBudget } from './record-budget.js';
|
|
7
9
|
import { providedReference, SepaPurpose } from './sepa-purpose.js';
|
|
8
10
|
|
|
@@ -18,11 +20,17 @@ export interface Mt940Statement {
|
|
|
18
20
|
readonly currency: string | undefined;
|
|
19
21
|
/** The balance at the start of the statement (field 60), negative for a debit. */
|
|
20
22
|
readonly openingBalance: Amount | undefined;
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The date of `openingBalance`; `undefined` where the bank states none, as `0` or `000000` for the first statement of
|
|
25
|
+
* an account.
|
|
26
|
+
*/
|
|
22
27
|
readonly openingDate: Date | undefined;
|
|
23
28
|
/** The balance at the end of the statement (field 62), negative for a debit. */
|
|
24
29
|
readonly closingBalance: Amount | undefined;
|
|
25
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The date of `closingBalance`; `undefined` where the bank states none, as `0` or `000000` for the first statement of
|
|
32
|
+
* an account.
|
|
33
|
+
*/
|
|
26
34
|
readonly closingDate: Date | undefined;
|
|
27
35
|
/**
|
|
28
36
|
* The transactions of the statement, in the order the bank lists them; they lead from `openingBalance` to
|
|
@@ -41,26 +49,35 @@ export interface Mt940Options {
|
|
|
41
49
|
|
|
42
50
|
const tagPattern = /^:(\d{2}[A-Z]?):/gm;
|
|
43
51
|
|
|
52
|
+
/** Longest field the parser reads, from its tag to the next one: SWIFT fields have a few hundred characters. */
|
|
53
|
+
export const maxFieldLength = 65_536;
|
|
54
|
+
|
|
44
55
|
/** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
|
|
45
56
|
export const Mt940 = Object.freeze({
|
|
46
57
|
/** Parses all transactions; see `parse`. */
|
|
47
58
|
parseTransactions(text: string, options: Mt940Options = {}): Transaction[] {
|
|
48
|
-
return Mt940.parse(text, options).flatMap((s) =>
|
|
59
|
+
return Mt940.parse(text, options).flatMap((s) => s.transactions);
|
|
49
60
|
},
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
63
|
* Parses all statements. Their lines are separated by line breaks, and by `@@` as well where the first line holds it,
|
|
53
64
|
* as some banks separate them.
|
|
54
65
|
*
|
|
55
|
-
* Throws `FormatError` for text without any field,
|
|
56
|
-
* the currency of a transaction is unknown
|
|
57
|
-
*
|
|
66
|
+
* Throws `FormatError` for text without any field, if a transaction line (`:61:`), a balance or the creation date of
|
|
67
|
+
* an MT942 (`:13D:`) cannot be read or the currency of a transaction is unknown, and for a closing balance dated
|
|
68
|
+
* before the opening one. It does as well for a statement that states only one of its opening and closing balances,
|
|
69
|
+
* one of them, its account, its number or its creation date twice, or transactions that do not lead from the one
|
|
58
70
|
* balance to the other: a line of a purpose that starts like a field, such as `:61:` or `:25:`, would pass for a
|
|
59
71
|
* transaction, the account or a statement of its own otherwise. Text of more than 200,000 statements and
|
|
60
|
-
* transactions is a `FormatError` as well.
|
|
72
|
+
* transactions, or with a field of more than 65,536 characters, is a `FormatError` as well.
|
|
73
|
+
*
|
|
74
|
+
* Throws `TypeError` for a `status` other than `booked` and `pending`, `null` included, and for a `currency` that is
|
|
75
|
+
* no string; `null` counts as no currency.
|
|
61
76
|
*/
|
|
62
77
|
parse(text: string, options: Mt940Options = {}): Mt940Statement[] {
|
|
63
|
-
|
|
78
|
+
const status = options.status === undefined ? undefined : requireStatus(options.status);
|
|
79
|
+
const currency = optionalString(options.currency, 'currency');
|
|
80
|
+
return parseMt940(text, { status, currency }, new RecordBudget());
|
|
64
81
|
},
|
|
65
82
|
});
|
|
66
83
|
|
|
@@ -71,6 +88,7 @@ export function parseMt940(text: string, options: Mt940Options, budget: RecordBu
|
|
|
71
88
|
const statements: Mt940Statement[] = [];
|
|
72
89
|
let current: StatementBuilder | undefined;
|
|
73
90
|
for (const [tag, value] of taggedParts(normalized, tagPattern)) {
|
|
91
|
+
if (value.length > maxFieldLength) throw new FormatError(`MT940 field :${tag}: too long`);
|
|
74
92
|
if (tag === '20' || current === undefined) {
|
|
75
93
|
if (current !== undefined) statements.push(current.build());
|
|
76
94
|
budget.take();
|
|
@@ -102,27 +120,32 @@ function* taggedParts(text: string, tag: RegExp): Generator<[tag: string, text:
|
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
/**
|
|
105
|
-
* `text` with line feeds between its lines
|
|
106
|
-
*
|
|
107
|
-
*
|
|
123
|
+
* `text` with line feeds between its lines, without the blanks and the byte order mark it may start with, which would
|
|
124
|
+
* hide its first tag. Some banks separate the lines with `@@`, and may still break the line between statements or at
|
|
125
|
+
* the ends. So `@@` separates lines where the first line holds it, which the bank writes; in other text, `@@` may be
|
|
126
|
+
* part of a purpose, which the payer writes.
|
|
108
127
|
*/
|
|
109
128
|
function withLineFeeds(text: string): string {
|
|
110
|
-
const lines = text.replace(/\r\n?/g, '\n');
|
|
111
|
-
return lines.
|
|
129
|
+
const lines = trimBlanks(text.replace(/\r\n?/g, '\n'), { end: false });
|
|
130
|
+
return lines.split('\n', 1)[0]!.includes('@@') ? lines.replace(/@@/g, '\n') : lines;
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
/**
|
|
115
|
-
* The value of a field in `text`, which runs from its tag to the next one: without the
|
|
116
|
-
*
|
|
134
|
+
* The value of a field in `text`, which runs from its tag to the next one: without the statement end (`-`) and the line
|
|
135
|
+
* feeds at its end. Other blanks there stay: the subfield of a `:86:` that ends it may precede others in the purpose.
|
|
117
136
|
*
|
|
118
137
|
* Trimmed without a pattern such as `/\n+$/`, which takes quadratic time on a long run of line breaks.
|
|
119
138
|
*/
|
|
120
139
|
function fieldValue(text: string): string {
|
|
121
|
-
const
|
|
122
|
-
|
|
140
|
+
const trimmed = text.trimEnd();
|
|
141
|
+
let end = trimmed.endsWith('\n-') ? trimmed.length - 2 : text.length;
|
|
142
|
+
while (end > 0 && text.charCodeAt(end - 1) === 0x0a) end--;
|
|
143
|
+
return text.substring(0, end);
|
|
123
144
|
}
|
|
124
145
|
|
|
125
|
-
|
|
146
|
+
/** The date is `0` or `000000` for the first statement of an account. */
|
|
147
|
+
const balancePattern = /^([CD])(\d{6}|0)([A-Z]{3})(\d+,\d*)/;
|
|
148
|
+
const dateTimePattern = /^(\d{6})\d{4}/;
|
|
126
149
|
const currencyPrefix = /^([A-Z]{3})/;
|
|
127
150
|
|
|
128
151
|
class StatementBuilder {
|
|
@@ -134,6 +157,8 @@ class StatementBuilder {
|
|
|
134
157
|
openingDate: Date | undefined;
|
|
135
158
|
closing: Amount | undefined;
|
|
136
159
|
closingDate: Date | undefined;
|
|
160
|
+
/** The day an MT942 was created (field 13D). */
|
|
161
|
+
creationDate: Date | undefined;
|
|
137
162
|
readonly transactions: TransactionLines[] = [];
|
|
138
163
|
|
|
139
164
|
/**
|
|
@@ -151,19 +176,28 @@ class StatementBuilder {
|
|
|
151
176
|
add(tag: string, value: string): void {
|
|
152
177
|
switch (tag) {
|
|
153
178
|
case '20':
|
|
154
|
-
this.reference = value
|
|
179
|
+
this.reference = trimBlanks(value);
|
|
155
180
|
break;
|
|
156
181
|
case '25':
|
|
157
182
|
if (this.accountId !== undefined) throw new FormatError('MT940 statement with two accounts', this.reference);
|
|
158
|
-
this.accountId = value
|
|
183
|
+
this.accountId = trimBlanks(value);
|
|
159
184
|
break;
|
|
160
185
|
case '28C':
|
|
161
186
|
case '28':
|
|
162
187
|
if (this.statementNumber !== undefined) {
|
|
163
188
|
throw new FormatError('MT940 statement with two statement numbers', this.reference);
|
|
164
189
|
}
|
|
165
|
-
this.statementNumber = value
|
|
190
|
+
this.statementNumber = trimBlanks(value);
|
|
166
191
|
break;
|
|
192
|
+
case '13D':
|
|
193
|
+
case '13': {
|
|
194
|
+
if (this.creationDate !== undefined) {
|
|
195
|
+
throw new FormatError('MT940 statement with two creation dates', this.reference);
|
|
196
|
+
}
|
|
197
|
+
const m = dateTimePattern.exec(trimBlanks(value));
|
|
198
|
+
this.creationDate = (m && parseSwiftDate(m[1]!)) ?? fail('Invalid MT940 creation date', value);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
167
201
|
case '60F':
|
|
168
202
|
case '60M': {
|
|
169
203
|
if (this.opening !== undefined) {
|
|
@@ -187,7 +221,7 @@ class StatementBuilder {
|
|
|
187
221
|
break;
|
|
188
222
|
}
|
|
189
223
|
case '34F':
|
|
190
|
-
this.currency ??= currencyPrefix.exec(value
|
|
224
|
+
this.currency ??= currencyPrefix.exec(trimBlanks(value))?.[1];
|
|
191
225
|
break;
|
|
192
226
|
case '61':
|
|
193
227
|
this.transactions.push(new TransactionLines(value));
|
|
@@ -205,20 +239,28 @@ class StatementBuilder {
|
|
|
205
239
|
throw new FormatError('MT940 statement without currency', this.reference);
|
|
206
240
|
}
|
|
207
241
|
const { openingDate, closingDate } = this;
|
|
242
|
+
if (openingDate && closingDate && closingDate.getTime() < openingDate.getTime()) {
|
|
243
|
+
throw new FormatError('MT940 closing balance dated before the opening balance', this.reference);
|
|
244
|
+
}
|
|
208
245
|
const period = openingDate && closingDate ? ([openingDate, closingDate] as const) : undefined;
|
|
209
|
-
const transactions = this.transactions.map((t) =>
|
|
246
|
+
const transactions = this.transactions.map((t) =>
|
|
247
|
+
t.toTransaction(statementCurrency!, this.status, period, this.creationDate),
|
|
248
|
+
);
|
|
210
249
|
this.checkBalances(transactions);
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
250
|
+
return frozenWithInspect(
|
|
251
|
+
{
|
|
252
|
+
reference: this.reference,
|
|
253
|
+
accountId: this.accountId,
|
|
254
|
+
statementNumber: this.statementNumber,
|
|
255
|
+
currency: statementCurrency,
|
|
256
|
+
openingBalance: this.opening,
|
|
257
|
+
openingDate: this.openingDate,
|
|
258
|
+
closingBalance: this.closing,
|
|
259
|
+
closingDate: this.closingDate,
|
|
260
|
+
transactions: Object.freeze(transactions),
|
|
261
|
+
},
|
|
262
|
+
(s) => `Mt940Statement(${maskedId(s.accountId ?? '')}, ${counted(s.transactions.length, 'transaction')})`,
|
|
263
|
+
);
|
|
222
264
|
}
|
|
223
265
|
|
|
224
266
|
/** Checks that the statement states both balances or neither, and that its `transactions` lead from one to the other. */
|
|
@@ -231,28 +273,17 @@ class StatementBuilder {
|
|
|
231
273
|
if (opening.currency !== this.currency || closing.currency !== this.currency) {
|
|
232
274
|
throw new FormatError('MT940 balances in another currency than the statement', this.reference);
|
|
233
275
|
}
|
|
234
|
-
if (!
|
|
276
|
+
if (!addsUpTo([opening, ...transactions.map((t) => t.amount)], closing)) {
|
|
235
277
|
throw new FormatError('MT940 transactions do not add up to the closing balance', this.reference);
|
|
236
278
|
}
|
|
237
279
|
}
|
|
238
280
|
}
|
|
239
281
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
try {
|
|
243
|
-
return transactions.reduce((balance, t) => balance.add(t.amount), opening);
|
|
244
|
-
} catch (error) {
|
|
245
|
-
if (error instanceof RangeError) throw new FormatError('MT940 amounts too large to add up');
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function parseBalance(value: string): [Amount, Date] {
|
|
251
|
-
const m = balancePattern.exec(value.trim());
|
|
282
|
+
function parseBalance(value: string): [Amount, Date | undefined] {
|
|
283
|
+
const m = balancePattern.exec(trimBlanks(value));
|
|
252
284
|
if (!m) throw new FormatError('Unreadable MT940 balance', value);
|
|
253
285
|
const amount = Amount.parseDecimal(m[4]!, m[3]!);
|
|
254
|
-
const date = parseSwiftDate(m[2]!);
|
|
255
|
-
if (date === undefined) throw new FormatError('Invalid balance date', value);
|
|
286
|
+
const date = Number(m[2]) === 0 ? undefined : (parseSwiftDate(m[2]!) ?? fail('Invalid balance date', value));
|
|
256
287
|
return [m[1] === 'D' ? amount.negate() : amount, date];
|
|
257
288
|
}
|
|
258
289
|
|
|
@@ -263,27 +294,42 @@ function parseBalance(value: string): [Amount, Date] {
|
|
|
263
294
|
const line61 =
|
|
264
295
|
/^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
|
|
265
296
|
|
|
266
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* The dates between which the transactions of a statement are booked: those of its opening and its closing balance. A
|
|
299
|
+
* statement without balances, such as an MT942, has the period from the value date of each to the date the statement
|
|
300
|
+
* was created, or that value date alone.
|
|
301
|
+
*/
|
|
267
302
|
type Period = readonly [first: Date, last: Date];
|
|
268
303
|
|
|
304
|
+
function spanning(a: Date, b: Date): Period {
|
|
305
|
+
return a.getTime() <= b.getTime() ? [a, b] : [b, a];
|
|
306
|
+
}
|
|
307
|
+
|
|
269
308
|
/** The `:61:` line of a transaction and its `:86:` details. */
|
|
270
309
|
class TransactionLines {
|
|
271
310
|
details: string | undefined;
|
|
272
311
|
|
|
273
312
|
constructor(readonly line: string) {}
|
|
274
313
|
|
|
275
|
-
toTransaction(
|
|
276
|
-
|
|
314
|
+
toTransaction(
|
|
315
|
+
currency: string,
|
|
316
|
+
status: TransactionStatus,
|
|
317
|
+
period: Period | undefined,
|
|
318
|
+
created: Date | undefined,
|
|
319
|
+
): Transaction {
|
|
320
|
+
const m = line61.exec(trimBlanks(this.line));
|
|
277
321
|
const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
|
|
278
322
|
if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
|
|
279
323
|
const mark = m[3]!;
|
|
280
324
|
const amount = Amount.parseDecimal(m[5]!, currency);
|
|
281
325
|
const debit = mark === 'D' || mark === 'RC';
|
|
282
|
-
const customerReference = nonBlank(m[7]);
|
|
283
326
|
const d = Details.parse(this.details);
|
|
327
|
+
const reference = nonBlank(m[7]);
|
|
328
|
+
// `KREF+` stands for the customer reference that :86: states.
|
|
329
|
+
const customerReference = reference === 'KREF+' ? d.sepa.get('KREF') : reference;
|
|
284
330
|
return new Transaction({
|
|
285
331
|
status,
|
|
286
|
-
bookingDate: this.bookingDate(m[2], valueDate, period),
|
|
332
|
+
bookingDate: this.bookingDate(m[2], valueDate, period ?? spanning(valueDate, created ?? valueDate)),
|
|
287
333
|
valueDate,
|
|
288
334
|
amount: debit ? amount.negate() : amount,
|
|
289
335
|
isReversal: mark.startsWith('R'),
|
|
@@ -310,14 +356,13 @@ class TransactionLines {
|
|
|
310
356
|
|
|
311
357
|
/**
|
|
312
358
|
* The booking date `MMDD`, in the year that places it within `period`, or else closest to it; of several such years,
|
|
313
|
-
* the one closest to `valueDate
|
|
314
|
-
*
|
|
359
|
+
* the one closest to `valueDate`. The value date alone misleads: banks book refunds and corrections with the value
|
|
360
|
+
* date of the original booking, up to 13 months before.
|
|
315
361
|
*/
|
|
316
|
-
private bookingDate(mmdd: string | undefined, valueDate: Date,
|
|
362
|
+
private bookingDate(mmdd: string | undefined, valueDate: Date, [first, last]: Period): Date | undefined {
|
|
317
363
|
if (mmdd === undefined) return undefined;
|
|
318
364
|
const month = Number(mmdd.substring(0, 2));
|
|
319
365
|
const day = Number(mmdd.substring(2, 4));
|
|
320
|
-
const [first, last] = period ?? [valueDate, valueDate];
|
|
321
366
|
const outside = (date: Date): number =>
|
|
322
367
|
Math.max(first.getTime() - date.getTime(), date.getTime() - last.getTime(), 0);
|
|
323
368
|
const fromValueDate = (date: Date): number => Math.abs(date.getTime() - valueDate.getTime());
|
|
@@ -365,7 +410,7 @@ class Details {
|
|
|
365
410
|
const text = raw.replace(/\n/g, '');
|
|
366
411
|
if (!structuredPattern.test(text)) {
|
|
367
412
|
// Free text, whose lines are separate phrases; the purpose and its keywords are read from the same text.
|
|
368
|
-
const free = raw.replace(/\n/g, ' ')
|
|
413
|
+
const free = trimBlanks(raw.replace(/\n/g, ' '));
|
|
369
414
|
d.sepa = SepaPurpose.parse(free);
|
|
370
415
|
d.purpose = d.sepa.get('SVWZ') ?? nonBlank(free);
|
|
371
416
|
return d;
|
|
@@ -384,8 +429,6 @@ class Details {
|
|
|
384
429
|
d.purpose = d.sepa.get('SVWZ') ?? nonBlank(purposeParts.join(''));
|
|
385
430
|
d.name = nonBlank(parts([32, 33]).join(''));
|
|
386
431
|
d.readCounterpartyAccount(subfield(30), subfield(31));
|
|
387
|
-
d.iban ??= d.sepa.get('IBAN');
|
|
388
|
-
d.bic ??= d.sepa.get('BIC');
|
|
389
432
|
return d;
|
|
390
433
|
}
|
|
391
434
|
|
|
@@ -400,20 +443,13 @@ class Details {
|
|
|
400
443
|
}
|
|
401
444
|
|
|
402
445
|
/**
|
|
403
|
-
* Takes the counterparty's account from subfields 30 (bank) and 31 (account):
|
|
404
|
-
*
|
|
446
|
+
* Takes the counterparty's account from subfields 30 (bank) and 31 (account): a BIC, or else a bank code, and an IBAN,
|
|
447
|
+
* or else an account number.
|
|
405
448
|
*/
|
|
406
449
|
private readCounterpartyAccount(bank: string | undefined, account: string | undefined): void {
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
412
|
-
this.accountNumber = account;
|
|
413
|
-
if (bank !== undefined && bicPattern.test(bank)) {
|
|
414
|
-
this.bic = bank;
|
|
415
|
-
} else {
|
|
416
|
-
this.bankCode = bank;
|
|
417
|
-
}
|
|
450
|
+
if (bank !== undefined && bicPattern.test(bank)) this.bic = bank;
|
|
451
|
+
else this.bankCode = bank;
|
|
452
|
+
if (account !== undefined && ibanPattern.test(account)) this.iban = account;
|
|
453
|
+
else this.accountNumber = account;
|
|
418
454
|
}
|
|
419
455
|
}
|
package/src/formats/readers.ts
CHANGED
|
@@ -29,11 +29,15 @@ export function mt940Transactions(
|
|
|
29
29
|
*
|
|
30
30
|
* Each part is decoded on its own, since a bank may start each page with a byte order mark. Each part ends a line
|
|
31
31
|
* before the next one follows, since the parser finds fields at the start of a line and a bank need not end its pages
|
|
32
|
-
* with a line break.
|
|
32
|
+
* with a line break. Where no part holds a line break, a part ends its line with `@@` instead: a line break after a
|
|
33
|
+
* first page without `@@`, such as `:20:X`, would make the parser read the `@@` of the others as text.
|
|
33
34
|
*/
|
|
34
35
|
function joinedMt940(parts: readonly Uint8Array[]): string {
|
|
35
36
|
const pages = parts.filter((part) => part.length > 0).map((part) => decodeBankText(part));
|
|
36
|
-
|
|
37
|
+
const lineEnd = pages.some((page) => page.includes('\n') || page.includes('\r')) ? '\n' : '@@';
|
|
38
|
+
return pages
|
|
39
|
+
.map((page, i) => (i + 1 < pages.length && !page.endsWith(lineEnd) ? `${page}${lineEnd}` : page))
|
|
40
|
+
.join('');
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { invalidArgument } from '../util/errors.js';
|
|
2
|
+
import { trimBlanks } from '../util/text.js';
|
|
3
|
+
|
|
1
4
|
/** SEPA keywords German banks put into the purpose text, each followed by `+`. */
|
|
2
5
|
const keywords = [
|
|
3
6
|
'EREF',
|
|
@@ -26,8 +29,8 @@ export function providedReference(reference: string | undefined): string | undef
|
|
|
26
29
|
|
|
27
30
|
const keywordPattern = new RegExp(`(${keywords.join('|')})\\+`, 'g');
|
|
28
31
|
|
|
29
|
-
/** The keywords German banks
|
|
30
|
-
const
|
|
32
|
+
/** The keywords of the text the payer or the payee writes, which German banks put after every other keyword. */
|
|
33
|
+
const payerText = new Set(['SVWZ', 'ABWA', 'ABWE']);
|
|
31
34
|
|
|
32
35
|
/** Parser for the SEPA keywords in a purpose text. */
|
|
33
36
|
export const SepaPurpose = Object.freeze({
|
|
@@ -39,10 +42,11 @@ export const SepaPurpose = Object.freeze({
|
|
|
39
42
|
* `…4711EREF+` may be where one line ends and the next begins.
|
|
40
43
|
*
|
|
41
44
|
* The remittance information (`SVWZ+`) and the ultimate debtor and creditor (`ABWA+`, `ABWE+`) are text the payer or
|
|
42
|
-
* the payee writes, which banks put after every other keyword
|
|
43
|
-
* start a value:
|
|
44
|
-
* which of the two the bank wrote cannot be told, since a reference the payee
|
|
45
|
-
* the bank's `CRED+` and may hold `CRED+…` and `SVWZ+`, which turns the bank's
|
|
45
|
+
* the payee writes, which banks put after every other keyword, some of them `ABWA+` or `ABWE+` first. So from the
|
|
46
|
+
* first of them on only these three start a value: any other keyword in that text, such as `IBAN+`, is part of it. A
|
|
47
|
+
* keyword that occurs twice has no value: which of the two the bank wrote cannot be told, since a reference the payee
|
|
48
|
+
* chose, such as `EREF+`, comes before the bank's `CRED+` and may hold `CRED+…` and `SVWZ+`, which turns the bank's
|
|
49
|
+
* value into remittance information.
|
|
46
50
|
*/
|
|
47
51
|
parse(purpose: string): Map<string, string> {
|
|
48
52
|
return split(purpose, new Set());
|
|
@@ -54,11 +58,15 @@ export const SepaPurpose = Object.freeze({
|
|
|
54
58
|
*
|
|
55
59
|
* A keyword also counts at the start of a part, where the previous value may end in a capital letter, as in
|
|
56
60
|
* `EREF+NOTPROVIDED` followed by a part `MREF+…`.
|
|
61
|
+
*
|
|
62
|
+
* Throws `TypeError` for a string, whose characters would count as parts, and for parts that are no strings.
|
|
57
63
|
*/
|
|
58
64
|
parseParts(parts: Iterable<string>): Map<string, string> {
|
|
65
|
+
if (typeof parts === 'string') throw invalidArgument('Must be the parts, not one string', 'parts');
|
|
59
66
|
let text = '';
|
|
60
67
|
const partStarts = new Set<number>();
|
|
61
68
|
for (const part of parts) {
|
|
69
|
+
if (typeof part !== 'string') throw invalidArgument('Must be strings', 'parts');
|
|
62
70
|
partStarts.add(text.length);
|
|
63
71
|
text += part;
|
|
64
72
|
}
|
|
@@ -76,14 +84,14 @@ function split(purpose: string, partStarts: ReadonlySet<number>): Map<string, st
|
|
|
76
84
|
if (!atWordStart(m.index)) continue;
|
|
77
85
|
const keyword = m[1]!;
|
|
78
86
|
occurrences.set(keyword, (occurrences.get(keyword) ?? 0) + 1);
|
|
79
|
-
if (inPayerText && !
|
|
80
|
-
inPayerText ||=
|
|
87
|
+
if (inPayerText && !payerText.has(keyword)) continue;
|
|
88
|
+
inPayerText ||= payerText.has(keyword);
|
|
81
89
|
matches.push(m);
|
|
82
90
|
}
|
|
83
91
|
const result = new Map<string, string>();
|
|
84
92
|
matches.forEach((m, i) => {
|
|
85
93
|
const end = i + 1 < matches.length ? matches[i + 1]!.index : purpose.length;
|
|
86
|
-
if (occurrences.get(m[1]!) === 1) result.set(m[1]!, purpose.substring(m.index + m[0].length, end)
|
|
94
|
+
if (occurrences.get(m[1]!) === 1) result.set(m[1]!, trimBlanks(purpose.substring(m.index + m[0].length, end)));
|
|
87
95
|
});
|
|
88
96
|
return result;
|
|
89
97
|
}
|