@ducatjs/money 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 (285) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +51 -0
  3. package/README.md +617 -2
  4. package/assets/CREDITS.md +12 -0
  5. package/data/adyen-fx.json +19 -0
  6. package/data/adyen.json +171 -0
  7. package/data/braintree.json +223 -0
  8. package/data/cash-rounding.json +399 -0
  9. package/data/checkout.json +230 -0
  10. package/data/iso4217-list-one.xml +1956 -0
  11. package/data/klarna.json +95 -0
  12. package/data/paypal.json +65 -0
  13. package/data/stripe-fx.json +17 -0
  14. package/data/stripe.json +285 -0
  15. package/dist/adyen-fx.d.ts +25 -0
  16. package/dist/adyen-fx.d.ts.map +1 -0
  17. package/dist/adyen-fx.js +122 -0
  18. package/dist/adyen-fx.js.map +1 -0
  19. package/dist/adyen.d.ts +25 -0
  20. package/dist/adyen.d.ts.map +1 -0
  21. package/dist/adyen.js +34 -0
  22. package/dist/adyen.js.map +1 -0
  23. package/dist/allocate.d.ts +72 -0
  24. package/dist/allocate.d.ts.map +1 -0
  25. package/dist/allocate.js +314 -0
  26. package/dist/allocate.js.map +1 -0
  27. package/dist/bag.d.ts +58 -0
  28. package/dist/bag.d.ts.map +1 -0
  29. package/dist/bag.js +122 -0
  30. package/dist/bag.js.map +1 -0
  31. package/dist/braintree.d.ts +34 -0
  32. package/dist/braintree.d.ts.map +1 -0
  33. package/dist/braintree.js +45 -0
  34. package/dist/braintree.js.map +1 -0
  35. package/dist/cash.d.ts +35 -0
  36. package/dist/cash.d.ts.map +1 -0
  37. package/dist/cash.js +86 -0
  38. package/dist/cash.js.map +1 -0
  39. package/dist/checkout.d.ts +42 -0
  40. package/dist/checkout.d.ts.map +1 -0
  41. package/dist/checkout.js +52 -0
  42. package/dist/checkout.js.map +1 -0
  43. package/dist/convert.d.ts +51 -0
  44. package/dist/convert.d.ts.map +1 -0
  45. package/dist/convert.js +55 -0
  46. package/dist/convert.js.map +1 -0
  47. package/dist/currency.d.ts +22 -0
  48. package/dist/currency.d.ts.map +1 -0
  49. package/dist/currency.js +38 -0
  50. package/dist/currency.js.map +1 -0
  51. package/dist/decimal.d.ts +52 -0
  52. package/dist/decimal.d.ts.map +1 -0
  53. package/dist/decimal.js +251 -0
  54. package/dist/decimal.js.map +1 -0
  55. package/dist/ecb.d.ts +17 -0
  56. package/dist/ecb.d.ts.map +1 -0
  57. package/dist/ecb.js +147 -0
  58. package/dist/ecb.js.map +1 -0
  59. package/dist/exact.d.ts +62 -0
  60. package/dist/exact.d.ts.map +1 -0
  61. package/dist/exact.js +147 -0
  62. package/dist/exact.js.map +1 -0
  63. package/dist/fetch.d.ts +58 -0
  64. package/dist/fetch.d.ts.map +1 -0
  65. package/dist/fetch.js +79 -0
  66. package/dist/fetch.js.map +1 -0
  67. package/dist/fixed.d.ts +121 -0
  68. package/dist/fixed.d.ts.map +1 -0
  69. package/dist/fixed.js +420 -0
  70. package/dist/fixed.js.map +1 -0
  71. package/dist/fn.d.ts +27 -0
  72. package/dist/fn.d.ts.map +1 -0
  73. package/dist/fn.js +30 -0
  74. package/dist/fn.js.map +1 -0
  75. package/dist/format.d.ts +13 -0
  76. package/dist/format.d.ts.map +1 -0
  77. package/dist/format.js +90 -0
  78. package/dist/format.js.map +1 -0
  79. package/dist/generated/adyen.d.ts +3 -0
  80. package/dist/generated/adyen.d.ts.map +1 -0
  81. package/dist/generated/adyen.js +144 -0
  82. package/dist/generated/adyen.js.map +1 -0
  83. package/dist/generated/braintree.d.ts +3 -0
  84. package/dist/generated/braintree.d.ts.map +1 -0
  85. package/dist/generated/braintree.js +140 -0
  86. package/dist/generated/braintree.js.map +1 -0
  87. package/dist/generated/cash.d.ts +12 -0
  88. package/dist/generated/cash.d.ts.map +1 -0
  89. package/dist/generated/cash.js +364 -0
  90. package/dist/generated/cash.js.map +1 -0
  91. package/dist/generated/checkout.d.ts +3 -0
  92. package/dist/generated/checkout.d.ts.map +1 -0
  93. package/dist/generated/checkout.js +151 -0
  94. package/dist/generated/checkout.js.map +1 -0
  95. package/dist/generated/iso4217.d.ts +174 -0
  96. package/dist/generated/iso4217.d.ts.map +1 -0
  97. package/dist/generated/iso4217.js +175 -0
  98. package/dist/generated/iso4217.js.map +1 -0
  99. package/dist/generated/kernel.d.ts +17 -0
  100. package/dist/generated/kernel.d.ts.map +1 -0
  101. package/dist/generated/kernel.js +208 -0
  102. package/dist/generated/kernel.js.map +1 -0
  103. package/dist/generated/klarna.d.ts +3 -0
  104. package/dist/generated/klarna.d.ts.map +1 -0
  105. package/dist/generated/klarna.js +21 -0
  106. package/dist/generated/klarna.js.map +1 -0
  107. package/dist/generated/paypal.d.ts +3 -0
  108. package/dist/generated/paypal.d.ts.map +1 -0
  109. package/dist/generated/paypal.js +31 -0
  110. package/dist/generated/paypal.js.map +1 -0
  111. package/dist/generated/stripe.d.ts +8 -0
  112. package/dist/generated/stripe.d.ts.map +1 -0
  113. package/dist/generated/stripe.js +150 -0
  114. package/dist/generated/stripe.js.map +1 -0
  115. package/dist/index.d.ts +11 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +11 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/klarna.d.ts +31 -0
  120. package/dist/klarna.d.ts.map +1 -0
  121. package/dist/klarna.js +38 -0
  122. package/dist/klarna.js.map +1 -0
  123. package/dist/money.d.ts +218 -0
  124. package/dist/money.d.ts.map +1 -0
  125. package/dist/money.js +577 -0
  126. package/dist/money.js.map +1 -0
  127. package/dist/parse.d.ts +11 -0
  128. package/dist/parse.d.ts.map +1 -0
  129. package/dist/parse.js +132 -0
  130. package/dist/parse.js.map +1 -0
  131. package/dist/paypal.d.ts +41 -0
  132. package/dist/paypal.d.ts.map +1 -0
  133. package/dist/paypal.js +58 -0
  134. package/dist/paypal.js.map +1 -0
  135. package/dist/plugins/adyen.d.ts +15 -0
  136. package/dist/plugins/adyen.d.ts.map +1 -0
  137. package/dist/plugins/adyen.js +10 -0
  138. package/dist/plugins/adyen.js.map +1 -0
  139. package/dist/plugins/all.d.ts +22 -0
  140. package/dist/plugins/all.d.ts.map +1 -0
  141. package/dist/plugins/all.js +23 -0
  142. package/dist/plugins/all.js.map +1 -0
  143. package/dist/plugins/allocate.d.ts +20 -0
  144. package/dist/plugins/allocate.d.ts.map +1 -0
  145. package/dist/plugins/allocate.js +26 -0
  146. package/dist/plugins/allocate.js.map +1 -0
  147. package/dist/plugins/braintree.d.ts +12 -0
  148. package/dist/plugins/braintree.d.ts.map +1 -0
  149. package/dist/plugins/braintree.js +12 -0
  150. package/dist/plugins/braintree.js.map +1 -0
  151. package/dist/plugins/cash.d.ts +19 -0
  152. package/dist/plugins/cash.d.ts.map +1 -0
  153. package/dist/plugins/cash.js +16 -0
  154. package/dist/plugins/cash.js.map +1 -0
  155. package/dist/plugins/checkout.d.ts +21 -0
  156. package/dist/plugins/checkout.d.ts.map +1 -0
  157. package/dist/plugins/checkout.js +16 -0
  158. package/dist/plugins/checkout.js.map +1 -0
  159. package/dist/plugins/convert.d.ts +20 -0
  160. package/dist/plugins/convert.d.ts.map +1 -0
  161. package/dist/plugins/convert.js +13 -0
  162. package/dist/plugins/convert.js.map +1 -0
  163. package/dist/plugins/exact.d.ts +15 -0
  164. package/dist/plugins/exact.d.ts.map +1 -0
  165. package/dist/plugins/exact.js +13 -0
  166. package/dist/plugins/exact.js.map +1 -0
  167. package/dist/plugins/fixed.d.ts +17 -0
  168. package/dist/plugins/fixed.d.ts.map +1 -0
  169. package/dist/plugins/fixed.js +13 -0
  170. package/dist/plugins/fixed.js.map +1 -0
  171. package/dist/plugins/format.d.ts +9 -0
  172. package/dist/plugins/format.d.ts.map +1 -0
  173. package/dist/plugins/format.js +16 -0
  174. package/dist/plugins/format.js.map +1 -0
  175. package/dist/plugins/klarna.d.ts +10 -0
  176. package/dist/plugins/klarna.d.ts.map +1 -0
  177. package/dist/plugins/klarna.js +10 -0
  178. package/dist/plugins/klarna.js.map +1 -0
  179. package/dist/plugins/paypal.d.ts +14 -0
  180. package/dist/plugins/paypal.d.ts.map +1 -0
  181. package/dist/plugins/paypal.js +16 -0
  182. package/dist/plugins/paypal.js.map +1 -0
  183. package/dist/plugins/prisma.d.ts +8 -0
  184. package/dist/plugins/prisma.d.ts.map +1 -0
  185. package/dist/plugins/prisma.js +13 -0
  186. package/dist/plugins/prisma.js.map +1 -0
  187. package/dist/plugins/stripe.d.ts +14 -0
  188. package/dist/plugins/stripe.d.ts.map +1 -0
  189. package/dist/plugins/stripe.js +16 -0
  190. package/dist/plugins/stripe.js.map +1 -0
  191. package/dist/plugins/tax.d.ts +21 -0
  192. package/dist/plugins/tax.d.ts.map +1 -0
  193. package/dist/plugins/tax.js +18 -0
  194. package/dist/plugins/tax.js.map +1 -0
  195. package/dist/prisma.d.ts +66 -0
  196. package/dist/prisma.d.ts.map +1 -0
  197. package/dist/prisma.js +66 -0
  198. package/dist/prisma.js.map +1 -0
  199. package/dist/processor.d.ts +28 -0
  200. package/dist/processor.d.ts.map +1 -0
  201. package/dist/processor.js +118 -0
  202. package/dist/processor.js.map +1 -0
  203. package/dist/provider.d.ts +89 -0
  204. package/dist/provider.d.ts.map +1 -0
  205. package/dist/provider.js +240 -0
  206. package/dist/provider.js.map +1 -0
  207. package/dist/rate.d.ts +119 -0
  208. package/dist/rate.d.ts.map +1 -0
  209. package/dist/rate.js +271 -0
  210. package/dist/rate.js.map +1 -0
  211. package/dist/rounding-error.d.ts +9 -0
  212. package/dist/rounding-error.d.ts.map +1 -0
  213. package/dist/rounding-error.js +9 -0
  214. package/dist/rounding-error.js.map +1 -0
  215. package/dist/rounding.d.ts +50 -0
  216. package/dist/rounding.d.ts.map +1 -0
  217. package/dist/rounding.js +87 -0
  218. package/dist/rounding.js.map +1 -0
  219. package/dist/stripe-fx.d.ts +22 -0
  220. package/dist/stripe-fx.d.ts.map +1 -0
  221. package/dist/stripe-fx.js +30 -0
  222. package/dist/stripe-fx.js.map +1 -0
  223. package/dist/stripe.d.ts +36 -0
  224. package/dist/stripe.d.ts.map +1 -0
  225. package/dist/stripe.js +39 -0
  226. package/dist/stripe.js.map +1 -0
  227. package/dist/tax.d.ts +29 -0
  228. package/dist/tax.d.ts.map +1 -0
  229. package/dist/tax.js +39 -0
  230. package/dist/tax.js.map +1 -0
  231. package/package.json +171 -4
  232. package/src/adyen-fx.ts +154 -0
  233. package/src/adyen.ts +46 -0
  234. package/src/allocate.ts +393 -0
  235. package/src/bag.ts +170 -0
  236. package/src/braintree.ts +71 -0
  237. package/src/cash.ts +115 -0
  238. package/src/checkout.ts +77 -0
  239. package/src/convert.ts +159 -0
  240. package/src/currency.ts +61 -0
  241. package/src/decimal.ts +283 -0
  242. package/src/ecb.ts +162 -0
  243. package/src/exact.ts +182 -0
  244. package/src/fetch.ts +127 -0
  245. package/src/fixed.ts +558 -0
  246. package/src/fn.ts +80 -0
  247. package/src/format.ts +106 -0
  248. package/src/generated/adyen.ts +144 -0
  249. package/src/generated/braintree.ts +140 -0
  250. package/src/generated/cash.ts +375 -0
  251. package/src/generated/checkout.ts +151 -0
  252. package/src/generated/iso4217.ts +179 -0
  253. package/src/generated/kernel.ts +230 -0
  254. package/src/generated/klarna.ts +21 -0
  255. package/src/generated/paypal.ts +31 -0
  256. package/src/generated/stripe.ts +150 -0
  257. package/src/index.ts +37 -0
  258. package/src/klarna.ts +54 -0
  259. package/src/money.ts +781 -0
  260. package/src/parse.ts +144 -0
  261. package/src/paypal.ts +93 -0
  262. package/src/plugins/adyen.ts +25 -0
  263. package/src/plugins/all.ts +47 -0
  264. package/src/plugins/allocate.ts +53 -0
  265. package/src/plugins/braintree.ts +23 -0
  266. package/src/plugins/cash.ts +33 -0
  267. package/src/plugins/checkout.ts +32 -0
  268. package/src/plugins/convert.ts +50 -0
  269. package/src/plugins/exact.ts +23 -0
  270. package/src/plugins/fixed.ts +28 -0
  271. package/src/plugins/format.ts +28 -0
  272. package/src/plugins/klarna.ts +21 -0
  273. package/src/plugins/paypal.ts +29 -0
  274. package/src/plugins/prisma.ts +24 -0
  275. package/src/plugins/stripe.ts +29 -0
  276. package/src/plugins/tax.ts +41 -0
  277. package/src/prisma.ts +118 -0
  278. package/src/processor.ts +181 -0
  279. package/src/provider.ts +324 -0
  280. package/src/rate.ts +377 -0
  281. package/src/rounding-error.ts +8 -0
  282. package/src/rounding.ts +122 -0
  283. package/src/stripe-fx.ts +49 -0
  284. package/src/stripe.ts +69 -0
  285. package/src/tax.ts +71 -0
package/src/ecb.ts ADDED
@@ -0,0 +1,162 @@
1
+ // Offline parsers for the ECB euro foreign exchange reference rates (eurofxref). No I/O: fetch or
2
+ // read the files yourself, or use ducat/convert/fetch. Rates are kept exactly as published.
3
+ import { rateTable, type RateTable } from './provider.js';
4
+
5
+ const MONTHS = [
6
+ 'january',
7
+ 'february',
8
+ 'march',
9
+ 'april',
10
+ 'may',
11
+ 'june',
12
+ 'july',
13
+ 'august',
14
+ 'september',
15
+ 'october',
16
+ 'november',
17
+ 'december',
18
+ ];
19
+ const DAY_CUBE = /<Cube\s+time=["'](\d{4}-\d\d-\d\d)["']\s*>([\s\S]*?)<\/Cube>/g;
20
+ // One <Cube .../> element, and its attributes in any order and with any quoting or spacing.
21
+ const INNER_CUBE = /<Cube\b([^>]*?)\/?>/g;
22
+ const ATTRIBUTE = /([A-Za-z]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
23
+ const RATE_TEXT = /^\d+(?:\.\d+)?$/;
24
+ const ISO_DAY = /^(\d{4})-(\d\d)-(\d\d)$/;
25
+
26
+ /**
27
+ * The ECB's rates are typed 'reference', not 'mid': they are set once a day, around 14:10 CET, from
28
+ * a regular concertation between central banks, and published for information only. They are not a
29
+ * market mid-rate that anyone deals at.
30
+ */
31
+ function ecbTable(asOf: string, rates: Record<string, string>): RateTable {
32
+ return rateTable({ base: 'EUR', asOf, source: 'ECB', type: 'reference', rates });
33
+ }
34
+
35
+ function notAnEcbFile(): never {
36
+ throw new SyntaxError('Not an ECB reference rate file');
37
+ }
38
+
39
+ function withoutByteOrderMark(text: string): string {
40
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
41
+ }
42
+
43
+ /** The currency → rate attributes of the <Cube> elements in one day's cube. */
44
+ function ratesOfDay(body: string): Record<string, string> {
45
+ const rates: Record<string, string> = {};
46
+ for (const [, attributeText] of body.matchAll(INNER_CUBE)) {
47
+ const attributes: Record<string, string> = {};
48
+ for (const [, name, doubleQuoted, singleQuoted] of attributeText!.matchAll(ATTRIBUTE)) {
49
+ attributes[name!] = doubleQuoted ?? singleQuoted!;
50
+ }
51
+ const currency = attributes['currency'];
52
+ const rate = attributes['rate'];
53
+ if (currency === undefined && rate === undefined) {
54
+ continue;
55
+ }
56
+ // Fail rather than silently drop a currency: a missing rate would look like "not quoted".
57
+ if (
58
+ currency === undefined ||
59
+ !/^[A-Z]{3}$/.test(currency) ||
60
+ rate === undefined ||
61
+ !RATE_TEXT.test(rate)
62
+ ) {
63
+ throw new SyntaxError(`Malformed ECB rate entry <Cube${attributeText}>`);
64
+ }
65
+ rates[currency] = rate;
66
+ }
67
+ return rates;
68
+ }
69
+
70
+ function newestFirst(tables: RateTable[]): RateTable[] {
71
+ return tables.sort((table, other) => other.asOf!.localeCompare(table.asOf!));
72
+ }
73
+
74
+ /**
75
+ * Parses eurofxref-daily.xml, eurofxref-hist-90d.xml or eurofxref-hist.xml into one EUR-based table
76
+ * per day, newest first. Each rate is EUR → currency, with `source: 'ECB'`, `type: 'reference'` and
77
+ * `asOf` the publication day. A day without any rates is left out, so a lookback goes on to the day
78
+ * before. A rate entry that can't be read throws SyntaxError.
79
+ *
80
+ * const [latest] = parseEcbXml(await readFile('eurofxref-daily.xml', 'utf8'));
81
+ */
82
+ export function parseEcbXml(xml: string): RateTable[] {
83
+ let days = 0;
84
+ const tables: RateTable[] = [];
85
+ for (const [, asOf, body] of withoutByteOrderMark(xml).matchAll(DAY_CUBE)) {
86
+ days++;
87
+ const day = isoDay(asOf!);
88
+ const rates = ratesOfDay(body!);
89
+ if (Object.keys(rates).length > 0) {
90
+ tables.push(ecbTable(day, rates));
91
+ }
92
+ }
93
+
94
+ if (days === 0) {
95
+ notAnEcbFile();
96
+ }
97
+ return newestFirst(tables);
98
+ }
99
+
100
+ /** The ISO date of year-month-day, or a SyntaxError when that day doesn't exist (32 November). */
101
+ function calendarDay(year: number, month: number, day: number): string {
102
+ const date = new Date(Date.UTC(year, month - 1, day));
103
+ if (
104
+ date.getUTCFullYear() !== year ||
105
+ date.getUTCMonth() !== month - 1 ||
106
+ date.getUTCDate() !== day
107
+ ) {
108
+ throw new SyntaxError(`Not a calendar day: ${year}-${month}-${day}`);
109
+ }
110
+ return date.toISOString().slice(0, 10);
111
+ }
112
+
113
+ /** '2026-11-06', or '06 November 2026' as in the daily CSV, as an ISO date. */
114
+ function isoDay(text: string): string {
115
+ const iso = ISO_DAY.exec(text);
116
+ if (iso !== null) {
117
+ return calendarDay(Number(iso[1]), Number(iso[2]), Number(iso[3]));
118
+ }
119
+ const match = /^(\d{1,2}) ([A-Za-z]+) (\d{4})$/.exec(text);
120
+ if (match === null) {
121
+ notAnEcbFile();
122
+ }
123
+ const [, day, monthName, year] = match;
124
+ const month = MONTHS.indexOf(monthName!.toLowerCase()) + 1;
125
+ if (month === 0) {
126
+ notAnEcbFile();
127
+ }
128
+ return calendarDay(Number(year), month, Number(day));
129
+ }
130
+
131
+ /**
132
+ * Parses eurofxref.csv or eurofxref-hist.csv (from the ECB's zips) the way parseEcbXml does.
133
+ * 'N/A' and blank cells (currencies not quoted that day) are skipped, and a day with no rates at
134
+ * all is left out, so a lookback goes on to the day before.
135
+ */
136
+ export function parseEcbCsv(csv: string): RateTable[] {
137
+ const lines = withoutByteOrderMark(csv)
138
+ .split(/\r?\n/)
139
+ .filter((line) => line.trim() !== '');
140
+ const columns = (lines[0] ?? '').split(',').map((cell) => cell.trim());
141
+ if (columns[0] !== 'Date') {
142
+ notAnEcbFile();
143
+ }
144
+
145
+ const tables: RateTable[] = [];
146
+ for (const line of lines.slice(1)) {
147
+ const cells = line.split(',').map((cell) => cell.trim());
148
+ const rates: Record<string, string> = {};
149
+ for (let i = 1; i < columns.length; i++) {
150
+ const currency = columns[i];
151
+ const rate = cells[i];
152
+ if (currency && rate && rate !== 'N/A') {
153
+ rates[currency] = rate;
154
+ }
155
+ }
156
+ const day = isoDay(cells[0]!);
157
+ if (Object.keys(rates).length > 0) {
158
+ tables.push(ecbTable(day, rates));
159
+ }
160
+ }
161
+ return newestFirst(tables);
162
+ }
package/src/exact.ts ADDED
@@ -0,0 +1,182 @@
1
+ // ExactMoney: an exact rational amount for chains of arithmetic that should round once, at the end.
2
+ // It has no formatting, JSON or minor units on purpose: it must be rounded back to Money before it
3
+ // leaves the process.
4
+ import { currencyExponent } from './currency.js';
5
+ import { pow10n, ratio, type Factor } from './decimal.js';
6
+ import { CurrencyMismatchError, make, type Money } from './money.js';
7
+ import type { Rate } from './rate.js';
8
+ import { DEFAULT_ROUNDING, divideAndRoundBigint, type RoundingMode } from './rounding.js';
9
+
10
+ /**
11
+ * A rounded value and what rounding left out: `value + residual` is exactly the unrounded amount.
12
+ */
13
+ export interface Rounded<C extends string> {
14
+ readonly value: Money<C>;
15
+ readonly residual: ExactMoney<C>;
16
+ }
17
+
18
+ /**
19
+ * An exact amount of `currency`: num/den major units, reduced, den > 0. Build it with `exact()`.
20
+ */
21
+ export class ExactMoney<C extends string = string> {
22
+ readonly currency: C;
23
+ readonly num: bigint;
24
+ readonly den: bigint;
25
+
26
+ /** @internal use exact() */
27
+ constructor(currency: C, numerator: bigint, denominator: bigint) {
28
+ const reduced = ratio(numerator, denominator); // reduces and makes the denominator positive
29
+ this.currency = currency;
30
+ this.num = reduced.num;
31
+ this.den = reduced.den;
32
+ }
33
+
34
+ /** this + other, exactly. */
35
+ add(other: ExactMoney<C>): ExactMoney<C> {
36
+ assertSameCurrency(this, other);
37
+ const numerator = this.num * other.den + other.num * this.den;
38
+ return new ExactMoney(this.currency, numerator, this.den * other.den);
39
+ }
40
+
41
+ /** this − other, exactly. */
42
+ subtract(other: ExactMoney<C>): ExactMoney<C> {
43
+ return this.add(other.negate());
44
+ }
45
+
46
+ /** −this. */
47
+ negate(): ExactMoney<C> {
48
+ return new ExactMoney(this.currency, -this.num, this.den);
49
+ }
50
+
51
+ /** this × factor, exactly. */
52
+ multiply(factor: Factor): ExactMoney<C> {
53
+ const factorRatio = ratio(factor);
54
+ return new ExactMoney(this.currency, this.num * factorRatio.num, this.den * factorRatio.den);
55
+ }
56
+
57
+ /** this ÷ divisor, exactly. */
58
+ divide(divisor: Factor): ExactMoney<C> {
59
+ const divisorRatio = ratio(divisor);
60
+ if (divisorRatio.num === 0n) {
61
+ throw new RangeError('Division by zero');
62
+ }
63
+ return new ExactMoney(this.currency, this.num * divisorRatio.den, this.den * divisorRatio.num);
64
+ }
65
+
66
+ /** Converts exactly at `rate`; nothing is rounded. */
67
+ convert<To extends string>(rate: Rate<C, To>): ExactMoney<To> {
68
+ if (rate.from !== this.currency) {
69
+ throw new CurrencyMismatchError(rate.from, this.currency);
70
+ }
71
+ return new ExactMoney(rate.to, this.num * rate.ratio.num, this.den * rate.ratio.den);
72
+ }
73
+
74
+ /** −1, 0 or 1. */
75
+ compare(other: ExactMoney<C>): -1 | 0 | 1 {
76
+ assertSameCurrency(this, other);
77
+ // Both denominators are positive, so cross-multiplying keeps the order.
78
+ const left = this.num * other.den;
79
+ const right = other.num * this.den;
80
+ if (left < right) {
81
+ return -1;
82
+ }
83
+ if (left > right) {
84
+ return 1;
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ /** Same currency and the same exact value. */
90
+ equals(other: ExactMoney): boolean {
91
+ return this.currency === other.currency && this.num === other.num && this.den === other.den;
92
+ }
93
+
94
+ /** −1, 0 or 1. */
95
+ sign(): -1 | 0 | 1 {
96
+ if (this.num > 0n) {
97
+ return 1;
98
+ }
99
+ if (this.num < 0n) {
100
+ return -1;
101
+ }
102
+ return 0;
103
+ }
104
+
105
+ /** Whether the amount is zero. */
106
+ isZero(): boolean {
107
+ return this.num === 0n;
108
+ }
109
+
110
+ /** Whether the amount fits `scale` (default: the currency's exponent) without rounding. */
111
+ isExact(scale: number = currencyExponent(this.currency)): boolean {
112
+ return (this.num * pow10n(scale)) % this.den === 0n;
113
+ }
114
+
115
+ /** Rounds once to `scale` (default: the currency's exponent). */
116
+ round(
117
+ rounding: RoundingMode = DEFAULT_ROUNDING,
118
+ scale: number = currencyExponent(this.currency),
119
+ ): Money<C> {
120
+ const minor = divideAndRoundBigint(this.num * pow10n(scale), this.den, rounding);
121
+ return make(this.currency, minor, scale);
122
+ }
123
+
124
+ /** Rounds once and reports the difference, for booking it (e.g. an "FX rounding" ledger line). */
125
+ roundWithResidual(rounding: RoundingMode = DEFAULT_ROUNDING, scale?: number): Rounded<C> {
126
+ const value = this.round(rounding, scale);
127
+ const residual = this.subtract(exact(value));
128
+ return { value, residual };
129
+ }
130
+
131
+ /** "10/3 EUR", for debugging only. */
132
+ toString(): string {
133
+ if (this.den === 1n) {
134
+ return `${this.num} ${this.currency}`;
135
+ }
136
+ return `${this.num}/${this.den} ${this.currency}`;
137
+ }
138
+
139
+ /** Blocks `a + b` and `a > b`: round() first. */
140
+ [Symbol.toPrimitive](hint: string): string {
141
+ if (hint === 'string') {
142
+ return this.toString();
143
+ }
144
+ throw new TypeError('ExactMoney cannot be converted to a number; round() it first');
145
+ }
146
+ }
147
+
148
+ function assertSameCurrency(money: ExactMoney, other: ExactMoney): void {
149
+ if (money.currency !== other.currency) {
150
+ throw new CurrencyMismatchError(money.currency, other.currency);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The exact value of `money`, for chained arithmetic: `exact(price).divide(3).round('halfEven')`.
156
+ */
157
+ export function exact<C extends string>(money: Money<C>): ExactMoney<C> {
158
+ return new ExactMoney(money.currency, BigInt(money.minor), pow10n(money.scale));
159
+ }
160
+
161
+ /**
162
+ * The exact sum (zero of `currency` for an empty list). Round it once for a batch's rounding line.
163
+ */
164
+ export function sumExact<C extends string>(
165
+ items: readonly ExactMoney<C>[],
166
+ currency?: C,
167
+ ): ExactMoney<C> {
168
+ const sumCurrency = currency ?? items[0]?.currency;
169
+ if (sumCurrency === undefined) {
170
+ throw new RangeError('sumExact of an empty list needs a currency');
171
+ }
172
+ let sum = new ExactMoney(sumCurrency, 0n, 1n);
173
+ for (const item of items) {
174
+ sum = sum.add(item);
175
+ }
176
+ return sum;
177
+ }
178
+
179
+ /** Whether `value` is an ExactMoney. */
180
+ export function isExactMoney(value: unknown): value is ExactMoney {
181
+ return value instanceof ExactMoney;
182
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,127 @@
1
+ // Network fetchers for published reference rates, using the platform fetch. Opt-in: nothing else in
2
+ // the library imports this module, so core and @ducatjs/money/convert never touch the
3
+ // network. You decide when to call it and how to cache.
4
+ import { parseEcbXml } from './ecb.js';
5
+ import type { RateTable } from './provider.js';
6
+
7
+ /**
8
+ * The part of the platform AbortSignal used here. Declared structurally so the library needs no DOM
9
+ * or Node types; any real AbortSignal fits.
10
+ */
11
+ export interface AbortSignalLike {
12
+ readonly aborted: boolean;
13
+ readonly reason?: unknown;
14
+ addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void;
15
+ removeEventListener(type: 'abort', listener: () => void): void;
16
+ }
17
+
18
+ /** The subset of the platform fetch used here (Node 24+, Deno, Bun, browsers, workers). */
19
+ export type Fetch = (
20
+ url: string,
21
+ init?: { signal?: AbortSignalLike; headers?: Record<string, string> },
22
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
23
+
24
+ /** The platform's AbortSignal statics, where they exist. */
25
+ type AbortSignalStatics = {
26
+ timeout?: (milliseconds: number) => AbortSignalLike;
27
+ any?: (signals: AbortSignalLike[]) => AbortSignalLike;
28
+ };
29
+
30
+ const DEFAULT_TIMEOUT_MS = 30_000;
31
+
32
+ /** The ECB's eurofxref files: the latest day, the last 90 days, and the full history since 1999. */
33
+ export const ECB_URLS = {
34
+ daily: 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml',
35
+ '90d': 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml',
36
+ all: 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml',
37
+ } as const;
38
+
39
+ /** Options for fetching ECB reference rates. */
40
+ export interface FetchEcbOptions {
41
+ /** 'daily' (default): the latest day; '90d': the last 90 days; 'all': since 1999 (several MB). */
42
+ readonly history?: keyof typeof ECB_URLS;
43
+ /** An AbortSignal to cancel the request; it is combined with the timeout. */
44
+ readonly signal?: AbortSignalLike;
45
+ /**
46
+ * Milliseconds before the request is aborted (default 30 000; 0 for no timeout). It needs the
47
+ * platform's AbortSignal.timeout, which Node 24, Deno, Bun and current browsers all have.
48
+ */
49
+ readonly timeout?: number;
50
+ /** A fetch to use instead of the global one (for proxies, retries or tests). */
51
+ readonly fetch?: Fetch;
52
+ }
53
+
54
+ /** Thrown when the HTTP request completes without a 2xx status. */
55
+ export class FetchRatesError extends Error {
56
+ readonly status: number;
57
+ readonly url: string;
58
+ constructor(url: string, status: number) {
59
+ super(`GET ${url} failed with HTTP ${status}`);
60
+ this.name = 'FetchRatesError';
61
+ this.status = status;
62
+ this.url = url;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Fetches the ECB euro reference rates and parses them with parseEcbXml: EUR-based tables,
68
+ * newest first. The ECB publishes once per TARGET working day at about 16:00 CET and states
69
+ * that its rates are for information purposes only.
70
+ *
71
+ * const [latest] = await fetchEcb(); // the RateTable for the last published day
72
+ * const hist = snapshotsFrom(await fetchEcb({ history: '90d' }));
73
+ */
74
+ export async function fetchEcb(options?: FetchEcbOptions): Promise<RateTable[]> {
75
+ const url = ECB_URLS[options?.history ?? 'daily'];
76
+ const fetchFunction = options?.fetch ?? (globalThis as { fetch?: Fetch }).fetch;
77
+ if (fetchFunction === undefined) {
78
+ throw new TypeError('No fetch available: pass options.fetch');
79
+ }
80
+
81
+ const init: { signal?: AbortSignalLike; headers: Record<string, string> } = {
82
+ headers: { accept: 'application/xml' },
83
+ };
84
+ const signal = requestSignal(options?.signal, options?.timeout ?? DEFAULT_TIMEOUT_MS);
85
+ if (signal !== undefined) {
86
+ init.signal = signal;
87
+ }
88
+ const response = await fetchFunction(url, init);
89
+ if (!response.ok) {
90
+ throw new FetchRatesError(url, response.status);
91
+ }
92
+
93
+ const body = await response.text();
94
+ try {
95
+ return parseEcbXml(body);
96
+ } catch (error) {
97
+ // An error page or a captive portal is the usual cause, so say where the bad file came from.
98
+ if (error instanceof SyntaxError) {
99
+ throw new SyntaxError(`${error.message} (from ${url})`, { cause: error });
100
+ }
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ /** The caller's signal, a timeout signal, or both combined. */
106
+ function requestSignal(
107
+ signal: AbortSignalLike | undefined,
108
+ timeout: number,
109
+ ): AbortSignalLike | undefined {
110
+ if (!Number.isFinite(timeout) || timeout < 0) {
111
+ throw new RangeError(`timeout must be a non-negative number of milliseconds, got ${timeout}`);
112
+ }
113
+ const statics = (globalThis as unknown as { AbortSignal?: AbortSignalStatics }).AbortSignal;
114
+ if (timeout === 0 || statics?.timeout === undefined) {
115
+ return signal;
116
+ }
117
+
118
+ const timeoutSignal = statics.timeout(timeout);
119
+ if (signal === undefined) {
120
+ return timeoutSignal;
121
+ }
122
+ if (statics.any === undefined) {
123
+ // Without AbortSignal.any the caller's signal wins: it is the one they can control.
124
+ return signal;
125
+ }
126
+ return statics.any([signal, timeoutSignal]);
127
+ }