@coderoftime/fints 0.1.0 → 0.2.0

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