@coderoftime/fints 0.1.0 → 0.2.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 +142 -1
- package/README.md +127 -37
- package/dist/client.d.ts +48 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +199 -0
- package/dist/client.js.map +1 -0
- package/dist/core/account.d.ts +35 -7
- package/dist/core/account.d.ts.map +1 -1
- package/dist/core/account.js +84 -13
- package/dist/core/account.js.map +1 -1
- package/dist/core/amount.d.ts +2 -1
- package/dist/core/amount.d.ts.map +1 -1
- package/dist/core/amount.js +4 -1
- package/dist/core/amount.js.map +1 -1
- package/dist/core/balance.d.ts +1 -0
- package/dist/core/balance.d.ts.map +1 -1
- package/dist/core/balance.js +6 -5
- package/dist/core/balance.js.map +1 -1
- package/dist/core/bank-info.d.ts +1 -1
- package/dist/core/bank-info.js +1 -1
- package/dist/core/client.d.ts +50 -15
- package/dist/core/client.d.ts.map +1 -1
- package/dist/core/config.d.ts +28 -16
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +66 -26
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.d.ts +85 -23
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +112 -47
- package/dist/core/errors.js.map +1 -1
- package/dist/core/feedback.d.ts +4 -1
- package/dist/core/feedback.d.ts.map +1 -1
- package/dist/core/feedback.js +4 -1
- package/dist/core/feedback.js.map +1 -1
- package/dist/core/transaction.d.ts +6 -1
- package/dist/core/transaction.d.ts.map +1 -1
- package/dist/core/transaction.js +9 -4
- package/dist/core/transaction.js.map +1 -1
- package/dist/core/transport.d.ts +33 -6
- package/dist/core/transport.d.ts.map +1 -1
- package/dist/core/transport.js +77 -20
- package/dist/core/transport.js.map +1 -1
- package/dist/formats/camt.d.ts +24 -4
- package/dist/formats/camt.d.ts.map +1 -1
- package/dist/formats/camt.js +124 -41
- package/dist/formats/camt.js.map +1 -1
- package/dist/formats/mt940.d.ts +15 -3
- package/dist/formats/mt940.d.ts.map +1 -1
- package/dist/formats/mt940.js +161 -82
- 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 +24 -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 +6 -0
- package/dist/formats/sepa-purpose.d.ts.map +1 -1
- package/dist/formats/sepa-purpose.js +23 -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 +7 -10
- package/dist/session/dialog-runner.d.ts.map +1 -1
- package/dist/session/dialog-runner.js +15 -16
- 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 +156 -84
- 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 +33 -25
- package/dist/session/session-setup.d.ts.map +1 -1
- package/dist/session/session-setup.js +87 -65
- package/dist/session/session-setup.js.map +1 -1
- package/dist/session/session-state.d.ts +9 -6
- package/dist/session/session-state.d.ts.map +1 -1
- package/dist/session/session-state.js +52 -17
- package/dist/session/session-state.js.map +1 -1
- package/dist/session/signed-dialog.d.ts +77 -0
- package/dist/session/signed-dialog.d.ts.map +1 -0
- package/dist/session/signed-dialog.js +170 -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 +8 -4
- package/dist/session/user-prompts.d.ts.map +1 -1
- package/dist/session/user-prompts.js +34 -14
- package/dist/session/user-prompts.js.map +1 -1
- package/dist/tan/challenge.d.ts +34 -13
- package/dist/tan/challenge.d.ts.map +1 -1
- package/dist/tan/challenge.js +25 -26
- package/dist/tan/challenge.js.map +1 -1
- package/dist/tan/flicker.d.ts +7 -1
- package/dist/tan/flicker.d.ts.map +1 -1
- package/dist/tan/flicker.js +40 -6
- package/dist/tan/flicker.js.map +1 -1
- package/dist/tan/tan-image.d.ts +3 -2
- package/dist/tan/tan-image.d.ts.map +1 -1
- package/dist/tan/tan-image.js +7 -5
- package/dist/tan/tan-image.js.map +1 -1
- package/dist/tan/tan-models.d.ts +5 -1
- package/dist/tan/tan-models.d.ts.map +1 -1
- package/dist/tan/tan-models.js +10 -5
- package/dist/tan/tan-models.js.map +1 -1
- package/dist/util/async.d.ts +0 -2
- package/dist/util/async.d.ts.map +1 -1
- package/dist/util/async.js +0 -4
- package/dist/util/async.js.map +1 -1
- package/dist/util/bytes.d.ts +2 -3
- package/dist/util/bytes.d.ts.map +1 -1
- package/dist/util/bytes.js +12 -20
- 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 +9 -16
- package/dist/util/dates.d.ts.map +1 -1
- package/dist/util/dates.js +39 -19
- package/dist/util/dates.js.map +1 -1
- package/dist/util/errors.d.ts +9 -1
- package/dist/util/errors.d.ts.map +1 -1
- package/dist/util/errors.js +12 -3
- package/dist/util/errors.js.map +1 -1
- 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 +21 -4
- package/dist/util/text.d.ts.map +1 -1
- package/dist/util/text.js +42 -13
- package/dist/util/text.js.map +1 -1
- package/dist/util/xml.d.ts +4 -3
- package/dist/util/xml.d.ts.map +1 -1
- package/dist/util/xml.js +37 -26
- 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 +34 -0
- package/dist/v3/bank-connection.d.ts.map +1 -0
- package/dist/v3/bank-connection.js +58 -0
- package/dist/v3/bank-connection.js.map +1 -0
- package/dist/v3/dialog.d.ts +80 -46
- package/dist/v3/dialog.d.ts.map +1 -1
- package/dist/v3/dialog.js +162 -59
- package/dist/v3/dialog.js.map +1 -1
- package/dist/v3/message.d.ts +17 -4
- package/dist/v3/message.d.ts.map +1 -1
- package/dist/v3/message.js +62 -18
- package/dist/v3/message.js.map +1 -1
- package/dist/v3/parameter-store.d.ts +75 -0
- package/dist/v3/parameter-store.d.ts.map +1 -0
- package/dist/v3/parameter-store.js +143 -0
- package/dist/v3/parameter-store.js.map +1 -0
- package/dist/v3/parameters.d.ts +38 -12
- package/dist/v3/parameters.d.ts.map +1 -1
- package/dist/v3/parameters.js +91 -37
- package/dist/v3/parameters.js.map +1 -1
- package/dist/v3/protocol.d.ts +43 -50
- package/dist/v3/protocol.d.ts.map +1 -1
- package/dist/v3/protocol.js +58 -208
- 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 +120 -0
- package/dist/v3/queries.js.map +1 -0
- package/dist/v3/segments.d.ts +29 -12
- package/dist/v3/segments.d.ts.map +1 -1
- package/dist/v3/segments.js +65 -23
- package/dist/v3/segments.js.map +1 -1
- package/dist/v3/syntax.d.ts +65 -14
- package/dist/v3/syntax.d.ts.map +1 -1
- package/dist/v3/syntax.js +167 -101
- package/dist/v3/syntax.js.map +1 -1
- package/package.json +10 -6
- package/src/client.ts +252 -0
- package/src/core/account.ts +86 -13
- package/src/core/amount.ts +3 -1
- package/src/core/balance.ts +6 -5
- package/src/core/bank-info.ts +1 -1
- package/src/core/client.ts +52 -15
- package/src/core/config.ts +69 -28
- package/src/core/errors.ts +122 -48
- package/src/core/feedback.ts +4 -1
- package/src/core/transaction.ts +9 -4
- package/src/core/transport.ts +98 -23
- package/src/formats/camt.ts +136 -39
- package/src/formats/mt940.ts +167 -81
- package/src/formats/readers.ts +24 -31
- package/src/formats/record-budget.ts +18 -0
- package/src/formats/sepa-purpose.ts +21 -2
- package/src/index.ts +4 -2
- package/src/session/app-approval.ts +51 -38
- package/src/session/dialog-runner.ts +31 -28
- package/src/session/login-guard.ts +174 -88
- package/src/session/sepa-accounts.ts +63 -45
- package/src/session/session-setup.ts +90 -71
- package/src/session/session-state.ts +56 -22
- package/src/session/signed-dialog.ts +192 -0
- package/src/session/tan-selection.ts +50 -39
- package/src/session/user-prompts.ts +35 -16
- package/src/tan/challenge.ts +40 -31
- package/src/tan/flicker.ts +44 -6
- package/src/tan/tan-image.ts +7 -5
- package/src/tan/tan-models.ts +10 -5
- package/src/util/async.ts +0 -5
- package/src/util/bytes.ts +13 -19
- package/src/util/call-context.ts +3 -2
- package/src/util/dates.ts +41 -24
- package/src/util/errors.ts +14 -3
- package/src/util/json.ts +16 -0
- package/src/util/text.ts +42 -12
- package/src/util/xml.ts +37 -26
- package/src/v3/account-extension.ts +7 -12
- package/src/v3/bank-connection.ts +67 -0
- package/src/v3/dialog.ts +175 -91
- package/src/v3/message.ts +69 -17
- package/src/v3/parameter-store.ts +160 -0
- package/src/v3/parameters.ts +110 -37
- package/src/v3/protocol.ts +70 -253
- package/src/v3/queries.ts +190 -0
- package/src/v3/segments.ts +65 -22
- package/src/v3/syntax.ts +153 -91
- 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/core/transport.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { concatBytes } from '../util/bytes.js';
|
|
2
|
+
import { invalidArgument } from '../util/errors.js';
|
|
2
3
|
import { FinTsTransportError } from './errors.js';
|
|
3
4
|
|
|
4
5
|
/** Sends one FinTS message and returns the bank's reply; replace it for a relay, proxies, pinning or tests. */
|
|
@@ -6,9 +7,16 @@ export interface FinTsTransport {
|
|
|
6
7
|
/**
|
|
7
8
|
* Posts `body` to `url` and returns the response body.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* The client aborts `signal` once it is closed: a transport that stops the request then keeps `close` from waiting
|
|
11
|
+
* for the answer.
|
|
12
|
+
*
|
|
13
|
+
* Implementations report failures as `FinTsTransportError`, with the HTTP status as `statusCode` where the bank
|
|
14
|
+
* answered with an HTTP error: an anonymous request for the bank parameter data that fails so goes out once more,
|
|
15
|
+
* without the announcement of strong authentication, which some banks fail on. Of any other error, another
|
|
16
|
+
* `FinTsError` included, the client keeps only its message and that of its cause, as a `FinTsTransportError` without
|
|
17
|
+
* `statusCode`. `body` holds PIN and TAN in plain text: keep it out of error messages and logs.
|
|
10
18
|
*/
|
|
11
|
-
post(url: URL, body: Uint8Array, contentType: string): Promise<Uint8Array>;
|
|
19
|
+
post(url: URL, body: Uint8Array, contentType: string, signal: AbortSignal): Promise<Uint8Array>;
|
|
12
20
|
|
|
13
21
|
/** Releases the connection; the client calls it when it is closed. */
|
|
14
22
|
close(): void;
|
|
@@ -16,14 +24,26 @@ export interface FinTsTransport {
|
|
|
16
24
|
|
|
17
25
|
/** What `HttpTransport` takes; all optional. */
|
|
18
26
|
export interface HttpTransportOptions {
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* The `fetch` to send with, for example one with a proxy agent; the global `fetch` by default.
|
|
29
|
+
*
|
|
30
|
+
* It must post to the URL it is given: an answer from another URL counts as a redirect, which fails the request. Of
|
|
31
|
+
* an answer whose `body` is no `ReadableStream`, as with node-fetch, the size can only be checked once it is read
|
|
32
|
+
* whole.
|
|
33
|
+
*/
|
|
20
34
|
fetch?: typeof fetch;
|
|
21
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Time for the whole exchange in milliseconds, from sending the request to the last byte of the response; 90 s. At
|
|
37
|
+
* most 2147483647 (about 24 days), the longest `setTimeout` waits.
|
|
38
|
+
*/
|
|
22
39
|
timeoutMs?: number;
|
|
23
|
-
/** Largest response accepted, so that a faulty server cannot exhaust the memory; 16 MiB. */
|
|
40
|
+
/** Largest response accepted in bytes, so that a faulty server cannot exhaust the memory; 16 MiB. */
|
|
24
41
|
maxResponseBytes?: number;
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
/** The longest delay `setTimeout` takes; it fires at once for a longer one, such as `Infinity`. */
|
|
45
|
+
const maxTimeoutMs = 2 ** 31 - 1;
|
|
46
|
+
|
|
27
47
|
/**
|
|
28
48
|
* Default `FinTsTransport`: HTTP POST with `fetch`.
|
|
29
49
|
*
|
|
@@ -35,30 +55,47 @@ export class HttpTransport implements FinTsTransport {
|
|
|
35
55
|
readonly maxResponseBytes: number;
|
|
36
56
|
private readonly fetchFn: typeof fetch;
|
|
37
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Throws `TypeError` if `timeoutMs` is not a positive number of at most 2147483647, or `maxResponseBytes` is not a
|
|
60
|
+
* positive number.
|
|
61
|
+
*/
|
|
38
62
|
constructor(options: HttpTransportOptions = {}) {
|
|
39
|
-
|
|
63
|
+
const custom = options.fetch;
|
|
64
|
+
// Called as a plain function: a browser's native `fetch` fails as a method of another object.
|
|
65
|
+
this.fetchFn = custom === undefined ? (input, init) => fetch(input, init) : (input, init) => custom(input, init);
|
|
40
66
|
this.timeoutMs = options.timeoutMs ?? 90_000;
|
|
41
67
|
this.maxResponseBytes = options.maxResponseBytes ?? 16 << 20;
|
|
68
|
+
if (!isPositive(this.timeoutMs) || this.timeoutMs > maxTimeoutMs) {
|
|
69
|
+
throw invalidArgument(`Must be a positive number of at most ${maxTimeoutMs}`, 'timeoutMs');
|
|
70
|
+
}
|
|
71
|
+
if (!isPositive(this.maxResponseBytes)) throw invalidArgument('Must be a positive number', 'maxResponseBytes');
|
|
42
72
|
}
|
|
43
73
|
|
|
44
|
-
|
|
74
|
+
/** Fails with `FinTsTransportError` at the timeout or once `signal` is aborted; sends nothing if it is already. */
|
|
75
|
+
async post(url: URL, body: Uint8Array, contentType: string, signal?: AbortSignal): Promise<Uint8Array> {
|
|
76
|
+
if (signal?.aborted) throw new FinTsTransportError('Request aborted');
|
|
45
77
|
const controller = new AbortController();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
reject(new FinTsTransportError('Request timed out'));
|
|
52
|
-
}, this.timeoutMs);
|
|
78
|
+
// Raced with the exchange, for a `fetch` that cannot abort a request.
|
|
79
|
+
const stopped = new Promise<never>((_, reject) => {
|
|
80
|
+
controller.signal.addEventListener('abort', () => {
|
|
81
|
+
reject(controller.signal.reason as FinTsTransportError);
|
|
82
|
+
});
|
|
53
83
|
});
|
|
84
|
+
const stop = (message: string) => (): void => {
|
|
85
|
+
controller.abort(new FinTsTransportError(message));
|
|
86
|
+
};
|
|
87
|
+
const timer = setTimeout(stop('Request timed out'), this.timeoutMs);
|
|
88
|
+
const abort = stop('Request aborted');
|
|
89
|
+
signal?.addEventListener('abort', abort);
|
|
54
90
|
try {
|
|
55
|
-
return await Promise.race([this.exchange(url, body, contentType, controller.signal),
|
|
91
|
+
return await Promise.race([this.exchange(url, body, contentType, controller.signal), stopped]);
|
|
56
92
|
} catch (error) {
|
|
93
|
+
if (controller.signal.aborted) throw controller.signal.reason as FinTsTransportError;
|
|
57
94
|
if (error instanceof FinTsTransportError) throw error;
|
|
58
|
-
|
|
59
|
-
throw new FinTsTransportError(describe(error));
|
|
95
|
+
throw new FinTsTransportError(failureMessage(error));
|
|
60
96
|
} finally {
|
|
61
97
|
clearTimeout(timer);
|
|
98
|
+
signal?.removeEventListener('abort', abort);
|
|
62
99
|
}
|
|
63
100
|
}
|
|
64
101
|
|
|
@@ -74,8 +111,13 @@ export class HttpTransport implements FinTsTransport {
|
|
|
74
111
|
if (response.type === 'opaqueredirect') {
|
|
75
112
|
throw new FinTsTransportError('The server redirects the request, which the client does not follow');
|
|
76
113
|
}
|
|
114
|
+
// A `fetch` that ignores `redirect`, as one over XMLHttpRequest does, shows a redirect it followed only here.
|
|
115
|
+
if (response.redirected || (response.url && response.url !== withoutFragment(url))) {
|
|
116
|
+
await discard(response);
|
|
117
|
+
throw new FinTsTransportError('The fetch followed a redirect: the request may have gone to another address');
|
|
118
|
+
}
|
|
77
119
|
if (response.status < 200 || response.status > 299) {
|
|
78
|
-
await response
|
|
120
|
+
await discard(response);
|
|
79
121
|
throw new FinTsTransportError(`HTTP ${response.status} ${response.statusText}`.trim(), {
|
|
80
122
|
statusCode: response.status,
|
|
81
123
|
});
|
|
@@ -84,8 +126,14 @@ export class HttpTransport implements FinTsTransport {
|
|
|
84
126
|
}
|
|
85
127
|
|
|
86
128
|
private async readBody(response: Response): Promise<Uint8Array> {
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
// A `fetch` that does not stream, as one over XMLHttpRequest or node-fetch, gives no body or one of another kind.
|
|
130
|
+
const body = response.body as ReadableStream<Uint8Array> | null | undefined;
|
|
131
|
+
if (typeof body?.getReader !== 'function') {
|
|
132
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
133
|
+
if (bytes.length > this.maxResponseBytes) throw this.tooLarge();
|
|
134
|
+
return bytes;
|
|
135
|
+
}
|
|
136
|
+
const reader = body.getReader();
|
|
89
137
|
const chunks: Uint8Array[] = [];
|
|
90
138
|
let size = 0;
|
|
91
139
|
for (;;) {
|
|
@@ -95,19 +143,46 @@ export class HttpTransport implements FinTsTransport {
|
|
|
95
143
|
size += value.length;
|
|
96
144
|
if (size > this.maxResponseBytes) {
|
|
97
145
|
await reader.cancel().catch(() => undefined);
|
|
98
|
-
throw
|
|
146
|
+
throw this.tooLarge();
|
|
99
147
|
}
|
|
100
148
|
}
|
|
101
149
|
return concatBytes(chunks);
|
|
102
150
|
}
|
|
103
151
|
|
|
152
|
+
private tooLarge(): FinTsTransportError {
|
|
153
|
+
return new FinTsTransportError(`Response larger than ${this.maxResponseBytes} bytes`);
|
|
154
|
+
}
|
|
155
|
+
|
|
104
156
|
close(): void {
|
|
105
157
|
// `fetch` keeps no connection the transport could release.
|
|
106
158
|
}
|
|
107
159
|
}
|
|
108
160
|
|
|
109
|
-
/**
|
|
110
|
-
function
|
|
161
|
+
/** `url` as `Response.url` gives it: without fragment. */
|
|
162
|
+
function withoutFragment(url: URL): string {
|
|
163
|
+
const copy = new URL(url);
|
|
164
|
+
copy.hash = '';
|
|
165
|
+
return copy.href;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Cancels the body of a response the transport rejects, unless it cannot be cancelled, as a Node.js stream cannot. */
|
|
169
|
+
async function discard(response: Response): Promise<void> {
|
|
170
|
+
try {
|
|
171
|
+
await response.body?.cancel();
|
|
172
|
+
} catch {
|
|
173
|
+
// The response is rejected all the same.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isPositive(value: number): boolean {
|
|
178
|
+
return Number.isFinite(value) && value > 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The message of a failed request, with the message of its cause: Node.js reports `fetch failed` and the reason in the
|
|
183
|
+
* cause. Only the messages, since the error may hold the request, PIN and TAN included.
|
|
184
|
+
*/
|
|
185
|
+
export function failureMessage(error: unknown): string {
|
|
111
186
|
if (!(error instanceof Error)) return String(error);
|
|
112
187
|
const cause: unknown = error.cause;
|
|
113
188
|
return cause instanceof Error ? `${error.message}: ${cause.message}` : error.message;
|
package/src/formats/camt.ts
CHANGED
|
@@ -1,30 +1,81 @@
|
|
|
1
1
|
import { Amount } from '../core/amount.js';
|
|
2
2
|
import { Transaction, type TransactionStatus } from '../core/transaction.js';
|
|
3
|
-
import { parseIsoDate } from '../util/dates.js';
|
|
4
|
-
import { FormatError } from '../util/errors.js';
|
|
3
|
+
import { germanDay, parseIsoDate, parseIsoMoment } from '../util/dates.js';
|
|
4
|
+
import { fail, FormatError } from '../util/errors.js';
|
|
5
5
|
import { parseXml, type XmlElement } from '../util/xml.js';
|
|
6
|
+
import { RecordBudget } from './record-budget.js';
|
|
6
7
|
import { providedReference, SepaPurpose } from './sepa-purpose.js';
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/** One report (camt.052), statement (camt.053) or notification (camt.054): the entries of one account. */
|
|
10
|
+
export interface CamtStatement {
|
|
11
|
+
/** The bank's identification of the statement (`Id`). */
|
|
12
|
+
readonly reference: string | undefined;
|
|
13
|
+
/** The account the statement is for: its IBAN, or the bank's other identification of it where it states none. */
|
|
14
|
+
readonly accountId: string | undefined;
|
|
15
|
+
/** The currency of the account, where the statement states it. */
|
|
16
|
+
readonly currency: string | undefined;
|
|
17
|
+
/** The transactions of the statement, in the order the bank lists them. */
|
|
18
|
+
readonly transactions: readonly Transaction[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Local names of the elements of a document that hold the entries of one account. */
|
|
22
|
+
const statementNames = new Set(['Rpt', 'Stmt', 'Ntfctn']);
|
|
23
|
+
|
|
24
|
+
/** Parser for ISO 20022 camt.052, camt.053 and camt.054 account reports, statements and notifications. */
|
|
9
25
|
export const Camt = Object.freeze({
|
|
10
26
|
/**
|
|
11
|
-
* Parses
|
|
12
|
-
*
|
|
27
|
+
* Parses the transactions of all statements in one list, which does not tell the account of each: a document may hold
|
|
28
|
+
* statements of several accounts, which `parse` keeps apart. Throws as `parse` does.
|
|
29
|
+
*/
|
|
30
|
+
parseTransactions(xml: string, status?: TransactionStatus): Transaction[] {
|
|
31
|
+
return Camt.parse(xml, status).flatMap((s) => [...s.transactions]);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses all statements, each with the account it is for. Throws `FormatError` for malformed XML, for a document
|
|
36
|
+
* without any statement, for an entry without amount, currency, credit/debit indicator or date, for a date it cannot
|
|
37
|
+
* read, and for more than 200,000 statements and transactions.
|
|
13
38
|
*
|
|
14
39
|
* `status` overrides the booking status stated in the entries.
|
|
15
40
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return root.descendantsNamed('Ntry').flatMap((entry) => new CamtEntry(entry, status).transactions());
|
|
41
|
+
parse(xml: string, status?: TransactionStatus): CamtStatement[] {
|
|
42
|
+
return parseCamt(xml, status, new RecordBudget());
|
|
19
43
|
},
|
|
20
44
|
});
|
|
21
45
|
|
|
46
|
+
/** `Camt.parse`, which takes each statement and transaction from `budget`, before it builds it. */
|
|
47
|
+
export function parseCamt(xml: string, status: TransactionStatus | undefined, budget: RecordBudget): CamtStatement[] {
|
|
48
|
+
const root = parseXml(xml.trim());
|
|
49
|
+
const statements = root.childElements.flatMap((message) =>
|
|
50
|
+
message.childElements.filter((c) => statementNames.has(c.localName)),
|
|
51
|
+
);
|
|
52
|
+
if (statements.length === 0) throw new FormatError('camt document without report, statement or notification');
|
|
53
|
+
return statements.map((statement) => {
|
|
54
|
+
budget.take();
|
|
55
|
+
const account = statement.child('Acct');
|
|
56
|
+
const entries = statement.childrenNamed('Ntry');
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
reference: statement.textAt(['Id']),
|
|
59
|
+
accountId: account?.textAt(['Id', 'IBAN']) ?? account?.textAt(['Id', 'Othr', 'Id']),
|
|
60
|
+
currency: account?.textAt(['Ccy']),
|
|
61
|
+
transactions: Object.freeze(entries.flatMap((entry) => new CamtEntry(entry, status, budget).transactions())),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
/** Width of the purpose lines banks cut the remittance information into, as in MT940 and DTAUS. */
|
|
23
67
|
const cutLineLength = 35;
|
|
24
68
|
|
|
25
69
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
+
/**
|
|
77
|
+
* One booking (`Ntry`) of a report; a batch booking yields one transaction per transaction detail (`TxDtls`) when the
|
|
78
|
+
* details state their amounts and these add up to the booked amount.
|
|
28
79
|
*
|
|
29
80
|
* Nothing is guessed: what the entry lacks, and what the reader needs, is a `FormatError`. The one exception is the
|
|
30
81
|
* value date, which `Transaction.valueDate` requires: an entry with a booking date only takes that.
|
|
@@ -37,6 +88,8 @@ class CamtEntry {
|
|
|
37
88
|
private readonly status: TransactionStatus;
|
|
38
89
|
private readonly bookingDate: Date | undefined;
|
|
39
90
|
private readonly valueDate: Date;
|
|
91
|
+
private readonly bookingText: string | undefined;
|
|
92
|
+
private readonly bankReference: string | undefined;
|
|
40
93
|
|
|
41
94
|
/** German banks send `transaction type+GVC+prima nota`, for example `NDDT+105+9310`. */
|
|
42
95
|
private readonly proprietaryCode: readonly string[];
|
|
@@ -44,6 +97,7 @@ class CamtEntry {
|
|
|
44
97
|
constructor(
|
|
45
98
|
private readonly entry: XmlElement,
|
|
46
99
|
forcedStatus: TransactionStatus | undefined,
|
|
100
|
+
private readonly budget: RecordBudget,
|
|
47
101
|
) {
|
|
48
102
|
this.amountElement = entry.child('Amt') ?? fail('camt entry without amount');
|
|
49
103
|
this.currency = this.amountElement.getAttribute('Ccy') ?? fail('camt amount without currency');
|
|
@@ -52,6 +106,8 @@ class CamtEntry {
|
|
|
52
106
|
this.status = forcedStatus ?? this.statusOfEntry();
|
|
53
107
|
this.bookingDate = dateOf(entry.child('BookgDt'));
|
|
54
108
|
this.valueDate = dateOf(entry.child('ValDt')) ?? this.bookingDate ?? fail('camt entry without date');
|
|
109
|
+
this.bookingText = entry.textAt(['AddtlNtryInf']);
|
|
110
|
+
this.bankReference = entry.textAt(['AcctSvcrRef']);
|
|
55
111
|
this.proprietaryCode = entry.textAt(['BkTxCd', 'Prtry', 'Cd'])?.split('+') ?? [];
|
|
56
112
|
}
|
|
57
113
|
|
|
@@ -61,44 +117,53 @@ class CamtEntry {
|
|
|
61
117
|
}
|
|
62
118
|
|
|
63
119
|
transactions(): Transaction[] {
|
|
64
|
-
const total =
|
|
65
|
-
const details = this.entry.
|
|
120
|
+
const total = amountOf(this.amountElement, this.currency);
|
|
121
|
+
const details = this.entry.childrenNamed('NtryDtls').flatMap((d) => d.childrenNamed('TxDtls'));
|
|
66
122
|
if (details.length <= 1) return [this.transaction(details[0], total, this.debit)];
|
|
123
|
+
// A batch is split only into details that make up its booking: each states its amount, and they add up to the
|
|
124
|
+
// booked one, in its currency. It stays one booking otherwise, without the details of any one of them, which would
|
|
125
|
+
// describe the others wrongly.
|
|
126
|
+
const booking = this.transaction(undefined, total, this.debit);
|
|
67
127
|
const amounts = details.map((detail) => detail.at(['AmtDtls', 'TxAmt', 'Amt']) ?? detail.child('Amt'));
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return details.map((detail, i) => {
|
|
72
|
-
const amount = amounts[i]!;
|
|
73
|
-
return this.transaction(
|
|
128
|
+
if (amounts.includes(undefined)) return [booking];
|
|
129
|
+
const split = details.map((detail, i) =>
|
|
130
|
+
this.transaction(
|
|
74
131
|
detail,
|
|
75
|
-
|
|
132
|
+
amountOf(amounts[i]!, this.currency),
|
|
76
133
|
// A detail may book in the other direction than the batch as a whole.
|
|
77
134
|
debitIndicator(detail.textAt(['CdtDbtInd'])) ?? this.debit,
|
|
78
|
-
)
|
|
79
|
-
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
return addUpTo(split, booking) ? split : [booking];
|
|
80
138
|
}
|
|
81
139
|
|
|
82
140
|
/** A transaction of `amount`, booked as a debit if `debit`, with what `details` tell about it. */
|
|
83
141
|
private transaction(details: XmlElement | undefined, amount: Amount, debit: boolean): Transaction {
|
|
142
|
+
this.budget.take();
|
|
84
143
|
const parties = details?.child('RltdPties');
|
|
85
144
|
const agents = details?.child('RltdAgts');
|
|
86
145
|
// The related parties describe the original operation, which a reversal books the other way round (ISO 20022
|
|
87
146
|
// `RvslInd`: with `CRDT` the original operation was a debit, with `DBIT` a credit).
|
|
88
147
|
const originalDebit = debit !== this.reversal;
|
|
89
148
|
const counterparty = originalDebit ? 'Cdtr' : 'Dbtr';
|
|
90
|
-
const lines = (details?.
|
|
91
|
-
.map((line) => line.innerText
|
|
92
|
-
.filter((text) => text !== '');
|
|
93
|
-
|
|
94
|
-
|
|
149
|
+
const lines = (details?.child('RmtInf')?.childrenNamed('Ustrd') ?? [])
|
|
150
|
+
.map((line) => line.innerText)
|
|
151
|
+
.filter((text) => text.trim() !== '');
|
|
152
|
+
// 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
|
+
// other line ends a phrase, which the next one follows after a blank.
|
|
154
|
+
const cut = (i: number): boolean => lines[i]?.length === cutLineLength;
|
|
155
|
+
const purposeParts = lines.map((line, i) => {
|
|
156
|
+
const part = cut(i) ? line : line.trimEnd();
|
|
157
|
+
return i === 0 || cut(i - 1) ? part : ` ${part.trimStart()}`;
|
|
158
|
+
});
|
|
159
|
+
const purpose = purposeParts.join('').trim();
|
|
95
160
|
const keywords = SepaPurpose.parseParts(purposeParts);
|
|
96
161
|
const endToEndId = providedReference(details?.textAt(['Refs', 'EndToEndId']));
|
|
97
162
|
const mandateId = details?.textAt(['Refs', 'MndtId']);
|
|
98
|
-
const creditorId = creditorIdOf(parties);
|
|
163
|
+
const creditorId = creditorIdOf(parties?.child('Cdtr'));
|
|
99
164
|
const purposeCode = details?.textAt(['Purp', 'Cd']);
|
|
100
165
|
const typeId = this.proprietaryCode[0]?.length === 4 ? this.proprietaryCode[0] : undefined;
|
|
101
|
-
const purposeCodes = new Map(keywords);
|
|
166
|
+
const purposeCodes = new Map([...keywords].filter(([keyword]) => !keywordsStatedApart.has(keyword)));
|
|
102
167
|
if (endToEndId !== undefined) purposeCodes.set('EREF', endToEndId);
|
|
103
168
|
if (mandateId !== undefined) purposeCodes.set('MREF', mandateId);
|
|
104
169
|
if (creditorId !== undefined) purposeCodes.set('CRED', creditorId);
|
|
@@ -109,7 +174,7 @@ class CamtEntry {
|
|
|
109
174
|
valueDate: this.valueDate,
|
|
110
175
|
amount: debit ? amount.negate() : amount,
|
|
111
176
|
isReversal: this.reversal,
|
|
112
|
-
bookingText: this.
|
|
177
|
+
bookingText: this.bookingText ?? details?.textAt(['AddtlTxInf']),
|
|
113
178
|
purpose: keywords.get('SVWZ') ?? (purpose === '' ? details?.textAt(['AddtlTxInf']) : purpose),
|
|
114
179
|
counterpartyName: partyName(parties?.child(counterparty)),
|
|
115
180
|
counterpartyIban: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'IBAN']),
|
|
@@ -119,7 +184,7 @@ class CamtEntry {
|
|
|
119
184
|
mandateId,
|
|
120
185
|
creditorId,
|
|
121
186
|
customerReference: details?.textAt(['Refs', 'InstrId']) ?? details?.textAt(['Refs', 'PmtInfId']),
|
|
122
|
-
bankReference: details?.textAt(['Refs', 'AcctSvcrRef']) ?? this.
|
|
187
|
+
bankReference: details?.textAt(['Refs', 'AcctSvcrRef']) ?? this.bankReference,
|
|
123
188
|
transactionCode: this.proprietaryCode[1],
|
|
124
189
|
transactionTypeId: typeId,
|
|
125
190
|
primaNota: this.proprietaryCode[2],
|
|
@@ -130,8 +195,29 @@ class CamtEntry {
|
|
|
130
195
|
}
|
|
131
196
|
}
|
|
132
197
|
|
|
133
|
-
|
|
134
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The amount `element` states, in its currency or else in `currency`; throws `FormatError` for a negative one, whose
|
|
200
|
+
* sign the credit/debit indicator would turn: the indicator alone gives the direction.
|
|
201
|
+
*/
|
|
202
|
+
function amountOf(element: XmlElement, currency: string): Amount {
|
|
203
|
+
const amount = Amount.parseDecimal(element.innerText, element.getAttribute('Ccy') ?? currency);
|
|
204
|
+
if (amount.isNegative) throw new FormatError('Negative camt amount', element.innerText);
|
|
205
|
+
return amount;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether the amounts of `parts` add up to that of `whole`, in its currency; amounts whose sum an `Amount` cannot hold
|
|
210
|
+
* do not.
|
|
211
|
+
*/
|
|
212
|
+
function addUpTo(parts: readonly Transaction[], whole: Transaction): boolean {
|
|
213
|
+
const total = whole.amount;
|
|
214
|
+
if (parts.some((part) => part.amount.currency !== total.currency)) return false;
|
|
215
|
+
try {
|
|
216
|
+
return parts.reduce((sum, part) => sum.add(part.amount), new Amount(0, 0, total.currency)).equals(total);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof RangeError) return false;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
135
221
|
}
|
|
136
222
|
|
|
137
223
|
/** `true` for `DBIT`, `false` for `CRDT`, `undefined` if `indicator` is missing; throws for any other value. */
|
|
@@ -148,8 +234,17 @@ function debitIndicator(indicator: string | undefined): boolean | undefined {
|
|
|
148
234
|
}
|
|
149
235
|
}
|
|
150
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The calendar date of `e`: its date (`Dt`), or the German day of its date and time (`DtTm`), which may be stated in
|
|
239
|
+
* UTC or another offset; `undefined` if it states neither. Throws `FormatError` for one it cannot read, which would
|
|
240
|
+
* pass for a missing one otherwise, such as a value date the booking date stands in for.
|
|
241
|
+
*/
|
|
151
242
|
function dateOf(e: XmlElement | undefined): Date | undefined {
|
|
152
|
-
|
|
243
|
+
const date = e?.textAt(['Dt']);
|
|
244
|
+
if (date !== undefined) return parseIsoDate(date) ?? fail('Unreadable camt date', date);
|
|
245
|
+
const dateTime = e?.textAt(['DtTm']);
|
|
246
|
+
if (dateTime === undefined) return undefined;
|
|
247
|
+
return germanDay(parseIsoMoment(dateTime) ?? fail('Unreadable camt date', dateTime));
|
|
153
248
|
}
|
|
154
249
|
|
|
155
250
|
function partyName(party: XmlElement | undefined): string | undefined {
|
|
@@ -160,10 +255,12 @@ function bicOf(agent: XmlElement | undefined): string | undefined {
|
|
|
160
255
|
return agent?.textAt(['FinInstnId', 'BIC']) ?? agent?.textAt(['FinInstnId', 'BICFI']);
|
|
161
256
|
}
|
|
162
257
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
258
|
+
/**
|
|
259
|
+
* The SEPA creditor identifier of the `creditor`: its private `Othr` identification in the scheme `SEPA`. The other
|
|
260
|
+
* parties' identifications are the creditor's or the debtor's to choose, and may be in that scheme as well.
|
|
261
|
+
*/
|
|
262
|
+
function creditorIdOf(creditor: XmlElement | undefined): string | undefined {
|
|
263
|
+
const identifications = creditor?.at(['Id', 'PrvtId']) ?? creditor?.at(['Pty', 'Id', 'PrvtId']);
|
|
264
|
+
const sepa = identifications?.childrenNamed('Othr').find((other) => other.textAt(['SchmeNm', 'Prtry']) === 'SEPA');
|
|
265
|
+
return sepa?.textAt(['Id']);
|
|
169
266
|
}
|