@coderoftime/fints 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/README.md +31 -23
  3. package/dist/client.d.ts +3 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +52 -9
  6. package/dist/client.js.map +1 -1
  7. package/dist/core/account.d.ts +15 -4
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +84 -36
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +13 -3
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +42 -8
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +3 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +5 -0
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +20 -3
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +25 -4
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +42 -15
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +15 -7
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +29 -14
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +17 -5
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +25 -7
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +7 -0
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +10 -1
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +5 -0
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +12 -0
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +22 -7
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +51 -11
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +7 -4
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +53 -54
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +18 -6
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +83 -66
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts.map +1 -1
  54. package/dist/formats/readers.js +6 -2
  55. package/dist/formats/readers.js.map +1 -1
  56. package/dist/formats/sepa-purpose.d.ts +7 -4
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  58. package/dist/formats/sepa-purpose.js +18 -9
  59. package/dist/formats/sepa-purpose.js.map +1 -1
  60. package/dist/session/dialog-runner.d.ts +5 -2
  61. package/dist/session/dialog-runner.d.ts.map +1 -1
  62. package/dist/session/dialog-runner.js +23 -7
  63. package/dist/session/dialog-runner.js.map +1 -1
  64. package/dist/session/login-guard.d.ts +4 -4
  65. package/dist/session/login-guard.d.ts.map +1 -1
  66. package/dist/session/login-guard.js +29 -14
  67. package/dist/session/login-guard.js.map +1 -1
  68. package/dist/session/session-setup.d.ts +14 -5
  69. package/dist/session/session-setup.d.ts.map +1 -1
  70. package/dist/session/session-setup.js +30 -9
  71. package/dist/session/session-setup.js.map +1 -1
  72. package/dist/session/session-state.d.ts +4 -3
  73. package/dist/session/session-state.d.ts.map +1 -1
  74. package/dist/session/session-state.js +28 -9
  75. package/dist/session/session-state.js.map +1 -1
  76. package/dist/session/signed-dialog.d.ts +19 -13
  77. package/dist/session/signed-dialog.d.ts.map +1 -1
  78. package/dist/session/signed-dialog.js +45 -30
  79. package/dist/session/signed-dialog.js.map +1 -1
  80. package/dist/session/user-prompts.d.ts +6 -1
  81. package/dist/session/user-prompts.d.ts.map +1 -1
  82. package/dist/session/user-prompts.js +19 -4
  83. package/dist/session/user-prompts.js.map +1 -1
  84. package/dist/tan/challenge.d.ts +15 -13
  85. package/dist/tan/challenge.d.ts.map +1 -1
  86. package/dist/tan/challenge.js +32 -26
  87. package/dist/tan/challenge.js.map +1 -1
  88. package/dist/tan/flicker.d.ts +5 -1
  89. package/dist/tan/flicker.d.ts.map +1 -1
  90. package/dist/tan/flicker.js +42 -30
  91. package/dist/tan/flicker.js.map +1 -1
  92. package/dist/tan/tan-image.d.ts +4 -1
  93. package/dist/tan/tan-image.d.ts.map +1 -1
  94. package/dist/tan/tan-image.js +8 -2
  95. package/dist/tan/tan-image.js.map +1 -1
  96. package/dist/tan/tan-models.d.ts +9 -1
  97. package/dist/tan/tan-models.d.ts.map +1 -1
  98. package/dist/tan/tan-models.js +14 -2
  99. package/dist/tan/tan-models.js.map +1 -1
  100. package/dist/util/arguments.d.ts +13 -0
  101. package/dist/util/arguments.d.ts.map +1 -0
  102. package/dist/util/arguments.js +40 -0
  103. package/dist/util/arguments.js.map +1 -0
  104. package/dist/util/async.d.ts +4 -1
  105. package/dist/util/async.d.ts.map +1 -1
  106. package/dist/util/async.js +6 -3
  107. package/dist/util/async.js.map +1 -1
  108. package/dist/util/bytes.d.ts +1 -1
  109. package/dist/util/bytes.d.ts.map +1 -1
  110. package/dist/util/bytes.js +3 -3
  111. package/dist/util/bytes.js.map +1 -1
  112. package/dist/util/dates.d.ts +3 -10
  113. package/dist/util/dates.d.ts.map +1 -1
  114. package/dist/util/dates.js +49 -39
  115. package/dist/util/dates.js.map +1 -1
  116. package/dist/util/errors.d.ts +5 -3
  117. package/dist/util/errors.d.ts.map +1 -1
  118. package/dist/util/errors.js +8 -4
  119. package/dist/util/errors.js.map +1 -1
  120. package/dist/util/immutable-map.d.ts +5 -2
  121. package/dist/util/immutable-map.d.ts.map +1 -1
  122. package/dist/util/immutable-map.js +15 -12
  123. package/dist/util/immutable-map.js.map +1 -1
  124. package/dist/util/inspect.d.ts +11 -0
  125. package/dist/util/inspect.d.ts.map +1 -0
  126. package/dist/util/inspect.js +14 -0
  127. package/dist/util/inspect.js.map +1 -0
  128. package/dist/util/text.d.ts +18 -2
  129. package/dist/util/text.d.ts.map +1 -1
  130. package/dist/util/text.js +50 -5
  131. package/dist/util/text.js.map +1 -1
  132. package/dist/util/xml.d.ts +6 -4
  133. package/dist/util/xml.d.ts.map +1 -1
  134. package/dist/util/xml.js +106 -44
  135. package/dist/util/xml.js.map +1 -1
  136. package/dist/v3/bank-connection.d.ts +23 -3
  137. package/dist/v3/bank-connection.d.ts.map +1 -1
  138. package/dist/v3/bank-connection.js +66 -6
  139. package/dist/v3/bank-connection.js.map +1 -1
  140. package/dist/v3/dialog.d.ts +6 -2
  141. package/dist/v3/dialog.d.ts.map +1 -1
  142. package/dist/v3/dialog.js +20 -9
  143. package/dist/v3/dialog.js.map +1 -1
  144. package/dist/v3/message.d.ts +18 -10
  145. package/dist/v3/message.d.ts.map +1 -1
  146. package/dist/v3/message.js +92 -75
  147. package/dist/v3/message.js.map +1 -1
  148. package/dist/v3/parameter-store.d.ts +43 -7
  149. package/dist/v3/parameter-store.d.ts.map +1 -1
  150. package/dist/v3/parameter-store.js +69 -15
  151. package/dist/v3/parameter-store.js.map +1 -1
  152. package/dist/v3/parameters.d.ts +12 -11
  153. package/dist/v3/parameters.d.ts.map +1 -1
  154. package/dist/v3/parameters.js +57 -35
  155. package/dist/v3/parameters.js.map +1 -1
  156. package/dist/v3/protocol.d.ts +16 -9
  157. package/dist/v3/protocol.d.ts.map +1 -1
  158. package/dist/v3/protocol.js +57 -26
  159. package/dist/v3/protocol.js.map +1 -1
  160. package/dist/v3/queries.d.ts +2 -2
  161. package/dist/v3/queries.d.ts.map +1 -1
  162. package/dist/v3/queries.js +24 -16
  163. package/dist/v3/queries.js.map +1 -1
  164. package/dist/v3/segments.d.ts +18 -11
  165. package/dist/v3/segments.d.ts.map +1 -1
  166. package/dist/v3/segments.js +30 -21
  167. package/dist/v3/segments.js.map +1 -1
  168. package/dist/v3/syntax.d.ts +15 -11
  169. package/dist/v3/syntax.d.ts.map +1 -1
  170. package/dist/v3/syntax.js +30 -15
  171. package/dist/v3/syntax.js.map +1 -1
  172. package/package.json +1 -1
  173. package/src/client.ts +58 -17
  174. package/src/core/account.ts +91 -35
  175. package/src/core/amount.ts +42 -9
  176. package/src/core/balance.ts +6 -0
  177. package/src/core/bank-info.ts +28 -4
  178. package/src/core/client.ts +42 -15
  179. package/src/core/config.ts +37 -18
  180. package/src/core/errors.ts +27 -7
  181. package/src/core/feedback.ts +12 -1
  182. package/src/core/transaction.ts +13 -0
  183. package/src/core/transport.ts +62 -16
  184. package/src/formats/camt.ts +62 -51
  185. package/src/formats/mt940.ts +114 -78
  186. package/src/formats/readers.ts +6 -2
  187. package/src/formats/sepa-purpose.ts +17 -9
  188. package/src/session/dialog-runner.ts +34 -7
  189. package/src/session/login-guard.ts +29 -13
  190. package/src/session/session-setup.ts +27 -9
  191. package/src/session/session-state.ts +25 -8
  192. package/src/session/signed-dialog.ts +60 -30
  193. package/src/session/user-prompts.ts +28 -3
  194. package/src/tan/challenge.ts +50 -36
  195. package/src/tan/flicker.ts +40 -29
  196. package/src/tan/tan-image.ts +9 -2
  197. package/src/tan/tan-models.ts +16 -2
  198. package/src/util/arguments.ts +40 -0
  199. package/src/util/async.ts +10 -4
  200. package/src/util/bytes.ts +3 -3
  201. package/src/util/dates.ts +52 -38
  202. package/src/util/errors.ts +9 -4
  203. package/src/util/immutable-map.ts +15 -12
  204. package/src/util/inspect.ts +14 -0
  205. package/src/util/text.ts +47 -5
  206. package/src/util/xml.ts +105 -38
  207. package/src/v3/bank-connection.ts +65 -6
  208. package/src/v3/dialog.ts +18 -8
  209. package/src/v3/message.ts +81 -72
  210. package/src/v3/parameter-store.ts +82 -16
  211. package/src/v3/parameters.ts +68 -41
  212. package/src/v3/protocol.ts +61 -28
  213. package/src/v3/queries.ts +26 -18
  214. package/src/v3/segments.ts +32 -20
  215. package/src/v3/syntax.ts +31 -14
@@ -1,8 +1,11 @@
1
- import { Amount } from '../core/amount.js';
2
- import { Transaction, type TransactionStatus } from '../core/transaction.js';
3
- import { germanDay, parseIsoDate, parseIsoMoment } from '../util/dates.js';
1
+ import { addsUpTo, Amount } from '../core/amount.js';
2
+ import { requireStatus, Transaction, type TransactionStatus } from '../core/transaction.js';
3
+ import { parseIsoDate } from '../util/dates.js';
4
4
  import { fail, FormatError } from '../util/errors.js';
5
+ import { frozenWithInspect } from '../util/inspect.js';
6
+ import { counted, maskedId, nonBlank, trimBlanks } from '../util/text.js';
5
7
  import { parseXml, type XmlElement } from '../util/xml.js';
8
+ import { maxFieldLength } from './mt940.js';
6
9
  import { RecordBudget } from './record-budget.js';
7
10
  import { providedReference, SepaPurpose } from './sepa-purpose.js';
8
11
 
@@ -28,24 +31,32 @@ export const Camt = Object.freeze({
28
31
  * statements of several accounts, which `parse` keeps apart. Throws as `parse` does.
29
32
  */
30
33
  parseTransactions(xml: string, status?: TransactionStatus): Transaction[] {
31
- return Camt.parse(xml, status).flatMap((s) => [...s.transactions]);
34
+ return Camt.parse(xml, status).flatMap((s) => s.transactions);
32
35
  },
33
36
 
34
37
  /**
35
- * Parses all statements, each with the account it is for. Throws `FormatError` for malformed XML, for a document
36
- * without any statement, for an entry without amount, currency, credit/debit indicator or date, for a date it cannot
37
- * read, and for more than 200,000 statements and transactions.
38
+ * Parses all statements, each with the account it is for. Throws `FormatError` for malformed XML, for XML nested more
39
+ * than 100 levels deep or of more than 500,000 nodes besides text or one per 16 characters, whichever is more, for a
40
+ * document without any statement, for an entry without amount, currency, credit/debit indicator or date, for a date
41
+ * it cannot read, for remittance information of more than 65,536 characters, and for more than 200,000 statements and
42
+ * transactions.
38
43
  *
39
- * `status` overrides the booking status stated in the entries.
44
+ * `status` overrides the booking status stated in the entries, unless it is `undefined` or `null`; throws `TypeError`
45
+ * for one other than `booked` and `pending`.
40
46
  */
41
47
  parse(xml: string, status?: TransactionStatus): CamtStatement[] {
42
- return parseCamt(xml, status, new RecordBudget());
48
+ return parseCamt(xml, forcedStatus(status), new RecordBudget());
43
49
  },
44
50
  });
45
51
 
52
+ /** The `status` of `Camt.parse`; `undefined` for `null`, the status the entries state. */
53
+ function forcedStatus(status: unknown): TransactionStatus | undefined {
54
+ return status === undefined || status === null ? undefined : requireStatus(status);
55
+ }
56
+
46
57
  /** `Camt.parse`, which takes each statement and transaction from `budget`, before it builds it. */
47
58
  export function parseCamt(xml: string, status: TransactionStatus | undefined, budget: RecordBudget): CamtStatement[] {
48
- const root = parseXml(xml.trim());
59
+ const root = parseXml(trimBlanks(xml));
49
60
  const statements = root.childElements.flatMap((message) =>
50
61
  message.childElements.filter((c) => statementNames.has(c.localName)),
51
62
  );
@@ -54,25 +65,21 @@ export function parseCamt(xml: string, status: TransactionStatus | undefined, bu
54
65
  budget.take();
55
66
  const account = statement.child('Acct');
56
67
  const entries = statement.childrenNamed('Ntry');
57
- return Object.freeze({
58
- reference: statement.textAt(['Id']),
59
- accountId: account?.textAt(['Id', 'IBAN']) ?? account?.textAt(['Id', 'Othr', 'Id']),
60
- currency: account?.textAt(['Ccy']),
61
- transactions: Object.freeze(entries.flatMap((entry) => new CamtEntry(entry, status, budget).transactions())),
62
- });
68
+ return frozenWithInspect(
69
+ {
70
+ reference: statement.textAt(['Id']),
71
+ accountId: account?.textAt(['Id', 'IBAN']) ?? account?.textAt(['Id', 'Othr', 'Id']),
72
+ currency: account?.textAt(['Ccy']),
73
+ transactions: Object.freeze(entries.flatMap((entry) => new CamtEntry(entry, status, budget).transactions())),
74
+ },
75
+ (s) => `CamtStatement(${maskedId(s.accountId ?? '')}, ${counted(s.transactions.length, 'transaction')})`,
76
+ );
63
77
  });
64
78
  }
65
79
 
66
80
  /** Width of the purpose lines banks cut the remittance information into, as in MT940 and DTAUS. */
67
81
  const cutLineLength = 35;
68
82
 
69
- /**
70
- * The SEPA keywords of `purposeCodes` that count only where the bank states them apart, as `endToEndId`, `mandateId`
71
- * and `creditorId`: in the remittance text, which the payer or the payee writes, a creditor ID or mandate reference
72
- * could be that of a creditor the app trusts.
73
- */
74
- const keywordsStatedApart = new Set(['EREF', 'MREF', 'CRED']);
75
-
76
83
  /**
77
84
  * One booking (`Ntry`) of a report; a batch booking yields one transaction per transaction detail (`TxDtls`) when the
78
85
  * details state their amounts and these add up to the booked amount.
@@ -134,7 +141,8 @@ class CamtEntry {
134
141
  debitIndicator(detail.textAt(['CdtDbtInd'])) ?? this.debit,
135
142
  ),
136
143
  );
137
- return addUpTo(split, booking) ? split : [booking];
144
+ const parts = split.map((t) => t.amount);
145
+ return addsUpTo(parts, booking.amount) ? split : [booking];
138
146
  }
139
147
 
140
148
  /** A transaction of `amount`, booked as a debit if `debit`, with what `details` tell about it. */
@@ -148,25 +156,24 @@ class CamtEntry {
148
156
  const counterparty = originalDebit ? 'Cdtr' : 'Dbtr';
149
157
  const lines = (details?.child('RmtInf')?.childrenNamed('Ustrd') ?? [])
150
158
  .map((line) => line.innerText)
151
- .filter((text) => text.trim() !== '');
159
+ .filter((text) => trimBlanks(text) !== '');
152
160
  // A line of 35 characters goes on in the next one as it is, maybe in the middle of a word or before a blank; any
153
161
  // other line ends a phrase, which the next one follows after a blank.
154
162
  const cut = (i: number): boolean => lines[i]?.length === cutLineLength;
155
163
  const purposeParts = lines.map((line, i) => {
156
- const part = cut(i) ? line : line.trimEnd();
157
- return i === 0 || cut(i - 1) ? part : ` ${part.trimStart()}`;
164
+ const part = cut(i) ? line : trimBlanks(line, { start: false });
165
+ return i === 0 || cut(i - 1) ? part : ` ${trimBlanks(part, { end: false })}`;
158
166
  });
159
- const purpose = purposeParts.join('').trim();
167
+ const remittance = purposeParts.join('');
168
+ // As long as an MT940 field: the keywords of megabytes of text would take memory without limit.
169
+ if (remittance.length > maxFieldLength) throw new FormatError('camt remittance information too long');
160
170
  const keywords = SepaPurpose.parseParts(purposeParts);
161
171
  const endToEndId = providedReference(details?.textAt(['Refs', 'EndToEndId']));
162
172
  const mandateId = details?.textAt(['Refs', 'MndtId']);
163
173
  const creditorId = creditorIdOf(parties?.child('Cdtr'));
164
174
  const purposeCode = details?.textAt(['Purp', 'Cd']);
165
175
  const typeId = this.proprietaryCode[0]?.length === 4 ? this.proprietaryCode[0] : undefined;
166
- const purposeCodes = new Map([...keywords].filter(([keyword]) => !keywordsStatedApart.has(keyword)));
167
- if (endToEndId !== undefined) purposeCodes.set('EREF', endToEndId);
168
- if (mandateId !== undefined) purposeCodes.set('MREF', mandateId);
169
- if (creditorId !== undefined) purposeCodes.set('CRED', creditorId);
176
+ const purposeCodes = withKeywordsStatedApart(keywords, { EREF: endToEndId, MREF: mandateId, CRED: creditorId });
170
177
  if (purposeCode !== undefined) purposeCodes.set('PURP', purposeCode);
171
178
  return new Transaction({
172
179
  status: this.status,
@@ -175,7 +182,11 @@ class CamtEntry {
175
182
  amount: debit ? amount.negate() : amount,
176
183
  isReversal: this.reversal,
177
184
  bookingText: this.bookingText ?? details?.textAt(['AddtlTxInf']),
178
- purpose: keywords.get('SVWZ') ?? (purpose === '' ? details?.textAt(['AddtlTxInf']) : purpose),
185
+ purpose:
186
+ keywords.get('SVWZ') ??
187
+ nonBlank(remittance) ??
188
+ details?.textAt(['RmtInf', 'Strd', 'CdtrRefInf', 'Ref']) ??
189
+ details?.textAt(['AddtlTxInf']),
179
190
  counterpartyName: partyName(parties?.child(counterparty)),
180
191
  counterpartyIban: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'IBAN']),
181
192
  counterpartyAccountNumber: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'Othr', 'Id']),
@@ -206,18 +217,20 @@ function amountOf(element: XmlElement, currency: string): Amount {
206
217
  }
207
218
 
208
219
  /**
209
- * Whether the amounts of `parts` add up to that of `whole`, in its currency; amounts whose sum an `Amount` cannot hold
210
- * do not.
220
+ * `keywords` with the values of EREF, MREF and CRED that the bank `stated` apart, as `endToEndId`, `mandateId` and
221
+ * `creditorId`, in the place the text gives them, and without those it does not state: in the remittance text, which
222
+ * the payer or the payee writes, a creditor ID or mandate reference could be that of a creditor the app trusts.
211
223
  */
212
- function addUpTo(parts: readonly Transaction[], whole: Transaction): boolean {
213
- const total = whole.amount;
214
- if (parts.some((part) => part.amount.currency !== total.currency)) return false;
215
- try {
216
- return parts.reduce((sum, part) => sum.add(part.amount), new Amount(0, 0, total.currency)).equals(total);
217
- } catch (error) {
218
- if (error instanceof RangeError) return false;
219
- throw error;
224
+ function withKeywordsStatedApart(
225
+ keywords: ReadonlyMap<string, string>,
226
+ stated: Readonly<Record<'EREF' | 'MREF' | 'CRED', string | undefined>>,
227
+ ): Map<string, string> {
228
+ const codes = new Map(keywords);
229
+ for (const [keyword, value] of Object.entries(stated)) {
230
+ if (value === undefined) codes.delete(keyword);
231
+ else codes.set(keyword, value);
220
232
  }
233
+ return codes;
221
234
  }
222
235
 
223
236
  /** `true` for `DBIT`, `false` for `CRDT`, `undefined` if `indicator` is missing; throws for any other value. */
@@ -235,16 +248,14 @@ function debitIndicator(indicator: string | undefined): boolean | undefined {
235
248
  }
236
249
 
237
250
  /**
238
- * The calendar date of `e`: its date (`Dt`), or the German day of its date and time (`DtTm`), which may be stated in
239
- * UTC or another offset; `undefined` if it states neither. Throws `FormatError` for one it cannot read, which would
240
- * pass for a missing one otherwise, such as a value date the booking date stands in for.
251
+ * The calendar date of `e`: its date (`Dt`), or else its date and time (`DtTm`), both read by `parseIsoDate`, so that
252
+ * a date and time, which may be stated in UTC or another offset, counts on its German day; `undefined` if it states
253
+ * neither. Throws `FormatError` for one it cannot read, which would pass for a missing one otherwise, such as a value
254
+ * date the booking date stands in for.
241
255
  */
242
256
  function dateOf(e: XmlElement | undefined): Date | undefined {
243
- const date = e?.textAt(['Dt']);
244
- if (date !== undefined) return parseIsoDate(date) ?? fail('Unreadable camt date', date);
245
- const dateTime = e?.textAt(['DtTm']);
246
- if (dateTime === undefined) return undefined;
247
- return germanDay(parseIsoMoment(dateTime) ?? fail('Unreadable camt date', dateTime));
257
+ const date = e?.textAt(['Dt']) ?? e?.textAt(['DtTm']);
258
+ return date === undefined ? undefined : (parseIsoDate(date) ?? fail('Unreadable camt date', date));
248
259
  }
249
260
 
250
261
  function partyName(party: XmlElement | undefined): string | undefined {
@@ -1,8 +1,10 @@
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 { nonBlank } 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';
6
8
  import { RecordBudget } from './record-budget.js';
7
9
  import { providedReference, SepaPurpose } from './sepa-purpose.js';
8
10
 
@@ -18,11 +20,17 @@ export interface Mt940Statement {
18
20
  readonly currency: string | undefined;
19
21
  /** The balance at the start of the statement (field 60), negative for a debit. */
20
22
  readonly openingBalance: Amount | undefined;
21
- /** 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
+ */
22
27
  readonly openingDate: Date | undefined;
23
28
  /** The balance at the end of the statement (field 62), negative for a debit. */
24
29
  readonly closingBalance: Amount | undefined;
25
- /** 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
+ */
26
34
  readonly closingDate: Date | undefined;
27
35
  /**
28
36
  * The transactions of the statement, in the order the bank lists them; they lead from `openingBalance` to
@@ -41,26 +49,35 @@ export interface Mt940Options {
41
49
 
42
50
  const tagPattern = /^:(\d{2}[A-Z]?):/gm;
43
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;
54
+
44
55
  /** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
45
56
  export const Mt940 = Object.freeze({
46
57
  /** Parses all transactions; see `parse`. */
47
58
  parseTransactions(text: string, options: Mt940Options = {}): Transaction[] {
48
- return Mt940.parse(text, options).flatMap((s) => [...s.transactions]);
59
+ return Mt940.parse(text, options).flatMap((s) => s.transactions);
49
60
  },
50
61
 
51
62
  /**
52
63
  * Parses all statements. Their lines are separated by line breaks, and by `@@` as well where the first line holds it,
53
64
  * as some banks separate them.
54
65
  *
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
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
58
70
  * balance to the other: a line of a purpose that starts like a field, such as `:61:` or `:25:`, would pass for a
59
71
  * 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.
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.
61
76
  */
62
77
  parse(text: string, options: Mt940Options = {}): Mt940Statement[] {
63
- return parseMt940(text, options, new RecordBudget());
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());
64
81
  },
65
82
  });
66
83
 
@@ -71,6 +88,7 @@ export function parseMt940(text: string, options: Mt940Options, budget: RecordBu
71
88
  const statements: Mt940Statement[] = [];
72
89
  let current: StatementBuilder | undefined;
73
90
  for (const [tag, value] of taggedParts(normalized, tagPattern)) {
91
+ if (value.length > maxFieldLength) throw new FormatError(`MT940 field :${tag}: too long`);
74
92
  if (tag === '20' || current === undefined) {
75
93
  if (current !== undefined) statements.push(current.build());
76
94
  budget.take();
@@ -102,27 +120,32 @@ function* taggedParts(text: string, tag: RegExp): Generator<[tag: string, text:
102
120
  }
103
121
 
104
122
  /**
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.
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.
108
127
  */
109
128
  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;
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;
112
131
  }
113
132
 
114
133
  /**
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.
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.
117
136
  *
118
137
  * Trimmed without a pattern such as `/\n+$/`, which takes quadratic time on a long run of line breaks.
119
138
  */
120
139
  function fieldValue(text: string): string {
121
- const value = text.trimEnd();
122
- return value.endsWith('\n-') ? value.substring(0, value.length - 2).trimEnd() : value;
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);
123
144
  }
124
145
 
125
- const balancePattern = /^([CD])(\d{6})([A-Z]{3})(\d+,\d*)/;
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}/;
126
149
  const currencyPrefix = /^([A-Z]{3})/;
127
150
 
128
151
  class StatementBuilder {
@@ -134,6 +157,8 @@ class StatementBuilder {
134
157
  openingDate: Date | undefined;
135
158
  closing: Amount | undefined;
136
159
  closingDate: Date | undefined;
160
+ /** The day an MT942 was created (field 13D). */
161
+ creationDate: Date | undefined;
137
162
  readonly transactions: TransactionLines[] = [];
138
163
 
139
164
  /**
@@ -151,19 +176,28 @@ class StatementBuilder {
151
176
  add(tag: string, value: string): void {
152
177
  switch (tag) {
153
178
  case '20':
154
- this.reference = value.trim();
179
+ this.reference = trimBlanks(value);
155
180
  break;
156
181
  case '25':
157
182
  if (this.accountId !== undefined) throw new FormatError('MT940 statement with two accounts', this.reference);
158
- this.accountId = value.trim();
183
+ this.accountId = trimBlanks(value);
159
184
  break;
160
185
  case '28C':
161
186
  case '28':
162
187
  if (this.statementNumber !== undefined) {
163
188
  throw new FormatError('MT940 statement with two statement numbers', this.reference);
164
189
  }
165
- this.statementNumber = value.trim();
190
+ this.statementNumber = trimBlanks(value);
166
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);
199
+ break;
200
+ }
167
201
  case '60F':
168
202
  case '60M': {
169
203
  if (this.opening !== undefined) {
@@ -187,7 +221,7 @@ class StatementBuilder {
187
221
  break;
188
222
  }
189
223
  case '34F':
190
- this.currency ??= currencyPrefix.exec(value.trim())?.[1];
224
+ this.currency ??= currencyPrefix.exec(trimBlanks(value))?.[1];
191
225
  break;
192
226
  case '61':
193
227
  this.transactions.push(new TransactionLines(value));
@@ -205,20 +239,28 @@ class StatementBuilder {
205
239
  throw new FormatError('MT940 statement without currency', this.reference);
206
240
  }
207
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
+ }
208
245
  const period = openingDate && closingDate ? ([openingDate, closingDate] as const) : undefined;
209
- const transactions = this.transactions.map((t) => t.toTransaction(statementCurrency!, this.status, period));
246
+ const transactions = this.transactions.map((t) =>
247
+ t.toTransaction(statementCurrency!, this.status, period, this.creationDate),
248
+ );
210
249
  this.checkBalances(transactions);
211
- return Object.freeze({
212
- reference: this.reference,
213
- accountId: this.accountId,
214
- statementNumber: this.statementNumber,
215
- currency: statementCurrency,
216
- openingBalance: this.opening,
217
- openingDate: this.openingDate,
218
- closingBalance: this.closing,
219
- closingDate: this.closingDate,
220
- transactions: Object.freeze(transactions),
221
- });
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
+ );
222
264
  }
223
265
 
224
266
  /** Checks that the statement states both balances or neither, and that its `transactions` lead from one to the other. */
@@ -231,28 +273,17 @@ class StatementBuilder {
231
273
  if (opening.currency !== this.currency || closing.currency !== this.currency) {
232
274
  throw new FormatError('MT940 balances in another currency than the statement', this.reference);
233
275
  }
234
- if (!balanceAfter(opening, transactions).equals(closing)) {
276
+ if (!addsUpTo([opening, ...transactions.map((t) => t.amount)], closing)) {
235
277
  throw new FormatError('MT940 transactions do not add up to the closing balance', this.reference);
236
278
  }
237
279
  }
238
280
  }
239
281
 
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
- }
248
- }
249
-
250
- function parseBalance(value: string): [Amount, Date] {
251
- const m = balancePattern.exec(value.trim());
282
+ function parseBalance(value: string): [Amount, Date | undefined] {
283
+ const m = balancePattern.exec(trimBlanks(value));
252
284
  if (!m) throw new FormatError('Unreadable MT940 balance', value);
253
285
  const amount = Amount.parseDecimal(m[4]!, m[3]!);
254
- const date = parseSwiftDate(m[2]!);
255
- 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));
256
287
  return [m[1] === 'D' ? amount.negate() : amount, date];
257
288
  }
258
289
 
@@ -263,27 +294,42 @@ function parseBalance(value: string): [Amount, Date] {
263
294
  const line61 =
264
295
  /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
265
296
 
266
- /** The dates of the opening and the closing balance of a statement, between which its transactions are booked. */
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
+ */
267
302
  type Period = readonly [first: Date, last: Date];
268
303
 
304
+ function spanning(a: Date, b: Date): Period {
305
+ return a.getTime() <= b.getTime() ? [a, b] : [b, a];
306
+ }
307
+
269
308
  /** The `:61:` line of a transaction and its `:86:` details. */
270
309
  class TransactionLines {
271
310
  details: string | undefined;
272
311
 
273
312
  constructor(readonly line: string) {}
274
313
 
275
- toTransaction(currency: string, status: TransactionStatus, period: Period | undefined): Transaction {
276
- 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));
277
321
  const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
278
322
  if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
279
323
  const mark = m[3]!;
280
324
  const amount = Amount.parseDecimal(m[5]!, currency);
281
325
  const debit = mark === 'D' || mark === 'RC';
282
- const customerReference = nonBlank(m[7]);
283
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;
284
330
  return new Transaction({
285
331
  status,
286
- bookingDate: this.bookingDate(m[2], valueDate, period),
332
+ bookingDate: this.bookingDate(m[2], valueDate, period ?? spanning(valueDate, created ?? valueDate)),
287
333
  valueDate,
288
334
  amount: debit ? amount.negate() : amount,
289
335
  isReversal: mark.startsWith('R'),
@@ -310,14 +356,13 @@ class TransactionLines {
310
356
 
311
357
  /**
312
358
  * 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.
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.
315
361
  */
316
- private bookingDate(mmdd: string | undefined, valueDate: Date, period: Period | undefined): Date | undefined {
362
+ private bookingDate(mmdd: string | undefined, valueDate: Date, [first, last]: Period): Date | undefined {
317
363
  if (mmdd === undefined) return undefined;
318
364
  const month = Number(mmdd.substring(0, 2));
319
365
  const day = Number(mmdd.substring(2, 4));
320
- const [first, last] = period ?? [valueDate, valueDate];
321
366
  const outside = (date: Date): number =>
322
367
  Math.max(first.getTime() - date.getTime(), date.getTime() - last.getTime(), 0);
323
368
  const fromValueDate = (date: Date): number => Math.abs(date.getTime() - valueDate.getTime());
@@ -365,7 +410,7 @@ class Details {
365
410
  const text = raw.replace(/\n/g, '');
366
411
  if (!structuredPattern.test(text)) {
367
412
  // Free text, whose lines are separate phrases; the purpose and its keywords are read from the same text.
368
- const free = raw.replace(/\n/g, ' ').trim();
413
+ const free = trimBlanks(raw.replace(/\n/g, ' '));
369
414
  d.sepa = SepaPurpose.parse(free);
370
415
  d.purpose = d.sepa.get('SVWZ') ?? nonBlank(free);
371
416
  return d;
@@ -384,8 +429,6 @@ class Details {
384
429
  d.purpose = d.sepa.get('SVWZ') ?? nonBlank(purposeParts.join(''));
385
430
  d.name = nonBlank(parts([32, 33]).join(''));
386
431
  d.readCounterpartyAccount(subfield(30), subfield(31));
387
- d.iban ??= d.sepa.get('IBAN');
388
- d.bic ??= d.sepa.get('BIC');
389
432
  return d;
390
433
  }
391
434
 
@@ -400,20 +443,13 @@ class Details {
400
443
  }
401
444
 
402
445
  /**
403
- * Takes the counterparty's account from subfields 30 (bank) and 31 (account): IBAN and BIC, or account number and
404
- * 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.
405
448
  */
406
449
  private readCounterpartyAccount(bank: string | undefined, account: string | undefined): void {
407
- if (account !== undefined && ibanPattern.test(account)) {
408
- this.iban = account;
409
- this.bic = bank;
410
- return;
411
- }
412
- this.accountNumber = account;
413
- if (bank !== undefined && bicPattern.test(bank)) {
414
- this.bic = bank;
415
- } else {
416
- this.bankCode = bank;
417
- }
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;
418
454
  }
419
455
  }
@@ -29,11 +29,15 @@ export function mt940Transactions(
29
29
  *
30
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
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.
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
36
  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('');
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('');
37
41
  }
38
42
 
39
43
  /**
@@ -1,3 +1,6 @@
1
+ import { invalidArgument } from '../util/errors.js';
2
+ import { trimBlanks } from '../util/text.js';
3
+
1
4
  /** SEPA keywords German banks put into the purpose text, each followed by `+`. */
2
5
  const keywords = [
3
6
  'EREF',
@@ -26,8 +29,8 @@ export function providedReference(reference: string | undefined): string | undef
26
29
 
27
30
  const keywordPattern = new RegExp(`(${keywords.join('|')})\\+`, 'g');
28
31
 
29
- /** The keywords German banks write after the remittance information (`SVWZ`). */
30
- const afterRemittance = new Set(['ABWA', 'ABWE']);
32
+ /** The keywords of the text the payer or the payee writes, which German banks put after every other keyword. */
33
+ const payerText = new Set(['SVWZ', 'ABWA', 'ABWE']);
31
34
 
32
35
  /** Parser for the SEPA keywords in a purpose text. */
33
36
  export const SepaPurpose = Object.freeze({
@@ -39,10 +42,11 @@ export const SepaPurpose = Object.freeze({
39
42
  * `…4711EREF+` may be where one line ends and the next begins.
40
43
  *
41
44
  * 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.
45
+ * the payee writes, which banks put after every other keyword, some of them `ABWA+` or `ABWE+` first. So from the
46
+ * first of them on only these three start a value: any other keyword in that text, such as `IBAN+`, is part of it. A
47
+ * keyword that occurs twice has no value: which of the two the bank wrote cannot be told, since a reference the payee
48
+ * chose, such as `EREF+`, comes before the bank's `CRED+` and may hold `CRED+…` and `SVWZ+`, which turns the bank's
49
+ * value into remittance information.
46
50
  */
47
51
  parse(purpose: string): Map<string, string> {
48
52
  return split(purpose, new Set());
@@ -54,11 +58,15 @@ export const SepaPurpose = Object.freeze({
54
58
  *
55
59
  * A keyword also counts at the start of a part, where the previous value may end in a capital letter, as in
56
60
  * `EREF+NOTPROVIDED` followed by a part `MREF+…`.
61
+ *
62
+ * Throws `TypeError` for a string, whose characters would count as parts, and for parts that are no strings.
57
63
  */
58
64
  parseParts(parts: Iterable<string>): Map<string, string> {
65
+ if (typeof parts === 'string') throw invalidArgument('Must be the parts, not one string', 'parts');
59
66
  let text = '';
60
67
  const partStarts = new Set<number>();
61
68
  for (const part of parts) {
69
+ if (typeof part !== 'string') throw invalidArgument('Must be strings', 'parts');
62
70
  partStarts.add(text.length);
63
71
  text += part;
64
72
  }
@@ -76,14 +84,14 @@ function split(purpose: string, partStarts: ReadonlySet<number>): Map<string, st
76
84
  if (!atWordStart(m.index)) continue;
77
85
  const keyword = m[1]!;
78
86
  occurrences.set(keyword, (occurrences.get(keyword) ?? 0) + 1);
79
- if (inPayerText && !afterRemittance.has(keyword)) continue;
80
- inPayerText ||= keyword === 'SVWZ' || afterRemittance.has(keyword);
87
+ if (inPayerText && !payerText.has(keyword)) continue;
88
+ inPayerText ||= payerText.has(keyword);
81
89
  matches.push(m);
82
90
  }
83
91
  const result = new Map<string, string>();
84
92
  matches.forEach((m, i) => {
85
93
  const end = i + 1 < matches.length ? matches[i + 1]!.index : purpose.length;
86
- if (occurrences.get(m[1]!) === 1) result.set(m[1]!, purpose.substring(m.index + m[0].length, end).trim());
94
+ if (occurrences.get(m[1]!) === 1) result.set(m[1]!, trimBlanks(purpose.substring(m.index + m[0].length, end)));
87
95
  });
88
96
  return result;
89
97
  }