@coderoftime/fints 0.2.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.
Files changed (215) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/README.md +31 -23
  3. package/dist/client.d.ts +3 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +52 -9
  6. package/dist/client.js.map +1 -1
  7. package/dist/core/account.d.ts +15 -4
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +84 -36
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +13 -3
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +42 -8
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +3 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +5 -0
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +20 -3
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +25 -4
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +42 -15
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +15 -7
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +29 -14
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +17 -5
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +25 -7
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +7 -0
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +10 -1
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +5 -0
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +12 -0
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +22 -7
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +51 -11
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +7 -4
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +53 -54
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +18 -6
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +83 -66
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts.map +1 -1
  54. package/dist/formats/readers.js +6 -2
  55. package/dist/formats/readers.js.map +1 -1
  56. package/dist/formats/sepa-purpose.d.ts +7 -4
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  58. package/dist/formats/sepa-purpose.js +18 -9
  59. package/dist/formats/sepa-purpose.js.map +1 -1
  60. package/dist/session/dialog-runner.d.ts +5 -2
  61. package/dist/session/dialog-runner.d.ts.map +1 -1
  62. package/dist/session/dialog-runner.js +23 -7
  63. package/dist/session/dialog-runner.js.map +1 -1
  64. package/dist/session/login-guard.d.ts +4 -4
  65. package/dist/session/login-guard.d.ts.map +1 -1
  66. package/dist/session/login-guard.js +29 -14
  67. package/dist/session/login-guard.js.map +1 -1
  68. package/dist/session/session-setup.d.ts +14 -5
  69. package/dist/session/session-setup.d.ts.map +1 -1
  70. package/dist/session/session-setup.js +30 -9
  71. package/dist/session/session-setup.js.map +1 -1
  72. package/dist/session/session-state.d.ts +4 -3
  73. package/dist/session/session-state.d.ts.map +1 -1
  74. package/dist/session/session-state.js +28 -9
  75. package/dist/session/session-state.js.map +1 -1
  76. package/dist/session/signed-dialog.d.ts +19 -13
  77. package/dist/session/signed-dialog.d.ts.map +1 -1
  78. package/dist/session/signed-dialog.js +45 -30
  79. package/dist/session/signed-dialog.js.map +1 -1
  80. package/dist/session/user-prompts.d.ts +6 -1
  81. package/dist/session/user-prompts.d.ts.map +1 -1
  82. package/dist/session/user-prompts.js +19 -4
  83. package/dist/session/user-prompts.js.map +1 -1
  84. package/dist/tan/challenge.d.ts +15 -13
  85. package/dist/tan/challenge.d.ts.map +1 -1
  86. package/dist/tan/challenge.js +32 -26
  87. package/dist/tan/challenge.js.map +1 -1
  88. package/dist/tan/flicker.d.ts +5 -1
  89. package/dist/tan/flicker.d.ts.map +1 -1
  90. package/dist/tan/flicker.js +42 -30
  91. package/dist/tan/flicker.js.map +1 -1
  92. package/dist/tan/tan-image.d.ts +4 -1
  93. package/dist/tan/tan-image.d.ts.map +1 -1
  94. package/dist/tan/tan-image.js +8 -2
  95. package/dist/tan/tan-image.js.map +1 -1
  96. package/dist/tan/tan-models.d.ts +9 -1
  97. package/dist/tan/tan-models.d.ts.map +1 -1
  98. package/dist/tan/tan-models.js +14 -2
  99. package/dist/tan/tan-models.js.map +1 -1
  100. package/dist/util/arguments.d.ts +13 -0
  101. package/dist/util/arguments.d.ts.map +1 -0
  102. package/dist/util/arguments.js +40 -0
  103. package/dist/util/arguments.js.map +1 -0
  104. package/dist/util/async.d.ts +4 -1
  105. package/dist/util/async.d.ts.map +1 -1
  106. package/dist/util/async.js +6 -3
  107. package/dist/util/async.js.map +1 -1
  108. package/dist/util/bytes.d.ts +1 -1
  109. package/dist/util/bytes.d.ts.map +1 -1
  110. package/dist/util/bytes.js +3 -3
  111. package/dist/util/bytes.js.map +1 -1
  112. package/dist/util/dates.d.ts +3 -10
  113. package/dist/util/dates.d.ts.map +1 -1
  114. package/dist/util/dates.js +49 -39
  115. package/dist/util/dates.js.map +1 -1
  116. package/dist/util/errors.d.ts +5 -3
  117. package/dist/util/errors.d.ts.map +1 -1
  118. package/dist/util/errors.js +8 -4
  119. package/dist/util/errors.js.map +1 -1
  120. package/dist/util/immutable-map.d.ts +5 -2
  121. package/dist/util/immutable-map.d.ts.map +1 -1
  122. package/dist/util/immutable-map.js +15 -12
  123. package/dist/util/immutable-map.js.map +1 -1
  124. package/dist/util/inspect.d.ts +11 -0
  125. package/dist/util/inspect.d.ts.map +1 -0
  126. package/dist/util/inspect.js +14 -0
  127. package/dist/util/inspect.js.map +1 -0
  128. package/dist/util/text.d.ts +18 -2
  129. package/dist/util/text.d.ts.map +1 -1
  130. package/dist/util/text.js +50 -5
  131. package/dist/util/text.js.map +1 -1
  132. package/dist/util/xml.d.ts +6 -4
  133. package/dist/util/xml.d.ts.map +1 -1
  134. package/dist/util/xml.js +106 -44
  135. package/dist/util/xml.js.map +1 -1
  136. package/dist/v3/bank-connection.d.ts +23 -3
  137. package/dist/v3/bank-connection.d.ts.map +1 -1
  138. package/dist/v3/bank-connection.js +66 -6
  139. package/dist/v3/bank-connection.js.map +1 -1
  140. package/dist/v3/dialog.d.ts +6 -2
  141. package/dist/v3/dialog.d.ts.map +1 -1
  142. package/dist/v3/dialog.js +20 -9
  143. package/dist/v3/dialog.js.map +1 -1
  144. package/dist/v3/message.d.ts +18 -10
  145. package/dist/v3/message.d.ts.map +1 -1
  146. package/dist/v3/message.js +92 -75
  147. package/dist/v3/message.js.map +1 -1
  148. package/dist/v3/parameter-store.d.ts +43 -7
  149. package/dist/v3/parameter-store.d.ts.map +1 -1
  150. package/dist/v3/parameter-store.js +69 -15
  151. package/dist/v3/parameter-store.js.map +1 -1
  152. package/dist/v3/parameters.d.ts +12 -11
  153. package/dist/v3/parameters.d.ts.map +1 -1
  154. package/dist/v3/parameters.js +57 -35
  155. package/dist/v3/parameters.js.map +1 -1
  156. package/dist/v3/protocol.d.ts +16 -9
  157. package/dist/v3/protocol.d.ts.map +1 -1
  158. package/dist/v3/protocol.js +57 -26
  159. package/dist/v3/protocol.js.map +1 -1
  160. package/dist/v3/queries.d.ts +2 -2
  161. package/dist/v3/queries.d.ts.map +1 -1
  162. package/dist/v3/queries.js +24 -16
  163. package/dist/v3/queries.js.map +1 -1
  164. package/dist/v3/segments.d.ts +18 -11
  165. package/dist/v3/segments.d.ts.map +1 -1
  166. package/dist/v3/segments.js +30 -21
  167. package/dist/v3/segments.js.map +1 -1
  168. package/dist/v3/syntax.d.ts +15 -11
  169. package/dist/v3/syntax.d.ts.map +1 -1
  170. package/dist/v3/syntax.js +30 -15
  171. package/dist/v3/syntax.js.map +1 -1
  172. package/package.json +1 -1
  173. package/src/client.ts +58 -17
  174. package/src/core/account.ts +91 -35
  175. package/src/core/amount.ts +42 -9
  176. package/src/core/balance.ts +6 -0
  177. package/src/core/bank-info.ts +28 -4
  178. package/src/core/client.ts +42 -15
  179. package/src/core/config.ts +37 -18
  180. package/src/core/errors.ts +27 -7
  181. package/src/core/feedback.ts +12 -1
  182. package/src/core/transaction.ts +13 -0
  183. package/src/core/transport.ts +62 -16
  184. package/src/formats/camt.ts +62 -51
  185. package/src/formats/mt940.ts +114 -78
  186. package/src/formats/readers.ts +6 -2
  187. package/src/formats/sepa-purpose.ts +17 -9
  188. package/src/session/dialog-runner.ts +34 -7
  189. package/src/session/login-guard.ts +29 -13
  190. package/src/session/session-setup.ts +27 -9
  191. package/src/session/session-state.ts +25 -8
  192. package/src/session/signed-dialog.ts +60 -30
  193. package/src/session/user-prompts.ts +28 -3
  194. package/src/tan/challenge.ts +50 -36
  195. package/src/tan/flicker.ts +40 -29
  196. package/src/tan/tan-image.ts +9 -2
  197. package/src/tan/tan-models.ts +16 -2
  198. package/src/util/arguments.ts +40 -0
  199. package/src/util/async.ts +10 -4
  200. package/src/util/bytes.ts +3 -3
  201. package/src/util/dates.ts +52 -38
  202. package/src/util/errors.ts +9 -4
  203. package/src/util/immutable-map.ts +15 -12
  204. package/src/util/inspect.ts +14 -0
  205. package/src/util/text.ts +47 -5
  206. package/src/util/xml.ts +105 -38
  207. package/src/v3/bank-connection.ts +65 -6
  208. package/src/v3/dialog.ts +18 -8
  209. package/src/v3/message.ts +81 -72
  210. package/src/v3/parameter-store.ts +82 -16
  211. package/src/v3/parameters.ts +68 -41
  212. package/src/v3/protocol.ts +61 -28
  213. package/src/v3/queries.ts +26 -18
  214. package/src/v3/segments.ts +32 -20
  215. package/src/v3/syntax.ts +31 -14
@@ -9,6 +9,13 @@ import type { SignedDialog } from './signed-dialog.js';
9
9
  /** Most pages the client fetches for one query, far more than banks send. */
10
10
  const maxPages = 1000;
11
11
 
12
+ /**
13
+ * Most further pages of one query that may ask for an approval, as its first page may: enough for a bank that asks anew
14
+ * for each page of a query of four pages, while the user approves four times per query at most, and loses the pages
15
+ * approved so far with any approval they cancel.
16
+ */
17
+ const maxPageApprovals = 3;
18
+
12
19
  /**
13
20
  * Most data one query may hold, summed over its pages as `IncomingMessage3.size` measures it: the transport and the
14
21
  * parser bound a single response only. The statements and transactions read from them have a budget of their own
@@ -70,20 +77,27 @@ export class DialogRunner {
70
77
  return parseBankData(() => query.read(pages));
71
78
  }
72
79
 
73
- /** Sends `order` in `dialog` and completes its approval, which only the `firstPage` of a query may ask for. */
74
- private async run(dialog: SignedDialog, order: SegmentSpec, firstPage: boolean): Promise<IncomingMessage3> {
80
+ /** Sends `order` in `dialog` and completes its approval, which `admitApproval` may refuse (`SignedDialog.send`). */
81
+ private async run(
82
+ dialog: SignedDialog,
83
+ order: SegmentSpec,
84
+ admitApproval: (() => void) | undefined,
85
+ ): Promise<IncomingMessage3> {
75
86
  try {
76
- return firstPage ? await dialog.send(order) : await dialog.sendFurtherPage(order);
87
+ return await dialog.send(order, admitApproval);
77
88
  } catch (error) {
78
89
  // The bank rejected the order and the dialog stays usable, or the bank ended the dialog or rejected the PIN and
79
- // the dialog is discarded already. A cancelled or failed approval, or a broken connection, leaves the dialog in an
80
- // unknown state.
90
+ // the dialog is discarded already. A cancelled, failed or refused approval, or a broken connection, leaves the
91
+ // dialog in an unknown state.
81
92
  if (!(error instanceof FinTsBankError)) await this.end();
82
93
  throw error;
83
94
  }
84
95
  }
85
96
 
86
- /** Sends the order built by `order` until the bank has delivered all pages (Formals B.6.3, "Aufsetzpunkt"). */
97
+ /**
98
+ * Sends the order built by `order` until the bank has delivered all pages (Formals B.6.3, "Aufsetzpunkt"), and
99
+ * completes the approval each asks for, that of `maxPageApprovals` further pages at most.
100
+ */
87
101
  private async fetchPages(
88
102
  dialog: SignedDialog,
89
103
  order: (continuation: string | undefined) => SegmentSpec,
@@ -91,9 +105,22 @@ export class DialogRunner {
91
105
  const pages: IncomingMessage3[] = [];
92
106
  const seen = new Set<string>();
93
107
  let size = 0;
108
+ let pageApprovals = 0;
109
+ const admitPageApproval = (): void => {
110
+ if (pageApprovals === maxPageApprovals) {
111
+ throw new FinTsProtocolError(
112
+ `The bank asks for an approval of more than ${maxPageApprovals} further pages of one query; ask for a shorter period`,
113
+ );
114
+ }
115
+ pageApprovals++;
116
+ };
94
117
  let continuation: string | undefined;
95
118
  for (;;) {
96
- const page = await this.run(dialog, order(continuation), continuation === undefined);
119
+ const page = await this.run(
120
+ dialog,
121
+ order(continuation),
122
+ continuation === undefined ? undefined : admitPageApproval,
123
+ );
97
124
  pages.push(page);
98
125
  size += page.size;
99
126
  if (size > maxQueryMebibytes * 1024 * 1024) {
@@ -42,9 +42,9 @@ const maxUnreadLogins = 2;
42
42
  * access. `FinTsLoginRefusedError` tells which logins count as refused; `judge` decides it for each answer.
43
43
  *
44
44
  * A refusal counts at once and fails closed: only `remedied` lifts it, within the call that met it (`runCall`), once
45
- * the session has taken care of a reason it names, each reason once per call, so that a call sends at most three
46
- * logins the bank refuses. A refusal that outlasts the call is final, and the guard forgets the PIN; only a PIN the
47
- * user enters anew lifts it (`replacePin`), unless it brings new login data.
45
+ * the session has taken care of a reason it names, or has failed the call trying, each reason once per call, so that a
46
+ * call sends at most three logins the bank refuses. A refusal that outlasts the call is final, and the guard forgets
47
+ * the PIN; only a PIN the user enters anew lifts it (`replacePin`), unless it brings new login data.
48
48
  */
49
49
  export class LoginGuard implements PinGuard {
50
50
  // ECMAScript private, so that neither `util.inspect` nor `JSON.stringify` of the client reaches it.
@@ -143,10 +143,17 @@ export class LoginGuard implements PinGuard {
143
143
  // Only the bank's refusal the guard holds names reasons; any other error, a second refusal too, fails the call.
144
144
  if (refusal === undefined || error !== refusal || rejected === undefined) throw error;
145
145
  const reason = remediableReasons(refusal, rejected).find((r) => !this.pardonedReasons.has(r));
146
- if (reason === undefined || !(await remedy(reason, rejected))) throw error;
146
+ if (reason === undefined) throw error;
147
147
  this.pardonedReasons.add(reason);
148
- // A closed client keeps the refusal.
149
- if (this.#pin !== undefined) this.liftRefusal();
148
+ // The refusal named a reason the session takes care of, not the PIN, so it does not outlast a remedy that fails,
149
+ // such as a TAN method prompt the app ends: the call fails with the remedy's own error. On a closed client the
150
+ // next attempt fails before it sends anything.
151
+ const remedied = await remedy(reason, rejected).catch((remedyError: unknown) => {
152
+ this.liftRefusal();
153
+ throw remedyError;
154
+ });
155
+ if (!remedied) throw error;
156
+ this.liftRefusal();
150
157
  }
151
158
  }
152
159
  }
@@ -172,9 +179,14 @@ export class LoginGuard implements PinGuard {
172
179
  return { refusal: this.refusalValue?.feedback, unreadLogins: this.unreadLogins };
173
180
  }
174
181
 
175
- /** Restores what `saveState` returned; a restored refusal is final. */
182
+ /** Restores what `saveState` returned; a restored refusal is final, also one without return codes. */
176
183
  restoreState(saved: LoginGuardState): void {
177
- if (saved.refusal !== undefined) this.refusalValue = loginRefusalFor(saved.refusal);
184
+ if (saved.refusal !== undefined) {
185
+ this.refusalValue =
186
+ saved.refusal.length > 0
187
+ ? loginRefusalFor(saved.refusal)
188
+ : new FinTsLoginRefusedError([], 'A login was refused, and the saved state no longer tells why');
189
+ }
178
190
  this.unreadLogins = Math.max(0, saved.unreadLogins);
179
191
  this.forgetPinIfRefused();
180
192
  }
@@ -201,8 +213,11 @@ function remediableReasons(refusal: FinTsLoginRefusedError, method: TanMethod):
201
213
  return reasons;
202
214
  }
203
215
 
204
- /** Error codes that only say that the message failed, not why: 9050 (the message contains errors) and 9800. */
205
- const unspecificErrors = new Set(['9050', FeedbackCodes.dialogAborted]);
216
+ /**
217
+ * Error codes that cannot be about the PIN: 9050 (the message contains errors) and 9800, which only say that the
218
+ * message failed, and 9955 (the one-step method is not allowed).
219
+ */
220
+ const errorsNotAboutThePin = new Set(['9050', FeedbackCodes.dialogAborted, '9955']);
206
221
 
207
222
  /**
208
223
  * Whether `feedback`, which refuses a login signed with `method`, names the method as the reason: a 3920 that lists the
@@ -210,12 +225,13 @@ const unspecificErrors = new Set(['9050', FeedbackCodes.dialogAborted]);
210
225
  *
211
226
  * Only the return codes of the refusal itself count: TAN methods listed earlier say nothing about this login. Banks
212
227
  * list two-step methods only, so a 3920 always leaves the one-step method out, also where the login succeeds. After
213
- * the one-step method a 3920 counts only where the bank names no other error than an unspecific one: any other, such
214
- * as "Anmeldung fehlgeschlagen", may be about the PIN.
228
+ * the one-step method a 3920 counts only where the bank names no other error than one not about the PIN: any other,
229
+ * such as "Anmeldung fehlgeschlagen", may be about it. PIN/TAN B.8.2 has the bank answer a one-step dialog of a user
230
+ * bound to two-step methods with 9955 and 3920.
215
231
  */
216
232
  function refusesMethod(feedback: readonly FinTsFeedback[], method: TanMethod): boolean {
217
233
  if (method.isOneStep && hasCode(feedback, FeedbackCodes.scaRequired)) return true;
218
234
  const allowed = allowedTanMethodsIn(feedback);
219
235
  if (allowed.length === 0 || allowed.includes(method.securityFunction)) return false;
220
- return !method.isOneStep || feedback.every((f) => !f.isError || unspecificErrors.has(f.code));
236
+ return !method.isOneStep || feedback.every((f) => !f.isError || errorsNotAboutThePin.has(f.code));
221
237
  }
@@ -15,7 +15,8 @@ import type { TanSelection } from './tan-selection.js';
15
15
  * What has to be settled before a login: the bank parameter data, the synchronization, and through `TanSelection` the
16
16
  * TAN method and medium. Each of the dialogs this takes serves one purpose only.
17
17
  *
18
- * Where the bank refuses a login for a reason the session can take care of, `remedied` does so and logs in again.
18
+ * Where the bank refuses a login for a reason the session can take care of, `remedied` does so and logs in again; so it
19
+ * does where the limits of bank parameter data that may be outdated keep a login from going out.
19
20
  */
20
21
  export class SessionSetup {
21
22
  constructor(
@@ -59,9 +60,24 @@ export class SessionSetup {
59
60
  return new SignedDialog(this.tans.newDialog(), this.session);
60
61
  }
61
62
 
62
- /** Runs `attempt`, which may log in, once more after each refused login `remedy` takes care of. */
63
- remedied<T>(attempt: () => Promise<T>): Promise<T> {
64
- return this.session.guard.remedied(attempt, (reason, rejected) => this.remedy(reason, rejected));
63
+ /**
64
+ * Runs `attempt`, which may log in, once more after each refused login `remedy` takes care of.
65
+ *
66
+ * Where the limits of the bank parameter data kept a login from going out (`FinTs3Protocol.stoppedLogin`), it fetches
67
+ * them anew in an anonymous dialog, once, and runs `attempt` once more: they may be outdated, as saved ones are, and
68
+ * new ones would come only in answer to the login they rule out. The new limits judge the login built anew, not the
69
+ * one stopped: it reports version 0 for the parameter data they stand in for, so it may be shorter.
70
+ */
71
+ async remedied<T>(attempt: () => Promise<T>): Promise<T> {
72
+ const run = (): Promise<T> =>
73
+ this.session.guard.remedied(attempt, (reason, rejected) => this.remedy(reason, rejected));
74
+ try {
75
+ return await run();
76
+ } catch (error) {
77
+ if (!this.protocol.stoppedLogin(error)) throw error;
78
+ await this.fetchBankParametersAnonymously(true);
79
+ return run();
80
+ }
65
81
  }
66
82
 
67
83
  /**
@@ -97,14 +113,16 @@ export class SessionSetup {
97
113
 
98
114
  /**
99
115
  * Asks for the bank parameter data without logging in, keeps them where there are none from a dialog signed with the
100
- * PIN (`ParameterStore.keepAnonymous`), and passes the bank's messages on: all an anonymous dialog tells, since the
101
- * TAN methods it lists are not those of a user. A bank that does not answer anonymously is no error, but a failed
102
- * connection is: the synchronization would only wait for the same server again.
116
+ * PIN (`ParameterStore.keepAnonymous`), or with `refresh` their limits only (`ParameterStore.keepRefreshedLimits`),
117
+ * and passes the bank's messages on: all an anonymous dialog tells, since the TAN methods it lists are not those of a
118
+ * user. A bank that does not answer anonymously is no error, but a failed connection is: the synchronization would
119
+ * only wait for the same server again.
103
120
  */
104
- private async fetchBankParametersAnonymously(): Promise<void> {
121
+ private async fetchBankParametersAnonymously(refresh = false): Promise<void> {
105
122
  try {
106
123
  const response = await this.requestBankParametersAnonymously();
107
- this.session.parameters.keepAnonymous(response);
124
+ if (refresh) this.session.parameters.keepRefreshedLimits(response);
125
+ else this.session.parameters.keepAnonymous(response);
108
126
  for (const message of response.bankMessages) this.session.prompts.showBankMessage(message);
109
127
  } catch (error) {
110
128
  // Optional: the login delivers the parameters as well.
@@ -38,23 +38,27 @@ export function saveSession(parameters: ParameterStore, guard: LoginGuard, sessi
38
38
  * The session saved in `json`, and the parameter data and the guard's refused login restored from it.
39
39
  *
40
40
  * A missing or damaged state, or one saved for another user, bank, server or protocol version, gives a new session,
41
- * which only costs a new synchronization. The refused login belongs to the user at the bank, so it comes first: it holds
42
- * for another server or protocol version as well, and where the rest is damaged; a state saved by the Dart package or
43
- * an earlier version has none.
41
+ * which only costs a new synchronization. The refused login and the count of logins whose answer was lost belong to the
42
+ * user at the bank, so they come first: they hold for another server or protocol version as well, and where the rest is
43
+ * damaged; a state saved by fints-ts 0.1 or the Dart package 0.2.0 has neither. A damaged section of their own fails
44
+ * closed: it restores a refusal without return codes, since it may have held one.
44
45
  */
45
46
  export function restoreSession(json: string | undefined, parameters: ParameterStore, guard: LoginGuard): SessionState {
46
47
  if (json === undefined) return new SessionState();
47
48
  try {
48
49
  const saved: unknown = JSON.parse(json);
49
50
  if (!isJsonObject(saved) || saved.format !== stateFormat) return new SessionState();
50
- const [server, ...user] = readOptionalList(saved, 'owner', isString) ?? [];
51
+ // Entries of another type differ like any other value, so that a damaged server entry keeps the refused login.
52
+ const owner: unknown = saved.owner ?? [];
53
+ if (!Array.isArray(owner)) return new SessionState();
54
+ const [server, ...user] = owner as unknown[];
51
55
  const expected = userOf(parameters.config);
52
56
  if (user.length !== expected.length || user.some((value, i) => value !== expected[i])) return new SessionState();
53
- const loginGuard = readOptionalObject(saved, 'loginGuard');
54
- if (loginGuard !== undefined) guard.restoreState(guardStateFromJson(loginGuard));
57
+ const guardState = savedGuardState(saved);
58
+ if (guardState !== undefined) guard.restoreState(guardState);
55
59
  if (server !== hostOf(parameters.config.url) || saved.version !== protocolVersion) return new SessionState();
56
60
  const session = SessionState.fromJson(readObject(saved, 'session'));
57
- parameters.restoreState(readObject(saved, 'protocol'));
61
+ parameters.restoreState(readObject(saved, 'protocol'), session.tanMedium);
58
62
  return session;
59
63
  } catch (error) {
60
64
  // Damaged JSON, base64 or parameter data, and values of the wrong type, only cost a new synchronization.
@@ -137,7 +141,9 @@ export class SessionState {
137
141
  const state = new SessionState();
138
142
  state.useTanMethod(readOptionalString(json, 'tanMethod'), readOptionalString(json, 'tanMedium'));
139
143
  if (allowedMethods !== undefined) state.rememberAllowedMethods(allowedMethods);
140
- if (sepaAccounts !== undefined) state.cacheSepaAccounts(sepaAccounts, sepaUpdVersion);
144
+ // fints-ts 0.1 and the Dart package 0.2.0 saved an empty list as well, which completes nothing; without it, the
145
+ // list is asked for again.
146
+ if (sepaAccounts !== undefined && sepaAccounts.length > 0) state.cacheSepaAccounts(sepaAccounts, sepaUpdVersion);
141
147
  return state;
142
148
  }
143
149
 
@@ -181,6 +187,17 @@ function guardStateToJson(state: LoginGuardState): JsonObject {
181
187
  return { refusal: state.refusal?.map(feedbackToJson) ?? null, unreadLogins: state.unreadLogins };
182
188
  }
183
189
 
190
+ /** The guard's section of `saved`, `undefined` where there is none; a damaged one is a refusal without return codes. */
191
+ function savedGuardState(saved: JsonObject): LoginGuardState | undefined {
192
+ try {
193
+ const json = readOptionalObject(saved, 'loginGuard');
194
+ return json === undefined ? undefined : guardStateFromJson(json);
195
+ } catch (error) {
196
+ if (error instanceof FormatError) return { refusal: [], unreadLogins: 0 };
197
+ throw error;
198
+ }
199
+ }
200
+
184
201
  function guardStateFromJson(json: JsonObject): LoginGuardState {
185
202
  return {
186
203
  refusal: readOptionalList(json, 'refusal', isJsonObject)?.map(feedbackFromJson),
@@ -1,13 +1,23 @@
1
1
  import { requireLatin1 } from '../core/config.js';
2
2
  import {
3
3
  bankErrorFor,
4
+ FinTsBankError,
4
5
  FinTsLoginDataChangedError,
6
+ FinTsLoginRefusedError,
5
7
  FinTsProtocolError,
6
8
  FinTsTanCancelledError,
7
9
  } from '../core/errors.js';
8
- import { allowedTanMethodsIn, FeedbackCodes, hasCode, hasErrors, userWarnings } from '../core/feedback.js';
10
+ import {
11
+ allowedTanMethodsIn,
12
+ FeedbackCodes,
13
+ hasCode,
14
+ hasErrors,
15
+ userWarnings,
16
+ type FinTsFeedback,
17
+ } from '../core/feedback.js';
9
18
  import type { TanMethod } from '../tan/tan-models.js';
10
19
  import { invalidArgument } from '../util/errors.js';
20
+ import { trimBlanks, withAsciiForms } from '../util/text.js';
11
21
  import type { Approval, FinTs3Dialog } from '../v3/dialog.js';
12
22
  import { IncomingMessage3 } from '../v3/message.js';
13
23
  import type { ParameterStore } from '../v3/parameter-store.js';
@@ -43,8 +53,8 @@ export class SignedDialog {
43
53
  }
44
54
 
45
55
  /** Logs in, which opens the dialog; with `synchronize` the bank also assigns a customer system ID. */
46
- async login(synchronize = false): Promise<IncomingMessage3> {
47
- return this.settle(await this.dialog.start(synchronize), 'login');
56
+ login(synchronize = false): Promise<IncomingMessage3> {
57
+ return this.settleLogin(() => this.dialog.start(synchronize));
48
58
  }
49
59
 
50
60
  /**
@@ -53,27 +63,16 @@ export class SignedDialog {
53
63
  */
54
64
  async loginWith(order: SegmentSpec): Promise<IncomingMessage3[]> {
55
65
  const start = this.dialog.startWith(order);
56
- const login = await this.settle(await start.login(), 'login');
66
+ const login = await this.settleLogin(start.login);
57
67
  return [login, await this.settle(await start.order(), 'order')];
58
68
  }
59
69
 
60
- /** Sends `order`, the first page of a query, and completes the approval the bank asks for. */
61
- async send(order: SegmentSpec): Promise<IncomingMessage3> {
62
- return this.settle(await this.dialog.send(order), 'order');
63
- }
64
-
65
70
  /**
66
- * Sends `order` for a further page of a query (Formals B.6.3), whose answer must not ask for an approval: the client
67
- * completes that of the first page only, so that a bank that asks anew for every page cannot prompt the user once per
68
- * page. Throws `FinTsProtocolError` if it asks for one.
71
+ * Sends `order` and completes the approval the bank asks for. `admitApproval` runs before the user is asked, and
72
+ * refuses the approval by throwing.
69
73
  */
70
- async sendFurtherPage(order: SegmentSpec): Promise<IncomingMessage3> {
71
- const response = await this.dialog.send(order);
72
- this.receive(response, 'order', false);
73
- if (this.dialog.approvalAskedIn(response) !== undefined) {
74
- throw new FinTsProtocolError('The bank asks for an approval of a further page of the same query');
75
- }
76
- return response;
74
+ async send(order: SegmentSpec, admitApproval?: () => void): Promise<IncomingMessage3> {
75
+ return this.settle(await this.dialog.send(order), 'order', admitApproval);
77
76
  }
78
77
 
79
78
  /**
@@ -104,32 +103,63 @@ export class SignedDialog {
104
103
  }
105
104
 
106
105
  /**
107
- * Where `response` answers a login, a request of `kind` other than `order`, or its approval with new login data: ends
108
- * the dialog, stops the client from logging in with the old ones, and fails the call with
109
- * `FinTsLoginDataChangedError` (Rückmeldungscodes C.6). It runs before any approval, which would cost the user a TAN
110
- * for a session that ends anyway. New login data next to an error are the login's refusal already
111
- * (`loginRefusalFor`).
106
+ * Sends the login `send` and settles its answer. New login data next to an error in the answer to its approval are
107
+ * retired all the same (`retireLoginData`), although the guard counts that error as no refusal; next to an error in
108
+ * the answer to the login they are its refusal already (`loginRefusalFor`).
109
+ */
110
+ private async settleLogin(send: () => Promise<IncomingMessage3>): Promise<IncomingMessage3> {
111
+ try {
112
+ return await this.settle(await send(), 'login');
113
+ } catch (error) {
114
+ if (
115
+ !(error instanceof FinTsBankError) ||
116
+ error instanceof FinTsLoginRefusedError ||
117
+ !hasCode(error.feedback, FeedbackCodes.newLoginData)
118
+ ) {
119
+ throw error;
120
+ }
121
+ return this.retireLoginData(error.feedback, 'approval');
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Where `response` answers a login, a request of `kind` other than `order`, or its approval with new login data,
127
+ * retires them (`retireLoginData`). It runs before any approval, which would cost the user a TAN for a session that
128
+ * ends anyway.
112
129
  */
113
130
  private async retireOldLoginData(response: IncomingMessage3, kind: RequestKind): Promise<void> {
114
131
  if (kind === 'order' || !hasCode(response.feedback, FeedbackCodes.newLoginData)) return;
132
+ await this.retireLoginData(response.feedback, kind);
133
+ }
134
+
135
+ /**
136
+ * Ends the dialog, whose answer to a request of `kind` brought new login data with `feedback`, stops the client from
137
+ * logging in with the old ones, and fails the call with `FinTsLoginDataChangedError` (Rückmeldungscodes C.6).
138
+ */
139
+ private async retireLoginData(feedback: readonly FinTsFeedback[], kind: RequestKind): Promise<never> {
115
140
  await this.end();
116
- const error = new FinTsLoginDataChangedError(response.feedback);
141
+ const error = new FinTsLoginDataChangedError(feedback);
117
142
  throw this.session.guard.judge(error, kind, this.dialog.method) ?? error;
118
143
  }
119
144
 
120
145
  /**
121
- * Receives `response`, the answer to a request of `kind`, then completes the approval it asks for; returns it joined
122
- * with the response that completes the approval.
146
+ * Receives `response`, the answer to a request of `kind`, then completes the approval it asks for, once
147
+ * `admitApproval` has let it; returns it joined with the response that completes the approval.
123
148
  *
124
149
  * Throws `FinTsProtocolError` if that response asks for an approval once more: the bank has not executed the message
125
150
  * then either, and read as done it would report an empty result.
126
151
  */
127
- private async settle(response: IncomingMessage3, kind: RequestKind): Promise<IncomingMessage3> {
152
+ private async settle(
153
+ response: IncomingMessage3,
154
+ kind: RequestKind,
155
+ admitApproval?: () => void,
156
+ ): Promise<IncomingMessage3> {
128
157
  const opening = kind !== 'order';
129
158
  this.receive(response, kind, opening);
130
159
  await this.retireOldLoginData(response, kind);
131
160
  const approval = this.dialog.approvalAskedIn(response);
132
161
  if (approval === undefined) return response;
162
+ admitApproval?.();
133
163
  const approved = approval.challenge.isDecoupled
134
164
  ? await this.awaitAppApproval(approval, opening)
135
165
  : await this.enterTan(approval, opening);
@@ -154,8 +184,8 @@ export class SignedDialog {
154
184
  if (answer.kind === 'approved') {
155
185
  throw invalidArgument('Answered a TAN challenge with an approval in the banking app', 'tanCallback');
156
186
  }
157
- // NFKC, so that digits of another width, such as those of a Japanese input method, go out as the digits they are.
158
- const tan = answer.kind === 'tan' ? answer.tan.normalize('NFKC').trim() : '';
187
+ // Not NFKC, which also changes characters FinTS 3.0 sends as they are, such as `²`, in a TAN the bank compares.
188
+ const tan = answer.kind === 'tan' ? trimBlanks(withAsciiForms(answer.tan)) : '';
159
189
  if (tan === '') throw new FinTsTanCancelledError();
160
190
  const response = await approval.submitTan(requireTanOf(approval.challenge.method, tan));
161
191
  this.receive(response, 'approval', opening);
@@ -4,9 +4,16 @@ import { clientClosedError } from '../core/errors.js';
4
4
  import type { FinTsFeedback } from '../core/feedback.js';
5
5
  import { TanAnswer, type TanChallenge } from '../tan/challenge.js';
6
6
  import type { TanMedium, TanMethod } from '../tan/tan-models.js';
7
+ import { optionalFunction, requireFunction } from '../util/arguments.js';
7
8
  import { Deferred } from '../util/async.js';
8
9
  import { invalidArgument } from '../util/errors.js';
9
10
 
11
+ /** The callbacks of `FinTsClientOptions`. */
12
+ type Callbacks = Pick<
13
+ FinTsClientOptions,
14
+ 'tanCallback' | 'tanMethodSelector' | 'tanMediumSelector' | 'onBankMessage' | 'onWarning' | 'logger'
15
+ >;
16
+
10
17
  /**
11
18
  * Everything a session tells the app, asks of it or waits for: the bank's messages, warnings and log, the TAN callback,
12
19
  * the selectors and the pauses between status requests.
@@ -23,7 +30,25 @@ export class UserPrompts {
23
30
  /** Aborts the waits under way; each wait removes its abort when it is over, or when nothing waits for it any more. */
24
31
  private readonly aborts = new Set<() => void>();
25
32
 
26
- constructor(private readonly options: FinTsClientOptions) {}
33
+ /** The callbacks as the client was built with them, as in the Dart package, whose options are final. */
34
+ private readonly options: Callbacks;
35
+
36
+ /**
37
+ * Throws `TypeError` for a `tanCallback` that is no function, and for another callback that is neither a function
38
+ * nor `undefined` or `null`, which mean none.
39
+ */
40
+ constructor(options: FinTsClientOptions) {
41
+ const tanCallback = requireFunction(options.tanCallback, 'tanCallback');
42
+ this.options = {
43
+ // Called on the app's options, which a callback written as a method of them may refer to as `this`.
44
+ tanCallback: (challenge) => tanCallback.call(options, challenge),
45
+ tanMethodSelector: optionalFunction(options.tanMethodSelector, 'tanMethodSelector'),
46
+ tanMediumSelector: optionalFunction(options.tanMediumSelector, 'tanMediumSelector'),
47
+ onBankMessage: optionalFunction(options.onBankMessage, 'onBankMessage'),
48
+ onWarning: optionalFunction(options.onWarning, 'onWarning'),
49
+ logger: optionalFunction(options.logger, 'logger'),
50
+ };
51
+ }
27
52
 
28
53
  /** Ends every wait; the client calls it once. */
29
54
  close(): void {
@@ -121,7 +146,7 @@ export class UserPrompts {
121
146
  const selector = this.options.tanMethodSelector;
122
147
  if (candidates.length === 1 || selector === undefined) return candidates[0]!;
123
148
  const chosen = await this.unlessClosed(() => selector(candidates));
124
- if (chosen === undefined) return candidates[0]!;
149
+ if (chosen === undefined || chosen === null) return candidates[0]!;
125
150
  const match = candidates.find((m) => m.securityFunction === chosen.securityFunction);
126
151
  if (match === undefined) throw invalidArgument('Returned a TAN method the bank did not offer', 'tanMethodSelector');
127
152
  return match;
@@ -136,7 +161,7 @@ export class UserPrompts {
136
161
  const selector = this.options.tanMediumSelector;
137
162
  if (media.length === 1 || selector === undefined) return media[0]!;
138
163
  const chosen = await this.unlessClosed(() => selector(media));
139
- if (chosen === undefined) return media[0]!;
164
+ if (chosen === undefined || chosen === null) return media[0]!;
140
165
  const match = media.find((m) => m.name === chosen.name);
141
166
  if (match === undefined) throw invalidArgument('Returned a TAN medium the bank did not list', 'tanMediumSelector');
142
167
  return match;