@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,193 @@
1
+ // A value is either a calendar date, a local `Date` at midnight of which only the day counts, or the exact moment of a
2
+ // date with a time of day, which the bank states in German time (see `germanTime`). The parsers keep the two apart: a
3
+ // date without a valid time of day is not a moment.
4
+
5
+ const hourMs = 3_600_000;
6
+
7
+ function twoDigits(n: number): string {
8
+ return n.toString().padStart(2, '0');
9
+ }
10
+
11
+ function year(d: Date): string {
12
+ return d.getFullYear().toString().padStart(4, '0');
13
+ }
14
+
15
+ /** `YYYYMMDD`, the FinTS 3.0 date format. */
16
+ export function formatFintsDate(d: Date): string {
17
+ return `${year(d)}${twoDigits(d.getMonth() + 1)}${twoDigits(d.getDate())}`;
18
+ }
19
+
20
+ /** `HHMMSS`, the FinTS 3.0 time format. */
21
+ export function formatFintsTime(d: Date): string {
22
+ return `${twoDigits(d.getHours())}${twoDigits(d.getMinutes())}${twoDigits(d.getSeconds())}`;
23
+ }
24
+
25
+ /** `YYYY-MM-DD`, the ISO 8601 date format of camt. */
26
+ export function formatIsoDate(d: Date): string {
27
+ return `${year(d)}-${twoDigits(d.getMonth() + 1)}-${twoDigits(d.getDate())}`;
28
+ }
29
+
30
+ const fintsDate = /^(\d{4})(\d{2})(\d{2})$/;
31
+ const fintsTime = /^(\d{2})(\d{2})(\d{2})/;
32
+ const isoDate = /^(\d{4})-(\d{2})-(\d{2})/;
33
+ const isoTime = /^(\d{2}):(\d{2}):(\d{2})/;
34
+
35
+ /** Parses a FinTS 3.0 date `YYYYMMDD` as a calendar date; `undefined` if it is missing or invalid. */
36
+ export function parseFintsDate(date: string | undefined): Date | undefined {
37
+ return parseDate(fintsDate, date);
38
+ }
39
+
40
+ /**
41
+ * Parses a FinTS 3.0 date `YYYYMMDD` and time `HHMMSS` as the moment they name; `undefined` if either is missing or
42
+ * invalid.
43
+ */
44
+ export function parseFintsDateTime(date: string | undefined, time: string | undefined): Date | undefined {
45
+ return moment(parseDate(fintsDate, date), fintsTime, time);
46
+ }
47
+
48
+ /**
49
+ * Parses an ISO 8601 date `YYYY-MM-DD` as a calendar date, ignoring extra characters such as a time part; `undefined`
50
+ * if it is missing or invalid.
51
+ */
52
+ export function parseIsoDate(date: string | undefined): Date | undefined {
53
+ return parseDate(isoDate, date);
54
+ }
55
+
56
+ /** Parses an ISO 8601 date `YYYY-MM-DD` and time `HH:MM:SS` as the moment they name. */
57
+ export function parseIsoDateTime(date: string | undefined, time: string | undefined): Date | undefined {
58
+ return moment(parseDate(isoDate, date), isoTime, time);
59
+ }
60
+
61
+ function parseDate(pattern: RegExp, date: string | undefined): Date | undefined {
62
+ const d = date === undefined ? null : pattern.exec(date);
63
+ return d ? validDate(Number(d[1]), Number(d[2]), Number(d[3])) : undefined;
64
+ }
65
+
66
+ /** The moment of the German time matched by `timePattern` in `time` on `day`. */
67
+ function moment(day: Date | undefined, timePattern: RegExp, time: string | undefined): Date | undefined {
68
+ const t = time === undefined ? null : timePattern.exec(time);
69
+ if (day === undefined || !t) return undefined;
70
+ const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
71
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
72
+ return germanTime(day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second);
73
+ }
74
+
75
+ /**
76
+ * Parses a FinTS 3.0 date `YYYYMMDD` and time `HHMMSS` given in UTC+01:00 all year round, as the account extension of
77
+ * the user parameter data states its times (Formals E.3.1); `undefined` if either is missing or invalid. A time with
78
+ * fewer than six digits lost its leading zeros as a JSON number.
79
+ */
80
+ export function parseUtcPlusOneDateTime(date: string | undefined, time: string | undefined): Date | undefined {
81
+ const day = parseDate(fintsDate, date);
82
+ const t = time === undefined ? null : fintsTime.exec(time.padStart(6, '0'));
83
+ if (day === undefined || !t) return undefined;
84
+ const [hour, minute, second] = [Number(t[1]), Number(t[2]), Number(t[3])];
85
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
86
+ return new Date(utc(day.getFullYear(), day.getMonth() + 1, day.getDate(), hour, minute, second) - hourMs);
87
+ }
88
+
89
+ /**
90
+ * The moment a German clock shows the given time.
91
+ *
92
+ * German time is CET (UTC+1), and CEST (UTC+2) from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
93
+ * Sunday of October (the EU rule). The hour the clocks show twice in October counts as CEST; a time in the hour that is
94
+ * skipped in March counts as CET.
95
+ */
96
+ export function germanTime(
97
+ year: number,
98
+ month: number,
99
+ day: number,
100
+ hour: number,
101
+ minute: number,
102
+ second: number,
103
+ ): Date {
104
+ const clock = utc(year, month, day, hour, minute, second);
105
+ const summer = clock - 2 * hourMs;
106
+ return new Date(isSummerTime(summer) ? summer : clock - hourMs);
107
+ }
108
+
109
+ /** The calendar day a German clock shows at `moment`, as a local `Date` at midnight like every calendar date. */
110
+ export function germanDay(moment: Date): Date {
111
+ const utcMs = moment.getTime();
112
+ const clock = new Date(utcMs + (isSummerTime(utcMs) ? 2 : 1) * hourMs);
113
+ return localDate(clock.getUTCFullYear(), clock.getUTCMonth() + 1, clock.getUTCDate());
114
+ }
115
+
116
+ function isSummerTime(utcMs: number): boolean {
117
+ const y = new Date(utcMs).getUTCFullYear();
118
+ return utcMs >= clockChange(y, 3) && utcMs < clockChange(y, 10);
119
+ }
120
+
121
+ /** 01:00 UTC on the last Sunday of `month`, when the EU changes the clocks. */
122
+ function clockChange(year: number, month: number): number {
123
+ const lastDay = daysInMonth(year, month);
124
+ const weekday = new Date(utc(year, month, lastDay, 0, 0, 0)).getUTCDay();
125
+ return utc(year, month, lastDay - weekday, 1, 0, 0);
126
+ }
127
+
128
+ /** Milliseconds since the epoch of the UTC time; `month` counts from 1, and years below 100 are not moved to 19xx. */
129
+ function utc(year: number, month: number, day: number, hour: number, minute: number, second: number): number {
130
+ const d = new Date(0);
131
+ d.setUTCFullYear(year, month - 1, day);
132
+ d.setUTCHours(hour, minute, second, 0);
133
+ return d.getTime();
134
+ }
135
+
136
+ /** Local midnight of the day; `month` counts from 1, and years below 100 are not moved to 19xx. */
137
+ export function localDate(year: number, month: number, day: number): Date {
138
+ const d = new Date(0);
139
+ d.setFullYear(year, month - 1, day);
140
+ d.setHours(0, 0, 0, 0);
141
+ return d;
142
+ }
143
+
144
+ /** The date, or `undefined` if `month` or `day` is out of range; `Date` would roll it over into another date. */
145
+ export function validDate(year: number, month: number, day: number): Date | undefined {
146
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return undefined;
147
+ return localDate(year, month, day);
148
+ }
149
+
150
+ /**
151
+ * The date, with a day beyond the end of the month moved to its last day: banks write 30 February or 31 April for
152
+ * month-end bookings. `undefined` for a month or day that no month has.
153
+ */
154
+ export function clampedDate(year: number, month: number, day: number): Date | undefined {
155
+ if (month < 1 || month > 12 || day < 1 || day > 31) return undefined;
156
+ return validDate(year, month, day) ?? localDate(year, month, daysInMonth(year, month));
157
+ }
158
+
159
+ const swiftDate = /^(\d{2})(\d{2})(\d{2})$/;
160
+
161
+ /**
162
+ * Parses a SWIFT date `YYMMDD`, as MT940 writes it, with month ends as `clampedDate` reads them; years from 80 on count
163
+ * as 19xx. `undefined` if the date is invalid.
164
+ */
165
+ export function parseSwiftDate(yymmdd: string): Date | undefined {
166
+ const m = swiftDate.exec(yymmdd);
167
+ if (!m) return undefined;
168
+ const yy = Number(m[1]);
169
+ return clampedDate(yy >= 80 ? 1900 + yy : 2000 + yy, Number(m[2]), Number(m[3]));
170
+ }
171
+
172
+ /** Number of days the `month` (1 to 12) of `year` has. */
173
+ export function daysInMonth(year: number, month: number): number {
174
+ if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
175
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
176
+ }
177
+
178
+ /** The calendar day `moment` falls on in local time, as a local `Date` at midnight. */
179
+ export function calendarDay(moment: Date): Date {
180
+ return localDate(moment.getFullYear(), moment.getMonth() + 1, moment.getDate());
181
+ }
182
+
183
+ /**
184
+ * The calendar `day`, moved back to the bank's today if it lies after it: banks reject future dates in queries.
185
+ *
186
+ * The bank's today is the German day at `now`, the current time by default; east of Germany the local day starts
187
+ * earlier.
188
+ */
189
+ export function notAfterToday(day: Date | undefined, now: Date = new Date()): Date | undefined {
190
+ if (day === undefined) return undefined;
191
+ const today = germanDay(now);
192
+ return day.getTime() > today.getTime() ? today : day;
193
+ }
@@ -0,0 +1,16 @@
1
+ /** Data that do not have the expected format, such as a malformed MT940 line or amount. */
2
+ export class FormatError extends Error {
3
+ /** The part of the data that is malformed, where the parser can tell it. */
4
+ readonly source: string | undefined;
5
+
6
+ constructor(message: string, source?: string) {
7
+ super(source === undefined ? message : `${message}: ${source}`);
8
+ this.name = 'FormatError';
9
+ this.source = source;
10
+ }
11
+ }
12
+
13
+ /** An argument the function cannot work with, such as an empty PIN. The message never contains a PIN. */
14
+ export function invalidArgument(message: string, name?: string): TypeError {
15
+ return new TypeError(name === undefined ? message : `Invalid argument (${name}): ${message}`);
16
+ }
@@ -0,0 +1,47 @@
1
+ /** A map that cannot be changed, also not at runtime: it has no `set`, `delete` or `clear`. */
2
+ export class ImmutableMap<K, V> implements ReadonlyMap<K, V> {
3
+ private readonly map: Map<K, V>;
4
+
5
+ constructor(entries?: Iterable<readonly [K, V]>) {
6
+ this.map = new Map(entries);
7
+ }
8
+
9
+ get size(): number {
10
+ return this.map.size;
11
+ }
12
+
13
+ get(key: K): V | undefined {
14
+ return this.map.get(key);
15
+ }
16
+
17
+ has(key: K): boolean {
18
+ return this.map.has(key);
19
+ }
20
+
21
+ forEach(callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void): void {
22
+ this.map.forEach((value, key) => {
23
+ callback(value, key, this);
24
+ });
25
+ }
26
+
27
+ entries(): MapIterator<[K, V]> {
28
+ return this.map.entries();
29
+ }
30
+
31
+ keys(): MapIterator<K> {
32
+ return this.map.keys();
33
+ }
34
+
35
+ values(): MapIterator<V> {
36
+ return this.map.values();
37
+ }
38
+
39
+ [Symbol.iterator](): MapIterator<[K, V]> {
40
+ return this.map.entries();
41
+ }
42
+
43
+ /** A plain object of the entries, for JSON and for display. */
44
+ toJSON(): Record<string, V> {
45
+ return Object.fromEntries([...this.map].map(([key, value]) => [String(key), value]));
46
+ }
47
+ }
@@ -0,0 +1,51 @@
1
+ import { FormatError } from './errors.js';
2
+
3
+ /** A JSON object read from a saved state. */
4
+ export type JsonObject = Record<string, unknown>;
5
+
6
+ export function isJsonObject(value: unknown): value is JsonObject {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+
10
+ /** The optional string at `key`; throws `FormatError` for a value of another type. */
11
+ export function readOptionalString(json: JsonObject, key: string): string | undefined {
12
+ const value = json[key];
13
+ if (value === undefined || value === null) return undefined;
14
+ if (typeof value !== 'string') throw new FormatError(`Saved state: "${key}" is not a string`);
15
+ return value;
16
+ }
17
+
18
+ /** The optional integer at `key`; throws `FormatError` for a value of another type. */
19
+ export function readOptionalInt(json: JsonObject, key: string): number | undefined {
20
+ const value = json[key];
21
+ if (value === undefined || value === null) return undefined;
22
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
23
+ throw new FormatError(`Saved state: "${key}" is not an integer`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ /** The object at `key`; throws `FormatError` if it is missing or not an object. */
29
+ export function readObject(json: JsonObject, key: string): JsonObject {
30
+ const value = json[key];
31
+ if (!isJsonObject(value)) throw new FormatError(`Saved state: "${key}" is not an object`);
32
+ return value;
33
+ }
34
+
35
+ /** The optional list at `key`, each element checked by `isElement`; throws `FormatError` for anything else. */
36
+ export function readOptionalList<T>(
37
+ json: JsonObject,
38
+ key: string,
39
+ isElement: (value: unknown) => value is T,
40
+ ): T[] | undefined {
41
+ const value = json[key];
42
+ if (value === undefined || value === null) return undefined;
43
+ if (!Array.isArray(value) || !value.every(isElement)) {
44
+ throw new FormatError(`Saved state: "${key}" is not a list of the expected values`);
45
+ }
46
+ return value;
47
+ }
48
+
49
+ export function isString(value: unknown): value is string {
50
+ return typeof value === 'string';
51
+ }
@@ -0,0 +1,82 @@
1
+ import { base64Decode, latin1Decode, utf8Decode } from './bytes.js';
2
+ import { FormatError } from './errors.js';
3
+
4
+ const xmlEncodingDeclaration = /encoding\s*=\s*["']([A-Za-z0-9._-]+)["']/;
5
+ const whitespace = /\s/g;
6
+
7
+ /** Decodes text from the bank, which may be UTF-8 (with or without BOM) or ISO 8859-1. */
8
+ export function decodeBankText(bytes: Uint8Array): string {
9
+ const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
10
+ const content = hasBom ? bytes.subarray(3) : bytes;
11
+ if (content.every((b) => b < 0x80)) return latin1Decode(content);
12
+ try {
13
+ return utf8Decode(content);
14
+ } catch (error) {
15
+ if (!(error instanceof FormatError)) throw error;
16
+ return hasBom ? utf8Decode(content, true) : latin1Decode(content);
17
+ }
18
+ }
19
+
20
+ /** Decodes an XML document, honouring an ISO 8859-1 or Windows-1252 encoding declaration. */
21
+ export function decodeXmlText(bytes: Uint8Array): string {
22
+ const head = latin1Decode(bytes.length > 200 ? bytes.subarray(0, 200) : bytes);
23
+ switch (xmlEncodingDeclaration.exec(head)?.[1]?.toLowerCase()) {
24
+ case 'windows-1252':
25
+ case 'cp1252':
26
+ return decodeWindows1252(bytes);
27
+ case 'iso-8859-1':
28
+ case 'iso8859-1':
29
+ case 'latin1':
30
+ case 'latin-1':
31
+ return latin1Decode(bytes);
32
+ default:
33
+ return decodeBankText(bytes);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The code points of the Windows-1252 bytes 0x80 to 0x9F, where ISO 8859-1 has control characters; the five unassigned
39
+ * bytes keep their code.
40
+ */
41
+ const windows1252High = [
42
+ 0x20ac, 0x0081, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008d,
43
+ 0x017d, 0x008f, 0x0090, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a,
44
+ 0x0153, 0x009d, 0x017e, 0x0178,
45
+ ];
46
+
47
+ /** Decodes Windows-1252, which matches ISO 8859-1 except for `€`, typographic quotes and a few letters. */
48
+ export function decodeWindows1252(bytes: Uint8Array): string {
49
+ let text = '';
50
+ for (const b of bytes) text += String.fromCharCode(b >= 0x80 && b < 0xa0 ? windows1252High[b - 0x80]! : b);
51
+ return text;
52
+ }
53
+
54
+ /**
55
+ * The last four characters of `id`, such as an IBAN or a card number, after an ellipsis: enough to tell two apart, too
56
+ * little to identify one in a log.
57
+ */
58
+ export function maskedId(id: string): string {
59
+ return id.length <= 4 ? '…' : `…${id.substring(id.length - 4)}`;
60
+ }
61
+
62
+ /** `text` without spaces and line breaks. */
63
+ export function withoutWhitespace(text: string): string {
64
+ return text.replace(whitespace, '');
65
+ }
66
+
67
+ /** Decodes base64 that may be broken into lines; throws `FormatError` if it is invalid. */
68
+ export function decodeBase64Lines(text: string): Uint8Array {
69
+ return base64Decode(withoutWhitespace(text));
70
+ }
71
+
72
+ /** `text` with every match of `pattern`, a literal string, escaped for use in a regular expression. */
73
+ export function escapeRegExp(text: string): string {
74
+ return text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
75
+ }
76
+
77
+ /** The integer `text` states, decimal digits with an optional sign and blanks around; `undefined` for anything else. */
78
+ export function tryParseInt(text: string | undefined): number | undefined {
79
+ if (text === undefined) return undefined;
80
+ const match = /^\s*([+-]?\d+)\s*$/.exec(text);
81
+ return match ? Number(match[1]) : undefined;
82
+ }
@@ -0,0 +1,172 @@
1
+ import { FormatError } from './errors.js';
2
+
3
+ /**
4
+ * An element of a parsed XML document, looked up by local name: camt documents come with and without namespace
5
+ * prefixes.
6
+ */
7
+ export class XmlElement {
8
+ readonly children: (XmlElement | string)[] = [];
9
+
10
+ constructor(
11
+ /** The name without namespace prefix. */
12
+ readonly localName: string,
13
+ /** The attributes by name without namespace prefix; namespace declarations are left out. */
14
+ readonly attributes: ReadonlyMap<string, string>,
15
+ ) {}
16
+
17
+ get childElements(): XmlElement[] {
18
+ return this.children.filter((c): c is XmlElement => c instanceof XmlElement);
19
+ }
20
+
21
+ /** The text of this element and all below it. */
22
+ get innerText(): string {
23
+ return this.children.map((c) => (c instanceof XmlElement ? c.innerText : c)).join('');
24
+ }
25
+
26
+ getAttribute(localName: string): string | undefined {
27
+ return this.attributes.get(localName);
28
+ }
29
+
30
+ /** The first child element called `localName`. */
31
+ child(localName: string): XmlElement | undefined {
32
+ return this.childElements.find((c) => c.localName === localName);
33
+ }
34
+
35
+ /** The element reached by following child names along `path`; this element for an empty path. */
36
+ at(path: readonly string[]): XmlElement | undefined {
37
+ return path.reduce<XmlElement | undefined>((element, name) => element?.child(name), this);
38
+ }
39
+
40
+ /** The trimmed text of the element at `path`, or `undefined` if it is missing or empty. */
41
+ textAt(path: readonly string[]): string | undefined {
42
+ const text = this.at(path)?.innerText.trim();
43
+ return text === undefined || text === '' ? undefined : text;
44
+ }
45
+
46
+ /** All elements below this one called `localName`, in document order. */
47
+ descendantsNamed(localName: string): XmlElement[] {
48
+ const found: XmlElement[] = [];
49
+ const visit = (element: XmlElement): void => {
50
+ for (const c of element.childElements) {
51
+ if (c.localName === localName) found.push(c);
52
+ visit(c);
53
+ }
54
+ };
55
+ visit(this);
56
+ return found;
57
+ }
58
+ }
59
+
60
+ const namePattern = /[^\s<>/=!?"']+/y;
61
+ const attributePattern = /\s+([^\s<>/=!?"']+)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/y;
62
+
63
+ /** The match of the sticky `pattern` at `position` in `text`. */
64
+ function matchAt(pattern: RegExp, text: string, position: number): RegExpExecArray | null {
65
+ pattern.lastIndex = position;
66
+ return pattern.exec(text);
67
+ }
68
+ const predefinedEntities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
69
+
70
+ function localNameOf(qualified: string): string {
71
+ const colon = qualified.indexOf(':');
72
+ return colon < 0 ? qualified : qualified.substring(colon + 1);
73
+ }
74
+
75
+ /** Resolves the character and predefined entity references in `text`; throws `FormatError` for any other. */
76
+ function decodeEntities(text: string): string {
77
+ return text.replace(/&([^;&\s]*);?/g, (match, name: string) => {
78
+ if (!match.endsWith(';')) throw new FormatError('Invalid XML: unterminated entity reference');
79
+ if (name.startsWith('#')) {
80
+ const hex = name.startsWith('#x');
81
+ const digits = name.substring(hex ? 2 : 1);
82
+ const code = (hex ? /^[0-9a-fA-F]+$/ : /^\d+$/).test(digits) ? parseInt(digits, hex ? 16 : 10) : NaN;
83
+ if (!(code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff))) {
84
+ throw new FormatError('Invalid XML character reference', match);
85
+ }
86
+ return String.fromCodePoint(code);
87
+ }
88
+ const value = predefinedEntities[name];
89
+ if (value === undefined) throw new FormatError('Invalid XML: unknown entity', match);
90
+ return value;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Parses an XML document and returns its root element; throws `FormatError` if it is not well-formed.
96
+ *
97
+ * A document type declaration is refused: bank data have none, and its entities could expand without limit.
98
+ */
99
+ export function parseXml(xml: string): XmlElement {
100
+ let position = 0;
101
+ const stack: XmlElement[] = [];
102
+ let root: XmlElement | undefined;
103
+
104
+ const fail = (reason: string): never => {
105
+ throw new FormatError(`Invalid XML: ${reason} at position ${position}`);
106
+ };
107
+ const skipPast = (terminator: string, what: string): void => {
108
+ const end = xml.indexOf(terminator, position);
109
+ if (end < 0) fail(`unterminated ${what}`);
110
+ position = end + terminator.length;
111
+ };
112
+
113
+ while (position < xml.length) {
114
+ const lt = xml.indexOf('<', position);
115
+ const textEnd = lt < 0 ? xml.length : lt;
116
+ if (textEnd > position) {
117
+ const text = xml.substring(position, textEnd);
118
+ const parent = stack.at(-1);
119
+ if (parent) parent.children.push(decodeEntities(text));
120
+ else if (text.trim() !== '') fail('text outside the root element');
121
+ position = textEnd;
122
+ continue;
123
+ }
124
+ if (xml.startsWith('<?', position)) {
125
+ skipPast('?>', 'processing instruction');
126
+ } else if (xml.startsWith('<!--', position)) {
127
+ skipPast('-->', 'comment');
128
+ } else if (xml.startsWith('<![CDATA[', position)) {
129
+ const parent = stack.at(-1) ?? fail('CDATA outside the root element');
130
+ const start = position + 9;
131
+ skipPast(']]>', 'CDATA section');
132
+ parent.children.push(xml.substring(start, position - 3));
133
+ } else if (xml.startsWith('<!', position)) {
134
+ fail('document type declarations are not supported');
135
+ } else if (xml.startsWith('</', position)) {
136
+ position += 2;
137
+ const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing end tag name');
138
+ position += name.length;
139
+ while (/\s/.test(xml.charAt(position))) position++;
140
+ if (xml.charAt(position) !== '>') fail('malformed end tag');
141
+ position++;
142
+ const open = stack.pop();
143
+ if (open === undefined || open.localName !== localNameOf(name)) fail(`unexpected end tag ${name}`);
144
+ } else {
145
+ position++;
146
+ const name = matchAt(namePattern, xml, position)?.[0] ?? fail('missing element name');
147
+ position += name.length;
148
+ const attributes = new Map<string, string>();
149
+ for (;;) {
150
+ const attribute = matchAt(attributePattern, xml, position);
151
+ if (!attribute) break;
152
+ position += attribute[0].length;
153
+ const attributeName = attribute[1]!;
154
+ if (attributeName !== 'xmlns' && !attributeName.startsWith('xmlns:')) {
155
+ attributes.set(localNameOf(attributeName), decodeEntities(attribute[2] ?? attribute[3] ?? ''));
156
+ }
157
+ }
158
+ while (/\s/.test(xml.charAt(position))) position++;
159
+ const selfClosing = xml.startsWith('/>', position);
160
+ if (!selfClosing && xml.charAt(position) !== '>') fail(`malformed start tag ${name}`);
161
+ position += selfClosing ? 2 : 1;
162
+ const element = new XmlElement(localNameOf(name), attributes);
163
+ const parent = stack.at(-1);
164
+ if (parent) parent.children.push(element);
165
+ else if (root) fail('a second root element');
166
+ else root = element;
167
+ if (!selfClosing) stack.push(element);
168
+ }
169
+ }
170
+ if (stack.length > 0) fail(`unclosed element ${stack.at(-1)!.localName}`);
171
+ return root ?? fail('no root element');
172
+ }