@coderoftime/fints 0.0.0-stage → 0.1.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 (254) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +28 -0
  3. package/README.md +156 -2
  4. package/dist/core/account.d.ts +140 -0
  5. package/dist/core/account.d.ts.map +1 -0
  6. package/dist/core/account.js +174 -0
  7. package/dist/core/account.js.map +1 -0
  8. package/dist/core/amount.d.ts +62 -0
  9. package/dist/core/amount.d.ts.map +1 -0
  10. package/dist/core/amount.js +189 -0
  11. package/dist/core/amount.js.map +1 -0
  12. package/dist/core/balance.d.ts +54 -0
  13. package/dist/core/balance.d.ts.map +1 -0
  14. package/dist/core/balance.js +53 -0
  15. package/dist/core/balance.js.map +1 -0
  16. package/dist/core/bank-info.d.ts +49 -0
  17. package/dist/core/bank-info.d.ts.map +1 -0
  18. package/dist/core/bank-info.js +52 -0
  19. package/dist/core/bank-info.js.map +1 -0
  20. package/dist/core/client.d.ts +126 -0
  21. package/dist/core/client.d.ts.map +1 -0
  22. package/dist/core/client.js +2 -0
  23. package/dist/core/client.js.map +1 -0
  24. package/dist/core/config.d.ts +68 -0
  25. package/dist/core/config.d.ts.map +1 -0
  26. package/dist/core/config.js +113 -0
  27. package/dist/core/config.js.map +1 -0
  28. package/dist/core/errors.d.ts +91 -0
  29. package/dist/core/errors.d.ts.map +1 -0
  30. package/dist/core/errors.js +182 -0
  31. package/dist/core/errors.js.map +1 -0
  32. package/dist/core/feedback.d.ts +69 -0
  33. package/dist/core/feedback.d.ts.map +1 -0
  34. package/dist/core/feedback.js +124 -0
  35. package/dist/core/feedback.js.map +1 -0
  36. package/dist/core/transaction.d.ts +98 -0
  37. package/dist/core/transaction.d.ts.map +1 -0
  38. package/dist/core/transaction.js +91 -0
  39. package/dist/core/transaction.js.map +1 -0
  40. package/dist/core/transport.d.ts +37 -0
  41. package/dist/core/transport.d.ts.map +1 -0
  42. package/dist/core/transport.js +92 -0
  43. package/dist/core/transport.js.map +1 -0
  44. package/dist/formats/camt.d.ts +12 -0
  45. package/dist/formats/camt.d.ts.map +1 -0
  46. package/dist/formats/camt.js +159 -0
  47. package/dist/formats/camt.js.map +1 -0
  48. package/dist/formats/mt940.d.ts +42 -0
  49. package/dist/formats/mt940.d.ts.map +1 -0
  50. package/dist/formats/mt940.js +297 -0
  51. package/dist/formats/mt940.js.map +1 -0
  52. package/dist/formats/readers.d.ts +11 -0
  53. package/dist/formats/readers.d.ts.map +1 -0
  54. package/dist/formats/readers.js +47 -0
  55. package/dist/formats/readers.js.map +1 -0
  56. package/dist/formats/sepa-purpose.d.ts +22 -0
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -0
  58. package/dist/formats/sepa-purpose.js +68 -0
  59. package/dist/formats/sepa-purpose.js.map +1 -0
  60. package/dist/index.d.ts +28 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +27 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/session/app-approval.d.ts +60 -0
  65. package/dist/session/app-approval.d.ts.map +1 -0
  66. package/dist/session/app-approval.js +215 -0
  67. package/dist/session/app-approval.js.map +1 -0
  68. package/dist/session/bank-connection.d.ts +28 -0
  69. package/dist/session/bank-connection.d.ts.map +1 -0
  70. package/dist/session/bank-connection.js +48 -0
  71. package/dist/session/bank-connection.js.map +1 -0
  72. package/dist/session/dialog-runner.d.ts +34 -0
  73. package/dist/session/dialog-runner.d.ts.map +1 -0
  74. package/dist/session/dialog-runner.js +112 -0
  75. package/dist/session/dialog-runner.js.map +1 -0
  76. package/dist/session/login-guard.d.ts +73 -0
  77. package/dist/session/login-guard.d.ts.map +1 -0
  78. package/dist/session/login-guard.js +109 -0
  79. package/dist/session/login-guard.js.map +1 -0
  80. package/dist/session/parameter-data.d.ts +83 -0
  81. package/dist/session/parameter-data.d.ts.map +1 -0
  82. package/dist/session/parameter-data.js +68 -0
  83. package/dist/session/parameter-data.js.map +1 -0
  84. package/dist/session/pin-tan-client.d.ts +54 -0
  85. package/dist/session/pin-tan-client.d.ts.map +1 -0
  86. package/dist/session/pin-tan-client.js +153 -0
  87. package/dist/session/pin-tan-client.js.map +1 -0
  88. package/dist/session/pin-tan-dialog.d.ts +71 -0
  89. package/dist/session/pin-tan-dialog.d.ts.map +1 -0
  90. package/dist/session/pin-tan-dialog.js +124 -0
  91. package/dist/session/pin-tan-dialog.js.map +1 -0
  92. package/dist/session/protocol.d.ts +209 -0
  93. package/dist/session/protocol.d.ts.map +1 -0
  94. package/dist/session/protocol.js +75 -0
  95. package/dist/session/protocol.js.map +1 -0
  96. package/dist/session/response-handler.d.ts +48 -0
  97. package/dist/session/response-handler.d.ts.map +1 -0
  98. package/dist/session/response-handler.js +121 -0
  99. package/dist/session/response-handler.js.map +1 -0
  100. package/dist/session/sepa-accounts.d.ts +34 -0
  101. package/dist/session/sepa-accounts.d.ts.map +1 -0
  102. package/dist/session/sepa-accounts.js +96 -0
  103. package/dist/session/sepa-accounts.js.map +1 -0
  104. package/dist/session/session-setup.d.ts +72 -0
  105. package/dist/session/session-setup.d.ts.map +1 -0
  106. package/dist/session/session-setup.js +160 -0
  107. package/dist/session/session-setup.js.map +1 -0
  108. package/dist/session/session-state.d.ts +47 -0
  109. package/dist/session/session-state.d.ts.map +1 -0
  110. package/dist/session/session-state.js +147 -0
  111. package/dist/session/session-state.js.map +1 -0
  112. package/dist/session/tan-selection.d.ts +88 -0
  113. package/dist/session/tan-selection.d.ts.map +1 -0
  114. package/dist/session/tan-selection.js +178 -0
  115. package/dist/session/tan-selection.js.map +1 -0
  116. package/dist/session/user-prompts.d.ts +61 -0
  117. package/dist/session/user-prompts.d.ts.map +1 -0
  118. package/dist/session/user-prompts.js +164 -0
  119. package/dist/session/user-prompts.js.map +1 -0
  120. package/dist/tan/challenge.d.ts +114 -0
  121. package/dist/tan/challenge.d.ts.map +1 -0
  122. package/dist/tan/challenge.js +185 -0
  123. package/dist/tan/challenge.js.map +1 -0
  124. package/dist/tan/flicker.d.ts +36 -0
  125. package/dist/tan/flicker.d.ts.map +1 -0
  126. package/dist/tan/flicker.js +262 -0
  127. package/dist/tan/flicker.js.map +1 -0
  128. package/dist/tan/tan-image.d.ts +21 -0
  129. package/dist/tan/tan-image.d.ts.map +1 -0
  130. package/dist/tan/tan-image.js +41 -0
  131. package/dist/tan/tan-image.js.map +1 -0
  132. package/dist/tan/tan-models.d.ts +145 -0
  133. package/dist/tan/tan-models.d.ts.map +1 -0
  134. package/dist/tan/tan-models.js +148 -0
  135. package/dist/tan/tan-models.js.map +1 -0
  136. package/dist/util/async.d.ts +24 -0
  137. package/dist/util/async.d.ts.map +1 -0
  138. package/dist/util/async.js +49 -0
  139. package/dist/util/async.js.map +1 -0
  140. package/dist/util/bytes.d.ts +23 -0
  141. package/dist/util/bytes.d.ts.map +1 -0
  142. package/dist/util/bytes.js +136 -0
  143. package/dist/util/bytes.js.map +1 -0
  144. package/dist/util/call-context.d.ts +12 -0
  145. package/dist/util/call-context.d.ts.map +1 -0
  146. package/dist/util/call-context.js +29 -0
  147. package/dist/util/call-context.js.map +1 -0
  148. package/dist/util/dates.d.ts +62 -0
  149. package/dist/util/dates.d.ts.map +1 -0
  150. package/dist/util/dates.js +169 -0
  151. package/dist/util/dates.js.map +1 -0
  152. package/dist/util/errors.d.ts +9 -0
  153. package/dist/util/errors.d.ts.map +1 -0
  154. package/dist/util/errors.js +15 -0
  155. package/dist/util/errors.js.map +1 -0
  156. package/dist/util/immutable-map.d.ts +16 -0
  157. package/dist/util/immutable-map.d.ts.map +1 -0
  158. package/dist/util/immutable-map.js +38 -0
  159. package/dist/util/immutable-map.js.map +1 -0
  160. package/dist/util/json.d.ts +13 -0
  161. package/dist/util/json.d.ts.map +1 -0
  162. package/dist/util/json.js +44 -0
  163. package/dist/util/json.js.map +1 -0
  164. package/dist/util/text.d.ts +20 -0
  165. package/dist/util/text.d.ts.map +1 -0
  166. package/dist/util/text.js +78 -0
  167. package/dist/util/text.js.map +1 -0
  168. package/dist/util/xml.d.ts +35 -0
  169. package/dist/util/xml.d.ts.map +1 -0
  170. package/dist/util/xml.js +183 -0
  171. package/dist/util/xml.js.map +1 -0
  172. package/dist/v3/account-extension.d.ts +25 -0
  173. package/dist/v3/account-extension.d.ts.map +1 -0
  174. package/dist/v3/account-extension.js +176 -0
  175. package/dist/v3/account-extension.js.map +1 -0
  176. package/dist/v3/client.d.ts +24 -0
  177. package/dist/v3/client.d.ts.map +1 -0
  178. package/dist/v3/client.js +16 -0
  179. package/dist/v3/client.js.map +1 -0
  180. package/dist/v3/dialog.d.ts +78 -0
  181. package/dist/v3/dialog.d.ts.map +1 -0
  182. package/dist/v3/dialog.js +138 -0
  183. package/dist/v3/dialog.js.map +1 -0
  184. package/dist/v3/message.d.ts +72 -0
  185. package/dist/v3/message.d.ts.map +1 -0
  186. package/dist/v3/message.js +259 -0
  187. package/dist/v3/message.js.map +1 -0
  188. package/dist/v3/parameters.d.ts +79 -0
  189. package/dist/v3/parameters.d.ts.map +1 -0
  190. package/dist/v3/parameters.js +396 -0
  191. package/dist/v3/parameters.js.map +1 -0
  192. package/dist/v3/protocol.d.ts +68 -0
  193. package/dist/v3/protocol.d.ts.map +1 -0
  194. package/dist/v3/protocol.js +263 -0
  195. package/dist/v3/protocol.js.map +1 -0
  196. package/dist/v3/segments.d.ts +89 -0
  197. package/dist/v3/segments.d.ts.map +1 -0
  198. package/dist/v3/segments.js +283 -0
  199. package/dist/v3/segments.js.map +1 -0
  200. package/dist/v3/syntax.d.ts +76 -0
  201. package/dist/v3/syntax.d.ts.map +1 -0
  202. package/dist/v3/syntax.js +301 -0
  203. package/dist/v3/syntax.js.map +1 -0
  204. package/package.json +63 -4
  205. package/src/core/account.ts +250 -0
  206. package/src/core/amount.ts +209 -0
  207. package/src/core/balance.ts +84 -0
  208. package/src/core/bank-info.ts +66 -0
  209. package/src/core/client.ts +149 -0
  210. package/src/core/config.ts +141 -0
  211. package/src/core/errors.ts +203 -0
  212. package/src/core/feedback.ts +137 -0
  213. package/src/core/transaction.ts +156 -0
  214. package/src/core/transport.ts +114 -0
  215. package/src/formats/camt.ts +169 -0
  216. package/src/formats/mt940.ts +333 -0
  217. package/src/formats/readers.ts +56 -0
  218. package/src/formats/sepa-purpose.ts +75 -0
  219. package/src/index.ts +68 -0
  220. package/src/session/app-approval.ts +265 -0
  221. package/src/session/bank-connection.ts +51 -0
  222. package/src/session/dialog-runner.ts +119 -0
  223. package/src/session/login-guard.ts +135 -0
  224. package/src/session/parameter-data.ts +137 -0
  225. package/src/session/pin-tan-client.ts +186 -0
  226. package/src/session/pin-tan-dialog.ts +160 -0
  227. package/src/session/protocol.ts +311 -0
  228. package/src/session/response-handler.ts +130 -0
  229. package/src/session/sepa-accounts.ts +97 -0
  230. package/src/session/session-setup.ts +165 -0
  231. package/src/session/session-state.ts +170 -0
  232. package/src/session/tan-selection.ts +192 -0
  233. package/src/session/user-prompts.ts +174 -0
  234. package/src/tan/challenge.ts +252 -0
  235. package/src/tan/flicker.ts +270 -0
  236. package/src/tan/tan-image.ts +36 -0
  237. package/src/tan/tan-models.ts +247 -0
  238. package/src/util/async.ts +53 -0
  239. package/src/util/bytes.ts +135 -0
  240. package/src/util/call-context.ts +40 -0
  241. package/src/util/dates.ts +193 -0
  242. package/src/util/errors.ts +16 -0
  243. package/src/util/immutable-map.ts +47 -0
  244. package/src/util/json.ts +51 -0
  245. package/src/util/text.ts +82 -0
  246. package/src/util/xml.ts +172 -0
  247. package/src/v3/account-extension.ts +190 -0
  248. package/src/v3/client.ts +29 -0
  249. package/src/v3/dialog.ts +199 -0
  250. package/src/v3/message.ts +321 -0
  251. package/src/v3/parameters.ts +451 -0
  252. package/src/v3/protocol.ts +331 -0
  253. package/src/v3/segments.ts +331 -0
  254. package/src/v3/syntax.ts +307 -0
@@ -0,0 +1,333 @@
1
+ import { Amount } from '../core/amount.js';
2
+ import { Transaction, type TransactionStatus } from '../core/transaction.js';
3
+ import { clampedDate, parseSwiftDate } from '../util/dates.js';
4
+ import { FormatError } from '../util/errors.js';
5
+ import { escapeRegExp } from '../util/text.js';
6
+ import { providedReference, SepaPurpose } from './sepa-purpose.js';
7
+
8
+ /** One statement from an MT940 or MT942 file, with its balances and transactions. */
9
+ export interface Mt940Statement {
10
+ /** The sender's reference of the statement (field 20). */
11
+ readonly reference: string | undefined;
12
+ /** The account the statement is for (field 25). */
13
+ readonly accountId: string | undefined;
14
+ /** Statement and sequence number (field 28C). */
15
+ readonly statementNumber: string | undefined;
16
+ /** Currency of the statement; `undefined` for a statement without balances and transactions. */
17
+ readonly currency: string | undefined;
18
+ /** The balance at the start of the statement (field 60), negative for a debit. */
19
+ readonly openingBalance: Amount | undefined;
20
+ /** The date of `openingBalance`. */
21
+ readonly openingDate: Date | undefined;
22
+ /** The balance at the end of the statement (field 62), negative for a debit. */
23
+ readonly closingBalance: Amount | undefined;
24
+ /** The date of `closingBalance`. */
25
+ readonly closingDate: Date | undefined;
26
+ /** The transactions of the statement, in the order the bank lists them. */
27
+ readonly transactions: readonly Transaction[];
28
+ }
29
+
30
+ /** What the MT940 parser takes besides the text. */
31
+ export interface Mt940Options {
32
+ /** The status of the transactions: `booked` (MT940, the default) or `pending` (MT942). */
33
+ status?: TransactionStatus;
34
+ /** The currency of statements that state none in their balances. */
35
+ currency?: string | undefined;
36
+ }
37
+
38
+ const tagPattern = /^:(\d{2}[A-Z]?):/gm;
39
+ const statementEnd = /\n-\s*$/;
40
+ const trailingNewlines = /\n+$/;
41
+
42
+ /** Parser for SWIFT MT940/MT942 statements, including German structured `:86:` fields. */
43
+ export const Mt940 = Object.freeze({
44
+ /** Parses all transactions; see `parse`. */
45
+ parseTransactions(text: string, options: Mt940Options = {}): Transaction[] {
46
+ return Mt940.parse(text, options).flatMap((s) => [...s.transactions]);
47
+ },
48
+
49
+ /**
50
+ * Parses all statements.
51
+ *
52
+ * Throws `FormatError` if a transaction line (`:61:`) cannot be read or the currency of a transaction is unknown.
53
+ */
54
+ 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;
76
+ },
77
+ });
78
+
79
+ const balancePattern = /^([CD])(\d{6})([A-Z]{3})(\d+,\d*)/;
80
+ const currencyPrefix = /^([A-Z]{3})/;
81
+
82
+ class StatementBuilder {
83
+ reference: string | undefined;
84
+ accountId: string | undefined;
85
+ statementNumber: string | undefined;
86
+ currency: string | undefined;
87
+ opening: Amount | undefined;
88
+ openingDate: Date | undefined;
89
+ closing: Amount | undefined;
90
+ closingDate: Date | undefined;
91
+ readonly transactions: TransactionLines[] = [];
92
+
93
+ /**
94
+ * The tag added last: a `:86:` right after a `:61:` details the transaction, anywhere else it informs the account
95
+ * owner about the statement.
96
+ */
97
+ private previousTag: string | undefined;
98
+
99
+ constructor(
100
+ private readonly status: TransactionStatus,
101
+ /** Currency to use when the statement states none. */
102
+ private readonly fallbackCurrency: string | undefined,
103
+ ) {}
104
+
105
+ add(tag: string, value: string): void {
106
+ switch (tag) {
107
+ case '20':
108
+ this.reference = value.trim();
109
+ break;
110
+ case '25':
111
+ this.accountId = value.trim();
112
+ break;
113
+ case '28C':
114
+ case '28':
115
+ this.statementNumber = value.trim();
116
+ break;
117
+ case '60F':
118
+ case '60M': {
119
+ const balance = parseBalance(value);
120
+ if (balance) {
121
+ [this.opening, this.openingDate] = balance;
122
+ this.currency ??= balance[0].currency;
123
+ }
124
+ break;
125
+ }
126
+ case '62F':
127
+ case '62M': {
128
+ const balance = parseBalance(value);
129
+ if (balance) {
130
+ [this.closing, this.closingDate] = balance;
131
+ this.currency ??= balance[0].currency;
132
+ }
133
+ break;
134
+ }
135
+ case '34F':
136
+ this.currency ??= currencyPrefix.exec(value.trim())?.[1];
137
+ break;
138
+ case '61':
139
+ this.transactions.push(new TransactionLines(value));
140
+ break;
141
+ case '86':
142
+ if (this.previousTag === '61') this.transactions.at(-1)!.details = value;
143
+ break;
144
+ }
145
+ this.previousTag = tag;
146
+ }
147
+
148
+ build(): Mt940Statement {
149
+ const statementCurrency = this.currency ?? this.fallbackCurrency;
150
+ if (statementCurrency === undefined && this.transactions.length > 0) {
151
+ throw new FormatError('MT940 statement without currency', this.reference);
152
+ }
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
+ });
164
+ }
165
+ }
166
+
167
+ function parseBalance(value: string): [Amount, Date] | undefined {
168
+ const m = balancePattern.exec(value.trim());
169
+ if (!m) return undefined;
170
+ 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);
173
+ return [m[1] === 'D' ? amount.negate() : amount, date];
174
+ }
175
+
176
+ /**
177
+ * Value date, optional booking date (MMDD), debit/credit mark, funds code, amount, transaction type, customer
178
+ * reference, optional bank reference and supplementary details.
179
+ */
180
+ const line61 =
181
+ /^(\d{6})(\d{4})?(RC|RD|C|D)([A-Z])?(\d+,\d*)([NFS][A-Z0-9]{3})([^\n]*?)(?:\/\/([^\n]*))?(?:\n([\s\S]*))?$/;
182
+
183
+ /** The `:61:` line of a transaction and its `:86:` details. */
184
+ class TransactionLines {
185
+ details: string | undefined;
186
+
187
+ constructor(readonly line: string) {}
188
+
189
+ toTransaction(currency: string, status: TransactionStatus): Transaction {
190
+ const m = line61.exec(this.line.trim());
191
+ const valueDate = m ? parseSwiftDate(m[1]!) : undefined;
192
+ if (!m || valueDate === undefined) throw new FormatError('Unreadable MT940 transaction line (:61:)', this.line);
193
+ const mark = m[3]!;
194
+ const amount = Amount.parseDecimal(m[5]!, currency);
195
+ const debit = mark === 'D' || mark === 'RC';
196
+ const customerReference = m[7]?.trim();
197
+ const bankReference = m[8]?.trim();
198
+ const d = Details.parse(this.details);
199
+ return new Transaction({
200
+ status,
201
+ bookingDate: this.bookingDate(m[2], valueDate),
202
+ valueDate,
203
+ amount: debit ? amount.negate() : amount,
204
+ isReversal: mark.startsWith('R'),
205
+ transactionTypeId: m[6],
206
+ customerReference:
207
+ customerReference === undefined || customerReference === '' || customerReference === 'NONREF'
208
+ ? undefined
209
+ : customerReference,
210
+ bankReference: bankReference === undefined || bankReference === '' ? undefined : bankReference,
211
+ transactionCode: d.transactionCode,
212
+ bookingText: d.bookingText,
213
+ primaNota: d.primaNota,
214
+ purpose: d.purpose,
215
+ counterpartyName: d.name,
216
+ counterpartyIban: d.iban,
217
+ counterpartyBic: d.bic,
218
+ counterpartyAccountNumber: d.accountNumber,
219
+ counterpartyBankCode: d.bankCode,
220
+ endToEndId: providedReference(d.sepa.get('EREF')),
221
+ mandateId: d.sepa.get('MREF'),
222
+ creditorId: d.sepa.get('CRED'),
223
+ ultimateCreditor: d.sepa.get('ABWE'),
224
+ ultimateDebtor: d.sepa.get('ABWA'),
225
+ purposeCodes: d.sepa,
226
+ });
227
+ }
228
+
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 {
231
+ if (mmdd === undefined) return undefined;
232
+ const month = Number(mmdd.substring(0, 2));
233
+ 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;
240
+ }
241
+ }
242
+
243
+ /**
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.
246
+ */
247
+ const structuredPattern = /^(\d{3})([^\w\s])\d{2}/;
248
+ const ibanPattern = /^[A-Z]{2}\d{2}[A-Z0-9]+$/;
249
+ const bicPattern = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
250
+
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
+ /** The German structured `:86:` field: a business transaction code and `?nn` subfields. */
258
+ class Details {
259
+ transactionCode: string | undefined;
260
+ bookingText: string | undefined;
261
+ primaNota: string | undefined;
262
+ purpose: string | undefined;
263
+ name: string | undefined;
264
+ iban: string | undefined;
265
+ bic: string | undefined;
266
+ accountNumber: string | undefined;
267
+ bankCode: string | undefined;
268
+ sepa = new Map<string, string>();
269
+
270
+ static parse(raw: string | undefined): Details {
271
+ const d = new Details();
272
+ if (raw === undefined) return d;
273
+ const text = raw.replace(/\n/g, '');
274
+ const structured = structuredPattern.exec(text);
275
+ if (!structured) {
276
+ // 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();
278
+ d.sepa = SepaPurpose.parse(free);
279
+ d.purpose = d.sepa.get('SVWZ') ?? (free === '' ? undefined : free);
280
+ return d;
281
+ }
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
+ };
288
+ // Subfields continue each other: they are joined keeping the spaces at their boundaries.
289
+ const parts = (keys: number[]): string[] =>
290
+ keys.map((k) => subfields.get(k)).filter((v): v is string => v !== undefined);
291
+
292
+ d.bookingText = subfield(0);
293
+ d.primaNota = subfield(10);
294
+ const purposeParts = parts([20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 60, 61, 62, 63]);
295
+ const purpose = purposeParts.join('').trim();
296
+ 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;
300
+ d.readCounterpartyAccount(subfield(30), subfield(31));
301
+ d.iban ??= d.sepa.get('IBAN');
302
+ d.bic ??= d.sepa.get('BIC');
303
+ return d;
304
+ }
305
+
306
+ /** 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> {
308
+ 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]!);
312
+ }
313
+ return subfields;
314
+ }
315
+
316
+ /**
317
+ * Takes the counterparty's account from subfields 30 (bank) and 31 (account): IBAN and BIC, or account number and
318
+ * bank code.
319
+ */
320
+ 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
+ }
332
+ }
333
+ }
@@ -0,0 +1,56 @@
1
+ import type { Transaction } from '../core/transaction.js';
2
+ import { BytesBuilder } from '../util/bytes.js';
3
+ import { decodeBankText, decodeXmlText } from '../util/text.js';
4
+ import { Camt } from './camt.js';
5
+ import { Mt940 } from './mt940.js';
6
+
7
+ /**
8
+ * The transactions in MT940 data: the `booked` parts joined, since a statement may continue on the next page, then the
9
+ * `pending` ones (MT942).
10
+ *
11
+ * `currency` applies to statements that do not state one; throws `FormatError` if both are missing.
12
+ */
13
+ export function mt940Transactions(
14
+ booked: readonly Uint8Array[],
15
+ pending: readonly Uint8Array[],
16
+ currency: string | undefined,
17
+ ): Transaction[] {
18
+ return [
19
+ ...Mt940.parseTransactions(joinedMt940(booked), { currency }),
20
+ ...Mt940.parseTransactions(joinedMt940(pending), { currency, status: 'pending' }),
21
+ ];
22
+ }
23
+
24
+ const lineFeed = 0x0a;
25
+ const carriageReturn = 0x0d;
26
+ const atSign = 0x40;
27
+
28
+ /**
29
+ * The MT940 `parts` as one text.
30
+ *
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.
33
+ */
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());
48
+ }
49
+
50
+ /** The transactions in camt documents; each document stands on its own. */
51
+ export function camtTransactions(booked: readonly Uint8Array[], pending: readonly Uint8Array[]): Transaction[] {
52
+ return [
53
+ ...booked.flatMap((document) => Camt.parseTransactions(decodeXmlText(document))),
54
+ ...pending.flatMap((document) => Camt.parseTransactions(decodeXmlText(document), 'pending')),
55
+ ];
56
+ }
@@ -0,0 +1,75 @@
1
+ /** SEPA keywords German banks put into the purpose text, each followed by `+`. */
2
+ const keywords = [
3
+ 'EREF',
4
+ 'KREF',
5
+ 'MREF',
6
+ 'CRED',
7
+ 'DEBT',
8
+ 'COAM',
9
+ 'OAMT',
10
+ 'SVWZ',
11
+ 'ABWA',
12
+ 'ABWE',
13
+ 'IBAN',
14
+ 'BIC',
15
+ 'PURP',
16
+ 'SQTP',
17
+ ];
18
+
19
+ /** The filler SEPA prescribes where the payer gave no end-to-end reference. */
20
+ const notProvided = 'NOTPROVIDED';
21
+
22
+ /** `reference`, or `undefined` if it is missing or the filler for a reference the payer did not give. */
23
+ export function providedReference(reference: string | undefined): string | undefined {
24
+ return reference === notProvided ? undefined : reference;
25
+ }
26
+
27
+ const keywordPattern = new RegExp(`(${keywords.join('|')})\\+`, 'g');
28
+
29
+ /** Parser for the SEPA keywords in a purpose text. */
30
+ export const SepaPurpose = Object.freeze({
31
+ /**
32
+ * Splits `purpose` into its SEPA keywords (EREF, KREF, MREF, CRED, SVWZ, …) and their values.
33
+ *
34
+ * A keyword counts unless a capital letter comes right before it, so `XEREF+` does not open an EREF value. After a
35
+ * 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
+ * `…4711EREF+` may be where one line ends and the next begins.
37
+ */
38
+ parse(purpose: string): Map<string, string> {
39
+ return split(purpose, new Set());
40
+ },
41
+
42
+ /**
43
+ * Like `parse` for a purpose the bank sends in `parts`, such as the MT940 subfields `?20` to `?29`, which continue
44
+ * each other without a separator.
45
+ *
46
+ * A keyword also counts at the start of a part, where the previous value may end in a capital letter, as in
47
+ * `EREF+NOTPROVIDED` followed by a part `MREF+…`.
48
+ */
49
+ parseParts(parts: Iterable<string>): Map<string, string> {
50
+ let text = '';
51
+ const partStarts = new Set<number>();
52
+ for (const part of parts) {
53
+ partStarts.add(text.length);
54
+ text += part;
55
+ }
56
+ return split(text, partStarts);
57
+ },
58
+ });
59
+
60
+ function split(purpose: string, partStarts: ReadonlySet<number>): Map<string, string> {
61
+ const atWordStart = (index: number): boolean =>
62
+ index === 0 || partStarts.has(index) || !isCapitalLetter(purpose, index - 1);
63
+ const matches = [...purpose.matchAll(keywordPattern)].filter((m) => atWordStart(m.index));
64
+ const result = new Map<string, string>();
65
+ matches.forEach((m, i) => {
66
+ 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());
68
+ });
69
+ return result;
70
+ }
71
+
72
+ function isCapitalLetter(text: string, index: number): boolean {
73
+ const c = text.charCodeAt(index);
74
+ return c >= 0x41 && c <= 0x5a;
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * FinTS 3.0 client for German banks: accounts, balances and transactions with PIN/TAN, PSD2 strong customer
3
+ * authentication and decoupled app approval.
4
+ *
5
+ * Start with `FinTs3Client`. The public API is what this file names; everything else is internal.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ export { Account, AccountDataStatus, type AccountCategory, type AccountInit } from './core/account.js';
11
+ export { Amount } from './core/amount.js';
12
+ export { Balance, type BalanceInit } from './core/balance.js';
13
+ export { BankInfo, BankMessage } from './core/bank-info.js';
14
+ export type {
15
+ BankMessageHandler,
16
+ FinTsClient,
17
+ FinTsClientOptions,
18
+ MessageDirection,
19
+ MessageLogger,
20
+ TransactionQuery,
21
+ WarningHandler,
22
+ } from './core/client.js';
23
+ export { FinTsConfig, type FinTsConfigInit } from './core/config.js';
24
+ export {
25
+ FinTsBankError,
26
+ FinTsDecoupledTimeoutError,
27
+ FinTsDialogAbortedError,
28
+ FinTsError,
29
+ FinTsLoginDataChangedError,
30
+ FinTsPinError,
31
+ FinTsProtocolError,
32
+ FinTsStateError,
33
+ FinTsTanCancelledError,
34
+ FinTsTanError,
35
+ FinTsTransportError,
36
+ FinTsUnsupportedError,
37
+ } from './core/errors.js';
38
+ export { FeedbackCodes, FinTsFeedback } from './core/feedback.js';
39
+ export {
40
+ Transaction,
41
+ type TransactionFormat,
42
+ type TransactionInit,
43
+ type TransactionStatus,
44
+ } from './core/transaction.js';
45
+ export { HttpTransport, type FinTsTransport, type HttpTransportOptions } from './core/transport.js';
46
+ export { Camt } from './formats/camt.js';
47
+ export { Mt940, type Mt940Options, type Mt940Statement } from './formats/mt940.js';
48
+ export { SepaPurpose } from './formats/sepa-purpose.js';
49
+ export {
50
+ TanAnswer,
51
+ TanChallenge,
52
+ type TanCallback,
53
+ type TanChallengeInit,
54
+ type TanMediumSelector,
55
+ type TanMethodSelector,
56
+ } from './tan/challenge.js';
57
+ export { FlickerCode, type HhdVersion } from './tan/flicker.js';
58
+ export { TanImage } from './tan/tan-image.js';
59
+ export {
60
+ TanMedium,
61
+ TanMethod,
62
+ type TanMediumInit,
63
+ type TanMediumRequirement,
64
+ type TanMediumStatus,
65
+ type TanMethodInit,
66
+ } from './tan/tan-models.js';
67
+ export { FormatError } from './util/errors.js';
68
+ export { FinTs3Client, type FinTs3ClientInit } from './v3/client.js';