@ducatjs/money 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +479 -62
  2. package/data/cash-rounding.json +123 -2
  3. package/data/cldr-cash.json +101 -0
  4. package/data/historic.json +78 -0
  5. package/data/iso4217-list-three.xml +1185 -0
  6. package/dist/adyen-fx.d.ts.map +1 -1
  7. package/dist/adyen-fx.js +22 -4
  8. package/dist/adyen-fx.js.map +1 -1
  9. package/dist/boc.d.ts +31 -0
  10. package/dist/boc.d.ts.map +1 -0
  11. package/dist/boc.js +114 -0
  12. package/dist/boc.js.map +1 -0
  13. package/dist/boe.d.ts +33 -0
  14. package/dist/boe.d.ts.map +1 -0
  15. package/dist/boe.js +114 -0
  16. package/dist/boe.js.map +1 -0
  17. package/dist/cash.d.ts +30 -1
  18. package/dist/cash.d.ts.map +1 -1
  19. package/dist/cash.js +50 -2
  20. package/dist/cash.js.map +1 -1
  21. package/dist/convert.d.ts +10 -9
  22. package/dist/convert.d.ts.map +1 -1
  23. package/dist/convert.js +13 -3
  24. package/dist/convert.js.map +1 -1
  25. package/dist/drizzle/mysql.d.ts +105 -0
  26. package/dist/drizzle/mysql.d.ts.map +1 -0
  27. package/dist/drizzle/mysql.js +60 -0
  28. package/dist/drizzle/mysql.js.map +1 -0
  29. package/dist/drizzle/pg.d.ts +103 -0
  30. package/dist/drizzle/pg.d.ts.map +1 -0
  31. package/dist/drizzle/pg.js +60 -0
  32. package/dist/drizzle/pg.js.map +1 -0
  33. package/dist/drizzle/sqlite.d.ts +102 -0
  34. package/dist/drizzle/sqlite.d.ts.map +1 -0
  35. package/dist/drizzle/sqlite.js +59 -0
  36. package/dist/drizzle/sqlite.js.map +1 -0
  37. package/dist/ecb.d.ts +37 -2
  38. package/dist/ecb.d.ts.map +1 -1
  39. package/dist/ecb.js +148 -10
  40. package/dist/ecb.js.map +1 -1
  41. package/dist/fed.d.ts +35 -0
  42. package/dist/fed.d.ts.map +1 -0
  43. package/dist/fed.js +134 -0
  44. package/dist/fed.js.map +1 -0
  45. package/dist/fetch.d.ts +45 -0
  46. package/dist/fetch.d.ts.map +1 -1
  47. package/dist/fetch.js +167 -24
  48. package/dist/fetch.js.map +1 -1
  49. package/dist/fn.d.ts +8 -1
  50. package/dist/fn.d.ts.map +1 -1
  51. package/dist/fn.js +8 -1
  52. package/dist/fn.js.map +1 -1
  53. package/dist/generated/cash.d.ts +2 -0
  54. package/dist/generated/cash.d.ts.map +1 -1
  55. package/dist/generated/cash.js +101 -0
  56. package/dist/generated/cash.js.map +1 -1
  57. package/dist/generated/historic.d.ts +337 -0
  58. package/dist/generated/historic.d.ts.map +1 -0
  59. package/dist/generated/historic.js +66 -0
  60. package/dist/generated/historic.js.map +1 -0
  61. package/dist/historic.d.ts +40 -0
  62. package/dist/historic.d.ts.map +1 -0
  63. package/dist/historic.js +43 -0
  64. package/dist/historic.js.map +1 -0
  65. package/dist/history.d.ts +96 -0
  66. package/dist/history.d.ts.map +1 -0
  67. package/dist/history.js +213 -0
  68. package/dist/history.js.map +1 -0
  69. package/dist/hmrc.d.ts +28 -0
  70. package/dist/hmrc.d.ts.map +1 -0
  71. package/dist/hmrc.js +108 -0
  72. package/dist/hmrc.js.map +1 -0
  73. package/dist/index.d.ts +1 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/kysely.d.ts +120 -0
  78. package/dist/kysely.d.ts.map +1 -0
  79. package/dist/kysely.js +309 -0
  80. package/dist/kysely.js.map +1 -0
  81. package/dist/money.d.ts +93 -9
  82. package/dist/money.d.ts.map +1 -1
  83. package/dist/money.js +135 -9
  84. package/dist/money.js.map +1 -1
  85. package/dist/parse.d.ts +45 -1
  86. package/dist/parse.d.ts.map +1 -1
  87. package/dist/parse.js +290 -3
  88. package/dist/parse.js.map +1 -1
  89. package/dist/pg.d.ts +100 -0
  90. package/dist/pg.d.ts.map +1 -0
  91. package/dist/pg.js +343 -0
  92. package/dist/pg.js.map +1 -0
  93. package/dist/plugins/all.d.ts +8 -1
  94. package/dist/plugins/all.d.ts.map +1 -1
  95. package/dist/plugins/all.js +8 -1
  96. package/dist/plugins/all.js.map +1 -1
  97. package/dist/plugins/convert.d.ts +3 -4
  98. package/dist/plugins/convert.d.ts.map +1 -1
  99. package/dist/plugins/convert.js.map +1 -1
  100. package/dist/plugins/tax.d.ts +4 -5
  101. package/dist/plugins/tax.d.ts.map +1 -1
  102. package/dist/plugins/tax.js.map +1 -1
  103. package/dist/policy.d.ts +34 -0
  104. package/dist/policy.d.ts.map +1 -0
  105. package/dist/policy.js +90 -0
  106. package/dist/policy.js.map +1 -0
  107. package/dist/prisma.d.ts +25 -14
  108. package/dist/prisma.d.ts.map +1 -1
  109. package/dist/prisma.js +29 -19
  110. package/dist/prisma.js.map +1 -1
  111. package/dist/provider.d.ts +21 -7
  112. package/dist/provider.d.ts.map +1 -1
  113. package/dist/provider.js +51 -11
  114. package/dist/provider.js.map +1 -1
  115. package/dist/rate-text.d.ts +38 -0
  116. package/dist/rate-text.d.ts.map +1 -0
  117. package/dist/rate-text.js +143 -0
  118. package/dist/rate-text.js.map +1 -0
  119. package/dist/rate.d.ts +61 -5
  120. package/dist/rate.d.ts.map +1 -1
  121. package/dist/rate.js +133 -33
  122. package/dist/rate.js.map +1 -1
  123. package/dist/rounding.js +1 -1
  124. package/dist/rounding.js.map +1 -1
  125. package/dist/schema.d.ts +43 -0
  126. package/dist/schema.d.ts.map +1 -0
  127. package/dist/schema.js +77 -0
  128. package/dist/schema.js.map +1 -0
  129. package/dist/sql.d.ts +54 -0
  130. package/dist/sql.d.ts.map +1 -0
  131. package/dist/sql.js +131 -0
  132. package/dist/sql.js.map +1 -0
  133. package/dist/tax.d.ts +13 -9
  134. package/dist/tax.d.ts.map +1 -1
  135. package/dist/tax.js +20 -4
  136. package/dist/tax.js.map +1 -1
  137. package/dist/testing/arbitraries.d.ts +43 -0
  138. package/dist/testing/arbitraries.d.ts.map +1 -0
  139. package/dist/testing/arbitraries.js +98 -0
  140. package/dist/testing/arbitraries.js.map +1 -0
  141. package/dist/testing/index.d.ts +67 -0
  142. package/dist/testing/index.d.ts.map +1 -0
  143. package/dist/testing/index.js +159 -0
  144. package/dist/testing/index.js.map +1 -0
  145. package/dist/testing/jest.d.ts +2 -0
  146. package/dist/testing/jest.d.ts.map +1 -0
  147. package/dist/testing/jest.js +15 -0
  148. package/dist/testing/jest.js.map +1 -0
  149. package/dist/testing/vitest.d.ts +7 -0
  150. package/dist/testing/vitest.d.ts.map +1 -0
  151. package/dist/testing/vitest.js +12 -0
  152. package/dist/testing/vitest.js.map +1 -0
  153. package/dist/valibot.d.ts +33 -0
  154. package/dist/valibot.d.ts.map +1 -0
  155. package/dist/valibot.js +61 -0
  156. package/dist/valibot.js.map +1 -0
  157. package/dist/zod.d.ts +36 -0
  158. package/dist/zod.d.ts.map +1 -0
  159. package/dist/zod.js +58 -0
  160. package/dist/zod.js.map +1 -0
  161. package/package.json +116 -5
  162. package/src/adyen-fx.ts +25 -4
  163. package/src/boc.ts +136 -0
  164. package/src/boe.ts +131 -0
  165. package/src/cash.ts +70 -2
  166. package/src/convert.ts +33 -15
  167. package/src/drizzle/mysql.ts +78 -0
  168. package/src/drizzle/pg.ts +78 -0
  169. package/src/drizzle/sqlite.ts +70 -0
  170. package/src/ecb.ts +179 -13
  171. package/src/fed.ts +146 -0
  172. package/src/fetch.ts +207 -28
  173. package/src/fn.ts +14 -0
  174. package/src/generated/cash.ts +103 -0
  175. package/src/generated/historic.ts +67 -0
  176. package/src/historic.ts +77 -0
  177. package/src/history.ts +333 -0
  178. package/src/hmrc.ts +125 -0
  179. package/src/index.ts +4 -0
  180. package/src/kysely.ts +588 -0
  181. package/src/money.ts +210 -12
  182. package/src/parse.ts +371 -4
  183. package/src/pg.ts +410 -0
  184. package/src/plugins/all.ts +10 -0
  185. package/src/plugins/convert.ts +4 -5
  186. package/src/plugins/tax.ts +9 -10
  187. package/src/policy.ts +124 -0
  188. package/src/prisma.ts +91 -10
  189. package/src/provider.ts +81 -16
  190. package/src/rate-text.ts +151 -0
  191. package/src/rate.ts +205 -36
  192. package/src/rounding.ts +1 -1
  193. package/src/schema.ts +119 -0
  194. package/src/sql.ts +178 -0
  195. package/src/tax.ts +40 -9
  196. package/src/testing/arbitraries.ts +134 -0
  197. package/src/testing/index.ts +247 -0
  198. package/src/testing/jest.ts +18 -0
  199. package/src/testing/vitest.ts +18 -0
  200. package/src/valibot.ts +87 -0
  201. package/src/zod.ts +78 -0
package/src/history.ts ADDED
@@ -0,0 +1,333 @@
1
+ // `@ducatjs/money/convert/history`: rates as central banks publish them (the other way round, per
2
+ // 100 units, with stripped digits) and dated tables over a range, with lookups that say why they
3
+ // fell back. Opt-in, so that converting at a rate you already hold doesn't pay for it.
4
+ import type { CurrencyInput } from './money.js';
5
+ import {
6
+ rateTable,
7
+ type RateProvider,
8
+ type RateQuery,
9
+ type RateTable,
10
+ type RateTableSpec,
11
+ } from './provider.js';
12
+
13
+ import { ratio, type Ratio } from './decimal.js';
14
+ import { hasEnded, hasStarted, Rate, toIsoString } from './rate.js';
15
+ import type { RateInfo, RateQuote } from './rate.js';
16
+ import { withDecimals } from './rate-text.js';
17
+
18
+ const DAY_MS = 86_400_000;
19
+
20
+ /**
21
+ * Why a lookup passed over a day: no table that day (a weekend or holiday), the table not yet
22
+ * published at the time asked for, its period over, or the pair not in it (`missing` when the
23
+ * source listed the currency without a value, `notQuoted` when it isn't listed at all).
24
+ */
25
+ export type FallbackReason = 'noTable' | 'notYetAvailable' | 'expired' | 'missing' | 'notQuoted';
26
+
27
+ /** A day a lookup passed over, and why. */
28
+ export interface Fallback {
29
+ readonly day: string;
30
+ readonly reason: FallbackReason;
31
+ }
32
+
33
+ const PLAIN_DECIMAL = /^\d+(?:\.\d+)?$/;
34
+
35
+ /** The exact rate a quote stands for, in the Rate's own direction. */
36
+ function quoteRatio(quote: RateQuote): Ratio {
37
+ if (!PLAIN_DECIMAL.test(quote.rate)) {
38
+ throw new RangeError(`A quote is a plain decimal string, got ${quote.rate}`);
39
+ }
40
+ const per = quote.per ?? 1;
41
+ if (!Number.isSafeInteger(per) || per < 1) {
42
+ throw new RangeError(`A quote's per is a positive integer, got ${per}`);
43
+ }
44
+ const published = ratio(quote.rate);
45
+ if (quote.inverted === true) {
46
+ return ratio(published.den * BigInt(per), published.num);
47
+ }
48
+ return ratio(published.num, published.den * BigInt(per));
49
+ }
50
+
51
+ /**
52
+ * A Rate from a published quote: `per` units and the direction are applied exactly, and the quote
53
+ * is kept on the Rate.
54
+ *
55
+ * rateFromQuote('JPY', 'NOK', { rate: '6.50', per: 100 }) // JPY→NOK 0.065
56
+ * rateFromQuote('USD', 'EUR', { rate: '1.1650', inverted: true }) // USD→EUR 200/233
57
+ */
58
+ export function rateFromQuote<From extends CurrencyInput, To extends CurrencyInput>(
59
+ from: From,
60
+ to: To,
61
+ quote: RateQuote,
62
+ info?: Omit<RateInfo, 'quote'>,
63
+ ): Rate<From, To> {
64
+ const exact = quoteRatio(quote);
65
+ // Stored in one form: per 1 and inverted false are the same as leaving them out, and a quote
66
+ // without them prints its own digits.
67
+ const kept: { rate: string; per?: number; inverted?: boolean } = { rate: quote.rate };
68
+ if (quote.per !== undefined && quote.per !== 1) {
69
+ kept.per = quote.per;
70
+ }
71
+ if (quote.inverted === true) {
72
+ kept.inverted = true;
73
+ }
74
+ return new Rate(from, to, exact, { ...info, quote: kept });
75
+ }
76
+
77
+ /** One published rate in a quoted table. */
78
+ export interface QuotedRate {
79
+ /** The rate as published, a decimal string. */
80
+ readonly rate: string;
81
+ /**
82
+ * How many units the published number is for: of `base`, or of the currency when the rate is
83
+ * `inverted` ("NOK per 100 JPY" is `{ rate: '6.50', per: 100, inverted: true }` in a NOK table).
84
+ */
85
+ readonly per?: number;
86
+ /** Published as base per unit of the currency, the other way round from the table. */
87
+ readonly inverted?: boolean;
88
+ /**
89
+ * The decimals the source publishes, for feeds that strip trailing zeros: '1.175' with 4 is
90
+ * kept as '1.1750'.
91
+ */
92
+ readonly decimals?: number;
93
+ }
94
+
95
+ /** A one-base table of published quotes: a decimal string, a QuotedRate, or null (no value). */
96
+ export interface QuotedTableSpec extends Omit<RateTableSpec, 'rates'> {
97
+ readonly rates: Readonly<Record<string, string | QuotedRate | null>>;
98
+ /**
99
+ * Every rate is published as base per unit of the currency (CAD per USD, as the Bank of Canada
100
+ * quotes), unless its entry says otherwise.
101
+ */
102
+ readonly inverted?: boolean;
103
+ /** The decimals every rate is published with, unless its entry says otherwise. */
104
+ readonly decimals?: number;
105
+ }
106
+
107
+ /**
108
+ * A rateTable from rates as a source published them: each is stored exactly in the table's
109
+ * direction, base → currency, and keeps its quote (`rate.quote`).
110
+ *
111
+ * const boc = quotedTable({ base: 'CAD', inverted: true, asOf: '2026-11-06', rates: { USD: '1.3654' } });
112
+ * boc.rate('CAD', 'USD')?.inverse().toString(); // '1.3654'
113
+ */
114
+ export function quotedTable(spec: QuotedTableSpec): RateTable {
115
+ const rates: Record<string, Rate | null> = {};
116
+ for (const [currency, entry] of Object.entries(spec.rates)) {
117
+ if (entry === null) {
118
+ rates[currency] = null;
119
+ continue;
120
+ }
121
+ let quoted: QuotedRate;
122
+ if (typeof entry === 'string') {
123
+ quoted = { rate: entry };
124
+ } else {
125
+ quoted = entry;
126
+ }
127
+
128
+ let text = quoted.rate.trim();
129
+ const decimals = quoted.decimals ?? spec.decimals;
130
+ if (decimals !== undefined) {
131
+ text = withDecimals(text, decimals);
132
+ }
133
+ const quote: { rate: string; per?: number; inverted?: boolean } = { rate: text };
134
+ if (quoted.per !== undefined) {
135
+ quote.per = quoted.per;
136
+ }
137
+ if (quoted.inverted ?? spec.inverted ?? false) {
138
+ quote.inverted = true;
139
+ }
140
+ rates[currency] = rateFromQuote(spec.base, currency, quote, spec);
141
+ }
142
+ return rateTable({ ...spec, rates });
143
+ }
144
+
145
+ /** The UTC date ('2026-11-06') of a date, an instant (any offset) or a Date. */
146
+ function dayOf(time: string | Date): string {
147
+ return toIsoString(time).slice(0, 10);
148
+ }
149
+
150
+ /** Options for rateHistory(). */
151
+ export interface RateHistoryOptions {
152
+ /**
153
+ * How many days before the day asked for to look back for a daily table (weekends, holidays,
154
+ * currencies missing that day). Default 4, which covers Good Friday to Easter Monday. A table
155
+ * valid for a period (`validUntil`) answers for its whole period instead.
156
+ */
157
+ readonly lookback?: number;
158
+ }
159
+
160
+ /** A rate lookup with its reasons: the rate, or undefined, and the days passed over. */
161
+ export interface RateLookup<From extends string = string, To extends string = string> {
162
+ readonly rate: Rate<From, To> | undefined;
163
+ /** Days passed over before the answer (or before giving up), newest first. */
164
+ readonly skipped: readonly Fallback[];
165
+ }
166
+
167
+ /** Dated tables held in memory, answering any day synchronously. */
168
+ export interface RateHistory extends RateProvider {
169
+ /** The dated tables, newest first. */
170
+ readonly tables: readonly RateTable[];
171
+ /** The same lookup as `rate`, reporting the days it passed over and why. */
172
+ lookup<From extends string, To extends string>(
173
+ from: From,
174
+ to: To,
175
+ query?: RateQuery,
176
+ ): RateLookup<From, To>;
177
+ }
178
+
179
+ /**
180
+ * Why a table can't answer the pair at the time asked for, or undefined when it can. The table's
181
+ * own dates are checked first; a table built from a list of Rates has none, so its rate's are.
182
+ */
183
+ function passOver(
184
+ table: RateTable,
185
+ from: string,
186
+ to: string,
187
+ query: RateQuery | undefined,
188
+ periodEnded: boolean,
189
+ ): FallbackReason | undefined {
190
+ const asOf = query?.asOf;
191
+ if (periodEnded) {
192
+ return 'expired';
193
+ }
194
+ if (
195
+ asOf !== undefined &&
196
+ table.availableAt !== undefined &&
197
+ !hasStarted(table.availableAt, asOf)
198
+ ) {
199
+ return 'notYetAvailable';
200
+ }
201
+ if (table.rate(from, to, query) !== undefined) {
202
+ return undefined;
203
+ }
204
+
205
+ if (table.missing?.includes(from) === true || table.missing?.includes(to) === true) {
206
+ return 'missing';
207
+ }
208
+ const held = table.rates.find((rate) => rate.from === from && rate.to === to);
209
+ if (held !== undefined && asOf !== undefined) {
210
+ if (held.isExpired(asOf)) {
211
+ return 'expired';
212
+ }
213
+ if (!held.isAvailable(asOf)) {
214
+ return 'notYetAvailable';
215
+ }
216
+ }
217
+ return 'notQuoted';
218
+ }
219
+
220
+ /** The ISO date `count` days before `day`. */
221
+ function daysBefore(day: string, count: number): string {
222
+ return new Date(Date.parse(`${day}T00:00:00Z`) - count * DAY_MS).toISOString().slice(0, 10);
223
+ }
224
+
225
+ /**
226
+ * A provider over many dated tables, such as a date range loaded in one call: for each lookup it
227
+ * takes the latest table at or before `asOf` that has the pair, published by then and still
228
+ * valid, looking back up to `lookback` days. Each currency pair falls back on its own, so a
229
+ * currency missing on a holiday comes from the day before while the others stay current; the
230
+ * Rate's `asOf` says which day it is from. Without an `asOf`, the newest table with the pair
231
+ * answers. `lookup` reports why a lookup fell back.
232
+ *
233
+ * const history = rateHistory(parseEcbXml(xml90Days));
234
+ * history.rate('EUR', 'USD', { asOf: '2026-11-08' }); // Sunday: Friday's rate
235
+ * history.lookup('EUR', 'USD', { asOf: '2026-11-08' }).skipped;
236
+ * // [{ day: '2026-11-08', reason: 'noTable' }, { day: '2026-11-07', reason: 'noTable' }]
237
+ */
238
+ export function rateHistory(
239
+ tables: readonly RateTable[],
240
+ options?: RateHistoryOptions,
241
+ ): RateHistory {
242
+ const lookback = options?.lookback ?? 4;
243
+ if (!Number.isSafeInteger(lookback) || lookback < 0) {
244
+ throw new RangeError(`lookback must be a non-negative integer, got ${lookback}`);
245
+ }
246
+ const dated: { day: string; table: RateTable }[] = [];
247
+ for (const table of tables) {
248
+ if (table.asOf === undefined) {
249
+ throw new RangeError('rateHistory needs tables with an asOf');
250
+ }
251
+ dated.push({ day: dayOf(table.asOf), table });
252
+ }
253
+ // Newest first; a stable sort keeps the caller's order within a day.
254
+ dated.sort((entry, other) => (other.day < entry.day ? -1 : Number(other.day > entry.day)));
255
+
256
+ const hasPeriods = dated.some((entry) => entry.table.validUntil !== undefined);
257
+
258
+ function lookup<From extends string, To extends string>(
259
+ from: From,
260
+ to: To,
261
+ query?: RateQuery,
262
+ ): RateLookup<From, To> {
263
+ const asOf = query?.asOf;
264
+ const skipped: Fallback[] = [];
265
+ let firstDay: string | undefined;
266
+ if (asOf !== undefined) {
267
+ firstDay = dayOf(asOf);
268
+ } else {
269
+ firstDay = dated[0]?.day;
270
+ }
271
+ if (firstDay === undefined) {
272
+ return { rate: undefined, skipped };
273
+ }
274
+ const lastDay = daysBefore(firstDay, lookback);
275
+ // The newest day of the lookback window not yet accounted for by a daily table.
276
+ let expectedDay = firstDay;
277
+
278
+ for (const { day, table } of dated) {
279
+ if (day > firstDay) {
280
+ continue;
281
+ }
282
+ const periodEnded =
283
+ asOf !== undefined && table.validUntil !== undefined && hasEnded(table.validUntil, asOf);
284
+ const coversPeriod = table.validUntil !== undefined && !periodEnded;
285
+ if (day < lastDay && !coversPeriod) {
286
+ // Older daily tables are out of the window; an older period table may still cover it.
287
+ if (!hasPeriods) {
288
+ break;
289
+ }
290
+ continue;
291
+ }
292
+
293
+ // Days between the last table and this one had no table. A period table that answers
294
+ // covers them, so for one they count only once it turns out not to answer.
295
+ // An older period table comes after every day of the window, so all of them are gaps.
296
+ const inWindow = day >= lastDay;
297
+ const accountedTo = inWindow ? day : daysBefore(lastDay, 1);
298
+ const gaps: Fallback[] = [];
299
+ for (let gap = expectedDay; gap > accountedTo; gap = daysBefore(gap, 1)) {
300
+ gaps.push({ day: gap, reason: 'noTable' });
301
+ }
302
+ if (!coversPeriod) {
303
+ skipped.push(...gaps);
304
+ }
305
+
306
+ const reason = passOver(table, from, to, query, periodEnded);
307
+ if (reason === undefined) {
308
+ return { rate: table.rate(from, to, query) as Rate<From, To>, skipped };
309
+ }
310
+ if (coversPeriod) {
311
+ skipped.push(...gaps);
312
+ }
313
+ skipped.push({ day, reason });
314
+ if (accountedTo <= expectedDay) {
315
+ expectedDay = inWindow ? daysBefore(day, 1) : accountedTo;
316
+ }
317
+ }
318
+
319
+ // Days left in the window without any table at all.
320
+ for (let gap = expectedDay; gap >= lastDay; gap = daysBefore(gap, 1)) {
321
+ skipped.push({ day: gap, reason: 'noTable' });
322
+ }
323
+ return { rate: undefined, skipped };
324
+ }
325
+
326
+ return {
327
+ tables: dated.map((entry) => entry.table),
328
+ rate(from, to, query) {
329
+ return lookup(from, to, query).rate;
330
+ },
331
+ lookup,
332
+ };
333
+ }
package/src/hmrc.ts ADDED
@@ -0,0 +1,125 @@
1
+ // `@ducatjs/money/convert/hmrc`: an offline parser for HMRC's monthly exchange rates, the UK's
2
+ // rates for customs and VAT. No I/O: download the CSV yourself.
3
+ import { rateTable, type RateTable } from './provider.js';
4
+ import { addDayRate, csvRows, isCalendarDay, newestFirst, readRateCell } from './rate-text.js';
5
+
6
+ const UK_DATE = /^(\d\d)\/(\d\d)\/(\d{4})$/;
7
+ const COLUMNS = ['Currency Code', 'Currency Units per £1', 'Start date', 'End date'] as const;
8
+
9
+ function notHmrc(why: string): never {
10
+ throw new SyntaxError(`Not an HMRC monthly exchange rates CSV file: ${why}`);
11
+ }
12
+
13
+ /** 'DD/MM/YYYY' as an ISO date, or SyntaxError. */
14
+ function isoDate(text: string): string {
15
+ const match = UK_DATE.exec(text.trim());
16
+ const day = match === null ? '' : `${match[3]}-${match[2]}-${match[1]}`;
17
+ if (!isCalendarDay(day)) {
18
+ notHmrc(`bad date ${JSON.stringify(text)}`);
19
+ }
20
+ return day;
21
+ }
22
+
23
+ /**
24
+ * When a month's rates are public: the end of the day HMRC publishes them, the Wednesday before the
25
+ * penultimate Thursday of the month before. The trade tariff service's API gives that Wednesday as
26
+ * the `publication_date` of every monthly file from January 2024 to November 2026; it gives no
27
+ * time of day, so the end of that UTC day is the earliest moment that is never too early. That
28
+ * instant is the start of the penultimate Thursday.
29
+ */
30
+ function publishedBy(start: string): string {
31
+ // The last day of the previous month, then back to its last Thursday, then a week before that.
32
+ const lastDay = new Date(Date.UTC(Number(start.slice(0, 4)), Number(start.slice(5, 7)) - 1, 0));
33
+ const backToThursday = (lastDay.getUTCDay() - 4 + 7) % 7;
34
+ lastDay.setUTCDate(lastDay.getUTCDate() - backToThursday - 7);
35
+ return lastDay.toISOString();
36
+ }
37
+
38
+ /** Options for parseHmrcMonthlyCsv. */
39
+ export interface HmrcOptions {
40
+ /**
41
+ * When each period's rates were published, or false for none. Default: the end (UTC) of the
42
+ * Wednesday before the penultimate Thursday of the month before, when HMRC publishes them.
43
+ */
44
+ readonly availableAt?: false | ((start: string) => string | Date);
45
+ }
46
+
47
+ /**
48
+ * Parses HMRC's monthly exchange rates CSV (trade-tariff.service.gov.uk, under the Open
49
+ * Government Licence) into one GBP-based table per period, newest first. Each is valid for its
50
+ * calendar month: `asOf` is the start date and `validUntil` the end date, so a `rateHistory` of
51
+ * them answers any day of the month (see @ducatjs/money/convert/history). Rates are currency units
52
+ * per £1, with `source: 'HMRC'` and `type: 'customs'`, four decimals as published. HMRC publishes
53
+ * each month's file in the month before, on the Wednesday before its penultimate Thursday (the
54
+ * `publication_date` that https://www.trade-tariff.service.gov.uk/api/v2/exchange_rates/
55
+ * period_lists/2026?filter[type]=monthly gives for each file); `availableAt` is the end of that
56
+ * UTC day. Several countries share a code (the CFA francs, AED, XCD): their rows must agree.
57
+ * HMRC never leaves a rate blank, so a blank or missing-value cell throws SyntaxError, as does any
58
+ * other row that can't be read. A file may hold several periods; they may overlap (a mid-month
59
+ * amendment, say), and `rateHistory` then uses the one that started last.
60
+ *
61
+ * const [november] = parseHmrcMonthlyCsv(await readFile('monthly_csv_2026-11.csv', 'utf8'));
62
+ * november.rate('GBP', 'EUR', { asOf: '2026-11-15' })?.toString(); // '1.1930'
63
+ */
64
+ export function parseHmrcMonthlyCsv(csv: string, options?: HmrcOptions): RateTable[] {
65
+ const [header, ...rows] = csvRows(csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv);
66
+ const names = (header ?? []).map((name) => name.trim());
67
+ const index = COLUMNS.map((name) => names.indexOf(name));
68
+ if (index.some((i) => i === -1)) {
69
+ notHmrc(`the columns are ${names.join(', ')}`);
70
+ }
71
+ const [codeAt, rateAt, startAt, endAt] = index as [number, number, number, number];
72
+
73
+ if (rows.length === 0) {
74
+ notHmrc('no rates');
75
+ }
76
+
77
+ // Periods by their start and end; within one, rates by code.
78
+ const periods = new Map<string, Map<string, Record<string, string | null>>>();
79
+ for (const cells of rows) {
80
+ if (cells.length !== names.length) {
81
+ notHmrc(`a row has ${cells.length} cells, the header ${names.length}`);
82
+ }
83
+ const code = cells[codeAt]!.trim();
84
+ if (!/^[A-Z]{3}$/.test(code) || code === 'GBP') {
85
+ notHmrc(`bad currency code ${JSON.stringify(code)}`);
86
+ }
87
+ const start = isoDate(cells[startAt]!);
88
+ const end = isoDate(cells[endAt]!);
89
+ if (end < start) {
90
+ notHmrc(`${code} ends on ${end}, before it starts on ${start}`);
91
+ }
92
+ const key = `${start}/${end}`;
93
+ let codes = periods.get(key);
94
+ if (codes === undefined) {
95
+ codes = new Map();
96
+ periods.set(key, codes);
97
+ }
98
+ const where = `${code} from ${start}`;
99
+ const rate = readRateCell(cells[rateAt]!, where);
100
+ if (rate === null) {
101
+ notHmrc(`no rate for ${where}`);
102
+ }
103
+ addDayRate(codes, start, code, rate);
104
+ }
105
+
106
+ const tables: RateTable[] = [];
107
+ for (const [key, codes] of periods) {
108
+ const [start, end] = key.split('/') as [string, string];
109
+ const rates = codes.get(start)!;
110
+ if (!Object.values(rates).some((rate) => rate !== null)) {
111
+ continue;
112
+ }
113
+ let availableAt: string | Date | undefined;
114
+ if (options?.availableAt === undefined) {
115
+ availableAt = publishedBy(start);
116
+ } else if (options.availableAt !== false) {
117
+ availableAt = options.availableAt(start);
118
+ }
119
+ const info = { base: 'GBP', source: 'HMRC', type: 'customs', asOf: start, validUntil: end };
120
+ tables.push(
121
+ rateTable({ ...info, ...(availableAt === undefined ? {} : { availableAt }), rates }),
122
+ );
123
+ }
124
+ return newestFirst(tables);
125
+ }
package/src/index.ts CHANGED
@@ -11,11 +11,15 @@ export {
11
11
  zero,
12
12
  fromJSON,
13
13
  isMoney,
14
+ isMoneyIn,
15
+ assertCurrency,
14
16
  extendMoney,
15
17
  type ExtendMoneyOptions,
16
18
  sum,
17
19
  ratio,
18
20
  CurrencyMismatchError,
21
+ type RoundingPolicy,
22
+ type RoundingInput,
19
23
  type Money,
20
24
  type MoneyJSON,
21
25
  type MoneyOptions,