@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/adyen-fx.ts CHANGED
@@ -55,9 +55,20 @@ function offsetMinutes(zone: string): number | undefined {
55
55
  return numeric[1] === '-' ? -total : total;
56
56
  }
57
57
 
58
- /** The offset of an IANA time zone at a UTC instant, in minutes. */
59
- function ianaOffsetMinutes(timeZone: string, utcMs: number): number {
60
- const parts = new Intl.DateTimeFormat('en-US', {
58
+ // Building an Intl.DateTimeFormat is far slower than using one, and a report has thousands of rows
59
+ // in one zone, so keep one formatter per zone. The bound stops a stream of distinct zone names
60
+ // (case variants are accepted by Intl) from growing the cache without limit.
61
+ const MAX_CACHED_FORMATTERS = 32;
62
+ const formatters = new Map<string, Intl.DateTimeFormat>();
63
+
64
+ /** The cached wall-clock formatter for an IANA time zone. */
65
+ function wallClockFormatter(timeZone: string): Intl.DateTimeFormat {
66
+ let formatter = formatters.get(timeZone);
67
+ if (formatter !== undefined) {
68
+ return formatter;
69
+ }
70
+
71
+ formatter = new Intl.DateTimeFormat('en-US', {
61
72
  timeZone,
62
73
  hourCycle: 'h23',
63
74
  year: 'numeric',
@@ -66,7 +77,17 @@ function ianaOffsetMinutes(timeZone: string, utcMs: number): number {
66
77
  hour: 'numeric',
67
78
  minute: 'numeric',
68
79
  second: 'numeric',
69
- }).formatToParts(new Date(utcMs));
80
+ });
81
+ if (formatters.size >= MAX_CACHED_FORMATTERS) {
82
+ formatters.clear();
83
+ }
84
+ formatters.set(timeZone, formatter);
85
+ return formatter;
86
+ }
87
+
88
+ /** The offset of an IANA time zone at a UTC instant, in minutes. */
89
+ function ianaOffsetMinutes(timeZone: string, utcMs: number): number {
90
+ const parts = wallClockFormatter(timeZone).formatToParts(new Date(utcMs));
70
91
  const field = (type: string): number => Number(parts.find((part) => part.type === type)!.value);
71
92
  const wallClockAsUtc = Date.UTC(
72
93
  field('year'),
package/src/boc.ts ADDED
@@ -0,0 +1,136 @@
1
+ // `@ducatjs/money/convert/boc`: an offline parser for the Bank of Canada's daily exchange rates
2
+ // (the Valet API). No I/O: fetch the JSON yourself, or use fetchBankOfCanada from
3
+ // @ducatjs/money/convert/fetch. Rates are kept exactly as published.
4
+ import { quotedTable } from './history.js';
5
+ import type { RateTable } from './provider.js';
6
+ import { addDayRate, isCalendarDay, newestFirst, readRateCell } from './rate-text.js';
7
+
8
+ const SERIES = /^FX([A-Z]{3})CAD$/;
9
+ const ZERO = /^0+(?:\.0+)?$/;
10
+
11
+ // The Bank publishes "once each business day by 16:30 (ET)". One formatter for the zone, built on
12
+ // first use, gives the Toronto offset on any day, summer time included.
13
+ let torontoFormatter: Intl.DateTimeFormat | undefined;
14
+
15
+ /** 16:30 in Toronto on `day`, as a UTC instant. */
16
+ function publishedBy(day: string): string {
17
+ torontoFormatter ??= new Intl.DateTimeFormat('en-US', {
18
+ timeZone: 'America/Toronto',
19
+ timeZoneName: 'shortOffset',
20
+ });
21
+ // Noon UTC is 07:00 or 08:00 in Toronto, after any clock change that day (they happen at 02:00).
22
+ const noon = new Date(`${day}T12:00:00Z`);
23
+ const zone = torontoFormatter.formatToParts(noon).find((part) => part.type === 'timeZoneName');
24
+ const offset = /^GMT([+-]\d+)$/.exec(zone?.value ?? '');
25
+ if (offset === null) {
26
+ throw new RangeError(`Can't tell Toronto's UTC offset on ${day} (${zone?.value})`);
27
+ }
28
+ const utcMs = Date.parse(`${day}T16:30:00Z`) - Number(offset[1]) * 3_600_000;
29
+ return new Date(utcMs).toISOString();
30
+ }
31
+
32
+ function notValet(why: string): never {
33
+ throw new SyntaxError(`Not a Bank of Canada Valet daily FX file: ${why}`);
34
+ }
35
+
36
+ /** Options for parseBankOfCanadaJson. */
37
+ export interface BankOfCanadaOptions {
38
+ /**
39
+ * When each day's rates were published, or false for none. Default: 16:30 Toronto time that day,
40
+ * when the Bank says it publishes them.
41
+ */
42
+ readonly availableAt?: false | ((day: string) => string | Date);
43
+ }
44
+
45
+ /**
46
+ * Parses the Bank of Canada's daily exchange rates from the Valet API's JSON (the
47
+ * `FX_RATES_DAILY` group, or single `FX…CAD` series) into one CAD-based table per business day,
48
+ * newest first. The Bank quotes CAD per unit of each currency (FXUSDCAD 1.3748), so each table
49
+ * holds the exact inverse, CAD → currency, and keeps the published quote: `rate.inverse()` prints
50
+ * '1.3748' again. Rates have `source: 'BoC'`, `type: 'average'` (an average of a day's quotes, not
51
+ * a market rate) and `availableAt` 16:30 Toronto time, when the Bank says it publishes them (a late
52
+ * rate can come later; pass `availableAt` to change it). Valet leaves a series out of a day
53
+ * without a value, so a series listed in `seriesDetail` but absent on a day, an empty value or a
54
+ * null one, is in that day's `missing`. Anything but daily `FX…CAD` series (monthly or annual
55
+ * groups, other data) throws SyntaxError, as do two different values for one series on one day.
56
+ *
57
+ * Precision: the Bank quotes four decimals, up to six for small rates, so a small rate has only
58
+ * about four significant digits (JPY 0.008760, KRW 0.000929, and IDR 0.000082 with two). Its
59
+ * inverse is exact, but only as precise as that. For such currencies, prefer a source that quotes
60
+ * them the other way round.
61
+ *
62
+ * const tables = parseBankOfCanadaJson(await (await fetch(url)).text());
63
+ * tables[0].rate('CAD', 'USD')?.inverse().toString(); // '1.3748'
64
+ */
65
+ export function parseBankOfCanadaJson(json: string, options?: BankOfCanadaOptions): RateTable[] {
66
+ const parsed = JSON.parse(json) as {
67
+ observations?: unknown;
68
+ seriesDetail?: Record<string, unknown>;
69
+ } | null;
70
+ const observations = parsed?.observations;
71
+ if (!Array.isArray(observations)) {
72
+ notValet('no observations');
73
+ }
74
+
75
+ // The series the file says it has; each must be a daily FX…CAD series.
76
+ const expected = new Set<string>();
77
+ for (const series of Object.keys(parsed?.seriesDetail ?? {})) {
78
+ if (!SERIES.test(series)) {
79
+ notValet(`series ${series} isn't a daily FX…CAD rate`);
80
+ }
81
+ expected.add(series);
82
+ }
83
+
84
+ const days = new Map<string, Record<string, string | null>>();
85
+ for (const observation of observations as readonly Record<string, unknown>[]) {
86
+ const day = observation?.['d'];
87
+ if (typeof day !== 'string' || !isCalendarDay(day)) {
88
+ notValet(`bad date ${JSON.stringify(day)}`);
89
+ }
90
+
91
+ for (const [series, cell] of Object.entries(observation)) {
92
+ if (series === 'd') {
93
+ continue;
94
+ }
95
+ const currency = SERIES.exec(series)?.[1];
96
+ if (currency === undefined) {
97
+ notValet(`series ${series} isn't a daily FX…CAD rate`);
98
+ }
99
+ const where = `${series} on ${day}`;
100
+ const value = (cell as { v?: unknown } | null)?.v;
101
+ if (value !== null && typeof value !== 'string') {
102
+ throw new SyntaxError(`Malformed Valet value for ${where}`);
103
+ }
104
+ const rate = value === null ? null : readRateCell(value, where);
105
+ if (rate !== null && ZERO.test(rate)) {
106
+ throw new SyntaxError(`A zero rate for ${where}`);
107
+ }
108
+ addDayRate(days, day, currency, rate);
109
+ }
110
+ // Valet leaves out a series without a value that day.
111
+ for (const series of expected) {
112
+ const currency = SERIES.exec(series)![1]!;
113
+ if (days.get(day)?.[currency] === undefined) {
114
+ addDayRate(days, day, currency, null);
115
+ }
116
+ }
117
+ }
118
+
119
+ const tables: RateTable[] = [];
120
+ for (const [day, rates] of days) {
121
+ if (!Object.values(rates).some((rate) => rate !== null)) {
122
+ continue;
123
+ }
124
+ let availableAt: string | Date | undefined;
125
+ if (options?.availableAt === undefined) {
126
+ availableAt = publishedBy(day);
127
+ } else if (options.availableAt !== false) {
128
+ availableAt = options.availableAt(day);
129
+ }
130
+ const info = { base: 'CAD', inverted: true, source: 'BoC', type: 'average', asOf: day };
131
+ tables.push(
132
+ quotedTable({ ...info, ...(availableAt === undefined ? {} : { availableAt }), rates }),
133
+ );
134
+ }
135
+ return newestFirst(tables);
136
+ }
package/src/boe.ts ADDED
@@ -0,0 +1,131 @@
1
+ // `@ducatjs/money/convert/boe`: an offline parser for the Bank of England's daily spot exchange
2
+ // rates against sterling. Parser only: see parseBankOfEnglandCsv on licensing.
3
+ import { quotedTable } from './history.js';
4
+ import type { RateTable } from './provider.js';
5
+ import { addDayRate, csvRows, isCalendarDay, newestFirst, readRateCell } from './rate-text.js';
6
+
7
+ // The Bank's daily spot series against sterling, by code (currency units per £1).
8
+ const BOE_SERIES: Readonly<Record<string, string>> = {
9
+ XUDLADS: 'AUD',
10
+ XUDLZOS3: 'BGN',
11
+ XUDLCDS: 'CAD',
12
+ XUDLBK89: 'CNY',
13
+ XUDLBK25: 'CZK',
14
+ XUDLDKS: 'DKK',
15
+ XUDLERS: 'EUR',
16
+ XUDLHDS: 'HKD',
17
+ XUDLBK33: 'HUF',
18
+ XUDLBK97: 'INR',
19
+ XUDLBK78: 'ILS',
20
+ XUDLJYS: 'JPY',
21
+ XUDLBK83: 'MYR',
22
+ XUDLNDS: 'NZD',
23
+ XUDLNKS: 'NOK',
24
+ XUDLBK47: 'PLN',
25
+ XUDLZOS4: 'RON',
26
+ XUDLSRS: 'SAR',
27
+ XUDLSGS: 'SGD',
28
+ XUDLZRS: 'ZAR',
29
+ XUDLBK93: 'KRW',
30
+ XUDLSKS: 'SEK',
31
+ XUDLSFS: 'CHF',
32
+ XUDLTWS: 'TWD',
33
+ XUDLBK87: 'THB',
34
+ XUDLBK95: 'TRY',
35
+ XUDLUSS: 'USD',
36
+ };
37
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
38
+
39
+ function notBoe(why: string): never {
40
+ throw new SyntaxError(`Not a Bank of England spot rates CSV file: ${why}`);
41
+ }
42
+
43
+ /** '22 Dec 2025' as an ISO date, or SyntaxError. */
44
+ function isoDate(text: string): string {
45
+ const match = /^(\d\d) ([A-Z][a-z]{2}) (\d{4})$/.exec(text.trim());
46
+ const month = match === null ? 0 : MONTHS.indexOf(match[2]!) + 1;
47
+ const day = match === null ? '' : `${match[3]}-${String(month).padStart(2, '0')}-${match[1]}`;
48
+ if (month === 0 || !isCalendarDay(day)) {
49
+ notBoe(`bad date ${JSON.stringify(text)}`);
50
+ }
51
+ return day;
52
+ }
53
+
54
+ /** Options for parseBankOfEnglandCsv. */
55
+ export interface BankOfEnglandOptions {
56
+ /**
57
+ * The decimals the Bank publishes, rebuilt because its CSV strips trailing zeros ('1.35' for
58
+ * 1.3500). Default 4, as its database shows every spot series.
59
+ */
60
+ readonly decimals?: number;
61
+ }
62
+
63
+ /**
64
+ * Parses the Bank of England database's CSV of daily spot rates against sterling (series XUDL…,
65
+ * downloaded with codes as column titles: `DATE,XUDLUSS,XUDLERS,…` and dates like `22 Dec 2025`)
66
+ * into one GBP-based table per day, newest first. Rates are currency units per £1, with `source:
67
+ * 'BoE'` and `type: 'spot'`. The CSV strips trailing zeros, so they are rebuilt to `decimals`
68
+ * (default 4). Holidays have no row; an empty cell (before a series starts, or after it ends, as
69
+ * for BGN) is a missing value. An unknown series code, a bad date, two different values for a day
70
+ * or any other unreadable cell throws SyntaxError. No `availableAt` is set: the Bank says only
71
+ * that daily rates are updated "no more than two working days after the date to which they
72
+ * relate".
73
+ *
74
+ * **Licensing: you must check the licence of every Bank of England series you load.** The database
75
+ * is under the Open Government Licence,
76
+ * but the Bank's legal page says "selected exchange rate data and series are excluded from this
77
+ * licence" and "reproduced by the Bank under licence from third parties", and its spot rates
78
+ * page says LSEG and Bloomberg BFIX may be sources, with LSEG content not to be redistributed
79
+ * without LSEG's consent. No series is named either way, so ducat ships no Bank of England data
80
+ * and no fetcher. Parse only files you have downloaded and are allowed to use, and get the Bank's
81
+ * (or LSEG's) permission before you republish any of it. The Bank also says these "are not
82
+ * official rates". For a licensed UK source, see HMRC's monthly rates
83
+ * (@ducatjs/money/convert/hmrc).
84
+ */
85
+ export function parseBankOfEnglandCsv(csv: string, options?: BankOfEnglandOptions): RateTable[] {
86
+ const decimals = options?.decimals ?? 4;
87
+ const [header, ...rows] = csvRows(csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv);
88
+ if (header === undefined || header[0]?.trim() !== 'DATE' || header.length < 2) {
89
+ notBoe('no DATE column');
90
+ }
91
+ const currencies = header.slice(1).map((code) => {
92
+ const currency = BOE_SERIES[code.trim()];
93
+ if (currency === undefined) {
94
+ notBoe(`unknown series ${code} (use the daily spot series against sterling, XUDL…)`);
95
+ }
96
+ return currency;
97
+ });
98
+ if (new Set(currencies).size !== currencies.length) {
99
+ notBoe('a series appears twice');
100
+ }
101
+
102
+ // A download always has a row per business day, so none at all is a cut-off file, not a range
103
+ // of holidays.
104
+ if (rows.length === 0) {
105
+ notBoe('no observations');
106
+ }
107
+ const days = new Map<string, Record<string, string | null>>();
108
+ for (const cells of rows) {
109
+ if (cells.length !== header.length) {
110
+ notBoe(`a row has ${cells.length} cells, the header ${header.length}`);
111
+ }
112
+ const day = isoDate(cells[0]!);
113
+ currencies.forEach((currency, i) => {
114
+ addDayRate(days, day, currency, readRateCell(cells[i + 1]!, `${currency} on ${day}`));
115
+ });
116
+ }
117
+
118
+ const tables: RateTable[] = [];
119
+ for (const [day, rates] of days) {
120
+ if (Object.values(rates).some((rate) => rate !== null)) {
121
+ tables.push(
122
+ quotedTable({ base: 'GBP', source: 'BoE', type: 'spot', asOf: day, decimals, rates }),
123
+ );
124
+ }
125
+ }
126
+ // A download only has rows for business days, so rows without a single rate aren't data.
127
+ if (tables.length === 0) {
128
+ notBoe('no rates in any row');
129
+ }
130
+ return newestFirst(tables);
131
+ }
package/src/cash.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import { CASH_RULES, type CashRule } from './generated/cash.js';
2
- import { CurrencyMismatchError, make, scaleUp, toScale, type Money } from './money.js';
2
+ import {
3
+ CurrencyMismatchError,
4
+ make,
5
+ scaleUp,
6
+ toScale,
7
+ type Money,
8
+ type RoundingPolicy,
9
+ } from './money.js';
10
+ import { roundingPolicy } from './policy.js';
3
11
  import { divideAndRound, divideAndRoundBigint, type RoundingMode } from './rounding.js';
4
12
  import { currencyExponent } from './currency.js';
5
13
 
@@ -109,7 +117,67 @@ export function cashRound<C extends string>(money: Money<C>, options?: CashRound
109
117
  return money;
110
118
  }
111
119
  const inc = make(money.currency, rule.increment, currencyExponent(money.currency));
112
- return roundToIncrement(money, inc, options?.rounding ?? 'halfExpand');
120
+ return roundToIncrement(money, inc, options?.rounding ?? rule.rounding ?? 'halfExpand');
121
+ }
122
+
123
+ /**
124
+ * The cash-rounding rule for a currency as a `RoundingPolicy`, to pass wherever a rounding mode
125
+ * goes: `m.multiply('1.075', cashPolicy('CHF'))` rounds once, straight to CHF 0.05. The policy only
126
+ * accepts amounts in `currency`. Throws a RangeError when no rule applies (EUR needs a country);
127
+ * use `isoPolicy` for currencies without cash rounding.
128
+ */
129
+ export function cashPolicy(currency: string, options?: CashRoundOptions): RoundingPolicy {
130
+ const rule = cashRule(currency, options?.country);
131
+ if (!rule || (options?.includeUnverified === false && !rule.verified)) {
132
+ const where = options?.country === undefined ? '' : ` in ${options.country}`;
133
+ throw new RangeError(`No cash-rounding rule for ${currency}${where}`);
134
+ }
135
+ return roundingPolicy({
136
+ scale: currencyExponent(currency),
137
+ increment: rule.increment,
138
+ rounding: options?.rounding ?? rule.rounding ?? 'halfExpand',
139
+ currency,
140
+ });
141
+ }
142
+
143
+ /** Options for `currencyDigits`. */
144
+ export interface CurrencyDigitsOptions {
145
+ /** 'standard' (the default): the ISO 4217 exponent. 'cash': the decimals of a cash amount. */
146
+ readonly usage?: 'standard' | 'cash';
147
+ /** For cash: the country (ISO 3166 alpha-2), which EUR needs. */
148
+ readonly country?: string;
149
+ /** For cash: whether to use rules backed only by secondary sources (default true). */
150
+ readonly includeUnverified?: boolean;
151
+ }
152
+
153
+ /**
154
+ * The number of decimals a currency's amounts are shown with: its ISO 4217 exponent, or with
155
+ * `{ usage: 'cash' }`, the decimals of a cash amount (what CLDR calls `cashDigits`): none when cash
156
+ * is rounded to whole units, the exponent otherwise.
157
+ *
158
+ * currencyDigits('SEK') // 2
159
+ * currencyDigits('SEK', { usage: 'cash' }) // 0: cash is rounded to whole kronor
160
+ * currencyDigits('CHF', { usage: 'cash' }) // 2: CHF 0.05 still needs two decimals
161
+ *
162
+ * A currency without a cash rule (or EUR without a country that has one) takes its exponent.
163
+ * Throws for unknown codes, like `currencyExponent`.
164
+ */
165
+ export function currencyDigits(currency: string, options?: CurrencyDigitsOptions): number {
166
+ const exponent = currencyExponent(currency);
167
+ if (options?.usage !== 'cash') {
168
+ return exponent;
169
+ }
170
+ const rule = cashRule(currency, options.country);
171
+ if (!rule || (options.includeUnverified === false && !rule.verified)) {
172
+ return exponent;
173
+ }
174
+
175
+ // Cash amounts show no decimals once the step is whole units (SEK 1, HUF 5, UZS 50), and the
176
+ // currency's usual decimals otherwise: DKK 0.50 is shown as 10.50, not 10.5, as in CLDR.
177
+ if (rule.increment % 10 ** exponent === 0) {
178
+ return 0;
179
+ }
180
+ return exponent;
113
181
  }
114
182
 
115
183
  export { CASH_RULES, type CashRule };
package/src/convert.ts CHANGED
@@ -2,10 +2,16 @@
2
2
  import { currencyExponent } from './currency.js';
3
3
  import { exact, type Rounded } from './exact.js';
4
4
  import { ratio, type Factor } from './decimal.js';
5
- import { make, type CurrencyInput, type Money } from './money.js';
5
+ import { make, type CurrencyInput, type Money, type RoundingInput } from './money.js';
6
6
  import { rateOf, type RateProvider } from './provider.js';
7
- import { isRate, multiplyByRatioAtScale, type ConvertOptions, type Rate } from './rate.js';
8
- import { DEFAULT_ROUNDING, type RoundingMode } from './rounding.js';
7
+ import {
8
+ convertWithPolicy,
9
+ isRate,
10
+ multiplyByRatioAtScale,
11
+ type ConvertOptions,
12
+ type Rate,
13
+ } from './rate.js';
14
+ import { DEFAULT_ROUNDING } from './rounding.js';
9
15
 
10
16
  export {
11
17
  exchangeRate,
@@ -18,6 +24,7 @@ export {
18
24
  type ConvertOptions,
19
25
  type RateInfo,
20
26
  type RateJSON,
27
+ type RateQuote,
21
28
  type RateType,
22
29
  } from './rate.js';
23
30
  export {
@@ -58,7 +65,8 @@ function isProvider(value: unknown): value is RateProvider {
58
65
  }
59
66
 
60
67
  /**
61
- * Converts `money` at a Rate, rounding once to the target currency's exponent (or `options.scale`).
68
+ * Converts `money` at a Rate, rounding once to the target currency's exponent (or `options.scale`),
69
+ * or with a rounding policy to its scale and increment (`@ducatjs/money/policy`).
62
70
  * The rate's pair is checked, at compile time for literal currencies and at run time otherwise.
63
71
  *
64
72
  * convert(money('100', 'EUR'), eurUsd, 'halfEven') // 111.65 USD
@@ -67,7 +75,7 @@ function isProvider(value: unknown): value is RateProvider {
67
75
  export function convert<From extends string, To extends string>(
68
76
  money: Money<From>,
69
77
  rate: Rate<NoInfer<From>, To>,
70
- rounding?: RoundingMode,
78
+ rounding?: RoundingInput,
71
79
  options?: ConvertOptions,
72
80
  ): Money<To>;
73
81
  /**
@@ -80,7 +88,7 @@ export function convert<To extends CurrencyInput>(
80
88
  money: Money,
81
89
  to: To,
82
90
  rate: Factor,
83
- rounding?: RoundingMode,
91
+ rounding?: RoundingInput,
84
92
  options?: ConvertOptions,
85
93
  ): Money<To>;
86
94
  /**
@@ -93,18 +101,18 @@ export function convert<To extends CurrencyInput>(
93
101
  money: Money,
94
102
  to: To,
95
103
  provider: RateProvider,
96
- rounding?: RoundingMode,
104
+ rounding?: RoundingInput,
97
105
  options?: ProviderConvertOptions,
98
106
  ): Money<To>;
99
107
  export function convert(
100
108
  money: Money,
101
109
  rateOrTarget: Rate | string,
102
- roundingOrRate?: RoundingMode | Factor | RateProvider,
103
- optionsOrRounding?: ConvertOptions | RoundingMode,
110
+ roundingOrRate?: RoundingInput | Factor | RateProvider,
111
+ optionsOrRounding?: ConvertOptions | RoundingInput,
104
112
  maybeOptions?: ProviderConvertOptions,
105
113
  ): Money {
106
114
  if (isRate(rateOrTarget)) {
107
- const rounding = roundingOrRate as RoundingMode | undefined;
115
+ const rounding = roundingOrRate as RoundingInput | undefined;
108
116
  return rateOrTarget.convert(money, rounding, optionsOrRounding as ConvertOptions | undefined);
109
117
  }
110
118
 
@@ -115,7 +123,7 @@ export function convert(
115
123
  query = { asOf: maybeOptions.asOf };
116
124
  }
117
125
  const rate = rateOf(roundingOrRate, money.currency, to, query);
118
- return rate.convert(money, optionsOrRounding as RoundingMode | undefined, maybeOptions);
126
+ return rate.convert(money, optionsOrRounding as RoundingInput | undefined, maybeOptions);
119
127
  }
120
128
 
121
129
  // The bare-rate form converts directly, without building a Rate, to keep it allocation-light.
@@ -128,7 +136,10 @@ export function convert(
128
136
  if (rate.num <= 0n) {
129
137
  throw new RangeError('Exchange rate must be positive');
130
138
  }
131
- const rounding = (optionsOrRounding as RoundingMode | undefined) ?? DEFAULT_ROUNDING;
139
+ const rounding = (optionsOrRounding as RoundingInput | undefined) ?? DEFAULT_ROUNDING;
140
+ if (typeof rounding === 'function') {
141
+ return convertWithPolicy(money, to, rate, rounding, maybeOptions);
142
+ }
132
143
  const scale = maybeOptions?.scale ?? currencyExponent(to);
133
144
  const minor = multiplyByRatioAtScale(money.minor, rate, scale - money.scale, rounding);
134
145
  return make(to, minor, scale);
@@ -143,7 +154,8 @@ export interface DetailedConversion<From extends string, To extends string> exte
143
154
 
144
155
  /**
145
156
  * Converts at `rate` like convert(), and also reports the residual: `value + residual` is exactly
146
- * money × rate, so a ledger can book the rounding difference.
157
+ * money × rate, so a ledger can book the rounding difference. With a rounding policy, the value
158
+ * is on the policy's scale and increment, and the residual is still the exact difference.
147
159
  *
148
160
  * const { value, residual } = convertDetailed(money('0.07', 'EUR'), eurUsd, 'halfEven');
149
161
  * // value 0.08 USD, residual -0.001845 USD (as an exact ExactMoney)
@@ -151,9 +163,15 @@ export interface DetailedConversion<From extends string, To extends string> exte
151
163
  export function convertDetailed<From extends string, To extends string>(
152
164
  money: Money<From>,
153
165
  rate: Rate<NoInfer<From>, To>,
154
- rounding: RoundingMode = DEFAULT_ROUNDING,
166
+ rounding: RoundingInput = DEFAULT_ROUNDING,
155
167
  options?: ConvertOptions,
156
168
  ): DetailedConversion<From, To> {
157
- const rounded = exact(money).convert(rate).roundWithResidual(rounding, options?.scale);
169
+ const converted = exact(money).convert(rate);
170
+ if (typeof rounding === 'function') {
171
+ // The policy rounds; the residual is still exactly what it left out.
172
+ const value = rate.convert(money, rounding, options);
173
+ return { value, residual: converted.subtract(exact(value)), rate };
174
+ }
175
+ const rounded = converted.roundWithResidual(rounding, options?.scale);
158
176
  return { value: rounded.value, residual: rounded.residual, rate };
159
177
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `@ducatjs/money/drizzle/mysql`: Drizzle ORM column types for MySQL and MariaDB (mysql2,
3
+ * PlanetScale). Selects return `Money`, inserts and updates accept it, and the currency is part of
4
+ * the column's type.
5
+ *
6
+ * import { mysqlTable } from 'drizzle-orm/mysql-core';
7
+ * import { moneyMinor } from '@ducatjs/money/drizzle/mysql';
8
+ *
9
+ * const orders = mysqlTable('orders', { total: moneyMinor('EUR')('total_minor').notNull() });
10
+ */
11
+ import { customType } from 'drizzle-orm/mysql-core';
12
+ import type { CurrencyInput, Money, MoneyJSON } from '../money.js';
13
+ import {
14
+ checkDecimalSize,
15
+ decimalFromDriver,
16
+ decimalToDriver,
17
+ DEFAULT_DECIMAL_SIZE,
18
+ jsonFromDriver,
19
+ jsonToDriver,
20
+ minorFromDriver,
21
+ minorToDriver,
22
+ type DecimalColumnSize,
23
+ } from '../sql.js';
24
+
25
+ export { DEFAULT_DECIMAL_SIZE, type DecimalColumnSize } from '../sql.js';
26
+
27
+ /**
28
+ * A BIGINT column of minor units in a fixed currency (`1234` is 12.34 EUR), read back as
29
+ * `Money<C>`. Exact up to the column's limit of ±9.2·10^18 minor units. mysql2 returns BIGINT as a
30
+ * number by default, which is exact only below 2^53; beyond that it throws, so set
31
+ * `supportBigNumbers: true` (with `bigNumberStrings: true`) on the connection for larger amounts.
32
+ * Writing another currency, or a value at a scale other than the currency's, throws.
33
+ *
34
+ * total: moneyMinor('EUR')('total_minor').notNull()
35
+ */
36
+ export function moneyMinor<const C extends CurrencyInput>(currency: C) {
37
+ return customType<{ data: Money<C>; driverData: string | number | bigint }>({
38
+ dataType: () => 'bigint',
39
+ toDriver: (money) => minorToDriver(money, currency).toString(),
40
+ fromDriver: (value) => minorFromDriver(value, currency),
41
+ });
42
+ }
43
+
44
+ /**
45
+ * A DECIMAL(precision, scale) column, DECIMAL(19,4) by default, read back as `Money<C>`. The driver
46
+ * must return DECIMAL as a string (mysql2's default; don't set `decimalNumbers`): a number has been
47
+ * through a float and throws. Writing a value the column could not hold unchanged throws (outside
48
+ * strict mode MySQL would clamp it), and so does a column scale below the currency's exponent.
49
+ *
50
+ * price: moneyDecimal('EUR', { precision: 19, scale: 4 })('price').notNull()
51
+ */
52
+ export function moneyDecimal<const C extends CurrencyInput>(
53
+ currency: C,
54
+ size: DecimalColumnSize = DEFAULT_DECIMAL_SIZE,
55
+ ) {
56
+ checkDecimalSize(currency, size);
57
+
58
+ return customType<{ data: Money<C>; driverData: string }>({
59
+ dataType: () => `decimal(${size.precision}, ${size.scale})`,
60
+ toDriver: (money) => decimalToDriver(money, currency, size),
61
+ fromDriver: (value) => decimalFromDriver(value, currency),
62
+ });
63
+ }
64
+
65
+ /**
66
+ * A JSON column holding `{"amount":"12.34","currency":"EUR"}`, with the currency in each value.
67
+ * Pass a currency to fix it: then a value in another currency throws, on write and on read.
68
+ *
69
+ * refund: moneyJson()('refund')
70
+ */
71
+ export function moneyJson<const C extends CurrencyInput = CurrencyInput>(currency?: C) {
72
+ return customType<{ data: Money<C>; driverData: string | MoneyJSON }>({
73
+ dataType: () => 'json',
74
+ toDriver: (money) => jsonToDriver(money, currency),
75
+ // mysql2 parses JSON columns itself; other drivers may return the text
76
+ fromDriver: (value) => jsonFromDriver(value, currency),
77
+ });
78
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `@ducatjs/money/drizzle/pg`: Drizzle ORM column types for Postgres (node-postgres, postgres.js,
3
+ * PGlite, Neon). Selects return `Money`, inserts and updates accept it, and the currency is part of
4
+ * the column's type.
5
+ *
6
+ * import { pgTable } from 'drizzle-orm/pg-core';
7
+ * import { moneyMinor } from '@ducatjs/money/drizzle/pg';
8
+ *
9
+ * const orders = pgTable('orders', { total: moneyMinor('EUR')('total_minor').notNull() });
10
+ */
11
+ import { customType } from 'drizzle-orm/pg-core';
12
+ import type { CurrencyInput, Money, MoneyJSON } from '../money.js';
13
+ import {
14
+ checkDecimalSize,
15
+ decimalFromDriver,
16
+ decimalToDriver,
17
+ DEFAULT_DECIMAL_SIZE,
18
+ jsonFromDriver,
19
+ jsonToDriver,
20
+ minorFromDriver,
21
+ minorToDriver,
22
+ type DecimalColumnSize,
23
+ } from '../sql.js';
24
+
25
+ export { DEFAULT_DECIMAL_SIZE, type DecimalColumnSize } from '../sql.js';
26
+
27
+ /**
28
+ * A BIGINT column of minor units in a fixed currency (`1234` is 12.34 EUR), read back as
29
+ * `Money<C>`. Exact up to the column's limit of ±9.2·10^18 minor units. Writing another currency, or
30
+ * a value at a scale other than the currency's, throws.
31
+ *
32
+ * total: moneyMinor('EUR')('total_minor').notNull()
33
+ */
34
+ export function moneyMinor<const C extends CurrencyInput>(currency: C) {
35
+ return customType<{ data: Money<C>; driverData: string | number | bigint }>({
36
+ dataType: () => 'bigint',
37
+ // a string, not a bigint: node-postgres cannot bind a bigint parameter
38
+ toDriver: (money) => minorToDriver(money, currency).toString(),
39
+ // node-postgres returns int8 as a string, postgres.js as a string or a bigint
40
+ fromDriver: (value) => minorFromDriver(value, currency),
41
+ });
42
+ }
43
+
44
+ /**
45
+ * A NUMERIC(precision, scale) column, NUMERIC(19,4) by default, read back as `Money<C>`. The
46
+ * driver must return NUMERIC as a string (node-postgres and postgres.js do); a number has been
47
+ * through a float and throws, as do `NaN` and `Infinity`. Writing a value the column could not hold
48
+ * unchanged throws, and so does a column scale below the currency's exponent.
49
+ *
50
+ * price: moneyDecimal('EUR', { precision: 19, scale: 4 })('price').notNull()
51
+ */
52
+ export function moneyDecimal<const C extends CurrencyInput>(
53
+ currency: C,
54
+ size: DecimalColumnSize = DEFAULT_DECIMAL_SIZE,
55
+ ) {
56
+ checkDecimalSize(currency, size);
57
+
58
+ return customType<{ data: Money<C>; driverData: string }>({
59
+ dataType: () => `numeric(${size.precision}, ${size.scale})`,
60
+ toDriver: (money) => decimalToDriver(money, currency, size),
61
+ fromDriver: (value) => decimalFromDriver(value, currency),
62
+ });
63
+ }
64
+
65
+ /**
66
+ * A JSONB column holding `{"amount":"12.34","currency":"EUR"}`, with the currency in each value.
67
+ * Pass a currency to fix it: then a value in another currency throws, on write and on read.
68
+ *
69
+ * refund: moneyJson()('refund')
70
+ */
71
+ export function moneyJson<const C extends CurrencyInput = CurrencyInput>(currency?: C) {
72
+ return customType<{ data: Money<C>; driverData: string | MoneyJSON }>({
73
+ dataType: () => 'jsonb',
74
+ toDriver: (money) => jsonToDriver(money, currency),
75
+ // node-postgres parses JSONB itself; other drivers may return the text
76
+ fromDriver: (value) => jsonFromDriver(value, currency),
77
+ });
78
+ }