@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/tan/flicker.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FormatError } from '../util/errors.js';
|
|
2
|
-
import {
|
|
2
|
+
import { inspectCustom } from '../util/inspect.js';
|
|
3
|
+
import { trimBlanks, withoutWhitespace } from '../util/text.js';
|
|
3
4
|
|
|
4
5
|
/** Version of the chipTAN HHD specification a flicker code follows: 1.3, or 1.4, which adds control bytes. */
|
|
5
6
|
export type HhdVersion = 'hhd13' | 'hhd14';
|
|
@@ -10,23 +11,36 @@ const codeMarker = 'CHLGUC';
|
|
|
10
11
|
const textMarker = 'CHLGTEXT';
|
|
11
12
|
const markerLengthDigits = 4;
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The flicker code and the instruction text of a combined challenge; `undefined` unless `message` starts with
|
|
16
|
+
* `CHLGUC`, after blanks, and states the length of the code. White space in the code wraps lines and is dropped.
|
|
17
|
+
*/
|
|
18
|
+
function splitCombined(message: string): { code: string; instruction: string } | undefined {
|
|
19
|
+
const text = trimBlanks(message, { end: false });
|
|
20
|
+
const end = text.indexOf(textMarker);
|
|
21
|
+
if (!text.startsWith(codeMarker) || end < 0) return undefined;
|
|
22
|
+
const lengthAndCode = withoutWhitespace(text.substring(codeMarker.length, end));
|
|
23
|
+
const code = lengthAndCode.substring(markerLengthDigits);
|
|
24
|
+
const length = lengthAndCode.substring(0, markerLengthDigits);
|
|
25
|
+
if (!/^[0-9]{4}$/.test(length) || parseInt(length, 10) !== code.length) return undefined;
|
|
26
|
+
return { code, instruction: text.substring(end + textMarker.length + markerLengthDigits) };
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
/** Whether `message` is a combined challenge with an embedded flicker code. */
|
|
14
30
|
export function hasEmbeddedFlickerCode(message: string): boolean {
|
|
15
|
-
return message
|
|
31
|
+
return splitCombined(message) !== undefined;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** The instruction text of a combined challenge; `message` itself if it is none. */
|
|
19
35
|
export function challengeInstruction(message: string): string {
|
|
20
|
-
|
|
21
|
-
if (start < 0) return message;
|
|
22
|
-
const textStart = start + textMarker.length + markerLengthDigits;
|
|
23
|
-
return textStart < message.length ? message.substring(textStart) : '';
|
|
36
|
+
return splitCombined(message)?.instruction ?? message;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/**
|
|
27
40
|
* Digit counts of the challenge length (LC) and of the data element lengths (LDE) to try when parsing.
|
|
28
41
|
*
|
|
29
|
-
* HHD 1.4 states the LC with three digits, HHD 1.3 with two.
|
|
42
|
+
* HHD 1.4 states the LC with three digits, HHD 1.3 with two. Some banks state the LDE of HHD 1.4 with three digits;
|
|
43
|
+
* a code that both LDE widths read is taken with two, as the specification has it.
|
|
30
44
|
*/
|
|
31
45
|
const layouts = [
|
|
32
46
|
{ lcDigits: 3, lengthDigits: 2 },
|
|
@@ -34,6 +48,15 @@ const layouts = [
|
|
|
34
48
|
{ lcDigits: 2, lengthDigits: 2 },
|
|
35
49
|
];
|
|
36
50
|
|
|
51
|
+
/** The most bytes a length field states: HHD 1.3 flags ASCII data in bit 4 of it, HHD 1.4 in bit 6. */
|
|
52
|
+
const maxElementLength: Readonly<Record<HhdVersion, number>> = { hhd13: 0x0f, hhd14: 0x3f };
|
|
53
|
+
|
|
54
|
+
/** The most bytes of a rendered code, which its one-byte length (LC) states. */
|
|
55
|
+
const maxCodeLength = 0xff;
|
|
56
|
+
|
|
57
|
+
/** The characters a TAN generator shows; one byte each. */
|
|
58
|
+
const printableAscii = /^[\x20-\x7E]*$/;
|
|
59
|
+
|
|
37
60
|
function hex(value: number, width: number): string {
|
|
38
61
|
return value.toString(16).toUpperCase().padStart(width, '0');
|
|
39
62
|
}
|
|
@@ -49,12 +72,17 @@ export class FlickerCode {
|
|
|
49
72
|
private readonly dataElements: readonly DataElement[],
|
|
50
73
|
) {}
|
|
51
74
|
|
|
52
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Parses the challenge data sent by the bank, the code itself or a combined challenge, which embeds it in the
|
|
77
|
+
* challenge text (`CHLGUC`), wrapped into lines; throws `FormatError` if it is not a flicker code a TAN generator can
|
|
78
|
+
* take: one with characters after the length it states, an empty data element, data other than printable ASCII, or
|
|
79
|
+
* more data than its length fields can state, such as more than 15 bytes in an element in HHD 1.3 or 255 bytes in all.
|
|
80
|
+
*/
|
|
53
81
|
static parse(code: string): FlickerCode {
|
|
54
|
-
const
|
|
82
|
+
const embedded = splitCombined(code)?.code ?? trimBlanks(code);
|
|
55
83
|
for (const layout of layouts) {
|
|
56
84
|
try {
|
|
57
|
-
return FlickerCode.parseLayout(new Reader(
|
|
85
|
+
return FlickerCode.parseLayout(new Reader(embedded), layout.lcDigits, layout.lengthDigits);
|
|
58
86
|
} catch (error) {
|
|
59
87
|
// Not this layout; try the next one.
|
|
60
88
|
if (!(error instanceof FormatError)) throw error;
|
|
@@ -82,19 +110,28 @@ export class FlickerCode {
|
|
|
82
110
|
const controlBytes: number[] = [];
|
|
83
111
|
if ((startLength & 0x80) !== 0) {
|
|
84
112
|
do {
|
|
113
|
+
if (controlBytes.length === 10) throw new FormatError('Too many control bytes in flicker code');
|
|
85
114
|
controlBytes.push(challenge.hex(2));
|
|
86
|
-
} while ((controlBytes.at(-1)! & 0x80) !== 0
|
|
115
|
+
} while ((controlBytes.at(-1)! & 0x80) !== 0);
|
|
87
116
|
}
|
|
88
|
-
const startCode = new StartCode(challenge.take(startLength &
|
|
117
|
+
const startCode = new StartCode(challenge.take(elementLength(startLength & 0x7f)), controlBytes);
|
|
89
118
|
const dataElements: DataElement[] = [];
|
|
90
119
|
for (let i = 0; i < 3; i++) {
|
|
91
120
|
dataElements.push(
|
|
92
|
-
new DataElement(challenge.isAtEnd ? undefined : challenge.take(challenge.decimal(lengthDigits)
|
|
121
|
+
new DataElement(challenge.isAtEnd ? undefined : challenge.take(elementLength(challenge.decimal(lengthDigits)))),
|
|
93
122
|
);
|
|
94
123
|
}
|
|
95
|
-
//
|
|
96
|
-
if (!challenge.isAtEnd) throw new FormatError('Flicker code fields do not match its length');
|
|
97
|
-
|
|
124
|
+
// Only a layout whose fields fill exactly the LC characters, which end the code, can be the right one.
|
|
125
|
+
if (!challenge.isAtEnd || !code.isAtEnd) throw new FormatError('Flicker code fields do not match its length');
|
|
126
|
+
const flicker = new FlickerCode(controlBytes.length === 0 ? 'hhd13' : 'hhd14', lc, startCode, dataElements);
|
|
127
|
+
if (!flicker.fitsTanGenerator) throw new FormatError('Flicker code data the TAN generator cannot take');
|
|
128
|
+
return flicker;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether the code renders as the bank sent it: its data printable ASCII, every length within its field. */
|
|
132
|
+
private get fitsTanGenerator(): boolean {
|
|
133
|
+
const elements = [this.startCode, ...this.dataElements];
|
|
134
|
+
return elements.every((element) => element.fits(this.version)) && this.payload().length <= 2 * maxCodeLength;
|
|
98
135
|
}
|
|
99
136
|
|
|
100
137
|
/** The data of the start code, which opens the challenge. */
|
|
@@ -161,15 +198,21 @@ export class FlickerCode {
|
|
|
161
198
|
}
|
|
162
199
|
return result;
|
|
163
200
|
}
|
|
201
|
+
|
|
202
|
+
/** What Node.js's `util.inspect` and `console.log` show: the version, not the data, which may hold IBAN and amount. */
|
|
203
|
+
[inspectCustom](): string {
|
|
204
|
+
return `FlickerCode(${this.version})`;
|
|
205
|
+
}
|
|
164
206
|
}
|
|
165
207
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The length a field of the challenge states for the start code or a data element: 1 to 63 characters. The challenge
|
|
210
|
+
* leaves out an element without data and sets no flag in bit 6, which only the rendered code carries; bit 7 of the start
|
|
211
|
+
* code, which announces control bytes, is masked off before.
|
|
212
|
+
*/
|
|
213
|
+
function elementLength(length: number): number {
|
|
214
|
+
if (length === 0 || length > 0x3f) throw new FormatError('Invalid data element length');
|
|
215
|
+
return length;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
function xorChecksum(payload: string): string {
|
|
@@ -233,6 +276,12 @@ class DataElement {
|
|
|
233
276
|
return this.data === undefined || /^[0-9]+$/.test(this.data);
|
|
234
277
|
}
|
|
235
278
|
|
|
279
|
+
/** Whether the data are printable ASCII, and no more bytes than the length field of `version` can state. */
|
|
280
|
+
fits(version: HhdVersion): boolean {
|
|
281
|
+
const d = this.data;
|
|
282
|
+
return d === undefined || (printableAscii.test(d) && this.renderData().length / 2 <= maxElementLength[version]);
|
|
283
|
+
}
|
|
284
|
+
|
|
236
285
|
renderData(): string {
|
|
237
286
|
const d = this.data;
|
|
238
287
|
if (d === undefined) return '';
|
package/src/tan/tan-image.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { base64Encode, latin1Decode } from '../util/bytes.js';
|
|
2
|
+
import { inspectCustom } from '../util/inspect.js';
|
|
3
|
+
import { counted } from '../util/text.js';
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
/** The formats of photoTAN and QR-TAN images: raster images, which unlike SVG cannot carry scripts. */
|
|
6
|
+
const imageMimeTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp']);
|
|
4
7
|
|
|
5
8
|
/** An image challenge (photoTAN, QR-TAN) with its MIME type. */
|
|
6
9
|
export class TanImage {
|
|
@@ -14,23 +17,29 @@ export class TanImage {
|
|
|
14
17
|
/**
|
|
15
18
|
* Reads the bank's image container: a two-byte length and the MIME type, then a two-byte length and the image.
|
|
16
19
|
*
|
|
17
|
-
* `undefined` unless the MIME type is
|
|
20
|
+
* `undefined` unless the MIME type, in any case, is that of a PNG, JPEG, GIF, WebP or BMP image, and the container
|
|
21
|
+
* holds the whole image, of at least one byte.
|
|
18
22
|
*/
|
|
19
23
|
static tryParse(data: Uint8Array): TanImage | undefined {
|
|
20
24
|
if (data.length < 4) return undefined;
|
|
21
25
|
const mimeLength = (data[0]! << 8) | data[1]!;
|
|
22
26
|
if (mimeLength === 0 || data.length < 2 + mimeLength + 2) return undefined;
|
|
23
|
-
const mime = latin1Decode(data.subarray(2, 2 + mimeLength));
|
|
24
|
-
if (!
|
|
27
|
+
const mime = latin1Decode(data.subarray(2, 2 + mimeLength)).toLowerCase();
|
|
28
|
+
if (!imageMimeTypes.has(mime)) return undefined;
|
|
25
29
|
const offset = 2 + mimeLength;
|
|
26
30
|
const imageLength = (data[offset]! << 8) | data[offset + 1]!;
|
|
27
31
|
const start = offset + 2;
|
|
28
|
-
if (start + imageLength > data.length) return undefined;
|
|
32
|
+
if (imageLength === 0 || start + imageLength > data.length) return undefined;
|
|
29
33
|
return new TanImage(mime, data.subarray(start, start + imageLength));
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
/** The image as a `data:` URL,
|
|
36
|
+
/** The image as a `data:` URL, for the `src` of an `<img>` only: the bytes are the bank's, whatever their type says. */
|
|
33
37
|
toDataUrl(): string {
|
|
34
38
|
return `data:${this.mimeType};base64,${base64Encode(this.bytes)}`;
|
|
35
39
|
}
|
|
40
|
+
|
|
41
|
+
/** What Node.js's `util.inspect` and `console.log` show: type and size, not the image, which shows the order. */
|
|
42
|
+
[inspectCustom](): string {
|
|
43
|
+
return `TanImage(${this.mimeType}, ${counted(this.bytes.length, 'byte')})`;
|
|
44
|
+
}
|
|
36
45
|
}
|
package/src/tan/tan-models.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { copyDate } from '../util/dates.js';
|
|
2
|
+
import { inspectCustom } from '../util/inspect.js';
|
|
1
3
|
import { maskedId } from '../util/text.js';
|
|
2
4
|
|
|
3
5
|
/** Whether a TAN method needs the name of a TAN medium. */
|
|
@@ -82,7 +84,10 @@ export class TanMethod {
|
|
|
82
84
|
/** Whether approval happens in a separate app instead of entering a TAN. */
|
|
83
85
|
readonly decoupled: boolean;
|
|
84
86
|
|
|
85
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Most status requests the bank allows for a decoupled approval, 0 for any number. The client sends 60 where the bank
|
|
89
|
+
* states none, and polls for 10 minutes at most.
|
|
90
|
+
*/
|
|
86
91
|
readonly decoupledMaxPolls: number | undefined;
|
|
87
92
|
|
|
88
93
|
/** How long to wait before the first status request of a decoupled approval, in seconds. */
|
|
@@ -134,6 +139,11 @@ export class TanMethod {
|
|
|
134
139
|
toString(): string {
|
|
135
140
|
return `TanMethod(${this.securityFunction}, ${this.name})`;
|
|
136
141
|
}
|
|
142
|
+
|
|
143
|
+
/** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
|
|
144
|
+
[inspectCustom](): string {
|
|
145
|
+
return this.toString();
|
|
146
|
+
}
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/** State of a TAN medium as reported by the bank. */
|
|
@@ -219,6 +229,7 @@ export class TanMedium {
|
|
|
219
229
|
/** When the medium was activated. */
|
|
220
230
|
readonly activatedOn: Date | undefined;
|
|
221
231
|
|
|
232
|
+
/** Takes copies of the dates of `init`: each medium has its own, which no other one shares. */
|
|
222
233
|
constructor(init: TanMediumInit) {
|
|
223
234
|
this.mediumClass = init.mediumClass;
|
|
224
235
|
this.status = init.status;
|
|
@@ -228,10 +239,10 @@ export class TanMedium {
|
|
|
228
239
|
this.cardSequenceNumber = init.cardSequenceNumber;
|
|
229
240
|
this.mobileNumber = init.mobileNumber;
|
|
230
241
|
this.mobileNumberMasked = init.mobileNumberMasked;
|
|
231
|
-
this.validFrom = init.validFrom;
|
|
232
|
-
this.validUntil = init.validUntil;
|
|
233
|
-
this.lastUsed = init.lastUsed;
|
|
234
|
-
this.activatedOn = init.activatedOn;
|
|
242
|
+
this.validFrom = copyDate(init.validFrom);
|
|
243
|
+
this.validUntil = copyDate(init.validUntil);
|
|
244
|
+
this.lastUsed = copyDate(init.lastUsed);
|
|
245
|
+
this.activatedOn = copyDate(init.activatedOn);
|
|
235
246
|
}
|
|
236
247
|
|
|
237
248
|
/** Whether the medium is the one in use. */
|
|
@@ -239,9 +250,17 @@ export class TanMedium {
|
|
|
239
250
|
return this.status === 'active' || this.status === 'activeFollowUpCard';
|
|
240
251
|
}
|
|
241
252
|
|
|
242
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* The medium class, or the last digits of the card number, and the status: no name, which the user may have given the
|
|
255
|
+
* medium after themselves, such as `Max’s iPhone`, so that logs and crash reports collect neither.
|
|
256
|
+
*/
|
|
243
257
|
toString(): string {
|
|
244
|
-
const label = this.
|
|
258
|
+
const label = this.cardNumber === undefined ? this.mediumClass : maskedId(this.cardNumber);
|
|
245
259
|
return `TanMedium(${label}, ${this.status})`;
|
|
246
260
|
}
|
|
261
|
+
|
|
262
|
+
/** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
|
|
263
|
+
[inspectCustom](): string {
|
|
264
|
+
return this.toString();
|
|
265
|
+
}
|
|
247
266
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { invalidArgument } from './errors.js';
|
|
2
|
+
|
|
3
|
+
// Plain JavaScript passes arguments of types the declarations rule out, which the Dart package's types refuse. These
|
|
4
|
+
// checks refuse them as well, with messages that leave out the value.
|
|
5
|
+
|
|
6
|
+
/** `value`; throws `TypeError` unless it is a string. */
|
|
7
|
+
export function requireString(value: unknown, name: string): string {
|
|
8
|
+
if (typeof value !== 'string') throw invalidArgument('Must be a string', name);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `value`, `undefined` for `null`; throws `TypeError` unless it is a string or neither. */
|
|
13
|
+
export function optionalString(value: unknown, name: string): string | undefined {
|
|
14
|
+
return value === undefined || value === null ? undefined : requireString(value, name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `value`, `undefined` for `null`; throws `TypeError` unless it is an integer or neither. */
|
|
18
|
+
export function optionalInteger(value: unknown, name: string): number | undefined {
|
|
19
|
+
if (value === undefined || value === null) return undefined;
|
|
20
|
+
if (!Number.isInteger(value)) throw invalidArgument('Must be an integer', name);
|
|
21
|
+
return value as number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `value`, `undefined` for `null`; throws `TypeError` unless it is a boolean or neither. */
|
|
25
|
+
export function optionalBoolean(value: unknown, name: string): boolean | undefined {
|
|
26
|
+
if (value === undefined || value === null) return undefined;
|
|
27
|
+
if (typeof value !== 'boolean') throw invalidArgument('Must be a boolean', name);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `value`; throws `TypeError` unless it is a function. */
|
|
32
|
+
export function requireFunction<F>(value: F, name: string): F {
|
|
33
|
+
if (typeof value !== 'function') throw invalidArgument('Must be a function', name);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `value`, `undefined` for `null`; throws `TypeError` unless it is a function or neither. */
|
|
38
|
+
export function optionalFunction<F>(value: F | null | undefined, name: string): F | undefined {
|
|
39
|
+
return value === undefined || value === null ? undefined : requireFunction(value, name);
|
|
40
|
+
}
|
package/src/util/async.ts
CHANGED
|
@@ -37,17 +37,18 @@ export class Deferred<T> {
|
|
|
37
37
|
* A failing action does not stop the ones queued after it.
|
|
38
38
|
*/
|
|
39
39
|
export class SerialExecutor {
|
|
40
|
-
private last: Promise<
|
|
40
|
+
private last: Promise<void> = Promise.resolve();
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Runs `action` once the actions submitted before have finished; the result is that of `action`, which the executor
|
|
44
|
+
* does not keep.
|
|
45
|
+
*/
|
|
43
46
|
run<T>(action: () => Promise<T>): Promise<T> {
|
|
44
|
-
const result = this.last.then(action);
|
|
45
|
-
this.last = result.
|
|
47
|
+
const result = this.last.then(() => action());
|
|
48
|
+
this.last = result.then(
|
|
49
|
+
() => undefined,
|
|
50
|
+
() => undefined,
|
|
51
|
+
);
|
|
46
52
|
return result;
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
|
-
|
|
50
|
-
/** Waits for `promise` and ignores its result, so that neither a value nor an error goes unhandled. */
|
|
51
|
-
export function ignoreOutcome(promise: Promise<unknown>): void {
|
|
52
|
-
promise.catch(() => undefined);
|
|
53
|
-
}
|
package/src/util/bytes.ts
CHANGED
|
@@ -16,16 +16,9 @@ export function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
|
16
16
|
/** Collects bytes, like a growing buffer. */
|
|
17
17
|
export class BytesBuilder {
|
|
18
18
|
private readonly parts: Uint8Array[] = [];
|
|
19
|
-
private size = 0;
|
|
20
|
-
|
|
21
|
-
get length(): number {
|
|
22
|
-
return this.size;
|
|
23
|
-
}
|
|
24
19
|
|
|
25
20
|
add(bytes: Uint8Array | readonly number[]): void {
|
|
26
|
-
|
|
27
|
-
this.parts.push(part);
|
|
28
|
-
this.size += part.length;
|
|
21
|
+
this.parts.push(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes));
|
|
29
22
|
}
|
|
30
23
|
|
|
31
24
|
addByte(byte: number): void {
|
|
@@ -35,7 +28,6 @@ export class BytesBuilder {
|
|
|
35
28
|
takeBytes(): Uint8Array {
|
|
36
29
|
const result = concatBytes(this.parts);
|
|
37
30
|
this.parts.length = 0;
|
|
38
|
-
this.size = 0;
|
|
39
31
|
return result;
|
|
40
32
|
}
|
|
41
33
|
}
|
|
@@ -49,24 +41,32 @@ export function latin1Encode(text: string): Uint8Array {
|
|
|
49
41
|
|
|
50
42
|
/** The text of ISO 8859-1 `bytes`. */
|
|
51
43
|
export function latin1Decode(bytes: Uint8Array): string {
|
|
44
|
+
return fromCharCodes(bytes);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text of the UTF-16 code units `codes`. */
|
|
48
|
+
export function fromCharCodes(codes: Uint8Array | Uint16Array): string {
|
|
52
49
|
let text = '';
|
|
53
|
-
// In chunks, since a
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
// In chunks, since a large array exceeds the argument limit of some engines. `apply` takes the array as it is, where a
|
|
51
|
+
// spread would iterate over it.
|
|
52
|
+
for (let i = 0; i < codes.length; i += 0x8000) {
|
|
53
|
+
text += Reflect.apply(String.fromCharCode, undefined, codes.subarray(i, i + 0x8000)) as string;
|
|
56
54
|
}
|
|
57
55
|
return text;
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
const utf8Encoder = new TextEncoder();
|
|
59
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
60
|
+
const lenientUtf8Decoder = new TextDecoder('utf-8');
|
|
61
61
|
|
|
62
62
|
export function utf8Encode(text: string): Uint8Array {
|
|
63
63
|
return utf8Encoder.encode(text);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/** Decodes UTF-8; throws `FormatError` for malformed data unless `allowMalformed`. */
|
|
66
|
+
/** Decodes UTF-8, without a leading BOM; throws `FormatError` for malformed data unless `allowMalformed`. */
|
|
67
67
|
export function utf8Decode(bytes: Uint8Array, allowMalformed = false): string {
|
|
68
68
|
try {
|
|
69
|
-
return
|
|
69
|
+
return (allowMalformed ? lenientUtf8Decoder : utf8Decoder).decode(bytes);
|
|
70
70
|
} catch {
|
|
71
71
|
throw new FormatError('Invalid UTF-8');
|
|
72
72
|
}
|
|
@@ -127,9 +127,3 @@ export function base64Decode(text: string): Uint8Array {
|
|
|
127
127
|
}
|
|
128
128
|
return out;
|
|
129
129
|
}
|
|
130
|
-
|
|
131
|
-
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
132
|
-
if (a.length !== b.length) return false;
|
|
133
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
134
|
-
return true;
|
|
135
|
-
}
|
package/src/util/call-context.ts
CHANGED
|
@@ -7,8 +7,9 @@ interface AsyncStorage<T> {
|
|
|
7
7
|
type AsyncStorageConstructor = new <T>() => AsyncStorage<T>;
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* `AsyncLocalStorage` where the runtime has it (Node.js, Deno
|
|
11
|
-
* bundlers for the browser have nothing to
|
|
10
|
+
* `AsyncLocalStorage` where the runtime has it, found through `process.getBuiltinModule` (Node.js 22.3, Deno 2.1 and
|
|
11
|
+
* Bun 1.2.6 or later) rather than by importing `node:async_hooks`, so that bundlers for the browser have nothing to
|
|
12
|
+
* resolve.
|
|
12
13
|
*/
|
|
13
14
|
const AsyncStorageClass: AsyncStorageConstructor | undefined = (() => {
|
|
14
15
|
const runtime = globalThis as {
|
package/src/util/dates.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// A value is either a calendar date, a local `Date` at midnight of which only the day counts, or the exact moment of a
|
|
2
|
-
// date with a time of day, which the bank states in German time (see `germanTime`)
|
|
3
|
-
// date without a valid time of day is not a moment.
|
|
2
|
+
// date with a time of day, which the bank states in German time (see `germanTime`) unless it gives the UTC offset. The
|
|
3
|
+
// parsers keep the two apart: a date without a valid time of day is not a moment. A time of day runs from 00:00:00 to
|
|
4
|
+
// 23:59:59, and 24:00:00, which ISO 8601 and XML Schema allow for the end of a day, is midnight of the next.
|
|
4
5
|
|
|
5
6
|
const hourMs = 3_600_000;
|
|
6
7
|
|
|
@@ -30,7 +31,11 @@ export function formatIsoDate(d: Date): string {
|
|
|
30
31
|
const fintsDate = /^(\d{4})(\d{2})(\d{2})$/;
|
|
31
32
|
const fintsTime = /^(\d{2})(\d{2})(\d{2})/;
|
|
32
33
|
const isoDate = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
33
|
-
|
|
34
|
+
// The time of an XML Schema `dateTime`, whose UTC offset lies between -14:00 and +14:00.
|
|
35
|
+
const isoTime = /^(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))?$/;
|
|
36
|
+
|
|
37
|
+
/** A date and time of day as a clock shows them, the month counted from 1. */
|
|
38
|
+
type Clock = [year: number, month: number, day: number, hour: number, minute: number, second: number];
|
|
34
39
|
|
|
35
40
|
/** Parses a FinTS 3.0 date `YYYYMMDD` as a calendar date; `undefined` if it is missing or invalid. */
|
|
36
41
|
export function parseFintsDate(date: string | undefined): Date | undefined {
|
|
@@ -42,34 +47,64 @@ export function parseFintsDate(date: string | undefined): Date | undefined {
|
|
|
42
47
|
* invalid.
|
|
43
48
|
*/
|
|
44
49
|
export function parseFintsDateTime(date: string | undefined, time: string | undefined): Date | undefined {
|
|
45
|
-
|
|
50
|
+
const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time));
|
|
51
|
+
return clock && germanTime(...clock);
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/**
|
|
49
|
-
* Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring
|
|
50
|
-
* if it is missing or invalid.
|
|
55
|
+
* Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring what follows it, such as a time zone; `undefined`
|
|
56
|
+
* if it is missing or invalid. A `T` right after the date makes it a date and time, which must be one `parseIsoMoment`
|
|
57
|
+
* reads, and the date the day in Germany at that moment: for a time in UTC late in the evening, the next day.
|
|
51
58
|
*/
|
|
52
59
|
export function parseIsoDate(date: string | undefined): Date | undefined {
|
|
53
|
-
return parseDate(isoDate, date);
|
|
60
|
+
if (date?.charAt(10) !== 'T') return parseDate(isoDate, date);
|
|
61
|
+
const moment = parseIsoMoment(date);
|
|
62
|
+
return moment && germanDay(moment);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parses an ISO 8601 date and time as XML Schema writes it, `YYYY-MM-DDThh:mm:ss` with optional fractional seconds and
|
|
67
|
+
* UTC offset (`Z` or `±hh:mm` up to ±14:00), as the moment it names, to the second: in German time where it states no
|
|
68
|
+
* offset. `undefined` if it is invalid.
|
|
69
|
+
*/
|
|
70
|
+
function parseIsoMoment(dateTime: string): Date | undefined {
|
|
71
|
+
const t = isoTime.exec(dateTime.slice(11));
|
|
72
|
+
const clock = clockOn(parseDate(isoDate, dateTime), t);
|
|
73
|
+
if (clock === undefined) return undefined;
|
|
74
|
+
const zone = t?.[5];
|
|
75
|
+
return zone === undefined ? germanTime(...clock) : new Date(utc(...clock) - offsetMs(zone));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The UTC offset `Z` or `±hh:mm` in milliseconds. */
|
|
79
|
+
function offsetMs(zone: string): number {
|
|
80
|
+
if (zone === 'Z') return 0;
|
|
81
|
+
const ms = (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4))) * 60_000;
|
|
82
|
+
return zone.startsWith('-') ? -ms : ms;
|
|
54
83
|
}
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return moment(parseDate(isoDate, date), isoTime, time);
|
|
85
|
+
function match(pattern: RegExp, text: string | undefined): RegExpExecArray | null {
|
|
86
|
+
return text === undefined ? null : pattern.exec(text);
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
function parseDate(pattern: RegExp, date: string | undefined): Date | undefined {
|
|
62
|
-
const d =
|
|
90
|
+
const d = match(pattern, date);
|
|
63
91
|
return d ? validDate(Number(d[1]), Number(d[2]), Number(d[3])) : undefined;
|
|
64
92
|
}
|
|
65
93
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The time of day `t` on `day`, where `t` matched hour, minute and second, and maybe a fraction of a second after them;
|
|
96
|
+
* `undefined` if either is missing or the time is invalid.
|
|
97
|
+
*/
|
|
98
|
+
function clockOn(day: Date | undefined, t: RegExpExecArray | null): Clock | undefined {
|
|
69
99
|
if (day === undefined || !t) return undefined;
|
|
70
100
|
const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
|
|
101
|
+
// The fraction counts only here: 24:00:00.5 lies past the end of the day.
|
|
102
|
+
if (hour === 24 && minute === 0 && second === 0 && !/[1-9]/.test(t[4] ?? '')) {
|
|
103
|
+
const next = localDate(day.getFullYear(), day.getMonth() + 1, day.getDate() + 1);
|
|
104
|
+
return [next.getFullYear(), next.getMonth() + 1, next.getDate(), 0, 0, 0];
|
|
105
|
+
}
|
|
71
106
|
if (hour > 23 || minute > 59 || second > 59) return undefined;
|
|
72
|
-
return
|
|
107
|
+
return [day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second];
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
/**
|
|
@@ -78,12 +113,8 @@ function moment(day: Date | undefined, timePattern: RegExp, time: string | undef
|
|
|
78
113
|
* fewer than six digits lost its leading zeros as a JSON number.
|
|
79
114
|
*/
|
|
80
115
|
export function parseUtcPlusOneDateTime(date: string | undefined, time: string | undefined): Date | undefined {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (day === undefined || !t) return undefined;
|
|
84
|
-
const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
|
|
85
|
-
if (hour > 23 || minute > 59 || second > 59) return undefined;
|
|
86
|
-
return new Date(utc(day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second) - hourMs);
|
|
116
|
+
const clock = clockOn(parseDate(fintsDate, date), match(fintsTime, time?.padStart(6, '0')));
|
|
117
|
+
return clock && new Date(utc(...clock) - hourMs);
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
/**
|
|
@@ -93,21 +124,14 @@ export function parseUtcPlusOneDateTime(date: string | undefined, time: string |
|
|
|
93
124
|
* Sunday of October (the EU rule). The hour the clocks show twice in October counts as CEST; a time in the hour that is
|
|
94
125
|
* skipped in March counts as CET.
|
|
95
126
|
*/
|
|
96
|
-
|
|
97
|
-
year: number,
|
|
98
|
-
month: number,
|
|
99
|
-
day: number,
|
|
100
|
-
hour: number,
|
|
101
|
-
minute: number,
|
|
102
|
-
second: number,
|
|
103
|
-
): Date {
|
|
127
|
+
function germanTime(year: number, month: number, day: number, hour: number, minute: number, second: number): Date {
|
|
104
128
|
const clock = utc(year, month, day, hour, minute, second);
|
|
105
129
|
const summer = clock - 2 * hourMs;
|
|
106
130
|
return new Date(isSummerTime(summer) ? summer : clock - hourMs);
|
|
107
131
|
}
|
|
108
132
|
|
|
109
133
|
/** The calendar day a German clock shows at `moment`, as a local `Date` at midnight like every calendar date. */
|
|
110
|
-
|
|
134
|
+
function germanDay(moment: Date): Date {
|
|
111
135
|
const utcMs = moment.getTime();
|
|
112
136
|
const clock = new Date(utcMs + (isSummerTime(utcMs) ? 2 : 1) * hourMs);
|
|
113
137
|
return localDate(clock.getUTCFullYear(), clock.getUTCMonth() + 1, clock.getUTCDate());
|
|
@@ -134,7 +158,7 @@ function utc(year: number, month: number, day: number, hour: number, minute: num
|
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
/** Local midnight of the day; `month` counts from 1, and years below 100 are not moved to 19xx. */
|
|
137
|
-
|
|
161
|
+
function localDate(year: number, month: number, day: number): Date {
|
|
138
162
|
const d = new Date(0);
|
|
139
163
|
d.setFullYear(year, month - 1, day);
|
|
140
164
|
d.setHours(0, 0, 0, 0);
|
|
@@ -142,7 +166,7 @@ export function localDate(year: number, month: number, day: number): Date {
|
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
/** The date, or `undefined` if `month` or `day` is out of range; `Date` would roll it over into another date. */
|
|
145
|
-
|
|
169
|
+
function validDate(year: number, month: number, day: number): Date | undefined {
|
|
146
170
|
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return undefined;
|
|
147
171
|
return localDate(year, month, day);
|
|
148
172
|
}
|
|
@@ -170,11 +194,18 @@ export function parseSwiftDate(yymmdd: string): Date | undefined {
|
|
|
170
194
|
}
|
|
171
195
|
|
|
172
196
|
/** Number of days the `month` (1 to 12) of `year` has. */
|
|
173
|
-
|
|
197
|
+
function daysInMonth(year: number, month: number): number {
|
|
174
198
|
if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
|
|
175
199
|
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
176
200
|
}
|
|
177
201
|
|
|
202
|
+
/** A copy of `d`, so that a model keeps dates of its own: a `Date` can be changed in place. */
|
|
203
|
+
export function copyDate(d: Date): Date;
|
|
204
|
+
export function copyDate(d: Date | undefined): Date | undefined;
|
|
205
|
+
export function copyDate(d: Date | undefined): Date | undefined {
|
|
206
|
+
return d && new Date(d.getTime());
|
|
207
|
+
}
|
|
208
|
+
|
|
178
209
|
/** The calendar day `moment` falls on in local time, as a local `Date` at midnight. */
|
|
179
210
|
export function calendarDay(moment: Date): Date {
|
|
180
211
|
return localDate(moment.getFullYear(), moment.getMonth() + 1, moment.getDate());
|
package/src/util/errors.ts
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Data that do not have the expected format, such as a malformed MT940 line or amount.
|
|
3
|
+
*
|
|
4
|
+
* The message leaves out `source`, which may hold account data, such as amounts, names and IBANs, so that logs and
|
|
5
|
+
* crash reports do not collect them. For the same reason `source` is a getter, not a field: `JSON.stringify` and
|
|
6
|
+
* Node.js's report of an uncaught error show an error's fields as they are.
|
|
7
|
+
*/
|
|
2
8
|
export class FormatError extends Error {
|
|
3
|
-
|
|
4
|
-
|
|
9
|
+
override name = 'FormatError';
|
|
10
|
+
|
|
11
|
+
readonly #source: string | undefined;
|
|
5
12
|
|
|
6
13
|
constructor(message: string, source?: string) {
|
|
7
|
-
super(
|
|
8
|
-
this
|
|
9
|
-
this.source = source;
|
|
14
|
+
super(message);
|
|
15
|
+
this.#source = source;
|
|
10
16
|
}
|
|
17
|
+
|
|
18
|
+
/** The part of the data that is malformed, where the parser can tell it. */
|
|
19
|
+
get source(): string | undefined {
|
|
20
|
+
return this.#source;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Throws a `FormatError`, where an expression is expected, such as `value ?? fail('…')`. */
|
|
25
|
+
export function fail(message: string, source?: string): never {
|
|
26
|
+
throw new FormatError(message, source);
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
/** An argument the function cannot work with, such as an empty PIN. The message never contains a PIN. */
|