@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
@@ -2,7 +2,8 @@ import { Amount } from '../core/amount.js';
2
2
  import { Transaction, type TransactionStatus } from '../core/transaction.js';
3
3
  import { clampedDate, parseSwiftDate } from '../util/dates.js';
4
4
  import { FormatError } from '../util/errors.js';
5
- import { escapeRegExp } from '../util/text.js';
5
+ import { nonBlank } from '../util/text.js';
6
+ import { RecordBudget } from './record-budget.js';
6
7
  import { providedReference, SepaPurpose } from './sepa-purpose.js';
7
8
 
8
9
  /** One statement from an MT940 or MT942 file, with its balances and transactions. */
@@ -23,7 +24,10 @@ export interface Mt940Statement {
23
24
  readonly closingBalance: Amount | undefined;
24
25
  /** The date of `closingBalance`. */
25
26
  readonly closingDate: Date | undefined;
26
- /** The transactions of the statement, in the order the bank lists them. */
27
+ /**
28
+ * The transactions of the statement, in the order the bank lists them; they lead from `openingBalance` to
29
+ * `closingBalance` where the statement states these.
30
+ */
27
31
  readonly transactions: readonly Transaction[];
28
32
  }
29
33
 
@@ -36,8 +40,6 @@ export interface Mt940Options {
36
40
  }
37
41
 
38
42
  const tagPattern = /^:(\d{2}[A-Z]?):/gm;
39
- const statementEnd = /\n-\s*$/;
40
- const trailingNewlines = /\n+$/;
41
43
 
42
44
  /** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
43
45
  export const Mt940 = Object.freeze({
@@ -47,35 +49,79 @@ export const Mt940 = Object.freeze({
47
49
  },
48
50
 
49
51
  /**
50
- * Parses all statements.
52
+ * Parses all statements. Their lines are separated by line breaks, and by `@@` as well where the first line holds it,
53
+ * as some banks separate them.
51
54
  *
52
- * Throws `FormatError` if a transaction line (`:61:`) cannot be read or the currency of a transaction is unknown.
55
+ * Throws `FormatError` for text without any field, and if a transaction line (`:61:`) or a balance cannot be read or
56
+ * the currency of a transaction is unknown. It does as well for a statement that states only one of its opening and
57
+ * closing balances, one of them, its account or its number twice, or transactions that do not lead from the one
58
+ * balance to the other: a line of a purpose that starts like a field, such as `:61:` or `:25:`, would pass for a
59
+ * transaction, the account or a statement of its own otherwise. Text of more than 200,000 statements and
60
+ * transactions is a `FormatError` as well.
53
61
  */
54
62
  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;
63
+ return parseMt940(text, options, new RecordBudget());
76
64
  },
77
65
  });
78
66
 
67
+ /** `Mt940.parse`, which takes each statement and transaction from `budget`, before it builds it. */
68
+ export function parseMt940(text: string, options: Mt940Options, budget: RecordBudget): Mt940Statement[] {
69
+ const status = options.status ?? 'booked';
70
+ const normalized = withLineFeeds(text);
71
+ const statements: Mt940Statement[] = [];
72
+ let current: StatementBuilder | undefined;
73
+ for (const [tag, value] of taggedParts(normalized, tagPattern)) {
74
+ if (tag === '20' || current === undefined) {
75
+ if (current !== undefined) statements.push(current.build());
76
+ budget.take();
77
+ current = new StatementBuilder(status, options.currency);
78
+ }
79
+ if (tag === '61') budget.take();
80
+ current.add(tag, fieldValue(value));
81
+ }
82
+ if (current !== undefined) statements.push(current.build());
83
+ else if (normalized.trim() !== '') throw new FormatError('MT940 data without fields');
84
+ return statements;
85
+ }
86
+
87
+ /**
88
+ * The parts of `text` that the matches of `tag` start, each the first group of its match and the text up to the next
89
+ * match: the fields of a statement, or the subfields of a `:86:`. One by one: text of a few megabytes holds millions of
90
+ * 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
91
+ * few million characters.
92
+ */
93
+ function* taggedParts(text: string, tag: RegExp): Generator<[tag: string, text: string]> {
94
+ let current: string | undefined;
95
+ let start = 0;
96
+ for (const match of text.matchAll(tag)) {
97
+ if (current !== undefined) yield [current, text.substring(start, match.index)];
98
+ current = match[1]!;
99
+ start = match.index + match[0].length;
100
+ }
101
+ if (current !== undefined) yield [current, text.substring(start)];
102
+ }
103
+
104
+ /**
105
+ * `text` with line feeds between its lines. Some banks separate them with `@@`, and may still break the line between
106
+ * statements or at the ends. So `@@` separates lines where the first line holds it, which the bank writes; in other
107
+ * text, `@@` may be part of a purpose, which the payer writes.
108
+ */
109
+ function withLineFeeds(text: string): string {
110
+ const lines = text.replace(/\r\n?/g, '\n');
111
+ return lines.trimStart().split('\n', 1)[0]!.includes('@@') ? lines.replace(/@@/g, '\n') : lines;
112
+ }
113
+
114
+ /**
115
+ * The value of a field in `text`, which runs from its tag to the next one: without the blanks and the statement end
116
+ * (`-`) at its end.
117
+ *
118
+ * Trimmed without a pattern such as `/\n+$/`, which takes quadratic time on a long run of line breaks.
119
+ */
120
+ function fieldValue(text: string): string {
121
+ const value = text.trimEnd();
122
+ return value.endsWith('\n-') ? value.substring(0, value.length - 2).trimEnd() : value;
123
+ }
124
+
79
125
  const balancePattern = /^([CD])(\d{6})([A-Z]{3})(\d+,\d*)/;
80
126
  const currencyPrefix = /^([A-Z]{3})/;
81
127
 
@@ -108,28 +154,36 @@ class StatementBuilder {
108
154
  this.reference = value.trim();
109
155
  break;
110
156
  case '25':
157
+ if (this.accountId !== undefined) throw new FormatError('MT940 statement with two accounts', this.reference);
111
158
  this.accountId = value.trim();
112
159
  break;
113
160
  case '28C':
114
161
  case '28':
162
+ if (this.statementNumber !== undefined) {
163
+ throw new FormatError('MT940 statement with two statement numbers', this.reference);
164
+ }
115
165
  this.statementNumber = value.trim();
116
166
  break;
117
167
  case '60F':
118
168
  case '60M': {
119
- const balance = parseBalance(value);
120
- if (balance) {
121
- [this.opening, this.openingDate] = balance;
122
- this.currency ??= balance[0].currency;
169
+ if (this.opening !== undefined) {
170
+ throw new FormatError('MT940 statement with two opening balances', this.reference);
123
171
  }
172
+ const [balance, date] = parseBalance(value);
173
+ this.opening = balance;
174
+ this.openingDate = date;
175
+ this.currency ??= balance.currency;
124
176
  break;
125
177
  }
126
178
  case '62F':
127
179
  case '62M': {
128
- const balance = parseBalance(value);
129
- if (balance) {
130
- [this.closing, this.closingDate] = balance;
131
- this.currency ??= balance[0].currency;
180
+ if (this.closing !== undefined) {
181
+ throw new FormatError('MT940 statement with two closing balances', this.reference);
132
182
  }
183
+ const [balance, date] = parseBalance(value);
184
+ this.closing = balance;
185
+ this.closingDate = date;
186
+ this.currency ??= balance.currency;
133
187
  break;
134
188
  }
135
189
  case '34F':
@@ -150,6 +204,10 @@ class StatementBuilder {
150
204
  if (statementCurrency === undefined && this.transactions.length > 0) {
151
205
  throw new FormatError('MT940 statement without currency', this.reference);
152
206
  }
207
+ const { openingDate, closingDate } = this;
208
+ const period = openingDate && closingDate ? ([openingDate, closingDate] as const) : undefined;
209
+ const transactions = this.transactions.map((t) => t.toTransaction(statementCurrency!, this.status, period));
210
+ this.checkBalances(transactions);
153
211
  return Object.freeze({
154
212
  reference: this.reference,
155
213
  accountId: this.accountId,
@@ -159,14 +217,39 @@ class StatementBuilder {
159
217
  openingDate: this.openingDate,
160
218
  closingBalance: this.closing,
161
219
  closingDate: this.closingDate,
162
- transactions: Object.freeze(this.transactions.map((t) => t.toTransaction(statementCurrency!, this.status))),
220
+ transactions: Object.freeze(transactions),
163
221
  });
164
222
  }
223
+
224
+ /** Checks that the statement states both balances or neither, and that its `transactions` lead from one to the other. */
225
+ private checkBalances(transactions: readonly Transaction[]): void {
226
+ const { opening, closing } = this;
227
+ if (opening === undefined && closing === undefined) return;
228
+ if (opening === undefined || closing === undefined) {
229
+ throw new FormatError('MT940 statement with only one of its balances', this.reference);
230
+ }
231
+ if (opening.currency !== this.currency || closing.currency !== this.currency) {
232
+ throw new FormatError('MT940 balances in another currency than the statement', this.reference);
233
+ }
234
+ if (!balanceAfter(opening, transactions).equals(closing)) {
235
+ throw new FormatError('MT940 transactions do not add up to the closing balance', this.reference);
236
+ }
237
+ }
238
+ }
239
+
240
+ /** `opening` plus the amounts of `transactions`; throws `FormatError` for a sum beyond what an `Amount` holds. */
241
+ function balanceAfter(opening: Amount, transactions: readonly Transaction[]): Amount {
242
+ try {
243
+ return transactions.reduce((balance, t) => balance.add(t.amount), opening);
244
+ } catch (error) {
245
+ if (error instanceof RangeError) throw new FormatError('MT940 amounts too large to add up');
246
+ throw error;
247
+ }
165
248
  }
166
249
 
167
- function parseBalance(value: string): [Amount, Date] | undefined {
250
+ function parseBalance(value: string): [Amount, Date] {
168
251
  const m = balancePattern.exec(value.trim());
169
- if (!m) return undefined;
252
+ if (!m) throw new FormatError('Unreadable MT940 balance', value);
170
253
  const amount = Amount.parseDecimal(m[4]!, m[3]!);
171
254
  const date = parseSwiftDate(m[2]!);
172
255
  if (date === undefined) throw new FormatError('Invalid balance date', value);
@@ -180,34 +263,33 @@ function parseBalance(value: string): [Amount, Date] | undefined {
180
263
  const line61 =
181
264
  /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
182
265
 
266
+ /** The dates of the opening and the closing balance of a statement, between which its transactions are booked. */
267
+ type Period = readonly [first: Date, last: Date];
268
+
183
269
  /** The `:61:` line of a transaction and its `:86:` details. */
184
270
  class TransactionLines {
185
271
  details: string | undefined;
186
272
 
187
273
  constructor(readonly line: string) {}
188
274
 
189
- toTransaction(currency: string, status: TransactionStatus): Transaction {
275
+ toTransaction(currency: string, status: TransactionStatus, period: Period | undefined): Transaction {
190
276
  const m = line61.exec(this.line.trim());
191
277
  const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
192
278
  if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
193
279
  const mark = m[3]!;
194
280
  const amount = Amount.parseDecimal(m[5]!, currency);
195
281
  const debit = mark === 'D' || mark === 'RC';
196
- const customerReference = m[7]?.trim();
197
- const bankReference = m[8]?.trim();
282
+ const customerReference = nonBlank(m[7]);
198
283
  const d = Details.parse(this.details);
199
284
  return new Transaction({
200
285
  status,
201
- bookingDate: this.bookingDate(m[2], valueDate),
286
+ bookingDate: this.bookingDate(m[2], valueDate, period),
202
287
  valueDate,
203
288
  amount: debit ? amount.negate() : amount,
204
289
  isReversal: mark.startsWith('R'),
205
290
  transactionTypeId: m[6],
206
- customerReference:
207
- customerReference === undefined || customerReference === '' || customerReference === 'NONREF'
208
- ? undefined
209
- : customerReference,
210
- bankReference: bankReference === undefined || bankReference === '' ? undefined : bankReference,
291
+ customerReference: customerReference === 'NONREF' ? undefined : customerReference,
292
+ bankReference: nonBlank(m[8]),
211
293
  transactionCode: d.transactionCode,
212
294
  bookingText: d.bookingText,
213
295
  primaNota: d.primaNota,
@@ -226,34 +308,44 @@ class TransactionLines {
226
308
  });
227
309
  }
228
310
 
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 {
311
+ /**
312
+ * The booking date `MMDD`, in the year that places it within `period`, or else closest to it; of several such years,
313
+ * the one closest to `valueDate`; without a period, the year closest to `valueDate`. The value date alone misleads:
314
+ * banks book refunds and corrections with the value date of the original booking, up to 13 months before.
315
+ */
316
+ private bookingDate(mmdd: string | undefined, valueDate: Date, period: Period | undefined): Date | undefined {
231
317
  if (mmdd === undefined) return undefined;
232
318
  const month = Number(mmdd.substring(0, 2));
233
319
  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;
320
+ const [first, last] = period ?? [valueDate, valueDate];
321
+ const outside = (date: Date): number =>
322
+ Math.max(first.getTime() - date.getTime(), date.getTime() - last.getTime(), 0);
323
+ const fromValueDate = (date: Date): number => Math.abs(date.getTime() - valueDate.getTime());
324
+ // The best year lies next to one of these, however long the period.
325
+ const years = new Set(
326
+ [first, valueDate, last].flatMap((d) => [d.getFullYear() - 1, d.getFullYear(), d.getFullYear() + 1]),
327
+ );
328
+ let best: Date | undefined;
329
+ for (const year of years) {
330
+ const date = clampedDate(year, month, day);
331
+ if (date === undefined) throw new FormatError('Invalid booking date', this.line);
332
+ if (best === undefined || (outside(date) - outside(best) || fromValueDate(date) - fromValueDate(best)) < 0) {
333
+ best = date;
334
+ }
335
+ }
336
+ return best;
240
337
  }
241
338
  }
242
339
 
243
340
  /**
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.
341
+ * Three-digit business transaction code (GVC), then the first subfield: `?` and its two-digit number. Text that merely
342
+ * starts with three digits, such as `100 Euro` or `100,00 EUR`, is free text.
246
343
  */
247
- const structuredPattern = /^(\d{3})([^\w\s])\d{2}/;
344
+ const structuredPattern = /^\d{3}\?\d{2}/;
345
+ const subfieldTag = /\?(\d{2})/g;
248
346
  const ibanPattern = /^[A-Z]{2}\d{2}[A-Z0-9]+$/;
249
347
  const bicPattern = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
250
348
 
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
349
  /** The German structured `:86:` field: a business transaction code and `?nn` subfields. */
258
350
  class Details {
259
351
  transactionCode: string | undefined;
@@ -271,20 +363,16 @@ class Details {
271
363
  const d = new Details();
272
364
  if (raw === undefined) return d;
273
365
  const text = raw.replace(/\n/g, '');
274
- const structured = structuredPattern.exec(text);
275
- if (!structured) {
366
+ if (!structuredPattern.test(text)) {
276
367
  // Free text, whose lines are separate phrases; the purpose and its keywords are read from the same text.
277
368
  const free = raw.replace(/\n/g, ' ').trim();
278
369
  d.sepa = SepaPurpose.parse(free);
279
- d.purpose = d.sepa.get('SVWZ') ?? (free === '' ? undefined : free);
370
+ d.purpose = d.sepa.get('SVWZ') ?? nonBlank(free);
280
371
  return d;
281
372
  }
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
- };
373
+ d.transactionCode = text.substring(0, 3);
374
+ const subfields = Details.subfields(text.substring(3));
375
+ const subfield = (key: number): string | undefined => nonBlank(subfields.get(key));
288
376
  // Subfields continue each other: they are joined keeping the spaces at their boundaries.
289
377
  const parts = (keys: number[]): string[] =>
290
378
  keys.map((k) => subfields.get(k)).filter((v): v is string => v !== undefined);
@@ -292,11 +380,9 @@ class Details {
292
380
  d.bookingText = subfield(0);
293
381
  d.primaNota = subfield(10);
294
382
  const purposeParts = parts([20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 60, 61, 62, 63]);
295
- const purpose = purposeParts.join('').trim();
296
383
  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;
384
+ d.purpose = d.sepa.get('SVWZ') ?? nonBlank(purposeParts.join(''));
385
+ d.name = nonBlank(parts([32, 33]).join(''));
300
386
  d.readCounterpartyAccount(subfield(30), subfield(31));
301
387
  d.iban ??= d.sepa.get('IBAN');
302
388
  d.bic ??= d.sepa.get('BIC');
@@ -304,11 +390,11 @@ class Details {
304
390
  }
305
391
 
306
392
  /** 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> {
393
+ private static subfields(text: string): Map<number, string> {
308
394
  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]!);
395
+ for (const [tag, value] of taggedParts(text, subfieldTag)) {
396
+ const key = Number(tag);
397
+ subfields.set(key, (subfields.get(key) ?? '') + value);
312
398
  }
313
399
  return subfields;
314
400
  }
@@ -1,56 +1,49 @@
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.
33
33
  */
34
34
  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());
35
+ const pages = parts.filter((part) => part.length > 0).map((part) => decodeBankText(part));
36
+ return pages.map((page, i) => (i + 1 < pages.length && !page.endsWith('\n') ? `${page}\n` : page)).join('');
48
37
  }
49
38
 
50
- /** The transactions in camt documents; each document stands on its own. */
39
+ /**
40
+ * The transactions in camt documents; each document stands on its own. Throws `FormatError` for more than 200,000
41
+ * statements and transactions in all.
42
+ */
51
43
  export function camtTransactions(booked: readonly Uint8Array[], pending: readonly Uint8Array[]): Transaction[] {
44
+ const budget = new RecordBudget();
52
45
  return [
53
- ...booked.flatMap((document) => Camt.parseTransactions(decodeXmlText(document))),
54
- ...pending.flatMap((document) => Camt.parseTransactions(decodeXmlText(document), 'pending')),
55
- ];
46
+ ...booked.flatMap((document) => parseCamt(decodeXmlText(document), undefined, budget)),
47
+ ...pending.flatMap((document) => parseCamt(decodeXmlText(document), 'pending', budget)),
48
+ ].flatMap((statement) => statement.transactions);
56
49
  }
@@ -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
+ }
@@ -26,6 +26,9 @@ export function providedReference(reference: string | undefined): string | undef
26
26
 
27
27
  const keywordPattern = new RegExp(`(${keywords.join('|')})\\+`, 'g');
28
28
 
29
+ /** The keywords German banks write after the remittance information (`SVWZ`). */
30
+ const afterRemittance = new Set(['ABWA', 'ABWE']);
31
+
29
32
  /** Parser for the SEPA keywords in a purpose text. */
30
33
  export const SepaPurpose = Object.freeze({
31
34
  /**
@@ -34,6 +37,12 @@ export const SepaPurpose = Object.freeze({
34
37
  * A keyword counts unless a capital letter comes right before it, so `XEREF+` does not open an EREF value. After a
35
38
  * digit or a small letter it does: a purpose line cut at 35 characters is joined to the next one without a space, so
36
39
  * `…4711EREF+` may be where one line ends and the next begins.
40
+ *
41
+ * The remittance information (`SVWZ+`) and the ultimate debtor and creditor (`ABWA+`, `ABWE+`) are text the payer or
42
+ * the payee writes, which banks put after every other keyword. So from the first of them on only `ABWA+` and `ABWE+`
43
+ * start a value: a keyword in that text, such as `IBAN+`, is part of it. A keyword that occurs twice has no value:
44
+ * which of the two the bank wrote cannot be told, since a reference the payee chose, such as `EREF+`, comes before
45
+ * the bank's `CRED+` and may hold `CRED+…` and `SVWZ+`, which turns the bank's value into remittance information.
37
46
  */
38
47
  parse(purpose: string): Map<string, string> {
39
48
  return split(purpose, new Set());
@@ -60,11 +69,21 @@ export const SepaPurpose = Object.freeze({
60
69
  function split(purpose: string, partStarts: ReadonlySet<number>): Map<string, string> {
61
70
  const atWordStart = (index: number): boolean =>
62
71
  index === 0 || partStarts.has(index) || !isCapitalLetter(purpose, index - 1);
63
- const matches = [...purpose.matchAll(keywordPattern)].filter((m) => atWordStart(m.index));
72
+ const occurrences = new Map<string, number>();
73
+ const matches: RegExpExecArray[] = [];
74
+ let inPayerText = false;
75
+ for (const m of purpose.matchAll(keywordPattern)) {
76
+ if (!atWordStart(m.index)) continue;
77
+ const keyword = m[1]!;
78
+ occurrences.set(keyword, (occurrences.get(keyword) ?? 0) + 1);
79
+ if (inPayerText && !afterRemittance.has(keyword)) continue;
80
+ inPayerText ||= keyword === 'SVWZ' || afterRemittance.has(keyword);
81
+ matches.push(m);
82
+ }
64
83
  const result = new Map<string, string>();
65
84
  matches.forEach((m, i) => {
66
85
  const end = i + 1 < matches.length ? matches[i + 1]!.index : purpose.length;
67
- result.set(m[1]!, purpose.substring(m.index + m[0].length, end).trim());
86
+ if (occurrences.get(m[1]!) === 1) result.set(m[1]!, purpose.substring(m.index + m[0].length, end).trim());
68
87
  });
69
88
  return result;
70
89
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * @packageDocumentation
8
8
  */
9
9
 
10
+ export { FinTs3Client, type FinTs3ClientInit } from './client.js';
10
11
  export { Account, AccountDataStatus, type AccountCategory, type AccountInit } from './core/account.js';
11
12
  export { Amount } from './core/amount.js';
12
13
  export { Balance, type BalanceInit } from './core/balance.js';
@@ -27,11 +28,13 @@ export {
27
28
  FinTsDialogAbortedError,
28
29
  FinTsError,
29
30
  FinTsLoginDataChangedError,
31
+ FinTsLoginRefusedError,
30
32
  FinTsPinError,
31
33
  FinTsProtocolError,
32
34
  FinTsStateError,
33
35
  FinTsTanCancelledError,
34
36
  FinTsTanError,
37
+ FinTsTanSetupError,
35
38
  FinTsTransportError,
36
39
  FinTsUnsupportedError,
37
40
  } from './core/errors.js';
@@ -43,7 +46,7 @@ export {
43
46
  type TransactionStatus,
44
47
  } from './core/transaction.js';
45
48
  export { HttpTransport, type FinTsTransport, type HttpTransportOptions } from './core/transport.js';
46
- export { Camt } from './formats/camt.js';
49
+ export { Camt, type CamtStatement } from './formats/camt.js';
47
50
  export { Mt940, type Mt940Options, type Mt940Statement } from './formats/mt940.js';
48
51
  export { SepaPurpose } from './formats/sepa-purpose.js';
49
52
  export {
@@ -65,4 +68,3 @@ export {
65
68
  type TanMethodInit,
66
69
  } from './tan/tan-models.js';
67
70
  export { FormatError } from './util/errors.js';
68
- export { FinTs3Client, type FinTs3ClientInit } from './v3/client.js';