@ducatjs/money 0.0.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 (398) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +51 -0
  3. package/README.md +1034 -3
  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 +520 -0
  9. package/data/checkout.json +230 -0
  10. package/data/cldr-cash.json +101 -0
  11. package/data/historic.json +78 -0
  12. package/data/iso4217-list-one.xml +1956 -0
  13. package/data/iso4217-list-three.xml +1185 -0
  14. package/data/klarna.json +95 -0
  15. package/data/paypal.json +65 -0
  16. package/data/stripe-fx.json +17 -0
  17. package/data/stripe.json +285 -0
  18. package/dist/adyen-fx.d.ts +25 -0
  19. package/dist/adyen-fx.d.ts.map +1 -0
  20. package/dist/adyen-fx.js +140 -0
  21. package/dist/adyen-fx.js.map +1 -0
  22. package/dist/adyen.d.ts +25 -0
  23. package/dist/adyen.d.ts.map +1 -0
  24. package/dist/adyen.js +34 -0
  25. package/dist/adyen.js.map +1 -0
  26. package/dist/allocate.d.ts +72 -0
  27. package/dist/allocate.d.ts.map +1 -0
  28. package/dist/allocate.js +314 -0
  29. package/dist/allocate.js.map +1 -0
  30. package/dist/bag.d.ts +58 -0
  31. package/dist/bag.d.ts.map +1 -0
  32. package/dist/bag.js +122 -0
  33. package/dist/bag.js.map +1 -0
  34. package/dist/boc.d.ts +31 -0
  35. package/dist/boc.d.ts.map +1 -0
  36. package/dist/boc.js +114 -0
  37. package/dist/boc.js.map +1 -0
  38. package/dist/boe.d.ts +33 -0
  39. package/dist/boe.d.ts.map +1 -0
  40. package/dist/boe.js +114 -0
  41. package/dist/boe.js.map +1 -0
  42. package/dist/braintree.d.ts +34 -0
  43. package/dist/braintree.d.ts.map +1 -0
  44. package/dist/braintree.js +45 -0
  45. package/dist/braintree.js.map +1 -0
  46. package/dist/cash.d.ts +64 -0
  47. package/dist/cash.d.ts.map +1 -0
  48. package/dist/cash.js +134 -0
  49. package/dist/cash.js.map +1 -0
  50. package/dist/checkout.d.ts +42 -0
  51. package/dist/checkout.d.ts.map +1 -0
  52. package/dist/checkout.js +52 -0
  53. package/dist/checkout.js.map +1 -0
  54. package/dist/convert.d.ts +52 -0
  55. package/dist/convert.d.ts.map +1 -0
  56. package/dist/convert.js +65 -0
  57. package/dist/convert.js.map +1 -0
  58. package/dist/currency.d.ts +22 -0
  59. package/dist/currency.d.ts.map +1 -0
  60. package/dist/currency.js +38 -0
  61. package/dist/currency.js.map +1 -0
  62. package/dist/decimal.d.ts +52 -0
  63. package/dist/decimal.d.ts.map +1 -0
  64. package/dist/decimal.js +251 -0
  65. package/dist/decimal.js.map +1 -0
  66. package/dist/drizzle/mysql.d.ts +105 -0
  67. package/dist/drizzle/mysql.d.ts.map +1 -0
  68. package/dist/drizzle/mysql.js +60 -0
  69. package/dist/drizzle/mysql.js.map +1 -0
  70. package/dist/drizzle/pg.d.ts +103 -0
  71. package/dist/drizzle/pg.d.ts.map +1 -0
  72. package/dist/drizzle/pg.js +60 -0
  73. package/dist/drizzle/pg.js.map +1 -0
  74. package/dist/drizzle/sqlite.d.ts +102 -0
  75. package/dist/drizzle/sqlite.d.ts.map +1 -0
  76. package/dist/drizzle/sqlite.js +59 -0
  77. package/dist/drizzle/sqlite.js.map +1 -0
  78. package/dist/ecb.d.ts +52 -0
  79. package/dist/ecb.d.ts.map +1 -0
  80. package/dist/ecb.js +285 -0
  81. package/dist/ecb.js.map +1 -0
  82. package/dist/exact.d.ts +62 -0
  83. package/dist/exact.d.ts.map +1 -0
  84. package/dist/exact.js +147 -0
  85. package/dist/exact.js.map +1 -0
  86. package/dist/fed.d.ts +35 -0
  87. package/dist/fed.d.ts.map +1 -0
  88. package/dist/fed.js +134 -0
  89. package/dist/fed.js.map +1 -0
  90. package/dist/fetch.d.ts +103 -0
  91. package/dist/fetch.d.ts.map +1 -0
  92. package/dist/fetch.js +222 -0
  93. package/dist/fetch.js.map +1 -0
  94. package/dist/fixed.d.ts +121 -0
  95. package/dist/fixed.d.ts.map +1 -0
  96. package/dist/fixed.js +420 -0
  97. package/dist/fixed.js.map +1 -0
  98. package/dist/fn.d.ts +34 -0
  99. package/dist/fn.d.ts.map +1 -0
  100. package/dist/fn.js +37 -0
  101. package/dist/fn.js.map +1 -0
  102. package/dist/format.d.ts +13 -0
  103. package/dist/format.d.ts.map +1 -0
  104. package/dist/format.js +90 -0
  105. package/dist/format.js.map +1 -0
  106. package/dist/generated/adyen.d.ts +3 -0
  107. package/dist/generated/adyen.d.ts.map +1 -0
  108. package/dist/generated/adyen.js +144 -0
  109. package/dist/generated/adyen.js.map +1 -0
  110. package/dist/generated/braintree.d.ts +3 -0
  111. package/dist/generated/braintree.d.ts.map +1 -0
  112. package/dist/generated/braintree.js +140 -0
  113. package/dist/generated/braintree.js.map +1 -0
  114. package/dist/generated/cash.d.ts +14 -0
  115. package/dist/generated/cash.d.ts.map +1 -0
  116. package/dist/generated/cash.js +465 -0
  117. package/dist/generated/cash.js.map +1 -0
  118. package/dist/generated/checkout.d.ts +3 -0
  119. package/dist/generated/checkout.d.ts.map +1 -0
  120. package/dist/generated/checkout.js +151 -0
  121. package/dist/generated/checkout.js.map +1 -0
  122. package/dist/generated/historic.d.ts +337 -0
  123. package/dist/generated/historic.d.ts.map +1 -0
  124. package/dist/generated/historic.js +66 -0
  125. package/dist/generated/historic.js.map +1 -0
  126. package/dist/generated/iso4217.d.ts +174 -0
  127. package/dist/generated/iso4217.d.ts.map +1 -0
  128. package/dist/generated/iso4217.js +175 -0
  129. package/dist/generated/iso4217.js.map +1 -0
  130. package/dist/generated/kernel.d.ts +17 -0
  131. package/dist/generated/kernel.d.ts.map +1 -0
  132. package/dist/generated/kernel.js +208 -0
  133. package/dist/generated/kernel.js.map +1 -0
  134. package/dist/generated/klarna.d.ts +3 -0
  135. package/dist/generated/klarna.d.ts.map +1 -0
  136. package/dist/generated/klarna.js +21 -0
  137. package/dist/generated/klarna.js.map +1 -0
  138. package/dist/generated/paypal.d.ts +3 -0
  139. package/dist/generated/paypal.d.ts.map +1 -0
  140. package/dist/generated/paypal.js +31 -0
  141. package/dist/generated/paypal.js.map +1 -0
  142. package/dist/generated/stripe.d.ts +8 -0
  143. package/dist/generated/stripe.d.ts.map +1 -0
  144. package/dist/generated/stripe.js +150 -0
  145. package/dist/generated/stripe.js.map +1 -0
  146. package/dist/historic.d.ts +40 -0
  147. package/dist/historic.d.ts.map +1 -0
  148. package/dist/historic.js +43 -0
  149. package/dist/historic.js.map +1 -0
  150. package/dist/history.d.ts +96 -0
  151. package/dist/history.d.ts.map +1 -0
  152. package/dist/history.js +213 -0
  153. package/dist/history.js.map +1 -0
  154. package/dist/hmrc.d.ts +28 -0
  155. package/dist/hmrc.d.ts.map +1 -0
  156. package/dist/hmrc.js +108 -0
  157. package/dist/hmrc.js.map +1 -0
  158. package/dist/index.d.ts +11 -0
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +11 -0
  161. package/dist/index.js.map +1 -0
  162. package/dist/klarna.d.ts +31 -0
  163. package/dist/klarna.d.ts.map +1 -0
  164. package/dist/klarna.js +38 -0
  165. package/dist/klarna.js.map +1 -0
  166. package/dist/kysely.d.ts +120 -0
  167. package/dist/kysely.d.ts.map +1 -0
  168. package/dist/kysely.js +309 -0
  169. package/dist/kysely.js.map +1 -0
  170. package/dist/money.d.ts +302 -0
  171. package/dist/money.d.ts.map +1 -0
  172. package/dist/money.js +703 -0
  173. package/dist/money.js.map +1 -0
  174. package/dist/parse.d.ts +55 -0
  175. package/dist/parse.d.ts.map +1 -0
  176. package/dist/parse.js +419 -0
  177. package/dist/parse.js.map +1 -0
  178. package/dist/paypal.d.ts +41 -0
  179. package/dist/paypal.d.ts.map +1 -0
  180. package/dist/paypal.js +58 -0
  181. package/dist/paypal.js.map +1 -0
  182. package/dist/pg.d.ts +100 -0
  183. package/dist/pg.d.ts.map +1 -0
  184. package/dist/pg.js +343 -0
  185. package/dist/pg.js.map +1 -0
  186. package/dist/plugins/adyen.d.ts +15 -0
  187. package/dist/plugins/adyen.d.ts.map +1 -0
  188. package/dist/plugins/adyen.js +10 -0
  189. package/dist/plugins/adyen.js.map +1 -0
  190. package/dist/plugins/all.d.ts +29 -0
  191. package/dist/plugins/all.d.ts.map +1 -0
  192. package/dist/plugins/all.js +30 -0
  193. package/dist/plugins/all.js.map +1 -0
  194. package/dist/plugins/allocate.d.ts +20 -0
  195. package/dist/plugins/allocate.d.ts.map +1 -0
  196. package/dist/plugins/allocate.js +26 -0
  197. package/dist/plugins/allocate.js.map +1 -0
  198. package/dist/plugins/braintree.d.ts +12 -0
  199. package/dist/plugins/braintree.d.ts.map +1 -0
  200. package/dist/plugins/braintree.js +12 -0
  201. package/dist/plugins/braintree.js.map +1 -0
  202. package/dist/plugins/cash.d.ts +19 -0
  203. package/dist/plugins/cash.d.ts.map +1 -0
  204. package/dist/plugins/cash.js +16 -0
  205. package/dist/plugins/cash.js.map +1 -0
  206. package/dist/plugins/checkout.d.ts +21 -0
  207. package/dist/plugins/checkout.d.ts.map +1 -0
  208. package/dist/plugins/checkout.js +16 -0
  209. package/dist/plugins/checkout.js.map +1 -0
  210. package/dist/plugins/convert.d.ts +19 -0
  211. package/dist/plugins/convert.d.ts.map +1 -0
  212. package/dist/plugins/convert.js +13 -0
  213. package/dist/plugins/convert.js.map +1 -0
  214. package/dist/plugins/exact.d.ts +15 -0
  215. package/dist/plugins/exact.d.ts.map +1 -0
  216. package/dist/plugins/exact.js +13 -0
  217. package/dist/plugins/exact.js.map +1 -0
  218. package/dist/plugins/fixed.d.ts +17 -0
  219. package/dist/plugins/fixed.d.ts.map +1 -0
  220. package/dist/plugins/fixed.js +13 -0
  221. package/dist/plugins/fixed.js.map +1 -0
  222. package/dist/plugins/format.d.ts +9 -0
  223. package/dist/plugins/format.d.ts.map +1 -0
  224. package/dist/plugins/format.js +16 -0
  225. package/dist/plugins/format.js.map +1 -0
  226. package/dist/plugins/klarna.d.ts +10 -0
  227. package/dist/plugins/klarna.d.ts.map +1 -0
  228. package/dist/plugins/klarna.js +10 -0
  229. package/dist/plugins/klarna.js.map +1 -0
  230. package/dist/plugins/paypal.d.ts +14 -0
  231. package/dist/plugins/paypal.d.ts.map +1 -0
  232. package/dist/plugins/paypal.js +16 -0
  233. package/dist/plugins/paypal.js.map +1 -0
  234. package/dist/plugins/prisma.d.ts +8 -0
  235. package/dist/plugins/prisma.d.ts.map +1 -0
  236. package/dist/plugins/prisma.js +13 -0
  237. package/dist/plugins/prisma.js.map +1 -0
  238. package/dist/plugins/stripe.d.ts +14 -0
  239. package/dist/plugins/stripe.d.ts.map +1 -0
  240. package/dist/plugins/stripe.js +16 -0
  241. package/dist/plugins/stripe.js.map +1 -0
  242. package/dist/plugins/tax.d.ts +20 -0
  243. package/dist/plugins/tax.d.ts.map +1 -0
  244. package/dist/plugins/tax.js +18 -0
  245. package/dist/plugins/tax.js.map +1 -0
  246. package/dist/policy.d.ts +34 -0
  247. package/dist/policy.d.ts.map +1 -0
  248. package/dist/policy.js +90 -0
  249. package/dist/policy.js.map +1 -0
  250. package/dist/prisma.d.ts +77 -0
  251. package/dist/prisma.d.ts.map +1 -0
  252. package/dist/prisma.js +76 -0
  253. package/dist/prisma.js.map +1 -0
  254. package/dist/processor.d.ts +28 -0
  255. package/dist/processor.d.ts.map +1 -0
  256. package/dist/processor.js +118 -0
  257. package/dist/processor.js.map +1 -0
  258. package/dist/provider.d.ts +103 -0
  259. package/dist/provider.d.ts.map +1 -0
  260. package/dist/provider.js +280 -0
  261. package/dist/provider.js.map +1 -0
  262. package/dist/rate-text.d.ts +38 -0
  263. package/dist/rate-text.d.ts.map +1 -0
  264. package/dist/rate-text.js +143 -0
  265. package/dist/rate-text.js.map +1 -0
  266. package/dist/rate.d.ts +175 -0
  267. package/dist/rate.d.ts.map +1 -0
  268. package/dist/rate.js +371 -0
  269. package/dist/rate.js.map +1 -0
  270. package/dist/rounding-error.d.ts +9 -0
  271. package/dist/rounding-error.d.ts.map +1 -0
  272. package/dist/rounding-error.js +9 -0
  273. package/dist/rounding-error.js.map +1 -0
  274. package/dist/rounding.d.ts +50 -0
  275. package/dist/rounding.d.ts.map +1 -0
  276. package/dist/rounding.js +87 -0
  277. package/dist/rounding.js.map +1 -0
  278. package/dist/schema.d.ts +43 -0
  279. package/dist/schema.d.ts.map +1 -0
  280. package/dist/schema.js +77 -0
  281. package/dist/schema.js.map +1 -0
  282. package/dist/sql.d.ts +54 -0
  283. package/dist/sql.d.ts.map +1 -0
  284. package/dist/sql.js +131 -0
  285. package/dist/sql.js.map +1 -0
  286. package/dist/stripe-fx.d.ts +22 -0
  287. package/dist/stripe-fx.d.ts.map +1 -0
  288. package/dist/stripe-fx.js +30 -0
  289. package/dist/stripe-fx.js.map +1 -0
  290. package/dist/stripe.d.ts +36 -0
  291. package/dist/stripe.d.ts.map +1 -0
  292. package/dist/stripe.js +39 -0
  293. package/dist/stripe.js.map +1 -0
  294. package/dist/tax.d.ts +33 -0
  295. package/dist/tax.d.ts.map +1 -0
  296. package/dist/tax.js +55 -0
  297. package/dist/tax.js.map +1 -0
  298. package/dist/testing/arbitraries.d.ts +43 -0
  299. package/dist/testing/arbitraries.d.ts.map +1 -0
  300. package/dist/testing/arbitraries.js +98 -0
  301. package/dist/testing/arbitraries.js.map +1 -0
  302. package/dist/testing/index.d.ts +67 -0
  303. package/dist/testing/index.d.ts.map +1 -0
  304. package/dist/testing/index.js +159 -0
  305. package/dist/testing/index.js.map +1 -0
  306. package/dist/testing/jest.d.ts +2 -0
  307. package/dist/testing/jest.d.ts.map +1 -0
  308. package/dist/testing/jest.js +15 -0
  309. package/dist/testing/jest.js.map +1 -0
  310. package/dist/testing/vitest.d.ts +7 -0
  311. package/dist/testing/vitest.d.ts.map +1 -0
  312. package/dist/testing/vitest.js +12 -0
  313. package/dist/testing/vitest.js.map +1 -0
  314. package/dist/valibot.d.ts +33 -0
  315. package/dist/valibot.d.ts.map +1 -0
  316. package/dist/valibot.js +61 -0
  317. package/dist/valibot.js.map +1 -0
  318. package/dist/zod.d.ts +36 -0
  319. package/dist/zod.d.ts.map +1 -0
  320. package/dist/zod.js +58 -0
  321. package/dist/zod.js.map +1 -0
  322. package/package.json +280 -5
  323. package/src/adyen-fx.ts +175 -0
  324. package/src/adyen.ts +46 -0
  325. package/src/allocate.ts +393 -0
  326. package/src/bag.ts +170 -0
  327. package/src/boc.ts +136 -0
  328. package/src/boe.ts +131 -0
  329. package/src/braintree.ts +71 -0
  330. package/src/cash.ts +183 -0
  331. package/src/checkout.ts +77 -0
  332. package/src/convert.ts +177 -0
  333. package/src/currency.ts +61 -0
  334. package/src/decimal.ts +283 -0
  335. package/src/drizzle/mysql.ts +78 -0
  336. package/src/drizzle/pg.ts +78 -0
  337. package/src/drizzle/sqlite.ts +70 -0
  338. package/src/ecb.ts +328 -0
  339. package/src/exact.ts +182 -0
  340. package/src/fed.ts +146 -0
  341. package/src/fetch.ts +306 -0
  342. package/src/fixed.ts +558 -0
  343. package/src/fn.ts +94 -0
  344. package/src/format.ts +106 -0
  345. package/src/generated/adyen.ts +144 -0
  346. package/src/generated/braintree.ts +140 -0
  347. package/src/generated/cash.ts +478 -0
  348. package/src/generated/checkout.ts +151 -0
  349. package/src/generated/historic.ts +67 -0
  350. package/src/generated/iso4217.ts +179 -0
  351. package/src/generated/kernel.ts +230 -0
  352. package/src/generated/klarna.ts +21 -0
  353. package/src/generated/paypal.ts +31 -0
  354. package/src/generated/stripe.ts +150 -0
  355. package/src/historic.ts +77 -0
  356. package/src/history.ts +333 -0
  357. package/src/hmrc.ts +125 -0
  358. package/src/index.ts +41 -0
  359. package/src/klarna.ts +54 -0
  360. package/src/kysely.ts +588 -0
  361. package/src/money.ts +979 -0
  362. package/src/parse.ts +511 -0
  363. package/src/paypal.ts +93 -0
  364. package/src/pg.ts +410 -0
  365. package/src/plugins/adyen.ts +25 -0
  366. package/src/plugins/all.ts +57 -0
  367. package/src/plugins/allocate.ts +53 -0
  368. package/src/plugins/braintree.ts +23 -0
  369. package/src/plugins/cash.ts +33 -0
  370. package/src/plugins/checkout.ts +32 -0
  371. package/src/plugins/convert.ts +49 -0
  372. package/src/plugins/exact.ts +23 -0
  373. package/src/plugins/fixed.ts +28 -0
  374. package/src/plugins/format.ts +28 -0
  375. package/src/plugins/klarna.ts +21 -0
  376. package/src/plugins/paypal.ts +29 -0
  377. package/src/plugins/prisma.ts +24 -0
  378. package/src/plugins/stripe.ts +29 -0
  379. package/src/plugins/tax.ts +40 -0
  380. package/src/policy.ts +124 -0
  381. package/src/prisma.ts +199 -0
  382. package/src/processor.ts +181 -0
  383. package/src/provider.ts +389 -0
  384. package/src/rate-text.ts +151 -0
  385. package/src/rate.ts +546 -0
  386. package/src/rounding-error.ts +8 -0
  387. package/src/rounding.ts +122 -0
  388. package/src/schema.ts +119 -0
  389. package/src/sql.ts +178 -0
  390. package/src/stripe-fx.ts +49 -0
  391. package/src/stripe.ts +69 -0
  392. package/src/tax.ts +102 -0
  393. package/src/testing/arbitraries.ts +134 -0
  394. package/src/testing/index.ts +247 -0
  395. package/src/testing/jest.ts +18 -0
  396. package/src/testing/vitest.ts +18 -0
  397. package/src/valibot.ts +87 -0
  398. package/src/zod.ts +78 -0
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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Braintree's amount view (https://developer.paypal.com/braintree/docs/reference/general/currencies,
3
+ * encoded in data/braintree.json). `amount` is a decimal string in major units, "formatted like
4
+ * '10' or '10.00'":
5
+ * - zero-decimal currencies can't include decimals: BIF CLP DJF GNF ISK JPY KMF KRW LAK PYG RWF UGX
6
+ * VND VUV XAF XOF XPF (LAK has 2 in ISO 4217);
7
+ * - the others take their ISO decimals ("10.00"). No three-decimal currency is listed.
8
+ * Amounts must be greater than zero, refunds and credits included.
9
+ */
10
+ import { BRAINTREE_EXPONENTS } from './generated/braintree.js';
11
+ import { type CurrencyInput, type Money } from './money.js';
12
+ import { fromProcessorDecimal, toProcessorDecimal } from './processor.js';
13
+ import type { RoundingMode } from './rounding.js';
14
+
15
+ export { BRAINTREE_EXPONENTS };
16
+
17
+ /** "Can only contain numbers and one decimal point (e.g. x.xx)". */
18
+ const AMOUNT_PATTERN = /^[0-9]+(\.[0-9]+)?$/;
19
+
20
+ /** The number of decimals Braintree takes for a currency (JPY 0, LAK 0, USD 2). */
21
+ export function braintreeExponent(currency: string): number {
22
+ const exponent = BRAINTREE_EXPONENTS[currency.toUpperCase()];
23
+ if (exponent === undefined) {
24
+ throw new RangeError(`${currency} is not a Braintree currency`);
25
+ }
26
+ return exponent;
27
+ }
28
+
29
+ /** Whether Braintree lists the currency (case-insensitive). */
30
+ export function isBraintreeCurrency(currency: string): boolean {
31
+ return BRAINTREE_EXPONENTS[currency.toUpperCase()] !== undefined;
32
+ }
33
+
34
+ /** Options for the Braintree amount: how to round when Braintree needs a coarser amount. */
35
+ export interface BraintreeAmountOptions {
36
+ /**
37
+ * How to round when Braintree needs fewer decimals than the value has (default 'unnecessary':
38
+ * throw).
39
+ */
40
+ readonly rounding?: RoundingMode;
41
+ }
42
+
43
+ /**
44
+ * The exact `amount` string: toBraintree(money('10', 'USD')) === '10.00', JPY 100 -> '100'.
45
+ * Braintree has no currency field on a transaction: the merchant account sets it.
46
+ * Throws a RangeError for zero or negative amounts.
47
+ */
48
+ export function toBraintree(money: Money, options?: BraintreeAmountOptions): string {
49
+ const rounding = options?.rounding ?? 'unnecessary';
50
+
51
+ // 81531 "Amount must be greater than zero"; 81501: credits are positive too ("x.xx, not -x.xx").
52
+ return toProcessorDecimal(
53
+ 'Braintree',
54
+ money,
55
+ braintreeExponent(money.currency),
56
+ rounding,
57
+ 'positive',
58
+ );
59
+ }
60
+
61
+ /** Reads a Braintree amount string exactly: fromBraintree('10.00', 'USD'). */
62
+ export function fromBraintree<C extends CurrencyInput>(
63
+ amount: string,
64
+ currency: C,
65
+ ): Money<Uppercase<C>> {
66
+ const code = currency.toUpperCase() as Uppercase<C>;
67
+
68
+ // Validates the currency before parsing, so an unlisted currency is the error reported.
69
+ braintreeExponent(code);
70
+ return fromProcessorDecimal('Braintree', amount, code, AMOUNT_PATTERN);
71
+ }
package/src/cash.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { CASH_RULES, type CashRule } from './generated/cash.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';
11
+ import { divideAndRound, divideAndRoundBigint, type RoundingMode } from './rounding.js';
12
+ import { currencyExponent } from './currency.js';
13
+
14
+ /**
15
+ * Rounds to a multiple of `increment` (same currency): roundToIncrement(CHF 10.03, CHF 0.05) = CHF
16
+ * 10.05. Default rounding 'halfExpand' (nearest, ties away from zero), which is what the statutory
17
+ * cash-rounding rules (SE, NO, DK, HU, CZ, FI, BE ...) prescribe.
18
+ */
19
+ export function roundToIncrement<C extends string>(
20
+ money: Money<C>,
21
+ increment: Money<NoInfer<C>>,
22
+ rounding: RoundingMode = 'halfExpand',
23
+ ): Money<C> {
24
+ if (money.currency !== increment.currency) {
25
+ throw new CurrencyMismatchError(money.currency, increment.currency);
26
+ }
27
+ if (!(increment.minor > 0)) {
28
+ throw new RangeError('increment must be positive');
29
+ }
30
+ const scale = Math.max(money.scale, increment.scale);
31
+ const minor = scaleUp(money.minor, scale - money.scale);
32
+ const step = scaleUp(increment.minor, scale - increment.scale);
33
+ if (typeof minor === 'number' && typeof step === 'number') {
34
+ const steps = divideAndRound(minor, step, rounding);
35
+ const rounded = steps * step;
36
+ if (Number.isSafeInteger(rounded)) {
37
+ return toScale(
38
+ make(money.currency, rounded, scale),
39
+ money.scale > scale ? money.scale : scale,
40
+ );
41
+ }
42
+ }
43
+ return make(
44
+ money.currency,
45
+ divideAndRoundBigint(BigInt(minor), BigInt(step), rounding) * BigInt(step),
46
+ scale,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * The cash-rounding rule for a currency, optionally narrowed by country (ISO 3166 alpha-2).
52
+ * EUR has no euro-wide rule: pass the country (BE, NL, FI, IE, IT, SK, EE, LT).
53
+ */
54
+ export function cashRule(currency: string, country?: string): CashRule | undefined {
55
+ const rules = cashRulesFor(currency);
56
+ if (rules === undefined) {
57
+ return undefined;
58
+ }
59
+ if (country !== undefined) {
60
+ for (let i = 0; i < rules.length; i++) {
61
+ if (rules[i]!.countries.includes(country)) {
62
+ return rules[i];
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+ // EUR has no euro-wide rule, so it needs a country
68
+ if (currency === 'EUR') {
69
+ return undefined;
70
+ }
71
+ return rules[0];
72
+ }
73
+
74
+ // CASH_RULES grouped by currency, in table order. Built on first use, not at import, so the module
75
+ // stays free of side effects; cashRound calls cashRule on every amount, and a scan of the whole table
76
+ // was most of its cost.
77
+ let rulesByCurrency: Map<string, CashRule[]> | undefined;
78
+
79
+ function cashRulesFor(currency: string): readonly CashRule[] | undefined {
80
+ if (rulesByCurrency === undefined) {
81
+ rulesByCurrency = new Map();
82
+ for (let i = 0; i < CASH_RULES.length; i++) {
83
+ const rule = CASH_RULES[i]!;
84
+ const forCurrency = rulesByCurrency.get(rule.currency);
85
+ if (forCurrency === undefined) {
86
+ rulesByCurrency.set(rule.currency, [rule]);
87
+ } else {
88
+ forCurrency.push(rule);
89
+ }
90
+ }
91
+ }
92
+ return rulesByCurrency.get(currency);
93
+ }
94
+
95
+ /**
96
+ * Options for `cashRound`: the country (needed for EUR), the rounding mode, and whether to use
97
+ * unverified rules.
98
+ */
99
+ export interface CashRoundOptions {
100
+ readonly country?: string;
101
+ readonly rounding?: RoundingMode;
102
+ /**
103
+ * Apply rules that are only backed by secondary sources (default true; see
104
+ * data/cash-rounding.json).
105
+ */
106
+ readonly includeUnverified?: boolean;
107
+ }
108
+
109
+ /**
110
+ * Rounds a cash total to the smallest legal/practical cash step (CHF 0.05, SEK 1, DKK 0.50, CAD
111
+ * 0.05, HUF 5, EUR 0.05 in BE/NL/FI/IE/...). Card and other electronic payments are NOT rounded:
112
+ * only call this for cash tenders. Returns m unchanged when no rule applies.
113
+ */
114
+ export function cashRound<C extends string>(money: Money<C>, options?: CashRoundOptions): Money<C> {
115
+ const rule = cashRule(money.currency, options?.country);
116
+ if (!rule || (options?.includeUnverified === false && !rule.verified)) {
117
+ return money;
118
+ }
119
+ const inc = make(money.currency, rule.increment, currencyExponent(money.currency));
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;
181
+ }
182
+
183
+ export { CASH_RULES, type CashRule };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Checkout.com's amount view (https://www.checkout.com/docs/payments/accept-payments/format-the-amount-value,
3
+ * encoded in data/checkout.json). `amount` is an integer:
4
+ * - the full amount: BIF DJF GNF ISK JPY KMF KRW PYG RWF UGX VUV VND XAF XOF XPF
5
+ * - divided by 1000, last digit 0: BHD IQD JOD KWD LYD OMR TND
6
+ * - divided by 100 for every other currency. CLP is here too (ISO says 0 decimals), and for payment
7
+ * requests its last two digits must be 00 (5000 CLP -> 500000). Standalone authentication is
8
+ * exempt.
9
+ */
10
+ import { CHECKOUT_RULES } from './generated/checkout.js';
11
+ import { type CurrencyInput, type Money } from './money.js';
12
+ import { fromProcessorAmount, toProcessorAmount } from './processor.js';
13
+ import type { RoundingMode } from './rounding.js';
14
+
15
+ export { CHECKOUT_RULES };
16
+
17
+ /** Checkout.com's [exponent, payment multiple, Standalone API multiple] for a currency. */
18
+ function rulesFor(currency: string) {
19
+ const rules = CHECKOUT_RULES[currency.toUpperCase()];
20
+ if (!rules) {
21
+ throw new RangeError(`${currency} is not a Checkout.com currency`);
22
+ }
23
+ return rules;
24
+ }
25
+
26
+ /** Whether Checkout.com lists the currency (case-insensitive). */
27
+ export function isCheckoutCurrency(currency: string): boolean {
28
+ return CHECKOUT_RULES[currency.toUpperCase()] !== undefined;
29
+ }
30
+
31
+ /** The exponent Checkout.com's API uses for a currency (CLP 2, JPY 0, BHD 3). */
32
+ export function checkoutExponent(currency: string): number {
33
+ return rulesFor(currency)[0];
34
+ }
35
+
36
+ /** Options for the Checkout.com amount: how to round when Checkout.com needs a coarser amount. */
37
+ export interface CheckoutAmountOptions {
38
+ /** How to round when Checkout.com needs a coarser amount (default 'unnecessary': throw). */
39
+ readonly rounding?: RoundingMode;
40
+ /**
41
+ * An authentication request to the Standalone API, where CLP's "last two digits 00" rule does not
42
+ * apply.
43
+ */
44
+ readonly standalone?: boolean;
45
+ }
46
+
47
+ /**
48
+ * The integer `amount` to send, e.g. toCheckoutAmount(money('5000', 'CLP')) === 500000.
49
+ * Throws a RangeError for negative amounts (refunds are sent as positive amounts).
50
+ * SLL and XDR are on Checkout.com's list but not in ISO 4217's active list: call
51
+ * `defineCurrency('SLL', 2)` or `defineCurrency('XDR', 2)` before using them.
52
+ */
53
+ export function toCheckoutAmount(money: Money, options?: CheckoutAmountOptions): number {
54
+ const [exponent, paymentMultiple, standaloneMultiple] = rulesFor(money.currency);
55
+ const multiple = options?.standalone ? standaloneMultiple : paymentMultiple;
56
+ const rounding = options?.rounding ?? 'unnecessary';
57
+
58
+ // "Be greater than zero", except 0 for a card verification (API reference); refunds are positive.
59
+ return toProcessorAmount('Checkout.com', money, exponent, multiple, rounding, 'nonNegative');
60
+ }
61
+
62
+ /** `{ amount, currency }` for a payment request. */
63
+ export function toCheckout(
64
+ money: Money,
65
+ options?: CheckoutAmountOptions,
66
+ ): { amount: number; currency: string } {
67
+ return { amount: toCheckoutAmount(money, options), currency: money.currency.toUpperCase() };
68
+ }
69
+
70
+ /** Reads a Checkout.com amount: fromCheckout(500000, 'CLP') is 5000 CLP. */
71
+ export function fromCheckout<C extends CurrencyInput>(
72
+ amount: number | bigint,
73
+ currency: C,
74
+ ): Money<Uppercase<C>> {
75
+ const code = currency.toUpperCase() as Uppercase<C>;
76
+ return fromProcessorAmount('Checkout.com', amount, code, checkoutExponent(code));
77
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,177 @@
1
+ // `ducat/convert` free functions: exact exchange rates and conversion that rounds once.
2
+ import { currencyExponent } from './currency.js';
3
+ import { exact, type Rounded } from './exact.js';
4
+ import { ratio, type Factor } from './decimal.js';
5
+ import { make, type CurrencyInput, type Money, type RoundingInput } from './money.js';
6
+ import { rateOf, type RateProvider } from './provider.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';
15
+
16
+ export {
17
+ exchangeRate,
18
+ impliedRate,
19
+ isRate,
20
+ multiplyByRatioAtScale,
21
+ Rate,
22
+ RateExpiredError,
23
+ rateFromJSON,
24
+ type ConvertOptions,
25
+ type RateInfo,
26
+ type RateJSON,
27
+ type RateQuote,
28
+ type RateType,
29
+ } from './rate.js';
30
+ export {
31
+ bag,
32
+ comparator,
33
+ MoneyBag,
34
+ sortByValue,
35
+ sumByCurrency,
36
+ type ComparatorOptions,
37
+ type TotalOptions,
38
+ } from './bag.js';
39
+ export {
40
+ chain,
41
+ identity,
42
+ rateOf,
43
+ rateTable,
44
+ RateUnavailableError,
45
+ reversed,
46
+ snapshots,
47
+ snapshotsFrom,
48
+ via,
49
+ type RateProvider,
50
+ type RateQuery,
51
+ type RateTable,
52
+ type RateTableSpec,
53
+ type SnapshotOptions,
54
+ type Snapshots,
55
+ } from './provider.js';
56
+
57
+ /** Options for converting through a provider. */
58
+ export interface ProviderConvertOptions extends ConvertOptions {
59
+ /** The time the rate is wanted for (an ISO-8601 date or instant, or a Date). */
60
+ readonly asOf?: string | Date;
61
+ }
62
+
63
+ function isProvider(value: unknown): value is RateProvider {
64
+ return typeof (value as RateProvider | undefined)?.rate === 'function';
65
+ }
66
+
67
+ /**
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`).
70
+ * The rate's pair is checked, at compile time for literal currencies and at run time otherwise.
71
+ *
72
+ * convert(money('100', 'EUR'), eurUsd, 'halfEven') // 111.65 USD
73
+ * convert(money('100', 'USD'), eurUsd.inverse().andThen(eurJpy)) // composed exactly, rounded once
74
+ */
75
+ export function convert<From extends string, To extends string>(
76
+ money: Money<From>,
77
+ rate: Rate<NoInfer<From>, To>,
78
+ rounding?: RoundingInput,
79
+ options?: ConvertOptions,
80
+ ): Money<To>;
81
+ /**
82
+ * Converts at a bare rate: units of `to` per unit of money's currency, as a decimal string so that
83
+ * no float is involved ('1.0832', every digit kept). Rounds once.
84
+ *
85
+ * convert(money('100', 'EUR'), 'JPY', '162.457', 'halfEven') // 16246 JPY
86
+ */
87
+ export function convert<To extends CurrencyInput>(
88
+ money: Money,
89
+ to: To,
90
+ rate: Factor,
91
+ rounding?: RoundingInput,
92
+ options?: ConvertOptions,
93
+ ): Money<To>;
94
+ /**
95
+ * Converts through a provider: looks up money's currency → `to` (as of `options.asOf`) and rounds
96
+ * once. Throws RateUnavailableError when the provider has no rate.
97
+ *
98
+ * convert(money('100', 'USD'), 'JPY', via('EUR', ecb), 'halfEven')
99
+ */
100
+ export function convert<To extends CurrencyInput>(
101
+ money: Money,
102
+ to: To,
103
+ provider: RateProvider,
104
+ rounding?: RoundingInput,
105
+ options?: ProviderConvertOptions,
106
+ ): Money<To>;
107
+ export function convert(
108
+ money: Money,
109
+ rateOrTarget: Rate | string,
110
+ roundingOrRate?: RoundingInput | Factor | RateProvider,
111
+ optionsOrRounding?: ConvertOptions | RoundingInput,
112
+ maybeOptions?: ProviderConvertOptions,
113
+ ): Money {
114
+ if (isRate(rateOrTarget)) {
115
+ const rounding = roundingOrRate as RoundingInput | undefined;
116
+ return rateOrTarget.convert(money, rounding, optionsOrRounding as ConvertOptions | undefined);
117
+ }
118
+
119
+ const to = rateOrTarget;
120
+ if (isProvider(roundingOrRate)) {
121
+ let query: { asOf: string | Date } | undefined;
122
+ if (maybeOptions?.asOf !== undefined) {
123
+ query = { asOf: maybeOptions.asOf };
124
+ }
125
+ const rate = rateOf(roundingOrRate, money.currency, to, query);
126
+ return rate.convert(money, optionsOrRounding as RoundingInput | undefined, maybeOptions);
127
+ }
128
+
129
+ // The bare-rate form converts directly, without building a Rate, to keep it allocation-light.
130
+ let rate = ratio(roundingOrRate as Factor);
131
+ if (typeof roundingOrRate === 'object') {
132
+ // A hand-made Ratio object is passed through by ratio(); rebuild it so it is reduced and its
133
+ // number fields match its bigints. Strings and numbers are already normalised (and cached).
134
+ rate = ratio(rate.num, rate.den);
135
+ }
136
+ if (rate.num <= 0n) {
137
+ throw new RangeError('Exchange rate must be positive');
138
+ }
139
+ const rounding = (optionsOrRounding as RoundingInput | undefined) ?? DEFAULT_ROUNDING;
140
+ if (typeof rounding === 'function') {
141
+ return convertWithPolicy(money, to, rate, rounding, maybeOptions);
142
+ }
143
+ const scale = maybeOptions?.scale ?? currencyExponent(to);
144
+ const minor = multiplyByRatioAtScale(money.minor, rate, scale - money.scale, rounding);
145
+ return make(to, minor, scale);
146
+ }
147
+
148
+ /**
149
+ * A conversion's rounded value, what rounding left out, and the rate used (for the audit trail).
150
+ */
151
+ export interface DetailedConversion<From extends string, To extends string> extends Rounded<To> {
152
+ readonly rate: Rate<From, To>;
153
+ }
154
+
155
+ /**
156
+ * Converts at `rate` like convert(), and also reports the residual: `value + residual` is exactly
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.
159
+ *
160
+ * const { value, residual } = convertDetailed(money('0.07', 'EUR'), eurUsd, 'halfEven');
161
+ * // value 0.08 USD, residual -0.001845 USD (as an exact ExactMoney)
162
+ */
163
+ export function convertDetailed<From extends string, To extends string>(
164
+ money: Money<From>,
165
+ rate: Rate<NoInfer<From>, To>,
166
+ rounding: RoundingInput = DEFAULT_ROUNDING,
167
+ options?: ConvertOptions,
168
+ ): DetailedConversion<From, To> {
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);
176
+ return { value: rounded.value, residual: rounded.residual, rate };
177
+ }