@coderoftime/fints 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +28 -0
  3. package/README.md +246 -2
  4. package/dist/client.d.ts +48 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +199 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/core/account.d.ts +168 -0
  9. package/dist/core/account.d.ts.map +1 -0
  10. package/dist/core/account.js +245 -0
  11. package/dist/core/account.js.map +1 -0
  12. package/dist/core/amount.d.ts +63 -0
  13. package/dist/core/amount.d.ts.map +1 -0
  14. package/dist/core/amount.js +192 -0
  15. package/dist/core/amount.js.map +1 -0
  16. package/dist/core/balance.d.ts +55 -0
  17. package/dist/core/balance.d.ts.map +1 -0
  18. package/dist/core/balance.js +54 -0
  19. package/dist/core/balance.js.map +1 -0
  20. package/dist/core/bank-info.d.ts +49 -0
  21. package/dist/core/bank-info.d.ts.map +1 -0
  22. package/dist/core/bank-info.js +52 -0
  23. package/dist/core/bank-info.js.map +1 -0
  24. package/dist/core/client.d.ts +161 -0
  25. package/dist/core/client.d.ts.map +1 -0
  26. package/dist/core/client.js +2 -0
  27. package/dist/core/client.js.map +1 -0
  28. package/dist/core/config.d.ts +80 -0
  29. package/dist/core/config.d.ts.map +1 -0
  30. package/dist/core/config.js +153 -0
  31. package/dist/core/config.js.map +1 -0
  32. package/dist/core/errors.d.ts +153 -0
  33. package/dist/core/errors.d.ts.map +1 -0
  34. package/dist/core/errors.js +247 -0
  35. package/dist/core/errors.js.map +1 -0
  36. package/dist/core/feedback.d.ts +72 -0
  37. package/dist/core/feedback.d.ts.map +1 -0
  38. package/dist/core/feedback.js +127 -0
  39. package/dist/core/feedback.js.map +1 -0
  40. package/dist/core/transaction.d.ts +103 -0
  41. package/dist/core/transaction.d.ts.map +1 -0
  42. package/dist/core/transaction.js +96 -0
  43. package/dist/core/transaction.js.map +1 -0
  44. package/dist/core/transport.d.ts +64 -0
  45. package/dist/core/transport.d.ts.map +1 -0
  46. package/dist/core/transport.js +149 -0
  47. package/dist/core/transport.js.map +1 -0
  48. package/dist/formats/camt.d.ts +32 -0
  49. package/dist/formats/camt.d.ts.map +1 -0
  50. package/dist/formats/camt.js +242 -0
  51. package/dist/formats/camt.js.map +1 -0
  52. package/dist/formats/mt940.d.ts +54 -0
  53. package/dist/formats/mt940.d.ts.map +1 -0
  54. package/dist/formats/mt940.js +376 -0
  55. package/dist/formats/mt940.js.map +1 -0
  56. package/dist/formats/readers.d.ts +15 -0
  57. package/dist/formats/readers.d.ts.map +1 -0
  58. package/dist/formats/readers.js +42 -0
  59. package/dist/formats/readers.js.map +1 -0
  60. package/dist/formats/record-budget.d.ts +7 -0
  61. package/dist/formats/record-budget.d.ts.map +1 -0
  62. package/dist/formats/record-budget.js +17 -0
  63. package/dist/formats/record-budget.js.map +1 -0
  64. package/dist/formats/sepa-purpose.d.ts +28 -0
  65. package/dist/formats/sepa-purpose.d.ts.map +1 -0
  66. package/dist/formats/sepa-purpose.js +89 -0
  67. package/dist/formats/sepa-purpose.js.map +1 -0
  68. package/dist/index.d.ts +28 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +27 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/session/app-approval.d.ts +66 -0
  73. package/dist/session/app-approval.d.ts.map +1 -0
  74. package/dist/session/app-approval.js +226 -0
  75. package/dist/session/app-approval.js.map +1 -0
  76. package/dist/session/dialog-runner.d.ts +31 -0
  77. package/dist/session/dialog-runner.d.ts.map +1 -0
  78. package/dist/session/dialog-runner.js +111 -0
  79. package/dist/session/dialog-runner.js.map +1 -0
  80. package/dist/session/login-guard.d.ts +78 -0
  81. package/dist/session/login-guard.d.ts.map +1 -0
  82. package/dist/session/login-guard.js +181 -0
  83. package/dist/session/login-guard.js.map +1 -0
  84. package/dist/session/sepa-accounts.d.ts +40 -0
  85. package/dist/session/sepa-accounts.d.ts.map +1 -0
  86. package/dist/session/sepa-accounts.js +112 -0
  87. package/dist/session/sepa-accounts.js.map +1 -0
  88. package/dist/session/session-setup.d.ts +80 -0
  89. package/dist/session/session-setup.d.ts.map +1 -0
  90. package/dist/session/session-setup.js +182 -0
  91. package/dist/session/session-setup.js.map +1 -0
  92. package/dist/session/session-state.d.ts +50 -0
  93. package/dist/session/session-state.d.ts.map +1 -0
  94. package/dist/session/session-state.js +182 -0
  95. package/dist/session/session-state.js.map +1 -0
  96. package/dist/session/signed-dialog.d.ts +77 -0
  97. package/dist/session/signed-dialog.d.ts.map +1 -0
  98. package/dist/session/signed-dialog.js +170 -0
  99. package/dist/session/signed-dialog.js.map +1 -0
  100. package/dist/session/tan-selection.d.ts +96 -0
  101. package/dist/session/tan-selection.d.ts.map +1 -0
  102. package/dist/session/tan-selection.js +189 -0
  103. package/dist/session/tan-selection.js.map +1 -0
  104. package/dist/session/user-prompts.d.ts +65 -0
  105. package/dist/session/user-prompts.d.ts.map +1 -0
  106. package/dist/session/user-prompts.js +184 -0
  107. package/dist/session/user-prompts.js.map +1 -0
  108. package/dist/tan/challenge.d.ts +135 -0
  109. package/dist/tan/challenge.d.ts.map +1 -0
  110. package/dist/tan/challenge.js +184 -0
  111. package/dist/tan/challenge.js.map +1 -0
  112. package/dist/tan/flicker.d.ts +42 -0
  113. package/dist/tan/flicker.d.ts.map +1 -0
  114. package/dist/tan/flicker.js +296 -0
  115. package/dist/tan/flicker.js.map +1 -0
  116. package/dist/tan/tan-image.d.ts +22 -0
  117. package/dist/tan/tan-image.d.ts.map +1 -0
  118. package/dist/tan/tan-image.js +43 -0
  119. package/dist/tan/tan-image.js.map +1 -0
  120. package/dist/tan/tan-models.d.ts +149 -0
  121. package/dist/tan/tan-models.d.ts.map +1 -0
  122. package/dist/tan/tan-models.js +153 -0
  123. package/dist/tan/tan-models.js.map +1 -0
  124. package/dist/util/async.d.ts +22 -0
  125. package/dist/util/async.d.ts.map +1 -0
  126. package/dist/util/async.js +45 -0
  127. package/dist/util/async.js.map +1 -0
  128. package/dist/util/bytes.d.ts +22 -0
  129. package/dist/util/bytes.d.ts.map +1 -0
  130. package/dist/util/bytes.js +128 -0
  131. package/dist/util/bytes.js.map +1 -0
  132. package/dist/util/call-context.d.ts +12 -0
  133. package/dist/util/call-context.d.ts.map +1 -0
  134. package/dist/util/call-context.js +30 -0
  135. package/dist/util/call-context.js.map +1 -0
  136. package/dist/util/dates.d.ts +55 -0
  137. package/dist/util/dates.d.ts.map +1 -0
  138. package/dist/util/dates.js +189 -0
  139. package/dist/util/dates.js.map +1 -0
  140. package/dist/util/errors.d.ts +17 -0
  141. package/dist/util/errors.d.ts.map +1 -0
  142. package/dist/util/errors.js +24 -0
  143. package/dist/util/errors.js.map +1 -0
  144. package/dist/util/immutable-map.d.ts +16 -0
  145. package/dist/util/immutable-map.d.ts.map +1 -0
  146. package/dist/util/immutable-map.js +38 -0
  147. package/dist/util/immutable-map.js.map +1 -0
  148. package/dist/util/json.d.ts +17 -0
  149. package/dist/util/json.d.ts.map +1 -0
  150. package/dist/util/json.js +62 -0
  151. package/dist/util/json.js.map +1 -0
  152. package/dist/util/text.d.ts +37 -0
  153. package/dist/util/text.d.ts.map +1 -0
  154. package/dist/util/text.js +107 -0
  155. package/dist/util/text.js.map +1 -0
  156. package/dist/util/xml.d.ts +36 -0
  157. package/dist/util/xml.d.ts.map +1 -0
  158. package/dist/util/xml.js +194 -0
  159. package/dist/util/xml.js.map +1 -0
  160. package/dist/v3/account-extension.d.ts +25 -0
  161. package/dist/v3/account-extension.d.ts.map +1 -0
  162. package/dist/v3/account-extension.js +173 -0
  163. package/dist/v3/account-extension.js.map +1 -0
  164. package/dist/v3/bank-connection.d.ts +34 -0
  165. package/dist/v3/bank-connection.d.ts.map +1 -0
  166. package/dist/v3/bank-connection.js +58 -0
  167. package/dist/v3/bank-connection.js.map +1 -0
  168. package/dist/v3/dialog.d.ts +112 -0
  169. package/dist/v3/dialog.d.ts.map +1 -0
  170. package/dist/v3/dialog.js +241 -0
  171. package/dist/v3/dialog.js.map +1 -0
  172. package/dist/v3/message.d.ts +85 -0
  173. package/dist/v3/message.d.ts.map +1 -0
  174. package/dist/v3/message.js +303 -0
  175. package/dist/v3/message.js.map +1 -0
  176. package/dist/v3/parameter-store.d.ts +75 -0
  177. package/dist/v3/parameter-store.d.ts.map +1 -0
  178. package/dist/v3/parameter-store.js +143 -0
  179. package/dist/v3/parameter-store.js.map +1 -0
  180. package/dist/v3/parameters.d.ts +105 -0
  181. package/dist/v3/parameters.d.ts.map +1 -0
  182. package/dist/v3/parameters.js +450 -0
  183. package/dist/v3/parameters.js.map +1 -0
  184. package/dist/v3/protocol.d.ts +61 -0
  185. package/dist/v3/protocol.d.ts.map +1 -0
  186. package/dist/v3/protocol.js +113 -0
  187. package/dist/v3/protocol.js.map +1 -0
  188. package/dist/v3/queries.d.ts +51 -0
  189. package/dist/v3/queries.d.ts.map +1 -0
  190. package/dist/v3/queries.js +120 -0
  191. package/dist/v3/queries.js.map +1 -0
  192. package/dist/v3/segments.d.ts +106 -0
  193. package/dist/v3/segments.d.ts.map +1 -0
  194. package/dist/v3/segments.js +325 -0
  195. package/dist/v3/segments.js.map +1 -0
  196. package/dist/v3/syntax.d.ts +127 -0
  197. package/dist/v3/syntax.d.ts.map +1 -0
  198. package/dist/v3/syntax.js +367 -0
  199. package/dist/v3/syntax.js.map +1 -0
  200. package/package.json +67 -4
  201. package/src/client.ts +252 -0
  202. package/src/core/account.ts +323 -0
  203. package/src/core/amount.ts +211 -0
  204. package/src/core/balance.ts +85 -0
  205. package/src/core/bank-info.ts +66 -0
  206. package/src/core/client.ts +186 -0
  207. package/src/core/config.ts +182 -0
  208. package/src/core/errors.ts +277 -0
  209. package/src/core/feedback.ts +140 -0
  210. package/src/core/transaction.ts +161 -0
  211. package/src/core/transport.ts +189 -0
  212. package/src/formats/camt.ts +266 -0
  213. package/src/formats/mt940.ts +419 -0
  214. package/src/formats/readers.ts +49 -0
  215. package/src/formats/record-budget.ts +18 -0
  216. package/src/formats/sepa-purpose.ts +94 -0
  217. package/src/index.ts +70 -0
  218. package/src/session/app-approval.ts +278 -0
  219. package/src/session/dialog-runner.ts +122 -0
  220. package/src/session/login-guard.ts +221 -0
  221. package/src/session/sepa-accounts.ts +115 -0
  222. package/src/session/session-setup.ts +184 -0
  223. package/src/session/session-state.ts +204 -0
  224. package/src/session/signed-dialog.ts +192 -0
  225. package/src/session/tan-selection.ts +203 -0
  226. package/src/session/user-prompts.ts +193 -0
  227. package/src/tan/challenge.ts +261 -0
  228. package/src/tan/flicker.ts +308 -0
  229. package/src/tan/tan-image.ts +38 -0
  230. package/src/tan/tan-models.ts +252 -0
  231. package/src/util/async.ts +48 -0
  232. package/src/util/bytes.ts +129 -0
  233. package/src/util/call-context.ts +41 -0
  234. package/src/util/dates.ts +210 -0
  235. package/src/util/errors.ts +27 -0
  236. package/src/util/immutable-map.ts +47 -0
  237. package/src/util/json.ts +67 -0
  238. package/src/util/text.ts +112 -0
  239. package/src/util/xml.ts +183 -0
  240. package/src/v3/account-extension.ts +185 -0
  241. package/src/v3/bank-connection.ts +67 -0
  242. package/src/v3/dialog.ts +283 -0
  243. package/src/v3/message.ts +373 -0
  244. package/src/v3/parameter-store.ts +160 -0
  245. package/src/v3/parameters.ts +524 -0
  246. package/src/v3/protocol.ts +148 -0
  247. package/src/v3/queries.ts +190 -0
  248. package/src/v3/segments.ts +374 -0
  249. package/src/v3/syntax.ts +369 -0
@@ -0,0 +1,161 @@
1
+ import { copyDate, formatIsoDate } from '../util/dates.js';
2
+ import { ImmutableMap } from '../util/immutable-map.js';
3
+ import type { Amount } from './amount.js';
4
+
5
+ /** Whether a transaction is booked or still pending. */
6
+ export type TransactionStatus = 'booked' | 'pending';
7
+
8
+ /** Transaction format to request; `auto` prefers camt.052 when the bank supports it. */
9
+ export type TransactionFormat =
10
+ /** camt.052 where the bank offers it, otherwise MT940. */
11
+ | 'auto'
12
+ /** SWIFT MT940 (booked) and MT942 (pending). */
13
+ | 'mt940'
14
+ /** ISO 20022 camt.052. */
15
+ | 'camt';
16
+
17
+ /** What `Transaction` takes; the parsers fill in what the bank states. */
18
+ export interface TransactionInit {
19
+ status?: TransactionStatus;
20
+ bookingDate?: Date | undefined;
21
+ valueDate: Date;
22
+ amount: Amount;
23
+ isReversal?: boolean;
24
+ bookingText?: string | undefined;
25
+ purpose?: string | undefined;
26
+ counterpartyName?: string | undefined;
27
+ counterpartyIban?: string | undefined;
28
+ counterpartyBic?: string | undefined;
29
+ counterpartyAccountNumber?: string | undefined;
30
+ counterpartyBankCode?: string | undefined;
31
+ endToEndId?: string | undefined;
32
+ mandateId?: string | undefined;
33
+ creditorId?: string | undefined;
34
+ customerReference?: string | undefined;
35
+ bankReference?: string | undefined;
36
+ transactionCode?: string | undefined;
37
+ transactionTypeId?: string | undefined;
38
+ primaNota?: string | undefined;
39
+ ultimateCreditor?: string | undefined;
40
+ ultimateDebtor?: string | undefined;
41
+ purposeCodes?: ReadonlyMap<string, string>;
42
+ }
43
+
44
+ /** A single account transaction, parsed from camt.052 or MT940/MT942. */
45
+ export class Transaction {
46
+ /** Whether the transaction is booked or pending. */
47
+ readonly status: TransactionStatus;
48
+
49
+ /**
50
+ * Booking date; `undefined` where the bank states none, as for some pending transactions and MT940 entries without an
51
+ * entry date. A local `Date` at midnight, like every calendar date.
52
+ */
53
+ readonly bookingDate: Date | undefined;
54
+
55
+ /** Value date (Valuta); for a camt entry that states its booking date only, the booking date. */
56
+ readonly valueDate: Date;
57
+
58
+ /** Signed amount; negative for debits. */
59
+ readonly amount: Amount;
60
+
61
+ /** Whether this is a reversal (Storno) of an earlier booking. */
62
+ readonly isReversal: boolean;
63
+
64
+ /** Booking type text, for example `SEPA-UEBERWEISUNG`. */
65
+ readonly bookingText: string | undefined;
66
+
67
+ /** Remittance information; the `SVWZ+` part when the bank sends SEPA keywords. */
68
+ readonly purpose: string | undefined;
69
+
70
+ /** Payee for debits, payer for credits; for a reversal, the counterparty of the booking it reverses. */
71
+ readonly counterpartyName: string | undefined;
72
+
73
+ /** IBAN of the counterparty. */
74
+ readonly counterpartyIban: string | undefined;
75
+
76
+ /** BIC of the counterparty's bank. */
77
+ readonly counterpartyBic: string | undefined;
78
+
79
+ /** National account number of the counterparty, where the bank does not state an IBAN. */
80
+ readonly counterpartyAccountNumber: string | undefined;
81
+
82
+ /** German bank code of the counterparty's bank, where the bank does not state a BIC. */
83
+ readonly counterpartyBankCode: string | undefined;
84
+
85
+ /** SEPA end-to-end reference (EREF). */
86
+ readonly endToEndId: string | undefined;
87
+
88
+ /** SEPA direct debit mandate reference (MREF). */
89
+ readonly mandateId: string | undefined;
90
+
91
+ /** SEPA creditor identifier (CRED). */
92
+ readonly creditorId: string | undefined;
93
+
94
+ /** Reference the customer gave the order. */
95
+ readonly customerReference: string | undefined;
96
+
97
+ /** Reference the bank gave the booking. */
98
+ readonly bankReference: string | undefined;
99
+
100
+ /** German business transaction code (Geschäftsvorfallcode, GVC). */
101
+ readonly transactionCode: string | undefined;
102
+
103
+ /** SWIFT transaction type, for example `NTRF`. */
104
+ readonly transactionTypeId: string | undefined;
105
+
106
+ /** Prima nota number of the booking. */
107
+ readonly primaNota: string | undefined;
108
+
109
+ /** The party the payee acts for, where that is not the payee itself. */
110
+ readonly ultimateCreditor: string | undefined;
111
+
112
+ /** The party the payer acts for, where that is not the payer itself. */
113
+ readonly ultimateDebtor: string | undefined;
114
+
115
+ /**
116
+ * The SEPA keywords of the purpose, keyed by keyword (EREF, KREF, MREF, CRED, SVWZ, …): those `SepaPurpose` reads. In
117
+ * camt, EREF, MREF and CRED are those of `endToEndId`, `mandateId` and `creditorId`, which the bank states apart,
118
+ * never keywords of the remittance text the payer or the payee writes.
119
+ */
120
+ readonly purposeCodes: ReadonlyMap<string, string>;
121
+
122
+ /** Takes copies of the dates and purpose codes of `init`: each transaction has its own, which no other one shares. */
123
+ constructor(init: TransactionInit) {
124
+ this.status = init.status ?? 'booked';
125
+ this.bookingDate = copyDate(init.bookingDate);
126
+ this.valueDate = copyDate(init.valueDate);
127
+ this.amount = init.amount;
128
+ this.isReversal = init.isReversal ?? false;
129
+ this.bookingText = init.bookingText;
130
+ this.purpose = init.purpose;
131
+ this.counterpartyName = init.counterpartyName;
132
+ this.counterpartyIban = init.counterpartyIban;
133
+ this.counterpartyBic = init.counterpartyBic;
134
+ this.counterpartyAccountNumber = init.counterpartyAccountNumber;
135
+ this.counterpartyBankCode = init.counterpartyBankCode;
136
+ this.endToEndId = init.endToEndId;
137
+ this.mandateId = init.mandateId;
138
+ this.creditorId = init.creditorId;
139
+ this.customerReference = init.customerReference;
140
+ this.bankReference = init.bankReference;
141
+ this.transactionCode = init.transactionCode;
142
+ this.transactionTypeId = init.transactionTypeId;
143
+ this.primaNota = init.primaNota;
144
+ this.ultimateCreditor = init.ultimateCreditor;
145
+ this.ultimateDebtor = init.ultimateDebtor;
146
+ this.purposeCodes = new ImmutableMap(init.purposeCodes ?? []);
147
+ }
148
+
149
+ /** Whether the transaction is still pending. */
150
+ get isPending(): boolean {
151
+ return this.status === 'pending';
152
+ }
153
+
154
+ /**
155
+ * The date and status only, without amount, counterparty or purpose, so that logs and crash reports do not collect
156
+ * account data.
157
+ */
158
+ toString(): string {
159
+ return `Transaction(${formatIsoDate(this.bookingDate ?? this.valueDate)}, ${this.status}${this.isReversal ? ', reversal' : ''})`;
160
+ }
161
+ }
@@ -0,0 +1,189 @@
1
+ import { concatBytes } from '../util/bytes.js';
2
+ import { invalidArgument } from '../util/errors.js';
3
+ import { FinTsTransportError } from './errors.js';
4
+
5
+ /** Sends one FinTS message and returns the bank's reply; replace it for a relay, proxies, pinning or tests. */
6
+ export interface FinTsTransport {
7
+ /**
8
+ * Posts `body` to `url` and returns the response body.
9
+ *
10
+ * The client aborts `signal` once it is closed: a transport that stops the request then keeps `close` from waiting
11
+ * for the answer.
12
+ *
13
+ * Implementations report failures as `FinTsTransportError`, with the HTTP status as `statusCode` where the bank
14
+ * answered with an HTTP error: an anonymous request for the bank parameter data that fails so goes out once more,
15
+ * without the announcement of strong authentication, which some banks fail on. Of any other error, another
16
+ * `FinTsError` included, the client keeps only its message and that of its cause, as a `FinTsTransportError` without
17
+ * `statusCode`. `body` holds PIN and TAN in plain text: keep it out of error messages and logs.
18
+ */
19
+ post(url: URL, body: Uint8Array, contentType: string, signal: AbortSignal): Promise<Uint8Array>;
20
+
21
+ /** Releases the connection; the client calls it when it is closed. */
22
+ close(): void;
23
+ }
24
+
25
+ /** What `HttpTransport` takes; all optional. */
26
+ export interface HttpTransportOptions {
27
+ /**
28
+ * The `fetch` to send with, for example one with a proxy agent; the global `fetch` by default.
29
+ *
30
+ * It must post to the URL it is given: an answer from another URL counts as a redirect, which fails the request. Of
31
+ * an answer whose `body` is no `ReadableStream`, as with node-fetch, the size can only be checked once it is read
32
+ * whole.
33
+ */
34
+ fetch?: typeof fetch;
35
+ /**
36
+ * Time for the whole exchange in milliseconds, from sending the request to the last byte of the response; 90 s. At
37
+ * most 2147483647 (about 24 days), the longest `setTimeout` waits.
38
+ */
39
+ timeoutMs?: number;
40
+ /** Largest response accepted in bytes, so that a faulty server cannot exhaust the memory; 16 MiB. */
41
+ maxResponseBytes?: number;
42
+ }
43
+
44
+ /** The longest delay `setTimeout` takes; it fires at once for a longer one, such as `Infinity`. */
45
+ const maxTimeoutMs = 2 ** 31 - 1;
46
+
47
+ /**
48
+ * Default `FinTsTransport`: HTTP POST with `fetch`.
49
+ *
50
+ * In a browser it only reaches servers that allow the page's origin (CORS), which bank servers do not: a web app needs
51
+ * a server of its own between browser and bank, reached with a `FinTsTransport` of its own.
52
+ */
53
+ export class HttpTransport implements FinTsTransport {
54
+ readonly timeoutMs: number;
55
+ readonly maxResponseBytes: number;
56
+ private readonly fetchFn: typeof fetch;
57
+
58
+ /**
59
+ * Throws `TypeError` if `timeoutMs` is not a positive number of at most 2147483647, or `maxResponseBytes` is not a
60
+ * positive number.
61
+ */
62
+ constructor(options: HttpTransportOptions = {}) {
63
+ const custom = options.fetch;
64
+ // Called as a plain function: a browser's native `fetch` fails as a method of another object.
65
+ this.fetchFn = custom === undefined ? (input, init) => fetch(input, init) : (input, init) => custom(input, init);
66
+ this.timeoutMs = options.timeoutMs ?? 90_000;
67
+ this.maxResponseBytes = options.maxResponseBytes ?? 16 << 20;
68
+ if (!isPositive(this.timeoutMs) || this.timeoutMs > maxTimeoutMs) {
69
+ throw invalidArgument(`Must be a positive number of at most ${maxTimeoutMs}`, 'timeoutMs');
70
+ }
71
+ if (!isPositive(this.maxResponseBytes)) throw invalidArgument('Must be a positive number', 'maxResponseBytes');
72
+ }
73
+
74
+ /** Fails with `FinTsTransportError` at the timeout or once `signal` is aborted; sends nothing if it is already. */
75
+ async post(url: URL, body: Uint8Array, contentType: string, signal?: AbortSignal): Promise<Uint8Array> {
76
+ if (signal?.aborted) throw new FinTsTransportError('Request aborted');
77
+ const controller = new AbortController();
78
+ // Raced with the exchange, for a `fetch` that cannot abort a request.
79
+ const stopped = new Promise<never>((_, reject) => {
80
+ controller.signal.addEventListener('abort', () => {
81
+ reject(controller.signal.reason as FinTsTransportError);
82
+ });
83
+ });
84
+ const stop = (message: string) => (): void => {
85
+ controller.abort(new FinTsTransportError(message));
86
+ };
87
+ const timer = setTimeout(stop('Request timed out'), this.timeoutMs);
88
+ const abort = stop('Request aborted');
89
+ signal?.addEventListener('abort', abort);
90
+ try {
91
+ return await Promise.race([this.exchange(url, body, contentType, controller.signal), stopped]);
92
+ } catch (error) {
93
+ if (controller.signal.aborted) throw controller.signal.reason as FinTsTransportError;
94
+ if (error instanceof FinTsTransportError) throw error;
95
+ throw new FinTsTransportError(failureMessage(error));
96
+ } finally {
97
+ clearTimeout(timer);
98
+ signal?.removeEventListener('abort', abort);
99
+ }
100
+ }
101
+
102
+ private async exchange(url: URL, body: Uint8Array, contentType: string, signal: AbortSignal): Promise<Uint8Array> {
103
+ const response = await this.fetchFn(url, {
104
+ method: 'POST',
105
+ body: body as Uint8Array<ArrayBuffer>,
106
+ headers: { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
107
+ // The PIN travels in the body: never let the request be repeated at another address.
108
+ redirect: 'manual',
109
+ signal,
110
+ });
111
+ if (response.type === 'opaqueredirect') {
112
+ throw new FinTsTransportError('The server redirects the request, which the client does not follow');
113
+ }
114
+ // A `fetch` that ignores `redirect`, as one over XMLHttpRequest does, shows a redirect it followed only here.
115
+ if (response.redirected || (response.url && response.url !== withoutFragment(url))) {
116
+ await discard(response);
117
+ throw new FinTsTransportError('The fetch followed a redirect: the request may have gone to another address');
118
+ }
119
+ if (response.status < 200 || response.status > 299) {
120
+ await discard(response);
121
+ throw new FinTsTransportError(`HTTP ${response.status} ${response.statusText}`.trim(), {
122
+ statusCode: response.status,
123
+ });
124
+ }
125
+ return this.readBody(response);
126
+ }
127
+
128
+ private async readBody(response: Response): Promise<Uint8Array> {
129
+ // A `fetch` that does not stream, as one over XMLHttpRequest or node-fetch, gives no body or one of another kind.
130
+ const body = response.body as ReadableStream<Uint8Array> | null | undefined;
131
+ if (typeof body?.getReader !== 'function') {
132
+ const bytes = new Uint8Array(await response.arrayBuffer());
133
+ if (bytes.length > this.maxResponseBytes) throw this.tooLarge();
134
+ return bytes;
135
+ }
136
+ const reader = body.getReader();
137
+ const chunks: Uint8Array[] = [];
138
+ let size = 0;
139
+ for (;;) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+ chunks.push(value);
143
+ size += value.length;
144
+ if (size > this.maxResponseBytes) {
145
+ await reader.cancel().catch(() => undefined);
146
+ throw this.tooLarge();
147
+ }
148
+ }
149
+ return concatBytes(chunks);
150
+ }
151
+
152
+ private tooLarge(): FinTsTransportError {
153
+ return new FinTsTransportError(`Response larger than ${this.maxResponseBytes} bytes`);
154
+ }
155
+
156
+ close(): void {
157
+ // `fetch` keeps no connection the transport could release.
158
+ }
159
+ }
160
+
161
+ /** `url` as `Response.url` gives it: without fragment. */
162
+ function withoutFragment(url: URL): string {
163
+ const copy = new URL(url);
164
+ copy.hash = '';
165
+ return copy.href;
166
+ }
167
+
168
+ /** Cancels the body of a response the transport rejects, unless it cannot be cancelled, as a Node.js stream cannot. */
169
+ async function discard(response: Response): Promise<void> {
170
+ try {
171
+ await response.body?.cancel();
172
+ } catch {
173
+ // The response is rejected all the same.
174
+ }
175
+ }
176
+
177
+ function isPositive(value: number): boolean {
178
+ return Number.isFinite(value) && value > 0;
179
+ }
180
+
181
+ /**
182
+ * The message of a failed request, with the message of its cause: Node.js reports `fetch failed` and the reason in the
183
+ * cause. Only the messages, since the error may hold the request, PIN and TAN included.
184
+ */
185
+ export function failureMessage(error: unknown): string {
186
+ if (!(error instanceof Error)) return String(error);
187
+ const cause: unknown = error.cause;
188
+ return cause instanceof Error ? `${error.message}: ${cause.message}` : error.message;
189
+ }
@@ -0,0 +1,266 @@
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';
4
+ import { fail, FormatError } from '../util/errors.js';
5
+ import { parseXml, type XmlElement } from '../util/xml.js';
6
+ import { RecordBudget } from './record-budget.js';
7
+ import { providedReference, SepaPurpose } from './sepa-purpose.js';
8
+
9
+ /** One report (camt.052), statement (camt.053) or notification (camt.054): the entries of one account. */
10
+ export interface CamtStatement {
11
+ /** The bank's identification of the statement (`Id`). */
12
+ readonly reference: string | undefined;
13
+ /** The account the statement is for: its IBAN, or the bank's other identification of it where it states none. */
14
+ readonly accountId: string | undefined;
15
+ /** The currency of the account, where the statement states it. */
16
+ readonly currency: string | undefined;
17
+ /** The transactions of the statement, in the order the bank lists them. */
18
+ readonly transactions: readonly Transaction[];
19
+ }
20
+
21
+ /** Local names of the elements of a document that hold the entries of one account. */
22
+ const statementNames = new Set(['Rpt', 'Stmt', 'Ntfctn']);
23
+
24
+ /** Parser for ISO 20022 camt.052, camt.053 and camt.054 account reports, statements and notifications. */
25
+ export const Camt = Object.freeze({
26
+ /**
27
+ * Parses the transactions of all statements in one list, which does not tell the account of each: a document may hold
28
+ * statements of several accounts, which `parse` keeps apart. Throws as `parse` does.
29
+ */
30
+ parseTransactions(xml: string, status?: TransactionStatus): Transaction[] {
31
+ return Camt.parse(xml, status).flatMap((s) => [...s.transactions]);
32
+ },
33
+
34
+ /**
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
+ *
39
+ * `status` overrides the booking status stated in the entries.
40
+ */
41
+ parse(xml: string, status?: TransactionStatus): CamtStatement[] {
42
+ return parseCamt(xml, status, new RecordBudget());
43
+ },
44
+ });
45
+
46
+ /** `Camt.parse`, which takes each statement and transaction from `budget`, before it builds it. */
47
+ export function parseCamt(xml: string, status: TransactionStatus | undefined, budget: RecordBudget): CamtStatement[] {
48
+ const root = parseXml(xml.trim());
49
+ const statements = root.childElements.flatMap((message) =>
50
+ message.childElements.filter((c) => statementNames.has(c.localName)),
51
+ );
52
+ if (statements.length === 0) throw new FormatError('camt document without report, statement or notification');
53
+ return statements.map((statement) => {
54
+ budget.take();
55
+ const account = statement.child('Acct');
56
+ 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
+ });
63
+ });
64
+ }
65
+
66
+ /** Width of the purpose lines banks cut the remittance information into, as in MT940 and DTAUS. */
67
+ const cutLineLength = 35;
68
+
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
+ /**
77
+ * One booking (`Ntry`) of a report; a batch booking yields one transaction per transaction detail (`TxDtls`) when the
78
+ * details state their amounts and these add up to the booked amount.
79
+ *
80
+ * Nothing is guessed: what the entry lacks, and what the reader needs, is a `FormatError`. The one exception is the
81
+ * value date, which `Transaction.valueDate` requires: an entry with a booking date only takes that.
82
+ */
83
+ class CamtEntry {
84
+ private readonly amountElement: XmlElement;
85
+ private readonly currency: string;
86
+ private readonly debit: boolean;
87
+ private readonly reversal: boolean;
88
+ private readonly status: TransactionStatus;
89
+ private readonly bookingDate: Date | undefined;
90
+ private readonly valueDate: Date;
91
+ private readonly bookingText: string | undefined;
92
+ private readonly bankReference: string | undefined;
93
+
94
+ /** German banks send `transaction type+GVC+prima nota`, for example `NDDT+105+9310`. */
95
+ private readonly proprietaryCode: readonly string[];
96
+
97
+ constructor(
98
+ private readonly entry: XmlElement,
99
+ forcedStatus: TransactionStatus | undefined,
100
+ private readonly budget: RecordBudget,
101
+ ) {
102
+ this.amountElement = entry.child('Amt') ?? fail('camt entry without amount');
103
+ this.currency = this.amountElement.getAttribute('Ccy') ?? fail('camt amount without currency');
104
+ this.debit = debitIndicator(entry.textAt(['CdtDbtInd'])) ?? fail('camt entry without credit/debit indicator');
105
+ this.reversal = ['true', '1'].includes(entry.textAt(['RvslInd']) ?? '');
106
+ this.status = forcedStatus ?? this.statusOfEntry();
107
+ this.bookingDate = dateOf(entry.child('BookgDt'));
108
+ this.valueDate = dateOf(entry.child('ValDt')) ?? this.bookingDate ?? fail('camt entry without date');
109
+ this.bookingText = entry.textAt(['AddtlNtryInf']);
110
+ this.bankReference = entry.textAt(['AcctSvcrRef']);
111
+ this.proprietaryCode = entry.textAt(['BkTxCd', 'Prtry', 'Cd'])?.split('+') ?? [];
112
+ }
113
+
114
+ private statusOfEntry(): TransactionStatus {
115
+ const code = this.entry.textAt(['Sts', 'Cd']) ?? this.entry.textAt(['Sts']);
116
+ return code === 'PDNG' || code === 'INFO' ? 'pending' : 'booked';
117
+ }
118
+
119
+ transactions(): Transaction[] {
120
+ const total = amountOf(this.amountElement, this.currency);
121
+ const details = this.entry.childrenNamed('NtryDtls').flatMap((d) => d.childrenNamed('TxDtls'));
122
+ if (details.length <= 1) return [this.transaction(details[0], total, this.debit)];
123
+ // A batch is split only into details that make up its booking: each states its amount, and they add up to the
124
+ // booked one, in its currency. It stays one booking otherwise, without the details of any one of them, which would
125
+ // describe the others wrongly.
126
+ const booking = this.transaction(undefined, total, this.debit);
127
+ const amounts = details.map((detail) => detail.at(['AmtDtls', 'TxAmt', 'Amt']) ?? detail.child('Amt'));
128
+ if (amounts.includes(undefined)) return [booking];
129
+ const split = details.map((detail, i) =>
130
+ this.transaction(
131
+ detail,
132
+ amountOf(amounts[i]!, this.currency),
133
+ // A detail may book in the other direction than the batch as a whole.
134
+ debitIndicator(detail.textAt(['CdtDbtInd'])) ?? this.debit,
135
+ ),
136
+ );
137
+ return addUpTo(split, booking) ? split : [booking];
138
+ }
139
+
140
+ /** A transaction of `amount`, booked as a debit if `debit`, with what `details` tell about it. */
141
+ private transaction(details: XmlElement | undefined, amount: Amount, debit: boolean): Transaction {
142
+ this.budget.take();
143
+ const parties = details?.child('RltdPties');
144
+ const agents = details?.child('RltdAgts');
145
+ // The related parties describe the original operation, which a reversal books the other way round (ISO 20022
146
+ // `RvslInd`: with `CRDT` the original operation was a debit, with `DBIT` a credit).
147
+ const originalDebit = debit !== this.reversal;
148
+ const counterparty = originalDebit ? 'Cdtr' : 'Dbtr';
149
+ const lines = (details?.child('RmtInf')?.childrenNamed('Ustrd') ?? [])
150
+ .map((line) => line.innerText)
151
+ .filter((text) => text.trim() !== '');
152
+ // 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
+ // other line ends a phrase, which the next one follows after a blank.
154
+ const cut = (i: number): boolean => lines[i]?.length === cutLineLength;
155
+ const purposeParts = lines.map((line, i) => {
156
+ const part = cut(i) ? line : line.trimEnd();
157
+ return i === 0 || cut(i - 1) ? part : ` ${part.trimStart()}`;
158
+ });
159
+ const purpose = purposeParts.join('').trim();
160
+ const keywords = SepaPurpose.parseParts(purposeParts);
161
+ const endToEndId = providedReference(details?.textAt(['Refs', 'EndToEndId']));
162
+ const mandateId = details?.textAt(['Refs', 'MndtId']);
163
+ const creditorId = creditorIdOf(parties?.child('Cdtr'));
164
+ const purposeCode = details?.textAt(['Purp', 'Cd']);
165
+ 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);
170
+ if (purposeCode !== undefined) purposeCodes.set('PURP', purposeCode);
171
+ return new Transaction({
172
+ status: this.status,
173
+ bookingDate: this.bookingDate,
174
+ valueDate: this.valueDate,
175
+ amount: debit ? amount.negate() : amount,
176
+ isReversal: this.reversal,
177
+ bookingText: this.bookingText ?? details?.textAt(['AddtlTxInf']),
178
+ purpose: keywords.get('SVWZ') ?? (purpose === '' ? details?.textAt(['AddtlTxInf']) : purpose),
179
+ counterpartyName: partyName(parties?.child(counterparty)),
180
+ counterpartyIban: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'IBAN']),
181
+ counterpartyAccountNumber: parties?.child(`${counterparty}Acct`)?.textAt(['Id', 'Othr', 'Id']),
182
+ counterpartyBic: bicOf(agents?.child(`${counterparty}Agt`)),
183
+ endToEndId,
184
+ mandateId,
185
+ creditorId,
186
+ customerReference: details?.textAt(['Refs', 'InstrId']) ?? details?.textAt(['Refs', 'PmtInfId']),
187
+ bankReference: details?.textAt(['Refs', 'AcctSvcrRef']) ?? this.bankReference,
188
+ transactionCode: this.proprietaryCode[1],
189
+ transactionTypeId: typeId,
190
+ primaNota: this.proprietaryCode[2],
191
+ ultimateCreditor: partyName(parties?.child('UltmtCdtr')),
192
+ ultimateDebtor: partyName(parties?.child('UltmtDbtr')),
193
+ purposeCodes,
194
+ });
195
+ }
196
+ }
197
+
198
+ /**
199
+ * The amount `element` states, in its currency or else in `currency`; throws `FormatError` for a negative one, whose
200
+ * sign the credit/debit indicator would turn: the indicator alone gives the direction.
201
+ */
202
+ function amountOf(element: XmlElement, currency: string): Amount {
203
+ const amount = Amount.parseDecimal(element.innerText, element.getAttribute('Ccy') ?? currency);
204
+ if (amount.isNegative) throw new FormatError('Negative camt amount', element.innerText);
205
+ return amount;
206
+ }
207
+
208
+ /**
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.
211
+ */
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;
220
+ }
221
+ }
222
+
223
+ /** `true` for `DBIT`, `false` for `CRDT`, `undefined` if `indicator` is missing; throws for any other value. */
224
+ function debitIndicator(indicator: string | undefined): boolean | undefined {
225
+ switch (indicator) {
226
+ case 'DBIT':
227
+ return true;
228
+ case 'CRDT':
229
+ return false;
230
+ case undefined:
231
+ return undefined;
232
+ default:
233
+ throw new FormatError('camt credit/debit indicator is neither CRDT nor DBIT', indicator);
234
+ }
235
+ }
236
+
237
+ /**
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.
241
+ */
242
+ 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));
248
+ }
249
+
250
+ function partyName(party: XmlElement | undefined): string | undefined {
251
+ return party?.textAt(['Nm']) ?? party?.textAt(['Pty', 'Nm']);
252
+ }
253
+
254
+ function bicOf(agent: XmlElement | undefined): string | undefined {
255
+ return agent?.textAt(['FinInstnId', 'BIC']) ?? agent?.textAt(['FinInstnId', 'BICFI']);
256
+ }
257
+
258
+ /**
259
+ * The SEPA creditor identifier of the `creditor`: its private `Othr` identification in the scheme `SEPA`. The other
260
+ * parties' identifications are the creditor's or the debtor's to choose, and may be in that scheme as well.
261
+ */
262
+ function creditorIdOf(creditor: XmlElement | undefined): string | undefined {
263
+ const identifications = creditor?.at(['Id', 'PrvtId']) ?? creditor?.at(['Pty', 'Id', 'PrvtId']);
264
+ const sepa = identifications?.childrenNamed('Othr').find((other) => other.textAt(['SchmeNm', 'Prtry']) === 'SEPA');
265
+ return sepa?.textAt(['Id']);
266
+ }