@coderoftime/fints 0.1.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 +519 -1
- package/README.md +138 -40
- package/dist/client.d.ts +49 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +242 -0
- package/dist/client.js.map +1 -0
- package/dist/core/account.d.ts +46 -7
- package/dist/core/account.d.ts.map +1 -1
- package/dist/core/account.js +143 -24
- package/dist/core/account.js.map +1 -1
- package/dist/core/amount.d.ts +15 -4
- package/dist/core/amount.d.ts.map +1 -1
- package/dist/core/amount.js +46 -9
- package/dist/core/amount.js.map +1 -1
- package/dist/core/balance.d.ts +4 -0
- package/dist/core/balance.d.ts.map +1 -1
- package/dist/core/balance.js +11 -5
- package/dist/core/balance.js.map +1 -1
- package/dist/core/bank-info.d.ts +21 -4
- package/dist/core/bank-info.d.ts.map +1 -1
- package/dist/core/bank-info.js +26 -5
- package/dist/core/bank-info.js.map +1 -1
- package/dist/core/client.d.ts +81 -19
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/config.d.ts +40 -20
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +85 -30
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts +100 -26
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +136 -53
- package/dist/core/errors.js.map +1 -1
- package/dist/core/feedback.d.ts +11 -1
- package/dist/core/feedback.d.ts.map +1 -1
- package/dist/core/feedback.js +14 -2
- package/dist/core/feedback.js.map +1 -1
- package/dist/core/transaction.d.ts +11 -1
- package/dist/core/transaction.d.ts.map +1 -1
- package/dist/core/transaction.js +21 -4
- package/dist/core/transaction.js.map +1 -1
- package/dist/core/transport.d.ts +48 -6
- package/dist/core/transport.d.ts.map +1 -1
- package/dist/core/transport.js +126 -29
- package/dist/core/transport.js.map +1 -1
- package/dist/formats/camt.d.ts +28 -5
- package/dist/formats/camt.d.ts.map +1 -1
- package/dist/formats/camt.js +132 -50
- package/dist/formats/camt.js.map +1 -1
- package/dist/formats/mt940.d.ts +29 -5
- package/dist/formats/mt940.d.ts.map +1 -1
- package/dist/formats/mt940.js +209 -113
- package/dist/formats/mt940.js.map +1 -1
- package/dist/formats/readers.d.ts +6 -2
- package/dist/formats/readers.d.ts.map +1 -1
- package/dist/formats/readers.js +28 -29
- package/dist/formats/readers.js.map +1 -1
- package/dist/formats/record-budget.d.ts +7 -0
- package/dist/formats/record-budget.d.ts.map +1 -0
- package/dist/formats/record-budget.js +17 -0
- package/dist/formats/record-budget.js.map +1 -0
- package/dist/formats/sepa-purpose.d.ts +9 -0
- package/dist/formats/sepa-purpose.d.ts.map +1 -1
- package/dist/formats/sepa-purpose.js +32 -2
- package/dist/formats/sepa-purpose.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/session/app-approval.d.ts +11 -5
- package/dist/session/app-approval.d.ts.map +1 -1
- package/dist/session/app-approval.js +23 -12
- package/dist/session/app-approval.js.map +1 -1
- package/dist/session/dialog-runner.d.ts +11 -11
- package/dist/session/dialog-runner.d.ts.map +1 -1
- package/dist/session/dialog-runner.js +34 -19
- package/dist/session/dialog-runner.js.map +1 -1
- package/dist/session/login-guard.d.ts +54 -49
- package/dist/session/login-guard.d.ts.map +1 -1
- package/dist/session/login-guard.js +170 -83
- package/dist/session/login-guard.js.map +1 -1
- package/dist/session/sepa-accounts.d.ts +20 -14
- package/dist/session/sepa-accounts.d.ts.map +1 -1
- package/dist/session/sepa-accounts.js +62 -46
- package/dist/session/sepa-accounts.js.map +1 -1
- package/dist/session/session-setup.d.ts +41 -24
- package/dist/session/session-setup.d.ts.map +1 -1
- package/dist/session/session-setup.js +106 -63
- package/dist/session/session-setup.js.map +1 -1
- package/dist/session/session-state.d.ts +10 -6
- package/dist/session/session-state.d.ts.map +1 -1
- package/dist/session/session-state.js +72 -18
- package/dist/session/session-state.js.map +1 -1
- package/dist/session/signed-dialog.d.ts +83 -0
- package/dist/session/signed-dialog.d.ts.map +1 -0
- package/dist/session/signed-dialog.js +185 -0
- package/dist/session/signed-dialog.js.map +1 -0
- package/dist/session/tan-selection.d.ts +26 -18
- package/dist/session/tan-selection.d.ts.map +1 -1
- package/dist/session/tan-selection.js +44 -33
- package/dist/session/tan-selection.js.map +1 -1
- package/dist/session/user-prompts.d.ts +14 -5
- package/dist/session/user-prompts.d.ts.map +1 -1
- package/dist/session/user-prompts.js +53 -18
- package/dist/session/user-prompts.js.map +1 -1
- package/dist/tan/challenge.d.ts +47 -24
- package/dist/tan/challenge.d.ts.map +1 -1
- package/dist/tan/challenge.js +56 -51
- package/dist/tan/challenge.js.map +1 -1
- package/dist/tan/flicker.d.ts +11 -1
- package/dist/tan/flicker.d.ts.map +1 -1
- package/dist/tan/flicker.js +70 -24
- package/dist/tan/flicker.js.map +1 -1
- package/dist/tan/tan-image.d.ts +6 -2
- package/dist/tan/tan-image.d.ts.map +1 -1
- package/dist/tan/tan-image.js +14 -6
- package/dist/tan/tan-image.js.map +1 -1
- package/dist/tan/tan-models.d.ts +14 -2
- package/dist/tan/tan-models.d.ts.map +1 -1
- package/dist/tan/tan-models.js +24 -7
- 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 -3
- package/dist/util/async.d.ts.map +1 -1
- package/dist/util/async.js +6 -7
- package/dist/util/async.js.map +1 -1
- package/dist/util/bytes.d.ts +3 -4
- package/dist/util/bytes.d.ts.map +1 -1
- package/dist/util/bytes.js +13 -21
- package/dist/util/bytes.js.map +1 -1
- package/dist/util/call-context.d.ts.map +1 -1
- package/dist/util/call-context.js +3 -2
- package/dist/util/call-context.js.map +1 -1
- package/dist/util/dates.d.ts +6 -20
- package/dist/util/dates.d.ts.map +1 -1
- package/dist/util/dates.js +58 -28
- package/dist/util/dates.js.map +1 -1
- package/dist/util/errors.d.ts +13 -3
- package/dist/util/errors.d.ts.map +1 -1
- package/dist/util/errors.js +19 -6
- 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/json.d.ts +4 -0
- package/dist/util/json.d.ts.map +1 -1
- package/dist/util/json.js +18 -0
- package/dist/util/json.js.map +1 -1
- package/dist/util/text.d.ts +38 -5
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +90 -16
- package/dist/util/text.js.map +1 -1
- package/dist/util/xml.d.ts +9 -6
- package/dist/util/xml.d.ts.map +1 -1
- package/dist/util/xml.js +129 -56
- package/dist/util/xml.js.map +1 -1
- package/dist/v3/account-extension.d.ts.map +1 -1
- package/dist/v3/account-extension.js +7 -10
- package/dist/v3/account-extension.js.map +1 -1
- package/dist/v3/bank-connection.d.ts +54 -0
- package/dist/v3/bank-connection.d.ts.map +1 -0
- package/dist/v3/bank-connection.js +118 -0
- package/dist/v3/bank-connection.js.map +1 -0
- package/dist/v3/dialog.d.ts +84 -46
- package/dist/v3/dialog.d.ts.map +1 -1
- package/dist/v3/dialog.js +174 -60
- package/dist/v3/dialog.js.map +1 -1
- package/dist/v3/message.d.ts +32 -11
- package/dist/v3/message.d.ts.map +1 -1
- package/dist/v3/message.js +144 -83
- package/dist/v3/message.js.map +1 -1
- package/dist/v3/parameter-store.d.ts +111 -0
- package/dist/v3/parameter-store.d.ts.map +1 -0
- package/dist/v3/parameter-store.js +197 -0
- package/dist/v3/parameter-store.js.map +1 -0
- package/dist/v3/parameters.d.ts +44 -17
- package/dist/v3/parameters.d.ts.map +1 -1
- package/dist/v3/parameters.js +136 -60
- package/dist/v3/parameters.js.map +1 -1
- package/dist/v3/protocol.d.ts +50 -50
- package/dist/v3/protocol.d.ts.map +1 -1
- package/dist/v3/protocol.js +91 -210
- package/dist/v3/protocol.js.map +1 -1
- package/dist/v3/queries.d.ts +51 -0
- package/dist/v3/queries.d.ts.map +1 -0
- package/dist/v3/queries.js +128 -0
- package/dist/v3/queries.js.map +1 -0
- package/dist/v3/segments.d.ts +43 -19
- package/dist/v3/segments.d.ts.map +1 -1
- package/dist/v3/segments.js +84 -33
- package/dist/v3/segments.js.map +1 -1
- package/dist/v3/syntax.d.ts +68 -13
- package/dist/v3/syntax.d.ts.map +1 -1
- package/dist/v3/syntax.js +183 -102
- package/dist/v3/syntax.js.map +1 -1
- package/package.json +10 -6
- package/src/client.ts +293 -0
- package/src/core/account.ts +153 -24
- package/src/core/amount.ts +45 -10
- package/src/core/balance.ts +12 -5
- package/src/core/bank-info.ts +29 -5
- package/src/core/client.ts +83 -19
- package/src/core/config.ts +95 -35
- package/src/core/errors.ts +148 -54
- package/src/core/feedback.ts +16 -2
- package/src/core/transaction.ts +22 -4
- package/src/core/transport.ts +153 -32
- package/src/formats/camt.ts +153 -45
- package/src/formats/mt940.ts +244 -122
- package/src/formats/readers.ts +28 -31
- package/src/formats/record-budget.ts +18 -0
- package/src/formats/sepa-purpose.ts +29 -2
- package/src/index.ts +4 -2
- package/src/session/app-approval.ts +51 -38
- package/src/session/dialog-runner.ts +61 -31
- package/src/session/login-guard.ts +189 -87
- package/src/session/sepa-accounts.ts +63 -45
- package/src/session/session-setup.ts +105 -68
- package/src/session/session-state.ts +74 -23
- package/src/session/signed-dialog.ts +222 -0
- package/src/session/tan-selection.ts +50 -39
- package/src/session/user-prompts.ts +62 -18
- package/src/tan/challenge.ts +87 -64
- package/src/tan/flicker.ts +72 -23
- package/src/tan/tan-image.ts +15 -6
- package/src/tan/tan-models.ts +26 -7
- package/src/util/arguments.ts +40 -0
- package/src/util/async.ts +10 -9
- package/src/util/bytes.ts +14 -20
- package/src/util/call-context.ts +3 -2
- package/src/util/dates.ts +64 -33
- package/src/util/errors.ts +22 -6
- package/src/util/immutable-map.ts +15 -12
- package/src/util/inspect.ts +14 -0
- package/src/util/json.ts +16 -0
- package/src/util/text.ts +87 -15
- package/src/util/xml.ts +130 -52
- package/src/v3/account-extension.ts +7 -12
- package/src/v3/bank-connection.ts +126 -0
- package/src/v3/dialog.ts +186 -92
- package/src/v3/message.ts +140 -79
- package/src/v3/parameter-store.ts +226 -0
- package/src/v3/parameters.ts +166 -66
- package/src/v3/protocol.ts +103 -253
- package/src/v3/queries.ts +198 -0
- package/src/v3/segments.ts +86 -31
- package/src/v3/syntax.ts +172 -93
- package/dist/session/bank-connection.d.ts +0 -28
- package/dist/session/bank-connection.d.ts.map +0 -1
- package/dist/session/bank-connection.js +0 -48
- package/dist/session/bank-connection.js.map +0 -1
- package/dist/session/parameter-data.d.ts +0 -83
- package/dist/session/parameter-data.d.ts.map +0 -1
- package/dist/session/parameter-data.js +0 -68
- package/dist/session/parameter-data.js.map +0 -1
- package/dist/session/pin-tan-client.d.ts +0 -54
- package/dist/session/pin-tan-client.d.ts.map +0 -1
- package/dist/session/pin-tan-client.js +0 -153
- package/dist/session/pin-tan-client.js.map +0 -1
- package/dist/session/pin-tan-dialog.d.ts +0 -71
- package/dist/session/pin-tan-dialog.d.ts.map +0 -1
- package/dist/session/pin-tan-dialog.js +0 -124
- package/dist/session/pin-tan-dialog.js.map +0 -1
- package/dist/session/protocol.d.ts +0 -209
- package/dist/session/protocol.d.ts.map +0 -1
- package/dist/session/protocol.js +0 -75
- package/dist/session/protocol.js.map +0 -1
- package/dist/session/response-handler.d.ts +0 -48
- package/dist/session/response-handler.d.ts.map +0 -1
- package/dist/session/response-handler.js +0 -121
- package/dist/session/response-handler.js.map +0 -1
- package/dist/v3/client.d.ts +0 -24
- package/dist/v3/client.d.ts.map +0 -1
- package/dist/v3/client.js +0 -16
- package/dist/v3/client.js.map +0 -1
- package/src/session/bank-connection.ts +0 -51
- package/src/session/parameter-data.ts +0 -137
- package/src/session/pin-tan-client.ts +0 -186
- package/src/session/pin-tan-dialog.ts +0 -160
- package/src/session/protocol.ts +0 -311
- package/src/session/response-handler.ts +0 -130
- package/src/v3/client.ts +0 -29
package/src/formats/mt940.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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';
|
|
8
|
+
import { RecordBudget } from './record-budget.js';
|
|
6
9
|
import { providedReference, SepaPurpose } from './sepa-purpose.js';
|
|
7
10
|
|
|
8
11
|
/** One statement from an MT940 or MT942 file, with its balances and transactions. */
|
|
@@ -17,13 +20,22 @@ export interface Mt940Statement {
|
|
|
17
20
|
readonly currency: string | undefined;
|
|
18
21
|
/** The balance at the start of the statement (field 60), negative for a debit. */
|
|
19
22
|
readonly openingBalance: Amount | undefined;
|
|
20
|
-
/**
|
|
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
|
+
*/
|
|
21
27
|
readonly openingDate: Date | undefined;
|
|
22
28
|
/** The balance at the end of the statement (field 62), negative for a debit. */
|
|
23
29
|
readonly closingBalance: Amount | undefined;
|
|
24
|
-
/**
|
|
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
|
+
*/
|
|
25
34
|
readonly closingDate: Date | undefined;
|
|
26
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* The transactions of the statement, in the order the bank lists them; they lead from `openingBalance` to
|
|
37
|
+
* `closingBalance` where the statement states these.
|
|
38
|
+
*/
|
|
27
39
|
readonly transactions: readonly Transaction[];
|
|
28
40
|
}
|
|
29
41
|
|
|
@@ -36,47 +48,104 @@ export interface Mt940Options {
|
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
const tagPattern = /^:(\d{2}[A-Z]?):/gm;
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
41
54
|
|
|
42
55
|
/** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
|
|
43
56
|
export const Mt940 = Object.freeze({
|
|
44
57
|
/** Parses all transactions; see `parse`. */
|
|
45
58
|
parseTransactions(text: string, options: Mt940Options = {}): Transaction[] {
|
|
46
|
-
return Mt940.parse(text, options).flatMap((s) =>
|
|
59
|
+
return Mt940.parse(text, options).flatMap((s) => s.transactions);
|
|
47
60
|
},
|
|
48
61
|
|
|
49
62
|
/**
|
|
50
|
-
* Parses all statements.
|
|
63
|
+
* Parses all statements. Their lines are separated by line breaks, and by `@@` as well where the first line holds it,
|
|
64
|
+
* as some banks separate them.
|
|
51
65
|
*
|
|
52
|
-
* Throws `FormatError` if a transaction line (`:61:`)
|
|
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
|
|
70
|
+
* balance to the other: a line of a purpose that starts like a field, such as `:61:` or `:25:`, would pass for a
|
|
71
|
+
* transaction, the account or a statement of its own otherwise. Text of more than 200,000 statements and
|
|
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.
|
|
53
76
|
*/
|
|
54
77
|
parse(text: string, options: Mt940Options = {}): Mt940Statement[] {
|
|
55
|
-
const status = options.status
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const normalized = lines.includes('\n') ? lines : lines.replace(/@@/g, '\n');
|
|
59
|
-
const tags = [...normalized.matchAll(tagPattern)];
|
|
60
|
-
const statements: Mt940Statement[] = [];
|
|
61
|
-
let current: StatementBuilder | undefined;
|
|
62
|
-
tags.forEach((tag, i) => {
|
|
63
|
-
const end = i + 1 < tags.length ? tags[i + 1]!.index : normalized.length;
|
|
64
|
-
const value = normalized
|
|
65
|
-
.substring(tag.index + tag[0].length, end)
|
|
66
|
-
.replace(statementEnd, '')
|
|
67
|
-
.replace(trailingNewlines, '');
|
|
68
|
-
if (tag[1] === '20' || current === undefined) {
|
|
69
|
-
if (current !== undefined) statements.push(current.build());
|
|
70
|
-
current = new StatementBuilder(status, options.currency);
|
|
71
|
-
}
|
|
72
|
-
current.add(tag[1]!, value);
|
|
73
|
-
});
|
|
74
|
-
if (current !== undefined) statements.push(current.build());
|
|
75
|
-
return statements;
|
|
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());
|
|
76
81
|
},
|
|
77
82
|
});
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
/** `Mt940.parse`, which takes each statement and transaction from `budget`, before it builds it. */
|
|
85
|
+
export function parseMt940(text: string, options: Mt940Options, budget: RecordBudget): Mt940Statement[] {
|
|
86
|
+
const status = options.status ?? 'booked';
|
|
87
|
+
const normalized = withLineFeeds(text);
|
|
88
|
+
const statements: Mt940Statement[] = [];
|
|
89
|
+
let current: StatementBuilder | undefined;
|
|
90
|
+
for (const [tag, value] of taggedParts(normalized, tagPattern)) {
|
|
91
|
+
if (value.length > maxFieldLength) throw new FormatError(`MT940 field :${tag}: too long`);
|
|
92
|
+
if (tag === '20' || current === undefined) {
|
|
93
|
+
if (current !== undefined) statements.push(current.build());
|
|
94
|
+
budget.take();
|
|
95
|
+
current = new StatementBuilder(status, options.currency);
|
|
96
|
+
}
|
|
97
|
+
if (tag === '61') budget.take();
|
|
98
|
+
current.add(tag, fieldValue(value));
|
|
99
|
+
}
|
|
100
|
+
if (current !== undefined) statements.push(current.build());
|
|
101
|
+
else if (normalized.trim() !== '') throw new FormatError('MT940 data without fields');
|
|
102
|
+
return statements;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The parts of `text` that the matches of `tag` start, each the first group of its match and the text up to the next
|
|
107
|
+
* match: the fields of a statement, or the subfields of a `:86:`. One by one: text of a few megabytes holds millions of
|
|
108
|
+
* them. A pattern that matched the text up to the next tag as well would run out of backtracking stack on a part of a
|
|
109
|
+
* few million characters.
|
|
110
|
+
*/
|
|
111
|
+
function* taggedParts(text: string, tag: RegExp): Generator<[tag: string, text: string]> {
|
|
112
|
+
let current: string | undefined;
|
|
113
|
+
let start = 0;
|
|
114
|
+
for (const match of text.matchAll(tag)) {
|
|
115
|
+
if (current !== undefined) yield [current, text.substring(start, match.index)];
|
|
116
|
+
current = match[1]!;
|
|
117
|
+
start = match.index + match[0].length;
|
|
118
|
+
}
|
|
119
|
+
if (current !== undefined) yield [current, text.substring(start)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
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.
|
|
127
|
+
*/
|
|
128
|
+
function withLineFeeds(text: string): string {
|
|
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;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
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.
|
|
136
|
+
*
|
|
137
|
+
* Trimmed without a pattern such as `/\n+$/`, which takes quadratic time on a long run of line breaks.
|
|
138
|
+
*/
|
|
139
|
+
function fieldValue(text: string): string {
|
|
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);
|
|
144
|
+
}
|
|
145
|
+
|
|
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}/;
|
|
80
149
|
const currencyPrefix = /^([A-Z]{3})/;
|
|
81
150
|
|
|
82
151
|
class StatementBuilder {
|
|
@@ -88,6 +157,8 @@ class StatementBuilder {
|
|
|
88
157
|
openingDate: Date | undefined;
|
|
89
158
|
closing: Amount | undefined;
|
|
90
159
|
closingDate: Date | undefined;
|
|
160
|
+
/** The day an MT942 was created (field 13D). */
|
|
161
|
+
creationDate: Date | undefined;
|
|
91
162
|
readonly transactions: TransactionLines[] = [];
|
|
92
163
|
|
|
93
164
|
/**
|
|
@@ -105,35 +176,52 @@ class StatementBuilder {
|
|
|
105
176
|
add(tag: string, value: string): void {
|
|
106
177
|
switch (tag) {
|
|
107
178
|
case '20':
|
|
108
|
-
this.reference = value
|
|
179
|
+
this.reference = trimBlanks(value);
|
|
109
180
|
break;
|
|
110
181
|
case '25':
|
|
111
|
-
this.accountId
|
|
182
|
+
if (this.accountId !== undefined) throw new FormatError('MT940 statement with two accounts', this.reference);
|
|
183
|
+
this.accountId = trimBlanks(value);
|
|
112
184
|
break;
|
|
113
185
|
case '28C':
|
|
114
186
|
case '28':
|
|
115
|
-
this.statementNumber
|
|
187
|
+
if (this.statementNumber !== undefined) {
|
|
188
|
+
throw new FormatError('MT940 statement with two statement numbers', this.reference);
|
|
189
|
+
}
|
|
190
|
+
this.statementNumber = trimBlanks(value);
|
|
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);
|
|
116
199
|
break;
|
|
200
|
+
}
|
|
117
201
|
case '60F':
|
|
118
202
|
case '60M': {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
[this.opening, this.openingDate] = balance;
|
|
122
|
-
this.currency ??= balance[0].currency;
|
|
203
|
+
if (this.opening !== undefined) {
|
|
204
|
+
throw new FormatError('MT940 statement with two opening balances', this.reference);
|
|
123
205
|
}
|
|
206
|
+
const [balance, date] = parseBalance(value);
|
|
207
|
+
this.opening = balance;
|
|
208
|
+
this.openingDate = date;
|
|
209
|
+
this.currency ??= balance.currency;
|
|
124
210
|
break;
|
|
125
211
|
}
|
|
126
212
|
case '62F':
|
|
127
213
|
case '62M': {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
[this.closing, this.closingDate] = balance;
|
|
131
|
-
this.currency ??= balance[0].currency;
|
|
214
|
+
if (this.closing !== undefined) {
|
|
215
|
+
throw new FormatError('MT940 statement with two closing balances', this.reference);
|
|
132
216
|
}
|
|
217
|
+
const [balance, date] = parseBalance(value);
|
|
218
|
+
this.closing = balance;
|
|
219
|
+
this.closingDate = date;
|
|
220
|
+
this.currency ??= balance.currency;
|
|
133
221
|
break;
|
|
134
222
|
}
|
|
135
223
|
case '34F':
|
|
136
|
-
this.currency ??= currencyPrefix.exec(value
|
|
224
|
+
this.currency ??= currencyPrefix.exec(trimBlanks(value))?.[1];
|
|
137
225
|
break;
|
|
138
226
|
case '61':
|
|
139
227
|
this.transactions.push(new TransactionLines(value));
|
|
@@ -150,26 +238,52 @@ class StatementBuilder {
|
|
|
150
238
|
if (statementCurrency === undefined && this.transactions.length > 0) {
|
|
151
239
|
throw new FormatError('MT940 statement without currency', this.reference);
|
|
152
240
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|
|
245
|
+
const period = openingDate && closingDate ? ([openingDate, closingDate] as const) : undefined;
|
|
246
|
+
const transactions = this.transactions.map((t) =>
|
|
247
|
+
t.toTransaction(statementCurrency!, this.status, period, this.creationDate),
|
|
248
|
+
);
|
|
249
|
+
this.checkBalances(transactions);
|
|
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
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Checks that the statement states both balances or neither, and that its `transactions` lead from one to the other. */
|
|
267
|
+
private checkBalances(transactions: readonly Transaction[]): void {
|
|
268
|
+
const { opening, closing } = this;
|
|
269
|
+
if (opening === undefined && closing === undefined) return;
|
|
270
|
+
if (opening === undefined || closing === undefined) {
|
|
271
|
+
throw new FormatError('MT940 statement with only one of its balances', this.reference);
|
|
272
|
+
}
|
|
273
|
+
if (opening.currency !== this.currency || closing.currency !== this.currency) {
|
|
274
|
+
throw new FormatError('MT940 balances in another currency than the statement', this.reference);
|
|
275
|
+
}
|
|
276
|
+
if (!addsUpTo([opening, ...transactions.map((t) => t.amount)], closing)) {
|
|
277
|
+
throw new FormatError('MT940 transactions do not add up to the closing balance', this.reference);
|
|
278
|
+
}
|
|
164
279
|
}
|
|
165
280
|
}
|
|
166
281
|
|
|
167
|
-
function parseBalance(value: string): [Amount, Date
|
|
168
|
-
const m = balancePattern.exec(value
|
|
169
|
-
if (!m)
|
|
282
|
+
function parseBalance(value: string): [Amount, Date | undefined] {
|
|
283
|
+
const m = balancePattern.exec(trimBlanks(value));
|
|
284
|
+
if (!m) throw new FormatError('Unreadable MT940 balance', value);
|
|
170
285
|
const amount = Amount.parseDecimal(m[4]!, m[3]!);
|
|
171
|
-
const date = parseSwiftDate(m[2]!);
|
|
172
|
-
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));
|
|
173
287
|
return [m[1] === 'D' ? amount.negate() : amount, date];
|
|
174
288
|
}
|
|
175
289
|
|
|
@@ -180,34 +294,48 @@ function parseBalance(value: string): [Amount, Date] | undefined {
|
|
|
180
294
|
const line61 =
|
|
181
295
|
/^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
|
|
182
296
|
|
|
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
|
+
*/
|
|
302
|
+
type Period = readonly [first: Date, last: Date];
|
|
303
|
+
|
|
304
|
+
function spanning(a: Date, b: Date): Period {
|
|
305
|
+
return a.getTime() <= b.getTime() ? [a, b] : [b, a];
|
|
306
|
+
}
|
|
307
|
+
|
|
183
308
|
/** The `:61:` line of a transaction and its `:86:` details. */
|
|
184
309
|
class TransactionLines {
|
|
185
310
|
details: string | undefined;
|
|
186
311
|
|
|
187
312
|
constructor(readonly line: string) {}
|
|
188
313
|
|
|
189
|
-
toTransaction(
|
|
190
|
-
|
|
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));
|
|
191
321
|
const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
|
|
192
322
|
if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
|
|
193
323
|
const mark = m[3]!;
|
|
194
324
|
const amount = Amount.parseDecimal(m[5]!, currency);
|
|
195
325
|
const debit = mark === 'D' || mark === 'RC';
|
|
196
|
-
const customerReference = m[7]?.trim();
|
|
197
|
-
const bankReference = m[8]?.trim();
|
|
198
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;
|
|
199
330
|
return new Transaction({
|
|
200
331
|
status,
|
|
201
|
-
bookingDate: this.bookingDate(m[2], valueDate),
|
|
332
|
+
bookingDate: this.bookingDate(m[2], valueDate, period ?? spanning(valueDate, created ?? valueDate)),
|
|
202
333
|
valueDate,
|
|
203
334
|
amount: debit ? amount.negate() : amount,
|
|
204
335
|
isReversal: mark.startsWith('R'),
|
|
205
336
|
transactionTypeId: m[6],
|
|
206
|
-
customerReference:
|
|
207
|
-
|
|
208
|
-
? undefined
|
|
209
|
-
: customerReference,
|
|
210
|
-
bankReference: bankReference === undefined || bankReference === '' ? undefined : bankReference,
|
|
337
|
+
customerReference: customerReference === 'NONREF' ? undefined : customerReference,
|
|
338
|
+
bankReference: nonBlank(m[8]),
|
|
211
339
|
transactionCode: d.transactionCode,
|
|
212
340
|
bookingText: d.bookingText,
|
|
213
341
|
primaNota: d.primaNota,
|
|
@@ -226,34 +354,43 @@ class TransactionLines {
|
|
|
226
354
|
});
|
|
227
355
|
}
|
|
228
356
|
|
|
229
|
-
/**
|
|
230
|
-
|
|
357
|
+
/**
|
|
358
|
+
* The booking date `MMDD`, in the year that places it within `period`, or else closest to it; of several such years,
|
|
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.
|
|
361
|
+
*/
|
|
362
|
+
private bookingDate(mmdd: string | undefined, valueDate: Date, [first, last]: Period): Date | undefined {
|
|
231
363
|
if (mmdd === undefined) return undefined;
|
|
232
364
|
const month = Number(mmdd.substring(0, 2));
|
|
233
365
|
const day = Number(mmdd.substring(2, 4));
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
366
|
+
const outside = (date: Date): number =>
|
|
367
|
+
Math.max(first.getTime() - date.getTime(), date.getTime() - last.getTime(), 0);
|
|
368
|
+
const fromValueDate = (date: Date): number => Math.abs(date.getTime() - valueDate.getTime());
|
|
369
|
+
// The best year lies next to one of these, however long the period.
|
|
370
|
+
const years = new Set(
|
|
371
|
+
[first, valueDate, last].flatMap((d) => [d.getFullYear() - 1, d.getFullYear(), d.getFullYear() + 1]),
|
|
372
|
+
);
|
|
373
|
+
let best: Date | undefined;
|
|
374
|
+
for (const year of years) {
|
|
375
|
+
const date = clampedDate(year, month, day);
|
|
376
|
+
if (date === undefined) throw new FormatError('Invalid booking date', this.line);
|
|
377
|
+
if (best === undefined || (outside(date) - outside(best) || fromValueDate(date) - fromValueDate(best)) < 0) {
|
|
378
|
+
best = date;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return best;
|
|
240
382
|
}
|
|
241
383
|
}
|
|
242
384
|
|
|
243
385
|
/**
|
|
244
|
-
* Three-digit business transaction code (GVC), then the first subfield:
|
|
245
|
-
*
|
|
386
|
+
* Three-digit business transaction code (GVC), then the first subfield: `?` and its two-digit number. Text that merely
|
|
387
|
+
* starts with three digits, such as `100 Euro` or `100,00 EUR`, is free text.
|
|
246
388
|
*/
|
|
247
|
-
const structuredPattern =
|
|
389
|
+
const structuredPattern = /^\d{3}\?\d{2}/;
|
|
390
|
+
const subfieldTag = /\?(\d{2})/g;
|
|
248
391
|
const ibanPattern = /^[A-Z]{2}\d{2}[A-Z0-9]+$/;
|
|
249
392
|
const bicPattern = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
|
|
250
393
|
|
|
251
|
-
/** Subfield pattern for a separator: the separator, a two-digit key, then everything up to the next subfield. */
|
|
252
|
-
function subfieldPattern(separator: string): RegExp {
|
|
253
|
-
const s = escapeRegExp(separator);
|
|
254
|
-
return new RegExp(`${s}(\\d{2})((?:(?!${s}\\d{2})[\\s\\S])*)`, 'g');
|
|
255
|
-
}
|
|
256
|
-
|
|
257
394
|
/** The German structured `:86:` field: a business transaction code and `?nn` subfields. */
|
|
258
395
|
class Details {
|
|
259
396
|
transactionCode: string | undefined;
|
|
@@ -271,20 +408,16 @@ class Details {
|
|
|
271
408
|
const d = new Details();
|
|
272
409
|
if (raw === undefined) return d;
|
|
273
410
|
const text = raw.replace(/\n/g, '');
|
|
274
|
-
|
|
275
|
-
if (!structured) {
|
|
411
|
+
if (!structuredPattern.test(text)) {
|
|
276
412
|
// Free text, whose lines are separate phrases; the purpose and its keywords are read from the same text.
|
|
277
|
-
const free = raw.replace(/\n/g, ' ')
|
|
413
|
+
const free = trimBlanks(raw.replace(/\n/g, ' '));
|
|
278
414
|
d.sepa = SepaPurpose.parse(free);
|
|
279
|
-
d.purpose = d.sepa.get('SVWZ') ?? (free
|
|
415
|
+
d.purpose = d.sepa.get('SVWZ') ?? nonBlank(free);
|
|
280
416
|
return d;
|
|
281
417
|
}
|
|
282
|
-
d.transactionCode =
|
|
283
|
-
const subfields = Details.subfields(text.substring(3)
|
|
284
|
-
const subfield = (key: number): string | undefined =>
|
|
285
|
-
const value = subfields.get(key)?.trim();
|
|
286
|
-
return value === undefined || value === '' ? undefined : value;
|
|
287
|
-
};
|
|
418
|
+
d.transactionCode = text.substring(0, 3);
|
|
419
|
+
const subfields = Details.subfields(text.substring(3));
|
|
420
|
+
const subfield = (key: number): string | undefined => nonBlank(subfields.get(key));
|
|
288
421
|
// Subfields continue each other: they are joined keeping the spaces at their boundaries.
|
|
289
422
|
const parts = (keys: number[]): string[] =>
|
|
290
423
|
keys.map((k) => subfields.get(k)).filter((v): v is string => v !== undefined);
|
|
@@ -292,42 +425,31 @@ class Details {
|
|
|
292
425
|
d.bookingText = subfield(0);
|
|
293
426
|
d.primaNota = subfield(10);
|
|
294
427
|
const purposeParts = parts([20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 60, 61, 62, 63]);
|
|
295
|
-
const purpose = purposeParts.join('').trim();
|
|
296
428
|
d.sepa = SepaPurpose.parseParts(purposeParts);
|
|
297
|
-
d.purpose = d.sepa.get('SVWZ') ?? (
|
|
298
|
-
|
|
299
|
-
d.name = name === '' ? undefined : name;
|
|
429
|
+
d.purpose = d.sepa.get('SVWZ') ?? nonBlank(purposeParts.join(''));
|
|
430
|
+
d.name = nonBlank(parts([32, 33]).join(''));
|
|
300
431
|
d.readCounterpartyAccount(subfield(30), subfield(31));
|
|
301
|
-
d.iban ??= d.sepa.get('IBAN');
|
|
302
|
-
d.bic ??= d.sepa.get('BIC');
|
|
303
432
|
return d;
|
|
304
433
|
}
|
|
305
434
|
|
|
306
435
|
/** The text of each subfield (`?nn` and what follows), by its number; a subfield that appears twice is joined. */
|
|
307
|
-
private static subfields(text: string
|
|
436
|
+
private static subfields(text: string): Map<number, string> {
|
|
308
437
|
const subfields = new Map<number, string>();
|
|
309
|
-
for (const
|
|
310
|
-
const key = Number(
|
|
311
|
-
subfields.set(key, (subfields.get(key) ?? '') +
|
|
438
|
+
for (const [tag, value] of taggedParts(text, subfieldTag)) {
|
|
439
|
+
const key = Number(tag);
|
|
440
|
+
subfields.set(key, (subfields.get(key) ?? '') + value);
|
|
312
441
|
}
|
|
313
442
|
return subfields;
|
|
314
443
|
}
|
|
315
444
|
|
|
316
445
|
/**
|
|
317
|
-
* Takes the counterparty's account from subfields 30 (bank) and 31 (account):
|
|
318
|
-
*
|
|
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.
|
|
319
448
|
*/
|
|
320
449
|
private readCounterpartyAccount(bank: string | undefined, account: string | undefined): void {
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
this.accountNumber = account;
|
|
327
|
-
if (bank !== undefined && bicPattern.test(bank)) {
|
|
328
|
-
this.bic = bank;
|
|
329
|
-
} else {
|
|
330
|
-
this.bankCode = bank;
|
|
331
|
-
}
|
|
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;
|
|
332
454
|
}
|
|
333
455
|
}
|
package/src/formats/readers.ts
CHANGED
|
@@ -1,56 +1,53 @@
|
|
|
1
1
|
import type { Transaction } from '../core/transaction.js';
|
|
2
|
-
import { BytesBuilder } from '../util/bytes.js';
|
|
3
2
|
import { decodeBankText, decodeXmlText } from '../util/text.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { parseCamt } from './camt.js';
|
|
4
|
+
import { parseMt940 } from './mt940.js';
|
|
5
|
+
import { RecordBudget } from './record-budget.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The transactions in MT940 data: the `booked` parts joined, since a statement may continue on the next page, then the
|
|
9
9
|
* `pending` ones (MT942).
|
|
10
10
|
*
|
|
11
|
-
* `currency` applies to statements that do not state one; throws `FormatError` if both are missing
|
|
11
|
+
* `currency` applies to statements that do not state one; throws `FormatError` if both are missing, and for more than
|
|
12
|
+
* 200,000 statements and transactions in all.
|
|
12
13
|
*/
|
|
13
14
|
export function mt940Transactions(
|
|
14
15
|
booked: readonly Uint8Array[],
|
|
15
16
|
pending: readonly Uint8Array[],
|
|
16
17
|
currency: string | undefined,
|
|
17
18
|
): Transaction[] {
|
|
19
|
+
const budget = new RecordBudget();
|
|
18
20
|
return [
|
|
19
|
-
...
|
|
20
|
-
...
|
|
21
|
-
];
|
|
21
|
+
...parseMt940(joinedMt940(booked), { currency }, budget),
|
|
22
|
+
...parseMt940(joinedMt940(pending), { currency, status: 'pending' }, budget),
|
|
23
|
+
].flatMap((statement) => statement.transactions);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const lineFeed = 0x0a;
|
|
25
|
-
const carriageReturn = 0x0d;
|
|
26
|
-
const atSign = 0x40;
|
|
27
|
-
|
|
28
26
|
/**
|
|
29
|
-
* The MT940 `parts` as one text
|
|
27
|
+
* The MT940 `parts` as one text, whose line separator the parser takes from its first line: a later page may start
|
|
28
|
+
* with a purpose, which the payer writes.
|
|
30
29
|
*
|
|
31
|
-
* Each part
|
|
32
|
-
*
|
|
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
|
+
* 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. 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
|
-
const pages = parts.filter((part) => part.length > 0);
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: page.at(-1) === lineFeed || page.at(-1) === carriageReturn;
|
|
41
|
-
|
|
42
|
-
const bytes = new BytesBuilder();
|
|
43
|
-
pages.forEach((page, i) => {
|
|
44
|
-
if (i > 0 && !endsLine(pages[i - 1]!)) bytes.add(atSigns ? [atSign, atSign] : [lineFeed]);
|
|
45
|
-
bytes.add(page);
|
|
46
|
-
});
|
|
47
|
-
return decodeBankText(bytes.takeBytes());
|
|
36
|
+
const pages = parts.filter((part) => part.length > 0).map((part) => decodeBankText(part));
|
|
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('');
|
|
48
41
|
}
|
|
49
42
|
|
|
50
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The transactions in camt documents; each document stands on its own. Throws `FormatError` for more than 200,000
|
|
45
|
+
* statements and transactions in all.
|
|
46
|
+
*/
|
|
51
47
|
export function camtTransactions(booked: readonly Uint8Array[], pending: readonly Uint8Array[]): Transaction[] {
|
|
48
|
+
const budget = new RecordBudget();
|
|
52
49
|
return [
|
|
53
|
-
...booked.flatMap((document) =>
|
|
54
|
-
...pending.flatMap((document) =>
|
|
55
|
-
];
|
|
50
|
+
...booked.flatMap((document) => parseCamt(decodeXmlText(document), undefined, budget)),
|
|
51
|
+
...pending.flatMap((document) => parseCamt(decodeXmlText(document), 'pending', budget)),
|
|
52
|
+
].flatMap((statement) => statement.transactions);
|
|
56
53
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FormatError } from '../util/errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Most statements and transactions the parsers build for one query: several times what a year of a busy business
|
|
5
|
+
* account holds, and few enough that a hostile answer cannot exhaust the memory, at about 600 bytes each. The size of
|
|
6
|
+
* the answer does not bound them: a transaction takes up to 25 times the bytes of its MT940 line.
|
|
7
|
+
*/
|
|
8
|
+
const maxRecords = 200_000;
|
|
9
|
+
|
|
10
|
+
/** The statements and transactions the parsers may still build; the parses of one query share one budget. */
|
|
11
|
+
export class RecordBudget {
|
|
12
|
+
private left = maxRecords;
|
|
13
|
+
|
|
14
|
+
/** Counts a statement or transaction about to be built; throws `FormatError` for one beyond the budget. */
|
|
15
|
+
take(): void {
|
|
16
|
+
if (--this.left < 0) throw new FormatError(`More than ${maxRecords} statements and transactions`);
|
|
17
|
+
}
|
|
18
|
+
}
|