@coderoftime/fints 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/CHANGELOG.md +519 -1
  2. package/README.md +138 -40
  3. package/dist/client.d.ts +49 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +242 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/core/account.d.ts +46 -7
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +143 -24
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +15 -4
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +46 -9
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +4 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +11 -5
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +21 -4
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +26 -5
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +81 -19
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +40 -20
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +85 -30
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +100 -26
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +136 -53
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +11 -1
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +14 -2
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +11 -1
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +21 -4
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +48 -6
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +126 -29
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +28 -5
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +132 -50
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +29 -5
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +209 -113
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts +6 -2
  54. package/dist/formats/readers.d.ts.map +1 -1
  55. package/dist/formats/readers.js +28 -29
  56. package/dist/formats/readers.js.map +1 -1
  57. package/dist/formats/record-budget.d.ts +7 -0
  58. package/dist/formats/record-budget.d.ts.map +1 -0
  59. package/dist/formats/record-budget.js +17 -0
  60. package/dist/formats/record-budget.js.map +1 -0
  61. package/dist/formats/sepa-purpose.d.ts +9 -0
  62. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  63. package/dist/formats/sepa-purpose.js +32 -2
  64. package/dist/formats/sepa-purpose.js.map +1 -1
  65. package/dist/index.d.ts +3 -3
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/session/app-approval.d.ts +11 -5
  70. package/dist/session/app-approval.d.ts.map +1 -1
  71. package/dist/session/app-approval.js +23 -12
  72. package/dist/session/app-approval.js.map +1 -1
  73. package/dist/session/dialog-runner.d.ts +11 -11
  74. package/dist/session/dialog-runner.d.ts.map +1 -1
  75. package/dist/session/dialog-runner.js +34 -19
  76. package/dist/session/dialog-runner.js.map +1 -1
  77. package/dist/session/login-guard.d.ts +54 -49
  78. package/dist/session/login-guard.d.ts.map +1 -1
  79. package/dist/session/login-guard.js +170 -83
  80. package/dist/session/login-guard.js.map +1 -1
  81. package/dist/session/sepa-accounts.d.ts +20 -14
  82. package/dist/session/sepa-accounts.d.ts.map +1 -1
  83. package/dist/session/sepa-accounts.js +62 -46
  84. package/dist/session/sepa-accounts.js.map +1 -1
  85. package/dist/session/session-setup.d.ts +41 -24
  86. package/dist/session/session-setup.d.ts.map +1 -1
  87. package/dist/session/session-setup.js +106 -63
  88. package/dist/session/session-setup.js.map +1 -1
  89. package/dist/session/session-state.d.ts +10 -6
  90. package/dist/session/session-state.d.ts.map +1 -1
  91. package/dist/session/session-state.js +72 -18
  92. package/dist/session/session-state.js.map +1 -1
  93. package/dist/session/signed-dialog.d.ts +83 -0
  94. package/dist/session/signed-dialog.d.ts.map +1 -0
  95. package/dist/session/signed-dialog.js +185 -0
  96. package/dist/session/signed-dialog.js.map +1 -0
  97. package/dist/session/tan-selection.d.ts +26 -18
  98. package/dist/session/tan-selection.d.ts.map +1 -1
  99. package/dist/session/tan-selection.js +44 -33
  100. package/dist/session/tan-selection.js.map +1 -1
  101. package/dist/session/user-prompts.d.ts +14 -5
  102. package/dist/session/user-prompts.d.ts.map +1 -1
  103. package/dist/session/user-prompts.js +53 -18
  104. package/dist/session/user-prompts.js.map +1 -1
  105. package/dist/tan/challenge.d.ts +47 -24
  106. package/dist/tan/challenge.d.ts.map +1 -1
  107. package/dist/tan/challenge.js +56 -51
  108. package/dist/tan/challenge.js.map +1 -1
  109. package/dist/tan/flicker.d.ts +11 -1
  110. package/dist/tan/flicker.d.ts.map +1 -1
  111. package/dist/tan/flicker.js +70 -24
  112. package/dist/tan/flicker.js.map +1 -1
  113. package/dist/tan/tan-image.d.ts +6 -2
  114. package/dist/tan/tan-image.d.ts.map +1 -1
  115. package/dist/tan/tan-image.js +14 -6
  116. package/dist/tan/tan-image.js.map +1 -1
  117. package/dist/tan/tan-models.d.ts +14 -2
  118. package/dist/tan/tan-models.d.ts.map +1 -1
  119. package/dist/tan/tan-models.js +24 -7
  120. package/dist/tan/tan-models.js.map +1 -1
  121. package/dist/util/arguments.d.ts +13 -0
  122. package/dist/util/arguments.d.ts.map +1 -0
  123. package/dist/util/arguments.js +40 -0
  124. package/dist/util/arguments.js.map +1 -0
  125. package/dist/util/async.d.ts +4 -3
  126. package/dist/util/async.d.ts.map +1 -1
  127. package/dist/util/async.js +6 -7
  128. package/dist/util/async.js.map +1 -1
  129. package/dist/util/bytes.d.ts +3 -4
  130. package/dist/util/bytes.d.ts.map +1 -1
  131. package/dist/util/bytes.js +13 -21
  132. package/dist/util/bytes.js.map +1 -1
  133. package/dist/util/call-context.d.ts.map +1 -1
  134. package/dist/util/call-context.js +3 -2
  135. package/dist/util/call-context.js.map +1 -1
  136. package/dist/util/dates.d.ts +6 -20
  137. package/dist/util/dates.d.ts.map +1 -1
  138. package/dist/util/dates.js +58 -28
  139. package/dist/util/dates.js.map +1 -1
  140. package/dist/util/errors.d.ts +13 -3
  141. package/dist/util/errors.d.ts.map +1 -1
  142. package/dist/util/errors.js +19 -6
  143. package/dist/util/errors.js.map +1 -1
  144. package/dist/util/immutable-map.d.ts +5 -2
  145. package/dist/util/immutable-map.d.ts.map +1 -1
  146. package/dist/util/immutable-map.js +15 -12
  147. package/dist/util/immutable-map.js.map +1 -1
  148. package/dist/util/inspect.d.ts +11 -0
  149. package/dist/util/inspect.d.ts.map +1 -0
  150. package/dist/util/inspect.js +14 -0
  151. package/dist/util/inspect.js.map +1 -0
  152. package/dist/util/json.d.ts +4 -0
  153. package/dist/util/json.d.ts.map +1 -1
  154. package/dist/util/json.js +18 -0
  155. package/dist/util/json.js.map +1 -1
  156. package/dist/util/text.d.ts +38 -5
  157. package/dist/util/text.d.ts.map +1 -1
  158. package/dist/util/text.js +90 -16
  159. package/dist/util/text.js.map +1 -1
  160. package/dist/util/xml.d.ts +9 -6
  161. package/dist/util/xml.d.ts.map +1 -1
  162. package/dist/util/xml.js +129 -56
  163. package/dist/util/xml.js.map +1 -1
  164. package/dist/v3/account-extension.d.ts.map +1 -1
  165. package/dist/v3/account-extension.js +7 -10
  166. package/dist/v3/account-extension.js.map +1 -1
  167. package/dist/v3/bank-connection.d.ts +54 -0
  168. package/dist/v3/bank-connection.d.ts.map +1 -0
  169. package/dist/v3/bank-connection.js +118 -0
  170. package/dist/v3/bank-connection.js.map +1 -0
  171. package/dist/v3/dialog.d.ts +84 -46
  172. package/dist/v3/dialog.d.ts.map +1 -1
  173. package/dist/v3/dialog.js +174 -60
  174. package/dist/v3/dialog.js.map +1 -1
  175. package/dist/v3/message.d.ts +32 -11
  176. package/dist/v3/message.d.ts.map +1 -1
  177. package/dist/v3/message.js +144 -83
  178. package/dist/v3/message.js.map +1 -1
  179. package/dist/v3/parameter-store.d.ts +111 -0
  180. package/dist/v3/parameter-store.d.ts.map +1 -0
  181. package/dist/v3/parameter-store.js +197 -0
  182. package/dist/v3/parameter-store.js.map +1 -0
  183. package/dist/v3/parameters.d.ts +44 -17
  184. package/dist/v3/parameters.d.ts.map +1 -1
  185. package/dist/v3/parameters.js +136 -60
  186. package/dist/v3/parameters.js.map +1 -1
  187. package/dist/v3/protocol.d.ts +50 -50
  188. package/dist/v3/protocol.d.ts.map +1 -1
  189. package/dist/v3/protocol.js +91 -210
  190. package/dist/v3/protocol.js.map +1 -1
  191. package/dist/v3/queries.d.ts +51 -0
  192. package/dist/v3/queries.d.ts.map +1 -0
  193. package/dist/v3/queries.js +128 -0
  194. package/dist/v3/queries.js.map +1 -0
  195. package/dist/v3/segments.d.ts +43 -19
  196. package/dist/v3/segments.d.ts.map +1 -1
  197. package/dist/v3/segments.js +84 -33
  198. package/dist/v3/segments.js.map +1 -1
  199. package/dist/v3/syntax.d.ts +68 -13
  200. package/dist/v3/syntax.d.ts.map +1 -1
  201. package/dist/v3/syntax.js +183 -102
  202. package/dist/v3/syntax.js.map +1 -1
  203. package/package.json +10 -6
  204. package/src/client.ts +293 -0
  205. package/src/core/account.ts +153 -24
  206. package/src/core/amount.ts +45 -10
  207. package/src/core/balance.ts +12 -5
  208. package/src/core/bank-info.ts +29 -5
  209. package/src/core/client.ts +83 -19
  210. package/src/core/config.ts +95 -35
  211. package/src/core/errors.ts +148 -54
  212. package/src/core/feedback.ts +16 -2
  213. package/src/core/transaction.ts +22 -4
  214. package/src/core/transport.ts +153 -32
  215. package/src/formats/camt.ts +153 -45
  216. package/src/formats/mt940.ts +244 -122
  217. package/src/formats/readers.ts +28 -31
  218. package/src/formats/record-budget.ts +18 -0
  219. package/src/formats/sepa-purpose.ts +29 -2
  220. package/src/index.ts +4 -2
  221. package/src/session/app-approval.ts +51 -38
  222. package/src/session/dialog-runner.ts +61 -31
  223. package/src/session/login-guard.ts +189 -87
  224. package/src/session/sepa-accounts.ts +63 -45
  225. package/src/session/session-setup.ts +105 -68
  226. package/src/session/session-state.ts +74 -23
  227. package/src/session/signed-dialog.ts +222 -0
  228. package/src/session/tan-selection.ts +50 -39
  229. package/src/session/user-prompts.ts +62 -18
  230. package/src/tan/challenge.ts +87 -64
  231. package/src/tan/flicker.ts +72 -23
  232. package/src/tan/tan-image.ts +15 -6
  233. package/src/tan/tan-models.ts +26 -7
  234. package/src/util/arguments.ts +40 -0
  235. package/src/util/async.ts +10 -9
  236. package/src/util/bytes.ts +14 -20
  237. package/src/util/call-context.ts +3 -2
  238. package/src/util/dates.ts +64 -33
  239. package/src/util/errors.ts +22 -6
  240. package/src/util/immutable-map.ts +15 -12
  241. package/src/util/inspect.ts +14 -0
  242. package/src/util/json.ts +16 -0
  243. package/src/util/text.ts +87 -15
  244. package/src/util/xml.ts +130 -52
  245. package/src/v3/account-extension.ts +7 -12
  246. package/src/v3/bank-connection.ts +126 -0
  247. package/src/v3/dialog.ts +186 -92
  248. package/src/v3/message.ts +140 -79
  249. package/src/v3/parameter-store.ts +226 -0
  250. package/src/v3/parameters.ts +166 -66
  251. package/src/v3/protocol.ts +103 -253
  252. package/src/v3/queries.ts +198 -0
  253. package/src/v3/segments.ts +86 -31
  254. package/src/v3/syntax.ts +172 -93
  255. package/dist/session/bank-connection.d.ts +0 -28
  256. package/dist/session/bank-connection.d.ts.map +0 -1
  257. package/dist/session/bank-connection.js +0 -48
  258. package/dist/session/bank-connection.js.map +0 -1
  259. package/dist/session/parameter-data.d.ts +0 -83
  260. package/dist/session/parameter-data.d.ts.map +0 -1
  261. package/dist/session/parameter-data.js +0 -68
  262. package/dist/session/parameter-data.js.map +0 -1
  263. package/dist/session/pin-tan-client.d.ts +0 -54
  264. package/dist/session/pin-tan-client.d.ts.map +0 -1
  265. package/dist/session/pin-tan-client.js +0 -153
  266. package/dist/session/pin-tan-client.js.map +0 -1
  267. package/dist/session/pin-tan-dialog.d.ts +0 -71
  268. package/dist/session/pin-tan-dialog.d.ts.map +0 -1
  269. package/dist/session/pin-tan-dialog.js +0 -124
  270. package/dist/session/pin-tan-dialog.js.map +0 -1
  271. package/dist/session/protocol.d.ts +0 -209
  272. package/dist/session/protocol.d.ts.map +0 -1
  273. package/dist/session/protocol.js +0 -75
  274. package/dist/session/protocol.js.map +0 -1
  275. package/dist/session/response-handler.d.ts +0 -48
  276. package/dist/session/response-handler.d.ts.map +0 -1
  277. package/dist/session/response-handler.js +0 -121
  278. package/dist/session/response-handler.js.map +0 -1
  279. package/dist/v3/client.d.ts +0 -24
  280. package/dist/v3/client.d.ts.map +0 -1
  281. package/dist/v3/client.js +0 -16
  282. package/dist/v3/client.js.map +0 -1
  283. package/src/session/bank-connection.ts +0 -51
  284. package/src/session/parameter-data.ts +0 -137
  285. package/src/session/pin-tan-client.ts +0 -186
  286. package/src/session/pin-tan-dialog.ts +0 -160
  287. package/src/session/protocol.ts +0 -311
  288. package/src/session/response-handler.ts +0 -130
  289. package/src/v3/client.ts +0 -29
@@ -1,8 +1,11 @@
1
- import { Amount } from '../core/amount.js';
2
- import { Transaction, type TransactionStatus } from '../core/transaction.js';
1
+ import { addsUpTo, Amount } from '../core/amount.js';
2
+ import { requireStatus, Transaction, type TransactionStatus } from '../core/transaction.js';
3
+ import { optionalString } from '../util/arguments.js';
3
4
  import { clampedDate, parseSwiftDate } from '../util/dates.js';
4
- import { FormatError } from '../util/errors.js';
5
- import { escapeRegExp } from '../util/text.js';
5
+ import { fail, FormatError } from '../util/errors.js';
6
+ import { frozenWithInspect } from '../util/inspect.js';
7
+ import { counted, maskedId, nonBlank, trimBlanks } from '../util/text.js';
8
+ import { RecordBudget } from './record-budget.js';
6
9
  import { providedReference, SepaPurpose } from './sepa-purpose.js';
7
10
 
8
11
  /** One statement from an MT940 or MT942 file, with its balances and transactions. */
@@ -17,13 +20,22 @@ export interface Mt940Statement {
17
20
  readonly currency: string | undefined;
18
21
  /** The balance at the start of the statement (field 60), negative for a debit. */
19
22
  readonly openingBalance: Amount | undefined;
20
- /** The date of `openingBalance`. */
23
+ /**
24
+ * The date of `openingBalance`; `undefined` where the bank states none, as `0` or `000000` for the first statement of
25
+ * an account.
26
+ */
21
27
  readonly openingDate: Date | undefined;
22
28
  /** The balance at the end of the statement (field 62), negative for a debit. */
23
29
  readonly closingBalance: Amount | undefined;
24
- /** The date of `closingBalance`. */
30
+ /**
31
+ * The date of `closingBalance`; `undefined` where the bank states none, as `0` or `000000` for the first statement of
32
+ * an account.
33
+ */
25
34
  readonly closingDate: Date | undefined;
26
- /** The transactions of the statement, in the order the bank lists them. */
35
+ /**
36
+ * The transactions of the statement, in the order the bank lists them; they lead from `openingBalance` to
37
+ * `closingBalance` where the statement states these.
38
+ */
27
39
  readonly transactions: readonly Transaction[];
28
40
  }
29
41
 
@@ -36,47 +48,104 @@ export interface Mt940Options {
36
48
  }
37
49
 
38
50
  const tagPattern = /^:(\d{2}[A-Z]?):/gm;
39
- const statementEnd = /\n-\s*$/;
40
- const trailingNewlines = /\n+$/;
51
+
52
+ /** Longest field the parser reads, from its tag to the next one: SWIFT fields have a few hundred characters. */
53
+ export const maxFieldLength = 65_536;
41
54
 
42
55
  /** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
43
56
  export const Mt940 = Object.freeze({
44
57
  /** Parses all transactions; see `parse`. */
45
58
  parseTransactions(text: string, options: Mt940Options = {}): Transaction[] {
46
- return Mt940.parse(text, options).flatMap((s) => [...s.transactions]);
59
+ return Mt940.parse(text, options).flatMap((s) => s.transactions);
47
60
  },
48
61
 
49
62
  /**
50
- * Parses all statements.
63
+ * Parses all statements. Their lines are separated by line breaks, and by `@@` as well where the first line holds it,
64
+ * as some banks separate them.
51
65
  *
52
- * Throws `FormatError` if a transaction line (`:61:`) cannot be read or the currency of a transaction is unknown.
66
+ * Throws `FormatError` for text without any field, if a transaction line (`:61:`), a balance or the creation date of
67
+ * an MT942 (`:13D:`) cannot be read or the currency of a transaction is unknown, and for a closing balance dated
68
+ * before the opening one. It does as well for a statement that states only one of its opening and closing balances,
69
+ * one of them, its account, its number or its creation date twice, or transactions that do not lead from the one
70
+ * balance to the other: a line of a purpose that starts like a field, such as `:61:` or `:25:`, would pass for a
71
+ * transaction, the account or a statement of its own otherwise. Text of more than 200,000 statements and
72
+ * transactions, or with a field of more than 65,536 characters, is a `FormatError` as well.
73
+ *
74
+ * Throws `TypeError` for a `status` other than `booked` and `pending`, `null` included, and for a `currency` that is
75
+ * no string; `null` counts as no currency.
53
76
  */
54
77
  parse(text: string, options: Mt940Options = {}): Mt940Statement[] {
55
- const status = options.status ?? 'booked';
56
- const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
57
- // Some banks separate lines with `@@` instead of line breaks; where there are line breaks, `@@` is text.
58
- const normalized = lines.includes('\n') ? lines : lines.replace(/@@/g, '\n');
59
- const tags = [...normalized.matchAll(tagPattern)];
60
- const statements: Mt940Statement[] = [];
61
- let current: StatementBuilder | undefined;
62
- tags.forEach((tag, i) => {
63
- const end = i + 1 < tags.length ? tags[i + 1]!.index : normalized.length;
64
- const value = normalized
65
- .substring(tag.index + tag[0].length, end)
66
- .replace(statementEnd, '')
67
- .replace(trailingNewlines, '');
68
- if (tag[1] === '20' || current === undefined) {
69
- if (current !== undefined) statements.push(current.build());
70
- current = new StatementBuilder(status, options.currency);
71
- }
72
- current.add(tag[1]!, value);
73
- });
74
- if (current !== undefined) statements.push(current.build());
75
- return statements;
78
+ const status = options.status === undefined ? undefined : requireStatus(options.status);
79
+ const currency = optionalString(options.currency, 'currency');
80
+ return parseMt940(text, { status, currency }, new RecordBudget());
76
81
  },
77
82
  });
78
83
 
79
- const balancePattern = /^([CD])(\d{6})([A-Z]{3})(\d+,\d*)/;
84
+ /** `Mt940.parse`, which takes each statement and transaction from `budget`, before it builds it. */
85
+ export function parseMt940(text: string, options: Mt940Options, budget: RecordBudget): Mt940Statement[] {
86
+ const status = options.status ?? 'booked';
87
+ const normalized = withLineFeeds(text);
88
+ const statements: Mt940Statement[] = [];
89
+ let current: StatementBuilder | undefined;
90
+ for (const [tag, value] of taggedParts(normalized, tagPattern)) {
91
+ if (value.length > maxFieldLength) throw new FormatError(`MT940 field :${tag}: too long`);
92
+ if (tag === '20' || current === undefined) {
93
+ if (current !== undefined) statements.push(current.build());
94
+ budget.take();
95
+ current = new StatementBuilder(status, options.currency);
96
+ }
97
+ if (tag === '61') budget.take();
98
+ current.add(tag, fieldValue(value));
99
+ }
100
+ if (current !== undefined) statements.push(current.build());
101
+ else if (normalized.trim() !== '') throw new FormatError('MT940 data without fields');
102
+ return statements;
103
+ }
104
+
105
+ /**
106
+ * The parts of `text` that the matches of `tag` start, each the first group of its match and the text up to the next
107
+ * match: the fields of a statement, or the subfields of a `:86:`. One by one: text of a few megabytes holds millions of
108
+ * them. A pattern that matched the text up to the next tag as well would run out of backtracking stack on a part of a
109
+ * few million characters.
110
+ */
111
+ function* taggedParts(text: string, tag: RegExp): Generator<[tag: string, text: string]> {
112
+ let current: string | undefined;
113
+ let start = 0;
114
+ for (const match of text.matchAll(tag)) {
115
+ if (current !== undefined) yield [current, text.substring(start, match.index)];
116
+ current = match[1]!;
117
+ start = match.index + match[0].length;
118
+ }
119
+ if (current !== undefined) yield [current, text.substring(start)];
120
+ }
121
+
122
+ /**
123
+ * `text` with line feeds between its lines, without the blanks and the byte order mark it may start with, which would
124
+ * hide its first tag. Some banks separate the lines with `@@`, and may still break the line between statements or at
125
+ * the ends. So `@@` separates lines where the first line holds it, which the bank writes; in other text, `@@` may be
126
+ * part of a purpose, which the payer writes.
127
+ */
128
+ function withLineFeeds(text: string): string {
129
+ const lines = trimBlanks(text.replace(/\r\n?/g, '\n'), { end: false });
130
+ return lines.split('\n', 1)[0]!.includes('@@') ? lines.replace(/@@/g, '\n') : lines;
131
+ }
132
+
133
+ /**
134
+ * The value of a field in `text`, which runs from its tag to the next one: without the statement end (`-`) and the line
135
+ * feeds at its end. Other blanks there stay: the subfield of a `:86:` that ends it may precede others in the purpose.
136
+ *
137
+ * Trimmed without a pattern such as `/\n+$/`, which takes quadratic time on a long run of line breaks.
138
+ */
139
+ function fieldValue(text: string): string {
140
+ const trimmed = text.trimEnd();
141
+ let end = trimmed.endsWith('\n-') ? trimmed.length - 2 : text.length;
142
+ while (end > 0 && text.charCodeAt(end - 1) === 0x0a) end--;
143
+ return text.substring(0, end);
144
+ }
145
+
146
+ /** The date is `0` or `000000` for the first statement of an account. */
147
+ const balancePattern = /^([CD])(\d{6}|0)([A-Z]{3})(\d+,\d*)/;
148
+ const dateTimePattern = /^(\d{6})\d{4}/;
80
149
  const currencyPrefix = /^([A-Z]{3})/;
81
150
 
82
151
  class StatementBuilder {
@@ -88,6 +157,8 @@ class StatementBuilder {
88
157
  openingDate: Date | undefined;
89
158
  closing: Amount | undefined;
90
159
  closingDate: Date | undefined;
160
+ /** The day an MT942 was created (field 13D). */
161
+ creationDate: Date | undefined;
91
162
  readonly transactions: TransactionLines[] = [];
92
163
 
93
164
  /**
@@ -105,35 +176,52 @@ class StatementBuilder {
105
176
  add(tag: string, value: string): void {
106
177
  switch (tag) {
107
178
  case '20':
108
- this.reference = value.trim();
179
+ this.reference = trimBlanks(value);
109
180
  break;
110
181
  case '25':
111
- this.accountId = value.trim();
182
+ if (this.accountId !== undefined) throw new FormatError('MT940 statement with two accounts', this.reference);
183
+ this.accountId = trimBlanks(value);
112
184
  break;
113
185
  case '28C':
114
186
  case '28':
115
- this.statementNumber = value.trim();
187
+ if (this.statementNumber !== undefined) {
188
+ throw new FormatError('MT940 statement with two statement numbers', this.reference);
189
+ }
190
+ this.statementNumber = trimBlanks(value);
191
+ break;
192
+ case '13D':
193
+ case '13': {
194
+ if (this.creationDate !== undefined) {
195
+ throw new FormatError('MT940 statement with two creation dates', this.reference);
196
+ }
197
+ const m = dateTimePattern.exec(trimBlanks(value));
198
+ this.creationDate = (m && parseSwiftDate(m[1]!)) ?? fail('Invalid MT940 creation date', value);
116
199
  break;
200
+ }
117
201
  case '60F':
118
202
  case '60M': {
119
- const balance = parseBalance(value);
120
- if (balance) {
121
- [this.opening, this.openingDate] = balance;
122
- this.currency ??= balance[0].currency;
203
+ if (this.opening !== undefined) {
204
+ throw new FormatError('MT940 statement with two opening balances', this.reference);
123
205
  }
206
+ const [balance, date] = parseBalance(value);
207
+ this.opening = balance;
208
+ this.openingDate = date;
209
+ this.currency ??= balance.currency;
124
210
  break;
125
211
  }
126
212
  case '62F':
127
213
  case '62M': {
128
- const balance = parseBalance(value);
129
- if (balance) {
130
- [this.closing, this.closingDate] = balance;
131
- this.currency ??= balance[0].currency;
214
+ if (this.closing !== undefined) {
215
+ throw new FormatError('MT940 statement with two closing balances', this.reference);
132
216
  }
217
+ const [balance, date] = parseBalance(value);
218
+ this.closing = balance;
219
+ this.closingDate = date;
220
+ this.currency ??= balance.currency;
133
221
  break;
134
222
  }
135
223
  case '34F':
136
- this.currency ??= currencyPrefix.exec(value.trim())?.[1];
224
+ this.currency ??= currencyPrefix.exec(trimBlanks(value))?.[1];
137
225
  break;
138
226
  case '61':
139
227
  this.transactions.push(new TransactionLines(value));
@@ -150,26 +238,52 @@ class StatementBuilder {
150
238
  if (statementCurrency === undefined && this.transactions.length > 0) {
151
239
  throw new FormatError('MT940 statement without currency', this.reference);
152
240
  }
153
- return Object.freeze({
154
- reference: this.reference,
155
- accountId: this.accountId,
156
- statementNumber: this.statementNumber,
157
- currency: statementCurrency,
158
- openingBalance: this.opening,
159
- openingDate: this.openingDate,
160
- closingBalance: this.closing,
161
- closingDate: this.closingDate,
162
- transactions: Object.freeze(this.transactions.map((t) => t.toTransaction(statementCurrency!, this.status))),
163
- });
241
+ const { openingDate, closingDate } = this;
242
+ if (openingDate && closingDate && closingDate.getTime() < openingDate.getTime()) {
243
+ throw new FormatError('MT940 closing balance dated before the opening balance', this.reference);
244
+ }
245
+ const period = openingDate && closingDate ? ([openingDate, closingDate] as const) : undefined;
246
+ const transactions = this.transactions.map((t) =>
247
+ t.toTransaction(statementCurrency!, this.status, period, this.creationDate),
248
+ );
249
+ this.checkBalances(transactions);
250
+ return frozenWithInspect(
251
+ {
252
+ reference: this.reference,
253
+ accountId: this.accountId,
254
+ statementNumber: this.statementNumber,
255
+ currency: statementCurrency,
256
+ openingBalance: this.opening,
257
+ openingDate: this.openingDate,
258
+ closingBalance: this.closing,
259
+ closingDate: this.closingDate,
260
+ transactions: Object.freeze(transactions),
261
+ },
262
+ (s) => `Mt940Statement(${maskedId(s.accountId ?? '')}, ${counted(s.transactions.length, 'transaction')})`,
263
+ );
264
+ }
265
+
266
+ /** Checks that the statement states both balances or neither, and that its `transactions` lead from one to the other. */
267
+ private checkBalances(transactions: readonly Transaction[]): void {
268
+ const { opening, closing } = this;
269
+ if (opening === undefined && closing === undefined) return;
270
+ if (opening === undefined || closing === undefined) {
271
+ throw new FormatError('MT940 statement with only one of its balances', this.reference);
272
+ }
273
+ if (opening.currency !== this.currency || closing.currency !== this.currency) {
274
+ throw new FormatError('MT940 balances in another currency than the statement', this.reference);
275
+ }
276
+ if (!addsUpTo([opening, ...transactions.map((t) => t.amount)], closing)) {
277
+ throw new FormatError('MT940 transactions do not add up to the closing balance', this.reference);
278
+ }
164
279
  }
165
280
  }
166
281
 
167
- function parseBalance(value: string): [Amount, Date] | undefined {
168
- const m = balancePattern.exec(value.trim());
169
- if (!m) return undefined;
282
+ function parseBalance(value: string): [Amount, Date | undefined] {
283
+ const m = balancePattern.exec(trimBlanks(value));
284
+ if (!m) throw new FormatError('Unreadable MT940 balance', value);
170
285
  const amount = Amount.parseDecimal(m[4]!, m[3]!);
171
- const date = parseSwiftDate(m[2]!);
172
- if (date === undefined) throw new FormatError('Invalid balance date', value);
286
+ const date = Number(m[2]) === 0 ? undefined : (parseSwiftDate(m[2]!) ?? fail('Invalid balance date', value));
173
287
  return [m[1] === 'D' ? amount.negate() : amount, date];
174
288
  }
175
289
 
@@ -180,34 +294,48 @@ function parseBalance(value: string): [Amount, Date] | undefined {
180
294
  const line61 =
181
295
  /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
182
296
 
297
+ /**
298
+ * The dates between which the transactions of a statement are booked: those of its opening and its closing balance. A
299
+ * statement without balances, such as an MT942, has the period from the value date of each to the date the statement
300
+ * was created, or that value date alone.
301
+ */
302
+ type Period = readonly [first: Date, last: Date];
303
+
304
+ function spanning(a: Date, b: Date): Period {
305
+ return a.getTime() <= b.getTime() ? [a, b] : [b, a];
306
+ }
307
+
183
308
  /** The `:61:` line of a transaction and its `:86:` details. */
184
309
  class TransactionLines {
185
310
  details: string | undefined;
186
311
 
187
312
  constructor(readonly line: string) {}
188
313
 
189
- toTransaction(currency: string, status: TransactionStatus): Transaction {
190
- const m = line61.exec(this.line.trim());
314
+ toTransaction(
315
+ currency: string,
316
+ status: TransactionStatus,
317
+ period: Period | undefined,
318
+ created: Date | undefined,
319
+ ): Transaction {
320
+ const m = line61.exec(trimBlanks(this.line));
191
321
  const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
192
322
  if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
193
323
  const mark = m[3]!;
194
324
  const amount = Amount.parseDecimal(m[5]!, currency);
195
325
  const debit = mark === 'D' || mark === 'RC';
196
- const customerReference = m[7]?.trim();
197
- const bankReference = m[8]?.trim();
198
326
  const d = Details.parse(this.details);
327
+ const reference = nonBlank(m[7]);
328
+ // `KREF+` stands for the customer reference that :86: states.
329
+ const customerReference = reference === 'KREF+' ? d.sepa.get('KREF') : reference;
199
330
  return new Transaction({
200
331
  status,
201
- bookingDate: this.bookingDate(m[2], valueDate),
332
+ bookingDate: this.bookingDate(m[2], valueDate, period ?? spanning(valueDate, created ?? valueDate)),
202
333
  valueDate,
203
334
  amount: debit ? amount.negate() : amount,
204
335
  isReversal: mark.startsWith('R'),
205
336
  transactionTypeId: m[6],
206
- customerReference:
207
- customerReference === undefined || customerReference === '' || customerReference === 'NONREF'
208
- ? undefined
209
- : customerReference,
210
- bankReference: bankReference === undefined || bankReference === '' ? undefined : bankReference,
337
+ customerReference: customerReference === 'NONREF' ? undefined : customerReference,
338
+ bankReference: nonBlank(m[8]),
211
339
  transactionCode: d.transactionCode,
212
340
  bookingText: d.bookingText,
213
341
  primaNota: d.primaNota,
@@ -226,34 +354,43 @@ class TransactionLines {
226
354
  });
227
355
  }
228
356
 
229
- /** The booking date `MMDD`, in the year that places it closest to `valueDate` (across a year end). */
230
- private bookingDate(mmdd: string | undefined, valueDate: Date): Date | undefined {
357
+ /**
358
+ * The booking date `MMDD`, in the year that places it within `period`, or else closest to it; of several such years,
359
+ * the one closest to `valueDate`. The value date alone misleads: banks book refunds and corrections with the value
360
+ * date of the original booking, up to 13 months before.
361
+ */
362
+ private bookingDate(mmdd: string | undefined, valueDate: Date, [first, last]: Period): Date | undefined {
231
363
  if (mmdd === undefined) return undefined;
232
364
  const month = Number(mmdd.substring(0, 2));
233
365
  const day = Number(mmdd.substring(2, 4));
234
- let year = valueDate.getFullYear();
235
- if (month === 12 && valueDate.getMonth() === 0) year--;
236
- if (month === 1 && valueDate.getMonth() === 11) year++;
237
- const date = clampedDate(year, month, day);
238
- if (date === undefined) throw new FormatError('Invalid booking date', this.line);
239
- return date;
366
+ const outside = (date: Date): number =>
367
+ Math.max(first.getTime() - date.getTime(), date.getTime() - last.getTime(), 0);
368
+ const fromValueDate = (date: Date): number => Math.abs(date.getTime() - valueDate.getTime());
369
+ // The best year lies next to one of these, however long the period.
370
+ const years = new Set(
371
+ [first, valueDate, last].flatMap((d) => [d.getFullYear() - 1, d.getFullYear(), d.getFullYear() + 1]),
372
+ );
373
+ let best: Date | undefined;
374
+ for (const year of years) {
375
+ const date = clampedDate(year, month, day);
376
+ if (date === undefined) throw new FormatError('Invalid booking date', this.line);
377
+ if (best === undefined || (outside(date) - outside(best) || fromValueDate(date) - fromValueDate(best)) < 0) {
378
+ best = date;
379
+ }
380
+ }
381
+ return best;
240
382
  }
241
383
  }
242
384
 
243
385
  /**
244
- * Three-digit business transaction code (GVC), then the first subfield: a separator, usually `?`, and its two-digit
245
- * number. Text that merely starts with three digits, such as `100 Euro`, is free text.
386
+ * Three-digit business transaction code (GVC), then the first subfield: `?` and its two-digit number. Text that merely
387
+ * starts with three digits, such as `100 Euro` or `100,00 EUR`, is free text.
246
388
  */
247
- const structuredPattern = /^(\d{3})([^\w\s])\d{2}/;
389
+ const structuredPattern = /^\d{3}\?\d{2}/;
390
+ const subfieldTag = /\?(\d{2})/g;
248
391
  const ibanPattern = /^[A-Z]{2}\d{2}[A-Z0-9]+$/;
249
392
  const bicPattern = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
250
393
 
251
- /** Subfield pattern for a separator: the separator, a two-digit key, then everything up to the next subfield. */
252
- function subfieldPattern(separator: string): RegExp {
253
- const s = escapeRegExp(separator);
254
- return new RegExp(`${s}(\\d{2})((?:(?!${s}\\d{2})[\\s\\S])*)`, 'g');
255
- }
256
-
257
394
  /** The German structured `:86:` field: a business transaction code and `?nn` subfields. */
258
395
  class Details {
259
396
  transactionCode: string | undefined;
@@ -271,20 +408,16 @@ class Details {
271
408
  const d = new Details();
272
409
  if (raw === undefined) return d;
273
410
  const text = raw.replace(/\n/g, '');
274
- const structured = structuredPattern.exec(text);
275
- if (!structured) {
411
+ if (!structuredPattern.test(text)) {
276
412
  // Free text, whose lines are separate phrases; the purpose and its keywords are read from the same text.
277
- const free = raw.replace(/\n/g, ' ').trim();
413
+ const free = trimBlanks(raw.replace(/\n/g, ' '));
278
414
  d.sepa = SepaPurpose.parse(free);
279
- d.purpose = d.sepa.get('SVWZ') ?? (free === '' ? undefined : free);
415
+ d.purpose = d.sepa.get('SVWZ') ?? nonBlank(free);
280
416
  return d;
281
417
  }
282
- d.transactionCode = structured[1];
283
- const subfields = Details.subfields(text.substring(3), structured[2]!);
284
- const subfield = (key: number): string | undefined => {
285
- const value = subfields.get(key)?.trim();
286
- return value === undefined || value === '' ? undefined : value;
287
- };
418
+ d.transactionCode = text.substring(0, 3);
419
+ const subfields = Details.subfields(text.substring(3));
420
+ const subfield = (key: number): string | undefined => nonBlank(subfields.get(key));
288
421
  // Subfields continue each other: they are joined keeping the spaces at their boundaries.
289
422
  const parts = (keys: number[]): string[] =>
290
423
  keys.map((k) => subfields.get(k)).filter((v): v is string => v !== undefined);
@@ -292,42 +425,31 @@ class Details {
292
425
  d.bookingText = subfield(0);
293
426
  d.primaNota = subfield(10);
294
427
  const purposeParts = parts([20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 60, 61, 62, 63]);
295
- const purpose = purposeParts.join('').trim();
296
428
  d.sepa = SepaPurpose.parseParts(purposeParts);
297
- d.purpose = d.sepa.get('SVWZ') ?? (purpose === '' ? undefined : purpose);
298
- const name = parts([32, 33]).join('').trim();
299
- d.name = name === '' ? undefined : name;
429
+ d.purpose = d.sepa.get('SVWZ') ?? nonBlank(purposeParts.join(''));
430
+ d.name = nonBlank(parts([32, 33]).join(''));
300
431
  d.readCounterpartyAccount(subfield(30), subfield(31));
301
- d.iban ??= d.sepa.get('IBAN');
302
- d.bic ??= d.sepa.get('BIC');
303
432
  return d;
304
433
  }
305
434
 
306
435
  /** The text of each subfield (`?nn` and what follows), by its number; a subfield that appears twice is joined. */
307
- private static subfields(text: string, separator: string): Map<number, string> {
436
+ private static subfields(text: string): Map<number, string> {
308
437
  const subfields = new Map<number, string>();
309
- for (const m of text.matchAll(subfieldPattern(separator))) {
310
- const key = Number(m[1]);
311
- subfields.set(key, (subfields.get(key) ?? '') + m[2]!);
438
+ for (const [tag, value] of taggedParts(text, subfieldTag)) {
439
+ const key = Number(tag);
440
+ subfields.set(key, (subfields.get(key) ?? '') + value);
312
441
  }
313
442
  return subfields;
314
443
  }
315
444
 
316
445
  /**
317
- * Takes the counterparty's account from subfields 30 (bank) and 31 (account): IBAN and BIC, or account number and
318
- * bank code.
446
+ * Takes the counterparty's account from subfields 30 (bank) and 31 (account): a BIC, or else a bank code, and an IBAN,
447
+ * or else an account number.
319
448
  */
320
449
  private readCounterpartyAccount(bank: string | undefined, account: string | undefined): void {
321
- if (account !== undefined && ibanPattern.test(account)) {
322
- this.iban = account;
323
- this.bic = bank;
324
- return;
325
- }
326
- this.accountNumber = account;
327
- if (bank !== undefined && bicPattern.test(bank)) {
328
- this.bic = bank;
329
- } else {
330
- this.bankCode = bank;
331
- }
450
+ if (bank !== undefined && bicPattern.test(bank)) this.bic = bank;
451
+ else this.bankCode = bank;
452
+ if (account !== undefined && ibanPattern.test(account)) this.iban = account;
453
+ else this.accountNumber = account;
332
454
  }
333
455
  }
@@ -1,56 +1,53 @@
1
1
  import type { Transaction } from '../core/transaction.js';
2
- import { BytesBuilder } from '../util/bytes.js';
3
2
  import { decodeBankText, decodeXmlText } from '../util/text.js';
4
- import { Camt } from './camt.js';
5
- import { Mt940 } from './mt940.js';
3
+ import { parseCamt } from './camt.js';
4
+ import { parseMt940 } from './mt940.js';
5
+ import { RecordBudget } from './record-budget.js';
6
6
 
7
7
  /**
8
8
  * The transactions in MT940 data: the `booked` parts joined, since a statement may continue on the next page, then the
9
9
  * `pending` ones (MT942).
10
10
  *
11
- * `currency` applies to statements that do not state one; throws `FormatError` if both are missing.
11
+ * `currency` applies to statements that do not state one; throws `FormatError` if both are missing, and for more than
12
+ * 200,000 statements and transactions in all.
12
13
  */
13
14
  export function mt940Transactions(
14
15
  booked: readonly Uint8Array[],
15
16
  pending: readonly Uint8Array[],
16
17
  currency: string | undefined,
17
18
  ): Transaction[] {
19
+ const budget = new RecordBudget();
18
20
  return [
19
- ...Mt940.parseTransactions(joinedMt940(booked), { currency }),
20
- ...Mt940.parseTransactions(joinedMt940(pending), { currency, status: 'pending' }),
21
- ];
21
+ ...parseMt940(joinedMt940(booked), { currency }, budget),
22
+ ...parseMt940(joinedMt940(pending), { currency, status: 'pending' }, budget),
23
+ ].flatMap((statement) => statement.transactions);
22
24
  }
23
25
 
24
- const lineFeed = 0x0a;
25
- const carriageReturn = 0x0d;
26
- const atSign = 0x40;
27
-
28
26
  /**
29
- * The MT940 `parts` as one text.
27
+ * The MT940 `parts` as one text, whose line separator the parser takes from its first line: a later page may start
28
+ * with a purpose, which the payer writes.
30
29
  *
31
- * Each part ends a line before the next one follows, since the parser finds fields at the start of a line and a bank
32
- * need not end its pages with a line break. Banks that separate lines with `@@` get that separator.
30
+ * Each part is decoded on its own, since a bank may start each page with a byte order mark. Each part ends a line
31
+ * before the next one follows, since the parser finds fields at the start of a line and a bank need not end its pages
32
+ * with a line break. Where no part holds a line break, a part ends its line with `@@` instead: a line break after a
33
+ * first page without `@@`, such as `:20:X`, would make the parser read the `@@` of the others as text.
33
34
  */
34
35
  function joinedMt940(parts: readonly Uint8Array[]): string {
35
- const pages = parts.filter((part) => part.length > 0);
36
- const atSigns = !pages.some((p) => p.includes(lineFeed) || p.includes(carriageReturn));
37
- const endsLine = (page: Uint8Array): boolean =>
38
- atSigns
39
- ? page.length >= 2 && page[page.length - 1] === atSign && page[page.length - 2] === atSign
40
- : page.at(-1) === lineFeed || page.at(-1) === carriageReturn;
41
-
42
- const bytes = new BytesBuilder();
43
- pages.forEach((page, i) => {
44
- if (i > 0 && !endsLine(pages[i - 1]!)) bytes.add(atSigns ? [atSign, atSign] : [lineFeed]);
45
- bytes.add(page);
46
- });
47
- return decodeBankText(bytes.takeBytes());
36
+ const pages = parts.filter((part) => part.length > 0).map((part) => decodeBankText(part));
37
+ const lineEnd = pages.some((page) => page.includes('\n') || page.includes('\r')) ? '\n' : '@@';
38
+ return pages
39
+ .map((page, i) => (i + 1 < pages.length && !page.endsWith(lineEnd) ? `${page}${lineEnd}` : page))
40
+ .join('');
48
41
  }
49
42
 
50
- /** The transactions in camt documents; each document stands on its own. */
43
+ /**
44
+ * The transactions in camt documents; each document stands on its own. Throws `FormatError` for more than 200,000
45
+ * statements and transactions in all.
46
+ */
51
47
  export function camtTransactions(booked: readonly Uint8Array[], pending: readonly Uint8Array[]): Transaction[] {
48
+ const budget = new RecordBudget();
52
49
  return [
53
- ...booked.flatMap((document) => Camt.parseTransactions(decodeXmlText(document))),
54
- ...pending.flatMap((document) => Camt.parseTransactions(decodeXmlText(document), 'pending')),
55
- ];
50
+ ...booked.flatMap((document) => parseCamt(decodeXmlText(document), undefined, budget)),
51
+ ...pending.flatMap((document) => parseCamt(decodeXmlText(document), 'pending', budget)),
52
+ ].flatMap((statement) => statement.transactions);
56
53
  }
@@ -0,0 +1,18 @@
1
+ import { FormatError } from '../util/errors.js';
2
+
3
+ /**
4
+ * Most statements and transactions the parsers build for one query: several times what a year of a busy business
5
+ * account holds, and few enough that a hostile answer cannot exhaust the memory, at about 600 bytes each. The size of
6
+ * the answer does not bound them: a transaction takes up to 25 times the bytes of its MT940 line.
7
+ */
8
+ const maxRecords = 200_000;
9
+
10
+ /** The statements and transactions the parsers may still build; the parses of one query share one budget. */
11
+ export class RecordBudget {
12
+ private left = maxRecords;
13
+
14
+ /** Counts a statement or transaction about to be built; throws `FormatError` for one beyond the budget. */
15
+ take(): void {
16
+ if (--this.left < 0) throw new FormatError(`More than ${maxRecords} statements and transactions`);
17
+ }
18
+ }