@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/client.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { Account } from './core/account.js';
|
|
2
|
+
import type { Balance } from './core/balance.js';
|
|
3
|
+
import type { BankInfo } from './core/bank-info.js';
|
|
4
|
+
import type { FinTsClient, FinTsClientOptions, TransactionQuery } from './core/client.js';
|
|
5
|
+
import { FinTsConfig, requirePin, type FinTsConfigInit } from './core/config.js';
|
|
6
|
+
import { FinTsStateError, FinTsUnsupportedError, type FinTsLoginRefusedError } from './core/errors.js';
|
|
7
|
+
import type { Transaction, TransactionFormat } from './core/transaction.js';
|
|
8
|
+
import type { FinTsTransport } from './core/transport.js';
|
|
9
|
+
import { DialogRunner } from './session/dialog-runner.js';
|
|
10
|
+
import { LoginGuard } from './session/login-guard.js';
|
|
11
|
+
import { SepaAccounts } from './session/sepa-accounts.js';
|
|
12
|
+
import { SessionSetup } from './session/session-setup.js';
|
|
13
|
+
import { restoreSession, saveSession, type SessionState } from './session/session-state.js';
|
|
14
|
+
import { TanSelection } from './session/tan-selection.js';
|
|
15
|
+
import { UserPrompts } from './session/user-prompts.js';
|
|
16
|
+
import { TanMedium, type TanMethod } from './tan/tan-models.js';
|
|
17
|
+
import { optionalString, requireString } from './util/arguments.js';
|
|
18
|
+
import { SerialExecutor } from './util/async.js';
|
|
19
|
+
import { CallContext } from './util/call-context.js';
|
|
20
|
+
import { calendarDay, notAfterToday } from './util/dates.js';
|
|
21
|
+
import { invalidArgument } from './util/errors.js';
|
|
22
|
+
import { ParameterStore } from './v3/parameter-store.js';
|
|
23
|
+
import { FinTs3Protocol } from './v3/protocol.js';
|
|
24
|
+
import { balanceQuery, tanMediaQuery, transactionsQuery } from './v3/queries.js';
|
|
25
|
+
|
|
26
|
+
/** What `FinTs3Client` takes. */
|
|
27
|
+
export interface FinTs3ClientInit {
|
|
28
|
+
/** Where and as whom to log in: a `FinTsConfig`, or what its constructor takes. */
|
|
29
|
+
config: FinTsConfig | FinTsConfigInit;
|
|
30
|
+
/**
|
|
31
|
+
* The user's PIN. The client keeps it in memory only, never logs it, and forgets it once it is closed or the bank has
|
|
32
|
+
* refused the login. It is out of reach of `JSON.stringify` and Node.js's `util.inspect` of the client, but not of a
|
|
33
|
+
* debugger or the browser's DevTools: never log the client. A client whose `options.state` holds a refused login
|
|
34
|
+
* does not send it (`FinTsClient.loginRefusal`).
|
|
35
|
+
*/
|
|
36
|
+
pin: string;
|
|
37
|
+
/** How the client reaches the user and the bank. */
|
|
38
|
+
options: FinTsClientOptions;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A token of the client call running now, so that a call from inside its callbacks is recognized. */
|
|
42
|
+
const runningCalls = new CallContext<object>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `transport`, `undefined` for `null`; throws `TypeError` unless it is neither or has the functions of a
|
|
46
|
+
* `FinTsTransport`.
|
|
47
|
+
*/
|
|
48
|
+
function optionalTransport(transport: unknown): FinTsTransport | undefined {
|
|
49
|
+
if (transport === undefined || transport === null) return undefined;
|
|
50
|
+
const candidate = transport as Partial<FinTsTransport>;
|
|
51
|
+
if (typeof candidate.post !== 'function' || typeof candidate.close !== 'function') {
|
|
52
|
+
throw invalidArgument('Must have the functions post and close', 'transport');
|
|
53
|
+
}
|
|
54
|
+
return transport as FinTsTransport;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Throws `TypeError` for anything but an `Account`, such as one written to JSON and read back. */
|
|
58
|
+
function requireAccount(account: unknown): void {
|
|
59
|
+
if (!(account instanceof Account)) throw invalidArgument('Must be an Account', 'account');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The calendar day of `bound`, the `name` bound of a `TransactionQuery`: the bank takes dates without time of day.
|
|
64
|
+
* Throws `TypeError` for anything but a valid `Date`, such as an Invalid Date, which would go out as `0NaNNaNNaN`.
|
|
65
|
+
*/
|
|
66
|
+
function requestedDay(bound: unknown, name: string): Date | undefined {
|
|
67
|
+
if (bound === undefined) return undefined;
|
|
68
|
+
if (!(bound instanceof Date) || Number.isNaN(bound.getTime())) throw invalidArgument('Not a valid Date', name);
|
|
69
|
+
return calendarDay(bound);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The format a `TransactionQuery` asks for with `format`: `auto` where it is `undefined`. Throws `TypeError` for anything
|
|
74
|
+
* but a `TransactionFormat`, such as `'CAMT'` or `null`.
|
|
75
|
+
*/
|
|
76
|
+
function requestedFormat(format: unknown): TransactionFormat {
|
|
77
|
+
if (format === undefined) return 'auto';
|
|
78
|
+
if (format === 'auto' || format === 'mt940' || format === 'camt') return format;
|
|
79
|
+
throw invalidArgument("Must be 'auto', 'mt940' or 'camt'", 'format');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** FinTS 3.0 PIN/TAN client, the version German banks use. */
|
|
83
|
+
export class FinTs3Client implements FinTsClient {
|
|
84
|
+
// A call such as `getBalance` runs through these parts:
|
|
85
|
+
// 1. `DialogRunner.open` reuses the session's dialog or logs in, through `SessionSetup.remedied`, which logs in once
|
|
86
|
+
// more for each refusal it can take care of.
|
|
87
|
+
// 2. `SessionSetup.prepare` first settles what is open: the bank parameter data, the synchronization, and through
|
|
88
|
+
// `TanSelection` the TAN method and medium.
|
|
89
|
+
// 3. `SignedDialog.login` sends the login and completes the strong authentication the bank asks for, a TAN or an app
|
|
90
|
+
// approval (`AppApprovalWait`).
|
|
91
|
+
// 4. `DialogRunner.query` sends the order in that dialog, completes its approval the same way, and fetches all pages.
|
|
92
|
+
//
|
|
93
|
+
// `FinTs3Protocol` builds, signs and posts the messages of each `FinTs3Dialog`, the queries build the orders and read
|
|
94
|
+
// the answers, and the `ParameterStore` keeps the bank's parameter data and IDs. `SepaAccounts` completes IBAN and
|
|
95
|
+
// BIC, and `UserPrompts` is everything the app is told or asked. The `LoginGuard` holds the PIN: the protocol takes it
|
|
96
|
+
// from the guard for every message it signs, and `SignedDialog` has the guard judge every answer, so that the PIN does
|
|
97
|
+
// not go out after a refusal.
|
|
98
|
+
//
|
|
99
|
+
// The fields are ECMAScript private, so that neither `util.inspect` nor `JSON.stringify` of a client reaches the PIN
|
|
100
|
+
// or the account data behind them.
|
|
101
|
+
|
|
102
|
+
readonly #prompts: UserPrompts;
|
|
103
|
+
readonly #guard: LoginGuard;
|
|
104
|
+
readonly #parameters: ParameterStore;
|
|
105
|
+
readonly #protocol: FinTs3Protocol;
|
|
106
|
+
readonly #sessionState: SessionState;
|
|
107
|
+
readonly #tans: TanSelection;
|
|
108
|
+
readonly #setup: SessionSetup;
|
|
109
|
+
readonly #dialogs: DialogRunner;
|
|
110
|
+
readonly #sepa: SepaAccounts;
|
|
111
|
+
readonly #queue = new SerialExecutor();
|
|
112
|
+
#closed: Promise<void> | undefined;
|
|
113
|
+
|
|
114
|
+
/** The call running now, which a call from inside its callbacks would wait for. */
|
|
115
|
+
#runningCall: object | undefined;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A client with the session restored from `FinTsClientOptions.state` where it was saved for this user and bank.
|
|
119
|
+
*
|
|
120
|
+
* Throws `TypeError` if the configuration is invalid, as the constructor of `FinTsConfig` tells, or the PIN is empty,
|
|
121
|
+
* begins or ends with a blank, or has characters FinTS 3.0 cannot send: it uses ISO 8859-1, so `€` and most
|
|
122
|
+
* non-Latin letters are not possible. Throws `TypeError` as well for an option of another type than
|
|
123
|
+
* `FinTsClientOptions` names, as `FinTsClientOptions` tells.
|
|
124
|
+
*/
|
|
125
|
+
constructor(init: FinTs3ClientInit) {
|
|
126
|
+
const config = init.config instanceof FinTsConfig ? init.config : new FinTsConfig(init.config);
|
|
127
|
+
const { state, transport } = init.options;
|
|
128
|
+
this.#prompts = new UserPrompts(init.options);
|
|
129
|
+
this.#guard = new LoginGuard(requirePin(init.pin));
|
|
130
|
+
this.#parameters = new ParameterStore(config);
|
|
131
|
+
this.#protocol = new FinTs3Protocol(
|
|
132
|
+
this.#parameters,
|
|
133
|
+
this.#guard,
|
|
134
|
+
optionalTransport(transport),
|
|
135
|
+
this.#prompts.logger,
|
|
136
|
+
);
|
|
137
|
+
this.#sessionState = restoreSession(optionalString(state, 'state'), this.#parameters, this.#guard);
|
|
138
|
+
this.#tans = new TanSelection(this.#protocol, this.#sessionState, this.#prompts);
|
|
139
|
+
const session = {
|
|
140
|
+
parameters: this.#parameters,
|
|
141
|
+
state: this.#sessionState,
|
|
142
|
+
prompts: this.#prompts,
|
|
143
|
+
guard: this.#guard,
|
|
144
|
+
};
|
|
145
|
+
this.#setup = new SessionSetup(session, this.#protocol, this.#tans);
|
|
146
|
+
this.#dialogs = new DialogRunner(this.#setup);
|
|
147
|
+
this.#sepa = new SepaAccounts(this.#parameters, this.#sessionState, this.#dialogs);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get state(): string {
|
|
151
|
+
return saveSession(this.#parameters, this.#guard, this.#sessionState);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get loginRefusal(): FinTsLoginRefusedError | undefined {
|
|
155
|
+
return this.#guard.refusal;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
get selectedTanMethod(): string | undefined {
|
|
159
|
+
return this.#sessionState.tanMethod;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get selectedTanMedium(): string | undefined {
|
|
163
|
+
return this.#sessionState.tanMedium;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async selectTanMethod(securityFunction: string, tanMedium?: string): Promise<void> {
|
|
167
|
+
// Checked before the dialog ends; an async method, so that every argument error is a rejection.
|
|
168
|
+
const method = requireString(securityFunction, 'securityFunction');
|
|
169
|
+
const medium = optionalString(tanMedium, 'tanMedium');
|
|
170
|
+
return this.#serialized(async () => {
|
|
171
|
+
await this.#dialogs.end();
|
|
172
|
+
this.#tans.select(method, medium);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
clearLoginRefusal(pin: string): Promise<void> {
|
|
177
|
+
return this.#serialized(async () => {
|
|
178
|
+
const sendable = requirePin(pin);
|
|
179
|
+
// The dialog ends with the PIN it was opened with.
|
|
180
|
+
await this.#dialogs.end();
|
|
181
|
+
this.#guard.replacePin(sendable);
|
|
182
|
+
this.#tans.forgetMedium();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getBankInfo(): Promise<BankInfo> {
|
|
187
|
+
return this.#serialized(() => this.#setup.remedied(() => this.#setup.bankInfo()));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getTanMethods(): Promise<TanMethod[]> {
|
|
191
|
+
return this.#serialized(() => this.#setup.remedied(() => this.#setup.methods()));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getTanMedia(): Promise<TanMedium[]> {
|
|
195
|
+
return this.#serialized(async () => {
|
|
196
|
+
// The session keeps the list of a lookup that found no medium; the app gets media of its own.
|
|
197
|
+
const lookedUp = (await this.#setup.remedied(() => this.#setup.prepare())) ?? this.#tans.lookedUpMedia;
|
|
198
|
+
return (
|
|
199
|
+
lookedUp?.map((m) => new TanMedium(m)) ??
|
|
200
|
+
(await this.#dialogs.query(() => {
|
|
201
|
+
const query = tanMediaQuery(this.#parameters);
|
|
202
|
+
if (query === undefined) throw new FinTsUnsupportedError('The bank does not list TAN media');
|
|
203
|
+
return query;
|
|
204
|
+
}))
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getAccounts(): Promise<Account[]> {
|
|
210
|
+
return this.#serialized(() => this.#sepa.completedAccounts());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async getBalance(account: Account): Promise<Balance> {
|
|
214
|
+
// Checked before the call waits for others; an async method, so that every argument error is a rejection.
|
|
215
|
+
requireAccount(account);
|
|
216
|
+
return this.#serialized(async () => {
|
|
217
|
+
const completed = await this.#sepa.complete(account);
|
|
218
|
+
// The dialog start delivers the user parameter data, which may carry the complete balance: then the client must
|
|
219
|
+
// not ask for it separately (Formals E.3.1.1, F.3.2).
|
|
220
|
+
await this.#dialogs.open();
|
|
221
|
+
return (
|
|
222
|
+
this.#parameters.userParameters?.completeBalance(completed) ??
|
|
223
|
+
(await this.#dialogs.query(() => balanceQuery(this.#parameters, completed)))
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getTransactions(account: Account, query: TransactionQuery = {}): Promise<Transaction[]> {
|
|
229
|
+
// Checked before the call waits for others; an async method, so that every argument error is a rejection.
|
|
230
|
+
requireAccount(account);
|
|
231
|
+
const firstDay = requestedDay(query.from, 'from');
|
|
232
|
+
const lastDay = requestedDay(query.to, 'to');
|
|
233
|
+
if (firstDay !== undefined && lastDay !== undefined && firstDay.getTime() > lastDay.getTime()) {
|
|
234
|
+
throw invalidArgument('`from` lies on a day after `to`', 'from');
|
|
235
|
+
}
|
|
236
|
+
const format = requestedFormat(query.format);
|
|
237
|
+
return this.#serialized(async () => {
|
|
238
|
+
const completed = await this.#sepa.complete(account);
|
|
239
|
+
const transactions = await this.#dialogs.query(() =>
|
|
240
|
+
transactionsQuery(this.#parameters, completed, format, notAfterToday(firstDay), notAfterToday(lastDay)),
|
|
241
|
+
);
|
|
242
|
+
// A stable sort, so that transactions of the same day keep the bank's order.
|
|
243
|
+
return transactions.sort(
|
|
244
|
+
(a, b) => (a.bookingDate ?? a.valueDate).getTime() - (b.bookingDate ?? b.valueDate).getTime(),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
close(): Promise<void> {
|
|
250
|
+
this.#closed ??= this.#closeNow();
|
|
251
|
+
return this.#closed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#closeNow(): Promise<void> {
|
|
255
|
+
this.#prompts.close();
|
|
256
|
+
this.#protocol.beginClose();
|
|
257
|
+
return this.#queue.run(() =>
|
|
258
|
+
this.#prompts.reportingCallbackErrors(async () => {
|
|
259
|
+
try {
|
|
260
|
+
await this.#dialogs.end();
|
|
261
|
+
} finally {
|
|
262
|
+
this.#guard.close();
|
|
263
|
+
this.#protocol.close();
|
|
264
|
+
}
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#serialized<T>(action: () => Promise<T>): Promise<T> {
|
|
270
|
+
const caller = runningCalls.current;
|
|
271
|
+
if (caller !== undefined && caller === this.#runningCall) {
|
|
272
|
+
// The call would wait behind the running one, which waits for the callback: neither would ever end.
|
|
273
|
+
return Promise.reject(new FinTsStateError('The FinTS client was called from inside one of its callbacks'));
|
|
274
|
+
}
|
|
275
|
+
return this.#queue.run(async () => {
|
|
276
|
+
const call = {};
|
|
277
|
+
this.#runningCall = call;
|
|
278
|
+
try {
|
|
279
|
+
return await runningCalls.run(call, () =>
|
|
280
|
+
this.#guard.runCall(async () => {
|
|
281
|
+
this.#prompts.checkOpen();
|
|
282
|
+
const result = await this.#prompts.reportingCallbackErrors(action);
|
|
283
|
+
// Also where the last answer arrived after `close`: a closed client hands out no account data.
|
|
284
|
+
this.#prompts.checkOpen();
|
|
285
|
+
return result;
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
this.#runningCall = undefined;
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/core/account.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { optionalBoolean, optionalInteger, optionalString, requireString } from '../util/arguments.js';
|
|
2
|
+
import { copyDate } from '../util/dates.js';
|
|
3
|
+
import { invalidArgument } from '../util/errors.js';
|
|
1
4
|
import { ImmutableMap } from '../util/immutable-map.js';
|
|
2
|
-
import {
|
|
5
|
+
import { inspectCustom } from '../util/inspect.js';
|
|
6
|
+
import { isString } from '../util/json.js';
|
|
7
|
+
import { maskedId, nonBlank } from '../util/text.js';
|
|
3
8
|
import { defaultCountryCode } from './config.js';
|
|
4
9
|
|
|
5
10
|
/** Account kind derived from the FinTS account type code. */
|
|
@@ -40,7 +45,7 @@ const categoriesByTens: readonly AccountCategory[] = [
|
|
|
40
45
|
];
|
|
41
46
|
|
|
42
47
|
/** The category of the FinTS account type `code`, which is `undefined` if the bank states none. */
|
|
43
|
-
|
|
48
|
+
function accountCategoryOf(code: number | undefined): AccountCategory {
|
|
44
49
|
if (code === undefined || code < 1) return 'unknown';
|
|
45
50
|
return categoriesByTens[Math.floor(code / 10)] ?? 'other';
|
|
46
51
|
}
|
|
@@ -63,7 +68,15 @@ export interface AccountInit {
|
|
|
63
68
|
dataStatus?: AccountDataStatus | undefined;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A bank account as reported by the user parameter data or the SEPA account query.
|
|
73
|
+
*
|
|
74
|
+
* An account the app builds itself, such as from a stored IBAN, stands for the one of `getAccounts` it `matches`:
|
|
75
|
+
* queries take the details the bank states of that one, and the transactions the bank permits for it. One the bank
|
|
76
|
+
* names nowhere goes out with its account number, sub-account, IBAN, BIC, bank and country code and currency only, and
|
|
77
|
+
* is refused with `FinTsUnsupportedError` where the bank's lists of permitted transactions are complete. Queries never
|
|
78
|
+
* take the permitted transactions from the app's account.
|
|
79
|
+
*/
|
|
67
80
|
export class Account {
|
|
68
81
|
/** National account number. */
|
|
69
82
|
readonly accountNumber: string | undefined;
|
|
@@ -77,10 +90,13 @@ export class Account {
|
|
|
77
90
|
/** Numeric country code of the bank, `280` for Germany. */
|
|
78
91
|
readonly countryCode: string;
|
|
79
92
|
|
|
80
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* International bank account number, in its electronic format: without blanks and in capitals, however
|
|
95
|
+
* `AccountInit.iban` wrote it, such as in groups of four as printed.
|
|
96
|
+
*/
|
|
81
97
|
readonly iban: string | undefined;
|
|
82
98
|
|
|
83
|
-
/** Bank identifier code of the bank holding the account. */
|
|
99
|
+
/** Bank identifier code of the bank holding the account, without blanks and in capitals. */
|
|
84
100
|
readonly bic: string | undefined;
|
|
85
101
|
|
|
86
102
|
/** Customer ID the account is assigned to; may differ from the login name. */
|
|
@@ -101,8 +117,8 @@ export class Account {
|
|
|
101
117
|
/**
|
|
102
118
|
* The transactions the bank lists as permitted for this account, by their segment IDs, such as `HKSAL`.
|
|
103
119
|
*
|
|
104
|
-
* The client applies them itself: `getBalance` and `getTransactions`
|
|
105
|
-
*
|
|
120
|
+
* The client applies them itself: `getBalance` and `getTransactions` do not send a query they rule out, and fail with
|
|
121
|
+
* `FinTsUnsupportedError`.
|
|
106
122
|
*/
|
|
107
123
|
readonly allowedTransactions: readonly string[];
|
|
108
124
|
|
|
@@ -115,21 +131,28 @@ export class Account {
|
|
|
115
131
|
/** What the bank tells about the data it holds for the account, where it does. */
|
|
116
132
|
readonly dataStatus: AccountDataStatus | undefined;
|
|
117
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Takes copies of the permitted transactions and the data status of `init`, which no other account shares.
|
|
136
|
+
*
|
|
137
|
+
* Throws `TypeError` for a detail of another type than `AccountInit` names, such as a number as `bankCode` or
|
|
138
|
+
* `accountNumber`, or a missing `bankCode`; `null` counts as a detail not given. The message leaves out the value.
|
|
139
|
+
*/
|
|
118
140
|
constructor(init: AccountInit) {
|
|
119
|
-
this.accountNumber = init.accountNumber;
|
|
120
|
-
this.subAccount = init.subAccount;
|
|
121
|
-
this.bankCode = init.bankCode;
|
|
122
|
-
this.countryCode = init.countryCode ?? defaultCountryCode;
|
|
123
|
-
this.iban = init.iban;
|
|
124
|
-
this.bic = init.bic;
|
|
125
|
-
this.customerId = init.customerId;
|
|
126
|
-
this.currency = init.currency;
|
|
127
|
-
this.ownerName = init.ownerName;
|
|
128
|
-
this.productName = init.productName;
|
|
129
|
-
this.typeCode = init.typeCode;
|
|
130
|
-
this.allowedTransactions = Object.freeze(
|
|
131
|
-
this.allowedTransactionsComplete =
|
|
132
|
-
|
|
141
|
+
this.accountNumber = optionalString(init.accountNumber, 'accountNumber');
|
|
142
|
+
this.subAccount = optionalString(init.subAccount, 'subAccount');
|
|
143
|
+
this.bankCode = requireString(init.bankCode, 'bankCode');
|
|
144
|
+
this.countryCode = optionalString(init.countryCode, 'countryCode') ?? defaultCountryCode;
|
|
145
|
+
this.iban = electronicFormat(optionalString(init.iban, 'iban'));
|
|
146
|
+
this.bic = electronicFormat(optionalString(init.bic, 'bic'));
|
|
147
|
+
this.customerId = optionalString(init.customerId, 'customerId');
|
|
148
|
+
this.currency = optionalString(init.currency, 'currency');
|
|
149
|
+
this.ownerName = optionalString(init.ownerName, 'ownerName');
|
|
150
|
+
this.productName = optionalString(init.productName, 'productName');
|
|
151
|
+
this.typeCode = optionalInteger(init.typeCode, 'typeCode');
|
|
152
|
+
this.allowedTransactions = Object.freeze(segmentIds(init.allowedTransactions));
|
|
153
|
+
this.allowedTransactionsComplete =
|
|
154
|
+
optionalBoolean(init.allowedTransactionsComplete, 'allowedTransactionsComplete') ?? false;
|
|
155
|
+
this.dataStatus = copiedDataStatus(init.dataStatus);
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
/** The kind of account, derived from `typeCode`. */
|
|
@@ -146,10 +169,10 @@ export class Account {
|
|
|
146
169
|
*/
|
|
147
170
|
matches(other: Account): boolean {
|
|
148
171
|
if (this.iban !== undefined && other.iban !== undefined) return this.iban === other.iban;
|
|
149
|
-
const number = this
|
|
172
|
+
const number = nationalNumber(this);
|
|
150
173
|
return (
|
|
151
174
|
number !== undefined &&
|
|
152
|
-
number === other
|
|
175
|
+
number === nationalNumber(other) &&
|
|
153
176
|
(this.subAccount ?? '') === (other.subAccount ?? '') &&
|
|
154
177
|
sameOrUnstated(this.bankCode, other.bankCode) &&
|
|
155
178
|
sameOrUnstated(this.countryCode, other.countryCode)
|
|
@@ -188,12 +211,109 @@ export class Account {
|
|
|
188
211
|
];
|
|
189
212
|
return `Account(${parts.join(', ')})`;
|
|
190
213
|
}
|
|
214
|
+
|
|
215
|
+
/** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
|
|
216
|
+
[inspectCustom](): string {
|
|
217
|
+
return this.toString();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* An IBAN or BIC in the form the bank sends and compares, without blanks, U+0085 (NEL) among them, and in capitals;
|
|
223
|
+
* `undefined` for blanks only.
|
|
224
|
+
*/
|
|
225
|
+
function electronicFormat(code: string | undefined): string | undefined {
|
|
226
|
+
return nonBlank(code?.replace(/[\s\u0085]/g, '').toUpperCase());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A copy of `ids`, none for `undefined` or `null`; throws `TypeError` unless they are an array of strings. */
|
|
230
|
+
function segmentIds(ids: unknown): string[] {
|
|
231
|
+
if (ids === undefined || ids === null) return [];
|
|
232
|
+
if (!Array.isArray(ids) || !ids.every(isString)) {
|
|
233
|
+
throw invalidArgument('Must be an array of strings', 'allowedTransactions');
|
|
234
|
+
}
|
|
235
|
+
return [...ids];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A copy of `status`, none for `undefined` or `null`; throws `TypeError` unless it is an `AccountDataStatus`. */
|
|
239
|
+
function copiedDataStatus(status: unknown): AccountDataStatus | undefined {
|
|
240
|
+
if (status === undefined || status === null) return undefined;
|
|
241
|
+
if (!(status instanceof AccountDataStatus)) throw invalidArgument('Must be an AccountDataStatus', 'dataStatus');
|
|
242
|
+
return new AccountDataStatus(status);
|
|
191
243
|
}
|
|
192
244
|
|
|
193
245
|
function sameOrUnstated(a: string, b: string): boolean {
|
|
194
246
|
return a === '' || b === '' || a === b;
|
|
195
247
|
}
|
|
196
248
|
|
|
249
|
+
/** The account number as `Account.matches` compares it: without leading zeros. */
|
|
250
|
+
function nationalNumber(account: Account): string | undefined {
|
|
251
|
+
return account.accountNumber?.replace(/^0+/, '');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Accounts by IBAN and by national account, to find the first one an account `matches` without comparing it with each:
|
|
256
|
+
* a bank may list tens of thousands.
|
|
257
|
+
*/
|
|
258
|
+
export class AccountIndex {
|
|
259
|
+
private readonly byIban = new Map<string, number>();
|
|
260
|
+
|
|
261
|
+
/** By national account, under its bank and country code and under `null` for any of each, as `matches` takes them. */
|
|
262
|
+
private readonly byNational = new Map<string, number>();
|
|
263
|
+
|
|
264
|
+
/** Those without IBAN only: an account with IBAN matches one with IBAN by that alone. */
|
|
265
|
+
private readonly byNationalWithoutIban = new Map<string, number>();
|
|
266
|
+
|
|
267
|
+
constructor(private readonly accounts: readonly Account[]) {
|
|
268
|
+
accounts.forEach((account, i) => {
|
|
269
|
+
if (account.iban !== undefined) keepFirst(this.byIban, account.iban, i);
|
|
270
|
+
const number = nationalNumber(account);
|
|
271
|
+
if (number === undefined) return;
|
|
272
|
+
for (const bankCode of [account.bankCode, null]) {
|
|
273
|
+
for (const countryCode of [account.countryCode, null]) {
|
|
274
|
+
const national = nationalKey(number, account.subAccount, bankCode, countryCode);
|
|
275
|
+
keepFirst(this.byNational, national, i);
|
|
276
|
+
if (account.iban === undefined) keepFirst(this.byNationalWithoutIban, national, i);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The first of the accounts that `account` matches. */
|
|
283
|
+
find(account: Account): Account | undefined {
|
|
284
|
+
const number = nationalNumber(account);
|
|
285
|
+
const byNational = account.iban === undefined ? this.byNational : this.byNationalWithoutIban;
|
|
286
|
+
const found = [account.iban === undefined ? undefined : this.byIban.get(account.iban)];
|
|
287
|
+
if (number !== undefined) {
|
|
288
|
+
for (const bankCode of codesMatching(account.bankCode)) {
|
|
289
|
+
for (const countryCode of codesMatching(account.countryCode)) {
|
|
290
|
+
found.push(byNational.get(nationalKey(number, account.subAccount, bankCode, countryCode)));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const positions = found.filter((i) => i !== undefined);
|
|
295
|
+
return positions.length === 0 ? undefined : this.accounts[Math.min(...positions)];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The keys of the codes that `code` matches: an empty one matches any, and a stated one also an empty one. */
|
|
300
|
+
function codesMatching(code: string): (string | null)[] {
|
|
301
|
+
return code === '' ? [null] : [code, ''];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function nationalKey(
|
|
305
|
+
number: string,
|
|
306
|
+
subAccount: string | undefined,
|
|
307
|
+
bankCode: string | null,
|
|
308
|
+
countryCode: string | null,
|
|
309
|
+
): string {
|
|
310
|
+
return JSON.stringify([number, subAccount ?? '', bankCode, countryCode]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function keepFirst(index: Map<string, number>, key: string, position: number): void {
|
|
314
|
+
if (!index.has(key)) index.set(key, position);
|
|
315
|
+
}
|
|
316
|
+
|
|
197
317
|
/**
|
|
198
318
|
* What the bank tells about the data it holds for an account, so that an app asks only for what has changed (FinTS 3.0
|
|
199
319
|
* user parameter data, "Erweiterung, kontobezogen", Formals E.3.1 and F).
|
|
@@ -226,6 +346,7 @@ export class AccountDataStatus {
|
|
|
226
346
|
/** Whether the balances the transactions carry are the current ones. */
|
|
227
347
|
readonly transactionBalancesCurrent: boolean;
|
|
228
348
|
|
|
349
|
+
/** Takes copies of the inventory and the date of `init`, which no other status shares. */
|
|
229
350
|
constructor(init: {
|
|
230
351
|
inventory?: ReadonlyMap<string, string>;
|
|
231
352
|
lastTransactions?: string | undefined;
|
|
@@ -234,7 +355,7 @@ export class AccountDataStatus {
|
|
|
234
355
|
}) {
|
|
235
356
|
this.inventory = new ImmutableMap(init.inventory ?? []);
|
|
236
357
|
this.lastTransactions = init.lastTransactions;
|
|
237
|
-
this.nextTransactionsAt = init.nextTransactionsAt;
|
|
358
|
+
this.nextTransactionsAt = copyDate(init.nextTransactionsAt);
|
|
238
359
|
this.transactionBalancesCurrent = init.transactionBalancesCurrent ?? false;
|
|
239
360
|
}
|
|
240
361
|
|
|
@@ -247,4 +368,12 @@ export class AccountDataStatus {
|
|
|
247
368
|
get camtTransactionsId(): string | undefined {
|
|
248
369
|
return this.inventory.get('HKCAZ');
|
|
249
370
|
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* What Node.js's `util.inspect` and `console.log` show: the queries `inventory` holds an ID for, not the IDs, which
|
|
374
|
+
* mark the user's data.
|
|
375
|
+
*/
|
|
376
|
+
[inspectCustom](): string {
|
|
377
|
+
return `AccountDataStatus(${[...this.inventory.keys()].join(', ')})`;
|
|
378
|
+
}
|
|
250
379
|
}
|
package/src/core/amount.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { requireString } from '../util/arguments.js';
|
|
2
|
+
import { FormatError, invalidArgument } from '../util/errors.js';
|
|
3
|
+
import { inspectCustom } from '../util/inspect.js';
|
|
4
|
+
import { trimBlanks } from '../util/text.js';
|
|
2
5
|
|
|
3
6
|
/** Most decimal places an amount has: ISO 20022, the widest of the bank formats, allows five. */
|
|
4
7
|
const maxScale = 5;
|
|
@@ -10,6 +13,9 @@ const powersOfTen = [1, 10, 100, 1000, 10000, 100000];
|
|
|
10
13
|
/** Most digits a parsed amount has: few enough that the sum of two parsed amounts still fits. */
|
|
11
14
|
const maxDigits = 15;
|
|
12
15
|
|
|
16
|
+
/** Most characters a parsed value has: room for the zeros banks pad amounts with besides `maxDigits` digits. */
|
|
17
|
+
const maxLength = 64;
|
|
18
|
+
|
|
13
19
|
const decimal = /^([+-]?)(\d*)(?:[.,](\d*))?$/;
|
|
14
20
|
|
|
15
21
|
/** Decimal places of the minor unit of `currency` (ISO 4217): two, except for the currencies listed here. */
|
|
@@ -63,8 +69,13 @@ export class Amount {
|
|
|
63
69
|
/**
|
|
64
70
|
* An amount of `minorUnits` with `scale` decimal places; throws `RangeError` for a `scale` outside 0 to 5, and for
|
|
65
71
|
* `minorUnits` that are no integer or beyond 2^53 - 1 in either direction, about 90 trillion at two decimal places.
|
|
72
|
+
* Throws `TypeError` for `minorUnits` or `scale` that are no number, such as `'1234'`, and a `currency` that is no
|
|
73
|
+
* string.
|
|
66
74
|
*/
|
|
67
75
|
constructor(minorUnits: number, scale: number, currency: string) {
|
|
76
|
+
if (typeof minorUnits !== 'number') throw invalidArgument('Must be a number', 'minorUnits');
|
|
77
|
+
if (typeof scale !== 'number') throw invalidArgument('Must be a number', 'scale');
|
|
78
|
+
requireString(currency, 'currency');
|
|
68
79
|
if (!Number.isInteger(scale) || scale < 0 || scale > maxScale) {
|
|
69
80
|
throw new RangeError(`scale must be an integer from 0 to ${maxScale}: ${scale}`);
|
|
70
81
|
}
|
|
@@ -83,11 +94,15 @@ export class Amount {
|
|
|
83
94
|
* `JPY`, three for `KWD`. It has more where the value has more significant digits. So `100,` in `EUR` is `100.00`, in
|
|
84
95
|
* `JPY` `100`.
|
|
85
96
|
*
|
|
86
|
-
* Throws `FormatError` for anything else, so that `1.234,56` in bank data is an error, not a guess,
|
|
87
|
-
* five decimal places (trailing zeros aside) or more than 15 digits
|
|
97
|
+
* Throws `FormatError` for anything else, so that `1.234,56` in bank data is an error, not a guess, for more than
|
|
98
|
+
* five decimal places (trailing zeros aside) or more than 15 digits, and for more than 64 characters, zeros included
|
|
99
|
+
* and surrounding blanks aside. Throws `TypeError` for a `value` or `currency` that is no string.
|
|
88
100
|
*/
|
|
89
101
|
static parseDecimal(value: string, currency: string): Amount {
|
|
90
|
-
const
|
|
102
|
+
const trimmed = trimBlanks(requireString(value, 'value'));
|
|
103
|
+
requireString(currency, 'currency');
|
|
104
|
+
if (trimmed.length > maxLength) throw new FormatError(`Amount longer than ${maxLength} characters`, value);
|
|
105
|
+
const match = decimal.exec(trimmed);
|
|
91
106
|
const whole = match?.[2] ?? '';
|
|
92
107
|
let fraction = match?.[3] ?? '';
|
|
93
108
|
if (!match || (whole === '' && fraction === '')) throw new FormatError('Invalid amount', value);
|
|
@@ -175,14 +190,15 @@ export class Amount {
|
|
|
175
190
|
return `${this.toDecimalString()} ${this.currency}`;
|
|
176
191
|
}
|
|
177
192
|
|
|
193
|
+
/** What Node.js's `util.inspect` and `console.log` show: `toString`, not the fields. */
|
|
194
|
+
[inspectCustom](): string {
|
|
195
|
+
return this.toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
/** The minor units of both amounts at the larger scale, as `bigint`s, since the factor could leave the safe range. */
|
|
179
199
|
private atCommonScale(other: Amount): [bigint, bigint] {
|
|
180
200
|
const target = Math.max(this.scale, other.scale);
|
|
181
|
-
return [this
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
private rescale(target: number): bigint {
|
|
185
|
-
return BigInt(this.minorUnits) * BigInt(powersOfTen[target - this.scale]!);
|
|
201
|
+
return [unitsAtScale(this, target), unitsAtScale(other, target)];
|
|
186
202
|
}
|
|
187
203
|
|
|
188
204
|
private compareValue(other: Amount): number {
|
|
@@ -197,12 +213,31 @@ export class Amount {
|
|
|
197
213
|
}
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
function unitsAtScale(amount: Amount, scale: number): bigint {
|
|
217
|
+
return BigInt(amount.minorUnits) * BigInt(powersOfTen[scale - amount.scale]!);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Whether `parts` are all in the currency of `total` and add up to exactly `total`; unlike `add`, also where a sum on
|
|
222
|
+
* the way lies beyond what an amount holds.
|
|
223
|
+
*/
|
|
224
|
+
export function addsUpTo(parts: readonly Amount[], total: Amount): boolean {
|
|
225
|
+
let sum = 0n;
|
|
226
|
+
for (const part of parts) {
|
|
227
|
+
if (part.currency !== total.currency) return false;
|
|
228
|
+
sum += unitsAtScale(part, maxScale);
|
|
229
|
+
}
|
|
230
|
+
return sum === unitsAtScale(total, maxScale);
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
/**
|
|
201
234
|
* `amount` as a credit (unchanged) or a debit (negated), by the bank's credit/debit `mark`.
|
|
202
235
|
*
|
|
203
|
-
* Throws `FormatError` for any other `mark`, `undefined` included: a missing mark must not be read as a credit.
|
|
236
|
+
* Throws `FormatError` for any other `mark`, `undefined` included: a missing mark must not be read as a credit. It does
|
|
237
|
+
* as well for a negative `amount`, whose sign the mark would turn: the mark alone gives the direction.
|
|
204
238
|
*/
|
|
205
239
|
export function withMark(amount: Amount, mark: string | undefined, credit: string, debit: string): Amount {
|
|
240
|
+
if (amount.isNegative) throw new FormatError('Negative amount with a credit/debit mark', amount.toString());
|
|
206
241
|
if (mark === credit) return amount;
|
|
207
242
|
if (mark === debit) return amount.negate();
|
|
208
243
|
throw new FormatError(`Credit/debit mark is neither "${credit}" nor "${debit}"`, mark);
|