@coderoftime/fints 0.0.0-stage → 0.1.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.
Files changed (254) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +28 -0
  3. package/README.md +156 -2
  4. package/dist/core/account.d.ts +140 -0
  5. package/dist/core/account.d.ts.map +1 -0
  6. package/dist/core/account.js +174 -0
  7. package/dist/core/account.js.map +1 -0
  8. package/dist/core/amount.d.ts +62 -0
  9. package/dist/core/amount.d.ts.map +1 -0
  10. package/dist/core/amount.js +189 -0
  11. package/dist/core/amount.js.map +1 -0
  12. package/dist/core/balance.d.ts +54 -0
  13. package/dist/core/balance.d.ts.map +1 -0
  14. package/dist/core/balance.js +53 -0
  15. package/dist/core/balance.js.map +1 -0
  16. package/dist/core/bank-info.d.ts +49 -0
  17. package/dist/core/bank-info.d.ts.map +1 -0
  18. package/dist/core/bank-info.js +52 -0
  19. package/dist/core/bank-info.js.map +1 -0
  20. package/dist/core/client.d.ts +126 -0
  21. package/dist/core/client.d.ts.map +1 -0
  22. package/dist/core/client.js +2 -0
  23. package/dist/core/client.js.map +1 -0
  24. package/dist/core/config.d.ts +68 -0
  25. package/dist/core/config.d.ts.map +1 -0
  26. package/dist/core/config.js +113 -0
  27. package/dist/core/config.js.map +1 -0
  28. package/dist/core/errors.d.ts +91 -0
  29. package/dist/core/errors.d.ts.map +1 -0
  30. package/dist/core/errors.js +182 -0
  31. package/dist/core/errors.js.map +1 -0
  32. package/dist/core/feedback.d.ts +69 -0
  33. package/dist/core/feedback.d.ts.map +1 -0
  34. package/dist/core/feedback.js +124 -0
  35. package/dist/core/feedback.js.map +1 -0
  36. package/dist/core/transaction.d.ts +98 -0
  37. package/dist/core/transaction.d.ts.map +1 -0
  38. package/dist/core/transaction.js +91 -0
  39. package/dist/core/transaction.js.map +1 -0
  40. package/dist/core/transport.d.ts +37 -0
  41. package/dist/core/transport.d.ts.map +1 -0
  42. package/dist/core/transport.js +92 -0
  43. package/dist/core/transport.js.map +1 -0
  44. package/dist/formats/camt.d.ts +12 -0
  45. package/dist/formats/camt.d.ts.map +1 -0
  46. package/dist/formats/camt.js +159 -0
  47. package/dist/formats/camt.js.map +1 -0
  48. package/dist/formats/mt940.d.ts +42 -0
  49. package/dist/formats/mt940.d.ts.map +1 -0
  50. package/dist/formats/mt940.js +297 -0
  51. package/dist/formats/mt940.js.map +1 -0
  52. package/dist/formats/readers.d.ts +11 -0
  53. package/dist/formats/readers.d.ts.map +1 -0
  54. package/dist/formats/readers.js +47 -0
  55. package/dist/formats/readers.js.map +1 -0
  56. package/dist/formats/sepa-purpose.d.ts +22 -0
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -0
  58. package/dist/formats/sepa-purpose.js +68 -0
  59. package/dist/formats/sepa-purpose.js.map +1 -0
  60. package/dist/index.d.ts +28 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +27 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/session/app-approval.d.ts +60 -0
  65. package/dist/session/app-approval.d.ts.map +1 -0
  66. package/dist/session/app-approval.js +215 -0
  67. package/dist/session/app-approval.js.map +1 -0
  68. package/dist/session/bank-connection.d.ts +28 -0
  69. package/dist/session/bank-connection.d.ts.map +1 -0
  70. package/dist/session/bank-connection.js +48 -0
  71. package/dist/session/bank-connection.js.map +1 -0
  72. package/dist/session/dialog-runner.d.ts +34 -0
  73. package/dist/session/dialog-runner.d.ts.map +1 -0
  74. package/dist/session/dialog-runner.js +112 -0
  75. package/dist/session/dialog-runner.js.map +1 -0
  76. package/dist/session/login-guard.d.ts +73 -0
  77. package/dist/session/login-guard.d.ts.map +1 -0
  78. package/dist/session/login-guard.js +109 -0
  79. package/dist/session/login-guard.js.map +1 -0
  80. package/dist/session/parameter-data.d.ts +83 -0
  81. package/dist/session/parameter-data.d.ts.map +1 -0
  82. package/dist/session/parameter-data.js +68 -0
  83. package/dist/session/parameter-data.js.map +1 -0
  84. package/dist/session/pin-tan-client.d.ts +54 -0
  85. package/dist/session/pin-tan-client.d.ts.map +1 -0
  86. package/dist/session/pin-tan-client.js +153 -0
  87. package/dist/session/pin-tan-client.js.map +1 -0
  88. package/dist/session/pin-tan-dialog.d.ts +71 -0
  89. package/dist/session/pin-tan-dialog.d.ts.map +1 -0
  90. package/dist/session/pin-tan-dialog.js +124 -0
  91. package/dist/session/pin-tan-dialog.js.map +1 -0
  92. package/dist/session/protocol.d.ts +209 -0
  93. package/dist/session/protocol.d.ts.map +1 -0
  94. package/dist/session/protocol.js +75 -0
  95. package/dist/session/protocol.js.map +1 -0
  96. package/dist/session/response-handler.d.ts +48 -0
  97. package/dist/session/response-handler.d.ts.map +1 -0
  98. package/dist/session/response-handler.js +121 -0
  99. package/dist/session/response-handler.js.map +1 -0
  100. package/dist/session/sepa-accounts.d.ts +34 -0
  101. package/dist/session/sepa-accounts.d.ts.map +1 -0
  102. package/dist/session/sepa-accounts.js +96 -0
  103. package/dist/session/sepa-accounts.js.map +1 -0
  104. package/dist/session/session-setup.d.ts +72 -0
  105. package/dist/session/session-setup.d.ts.map +1 -0
  106. package/dist/session/session-setup.js +160 -0
  107. package/dist/session/session-setup.js.map +1 -0
  108. package/dist/session/session-state.d.ts +47 -0
  109. package/dist/session/session-state.d.ts.map +1 -0
  110. package/dist/session/session-state.js +147 -0
  111. package/dist/session/session-state.js.map +1 -0
  112. package/dist/session/tan-selection.d.ts +88 -0
  113. package/dist/session/tan-selection.d.ts.map +1 -0
  114. package/dist/session/tan-selection.js +178 -0
  115. package/dist/session/tan-selection.js.map +1 -0
  116. package/dist/session/user-prompts.d.ts +61 -0
  117. package/dist/session/user-prompts.d.ts.map +1 -0
  118. package/dist/session/user-prompts.js +164 -0
  119. package/dist/session/user-prompts.js.map +1 -0
  120. package/dist/tan/challenge.d.ts +114 -0
  121. package/dist/tan/challenge.d.ts.map +1 -0
  122. package/dist/tan/challenge.js +185 -0
  123. package/dist/tan/challenge.js.map +1 -0
  124. package/dist/tan/flicker.d.ts +36 -0
  125. package/dist/tan/flicker.d.ts.map +1 -0
  126. package/dist/tan/flicker.js +262 -0
  127. package/dist/tan/flicker.js.map +1 -0
  128. package/dist/tan/tan-image.d.ts +21 -0
  129. package/dist/tan/tan-image.d.ts.map +1 -0
  130. package/dist/tan/tan-image.js +41 -0
  131. package/dist/tan/tan-image.js.map +1 -0
  132. package/dist/tan/tan-models.d.ts +145 -0
  133. package/dist/tan/tan-models.d.ts.map +1 -0
  134. package/dist/tan/tan-models.js +148 -0
  135. package/dist/tan/tan-models.js.map +1 -0
  136. package/dist/util/async.d.ts +24 -0
  137. package/dist/util/async.d.ts.map +1 -0
  138. package/dist/util/async.js +49 -0
  139. package/dist/util/async.js.map +1 -0
  140. package/dist/util/bytes.d.ts +23 -0
  141. package/dist/util/bytes.d.ts.map +1 -0
  142. package/dist/util/bytes.js +136 -0
  143. package/dist/util/bytes.js.map +1 -0
  144. package/dist/util/call-context.d.ts +12 -0
  145. package/dist/util/call-context.d.ts.map +1 -0
  146. package/dist/util/call-context.js +29 -0
  147. package/dist/util/call-context.js.map +1 -0
  148. package/dist/util/dates.d.ts +62 -0
  149. package/dist/util/dates.d.ts.map +1 -0
  150. package/dist/util/dates.js +169 -0
  151. package/dist/util/dates.js.map +1 -0
  152. package/dist/util/errors.d.ts +9 -0
  153. package/dist/util/errors.d.ts.map +1 -0
  154. package/dist/util/errors.js +15 -0
  155. package/dist/util/errors.js.map +1 -0
  156. package/dist/util/immutable-map.d.ts +16 -0
  157. package/dist/util/immutable-map.d.ts.map +1 -0
  158. package/dist/util/immutable-map.js +38 -0
  159. package/dist/util/immutable-map.js.map +1 -0
  160. package/dist/util/json.d.ts +13 -0
  161. package/dist/util/json.d.ts.map +1 -0
  162. package/dist/util/json.js +44 -0
  163. package/dist/util/json.js.map +1 -0
  164. package/dist/util/text.d.ts +20 -0
  165. package/dist/util/text.d.ts.map +1 -0
  166. package/dist/util/text.js +78 -0
  167. package/dist/util/text.js.map +1 -0
  168. package/dist/util/xml.d.ts +35 -0
  169. package/dist/util/xml.d.ts.map +1 -0
  170. package/dist/util/xml.js +183 -0
  171. package/dist/util/xml.js.map +1 -0
  172. package/dist/v3/account-extension.d.ts +25 -0
  173. package/dist/v3/account-extension.d.ts.map +1 -0
  174. package/dist/v3/account-extension.js +176 -0
  175. package/dist/v3/account-extension.js.map +1 -0
  176. package/dist/v3/client.d.ts +24 -0
  177. package/dist/v3/client.d.ts.map +1 -0
  178. package/dist/v3/client.js +16 -0
  179. package/dist/v3/client.js.map +1 -0
  180. package/dist/v3/dialog.d.ts +78 -0
  181. package/dist/v3/dialog.d.ts.map +1 -0
  182. package/dist/v3/dialog.js +138 -0
  183. package/dist/v3/dialog.js.map +1 -0
  184. package/dist/v3/message.d.ts +72 -0
  185. package/dist/v3/message.d.ts.map +1 -0
  186. package/dist/v3/message.js +259 -0
  187. package/dist/v3/message.js.map +1 -0
  188. package/dist/v3/parameters.d.ts +79 -0
  189. package/dist/v3/parameters.d.ts.map +1 -0
  190. package/dist/v3/parameters.js +396 -0
  191. package/dist/v3/parameters.js.map +1 -0
  192. package/dist/v3/protocol.d.ts +68 -0
  193. package/dist/v3/protocol.d.ts.map +1 -0
  194. package/dist/v3/protocol.js +263 -0
  195. package/dist/v3/protocol.js.map +1 -0
  196. package/dist/v3/segments.d.ts +89 -0
  197. package/dist/v3/segments.d.ts.map +1 -0
  198. package/dist/v3/segments.js +283 -0
  199. package/dist/v3/segments.js.map +1 -0
  200. package/dist/v3/syntax.d.ts +76 -0
  201. package/dist/v3/syntax.d.ts.map +1 -0
  202. package/dist/v3/syntax.js +301 -0
  203. package/dist/v3/syntax.js.map +1 -0
  204. package/package.json +63 -4
  205. package/src/core/account.ts +250 -0
  206. package/src/core/amount.ts +209 -0
  207. package/src/core/balance.ts +84 -0
  208. package/src/core/bank-info.ts +66 -0
  209. package/src/core/client.ts +149 -0
  210. package/src/core/config.ts +141 -0
  211. package/src/core/errors.ts +203 -0
  212. package/src/core/feedback.ts +137 -0
  213. package/src/core/transaction.ts +156 -0
  214. package/src/core/transport.ts +114 -0
  215. package/src/formats/camt.ts +169 -0
  216. package/src/formats/mt940.ts +333 -0
  217. package/src/formats/readers.ts +56 -0
  218. package/src/formats/sepa-purpose.ts +75 -0
  219. package/src/index.ts +68 -0
  220. package/src/session/app-approval.ts +265 -0
  221. package/src/session/bank-connection.ts +51 -0
  222. package/src/session/dialog-runner.ts +119 -0
  223. package/src/session/login-guard.ts +135 -0
  224. package/src/session/parameter-data.ts +137 -0
  225. package/src/session/pin-tan-client.ts +186 -0
  226. package/src/session/pin-tan-dialog.ts +160 -0
  227. package/src/session/protocol.ts +311 -0
  228. package/src/session/response-handler.ts +130 -0
  229. package/src/session/sepa-accounts.ts +97 -0
  230. package/src/session/session-setup.ts +165 -0
  231. package/src/session/session-state.ts +170 -0
  232. package/src/session/tan-selection.ts +192 -0
  233. package/src/session/user-prompts.ts +174 -0
  234. package/src/tan/challenge.ts +252 -0
  235. package/src/tan/flicker.ts +270 -0
  236. package/src/tan/tan-image.ts +36 -0
  237. package/src/tan/tan-models.ts +247 -0
  238. package/src/util/async.ts +53 -0
  239. package/src/util/bytes.ts +135 -0
  240. package/src/util/call-context.ts +40 -0
  241. package/src/util/dates.ts +193 -0
  242. package/src/util/errors.ts +16 -0
  243. package/src/util/immutable-map.ts +47 -0
  244. package/src/util/json.ts +51 -0
  245. package/src/util/text.ts +82 -0
  246. package/src/util/xml.ts +172 -0
  247. package/src/v3/account-extension.ts +190 -0
  248. package/src/v3/client.ts +29 -0
  249. package/src/v3/dialog.ts +199 -0
  250. package/src/v3/message.ts +321 -0
  251. package/src/v3/parameters.ts +451 -0
  252. package/src/v3/protocol.ts +331 -0
  253. package/src/v3/segments.ts +331 -0
  254. package/src/v3/syntax.ts +307 -0
@@ -0,0 +1,165 @@
1
+ import type { BankInfo } from '../core/bank-info.js';
2
+ import { FinTsBankError, FinTsError, FinTsProtocolError, FinTsTransportError, parseBankData } from '../core/errors.js';
3
+ import type { TanMedium, TanMethod } from '../tan/tan-models.js';
4
+ import type { LoginGuard, RefusalReason } from './login-guard.js';
5
+ import { requireBankParameters } from './parameter-data.js';
6
+ import type { BankResponse, PinTanProtocol, ProtocolDialog } from './protocol.js';
7
+ import type { ResponseHandler } from './response-handler.js';
8
+ import type { TanSelection } from './tan-selection.js';
9
+
10
+ /**
11
+ * What has to be settled before a login: the bank parameter data, the synchronization, and through `TanSelection` the
12
+ * TAN method and medium. Each of the dialogs this takes serves one purpose only.
13
+ *
14
+ * Where the bank refuses a login for a reason the session can take care of, `remedied` does so and logs in again.
15
+ */
16
+ export class SessionSetup<R extends BankResponse, O> {
17
+ /** Whether a synchronization dialog ran, so that a helpful one runs once only. */
18
+ private synchronizationTried = false;
19
+
20
+ constructor(
21
+ private readonly protocol: PinTanProtocol<R, O>,
22
+ private readonly responses: ResponseHandler<R>,
23
+ private readonly tans: TanSelection<R, O>,
24
+ private readonly guard: LoginGuard,
25
+ ) {}
26
+
27
+ /**
28
+ * The bank's general information: from an anonymous dialog where the bank answers one, else from the synchronization.
29
+ * No TAN method or medium is chosen for it.
30
+ */
31
+ async bankInfo(): Promise<BankInfo> {
32
+ if (this.protocol.bankParameters === undefined) await this.fetchBankParametersAnonymously();
33
+ if (this.protocol.bankParameters === undefined) await this.synchronizeIfDue();
34
+ return requireBankParameters(this.protocol.bankParameters).bankInfo;
35
+ }
36
+
37
+ /** The TAN methods the bank allows for this user, which the synchronization reports. */
38
+ async methods(): Promise<TanMethod[]> {
39
+ await this.loadParameters();
40
+ return this.tans.available();
41
+ }
42
+
43
+ /** Settles what is still open: the bank parameter data, the synchronization, the TAN method and medium. */
44
+ async prepare(): Promise<void> {
45
+ await this.loadParameters();
46
+ await this.tans.ensureMethodChosen();
47
+ if (this.tans.mediumToLookUp) await this.tans.chooseMedium(await this.lookUpTanMedia());
48
+ }
49
+
50
+ /** A new dialog signed with the TAN method and medium in use, once `prepare` has settled them. */
51
+ async newDialog(): Promise<ProtocolDialog<R, O>> {
52
+ await this.prepare();
53
+ return this.tans.newDialog();
54
+ }
55
+
56
+ /**
57
+ * Runs `attempt`, which may log in, and runs it once more for each reason of a refused login the session can take
58
+ * care of, so that a bank that keeps refusing fails with its own error:
59
+ *
60
+ * | Refusal (`LoginGuard.remediableReasons`) | Remedy |
61
+ * | --- | --- |
62
+ * | 3920 without the method, or 9075 after the one-step method | another allowed method (`TanSelection.replace`) |
63
+ * | 9391 | a new synchronization, which issues a customer system ID |
64
+ */
65
+ async remedied<T>(attempt: () => Promise<T>): Promise<T> {
66
+ // A refusal of an earlier call stops the attempt before it sends the PIN; there is nothing to remedy.
67
+ if (this.guard.hasRefusal) return attempt();
68
+ const remedied = new Set<RefusalReason>();
69
+ for (;;) {
70
+ try {
71
+ return await attempt();
72
+ } catch (error) {
73
+ if (!(error instanceof FinTsBankError)) throw error;
74
+ const reason = this.guard.remediableReasons(error).find((r) => !remedied.has(r));
75
+ const rejected = this.guard.refusedMethod;
76
+ if (reason === undefined || rejected === undefined || !(await this.remedy(reason, rejected))) throw error;
77
+ remedied.add(reason);
78
+ this.guard.pardon(error);
79
+ }
80
+ }
81
+ }
82
+
83
+ /** Takes care of `reason`, given for refusing a login signed with `rejected`; returns whether it could. */
84
+ private async remedy(reason: RefusalReason, rejected: TanMethod): Promise<boolean> {
85
+ switch (reason) {
86
+ case 'tanMethodNotAllowed':
87
+ // The parameter data name the methods to choose from; the refusal may be the first to deliver them.
88
+ if (this.protocol.bankParameters === undefined) await this.fetchBankParametersAnonymously();
89
+ return this.tans.replace(rejected);
90
+ case 'systemIdRejected':
91
+ // Without a customer system ID there is none to lose, and logging in once more would only send the PIN again.
92
+ return this.protocol.forgetSystemId();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Loads the bank parameter data if they are missing, then synchronizes if that is due.
98
+ *
99
+ * The bank parameter data come first, from an anonymous dialog: the dialogs signed with the PIN need them for the
100
+ * dialog language, the PIN length and the TAN methods (PIN/TAN B.4.3.1, Formals C.3.1.3). A bank that does not answer
101
+ * anonymously delivers them with the synchronization.
102
+ *
103
+ * After a refused login it throws the refusal right away, before the user is asked for a TAN method or medium.
104
+ */
105
+ private async loadParameters(): Promise<void> {
106
+ this.guard.check();
107
+ if (this.protocol.bankParameters === undefined) await this.fetchBankParametersAnonymously();
108
+ await this.synchronizeIfDue();
109
+ }
110
+
111
+ /**
112
+ * Asks for the bank parameter data without logging in. A bank that does not answer anonymously is no error, but a
113
+ * failed connection is: the synchronization would only wait for the same server again.
114
+ */
115
+ private async fetchBankParametersAnonymously(): Promise<void> {
116
+ try {
117
+ this.responses.showBankMessages(await this.protocol.fetchBankParametersAnonymously());
118
+ } catch (error) {
119
+ // Optional: the login delivers the parameters as well.
120
+ if (error instanceof FinTsTransportError || !(error instanceof FinTsError)) throw error;
121
+ }
122
+ }
123
+
124
+ /** Runs the synchronization dialog where it is due (`TanSelection.synchronizationDialog`). */
125
+ private async synchronizeIfDue(): Promise<void> {
126
+ const due =
127
+ this.protocol.synchronization === 'required' ||
128
+ (this.protocol.synchronization === 'helpful' && !this.synchronizationTried);
129
+ if (!due) return;
130
+ this.synchronizationTried = true;
131
+ await this.once(this.tans.synchronizationDialog(), (dialog) =>
132
+ this.responses.startDialog(dialog, () => dialog.start(true)),
133
+ );
134
+ if (this.protocol.synchronization === 'required') {
135
+ throw new FinTsProtocolError('The bank did not complete the synchronization');
136
+ }
137
+ }
138
+
139
+ /**
140
+ * The TAN media of the method in use; `undefined` if the bank does not list them or rejects the list, after which the
141
+ * client reports a missing medium before the login.
142
+ *
143
+ * The lookup runs in a dialog of its own, whose start asks for the list, since the regular dialog start needs the
144
+ * medium the list tells (PIN/TAN B.4.3.1.3).
145
+ */
146
+ private async lookUpTanMedia(): Promise<TanMedium[] | undefined> {
147
+ const query = this.protocol.tanMedia();
148
+ if (query === undefined) return undefined;
149
+ return this.guard.tryLookUp(() =>
150
+ this.once(this.tans.lookupDialog(), async (dialog) => {
151
+ const pages = await this.responses.startDialogWith(dialog, dialog.startWith(query.order(undefined)));
152
+ return parseBankData(() => query.read(pages));
153
+ }),
154
+ );
155
+ }
156
+
157
+ /** Runs `use` in `dialog`, a dialog of its own, and ends it afterwards, however `use` ends. */
158
+ private async once<T>(dialog: ProtocolDialog<R, O>, use: (dialog: ProtocolDialog<R, O>) => Promise<T>): Promise<T> {
159
+ try {
160
+ return await use(dialog);
161
+ } finally {
162
+ await dialog.end();
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,170 @@
1
+ import { Account } from '../core/account.js';
2
+ import { hostOf, type FinTsConfig } from '../core/config.js';
3
+ import { FormatError } from '../util/errors.js';
4
+ import {
5
+ isJsonObject,
6
+ isString,
7
+ readObject,
8
+ readOptionalInt,
9
+ readOptionalList,
10
+ readOptionalString,
11
+ type JsonObject,
12
+ } from '../util/json.js';
13
+ import type { BankResponse, PinTanProtocol } from './protocol.js';
14
+
15
+ /** Layout of the saved session; a state of another layout is ignored. */
16
+ const stateFormat = 1;
17
+
18
+ /** The protocol version the state is for, named as the Dart package names it, so that both read each other's states. */
19
+ const protocolVersion = 'v300';
20
+
21
+ /** The session and the protocol's parameter data as one JSON string, which `restoreSession` reads. */
22
+ export function saveSession<R extends BankResponse, O>(protocol: PinTanProtocol<R, O>, session: SessionState): string {
23
+ return JSON.stringify({
24
+ format: stateFormat,
25
+ version: protocolVersion,
26
+ owner: ownerOf(protocol.config),
27
+ session: session.toJson(),
28
+ protocol: protocol.saveState(),
29
+ });
30
+ }
31
+
32
+ /**
33
+ * The session saved in `json`, and the protocol's parameter data restored from it.
34
+ *
35
+ * A missing or damaged state, or one saved for another user, bank, server or protocol version, gives a new session,
36
+ * which only costs a new synchronization.
37
+ */
38
+ export function restoreSession<R extends BankResponse, O>(
39
+ json: string | undefined,
40
+ protocol: PinTanProtocol<R, O>,
41
+ ): SessionState {
42
+ if (json === undefined) return new SessionState();
43
+ try {
44
+ const saved: unknown = JSON.parse(json);
45
+ if (!isJsonObject(saved) || saved.format !== stateFormat || saved.version !== protocolVersion) {
46
+ return new SessionState();
47
+ }
48
+ const owner = readOptionalList(saved, 'owner', isString);
49
+ const expected = ownerOf(protocol.config);
50
+ if (owner?.length !== expected.length || owner.some((value, i) => value !== expected[i])) {
51
+ return new SessionState();
52
+ }
53
+ const session = SessionState.fromJson(readObject(saved, 'session'));
54
+ protocol.restoreState(readObject(saved, 'protocol'));
55
+ return session;
56
+ } catch (error) {
57
+ // Damaged JSON, base64 or parameter data, and values of the wrong type, only cost a new synchronization.
58
+ if (error instanceof FormatError || error instanceof SyntaxError) return new SessionState();
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ /** Whose data a state holds, so that the state of another user or bank is never used. */
64
+ function ownerOf(config: FinTsConfig): string[] {
65
+ return [hostOf(config.url), config.bankCode, config.userId, config.customerId];
66
+ }
67
+
68
+ /**
69
+ * The session data saved between app starts: the allowed TAN methods, the TAN method and medium in use, and the SEPA
70
+ * account list.
71
+ */
72
+ export class SessionState {
73
+ private allowedMethodsValue: readonly string[] | undefined;
74
+ private tanMethodValue: string | undefined;
75
+ private tanMediumValue: string | undefined;
76
+ private sepaAccountsValue: readonly Account[] | undefined;
77
+ private sepaUpdVersion: number | undefined;
78
+
79
+ /** TAN methods the bank allows for this user (return code 3920); `undefined` until the bank has said. */
80
+ get allowedMethods(): readonly string[] | undefined {
81
+ return this.allowedMethodsValue;
82
+ }
83
+
84
+ /** Security function of the TAN method in use. */
85
+ get tanMethod(): string | undefined {
86
+ return this.tanMethodValue;
87
+ }
88
+
89
+ /** Name of the TAN medium in use. */
90
+ get tanMedium(): string | undefined {
91
+ return this.tanMediumValue;
92
+ }
93
+
94
+ /** Keeps the TAN methods the bank reports as allowed for this user. */
95
+ rememberAllowedMethods(securityFunctions: readonly string[]): void {
96
+ this.allowedMethodsValue = Object.freeze([...securityFunctions]);
97
+ }
98
+
99
+ /**
100
+ * Switches to the TAN method `securityFunction` and the TAN `medium` together, so that no medium of another method
101
+ * stays behind; `undefined` leaves the choice to the next login.
102
+ */
103
+ useTanMethod(securityFunction: string | undefined, medium?: string): void {
104
+ this.tanMethodValue = securityFunction;
105
+ this.tanMediumValue = medium;
106
+ }
107
+
108
+ /** Switches to the TAN `medium` of the method in use. */
109
+ useTanMedium(medium: string | undefined): void {
110
+ this.tanMediumValue = medium;
111
+ }
112
+
113
+ /** The SEPA account list read under the user parameter data of `updVersion`, if it is cached. */
114
+ sepaAccounts(updVersion: number | undefined): readonly Account[] | undefined {
115
+ return updVersion !== undefined && updVersion === this.sepaUpdVersion ? this.sepaAccountsValue : undefined;
116
+ }
117
+
118
+ /** Caches the SEPA account list `accounts`, read under the user parameter data of `updVersion`. */
119
+ cacheSepaAccounts(accounts: readonly Account[], updVersion: number | undefined): void {
120
+ this.sepaAccountsValue = Object.freeze([...accounts]);
121
+ this.sepaUpdVersion = updVersion;
122
+ }
123
+
124
+ /** Reads what `toJson` wrote; throws `FormatError` if `json` is damaged. */
125
+ static fromJson(json: JsonObject): SessionState {
126
+ const allowedMethods = readOptionalList(json, 'allowedMethods', isString);
127
+ const sepaAccounts = readOptionalList(json, 'sepaAccounts', isJsonObject)?.map(accountFromJson);
128
+ const sepaUpdVersion = readOptionalInt(json, 'sepaUpdVersion');
129
+ const state = new SessionState();
130
+ state.useTanMethod(readOptionalString(json, 'tanMethod'), readOptionalString(json, 'tanMedium'));
131
+ if (allowedMethods !== undefined) state.rememberAllowedMethods(allowedMethods);
132
+ if (sepaAccounts !== undefined) state.cacheSepaAccounts(sepaAccounts, sepaUpdVersion);
133
+ return state;
134
+ }
135
+
136
+ /** The session as JSON data. */
137
+ toJson(): JsonObject {
138
+ return {
139
+ allowedMethods: this.allowedMethodsValue ?? null,
140
+ tanMethod: this.tanMethodValue ?? null,
141
+ tanMedium: this.tanMediumValue ?? null,
142
+ sepaUpdVersion: this.sepaUpdVersion ?? null,
143
+ sepaAccounts: this.sepaAccountsValue?.map(accountToJson) ?? null,
144
+ };
145
+ }
146
+ }
147
+
148
+ function accountToJson(a: Account): JsonObject {
149
+ return {
150
+ iban: a.iban ?? null,
151
+ bic: a.bic ?? null,
152
+ accountNumber: a.accountNumber ?? null,
153
+ subAccount: a.subAccount ?? null,
154
+ countryCode: a.countryCode,
155
+ bankCode: a.bankCode,
156
+ };
157
+ }
158
+
159
+ function accountFromJson(json: JsonObject): Account {
160
+ const bankCode = readOptionalString(json, 'bankCode');
161
+ if (bankCode === undefined) throw new FormatError('Saved state: account without bank code');
162
+ return new Account({
163
+ iban: readOptionalString(json, 'iban'),
164
+ bic: readOptionalString(json, 'bic'),
165
+ accountNumber: readOptionalString(json, 'accountNumber'),
166
+ subAccount: readOptionalString(json, 'subAccount'),
167
+ countryCode: readOptionalString(json, 'countryCode'),
168
+ bankCode,
169
+ });
170
+ }
@@ -0,0 +1,192 @@
1
+ import { FinTsUnsupportedError } from '../core/errors.js';
2
+ import { TanMethod, type TanMedium } from '../tan/tan-models.js';
3
+ import { noReference, type BankResponse, type PinTanProtocol, type ProtocolDialog } from './protocol.js';
4
+ import type { SessionState } from './session-state.js';
5
+ import type { UserPrompts } from './user-prompts.js';
6
+
7
+ /**
8
+ * The TAN method and medium the session's messages are signed with, chosen by the user among what the bank offers and
9
+ * the user may use, and kept in the session state.
10
+ */
11
+ export class TanSelection<R extends BankResponse, O> {
12
+ /** TAN methods whose media were looked up in this session. A lookup is not repeated, even where it gave no medium. */
13
+ private readonly mediaLookedUp = new Set<string>();
14
+
15
+ /** The list the last lookup gave, until `takeLookedUpMedia` hands it out. */
16
+ private lookedUpMedia: TanMedium[] | undefined;
17
+
18
+ /** Whether the bank refused a login signed with the one-step method in this session. */
19
+ private oneStepRefused = false;
20
+
21
+ constructor(
22
+ private readonly protocol: PinTanProtocol<R, O>,
23
+ private readonly state: SessionState,
24
+ private readonly prompts: UserPrompts,
25
+ ) {}
26
+
27
+ /** The TAN method in use; the one-step method until one is chosen. */
28
+ get method(): TanMethod {
29
+ const code = this.state.tanMethod;
30
+ if (code === undefined || code === TanMethod.oneStep.securityFunction) return TanMethod.oneStep;
31
+ return this.protocol.bankParameters?.tanMethods.find((m) => m.securityFunction === code) ?? TanMethod.oneStep;
32
+ }
33
+
34
+ /** Switches to `securityFunction` and `tanMedium` for the dialogs to come. */
35
+ select(securityFunction: string, tanMedium: string | undefined): void {
36
+ this.state.useTanMethod(securityFunction, tanMedium);
37
+ this.mediaLookedUp.delete(securityFunction);
38
+ }
39
+
40
+ /**
41
+ * A dialog signed with the TAN method in use and its medium.
42
+ *
43
+ * Throws `FinTsUnsupportedError` if the method needs a medium the user has not chosen: the bank would refuse the
44
+ * login, and the client would not send the PIN again.
45
+ */
46
+ newDialog(): ProtocolDialog<R, O> {
47
+ const method = this.method;
48
+ if (this.lacksMedium(method)) {
49
+ throw new FinTsUnsupportedError(
50
+ `The TAN method ${method.name} needs a TAN medium, and the client could not learn one: the bank lists no ` +
51
+ 'active medium for it, or does not offer or permit the list of TAN media, or rejected it. Pass a medium to ' +
52
+ 'selectTanMethod, or choose another method',
53
+ );
54
+ }
55
+ return this.protocol.newDialog(method, this.state.tanMedium);
56
+ }
57
+
58
+ /**
59
+ * The dialog that looks up the TAN media, signed with the TAN method in use.
60
+ *
61
+ * It runs before the medium is known, so it names a placeholder where the method needs one; the bank ignores the
62
+ * medium in this dialog (PIN/TAN B.4.3.1.3).
63
+ */
64
+ lookupDialog(): ProtocolDialog<R, O> {
65
+ const method = this.method;
66
+ return this.protocol.newDialog(method, this.lacksMedium(method) ? noReference : this.state.tanMedium);
67
+ }
68
+
69
+ /**
70
+ * The synchronization dialog. It runs before the TAN media are looked up, so its method must do without a medium the
71
+ * user has not chosen: the placeholder for one is meant for the lookup only (PIN/TAN B.4.3.1.3). The first method
72
+ * that does signs it:
73
+ * 1. the TAN method in use;
74
+ * 2. the one-step method, as for a user whose methods are not known yet (PIN/TAN B.4.3.1), unless the bank has refused
75
+ * a one-step login;
76
+ * 3. another two-step method the user may use.
77
+ *
78
+ * Throws `FinTsUnsupportedError` if none does, before the bank could refuse the login.
79
+ */
80
+ synchronizationDialog(): ProtocolDialog<R, O> {
81
+ const method = this.method;
82
+ if (!this.lacksMedium(method)) return this.protocol.newDialog(method, this.state.tanMedium);
83
+ if (!this.oneStepRefused) return this.protocol.newDialog(TanMethod.oneStep);
84
+ const other = this.available().find((m) => !m.isOneStep && !this.lacksMedium(m));
85
+ if (other !== undefined) return this.protocol.newDialog(other);
86
+ throw new FinTsUnsupportedError(
87
+ `The bank refuses the one-step method, and the TAN method ${method.name} needs a TAN medium, which the client ` +
88
+ 'can look up only after the synchronization. Pass the medium to selectTanMethod, or choose another method',
89
+ );
90
+ }
91
+
92
+ /** Whether `method` needs a medium the user has not chosen. */
93
+ private lacksMedium(method: TanMethod): boolean {
94
+ return this.state.tanMedium === undefined && needsMediumChoice(method);
95
+ }
96
+
97
+ /**
98
+ * The two-step TAN methods the bank offers, the user may use and this client can run; the one-step method if there
99
+ * are none.
100
+ *
101
+ * Throws `FinTsUnsupportedError` if the bank offers the user nothing but methods the client cannot run.
102
+ */
103
+ available(): TanMethod[] {
104
+ const bpd = this.protocol.bankParameters;
105
+ if (bpd === undefined) return [TanMethod.oneStep];
106
+ const allowed = this.state.allowedMethods ?? this.protocol.userParameters?.allowedTanMethods;
107
+ const isAllowed = (securityFunction: string): boolean =>
108
+ allowed === undefined || allowed.length === 0 || allowed.includes(securityFunction);
109
+ const runnable = bpd.tanMethods.filter((m) => isAllowed(m.securityFunction));
110
+ if (runnable.length === 0 && bpd.unsupportedTanMethods.some(isAllowed)) {
111
+ throw new FinTsUnsupportedError(
112
+ 'The bank offers only TAN methods this client cannot run: process variant 1, or methods that need an SMS ' +
113
+ 'charge account or an HHD_UC answer',
114
+ );
115
+ }
116
+ return runnable.length === 0 ? [TanMethod.oneStep] : runnable;
117
+ }
118
+
119
+ /**
120
+ * Stops using `rejected`, the method of a login the bank refused; returns whether another available method is left.
121
+ *
122
+ * The user chooses that method, unless the one in use is still available: a refused one-step synchronization does
123
+ * not rule out the method the user chose before.
124
+ */
125
+ async replace(rejected: TanMethod): Promise<boolean> {
126
+ if (rejected.isOneStep) this.oneStepRefused = true;
127
+ const others = this.available().filter((m) => m.securityFunction !== rejected.securityFunction);
128
+ if (others.some((m) => m.securityFunction === this.state.tanMethod)) return true;
129
+ if (others.length === 0) return false;
130
+ this.state.useTanMethod((await this.prompts.chooseMethod(others)).securityFunction);
131
+ return true;
132
+ }
133
+
134
+ /** Keeps the chosen TAN method if it is still available, otherwise lets the user choose. */
135
+ async ensureMethodChosen(): Promise<void> {
136
+ const candidates = this.available();
137
+ const method =
138
+ candidates.find((m) => m.securityFunction === this.state.tanMethod) ??
139
+ (await this.prompts.chooseMethod(candidates));
140
+ if (method.securityFunction !== this.state.tanMethod) this.state.useTanMethod(method.securityFunction);
141
+ }
142
+
143
+ /**
144
+ * Whether the media of the method in use have to be looked up: it needs a medium the user has not chosen, and they
145
+ * were not looked up for it in this session.
146
+ */
147
+ get mediumToLookUp(): boolean {
148
+ const method = this.method;
149
+ return this.lacksMedium(method) && !this.mediaLookedUp.has(method.securityFunction);
150
+ }
151
+
152
+ /**
153
+ * Lets the user choose the medium of the method in use among the active ones in `media`, the result of a lookup;
154
+ * none where the lookup gave no list or no active medium.
155
+ */
156
+ async chooseMedium(media: TanMedium[] | undefined): Promise<void> {
157
+ const method = this.method;
158
+ this.lookedUpMedia = media;
159
+ this.state.useTanMedium(media === undefined ? undefined : await this.chooseMediumOf(method, media));
160
+ this.mediaLookedUp.add(method.securityFunction);
161
+ }
162
+
163
+ /**
164
+ * The list of the last lookup, once; `undefined` if there was none since the last time.
165
+ *
166
+ * `getTanMedia` takes it, so that the list is not asked for twice in a row. It also outlasts a call that failed for
167
+ * want of an active medium, so that the app can show the user the list that tells why; later calls ask anew.
168
+ */
169
+ takeLookedUpMedia(): TanMedium[] | undefined {
170
+ const media = this.lookedUpMedia;
171
+ this.lookedUpMedia = undefined;
172
+ return media;
173
+ }
174
+
175
+ /** The name of the medium the user chooses among the active ones in `media`; `undefined` if none is active. */
176
+ private async chooseMediumOf(method: TanMethod, media: readonly TanMedium[]): Promise<string | undefined> {
177
+ const active = media.filter((m) => m.isActive && m.name !== undefined);
178
+ // HITAB version 5 names the method each medium belongs to; a medium of another method would not do.
179
+ const ofMethod = active.filter((m) => m.securityFunction === method.securityFunction);
180
+ const candidates = ofMethod.length === 0 ? active : ofMethod;
181
+ if (candidates.length === 0) return undefined;
182
+ return (await this.prompts.chooseMedium(candidates)).name;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Whether messages signed with `method` must name the TAN medium: the method requires the name, and the user may have
188
+ * several media (PIN/TAN, HKTAN "Bezeichnung des TAN-Mediums").
189
+ */
190
+ function needsMediumChoice(method: TanMethod): boolean {
191
+ return method.needsMediumName && (method.activeMediaCount ?? 0) > 1;
192
+ }