@coderoftime/fints 0.0.0-stage → 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 (249) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +28 -0
  3. package/README.md +246 -2
  4. package/dist/client.d.ts +48 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +199 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/core/account.d.ts +168 -0
  9. package/dist/core/account.d.ts.map +1 -0
  10. package/dist/core/account.js +245 -0
  11. package/dist/core/account.js.map +1 -0
  12. package/dist/core/amount.d.ts +63 -0
  13. package/dist/core/amount.d.ts.map +1 -0
  14. package/dist/core/amount.js +192 -0
  15. package/dist/core/amount.js.map +1 -0
  16. package/dist/core/balance.d.ts +55 -0
  17. package/dist/core/balance.d.ts.map +1 -0
  18. package/dist/core/balance.js +54 -0
  19. package/dist/core/balance.js.map +1 -0
  20. package/dist/core/bank-info.d.ts +49 -0
  21. package/dist/core/bank-info.d.ts.map +1 -0
  22. package/dist/core/bank-info.js +52 -0
  23. package/dist/core/bank-info.js.map +1 -0
  24. package/dist/core/client.d.ts +161 -0
  25. package/dist/core/client.d.ts.map +1 -0
  26. package/dist/core/client.js +2 -0
  27. package/dist/core/client.js.map +1 -0
  28. package/dist/core/config.d.ts +80 -0
  29. package/dist/core/config.d.ts.map +1 -0
  30. package/dist/core/config.js +153 -0
  31. package/dist/core/config.js.map +1 -0
  32. package/dist/core/errors.d.ts +153 -0
  33. package/dist/core/errors.d.ts.map +1 -0
  34. package/dist/core/errors.js +247 -0
  35. package/dist/core/errors.js.map +1 -0
  36. package/dist/core/feedback.d.ts +72 -0
  37. package/dist/core/feedback.d.ts.map +1 -0
  38. package/dist/core/feedback.js +127 -0
  39. package/dist/core/feedback.js.map +1 -0
  40. package/dist/core/transaction.d.ts +103 -0
  41. package/dist/core/transaction.d.ts.map +1 -0
  42. package/dist/core/transaction.js +96 -0
  43. package/dist/core/transaction.js.map +1 -0
  44. package/dist/core/transport.d.ts +64 -0
  45. package/dist/core/transport.d.ts.map +1 -0
  46. package/dist/core/transport.js +149 -0
  47. package/dist/core/transport.js.map +1 -0
  48. package/dist/formats/camt.d.ts +32 -0
  49. package/dist/formats/camt.d.ts.map +1 -0
  50. package/dist/formats/camt.js +242 -0
  51. package/dist/formats/camt.js.map +1 -0
  52. package/dist/formats/mt940.d.ts +54 -0
  53. package/dist/formats/mt940.d.ts.map +1 -0
  54. package/dist/formats/mt940.js +376 -0
  55. package/dist/formats/mt940.js.map +1 -0
  56. package/dist/formats/readers.d.ts +15 -0
  57. package/dist/formats/readers.d.ts.map +1 -0
  58. package/dist/formats/readers.js +42 -0
  59. package/dist/formats/readers.js.map +1 -0
  60. package/dist/formats/record-budget.d.ts +7 -0
  61. package/dist/formats/record-budget.d.ts.map +1 -0
  62. package/dist/formats/record-budget.js +17 -0
  63. package/dist/formats/record-budget.js.map +1 -0
  64. package/dist/formats/sepa-purpose.d.ts +28 -0
  65. package/dist/formats/sepa-purpose.d.ts.map +1 -0
  66. package/dist/formats/sepa-purpose.js +89 -0
  67. package/dist/formats/sepa-purpose.js.map +1 -0
  68. package/dist/index.d.ts +28 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +27 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/session/app-approval.d.ts +66 -0
  73. package/dist/session/app-approval.d.ts.map +1 -0
  74. package/dist/session/app-approval.js +226 -0
  75. package/dist/session/app-approval.js.map +1 -0
  76. package/dist/session/dialog-runner.d.ts +31 -0
  77. package/dist/session/dialog-runner.d.ts.map +1 -0
  78. package/dist/session/dialog-runner.js +111 -0
  79. package/dist/session/dialog-runner.js.map +1 -0
  80. package/dist/session/login-guard.d.ts +78 -0
  81. package/dist/session/login-guard.d.ts.map +1 -0
  82. package/dist/session/login-guard.js +181 -0
  83. package/dist/session/login-guard.js.map +1 -0
  84. package/dist/session/sepa-accounts.d.ts +40 -0
  85. package/dist/session/sepa-accounts.d.ts.map +1 -0
  86. package/dist/session/sepa-accounts.js +112 -0
  87. package/dist/session/sepa-accounts.js.map +1 -0
  88. package/dist/session/session-setup.d.ts +80 -0
  89. package/dist/session/session-setup.d.ts.map +1 -0
  90. package/dist/session/session-setup.js +182 -0
  91. package/dist/session/session-setup.js.map +1 -0
  92. package/dist/session/session-state.d.ts +50 -0
  93. package/dist/session/session-state.d.ts.map +1 -0
  94. package/dist/session/session-state.js +182 -0
  95. package/dist/session/session-state.js.map +1 -0
  96. package/dist/session/signed-dialog.d.ts +77 -0
  97. package/dist/session/signed-dialog.d.ts.map +1 -0
  98. package/dist/session/signed-dialog.js +170 -0
  99. package/dist/session/signed-dialog.js.map +1 -0
  100. package/dist/session/tan-selection.d.ts +96 -0
  101. package/dist/session/tan-selection.d.ts.map +1 -0
  102. package/dist/session/tan-selection.js +189 -0
  103. package/dist/session/tan-selection.js.map +1 -0
  104. package/dist/session/user-prompts.d.ts +65 -0
  105. package/dist/session/user-prompts.d.ts.map +1 -0
  106. package/dist/session/user-prompts.js +184 -0
  107. package/dist/session/user-prompts.js.map +1 -0
  108. package/dist/tan/challenge.d.ts +135 -0
  109. package/dist/tan/challenge.d.ts.map +1 -0
  110. package/dist/tan/challenge.js +184 -0
  111. package/dist/tan/challenge.js.map +1 -0
  112. package/dist/tan/flicker.d.ts +42 -0
  113. package/dist/tan/flicker.d.ts.map +1 -0
  114. package/dist/tan/flicker.js +296 -0
  115. package/dist/tan/flicker.js.map +1 -0
  116. package/dist/tan/tan-image.d.ts +22 -0
  117. package/dist/tan/tan-image.d.ts.map +1 -0
  118. package/dist/tan/tan-image.js +43 -0
  119. package/dist/tan/tan-image.js.map +1 -0
  120. package/dist/tan/tan-models.d.ts +149 -0
  121. package/dist/tan/tan-models.d.ts.map +1 -0
  122. package/dist/tan/tan-models.js +153 -0
  123. package/dist/tan/tan-models.js.map +1 -0
  124. package/dist/util/async.d.ts +22 -0
  125. package/dist/util/async.d.ts.map +1 -0
  126. package/dist/util/async.js +45 -0
  127. package/dist/util/async.js.map +1 -0
  128. package/dist/util/bytes.d.ts +22 -0
  129. package/dist/util/bytes.d.ts.map +1 -0
  130. package/dist/util/bytes.js +128 -0
  131. package/dist/util/bytes.js.map +1 -0
  132. package/dist/util/call-context.d.ts +12 -0
  133. package/dist/util/call-context.d.ts.map +1 -0
  134. package/dist/util/call-context.js +30 -0
  135. package/dist/util/call-context.js.map +1 -0
  136. package/dist/util/dates.d.ts +55 -0
  137. package/dist/util/dates.d.ts.map +1 -0
  138. package/dist/util/dates.js +189 -0
  139. package/dist/util/dates.js.map +1 -0
  140. package/dist/util/errors.d.ts +17 -0
  141. package/dist/util/errors.d.ts.map +1 -0
  142. package/dist/util/errors.js +24 -0
  143. package/dist/util/errors.js.map +1 -0
  144. package/dist/util/immutable-map.d.ts +16 -0
  145. package/dist/util/immutable-map.d.ts.map +1 -0
  146. package/dist/util/immutable-map.js +38 -0
  147. package/dist/util/immutable-map.js.map +1 -0
  148. package/dist/util/json.d.ts +17 -0
  149. package/dist/util/json.d.ts.map +1 -0
  150. package/dist/util/json.js +62 -0
  151. package/dist/util/json.js.map +1 -0
  152. package/dist/util/text.d.ts +37 -0
  153. package/dist/util/text.d.ts.map +1 -0
  154. package/dist/util/text.js +107 -0
  155. package/dist/util/text.js.map +1 -0
  156. package/dist/util/xml.d.ts +36 -0
  157. package/dist/util/xml.d.ts.map +1 -0
  158. package/dist/util/xml.js +194 -0
  159. package/dist/util/xml.js.map +1 -0
  160. package/dist/v3/account-extension.d.ts +25 -0
  161. package/dist/v3/account-extension.d.ts.map +1 -0
  162. package/dist/v3/account-extension.js +173 -0
  163. package/dist/v3/account-extension.js.map +1 -0
  164. package/dist/v3/bank-connection.d.ts +34 -0
  165. package/dist/v3/bank-connection.d.ts.map +1 -0
  166. package/dist/v3/bank-connection.js +58 -0
  167. package/dist/v3/bank-connection.js.map +1 -0
  168. package/dist/v3/dialog.d.ts +112 -0
  169. package/dist/v3/dialog.d.ts.map +1 -0
  170. package/dist/v3/dialog.js +241 -0
  171. package/dist/v3/dialog.js.map +1 -0
  172. package/dist/v3/message.d.ts +85 -0
  173. package/dist/v3/message.d.ts.map +1 -0
  174. package/dist/v3/message.js +303 -0
  175. package/dist/v3/message.js.map +1 -0
  176. package/dist/v3/parameter-store.d.ts +75 -0
  177. package/dist/v3/parameter-store.d.ts.map +1 -0
  178. package/dist/v3/parameter-store.js +143 -0
  179. package/dist/v3/parameter-store.js.map +1 -0
  180. package/dist/v3/parameters.d.ts +105 -0
  181. package/dist/v3/parameters.d.ts.map +1 -0
  182. package/dist/v3/parameters.js +450 -0
  183. package/dist/v3/parameters.js.map +1 -0
  184. package/dist/v3/protocol.d.ts +61 -0
  185. package/dist/v3/protocol.d.ts.map +1 -0
  186. package/dist/v3/protocol.js +113 -0
  187. package/dist/v3/protocol.js.map +1 -0
  188. package/dist/v3/queries.d.ts +51 -0
  189. package/dist/v3/queries.d.ts.map +1 -0
  190. package/dist/v3/queries.js +120 -0
  191. package/dist/v3/queries.js.map +1 -0
  192. package/dist/v3/segments.d.ts +106 -0
  193. package/dist/v3/segments.d.ts.map +1 -0
  194. package/dist/v3/segments.js +325 -0
  195. package/dist/v3/segments.js.map +1 -0
  196. package/dist/v3/syntax.d.ts +127 -0
  197. package/dist/v3/syntax.d.ts.map +1 -0
  198. package/dist/v3/syntax.js +367 -0
  199. package/dist/v3/syntax.js.map +1 -0
  200. package/package.json +67 -4
  201. package/src/client.ts +252 -0
  202. package/src/core/account.ts +323 -0
  203. package/src/core/amount.ts +211 -0
  204. package/src/core/balance.ts +85 -0
  205. package/src/core/bank-info.ts +66 -0
  206. package/src/core/client.ts +186 -0
  207. package/src/core/config.ts +182 -0
  208. package/src/core/errors.ts +277 -0
  209. package/src/core/feedback.ts +140 -0
  210. package/src/core/transaction.ts +161 -0
  211. package/src/core/transport.ts +189 -0
  212. package/src/formats/camt.ts +266 -0
  213. package/src/formats/mt940.ts +419 -0
  214. package/src/formats/readers.ts +49 -0
  215. package/src/formats/record-budget.ts +18 -0
  216. package/src/formats/sepa-purpose.ts +94 -0
  217. package/src/index.ts +70 -0
  218. package/src/session/app-approval.ts +278 -0
  219. package/src/session/dialog-runner.ts +122 -0
  220. package/src/session/login-guard.ts +221 -0
  221. package/src/session/sepa-accounts.ts +115 -0
  222. package/src/session/session-setup.ts +184 -0
  223. package/src/session/session-state.ts +204 -0
  224. package/src/session/signed-dialog.ts +192 -0
  225. package/src/session/tan-selection.ts +203 -0
  226. package/src/session/user-prompts.ts +193 -0
  227. package/src/tan/challenge.ts +261 -0
  228. package/src/tan/flicker.ts +308 -0
  229. package/src/tan/tan-image.ts +38 -0
  230. package/src/tan/tan-models.ts +252 -0
  231. package/src/util/async.ts +48 -0
  232. package/src/util/bytes.ts +129 -0
  233. package/src/util/call-context.ts +41 -0
  234. package/src/util/dates.ts +210 -0
  235. package/src/util/errors.ts +27 -0
  236. package/src/util/immutable-map.ts +47 -0
  237. package/src/util/json.ts +67 -0
  238. package/src/util/text.ts +112 -0
  239. package/src/util/xml.ts +183 -0
  240. package/src/v3/account-extension.ts +185 -0
  241. package/src/v3/bank-connection.ts +67 -0
  242. package/src/v3/dialog.ts +283 -0
  243. package/src/v3/message.ts +373 -0
  244. package/src/v3/parameter-store.ts +160 -0
  245. package/src/v3/parameters.ts +524 -0
  246. package/src/v3/protocol.ts +148 -0
  247. package/src/v3/queries.ts +190 -0
  248. package/src/v3/segments.ts +374 -0
  249. package/src/v3/syntax.ts +369 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Data that do not have the expected format, such as a malformed MT940 line or amount.
3
+ *
4
+ * The message leaves out `source`, which may hold account data, such as amounts, names and IBANs, so that logs and
5
+ * crash reports do not collect them.
6
+ */
7
+ export class FormatError extends Error {
8
+ override name = 'FormatError';
9
+
10
+ /** The part of the data that is malformed, where the parser can tell it. */
11
+ readonly source: string | undefined;
12
+
13
+ constructor(message: string, source?: string) {
14
+ super(message);
15
+ this.source = source;
16
+ }
17
+ }
18
+
19
+ /** Throws a `FormatError`, where an expression is expected, such as `value ?? fail('…')`. */
20
+ export function fail(message: string, source?: string): never {
21
+ throw new FormatError(message, source);
22
+ }
23
+
24
+ /** An argument the function cannot work with, such as an empty PIN. The message never contains a PIN. */
25
+ export function invalidArgument(message: string, name?: string): TypeError {
26
+ return new TypeError(name === undefined ? message : `Invalid argument (${name}): ${message}`);
27
+ }
@@ -0,0 +1,47 @@
1
+ /** A map that cannot be changed, also not at runtime: it has no `set`, `delete` or `clear`. */
2
+ export class ImmutableMap<K, V> implements ReadonlyMap<K, V> {
3
+ private readonly map: Map<K, V>;
4
+
5
+ constructor(entries?: Iterable<readonly [K, V]>) {
6
+ this.map = new Map(entries);
7
+ }
8
+
9
+ get size(): number {
10
+ return this.map.size;
11
+ }
12
+
13
+ get(key: K): V | undefined {
14
+ return this.map.get(key);
15
+ }
16
+
17
+ has(key: K): boolean {
18
+ return this.map.has(key);
19
+ }
20
+
21
+ forEach(callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void): void {
22
+ this.map.forEach((value, key) => {
23
+ callback(value, key, this);
24
+ });
25
+ }
26
+
27
+ entries(): MapIterator<[K, V]> {
28
+ return this.map.entries();
29
+ }
30
+
31
+ keys(): MapIterator<K> {
32
+ return this.map.keys();
33
+ }
34
+
35
+ values(): MapIterator<V> {
36
+ return this.map.values();
37
+ }
38
+
39
+ [Symbol.iterator](): MapIterator<[K, V]> {
40
+ return this.map.entries();
41
+ }
42
+
43
+ /** A plain object of the entries, for JSON and for display. */
44
+ toJSON(): Record<string, V> {
45
+ return Object.fromEntries([...this.map].map(([key, value]) => [String(key), value]));
46
+ }
47
+ }
@@ -0,0 +1,67 @@
1
+ import { FormatError } from './errors.js';
2
+
3
+ /** A JSON object read from a saved state. */
4
+ export type JsonObject = Record<string, unknown>;
5
+
6
+ export function isJsonObject(value: unknown): value is JsonObject {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ /** The optional string at `key`; throws `FormatError` for a value of another type. */
11
+ export function readOptionalString(json: JsonObject, key: string): string | undefined {
12
+ const value = json[key];
13
+ if (value === undefined || value === null) return undefined;
14
+ if (typeof value !== 'string') throw new FormatError(`Saved state: "${key}" is not a string`);
15
+ return value;
16
+ }
17
+
18
+ /** The optional integer at `key`; throws `FormatError` for a value of another type. */
19
+ export function readOptionalInt(json: JsonObject, key: string): number | undefined {
20
+ const value = json[key];
21
+ if (value === undefined || value === null) return undefined;
22
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
23
+ throw new FormatError(`Saved state: "${key}" is not an integer`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ /** The optional boolean at `key`; throws `FormatError` for a value of another type. */
29
+ export function readOptionalBoolean(json: JsonObject, key: string): boolean | undefined {
30
+ const value = json[key];
31
+ if (value === undefined || value === null) return undefined;
32
+ if (typeof value !== 'boolean') throw new FormatError(`Saved state: "${key}" is not a boolean`);
33
+ return value;
34
+ }
35
+
36
+ /** The object at `key`; throws `FormatError` if it is missing or not an object. */
37
+ export function readObject(json: JsonObject, key: string): JsonObject {
38
+ const value = json[key];
39
+ if (!isJsonObject(value)) throw new FormatError(`Saved state: "${key}" is not an object`);
40
+ return value;
41
+ }
42
+
43
+ /** The optional object at `key`; throws `FormatError` for a value of another type. */
44
+ export function readOptionalObject(json: JsonObject, key: string): JsonObject | undefined {
45
+ const value = json[key];
46
+ if (value === undefined || value === null) return undefined;
47
+ if (!isJsonObject(value)) throw new FormatError(`Saved state: "${key}" is not an object`);
48
+ return value;
49
+ }
50
+
51
+ /** The optional list at `key`, each element checked by `isElement`; throws `FormatError` for anything else. */
52
+ export function readOptionalList<T>(
53
+ json: JsonObject,
54
+ key: string,
55
+ isElement: (value: unknown) => value is T,
56
+ ): T[] | undefined {
57
+ const value = json[key];
58
+ if (value === undefined || value === null) return undefined;
59
+ if (!Array.isArray(value) || !value.every(isElement)) {
60
+ throw new FormatError(`Saved state: "${key}" is not a list of the expected values`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ export function isString(value: unknown): value is string {
66
+ return typeof value === 'string';
67
+ }
@@ -0,0 +1,112 @@
1
+ import { base64Decode, fromCharCodes, latin1Decode, utf8Decode } from './bytes.js';
2
+ import { FormatError } from './errors.js';
3
+
4
+ const xmlEncodingDeclaration = /encoding\s*=\s*["']([A-Za-z0-9._-]+)["']/;
5
+ const whitespace = /\s/g;
6
+
7
+ /**
8
+ * Decodes text from the bank, which may be UTF-8 (with or without BOM) or ISO 8859-1; ASCII reads the same in both.
9
+ *
10
+ * UTF-8 comes first, since some banks send it. ISO 8859-1 text that happens to be valid UTF-8, such as `ß°`, reads as
11
+ * UTF-8 then: a rare misreading of text shown, as values sent back to the bank are read byte for byte
12
+ * (`Segment.verbatim`).
13
+ */
14
+ export function decodeBankText(bytes: Uint8Array): string {
15
+ const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
16
+ const content = hasBom ? bytes.subarray(3) : bytes;
17
+ try {
18
+ return utf8Decode(content);
19
+ } catch (error) {
20
+ if (!(error instanceof FormatError)) throw error;
21
+ return hasBom ? utf8Decode(content, true) : latin1Decode(content);
22
+ }
23
+ }
24
+
25
+ /** Decodes an XML document, honouring an ISO 8859-1 or Windows-1252 encoding declaration. */
26
+ export function decodeXmlText(bytes: Uint8Array): string {
27
+ const head = latin1Decode(bytes.length > 200 ? bytes.subarray(0, 200) : bytes);
28
+ switch (xmlEncodingDeclaration.exec(head)?.[1]?.toLowerCase()) {
29
+ case 'windows-1252':
30
+ case 'cp1252':
31
+ return decodeWindows1252(bytes);
32
+ case 'iso-8859-1':
33
+ case 'iso8859-1':
34
+ case 'latin1':
35
+ case 'latin-1':
36
+ return latin1Decode(bytes);
37
+ default:
38
+ return decodeBankText(bytes);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, where ISO 8859-1 has control characters; the five unassigned
44
+ * bytes keep their code.
45
+ */
46
+ const windows1252High = [
47
+ 0x20ac, 0x0081, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008d,
48
+ 0x017d, 0x008f, 0x0090, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a,
49
+ 0x0153, 0x009d, 0x017e, 0x0178,
50
+ ];
51
+
52
+ /**
53
+ * Decodes Windows-1252, which matches ISO 8859-1 except for `€`, typographic quotes and a few letters.
54
+ *
55
+ * @internal Exported for the tests.
56
+ */
57
+ export function decodeWindows1252(bytes: Uint8Array): string {
58
+ const codes = new Uint16Array(bytes.length);
59
+ for (let i = 0; i < bytes.length; i++) {
60
+ const b = bytes[i]!;
61
+ codes[i] = b >= 0x80 && b < 0xa0 ? windows1252High[b - 0x80]! : b;
62
+ }
63
+ return fromCharCodes(codes);
64
+ }
65
+
66
+ /**
67
+ * The last four characters of `id`, such as an IBAN or a card number, after an ellipsis: enough to tell two apart, too
68
+ * little to identify one in a log.
69
+ */
70
+ export function maskedId(id: string): string {
71
+ return id.length <= 4 ? '…' : `…${id.substring(id.length - 4)}`;
72
+ }
73
+
74
+ /** Whether `value` fits into ISO 8859-1, the FinTS 3.0 character set. */
75
+ export function isLatin1(value: string): boolean {
76
+ for (let i = 0; i < value.length; i++) if (value.charCodeAt(i) > 0xff) return false;
77
+ return true;
78
+ }
79
+
80
+ /** `text` without blanks around it; `undefined` if that leaves nothing. */
81
+ export function nonBlank(text: string | undefined): string | undefined {
82
+ const trimmed = text?.trim();
83
+ return trimmed === '' ? undefined : trimmed;
84
+ }
85
+
86
+ /**
87
+ * The character the numeric character reference `name` names, such as `#x41` or `#65`; `undefined` for any other name,
88
+ * and for a number that is no character as XML defines them: `NUL`, half a surrogate pair, or beyond Unicode.
89
+ */
90
+ export function characterReference(name: string): string | undefined {
91
+ const m = /^#(?:x([0-9a-fA-F]+)|([0-9]+))$/.exec(name);
92
+ if (!m) return undefined;
93
+ const code = m[1] === undefined ? parseInt(m[2]!, 10) : parseInt(m[1], 16);
94
+ return code > 0 && (code < 0xd800 || code > 0xdfff) && code <= 0x10ffff ? String.fromCodePoint(code) : undefined;
95
+ }
96
+
97
+ /** `text` without spaces and line breaks. */
98
+ export function withoutWhitespace(text: string): string {
99
+ return text.replace(whitespace, '');
100
+ }
101
+
102
+ /** Decodes base64 that may be broken into lines; throws `FormatError` if it is invalid. */
103
+ export function decodeBase64Lines(text: string): Uint8Array {
104
+ return base64Decode(withoutWhitespace(text));
105
+ }
106
+
107
+ /** The integer `text` states, decimal digits with an optional sign and blanks around; `undefined` for anything else. */
108
+ export function tryParseInt(text: string | undefined): number | undefined {
109
+ if (text === undefined) return undefined;
110
+ const match = /^\s*([+-]?\d+)\s*$/.exec(text);
111
+ return match ? Number(match[1]) : undefined;
112
+ }
@@ -0,0 +1,183 @@
1
+ import { FormatError } from './errors.js';
2
+ import { characterReference, nonBlank } from './text.js';
3
+
4
+ /**
5
+ * An element of a parsed XML document, looked up by local name: camt documents come with and without namespace
6
+ * prefixes.
7
+ */
8
+ export class XmlElement {
9
+ readonly children: (XmlElement | string)[] = [];
10
+
11
+ constructor(
12
+ /** The name without namespace prefix. */
13
+ readonly localName: string,
14
+ /** The attributes by name without namespace prefix; namespace declarations are left out. */
15
+ readonly attributes: ReadonlyMap<string, string>,
16
+ ) {}
17
+
18
+ get childElements(): XmlElement[] {
19
+ return this.children.filter((c): c is XmlElement => c instanceof XmlElement);
20
+ }
21
+
22
+ /** The text of this element and all below it. */
23
+ get innerText(): string {
24
+ return this.children.map((c) => (c instanceof XmlElement ? c.innerText : c)).join('');
25
+ }
26
+
27
+ getAttribute(localName: string): string | undefined {
28
+ return this.attributes.get(localName);
29
+ }
30
+
31
+ /** The first child element called `localName`. */
32
+ child(localName: string): XmlElement | undefined {
33
+ return this.children.find((c): c is XmlElement => c instanceof XmlElement && c.localName === localName);
34
+ }
35
+
36
+ /** The element reached by following child names along `path`; this element for an empty path. */
37
+ at(path: readonly string[]): XmlElement | undefined {
38
+ return path.reduce<XmlElement | undefined>((element, name) => element?.child(name), this);
39
+ }
40
+
41
+ /** The trimmed text of the element at `path`, or `undefined` if it is missing or empty. */
42
+ textAt(path: readonly string[]): string | undefined {
43
+ return nonBlank(this.at(path)?.innerText);
44
+ }
45
+
46
+ /** The child elements called `localName`, in document order. */
47
+ childrenNamed(localName: string): XmlElement[] {
48
+ return this.children.filter((c): c is XmlElement => c instanceof XmlElement && c.localName === localName);
49
+ }
50
+ }
51
+
52
+ const namePattern = /[^\s<>/=!?"']+/y;
53
+ const attributePattern = /\s+([^\s<>/=!?"']+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
54
+
55
+ /** The match of the sticky `pattern` at `position` in `text`. */
56
+ function matchAt(pattern: RegExp, text: string, position: number): RegExpExecArray | null {
57
+ pattern.lastIndex = position;
58
+ return pattern.exec(text);
59
+ }
60
+ const predefinedEntities = new Map([
61
+ ['amp', '&'],
62
+ ['lt', '<'],
63
+ ['gt', '>'],
64
+ ['quot', '"'],
65
+ ['apos', "'"],
66
+ ]);
67
+
68
+ /** Deepest nesting of elements the parser takes: camt needs fewer than 20 levels, and `innerText` recurses. */
69
+ const maxDepth = 256;
70
+
71
+ /**
72
+ * Most elements and attributes one document may hold. Real camt spends about 20 bytes on each, so the largest answer a
73
+ * bank can send holds fewer than 600,000, while a hostile document stays within about 150 MB of memory.
74
+ */
75
+ const maxNodes = 1_000_000;
76
+
77
+ const noAttributes: ReadonlyMap<string, string> = new Map();
78
+
79
+ function localNameOf(qualified: string): string {
80
+ const colon = qualified.indexOf(':');
81
+ return colon < 0 ? qualified : qualified.substring(colon + 1);
82
+ }
83
+
84
+ /** Resolves the character and predefined entity references in `text`; throws `FormatError` for any other. */
85
+ function decodeEntities(text: string): string {
86
+ return text.replace(/&([^;&\s]*);?/g, (match, name: string) => {
87
+ if (!match.endsWith(';')) throw new FormatError('Invalid XML: unterminated entity reference');
88
+ if (name.startsWith('#')) {
89
+ const character = characterReference(name);
90
+ if (character === undefined) throw new FormatError('Invalid XML character reference', match);
91
+ return character;
92
+ }
93
+ const value = predefinedEntities.get(name);
94
+ if (value === undefined) throw new FormatError('Invalid XML: unknown entity', match);
95
+ return value;
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Parses an XML document and returns its root element; throws `FormatError` if it is not well-formed, nests elements
101
+ * deeper than 256 levels or holds more than a million elements and attributes.
102
+ *
103
+ * A document type declaration is refused: bank data have none, and its entities could expand without limit.
104
+ */
105
+ export function parseXml(xml: string): XmlElement {
106
+ let position = 0;
107
+ const stack: XmlElement[] = [];
108
+ let root: XmlElement | undefined;
109
+ let nodes = 0;
110
+
111
+ const fail = (reason: string): never => {
112
+ throw new FormatError(`Invalid XML: ${reason} at position ${position}`);
113
+ };
114
+ const skipPast = (terminator: string, what: string): void => {
115
+ const end = xml.indexOf(terminator, position);
116
+ if (end < 0) fail(`unterminated ${what}`);
117
+ position = end + terminator.length;
118
+ };
119
+
120
+ while (position < xml.length) {
121
+ const lt = xml.indexOf('<', position);
122
+ const textEnd = lt < 0 ? xml.length : lt;
123
+ if (textEnd > position) {
124
+ const text = xml.substring(position, textEnd);
125
+ const parent = stack.at(-1);
126
+ if (parent) parent.children.push(decodeEntities(text));
127
+ else if (text.trim() !== '') fail('text outside the root element');
128
+ position = textEnd;
129
+ continue;
130
+ }
131
+ if (xml.startsWith('<?', position)) {
132
+ skipPast('?>', 'processing instruction');
133
+ } else if (xml.startsWith('<!--', position)) {
134
+ skipPast('-->', 'comment');
135
+ } else if (xml.startsWith('<![CDATA[', position)) {
136
+ const parent = stack.at(-1) ?? fail('CDATA outside the root element');
137
+ const start = position + 9;
138
+ skipPast(']]>', 'CDATA section');
139
+ parent.children.push(xml.substring(start, position - 3));
140
+ } else if (xml.startsWith('<!', position)) {
141
+ fail('document type declarations are not supported');
142
+ } else if (xml.startsWith('</', position)) {
143
+ position += 2;
144
+ const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing end tag name');
145
+ position += name.length;
146
+ while (/\s/.test(xml.charAt(position))) position++;
147
+ if (xml.charAt(position) !== '>') fail('malformed end tag');
148
+ position++;
149
+ const open = stack.pop();
150
+ if (open === undefined || open.localName !== localNameOf(name)) fail(`unexpected end tag ${name}`);
151
+ } else {
152
+ if (stack.length >= maxDepth) fail(`elements nested deeper than ${maxDepth} levels`);
153
+ if (++nodes > maxNodes) fail(`more than ${maxNodes} elements and attributes`);
154
+ position++;
155
+ const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing element name');
156
+ position += name.length;
157
+ let attributes: Map<string, string> | undefined;
158
+ for (;;) {
159
+ const attribute = matchAt(attributePattern, xml, position);
160
+ if (!attribute) break;
161
+ position += attribute[0].length;
162
+ if (++nodes > maxNodes) fail(`more than ${maxNodes} elements and attributes`);
163
+ const attributeName = attribute[1]!;
164
+ if (attributeName !== 'xmlns' && !attributeName.startsWith('xmlns:')) {
165
+ attributes ??= new Map();
166
+ attributes.set(localNameOf(attributeName), decodeEntities(attribute[2] ?? attribute[3] ?? ''));
167
+ }
168
+ }
169
+ while (/\s/.test(xml.charAt(position))) position++;
170
+ const selfClosing = xml.startsWith('/>', position);
171
+ if (!selfClosing && xml.charAt(position) !== '>') fail(`malformed start tag ${name}`);
172
+ position += selfClosing ? 2 : 1;
173
+ const element = new XmlElement(localNameOf(name), attributes ?? noAttributes);
174
+ const parent = stack.at(-1);
175
+ if (parent) parent.children.push(element);
176
+ else if (root) fail('a second root element');
177
+ else root = element;
178
+ if (!selfClosing) stack.push(element);
179
+ }
180
+ }
181
+ if (stack.length > 0) fail(`unclosed element ${stack.at(-1)!.localName}`);
182
+ return root ?? fail('no root element');
183
+ }
@@ -0,0 +1,185 @@
1
+ // Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
2
+
3
+ import { AccountDataStatus, type Account } from '../core/account.js';
4
+ import { Amount, withMark } from '../core/amount.js';
5
+ import { Balance } from '../core/balance.js';
6
+ import { parseFintsDate, parseUtcPlusOneDateTime } from '../util/dates.js';
7
+ import { fail, FormatError } from '../util/errors.js';
8
+ import { isJsonObject } from '../util/json.js';
9
+ import { nonBlank } from '../util/text.js';
10
+
11
+ /** A booked or pending balance of the extension: credit/debit mark, value, date and time. */
12
+ interface Saldo {
13
+ readonly amount: Amount;
14
+ readonly date: Date;
15
+ readonly time: Date | undefined;
16
+ }
17
+
18
+ /** The balance of the extension (`AcctBal`, built like HISAL version 7), read without the account it belongs to. */
19
+ interface AccountBalance {
20
+ readonly booked: Saldo;
21
+ readonly pending: Saldo | undefined;
22
+ readonly creditLine: Amount | undefined;
23
+ readonly available: Amount | undefined;
24
+ readonly used: Amount | undefined;
25
+ readonly overdraft: Amount | undefined;
26
+ readonly bookingDate: Date | undefined;
27
+ readonly bookingTime: Date | undefined;
28
+ }
29
+
30
+ /**
31
+ * The account extension of the user parameter data (HIUPD "Erweiterung, kontobezogen", Formals E.3.1): JSON with the
32
+ * BIC, the balance, and what the bank tells about the data it holds for the account.
33
+ */
34
+ export class AccountExtension {
35
+ private constructor(
36
+ /** The BIC of the account, which spares the SEPA account list (Formals F.3.4). */
37
+ readonly bic: string | undefined,
38
+ /** What the bank tells about the account's data. */
39
+ readonly status: AccountDataStatus,
40
+ private readonly balance: AccountBalance | undefined,
41
+ /** Whether `balance` holds all a balance query would deliver (`BalComplete`). */
42
+ private readonly balanceComplete: boolean,
43
+ ) {}
44
+
45
+ /**
46
+ * Reads `json`; `undefined` if it is not a JSON object or any element is malformed, which voids the whole extension
47
+ * (Formals F.2, KF3).
48
+ */
49
+ static tryParse(json: string): AccountExtension | undefined {
50
+ try {
51
+ const decoded: unknown = JSON.parse(json);
52
+ return isJsonObject(decoded) ? AccountExtension.read(new Json(decoded)) : undefined;
53
+ } catch (error) {
54
+ if (error instanceof FormatError || error instanceof SyntaxError) return undefined;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ private static read(json: Json): AccountExtension {
60
+ const balance = json.object('AcctBal');
61
+ const next = json.object('AcctStatNext');
62
+ return new AccountExtension(
63
+ json.string('BIC'),
64
+ new AccountDataStatus({
65
+ inventory: json.object('Inventory')?.strings ?? new Map(),
66
+ lastTransactions: json.string('umsltzt'),
67
+ nextTransactionsAt: next === undefined ? undefined : moment(next, '000000'),
68
+ transactionBalancesCurrent: json.flag('BalStatAcct') || json.flag('BalStatAct'),
69
+ }),
70
+ balance === undefined ? undefined : readBalance(balance),
71
+ json.flag('BalComplete'),
72
+ );
73
+ }
74
+
75
+ /** The balance of `account`, where the extension holds all a balance query would deliver (Formals F.3.2). */
76
+ completeBalance(account: Account): Balance | undefined {
77
+ const b = this.balance;
78
+ if (b === undefined || !this.balanceComplete) return undefined;
79
+ return new Balance({
80
+ account,
81
+ productName: account.productName,
82
+ booked: b.booked.amount,
83
+ bookedAsOf: b.booked.date,
84
+ bookedAsOfTime: b.booked.time,
85
+ pending: b.pending?.amount,
86
+ creditLine: b.creditLine,
87
+ available: b.available,
88
+ used: b.used,
89
+ overdraft: b.overdraft,
90
+ bookingDate: b.bookingDate,
91
+ bookingTime: b.bookingTime,
92
+ });
93
+ }
94
+ }
95
+
96
+ function readBalance(json: Json): AccountBalance {
97
+ const currency = json.string('Cur') ?? fail('Extension balance without currency');
98
+ const amount = (key: string): Amount | undefined => {
99
+ const value = json.string(key);
100
+ return value === undefined ? undefined : Amount.parseDecimal(value, currency);
101
+ };
102
+ const saldo = (key: string): Saldo | undefined => {
103
+ const s = json.object(key);
104
+ if (s === undefined) return undefined;
105
+ const value = s.string('Val') ?? fail('Extension balance without value');
106
+ return {
107
+ amount: withMark(Amount.parseDecimal(value, currency), s.string('DebCrd'), 'C', 'D'),
108
+ date: parseFintsDate(s.string('Date')) ?? fail('Extension balance without date'),
109
+ time: moment(s),
110
+ };
111
+ };
112
+ const bookTime = json.object('BookTime');
113
+ return {
114
+ booked: saldo('CurValBal') ?? fail('Extension balance without booked balance'),
115
+ pending: saldo('InclPendTransBal'),
116
+ creditLine: amount('OverdraftLim'),
117
+ available: amount('AvailFunds'),
118
+ used: amount('AlrdyDrwnOnBal'),
119
+ overdraft: amount('OverDraft'),
120
+ bookingDate: bookTime === undefined ? undefined : parseFintsDate(bookTime.string('Date')),
121
+ bookingTime: bookTime === undefined ? undefined : moment(bookTime),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * The moment of `Date` and `Time` in `json`, which the extension states in UTC+01:00 all year round; `undefined`
127
+ * without a time, unless `defaultTime` stands in for it.
128
+ */
129
+ function moment(json: Json, defaultTime?: string): Date | undefined {
130
+ const time = json.string('Time') ?? defaultTime;
131
+ if (time === undefined) return undefined;
132
+ return parseUtcPlusOneDateTime(json.string('Date'), time) ?? fail('Extension date or time is malformed');
133
+ }
134
+
135
+ /**
136
+ * A JSON object of the extension, read by key without regard to case: the specification itself spells some keys
137
+ * differently (`OverDraftLim` and `OverdraftLim`). Values of the wrong type are a `FormatError`.
138
+ */
139
+ class Json {
140
+ private readonly values = new Map<string, unknown>();
141
+
142
+ constructor(values: Record<string, unknown>) {
143
+ for (const [key, value] of Object.entries(values)) this.values.set(key.toLowerCase(), value);
144
+ }
145
+
146
+ private value(key: string): unknown {
147
+ return this.values.get(key.toLowerCase());
148
+ }
149
+
150
+ /** A string, or a number written as string: dates and times come as either. */
151
+ string(key: string): string | undefined {
152
+ const value = this.value(key);
153
+ if (value === undefined || value === null) return undefined;
154
+ if (typeof value === 'string') return nonBlank(value);
155
+ if (typeof value === 'number') return String(value);
156
+ throw new FormatError(`Extension element "${key}" is not text`);
157
+ }
158
+
159
+ flag(key: string): boolean {
160
+ const value = this.value(key);
161
+ if (value === undefined || value === null) return false;
162
+ if (typeof value === 'boolean') return value;
163
+ if (typeof value === 'string') return value === 'J' || value.toLowerCase() === 'true';
164
+ throw new FormatError(`Extension element "${key}" is not a flag`);
165
+ }
166
+
167
+ object(key: string): Json | undefined {
168
+ const value = this.value(key);
169
+ if (value === undefined || value === null) return undefined;
170
+ if (isJsonObject(value)) return new Json(value);
171
+ throw new FormatError(`Extension element "${key}" is not an object`);
172
+ }
173
+
174
+ /** The object's values as strings, keyed in capitals, as segment IDs are written. */
175
+ get strings(): Map<string, string> {
176
+ const result = new Map<string, string>();
177
+ for (const [key, value] of this.values) {
178
+ if (value === null) result.set(key.toUpperCase(), '');
179
+ else if (typeof value === 'string') result.set(key.toUpperCase(), value);
180
+ else if (typeof value === 'number') result.set(key.toUpperCase(), String(value));
181
+ else throw new FormatError(`Extension element "${key}" is not text`);
182
+ }
183
+ return result;
184
+ }
185
+ }