@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/README.md CHANGED
@@ -1,4 +1,1035 @@
1
- # @ducatjs/money
1
+ <p align="center"><img src="https://raw.githubusercontent.com/public/ducat/main/assets/logo.png" width="128" height="128" alt="A gold ducat of Doge Francesco Foscari, 1423–1457"></p>
2
+ <p align="center"><sub>Gold ducat of Francesco Foscari, Venice, 1423–1457. Photo: Francesco Bini, <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>, via Wikimedia Commons (<a href="assets/CREDITS.md">credits</a>).</sub></p>
2
3
 
3
- Exact, fast, typed money for TypeScript. In development, not usable yet.
4
- https://github.com/public/ducat
4
+ # ducat
5
+
6
+ [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://app.codspeed.io/public/ducat?utm_source=badge)
7
+
8
+ An exact, fast, typed Money type for TypeScript. It has zero runtime dependencies and runs on Node, Bun, Deno and browsers.
9
+
10
+ ```ts
11
+ import { money } from '@ducatjs/money';
12
+ import '@ducatjs/money/allocate'; // opt-in modules add their methods to Money
13
+ import '@ducatjs/money/tax';
14
+ import '@ducatjs/money/convert';
15
+ import '@ducatjs/money/format'; // or import '@ducatjs/money/all' for everything
16
+
17
+ const price = money('19.99', 'EUR'); // Money<'EUR'>
18
+ const total = price.add(money('5.01', 'EUR')); // → 25.00 EUR
19
+ total.multiply('1.075'); // → 26.88 EUR (26.875 rounds half away from zero by default)
20
+ total.multiply('1.075', 'halfEven'); // → 26.88 EUR (any of 9 modes, or 'unnecessary' to throw)
21
+ money('100', 'EUR').allocate([1, 1, 1]); // → [33.34 EUR, 33.33 EUR, 33.33 EUR] (sums to 100.00, always)
22
+ money('12', 'GBP').extractTax(20); // → { net: 10.00 GBP, tax: 2.00 GBP, gross: 12.00 GBP }
23
+ total.convert('JPY', '162.457'); // → 4061 JPY
24
+ total.format('de-DE'); // → '25,00 €'
25
+ price.add(money('1', 'USD')); // compile error, and throws if types are erased
26
+ ```
27
+
28
+ Operations are methods on `Money`. For pipelines or code that prefers plain functions, `@ducatjs/money/fn` has each of them as a function of the same name (`add(price, fee)`, `allocate(m, [1, 1, 1])`). The core (`@ducatjs/money`) holds construction, arithmetic, rounding, comparison and serialisation; everything else is an opt-in module, so a bundle only pays for what it imports (see [Modules](#modules)).
29
+
30
+ Status: 0.x. The API is meant to stay small, but until 1.0 a minor version may change it; the [changelog](CHANGELOG.md) lists every breaking change. See [What's missing](#whats-missing-honest-list).
31
+
32
+ ---
33
+
34
+ ## How it compares
35
+
36
+ The same 41 correctness vectors, the same benchmark and the same bundle snippet for every library. The details, the
37
+ other libraries' bugs, the fixed-point libraries and the money libraries in other languages are in
38
+ [docs/COMPARISON.md](docs/COMPARISON.md), regenerated from [compare/](compare) for each release.
39
+
40
+ | library | correct (of 41) | wrong, silently | bundle (gzip) |
41
+ | ---------------------------------- | --------------: | --------------: | -------------------: |
42
+ | **ducat** | **41** | **0** | 4.0 KiB |
43
+ | dinero.js 2 (bigint calculator) | 34 | 0 | 1.8 KiB |
44
+ | monetra | 34 | 3 | 4.5 KiB |
45
+ | ts-money / js-money | 31 | 7 / 6 | 30.7 / 8.8 KiB |
46
+ | bigint-money | 30 | 0 | 1.5 KiB |
47
+ | dinero.js 2 (number calculator) | 30 | 4 | 1.8 KiB |
48
+ | decimal.js / big.js / bignumber.js | 22 | 1 | 12.6 / 2.9 / 8.5 KiB |
49
+ | currency.js | 16 | 6 | 1.3 KiB |
50
+
51
+ - **Correct everywhere:** ducat is the only library that passes every vector, including amounts beyond 2⁵³ minor
52
+ units, float traps and negative allocations.
53
+ - **Fastest on arithmetic:** with its methods API, it is the fastest money library on creating, parsing a decimal string
54
+ (`money('12.34', 'EUR')`), add, multiply, allocate and toDecimal on Node, Bun and Deno, and on compare on Node and Deno. Against the fastest other money library
55
+ the margin ranges from about 1.05× (compare on Node: 7.31 against 7.65 ns) to about 9.5× (allocate on Node: 46
56
+ against bigint-money's 437 ns). Against dinero.js 2, on the operations where ducat is fastest, it ranges from 1.4× (creating on
57
+ Deno) to about 28× (multiply on Bun).
58
+ - **Where it isn't fastest:** formatting on Bun and Deno, where currency.js (formatting a float) is 3% faster (on Node,
59
+ ducat and currency.js are level, 380 against 381 ns, and so are ducat and dinero.js 1 on Bun). On Bun, libraries that
60
+ compare plain numbers are faster at compare (currency.js 6.4 against 9.8 ns).
61
+ - **Fixed-point decimals:** ducat's `Fixed` is the fastest fixed-point library on parsing a decimal string (`fixed('12.34')`)
62
+ and multiply on every runtime,
63
+ and ahead of every other library on compare everywhere and on add on Node and Deno. bigdecimal.js is faster at
64
+ dividing a `Fixed` on every runtime, though `Money` divides faster than either, and at add on Bun (11.9 against 12.3
65
+ ns). js-big-decimal and ethers are faster on `toString` everywhere; see
66
+ [docs/COMPARISON.md](docs/COMPARISON.md#fixed-point-decimal-libraries).
67
+ - **Not the smallest:** currency.js, bigint-money, dinero.js 2, @screeny05/ts-money, big.js and dinero.js 1 are smaller
68
+ for the same snippet. ducat's core includes exact decimal parsing, bigint support and the ISO table; everything else
69
+ is opt-in.
70
+ - **Measurement:** speed is from one quiet AMD EPYC Genoa vCPU, on ducat 0.2.0 (commit 7c9455a) under Node 24.22.2,
71
+ Bun 1.4.4 and Deno 2.9.8; sizes are gzipped KiB. Differences within 2.5% are called level.
72
+
73
+ ## Design
74
+
75
+ ### Representation: integer minor units in a JS number, with a bigint fallback
76
+
77
+ A `Money<C>` has three readonly fields:
78
+
79
+ | field | meaning |
80
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `currency: C` | ISO 4217 code (or a code registered with `defineCurrency`). It is a type parameter, so `Money<'EUR'>` and `Money<'USD'>` don't mix. |
82
+ | `minor: number \| bigint` | Integer count of 10^-scale units. It is a **number whenever it is a safe integer** (absolute value ≤ 2^53−1) and a **bigint only beyond that**. The invariant is enforced everywhere, so ordinary amounts never allocate a bigint and huge amounts never lose a digit. |
83
+ | `scale: number` | Decimal places. Defaults to the currency's ISO exponent (EUR 2, JPY 0, KWD 3, CLF 4). It can be larger for sub-minor precision (unit prices, FX intermediates). `round()` brings it back. |
84
+
85
+ Why this representation, based on `bench/designs.ts` (the same seven operations written four ways and run on five engines; full tables in [bench/RESULTS.md](bench/RESULTS.md)):
86
+
87
+ | ns/op, indicative | **A: number + bigint fallback** | B: bigint everywhere | C: decimal string | A + `Object.freeze` |
88
+ | ------------------------------------ | ------------------------------: | -------------------: | --------------------: | ------------------: |
89
+ | add (V8 / JSC) | **12–34 / 16–24** | 25–31 / 38–59 | 806–838 / 1107–1275 | 124–173 / 67–73 |
90
+ | multiply by ratio + round (V8 / JSC) | **23–37 / 16–19** | 49 / 223–262 | 415–444 / 1085–1506 | 145–246 / 115–124 |
91
+ | allocate into 3 (V8 / JSC) | **91–109 / 116–173** | 288–301 / 474–699 | 1122–1131 / 2250–4100 | 584–788 / 330–528 |
92
+ | compare (V8 / JSC) | **3–4 / 7–8** | 9.5–10 / 10–13 | 397–735 / 589–1034 | 5–11 / 4 |
93
+
94
+ - **bigint everywhere** is roughly 1.5–2x slower on V8 for add. On JSC it is **10–14x slower for multiply/divide**: JSC's bigint division is much slower than V8's, so a bigint-only design punishes Safari and Bun hardest. Every bigint op also allocates, and V8 can't keep bigints unboxed in object fields.
95
+ - **Decimal strings** are fast to store, parse and print, but every arithmetic op has to parse and re-print, which makes them 30–100x slower. They are good as a wire format, not as an in-memory one.
96
+ - **number + bigint fallback** is the fastest on every engine. Integer results are exact as long as they stay at or below 2^53: we check the result (`|r| ≤ MAX_SAFE_INTEGER`) and take the bigint path otherwise. The rounding argument for that check is in `src/money.ts`. Once an amount crosses 2^53 the fallback costs about 3–5x on add and 10–20x on multiply, but that only happens for amounts above 90 trillion minor units.
97
+ - **`Object.freeze` costs 5–8x on construction and add** on V8 and SpiderMonkey, and about 3x on JSC. Values are therefore _not_ frozen. Immutability comes from `readonly` fields, an API with no mutators, and a non-exported constructor, so `new Money()` can't be called from outside the library. `Symbol.toPrimitive` throws on numeric coercion, so `a + b`, `a > b` and `+a` fail loudly instead of doing float math. Template strings still work (`"12.34 EUR"`).
98
+
99
+ Engine notes (V8 vs JSC vs SpiderMonkey):
100
+
101
+ - JSC (Bun/Safari) applies escape analysis to `new Money(...)` in tight loops, which gives 0.7 ns "construction". This is an artefact of how the loop is shaped, not the real cost; V8 and SpiderMonkey sit at 15–80 ns.
102
+ - All three keep Smi/double fields unboxed when the field type is stable. A field that holds both numbers and bigints generalises once to "tagged", which we accept: the bigint case is rare.
103
+ - Engines handle `Intl.NumberFormat` differently in two ways:
104
+ - **Default currency digits:** V8 uses CLDR digits, so ALL, IQD, RSD and LAK show 0 decimals. JSC uses the ISO digits. We always set the digits explicitly from the value's scale (a test covers every ISO code).
105
+ - **Decimal-string input:** formatting a string is slower than formatting a double on every engine. Below 10^15 minor units we pass `minor / 10^scale`, a single correctly rounded division that is bit-identical to `Number(decimalString)` (property-tested). This display hand-off is the only place a double touches an amount. Larger values go to Intl as an exact decimal string; that path needs Node 19+, Safari 15.4+ or Firefox 116+, and Node 18 loses digits beyond 2^53 there.
106
+ - Hot paths avoid allocation:
107
+ - `parse` (`money('12.34','EUR')`) uses a hand-rolled character loop with no regex and no bigint for inputs of 15 digits or fewer.
108
+ - Ratios like `'1.075'` are parsed once and cached. You can also pre-build them with `ratio()`.
109
+ - `allocate` uses a number fast path with in-place selection instead of a sort.
110
+
111
+ ### Correctness rules
112
+
113
+ - **No float arithmetic on amounts.** Factors, rates and percentages are exact rationals (`ratio('1.075')`, `ratio(1, 3)`, `'1/3'`, bigint, integer). A JS number factor such as `0.1` is read at its shortest decimal form (`"0.1"`, exactly 1/10) and never multiplied as a float. `money(0.1 + 0.2, 'EUR')` **throws**: `0.30000000000000004` has too many decimals, and we don't silently turn it into 0.30.
114
+ - **Rounding is never hidden in the representation.** Every operation that may need to round takes a `RoundingMode`. Arithmetic defaults to `'halfExpand'` (ties away from zero, the most common default; see [Decisions](#decisions)); pass `'unnecessary'` to throw `RoundingError` instead. Parsing never rounds by default: `money('1.005', 'EUR')` throws.
115
+ - **Allocation is exact.** The parts always sum to the whole, and the remainder is distributed deterministically. A Lean model of it is proved for every remainder strategy, and the TypeScript is checked against that model (see [What is proved](#what-is-proved)) and property-tested with weights up to 2^40 and amounts up to 10^25.
116
+ - **Currency mismatch** is a compile-time error for literal currency types (`add(eur, usd)`, `sum([eur, usd])`, `compare`, `exchangeRate(...).convert`). When types are erased (`Money<string>`) it is a runtime `CurrencyMismatchError`. `equals()` across currencies returns `false`.
117
+
118
+ ---
119
+
120
+ ## API
121
+
122
+ ### Modules
123
+
124
+ | import | adds to `Money` | also exports |
125
+ | ----------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------- |
126
+ | `@ducatjs/money` | the core: arithmetic, rounding, comparison, `toDecimal`, `toJSON`… | `money`, `fromMinor`, `zero`, `sum`, `fromJSON`, ISO exponents |
127
+ | `@ducatjs/money/allocate` | `allocate`, `split`, `instalments`, `schedule`, `chunk` | the same as plain functions |
128
+ | `@ducatjs/money/tax` | `percentage`, `discount`, `addTax`, `extractTax` | the same |
129
+ | `@ducatjs/money/convert` | `convert` | `exchangeRate`, providers, `bag`, `comparator`… |
130
+ | `@ducatjs/money/exact` | `exact` | `ExactMoney`, `sumExact` |
131
+ | `.../convert/ecb` | nothing | `parseEcbXml`, `parseEcbCsv`, `parseEcbSdmxCsv` |
132
+ | `.../convert/history` | nothing | `rateHistory`, `quotedTable`, `rateFromQuote` |
133
+ | `.../convert/boc` | nothing | `parseBankOfCanadaJson` |
134
+ | `.../convert/fed` | nothing | `parseFedH10Csv` (parser only) |
135
+ | `.../convert/boe` | nothing | `parseBankOfEnglandCsv` (parser only) |
136
+ | `.../convert/hmrc` | nothing | `parseHmrcMonthlyCsv` (parser only) |
137
+ | `.../convert/fetch` | nothing | `fetchEcb`, `fetchEcbRange`, `fetchBankOfCanada` (network I/O) |
138
+ | `.../stripe-fx`, `adyen-fx` | nothing | `stripeRate`, `stripeImpliedRate`, `adyenRate` |
139
+ | `@ducatjs/money/policy` | nothing | `roundingPolicy`, `isoPolicy`, `assertScale` |
140
+ | `@ducatjs/money/cash` | `cashRound`, `roundToIncrement` | the same, `cashPolicy`, `cashRule`, `currencyDigits` |
141
+ | `@ducatjs/money/historic` | nothing | `defineHistoricCurrencies`, `historicCurrency` (withdrawn codes) |
142
+ | `@ducatjs/money/format` | `format`, `formatToParts` | the same |
143
+ | `@ducatjs/money/parse` | nothing | `parse`, `parseMoney` |
144
+ | `@ducatjs/money/stripe` | `toStripe`, `toStripeAmount` | the same, `fromStripe`, `stripeExponent`… |
145
+ | `@ducatjs/money/adyen` | `toAdyen` | the same, `fromAdyen`, `adyenExponent`… |
146
+ | `@ducatjs/money/checkout` | `toCheckout`, `toCheckoutAmount` | the same, `fromCheckout`, `checkoutExponent`… |
147
+ | `@ducatjs/money/paypal` | `toPayPal`, `toPayPalValue` | the same, `fromPayPal`, `paypalExponent`… |
148
+ | `@ducatjs/money/braintree` | `toBraintree` | the same, `fromBraintree`, `braintreeExponent`… |
149
+ | `@ducatjs/money/klarna` | `toKlarna` | the same, `fromKlarna`, `klarnaExponent`… |
150
+ | `@ducatjs/money/prisma` | `toDecimalValue` | the same, `fromDecimal`, the field helpers |
151
+ | `.../drizzle/pg`, `mysql`, `sqlite` | nothing | `moneyMinor`, `moneyDecimal`, `moneyJson` Drizzle columns |
152
+ | `@ducatjs/money/kysely` | nothing | `moneyHelpers`, `setMoney`, `MoneyResultPlugin` |
153
+ | `@ducatjs/money/pg` | nothing | `exactTypes`, `moneyTypeSql`, `toNumeric`, composite I/O |
154
+ | `@ducatjs/money/fixed` | `toFixedPoint` | `Fixed`, `fixed`, `fromScaled`, `isFixed`, `moneyToFixed` |
155
+ | `@ducatjs/money/zod` | nothing | `moneySchema`, `moneyJSONSchema` (Zod, an optional peer) |
156
+ | `@ducatjs/money/valibot` | nothing | `moneySchema`, `moneyJSONSchema` (Valibot, an optional peer) |
157
+ | `@ducatjs/money/testing` | nothing | matchers, value equality, snapshot serializer |
158
+ | `.../testing/vitest`, `/jest` | nothing (installs on `expect`) | the same, set up in one import |
159
+ | `.../testing/arbitraries` | nothing | `arbMoney`, `arbCurrency` (fast-check) |
160
+ | `@ducatjs/money/all` | the methods of every module above | everything except the database, schema and testing entries |
161
+ | `@ducatjs/money/fn` | nothing | every operation as a plain function, core and modules |
162
+
163
+ Importing a module (`import '@ducatjs/money/allocate'`, or any named import from it) installs its methods on `Money.prototype`, and its `declare module` augmentation makes them type-check. Without the import, `m.allocate()` is a compile error. The trade-off: the methods are installed for the whole program, so one import anywhere makes them available everywhere at run time, while TypeScript only knows about them in a program that includes the import. Import a module in each file that uses its methods: the import is cheap and installing twice is harmless, and it keeps working where files run in isolation (test runners, workers), where a single import in the entry point would leave `m.allocate` undefined at run time even though the code type-checks. Code that must not change any prototype (a library built on ducat, for example) can use the plain functions from `@ducatjs/money/fn` instead.
164
+
165
+ Your own module can add methods the same way:
166
+
167
+ ```ts
168
+ import { extendMoney, money, type Money } from '@ducatjs/money';
169
+
170
+ declare module '@ducatjs/money' {
171
+ interface Money<C extends string> {
172
+ half(): Money<C>;
173
+ }
174
+ }
175
+ extendMoney(
176
+ {
177
+ half(this: Money) {
178
+ return this.divide(2);
179
+ },
180
+ },
181
+ { owner: 'my-app/half' },
182
+ );
183
+ money('10', 'EUR').half(); // → 5.00 EUR
184
+ ```
185
+
186
+ `extendMoney` refuses names Money already has (its own methods, or ones another module added) and the names every object inherits, so two modules can't silently fight over a name. Pass `{ owner: 'your-package/module' }` so that a second copy of the same module (hot reload, two installed copies) replaces its own methods instead of throwing.
187
+
188
+ The sections below list each operation with its plain-function signature, as `@ducatjs/money/fn` exports it; the method takes the same arguments without the first (`add(a, b)` is `a.add(b)`).
189
+
190
+ **Create**
191
+
192
+ - `money(amount: string | number | bigint, currency, { scale?, rounding? })`: the amount is in major units. A string is the recommended form. An integer number means major units (`money(12,'EUR')` is 12.00).
193
+ - `fromMinor(minor: number | bigint, currency, scale?)`: from cents, e.g. a BIGINT column.
194
+ - `zero(currency)`, `fromJSON({ amount, currency } | string)`, `isMoney(x)`
195
+ - `isMoneyIn(x, currency)` and `assertCurrency(m, currency)` narrow a currency known only at run time (`Money<string>`) to a literal one, without a cast. `assertCurrency` throws `CurrencyMismatchError` for another currency.
196
+
197
+ ```ts
198
+ import { assertCurrency, fromJSON, isMoneyIn, money } from '@ducatjs/money';
199
+
200
+ const price = fromJSON({ amount: '12.34', currency: 'EUR' as string }); // Money<string>
201
+ if (isMoneyIn(price, 'EUR')) {
202
+ price.add(money('1', 'EUR')); // → 13.34 EUR
203
+ }
204
+ assertCurrency(price, 'EUR'); // now Money<'EUR'>
205
+
206
+ const other = fromJSON({ amount: '1', currency: 'USD' as string });
207
+ assertCurrency(other, 'EUR'); // throws CurrencyMismatchError
208
+ ```
209
+
210
+ **Arithmetic.** All of these are immutable and return new values.
211
+
212
+ - `add`, `subtract`, `sum(list, currency?)`, `negate`, `abs`
213
+ - `multiply(m, factor, rounding?)`, `divide(m, divisor, rounding?)`
214
+ - `round(m, rounding, scale = ISO exponent)`, `toScale(m, scale, rounding?)`
215
+ - `percentage(m, percent, rounding?)`, `discount(price, percent, rounding?)`
216
+ - `addTax(net, percent, rounding?)` and `extractTax(gross, percent, rounding?)` both return `{ net, tax, gross }`, and net + tax is always exactly gross.
217
+ - `convert(m, to, rate, rounding?, { scale? })` at a decimal-string rate, or `convert(m, exchangeRate(...), rounding?)`. See [Currency conversion](#currency-conversion).
218
+ - `m.with(operation)` applies a function to the amount and returns its result, so a rounding step or a conversion reads as one link of a chain.
219
+
220
+ ```ts
221
+ import { money, type Money } from '@ducatjs/money';
222
+
223
+ const toFiveRappen = (value: Money<'CHF'>) => value.divide(5).round('halfExpand').multiply(5);
224
+ money('10.00', 'CHF').multiply('1.077').with(toFiveRappen); // → 10.75 CHF
225
+ ```
226
+
227
+ **Compare**
228
+
229
+ - `compare` (returns −1, 0 or 1), `equals`, `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual`
230
+ - `isZero`, `isPositive`, `isNegative`, `sign`, `min`, `max`, `clamp`
231
+ - `identical(a, b)`: like `equals`, but the scale must match too (`1.0` is not identical to `1.00`), for round-trip tests.
232
+
233
+ ```ts
234
+ import { money } from '@ducatjs/money';
235
+
236
+ const one = money('1', 'EUR');
237
+ one.equals(money('1', 'EUR', { scale: 1 })); // → true
238
+ one.identical(money('1', 'EUR', { scale: 1 })); // → false
239
+ ```
240
+
241
+ **Allocate and split**
242
+
243
+ - `allocate(m, weights, { remainder })`. Weights are integers, bigints or decimal strings (`'33.3'`). The remainder strategies:
244
+ - `'largest'` (the default, Hamilton/largest remainder)
245
+ - `'first'` and `'last'`, which spread one unit at a time
246
+ - `'toFirst'` and `'toLast'`, which give the whole remainder to one bucket
247
+ - Zero weights never receive anything. A negative amount is allocated as the mirror image of the positive one.
248
+ - `split(m, n, { remainder })`
249
+ - `instalments(total, n, { remainder: 'toFirst' | 'toLast' | 'first' | 'last', unit? })`. For example, 100/6 gives 16.70 then 5 × 16.66. With `unit: money('1','EUR')`, 100/3 gives 34.00, 33.00, 33.00.
250
+ - `schedule(total, [{ percent: 30 }, { amount: money('200','EUR') }, { percent: 50, cap: money('100','EUR') }, { rest: true }])`: a mix of fixed amounts, capped percentages and one rest tranche. It is exact and throws if the tranches don't cover the total.
251
+ - `chunk(total, max)`: capped tranches, e.g. 1000 with max 300 gives 300, 300, 300, 100.
252
+
253
+ **Rounding.** The `RoundingMode` strings are the same ones `Intl.NumberFormat` uses for its `roundingMode` option:
254
+
255
+ | `Rounding.*` alias | string | behaviour |
256
+ | ---------------------- | --------------- | -------------------------------------------------- |
257
+ | `HalfEven` / `Bankers` | `'halfEven'` | ties go to the even neighbour |
258
+ | `HalfUp` | `'halfExpand'` | ties go away from zero |
259
+ | `HalfDown` | `'halfTrunc'` | ties go toward zero |
260
+ | `HalfCeil` | `'halfCeil'` | ties go toward +∞ |
261
+ | `HalfFloor` | `'halfFloor'` | ties go toward −∞ |
262
+ | `Up` | `'expand'` | always away from zero |
263
+ | `Down` | `'trunc'` | always toward zero |
264
+ | `Ceil` | `'ceil'` | always toward +∞ |
265
+ | `Floor` | `'floor'` | always toward −∞ |
266
+ | `Unnecessary` | `'unnecessary'` | throws `RoundingError` if rounding would be needed |
267
+
268
+ **Rounding policies** (`@ducatjs/money/policy`). A policy bundles a scale, an increment and a mode ("CHF cash", "4 decimals, half even") into one value that goes wherever a rounding mode goes: `multiply`, `divide`, `round`, `percentage`, `discount`, `addTax`, `extractTax`. The result is rounded once, straight to the policy, so it never rounds twice through the amount's own scale. A policy is also a function that rounds an amount, so `amounts.map(policy)` and `m.with(policy)` work.
269
+
270
+ - `roundingPolicy({ scale?, increment?, rounding?, currency? })`. The scale defaults to the currency's exponent, the increment (counted in 10^-scale units) to 1, and the mode to `'halfExpand'`. With `currency`, the policy refuses amounts in other currencies.
271
+ - `isoPolicy`: the currency's exponent, half up.
272
+ - `cashPolicy(currency, { country?, rounding?, includeUnverified? })`, from `@ducatjs/money/cash`, is the cash-rounding rule as a policy, for that currency only. It throws when no rule applies. Unlike `cashRound`, which keeps a larger scale (10.034 CHF gives 10.050), a policy always returns its own scale (10.05).
273
+ - `assertScale(m, policy)` throws a `RangeError` unless `m` is already at the policy's scale and on its increment, for example before an amount is booked.
274
+ - The tax functions apply a policy to the amount they compute: the **gross** for `addTax` (the amount charged), the **net** for `extractTax`, and the **discounted price** for `discount`. The other part is the exact difference, so net + tax is still exactly gross. With a plain rounding mode, `addTax` and `discount` round the tax and the discount amount, as before.
275
+
276
+ Policies are passed explicitly, never stored on amounts or set globally. Only policies built by this copy of the package are accepted. A lookalike function, or a policy from a second installed copy of ducat, throws a `TypeError`, just as `isMoney` rejects the other copy's Money.
277
+
278
+ ```ts
279
+ import { money } from '@ducatjs/money';
280
+ import { assertScale, isoPolicy, roundingPolicy } from '@ducatjs/money/policy';
281
+ import { cashPolicy } from '@ducatjs/money/cash';
282
+
283
+ money('10.00', 'CHF').multiply('1.077', cashPolicy('CHF')); // → 10.75 CHF
284
+ const unitPrice = roundingPolicy({ scale: 4, rounding: 'halfEven' });
285
+ money('10.00', 'EUR').divide(3, unitPrice); // → 3.3333 EUR
286
+ [money('1.234', 'EUR', { scale: 3 })].map(isoPolicy); // → [1.23 EUR]
287
+ assertScale(money('1.234', 'EUR', { scale: 3 }), isoPolicy); // throws RangeError
288
+ ```
289
+
290
+ **Cash rounding**
291
+
292
+ - `cashRound(m, { country?, rounding?, includeUnverified? })` uses the table in `data/cash-rounding.json`:
293
+ - CHF 0.05, SEK and NOK 1.00, DKK 0.50, CAD and AUD 0.05, NZD 0.10
294
+ - HUF 5, CZK 1, CLP 10, ILS 0.10, MYR 0.05
295
+ - AMD and CRC 10, UZS and TZS 50, RSD, PKR, MNT and TWD 1, MRU 0.20
296
+ - EUR 0.05 in BE, NL, FI, IE, IT, SK, EE and LT, and more.
297
+ - A rule follows its source's tie rule: Serbia rounds a 50 para tie down (`10.50 RSD` to `10.00`), every other rule rounds ties away from zero. A `rounding` option overrides it.
298
+ - CLDR's cash data is a second source (`data/cldr-cash.json`). A test fails when the table and CLDR disagree, except for the differences listed with the national source that settles them: CLDR rounds AMD, CRC, TZS and UZS cash to whole units, but their smallest coins are 10, 10, 50 and 50.
299
+ - `currencyDigits(code, { usage: 'cash', country? })` gives the decimals a cash amount is shown with: `currencyDigits('SEK', { usage: 'cash' })` is 0, CHF and DKK stay at 2. Without `usage: 'cash'` it is the ISO exponent.
300
+ - Call it **only for cash tenders**: card payments are never rounded in any of these jurisdictions.
301
+ - For EUR it needs the `country`, because there is no euro-wide rule.
302
+ - `roundToIncrement(m, increment, rounding = 'halfExpand')` and `cashRule(currency, country?)` are also exported.
303
+
304
+ **Format and parse**
305
+
306
+ - `format(m, locale?, Intl.NumberFormatOptions?)` and `formatToParts(...)`. Formatters are cached, and the digits shown always match the value's scale.
307
+ - `parse('1.234,50 €', 'EUR', 'de-DE')` handles:
308
+ - group and decimal separators per locale
309
+ - NBSP and narrow NBSP, Swiss `’`
310
+ - Arabic-Indic, Persian, Devanagari and Thai digits
311
+ - accounting parentheses and U+2212 minus
312
+ - `parse` takes the currency from its argument and never guesses it from the text. Group separators must be where the locale puts them, because a misplaced one is usually another locale's decimal separator: `parse('78.00', 'EUR', 'de-DE')` throws instead of becoming 7800.00 (`{ lenient: true }` accepts them anywhere). A `parse(format(x)) == x` property test runs across 14 locales.
313
+ - `parseMoney(text, locale, { hint?, symbols?, currencies?, lenient?, scale?, rounding? })` detects the currency from the text, and like the rest of ducat it never guesses.
314
+ - It reads ISO codes and symbols that mean exactly one currency, either in the locale (from `Intl.NumberFormat`) or in English (`US$`, `CA$`, `A$`). Then it reads the number as `parse` does.
315
+ - A symbol several currencies share (`$`, `kr`, and also `£` and `¥`, which other currencies use as narrow symbols) needs a `hint`, or your own `symbols` map such as `{ $: 'CAD' }`. This holds even where the locale "owns" it: `$` in en-CA is usually CAD, but a Canadian text can mean USD.
316
+ - It throws when a currency in the text contradicts the `hint`, when the text names two currencies, and when text around the number isn't a currency (`"about 100 USD"`).
317
+ - It also throws for a locale the runtime has no Intl data for, instead of quietly using another locale's separators and symbols, and for group separators in the wrong places for the locale (`1,234,567`; `12,34,567` in en-IN).
318
+ - `lenient: true` accepts any grouping, and a bare number when a `hint` is given. It never makes the currency a guess.
319
+ - `currencies` limits what is detected, and can include codes registered with `defineCurrency`.
320
+
321
+ ```ts
322
+ import { parseMoney } from '@ducatjs/money/parse';
323
+
324
+ parseMoney('CHF 1’234.50', 'de-CH'); // → 1234.50 CHF
325
+ parseMoney('1.234,50 €', 'de-DE'); // → 1234.50 EUR
326
+ parseMoney('$5.00', 'en-CA'); // throws SyntaxError (CAD, USD, AUD...: pass a hint)
327
+ parseMoney('$5.00', 'en-CA', { hint: 'CAD' }); // → 5.00 CAD
328
+ parseMoney('$5.00', 'en-CA', { symbols: { $: 'CAD' } }); // → 5.00 CAD
329
+ parseMoney('€5.00', 'en-IE', { hint: 'USD' }); // throws SyntaxError
330
+ parseMoney('12.34', 'en', { hint: 'EUR', lenient: true }); // → 12.34 EUR
331
+ ```
332
+
333
+ **Serialise**
334
+
335
+ - `toDecimal(m)` gives `"12.34"` for NUMERIC/DECIMAL and wire formats.
336
+ - `toMinor(m)` gives a bigint for BIGINT columns. `toMinorNumber(m)` gives a number and throws if it isn't safe.
337
+ - `majorPart(m)` and `minorPart(m)` split the amount at the decimal point, truncating toward zero, and both keep its sign: `money('-12.34', 'EUR').majorPart()` is `-12` and `.minorPart()` is `-34`. Each is a number while it is a safe integer and a bigint beyond, like `minor`.
338
+ - `JSON.stringify(m)` gives `{"amount":"12.34","currency":"EUR"}`, and `fromJSON` reverses it. The amount is a string so it survives JSON parsers that turn numbers into floats.
339
+
340
+ **Validate at API boundaries.** `@ducatjs/money/zod` and `@ducatjs/money/valibot` turn the JSON form into Money inside your request schemas. They accept exactly what `toJSON` writes:
341
+
342
+ - an exact decimal string (no exponent, plus sign, spaces or grouping) with at most 50 digits (`maxDigits`) and no more decimals than the scale;
343
+ - a known currency, or one of `{ currency }`.
344
+
345
+ The scale is the schema's, not the input's: the currency's exponent, or `{ scale }`. Shorter amounts are padded (`"1.5"` EUR is 1.50 EUR). `{ rounding }` accepts more decimals, as in `money()`. Errors are issues on the `amount` or `currency` path, and keys other than those two are dropped. Bad options throw when the schema is built. The Zod schema is a codec, so `z.encode(schema, price)` writes the JSON back. With Valibot, which has no codecs, use `price.toJSON()`. `moneyJSONSchema` checks the same things but keeps the JSON. Zod (4) and Valibot (1) are optional peer dependencies; no other entry imports them.
346
+
347
+ ```ts
348
+ import { z } from 'zod';
349
+ import { money } from '@ducatjs/money';
350
+ import { moneySchema } from '@ducatjs/money/zod';
351
+
352
+ const Order = z.object({ total: moneySchema({ currency: ['EUR', 'USD'] }) });
353
+ Order.parse({ total: { amount: '12.34', currency: 'EUR' } }).total; // → 12.34 EUR
354
+ Order.parse({ total: { amount: '12.345', currency: 'EUR' } }); // throws ZodError
355
+ z.encode(moneySchema(), money('9.99', 'USD')); // → { amount: '9.99', currency: 'USD' }
356
+ ```
357
+
358
+ ```ts
359
+ import * as v from 'valibot';
360
+ import { moneySchema } from '@ducatjs/money/valibot';
361
+
362
+ v.parse(moneySchema({ currency: 'JPY' }), { amount: '1000', currency: 'JPY' }); // → 1000 JPY
363
+ ```
364
+
365
+ **Currencies**
366
+
367
+ - `currencyExponent(code)`, `isCurrency(code)`, `ISO_EXPONENTS`, `ISO_4217_PUBLISHED`
368
+ - `defineCurrency('BTC', 8)` adds a non-ISO code. To also get it in the `CurrencyCode` type, augment `interface CustomCurrencies`.
369
+ - Withdrawn codes (`@ducatjs/money/historic`): 54 codes ISO 4217 has withdrawn (HRK, BGN, ANG, STD, SLL, ZWL, CUC, VEF, the euro's predecessors…) and the offshore yuan CNH, each with the exponent ISO gave it while it was current, so old records and processor listings load. Nothing is registered until you call `defineHistoricCurrencies`, with a list of codes or none for all of them. `historicCurrency(code)` gives the name, exponent, ISO numeric code and the month ISO withdrew it, from ISO 4217 List Three. Older codes whose minor units aren't sourced are left out; register those with `defineCurrency`.
370
+
371
+ ```ts
372
+ import { money } from '@ducatjs/money';
373
+ import { defineHistoricCurrencies, historicCurrency } from '@ducatjs/money/historic';
374
+
375
+ defineHistoricCurrencies(['HRK', 'ITL']);
376
+ money('7.53', 'HRK'); // → 7.53 HRK
377
+ money('1936.27', 'ITL', { rounding: 'halfExpand' }); // → 1936 ITL (the lira had no minor unit)
378
+ historicCurrency('HRK')?.withdrawn; // → '2023-01'
379
+ historicCurrency('CNH')?.withdrawn; // → undefined (not an ISO code)
380
+ ```
381
+
382
+ To have the codes in the `CurrencyCode` type as well:
383
+
384
+ ```ts
385
+ import type { HistoricCurrencies } from '@ducatjs/money/historic';
386
+
387
+ declare module '@ducatjs/money' {
388
+ interface CustomCurrencies extends HistoricCurrencies {}
389
+ }
390
+ ```
391
+
392
+ ### Currency conversion
393
+
394
+ `@ducatjs/money/convert` converts at exact rates and rounds once, at the end. A rate is a `Rate`, an exact rational with the pair in its type, so converting the wrong currency is a compile error. Inverses and chains of rates are exact too: nothing is rounded until an amount is converted.
395
+
396
+ ```ts
397
+ import { money } from '@ducatjs/money';
398
+ import { exchangeRate } from '@ducatjs/money/convert';
399
+
400
+ const eurUsd = exchangeRate('EUR', 'USD', '1.1165', {
401
+ source: 'ECB',
402
+ asOf: '2026-11-06',
403
+ type: 'reference',
404
+ });
405
+ const usdJpy = exchangeRate('USD', 'JPY', '158.33');
406
+ eurUsd.convert(money('100', 'EUR'), 'halfEven'); // → 111.65 USD
407
+ eurUsd.inverse().toString(); // → '2000/2233': exact, not 0.8956...
408
+ eurUsd.andThen(usdJpy); // EUR → JPY, exact, with both rates kept in `derivedFrom`
409
+ eurUsd.withMargin('-0.02'); // 2% less USD per EUR, exactly
410
+ ```
411
+
412
+ Write rates as decimal strings, with every digit the source published. A JS number is read at its shortest decimal form, so it is exact as well, but a string says what was meant. A rate keeps the digits it was given: `exchangeRate('EUR', 'HUF', '363.90').toString()` is `'363.90'`. A rate carries optional metadata: `source`, `asOf`, `availableAt` (when it was published), `validUntil`, `type` (`'mid'`, `'bid'`, `'ask'`, `'reference'`...) and `quote` (how the source published it). Instants are stored in UTC, and dates stay as given. A derived rate takes the oldest `asOf`, the latest `availableAt` and the earliest `validUntil` of its inputs, so staleness can't hide in a chain. `validUntil` is checked only when you pass `{ strictValidity: true }`, because ducat reads the clock only when asked to. A rounding policy (`@ducatjs/money/policy`, or `cashPolicy` from `/cash`) works wherever a rounding mode does, so a conversion can land directly on a cash increment or a fixed number of decimals:
413
+
414
+ ```ts
415
+ import { money } from '@ducatjs/money';
416
+ import { cashPolicy } from '@ducatjs/money/cash';
417
+ import { exchangeRate } from '@ducatjs/money/convert';
418
+
419
+ const eurChf = exchangeRate('EUR', 'CHF', '0.9143');
420
+ money('10.07', 'EUR').convert(eurChf, cashPolicy('CHF')); // → 9.20 CHF (9.207001, to 0.05)
421
+ ```
422
+
423
+ **Providers.** A `RateProvider` answers `rate(from, to, { asOf })` synchronously, or with `undefined`. `rateTable()` holds a fixed set of rates (one base against many, as the ECB publishes them), and combinators build the rest: `chain(...)` takes the first answer, `reversed(p)` inverts, `via('EUR', p)` triangulates through one currency, and `identity()` answers 1 for a currency to itself. Loading is async and stays outside providers. `snapshots(load)` gives the latest table at or before a day, looks back over weekends and holidays, and caches loaded days. A loader that throws fails the lookup instead of falling back to an older day, so an outage can't silently become a stale rate. Days are UTC days, and a table answers for its whole day, including the hours before it was published, unless it has an `availableAt`.
424
+
425
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const xml90Days = readFileSync('test/fixtures/ecb/eurofxref-hist-2d.xml', 'utf8'); -->
426
+
427
+ ```ts
428
+ import { money } from '@ducatjs/money';
429
+ import { snapshotsFrom, via } from '@ducatjs/money/convert';
430
+ import { parseEcbXml } from '@ducatjs/money/convert/ecb';
431
+
432
+ const history = snapshotsFrom(parseEcbXml(xml90Days));
433
+ const fx = via('EUR', await history.at('2026-11-08')); // a Sunday: Friday's table
434
+ money('100', 'USD').convert('JPY', fx, 'halfEven'); // → 15833 JPY, through EUR, rounded once
435
+ ```
436
+
437
+ **Historical rates.** A lookup for a past date should see only what was known then, in the form the source published it. Publication times and missing values work in `@ducatjs/money/convert` itself; quoted tables and ranges are in the opt-in `@ducatjs/money/convert/history`, so converting at rates you already hold doesn't pay for them.
438
+
439
+ - **Publication time.** `availableAt` on a rate or a table hides it from lookups before then, so a query for 09:00 on a publication day gets the day before. A date query covers the whole day.
440
+ - **How a rate was quoted.** Many central banks quote the other way round (CAD per USD) or per 100 units (NOK per 100 JPY). `quotedTable({ base, inverted: true, rates })` and per-entry `{ rate, per, inverted }` store the exact rate in the table's direction and keep the published quote. `{ rate, decimals }` rebuilds the trailing zeros a feed strips (`'1.175'` with 4 decimals is `'1.1750'`).
441
+ - **Missing values.** `null` in a table's `rates` marks a currency the source listed without a value (a holiday, `ND`, `N/A`). The table lists it in `missing`, and every parser reads those markers the same way.
442
+ - **Periods.** A table with `validUntil` (a monthly rate, say) answers any day up to that date and nothing after it.
443
+ - **Ranges.** `rateHistory(tables)` answers synchronously from a date range loaded in one call. Each pair falls back on its own (a currency missing on a holiday comes from the day before), up to `lookback` days (default 4), and `lookup()` says why it fell back: `noTable`, `notYetAvailable`, `expired`, `missing` or `notQuoted`. A table with a `validUntil` answers its whole period whatever the lookback. `snapshots()` skips tables not yet published, too.
444
+
445
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const ecbXml = readFileSync('test/fixtures/ecb/eurofxref-hist-2d.xml', 'utf8'); -->
446
+
447
+ ```ts
448
+ import { parseEcbXml } from '@ducatjs/money/convert/ecb';
449
+ import { quotedTable, rateHistory } from '@ducatjs/money/convert/history';
450
+
451
+ const history = rateHistory(parseEcbXml(ecbXml)); // e.g. eurofxref-hist-90d.xml
452
+ const sunday = history.lookup('EUR', 'USD', { asOf: '2026-11-08' });
453
+ sunday.rate?.asOf; // → '2026-11-06'
454
+ sunday.skipped.map((fallback) => fallback.day); // → ['2026-11-08', '2026-11-07']
455
+ sunday.skipped[0]?.reason; // → 'noTable' (a weekend: no table that day)
456
+
457
+ const nok = quotedTable({
458
+ base: 'NOK',
459
+ asOf: '2026-11-06',
460
+ rates: { JPY: { rate: '6.50', per: 100, inverted: true }, ISK: null },
461
+ });
462
+ nok.rate('NOK', 'JPY')?.inverse().toString(); // → '0.065'
463
+ nok.rate('NOK', 'JPY')?.quote; // → { rate: '6.50', per: 100, inverted: true }
464
+ nok.missing; // → ['ISK']
465
+ ```
466
+
467
+ **Where rates come from.** ducat ships parsers for the ECB's euro reference rates (`@ducatjs/money/convert/ecb`): the daily, 90-day and full-history eurofxref XML and CSV files, and the ECB Data Portal's SDMX-CSV (`parseEcbSdmxCsv`), which has any date range back to 1999 in one file. The opt-in fetchers are in `@ducatjs/money/convert/fetch`: `fetchEcb()` for the eurofxref files and `fetchEcbRange({ start, end, currencies })` for the Data Portal, which needs no key and allows browsers (CORS). They use the platform `fetch` and have a 30-second default timeout. Nothing else in ducat touches the network. The ECB publishes once per TARGET working day at about 16:00 CET, for information only, so its rates are typed `'reference'`. The Data Portal strips trailing zeros (`147.2` for 147.20), so the parser rebuilds the published digits from each series' `DECIMALS` attribute. The Bank of Canada's daily rates come from its Valet API (`parseBankOfCanadaJson` in `@ducatjs/money/convert/boc`, and `fetchBankOfCanada()`, also keyless and CORS-enabled). The Bank quotes CAD per unit of each currency, so the tables hold exact inverses with the published quote kept, and its rates carry `availableAt` 16:30 Toronto time, when it says it publishes them. It quotes four decimals (up to six for small rates), so a small rate has only about four significant digits: JPY `0.008760`, KRW `0.000929`, and IDR `0.000082` with two. The inverse of such a rate is exact, but no more precise than the quote, so for those currencies prefer a source that quotes them the other way round. The Federal Reserve's H.10 rates (noon buying rates in New York, certified for US customs, daily since 1971) have a parser only, `parseFedH10Csv` in `@ducatjs/money/convert/fed`, for the CSV from the Board's Data Download Program (release H10, series in columns, with labels). H.10 quotes currency per USD except AUD, EUR, NZD and GBP, which it quotes as USD per unit; those four are stored as exact inverses with the published quote kept, so every table is USD → currency. Holidays are rows of `ND` and are left out. The Board is retiring the Data Download Program in favour of FRED, whose terms (an API key, no storing or caching, no commercial use without consent) rule out a fetcher in ducat, so download the CSV yourself. For sterling there are two parsers. HMRC's monthly exchange rates (`parseHmrcMonthlyCsv` in `@ducatjs/money/convert/hmrc`) are the UK's rates for customs and VAT, under the Open Government Licence. Each month's table is valid for that month (`asOf` the 1st, `validUntil` the last day) and is available from the end of the Wednesday HMRC publishes it, the day before the penultimate Thursday of the month before (the `publication_date` the trade tariff service's API gives every monthly file). The Bank of England's daily spot rates (`parseBankOfEnglandCsv` in `@ducatjs/money/convert/boe`) come from CSV downloaded from its database. The Bank's legal page excludes "selected exchange rate data and series" from its Open Government Licence as third-party data, and its spot rates may come from LSEG, whose content can't be redistributed without consent. So ducat ships no Bank of England data and no fetcher, and **you must check the licence of every Bank of England series you load** before you use or republish it. For what a payment actually settled at, read the processor's own rate: `stripeRate` and `stripeImpliedRate` from a Stripe balance transaction, and `adyenRate` from an Adyen settlement details row. Any other source is a small loader that returns a `rateTable`.
468
+
469
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const sdmxCsv = readFileSync('test/fixtures/ecb-sdmx/exr-d-full.csv', 'utf8'); -->
470
+
471
+ ```ts
472
+ import { parseEcbSdmxCsv } from '@ducatjs/money/convert/ecb';
473
+ import { rateHistory } from '@ducatjs/money/convert/history';
474
+
475
+ // what fetchEcbRange({ start: '2025-12-22', end: '2026-01-06' }) parses
476
+ const history = rateHistory(parseEcbSdmxCsv(sdmxCsv));
477
+ history.rate('EUR', 'ISK', { asOf: '2026-01-06' })?.toString(); // → '147.20' (the API sends 147.2)
478
+ history.rate('EUR', 'USD', { asOf: '2025-12-26' })?.asOf; // → '2025-12-24' (Christmas)
479
+ ```
480
+
481
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const valetJson = readFileSync('test/fixtures/boc/fx-rates-daily.json', 'utf8'); -->
482
+
483
+ ```ts
484
+ import { parseBankOfCanadaJson } from '@ducatjs/money/convert/boc';
485
+
486
+ const [latest] = parseBankOfCanadaJson(valetJson); // what fetchBankOfCanada() parses
487
+ latest?.rate('CAD', 'USD')?.inverse().toString(); // → '1.3789' (CAD per USD, as published)
488
+ latest?.availableAt; // → '2026-01-06T21:30:00.000Z' (16:30 in Toronto)
489
+ ```
490
+
491
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const h10Csv = readFileSync('test/fixtures/fed/h10-daily.csv', 'utf8'); -->
492
+
493
+ ```ts
494
+ import { parseFedH10Csv } from '@ducatjs/money/convert/fed';
495
+
496
+ const [latest] = parseFedH10Csv(h10Csv); // a DDP download of H.10
497
+ latest?.rate('USD', 'JPY')?.toString(); // → '156.7000' (yen per USD, as published)
498
+ latest?.rate('USD', 'EUR')?.inverse().toString(); // → '1.1689' (USD per euro, as published)
499
+ ```
500
+
501
+ <!-- doctest: setup import { readFileSync } from 'node:fs'; const hmrcCsv = readFileSync('test/fixtures/hmrc/monthly_csv_2026-11.csv', 'utf8'); -->
502
+
503
+ ```ts
504
+ import { rateHistory } from '@ducatjs/money/convert/history';
505
+ import { parseHmrcMonthlyCsv } from '@ducatjs/money/convert/hmrc';
506
+
507
+ const hmrc = rateHistory(parseHmrcMonthlyCsv(hmrcCsv)); // monthly_csv_2026-11.csv
508
+ hmrc.rate('GBP', 'EUR', { asOf: '2026-11-27' })?.toString(); // → '1.1930' (valid all November)
509
+ hmrc.rate('GBP', 'EUR', { asOf: '2026-12-01' }); // → undefined (December has its own file)
510
+ ```
511
+
512
+ **Exact intermediates and residuals.** `@ducatjs/money/exact` adds `ExactMoney`, a rational amount for chained calculations that should round once at the end. In `@ducatjs/money/convert`, `convertDetailed()` also returns what rounding cost, so a ledger can book it:
513
+
514
+ ```ts
515
+ import { money } from '@ducatjs/money';
516
+ import { convertDetailed, exchangeRate } from '@ducatjs/money/convert';
517
+
518
+ const eurUsd = exchangeRate('EUR', 'USD', '1.1165');
519
+ const { value, residual } = convertDetailed(money('0.07', 'EUR'), eurUsd, 'halfEven');
520
+ value; // → 0.08 USD
521
+ residual; // → -369/200000 USD (value + residual is the exact conversion)
522
+ ```
523
+
524
+ **Totals in several currencies.** `bag(...amounts)` keeps per-currency totals, and `bag.total('EUR', fx, 'halfEven')` converts every entry exactly, sums and rounds once. To order amounts in different currencies, `comparator(fx, { base: 'USD' })` compares them through one base currency. With one rate per currency the order is consistent. Pairwise rates are not: with bid/ask or slightly inconsistent mid rates, A < B < C < A can happen, and `Array#sort` then gives engine-dependent results. `sortByValue(list, fx, { base })` looks every rate up before it reorders anything.
525
+
526
+ ### Fixed-point decimals
527
+
528
+ `@ducatjs/money/fixed` gives the engine under Money its own type: `Fixed`, an exact decimal without a currency. It is an integer and a scale, the count of decimal places, so `fixed('12.345')` is 12345 at scale 3. The integer is a JS number while it is a safe integer and a bigint beyond, as for Money, so everyday values stay fast and very large ones stay exact. Use it for quantities, unit prices with more decimals than the currency, rates, and intermediate values that are not money yet.
529
+
530
+ ```ts
531
+ import { fixed, fromScaled } from '@ducatjs/money/fixed';
532
+
533
+ const unitPrice = fixed('0.0425'); // → 0.0425 (scale 4)
534
+ const total = unitPrice.multiply(1200); // → 51.0000, exact
535
+ const perDay = fixed('100.00').divide(3, 'halfEven'); // → 33.33: rounded once, to this value's scale
536
+ fixed('1.5').add('0.25'); // → 1.75: mixed scales align to the larger one
537
+ fixed('2.5').round('halfEven'); // → 2: round() defaults to scale 0
538
+ fromScaled(12345, 3).toString(); // → '12.345'
539
+ JSON.stringify({ unitPrice }); // → '{"unitPrice":"0.0425"}'
540
+ ```
541
+
542
+ Input is strict in the same way as `money()`. A string keeps its own scale. A number is read at its shortest decimal form, and only when that has at most 15 significant digits, which a double always holds faithfully. So `fixed(0.1 + 0.2)` throws instead of becoming 0.30000000000000004: pass the decimal as a string, or give `{ scale, rounding }`. With a `scale`, input that has more decimals throws unless `rounding` is also given.
543
+
544
+ Arithmetic follows Money's rules. `add`, `subtract` and `compare` align to the larger scale exactly. `multiply` and `divide` take exact factors (integers, decimal strings, bigints, `ratio()` or another `Fixed`) and round once, with `'halfExpand'` by default. The result keeps this value's scale unless you pass another: `fixed('9.990').divide('1.2', 'halfEven', { scale: 2 })` is 8.32. Pass the scale instead of calling `toScale` afterwards, which would round twice. `toScale` and `round` take any of the ten rounding modes, including `'unnecessary'`, which throws if digits would be lost.
545
+
546
+ To convert, `money.toFixedPoint()` gives the exact amount at the money's scale, and `fixedValue.toMoney('EUR')` makes Money at the currency's exponent. That conversion throws RoundingError when it would drop digits, unless a rounding mode is passed: `fixed('1.005').toMoney('EUR', 'halfEven')` is 1.00 EUR.
547
+
548
+ ### Payment processors: the amount you send
549
+
550
+ ```ts
551
+ import { money } from '@ducatjs/money';
552
+ import { toStripe, toStripeAmount, fromStripe } from '@ducatjs/money/stripe';
553
+ import { toAdyen, fromAdyen } from '@ducatjs/money/adyen';
554
+ import { toCheckout, fromCheckout } from '@ducatjs/money/checkout';
555
+ import { toPayPal, fromPayPal } from '@ducatjs/money/paypal';
556
+ import { toBraintree, fromBraintree } from '@ducatjs/money/braintree';
557
+ import { toKlarna, fromKlarna } from '@ducatjs/money/klarna';
558
+
559
+ toStripe(money('10.99', 'USD')); // → { amount: 1099, currency: 'usd' }
560
+ toStripeAmount(money('5', 'ISK')); // → 500 (Stripe: two-decimal value ending in 00)
561
+ toStripeAmount(money('10.45', 'HUF'), { payout: true }); // throws RoundingError: payouts must be whole HUF
562
+ toStripeAmount(money('5.124', 'KWD'), { rounding: 'halfEven' }); // → 5120 (last digit must be 0)
563
+ toAdyen(money('15000', 'IDR')); // → { value: 15000, currency: 'IDR' } (Adyen: IDR has 0 decimals)
564
+ toAdyen(money('5000', 'CLP')); // → { value: 500000, currency: 'CLP' } (Adyen: CLP has 2 decimals)
565
+ toCheckout(money('5000', 'CLP')); // → { amount: 500000, currency: 'CLP' } (Checkout.com: 2 decimals, must end in 00)
566
+ toCheckout(money('1.001', 'BHD')); // throws RoundingError: Checkout.com needs the last digit 0
567
+ toPayPal(money('10.99', 'USD')); // → { currency_code: 'USD', value: '10.99' } (PayPal: exact decimal string)
568
+ toPayPal(money('1000', 'HUF')); // → { currency_code: 'HUF', value: '1000' } (PayPal: HUF takes no decimals)
569
+ toBraintree(money('5000', 'LAK')); // → '5000' (Braintree: LAK is zero-decimal; the merchant account sets the currency)
570
+ toKlarna(money('1000', 'HUF')); // → 100000 (Klarna: ISO minor units, so HUF in fillér)
571
+ ```
572
+
573
+ The same 1000 units of each currency on the wire (from `test/processors.test.ts`):
574
+
575
+ | | ISO exp | `toStripeAmount` | `toAdyen().value` | `toCheckoutAmount` | `toPayPalValue` | `toBraintree` | `toKlarna` |
576
+ | --- | ------- | ---------------: | ----------------: | -----------------: | --------------: | ------------: | ---------: |
577
+ | USD | 2 | 100000 | 100000 | 100000 | "1000.00" | "1000.00" | 100000 |
578
+ | JPY | 0 | 1000 | 1000 | 1000 | "1000" | "1000" | - |
579
+ | ISK | 0 | 100000 | 100000 | 1000 | - | "1000" | - |
580
+ | IDR | 2 | 100000 | 1000 | 100000 | - | "1000.00" | - |
581
+ | CLP | 0 | 1000 | 100000 | 100000 | - | "1000" | - |
582
+ | KWD | 3 | 1000000 | 1000000 | 1000000 | - | - | - |
583
+ | HUF | 2 | 100000 | 100000 | 100000 | "1000" | "1000.00" | 100000 |
584
+
585
+ When the processor needs less precision than the value has, the conversion throws unless you pass a rounding mode. This happens for IDR or CVE on Adyen, MGA on Stripe, Stripe's HUF/TWD payouts, CLP payments on Checkout.com, HUF/TWD on PayPal, LAK on Braintree and three-decimal currencies on Stripe and Checkout.com. PayPal and Braintree amounts are decimal strings, built from the exact value and never through a float. `fromStripe`, `fromAdyen`, `fromCheckout`, `fromPayPal`, `fromBraintree` and `fromKlarna` return the value at its ISO scale when that is exact, and otherwise keep the processor's extra precision instead of rounding it away.
586
+
587
+ Signs follow each processor's docs and are checked after rounding. Checkout.com (a card verification) and PayPal (breakdown fields) take zero, Braintree doesn't ("Amount must be greater than zero"), and none of the three takes negative amounts: refunds are sent as positive amounts. Klarna takes both, because its order lines document negative amounts for discounts ("Negative when type is discount").
588
+
589
+ ### Databases
590
+
591
+ | storage | Postgres | MySQL | SQLite | helpers |
592
+ | ------------------------------------ | ------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
593
+ | **minor units + code** (recommended) | `amount_minor BIGINT NOT NULL, currency CHAR(3) NOT NULL` | `BIGINT, CHAR(3)` | `INTEGER, TEXT` | `toMinorColumns(m, 'amountMinor', 'currency')`, `minorMoneyField(...)`, `fromMinor` |
594
+ | **decimal** | `NUMERIC(19,4)` for most cases; `NUMERIC(38,8)` for crypto/FX | `DECIMAL(19,4)` | **avoid** (NUMERIC affinity may store REAL); use TEXT + `toDecimal` if you must | `toDecimalValue(m, Prisma.Decimal)`, `fromDecimal(d, 'EUR')`, `decimalMoneyField(...)` |
595
+ | **JSON** | `jsonb` `{amount: "12.34", currency}` | `JSON` | `TEXT` | `JSON.stringify(m)`, `fromJSON` |
596
+
597
+ - BIGINT holds ±9.2·10^18 minor units, which is 92 quadrillion EUR. Postgres's `money` type is locale-dependent; don't use it.
598
+ - Choose a NUMERIC scale of at least the largest exponent you store: 4 covers CLF/UYW, and 3 covers BHD/KWD.
599
+ - Prisma maps `BigInt` to JS `bigint`. The helpers accept it, and `fromMinor` normalises it back to a number when it is safe.
600
+ - **A scale column** makes minor units self-describing: `amount_scale SMALLINT NOT NULL` next to `amount_minor` and `currency`. Write with `toMinorColumns(m, 'amountMinor', 'currency', { scale: 'amountScale' })` and read with `minorMoneyField('amountMinor', 'currency', { scale: 'amountScale' })`. Any scale then round-trips, sub-minor amounts included, which `toMinorColumns` alone refuses. Stored rows also keep their meaning if a currency's exponent changes (see below). To add the column to existing rows, backfill each currency's current exponent in the migration; a missing (NULL) scale throws on read.
601
+ - **Exponent changes.** Minor units stored without a scale are read at the currency's exponent in the installed version of ducat, so a changed exponent would silently change what old rows mean: 1234 stored as 12.34 would read as 1.234 or 123.4. So an exponent change is a breaking change (see [Decisions](#decisions)), listed under **Changed** in the changelog with the currencies affected. Before upgrading across one, migrate the affected rows, or store a scale column (or a decimal) so the question never arises. Custom currencies keep the exponent given to `defineCurrency`.
602
+ - `fromDecimal` rejects values with more decimals than the currency allows (e.g. `12.3456` in NUMERIC(19,4) for EUR) unless you pass `{ scale }` or `{ rounding }`. Trailing zeros are fine.
603
+
604
+ Prisma client extension (from `examples/prisma`, which runs end to end against SQLite):
605
+
606
+ <!-- doctest: skip (needs a database; examples/prisma runs it) -->
607
+
608
+ ```ts
609
+ const db = new PrismaClient().$extends({
610
+ result: {
611
+ order: {
612
+ total: minorMoneyField('totalMinor', 'currency'), // two columns -> Money
613
+ shippingMoney: decimalMoneyField('shipping', { currency: 'EUR' }), // Decimal column -> Money<'EUR'>
614
+ },
615
+ },
616
+ });
617
+ await db.order.create({
618
+ data: {
619
+ ...toMinorColumns(total, 'totalMinor', 'currency'),
620
+ shipping: toDecimalValue(fee, Prisma.Decimal),
621
+ },
622
+ });
623
+ (await db.order.findFirstOrThrow()).total; // Money
624
+ ```
625
+
626
+ `src/prisma.ts` does not import `@prisma/client`. It types Decimal structurally, as `{ toFixed(): string }`, so it also works with decimal.js and big.js. `examples/prisma/typecheck.ts` checks the helpers against Prisma's generated extension types.
627
+
628
+ #### Kysely
629
+
630
+ `@ducatjs/money/kysely` keeps Money exact in Kysely without the plugin having to understand your queries. A money value is selected as a JSON object built in SQL, which carries a marker, the amount as exact text, and the currency. `MoneyResultPlugin` turns exactly those objects into Money, wherever they appear: unions, CTEs, subqueries, nested `jsonArrayFrom` rows, every `stream()` chunk, and with `CamelCasePlugin`. A raw column keeps its driver type and is never typed as Money.
631
+
632
+ ```ts
633
+ import Database from 'better-sqlite3';
634
+ import { Kysely, SqliteDialect } from 'kysely';
635
+ import { money } from '@ducatjs/money';
636
+ import {
637
+ moneyHelpers,
638
+ MoneyResultPlugin,
639
+ setMoney,
640
+ type MinorUnitsColumn,
641
+ } from '@ducatjs/money/kysely';
642
+
643
+ interface Schema {
644
+ payments: { id: number; amount_minor: MinorUnitsColumn; currency: string };
645
+ }
646
+
647
+ const client = new Database(':memory:');
648
+ client.defaultSafeIntegers(true); // integers as bigint, exact beyond 2^53
649
+ client.exec('CREATE TABLE payments (id INTEGER, amount_minor INTEGER, currency TEXT)');
650
+ const db = new Kysely<Schema>({
651
+ dialect: new SqliteDialect({ database: client }),
652
+ plugins: [new MoneyResultPlugin()],
653
+ });
654
+ const { selectMoney, compareMoney, sumMoney } = moneyHelpers('sqlite');
655
+
656
+ const fee = money('1.234', 'KWD');
657
+ await db
658
+ .insertInto('payments')
659
+ .values({ id: 1, ...setMoney(fee, 'amount_minor', 'currency') })
660
+ .execute();
661
+ const row = await db
662
+ .selectFrom('payments')
663
+ .select((eb) => selectMoney(eb, 'amount_minor', 'currency').as('fee'))
664
+ .where((eb) => compareMoney(eb, 'amount_minor', 'currency', '>=', money('1', 'KWD')))
665
+ .executeTakeFirstOrThrow();
666
+ row.fee; // → 1.234 KWD
667
+ ```
668
+
669
+ - **Reads:** `selectMoney(eb, amount, currency)` for minor units and `selectDecimalMoney` for a NUMERIC/DECIMAL (SQLite TEXT) column. The currency comes from a column, or from `fixed('EUR')`, which types the result `Money<'EUR'>`. A nullable amount column gives `Money | null`.
670
+ - **Writes:** `setMoney(m, 'amount_minor', 'currency')` and `setDecimalMoney` return the column values for `values()`, `set()` and upserts. The amount is an exact string, and with `fixed()` Money in another currency is a compile error and throws. `setDecimalMoney` and `compareDecimalMoney` take the column's size (`{ precision, scale }`, NUMERIC(19,4) by default) and throw where the column would round or clamp the value.
671
+ - **Filters:** `compareMoney(eb, amount, currency, op, m)` (`=`, `<>`, `<`, `<=`, `>`, `>=`) compares currency and amount together, so 5 USD never matches 500 JPY. Parameters are cast exactly; MySQL would otherwise compare a string with a BIGINT or DECIMAL as doubles. SQL's three-valued logic applies: a row with a NULL currency or amount matches no comparison, `<>` included. There is no `in`-list form; combine comparisons with `eb.or`.
672
+ - **Sums:** `sumMoney` and `sumDecimalMoney` give Money, or null over no rows. Rows in more than one currency, or an amount without a currency, make the result throw, so group by the currency column to sum per currency.
673
+ - `moneyHelpers(dialect)` builds these for `'postgres'`, `'mysql'` or `'sqlite'`, which differ in their JSON and cast syntax. On SQLite, decimals are TEXT: `sumDecimalMoney` and ordering comparisons on them throw, and `=` and `<>` compare the text, so they only match values written with `setDecimalMoney` (`'5.0'` is not `'5.00'`). Compare and sum minor units instead. On Postgres the values are `jsonb`, so `UNION`, `DISTINCT` and `GROUP BY` work on them.
674
+ - Plugin order: for nested JSON that SQLite returns as text, put `ParseJSONResultsPlugin` before `MoneyResultPlugin`. Markers carry a random nonce chosen when the module loads, and the plugin converts only objects with exactly the marker's keys and this process's nonce. So data that merely looks like a marker, in a JSON or text column, stays as it is, and the nonce never appears in results. `min`, `max` and `coalesce` over a money expression aren't supported: they would compare the JSON objects. `kysely` is an optional peer dependency.
675
+
676
+ #### node-postgres
677
+
678
+ `@ducatjs/money/pg` keeps every amount node-postgres reads exact. It doesn't import `pg`; you pass `pg.types` in.
679
+
680
+ - **`exactTypes(pg.types)`** gives type parsers that return BIGINT as a bigint and NUMERIC as its exact string, rejecting `NaN` and `Infinity`. Arrays of both are parsed the same way: pg's default parses `numeric[]` as floats. Other types keep pg's parsers. **The bigint applies to every BIGINT on that client**, `count(*)` and bigserial ids included, and `JSON.stringify` throws on a bigint. To keep pg's exact strings, pass `{ int8: 'string' }`, or give the money parsers their own pool.
681
+ - **A composite type**, `(amount numeric, currency text)`. `moneyTypeSql()` returns its DDL plus a `sum` aggregate that throws on mixed currencies. With the type's OIDs, `exactTypes` reads it (and its arrays) as Money, keeping the scale it was stored at. `toMoneyComposite(m)` writes it. Run the DDL once, in a migration: it isn't idempotent. A schema-qualified name (`moneyTypeSql('billing.amount')`) puts the type, its function and the aggregate in that schema. OIDs are looked up once, so after recreating the type, or with one type per tenant schema, pass the new OIDs; a type `exactTypes` doesn't know comes back as pg's raw text. A value with a NULL field, `(,EUR)`, throws when read and makes `sum` raise.
682
+ - **`toNumeric(m, { precision, scale })`** gives the decimal for a `NUMERIC(p, s)` parameter, or throws where Postgres would change the value. Postgres rounds extra decimals silently on insert, which is why the guard exists.
683
+
684
+ <!-- doctest: skip (needs a Postgres server; test/db/pg-types.test.ts runs the same steps) -->
685
+
686
+ ```ts
687
+ import pg from 'pg';
688
+ import { money } from '@ducatjs/money';
689
+ import {
690
+ exactTypes,
691
+ moneyTypeOids,
692
+ moneyTypeSql,
693
+ toMoneyComposite,
694
+ toNumeric,
695
+ } from '@ducatjs/money/pg';
696
+
697
+ const setup = new pg.Client();
698
+ await setup.connect();
699
+ for (const statement of moneyTypeSql()) await setup.query(statement); // once, in a migration
700
+ const oids = await moneyTypeOids(setup);
701
+
702
+ const client = new pg.Client({ types: exactTypes(pg.types, { money: oids }) });
703
+ await client.connect();
704
+ await client.query('INSERT INTO items (price) VALUES ($1::ducat_money)', [
705
+ toMoneyComposite(money('12.34', 'EUR')),
706
+ ]);
707
+ (await client.query('SELECT sum(price) AS total FROM items')).rows[0].total; // Money
708
+ toNumeric(money('1.23456', 'EUR', { scale: 5 }), { precision: 19, scale: 4 }); // throws RangeError
709
+ ```
710
+
711
+ #### Drizzle
712
+
713
+ `@ducatjs/money/drizzle/pg`, `/drizzle/mysql` and `/drizzle/sqlite` give Drizzle ORM column types, so selects return `Money` and inserts take it, with the currency in the column's type:
714
+
715
+ | column | Postgres | MySQL | SQLite | value |
716
+ | --------------------- | ---------------- | ---------------- | --------- | ---------------------------------------------- |
717
+ | `moneyMinor('EUR')` | `bigint` | `bigint` | `integer` | minor units in a fixed currency |
718
+ | `moneyDecimal('EUR')` | `numeric(19, 4)` | `decimal(19, 4)` | `text` | the exact decimal; pass `{ precision, scale }` |
719
+ | `moneyJson()` | `jsonb` | `json` | `text` | `{"amount":"12.34","currency":"EUR"}` |
720
+
721
+ ```ts
722
+ import Database from 'better-sqlite3';
723
+ import { drizzle } from 'drizzle-orm/better-sqlite3';
724
+ import { integer, sqliteTable } from 'drizzle-orm/sqlite-core';
725
+ import { money } from '@ducatjs/money';
726
+ import { moneyJson, moneyMinor } from '@ducatjs/money/drizzle/sqlite';
727
+
728
+ const orders = sqliteTable('orders', {
729
+ id: integer('id').primaryKey(),
730
+ total: moneyMinor('EUR')('total_minor').notNull(), // Money<'EUR'>
731
+ refund: moneyJson()('refund'), // Money in any currency, or null
732
+ });
733
+
734
+ const client = new Database(':memory:');
735
+ client.defaultSafeIntegers(true); // integers as bigint, exact beyond 2^53
736
+ client.exec(
737
+ 'CREATE TABLE orders (id INTEGER PRIMARY KEY, total_minor INTEGER NOT NULL, refund TEXT)',
738
+ );
739
+ const db = drizzle({ client });
740
+
741
+ const huge = money('92233720368547758.07', 'EUR');
742
+ const dollar = money('1', 'USD');
743
+ db.insert(orders).values({ id: 1, total: huge }).run();
744
+ db.select().from(orders).get()?.total; // → 92233720368547758.07 EUR
745
+ db.insert(orders).values({ id: 2, total: dollar }).run(); // compile error, and throws
746
+ ```
747
+
748
+ The columns check what the types can't: writing another currency, a value at a scale other than the currency's, or a value a `numeric(p, s)` column would round or clamp throws. Reading rejects driver values that may have lost digits: a BIGINT beyond 2^53 that the driver returned as a number (use bigint or string mode: `defaultSafeIntegers(true)` in better-sqlite3, `supportBigNumbers` with `bigNumberStrings` in mysql2; node-postgres returns strings already), a DECIMAL returned as a number, and `NaN` or `Infinity`. `drizzle-orm` is an optional peer dependency, imported only by these three entries.
749
+
750
+ - **Currency per row:** a Drizzle column can't read a sibling column, so there is no "minor units plus a currency column" type. Use `moneyJson()`, which stores the currency in each value, or select both columns and build the Money with `fromMinor(row.amountMinor, row.currency)`.
751
+ - **Relational queries** (`db.query...findMany({ with: ... })`) return nested rows through JSON, where Postgres and MySQL turn NUMERIC and BIGINT values into JSON numbers. A nested `moneyDecimal` column then throws, and so does a nested `moneyMinor` beyond 2^53 minor units, rather than return a rounded amount. Top-level columns are not affected. For nested money, use `moneyJson`, or a join with `select`.
752
+ - **SQLite decimals are TEXT**, so in SQL they compare and sort as text (`'10.00' < '9.00'`), and `sum()` over them is a REAL. Use `moneyMinor` for columns you sort, compare or add up in SQL. For the same reason `/drizzle/sqlite`'s `moneyDecimal` takes no column size.
753
+
754
+ ---
755
+
756
+ ## Currency data: source, generation, verification
757
+
758
+ | file | source |
759
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
760
+ | `data/iso4217-list-one.xml` | ISO 4217 List One from SIX (the maintenance agency), published **2026-09-17**. It has 178 codes with minor units, plus N.A. codes such as XAU and XDR, which are excluded as non-monetary. |
761
+ | `data/stripe.json` | https://docs.stripe.com/currencies, retrieved 2026-11-07, with verbatim quotes for every special case |
762
+ | `data/adyen.json` | https://docs.adyen.com/development-resources/currency-codes, retrieved 2026-11-07 |
763
+ | `data/checkout.json` | https://www.checkout.com/docs/payments/accept-payments/format-the-amount-value, the currency-codes page and the API reference, retrieved 2026-11-07, with verbatim quotes |
764
+ | `data/paypal.json` | https://developer.paypal.com/api/rest/reference/currency-codes/, the other currency-codes page and the Orders v2 schema, retrieved 2026-11-07, with verbatim quotes |
765
+ | `data/braintree.json` | https://developer.paypal.com/braintree/docs/reference/general/currencies, the transaction.sale reference, the validation-error list and the GraphQL `Amount` type, retrieved 2026-11-07, with verbatim quotes |
766
+ | `data/klarna.json` | https://docs.klarna.com/acquirer/klarna/get-started/integration-resilience/data-types/, the purchase-countries page and the Payments API reference, retrieved 2026-11-07, with verbatim quotes |
767
+ | `data/cash-rounding.json` | Central-bank, statute and government pages per rule. Each entry has a `source` URL and a `verified` flag. |
768
+ | `data/cldr-cash.json` | CLDR release 49 `supplementalData.xml` cash digits and rounding, the second source for the cash rules (`scripts/fetch-cldr-cash.mjs` refreshes it) |
769
+
770
+ - `pnpm gen` (`scripts/gen-currencies.mjs`) turns these into `src/generated/*.ts`.
771
+ - `pnpm gen:check` fails if the generated files are stale. It also runs inside the test suite.
772
+ - `pnpm gen:fetch` downloads the live SIX list and diffs it against the snapshot (no differences on 2026-11-07).
773
+ - Tests assert every ISO exponent against the published list of non-2 exponents:
774
+ - 0: BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF
775
+ - 3: BHD IQD JOD KWD LYD OMR TND
776
+ - 4: CLF UYW
777
+ - Tests also check the Adyen differences, the consistency of every Stripe rule, and that `format()` shows ISO digits for every code on every engine.
778
+
779
+ A change to an ISO exponent in this data is a breaking change, because it changes what stored minor units mean (see [Decisions](#decisions) and [Databases](#databases)).
780
+
781
+ Recent ISO changes that are already encoded:
782
+
783
+ - Added: ZWG, SLE, VES/VED, XCG (which replaced ANG in 2025-03) and XAD.
784
+ - Withdrawn: BGN (Bulgaria moved to the euro on 2026-01-01), HRK, ZWL, SLL, CUC.
785
+ - dinero.js 2.0.2 still ships BGN.
786
+
787
+ Processor differences encoded:
788
+
789
+ | | ISO | Stripe | Adyen | Checkout.com | PayPal | Braintree | Klarna |
790
+ | ------------------- | --------- | ---------------------------------------------------------------------------- | ------------- | -------------------------------------------------- | -------------------------------------- | ------------------------------------------ | ------------------------------------ |
791
+ | ISK | 0 | 2, amount must be a multiple of 100 | 2 | 0 | n/a | 0 | n/a |
792
+ | UGX | 0 | 2, amount must be a multiple of 100 (confirmed, see [Decisions](#decisions)) | 0 | 0 | n/a | 0 | n/a |
793
+ | CLP | 0 | 0 | **2** | **2**, payments must be a multiple of 100 | n/a | 0 | n/a |
794
+ | IDR | 2 | 2 | **0** | 2 | n/a | 2 | n/a |
795
+ | CVE | 2 | 2 | **0** | 2 | n/a | 2 | n/a |
796
+ | MGA | 2 | **0** | not supported | 2 | n/a | n/a | n/a |
797
+ | HUF, TWD | 2 | 2 for charges; payouts must be a multiple of 100 | 2 | 2 | **0**, no decimals allowed | 2 | HUF 2 (TWD n/a) |
798
+ | BHD JOD KWD OMR TND | 3 | 3, last digit must be 0 (UAE accounts only) | 3 | 3, last digit must be 0 | n/a | n/a | n/a |
799
+ | IQD, LYD | 3 | not supported | 3 | 3, last digit must be 0 | n/a | n/a | n/a |
800
+ | RUB | 2 | 2 | 2 | n/a | 2 (one of its two currency pages only) | 2 | n/a |
801
+ | LAK | 2 | 2 | 2 | 2 | n/a | **0** | n/a |
802
+ | HRK LTL ZMK ZWD | withdrawn | n/a | n/a | n/a | n/a | still listed (2) | n/a |
803
+ | amount format | | integer | integer | integer | decimal string | decimal string, no sign, no currency field | integer; negative for discount lines |
804
+ | ANG, STD | withdrawn | still listed (2) | n/a | n/a | n/a | STD still listed (2) | n/a |
805
+ | SLL | withdrawn | n/a | n/a | still listed (2) | n/a | still listed (2) | n/a |
806
+ | CNH, VEF | not ISO | n/a | listed (2) | n/a | n/a | n/a | n/a |
807
+ | XDR | N.A. | n/a | n/a | listed (2) | n/a | n/a | n/a |
808
+ | amount limits | | | | docs: > 0, ≤ 9 digits; API schema: 0 to 9999999999 | ≤ 15 digits before the point | > 0; maximum set by the processor | order ≥ 0; order line ≤ 200000000 |
809
+
810
+ ---
811
+
812
+ ## What is proved
813
+
814
+ `proofs/` holds [Lean 4](https://lean-lang.org) proofs (with Mathlib) of two kinds:
815
+
816
+ - **Proved code.** The bigint rounding kernels are written once in Lean, as values of a small expression language (`proofs/Ducat/Kernel.lean`). The theorems are about those values, and `pnpm gen:kernel` prints the same values as TypeScript into `src/generated/kernel.ts`. They print as one function, `divideAndRoundBigint`: the steps every mode shares, then a `switch` with each mode's proved decision (`kernel_shared` states that the shared steps really are the same). The TypeScript `divideAndRoundBigint` in `src/rounding.ts` calls it; it is the bigint path of every rounded operation. What you have to trust is the printer (about 60 lines of Lean) and the fact that JS `bigint` `+ - * /` agree with Lean's `Int` (`/` truncates, as `Int.tdiv` does).
817
+ - **Proved models.** Allocation, instalments, chunks, schedules, cash rounding, tax and scale changes are proved about Lean models of what the TypeScript does, not about the TypeScript itself. The allocation, instalment, chunk, schedule and increment models are compiled into an oracle that prints generated cases to `test/fixtures/<model>.oracle.json`. The tests require the TypeScript to match each one exactly (`allocate` on both its number and bigint paths). The TypeScript for tax, scale and conversion is tested, not proved (see below).
818
+
819
+ `pnpm gen:check`, which runs in every check without Lean, fails in two cases. One is any unfinished proof (`sorry`, `admit`, a new `axiom`, `native_decide`). The other is a generated file that was edited by hand or not regenerated after `proofs/` changed: each generated file carries a hash of the Lean sources it came from. `pnpm gen:kernel:check` needs Lean, and CI runs it. It rebuilds the proofs, checks that the headline theorems depend only on Lean's standard axioms (`propext`, `Classical.choice`, `Quot.sound`), and fails if either generated file differs from what the proofs print.
820
+
821
+ ### Rounding kernels: proved code (`proofs/Ducat/Rounding.lean`)
822
+
823
+ `Rounds m p d q` is the specification: "`q` is `p / d` rounded with mode `m`", written as integer inequalities on the error `p − d·q`. For example, `floor` means `q ≤ p/d < q + 1`. The half modes mean `|p/d − q| ≤ ½`, with the tie going where the mode says. `unnecessary` means the division is exact. For a divisor `d > 0`:
824
+
825
+ - `kernel_sound`: whatever the generated kernel returns is correctly rounded.
826
+ - `rounds_unique`: the specification has at most one answer, so "correctly rounded" means "the right answer".
827
+ - `kernel_fails_iff`: the kernel throws exactly for `unnecessary` on an inexact division, and never otherwise.
828
+ - `kernel_correct`: so whenever a correctly rounded answer exists, the kernel returns it.
829
+
830
+ ### Allocation: proved model (`proofs/Ducat/Allocate.lean`)
831
+
832
+ The model takes an amount `x ≥ 0` and non-negative weights with a positive total `T`. Each bucket gets `⌊x·w/T⌋`, and the leftover units go to the non-zero buckets in the order the strategy fixes.
833
+
834
+ - `alloc_sum`: the parts add up to `x`, for every remainder strategy.
835
+ - `alloc_zero`: a bucket with zero weight gets zero.
836
+ - `alloc_near_largest`: with `'largest'` (Hamilton's method), each part is less than one unit from its exact share `x·w/T`.
837
+ - `alloc_near`: with `'first'` and `'last'`, each part is at most one unit from its exact share. Exactly one unit can happen: 0.02 split `[2, 1, 1]` with `'first'` gives 0.02, 0, 0.
838
+ - `alloc_scale`: multiplying every weight by the same positive number changes nothing, so decimal weights such as `'33.3'`, scaled to integers, are allocated by their exact ratios.
839
+ - Negative amounts are the mirror image of positive ones. The model defines them that way, as `allocate` does, so `allocS_neg` only confirms the definition.
840
+
841
+ ### Instalments, chunks and schedules: proved models (`proofs/Ducat/Schedule.lean`)
842
+
843
+ For a non-negative total; negative totals are the mirror image, as in `allocate`.
844
+
845
+ - `inst_sum`: the instalments add up to the total, for every remainder mode.
846
+ - `inst_multiple`: with a `unit`, every instalment except the one that takes the leftover is a whole number of units. `inst_near`: with `'first'`/`'last'`, those instalments are the regular amount or one unit more (both hold by how the model builds the instalments), and the leftover is less than one unit.
847
+ - `chunk_sum`, `chunk_le`, `chunk_full`, `chunk_last_pos`: the chunks add up to the total, none exceeds `max`, every chunk but the last is exactly `max`, and the last is positive (a zero total gives a single zero).
848
+ - `sched_sum`: whenever `schedule` succeeds, its tranches add up to the total. This holds by construction, since the rest tranche takes whatever is left, as in the TypeScript. The oracle cases include each way it must fail: two rest tranches, percentages over 100, tranches exceeding the total, and a leftover with no rest tranche.
849
+ - `sched_shares_near`: the percentage shares that `schedule` computes, before capping, are each less than one unit from their exact share. That they reach the right tranches, with caps applied, is checked by the oracle cases, not proved.
850
+
851
+ ### Cash rounding: proved model (`proofs/Ducat/Cash.lean`)
852
+
853
+ The model of `roundToIncrement` (and so of `cashRound`) is one kernel division times the increment. The TypeScript also aligns scales and, for safe integers, takes the number path. The oracle cases compare it with the model on both paths.
854
+
855
+ - `increment_multiple`: the result is a whole number of increments.
856
+ - `increment_near`, `increment_half`: it is less than one increment from the amount in every mode, and at most half an increment in the half modes.
857
+ - `increment_exact`, `increment_idem`: an amount that already is a multiple comes back unchanged in every mode, so rounding twice is rounding once.
858
+
859
+ ### Tax, scale and conversion: proved model (`proofs/Ducat/Tax.lean`)
860
+
861
+ These are stated over the proved kernel (`divRound`):
862
+
863
+ - `addTax_bound`: the modelled tax is the exact tax, correctly rounded once. `extractTax_bound`: the modelled net is `gross / (1 + rate)` correctly rounded, and the tax carries the same error with the opposite sign. (`addTax_gross` and `extractTax_gross`, net + tax = gross, hold by construction, as in the TypeScript.)
864
+ - `rounds_half_bound`, `rounds_directed_bound`: a correctly rounded result is within half a unit of the exact value in the half modes, and under one unit in every mode.
865
+ - `toScale_roundtrip`: raising the scale and lowering it again gives back the original value in every mode, and `unnecessary` does not throw.
866
+ - `divRound_mul_left`: multiplying the numerator and denominator by the same positive number does not change the result. So reducing a rate by its gcd, or composing exact rates, cannot change a rounded result, provided it is computed as a single division of the exact product.
867
+
868
+ ### Tested, not proved
869
+
870
+ - **The printer** that turns the Lean kernels into TypeScript, and the `divideAndRoundBigint` dispatch in `src/rounding.ts`. The generated kernels are compared with an independent bigint oracle (`test/kernel.test.ts`).
871
+ - **The number fast path** of `divideAndRound`. It computes the exact truncated quotient and remainder itself, then calls `roundTruncatedQuotient`: the same Lean terms as the proved switch, printed over `number`. That is exact while every value is a safe integer, which is the caller's obligation. It is compared with the generated kernels for every `p ∈ [−300, 300]`, `d ∈ [1, 40]` in every mode, and with fast-check over the full safe-integer range, on ties, and on bigints up to 10^30.
872
+ - **The TypeScript `allocate`**, number and bigint paths. It is compared with the 1,500-case oracle fixture, plus property tests against a separate bigint largest-remainder allocator (`test/oracle.test.ts`).
873
+ - **The TypeScript tax, conversion and `multiplyByRatio` code:**
874
+ - that each rounds with exactly one kernel call on the exact product;
875
+ - the scale shifts;
876
+ - that every call site passes a divisor `d > 0`, with the sign moved to the numerator.
877
+
878
+ These are covered by unit vectors and by property tests against decimal.js at 200 significant digits (`test/oracle.test.ts`).
879
+
880
+ - **The TypeScript `instalments`, `chunk`, `schedule` and `roundToIncrement`**, compared with their models' oracle fixtures, on both the number and bigint paths, including the cases where `schedule` must throw.
881
+ - `split` without a unit is `allocate` with equal weights. Decimal parsing, `Intl` formatting and parsing, and the processor and database adapters are tested, not proved.
882
+
883
+ ## Testing your code
884
+
885
+ `@ducatjs/money/testing` makes Money pleasant to test with Vitest and Jest. One import in a setup file is enough:
886
+
887
+ <!-- doctest: skip (a setup-file import, shown for its setup) -->
888
+
889
+ ```ts
890
+ import '@ducatjs/money/testing/vitest'; // or '@ducatjs/money/testing/jest', or installMoneyMatchers(expect)
891
+ ```
892
+
893
+ - **Value equality.** `toEqual`, `toStrictEqual` and nested structures compare Money by value: 1.0 EUR at scale 1 equals 1.00 EUR, and number and bigint minor units compare as equal.
894
+ - **Readable output.** Snapshots and failure diffs show `12.34 EUR`, and `console.log` shows `Money(12.34 EUR)`.
895
+ - **Matchers**, whose failures read "expected 12.35 EUR to be 12.34 EUR":
896
+ - `toBeMoney(amount, currency)` and `toHaveCurrency(code)`;
897
+ - `toBeGreaterThanMoney`, `toBeGreaterThanOrEqualMoney`, `toBeLessThanMoney` and `toBeLessThanOrEqualMoney`;
898
+ - `toSumTo(total)`, which checks that a list of parts sums exactly to a total, for allocation and instalment tests.
899
+ - **Arbitraries** in `@ducatjs/money/testing/arbitraries`, for fast-check property tests. `arbMoney({ currency?, min?, max?, scale? })` and `arbCurrency({ exponents?, all? })` cover minor units beyond 2^53, currencies with exponents 0, 3 and 4, and scales above the currency's exponent.
900
+
901
+ <!-- doctest: skip (needs a test runner) -->
902
+
903
+ ```ts
904
+ import fc from 'fast-check';
905
+ import { arbMoney } from '@ducatjs/money/testing/arbitraries';
906
+
907
+ test('a split always sums to its total', () => {
908
+ fc.assert(fc.property(arbMoney(), (total) => expect(total.split(3)).toSumTo(total)));
909
+ });
910
+ ```
911
+
912
+ Vitest's types come with the import. For Jest, add the matchers to Jest's `Matchers` in a declaration file, in the form your Jest types use. ducat doesn't do this itself: augmenting Jest's global namespace clashes with Vitest's `Assertion` type (TS2320) in a project that has both.
913
+
914
+ With `@types/jest` (the global `jest` namespace):
915
+
916
+ <!-- doctest: skip (a declaration file for Jest projects; type-checked in test/jest-types) -->
917
+
918
+ ```ts
919
+ import type { MoneyMatchers } from '@ducatjs/money/testing';
920
+ declare global {
921
+ namespace jest {
922
+ interface Matchers<R> extends MoneyMatchers<R> {}
923
+ }
924
+ }
925
+ ```
926
+
927
+ With `@jest/globals` (Jest's `expect` package, which must be resolvable from your project, so under pnpm add `expect` as a dev dependency):
928
+
929
+ <!-- doctest: skip (a declaration file for Jest projects) -->
930
+
931
+ ```ts
932
+ import type { MoneyMatchers } from '@ducatjs/money/testing';
933
+ declare module 'expect' {
934
+ interface Matchers<R> extends MoneyMatchers<R> {}
935
+ }
936
+ ```
937
+
938
+ Under `bun test`, `installMoneyMatchers(expect)` installs the matchers and the inspect hook. Bun supports neither custom equality testers nor snapshot serializers yet, so `toEqual` compares Money field by field there and snapshots show the fields; use `toBeMoney` instead.
939
+
940
+ Value equality relies on `isMoney`, so a Money from a second installed copy of ducat is compared field by field, like any other object.
941
+
942
+ Vitest and fast-check are optional peer dependencies, used only by these entries. Nothing in the core changes, so production bundles are unaffected.
943
+
944
+ ## Tests
945
+
946
+ `pnpm test` runs the vitest suite.
947
+
948
+ - Unit tests use real-world vectors:
949
+ - the `java.math.RoundingMode` table for all modes
950
+ - UK VAT 1/6 extraction, German 19% and 7% VAT
951
+ - the HMRC 9.99 case, the classic `2.675` float trap
952
+ - the allocation splits from Fowler and dinero
953
+ - Stripe and Adyen doc examples, the statutory cash-rounding thresholds for SE, NO, DK, HU and CA
954
+ - Intl output for de, fr, ja, ar and en-IN
955
+ - fast-check property tests cover:
956
+ - `divideAndRound` against an independent floor-based bigint oracle over the full safe range, including float-quotient edge cases
957
+ - rounding-mode invariants (floor ≤ x ≤ ceil, half modes within ½)
958
+ - add associativity, commutativity and identity, and agreement with bigint arithmetic
959
+ - the number-iff-safe representation invariant
960
+ - decimal, JSON and minor-unit round trips
961
+ - multiply against exact rationals
962
+ - allocation: sums are exact, each part is within one unit of its ideal share, results are deterministic, and largest-remainder order holds
963
+ - instalment sums, tax breakdowns that add up
964
+ - `parse(format(x))` across 14 locales
965
+ - the Intl hand-off being bit-identical
966
+ - `pnpm typecheck` compiles `test/types.ts`. Its `@ts-expect-error` lines prove that currency mismatch, readonly fields and unknown rounding modes are compile errors.
967
+ - `pnpm test:runtimes` runs:
968
+ - the **full suite on Node 24, Bun 1.4 (via `bun test`) and Deno 2.9**: all pass
969
+ - a bundled smoke test on Node 24, Node 18, Bun, Deno and the **SpiderMonkey and JavaScriptCore shells** (installed with jsvu): all pass. On Node 18, `format()` of amounts above 2^53 is lossy (no Intl string input).
970
+ - `pnpm test:db` runs the SQL helpers against real databases: SQLite through Node's built-in `node:sqlite`, and Postgres and MySQL when `DUCAT_PG_URL` and `DUCAT_MYSQL_URL` point at a server (CI runs both as service containers, and sets `DUCAT_REQUIRE_DB=1` so a missing URL fails instead of skipping). It covers BIGINT minor units beyond 2^53 and at the BIGINT limits, `NUMERIC(19,4)` and `NUMERIC(38,4)` columns, Money as JSON/JSONB, values a column rounded silently (rejected on read), NaN and Infinity, and driver defaults that lose precision.
971
+ - `pnpm test:browser` runs a smoke and property subset in Chromium, Firefox and WebKit through Playwright, including `parse(format())` round trips in 18 locales; `DUCAT_BROWSER_RUNS` sets the cases per property (default 300) and `DUCAT_SEED` the seed.
972
+ - `pnpm test:mutation` runs Stryker over `src/` with property counts capped; CI runs it nightly and keeps the report.
973
+
974
+ ## Benchmarks
975
+
976
+ - Clean-machine numbers against the other money and fixed-point libraries are in [How it compares](#how-it-compares) and
977
+ [docs/COMPARISON.md](docs/COMPARISON.md), generated from [compare/](compare) on one quiet AMD EPYC Genoa vCPU under
978
+ Node, Bun and Deno.
979
+ - CodSpeed measures every pull request by instruction count, so speed regressions show up even on noisy CI machines.
980
+ - The benchmarks in `bench/` are for quick local comparisons; on a shared machine their run-to-run variation is often
981
+ ±20–30%.
982
+ - Bundle size (esbuild, minified + gzip; `pnpm size` checks every entry point and snippet against `scripts/size-budgets.json`):
983
+ - `money('1.00', 'EUR').add(…).multiply('1.075', 'halfEven').toDecimal()`: **4.0 KiB**, of which about 0.55 KiB is the ISO table
984
+ - the core entry point: 4.9 KiB; everything (`@ducatjs/money/all`): 27.6 KiB
985
+ - the same snippet with dinero.js (EUR only): 1.8 KiB
986
+
987
+ ---
988
+
989
+ ## Decisions
990
+
991
+ - **JSON is a decimal string plus the currency, never minor units**: `{"amount":"12.34","currency":"EUR"}`. Minor units only mean something to a reader who knows the exponent, and the exponent isn't fixed:
992
+ - Processors disagree with ISO. Stripe sends ISK and UGX as two-decimal; Adyen has its own exceptions.
993
+ - ISO exponents change over time.
994
+ - Generic consumers (other languages, `NUMERIC` and `Decimal` columns, logs) expect a decimal amount.
995
+
996
+ Minor units remain available, but only through explicit, named conversions, where the owner of the exponent is known: `toMinor`, `toStripeAmount`, `toAdyenAmount`, and the BIGINT column helpers.
997
+
998
+ Evidence from a comparison of the npm money libraries (2026-11): by default, `JSON.stringify` of 12.34 EUR gives minor units in dinero.js 2 (`{"amount":1234,"currency":{"code":"EUR","base":10,"exponent":2},"scale":2}`; with the bigint calculator it throws "Do not know how to serialize a BigInt"), dinero.js 1 (`{"amount":1234,"currency":"EUR","precision":2}`), js-money, ts-money and @screeny05/ts-money (`{"amount":1234,"currency":"EUR"}`, with no exponent, so the reader must know it) and monetra (`{"amount":"1234","currency":"EUR","precision":2}`). The decimal libraries and money-math give `"12.34"`, and bigint-money gives `["12.34","EUR"]`. Related dinero.js issues: #22 (`JSON.stringify` gave `{}`, then `toJSON` was debated), #636 (deserialising needs the full currency object from a dynamic code), and #771 (`allocate` hangs on a value deserialised at scale 19).
999
+
1000
+ - **The package is `@ducatjs/money`** (the brand and repo stay ducat). npm's name-similarity rule blocks the unscoped `ducat`, and the npm org `ducat` was taken, so it lives under the `ducatjs` org; one package, with opt-in subpath modules. The ducat was the Venetian gold trade coin, used across Europe for about 600 years.
1001
+ - **Methods on `Money`**, because free functions alone are awkward to use: `price.add(fee).multiply('1.075').split(3)`. The same operations as plain functions are in `@ducatjs/money/fn`, not in the root entry, so there is one way to write each in ordinary code. Operands are `Money` only, never bare numbers (monetra's `add(5)` means 5 minor units, a trap we avoid).
1002
+ - **The core is small; everything else is an opt-in module that installs methods.** Methods on one class pull every feature into every bundle, so `Money` carries only the core, and `@ducatjs/money/allocate`, `/tax`, `/format`… add their methods when imported (types through `declare module` augmentation, as dayjs plugins do). The cost is that installed methods are global at run time. `@ducatjs/money/fn` offers the same functions without touching the prototype, for function-level tree-shaking.
1003
+ - **Correctness and features come before bundle size.** The core keeps the currency table, the comparison methods, bigint support and exact string parsing; everything else is an opt-in module, and size budgets in CI stop either from growing.
1004
+ - **Default rounding is `'halfExpand'`** (ties away from zero) for arithmetic, the most common default. It is the default of decimal.js, Prisma.Decimal, big.js, bignumber.js and `Intl.NumberFormat`, and what currency.js, js-money and ts-money intend with `Math.round` (which differs for negative ties). The minority defaults: half-even (dinero.js 1, bigint-money), throw (monetra), truncate (dinero.js 2 `transformScale`). Parsing (`money('1.005','EUR')`, `fromDecimal`) and processor amounts (`toStripeAmount`, `toAdyen`) still throw unless a mode is passed, as ts-money and monetra do for input. The default is exported as `DEFAULT_ROUNDING`.
1005
+ - **Rounding policies are explicit values.** A policy (scale, increment and mode) is passed to each operation that rounds, the way a rounding mode is. It is never attached to an amount or installed as a global default: a stored or global policy would make the same expression round differently depending on where a value came from or what ran earlier, which is the kind of action at a distance a money library should rule out.
1006
+ - **A bundled ISO 4217 exponent change is breaking:** it is listed under **Changed**, and ships only in a minor version before 1.0 and a major version from 1.0. Minor units stored without a scale are read at the installed exponent, so such a change silently changes what stored rows mean.
1007
+ - **Minor units are always a power of ten.** The ariary (MGA) and the ouguiya (MRU) subdivide into five (iraimbilanja and khoums), but ISO 4217 lists both with exponent 2, and so do Checkout.com and dinero.js's own currency table, as well as Adyen and CLDR for MRU. Stripe and CLDR use no decimals at all for MGA, whose fifths aren't used in practice. Nobody uses a base of five: a fifth is 0.20 exactly at exponent 2, and the coin step belongs in the cash rules (CLDR has MRU at 0.20). A base other than ten would only matter for pre-decimal money such as £sd (20 shillings of 12 pence). Supporting it would put a second path into every scaling, parsing, formatting and `toDecimal`, on the hot path of every currency, for a case no processor or database stores. For such records, hold the smallest unit (pence) as a `defineCurrency` code with exponent 0, and split it into pounds, shillings and pence for display.
1008
+ - **Stripe UGX is ×100.** Stripe's docs: "UGX transitioned to a zero-decimal currency, but backwards compatibility requires you to represent it as a two-decimal value, where the decimal amount is always 00. For example, to charge 5 UGX, provide an amount value of 500. You can't charge fractions of UGX. For invoices where the amount is fractional after prorations, coupons, or taxes, Stripe automatically rounds that amount to the nearest number evenly divisible by 100." That is what `toStripeAmount` encodes. An amount with fractional UGX throws unless a rounding mode is passed.
1009
+ - **ESM only; Node ≥ 24.** Every supported Node can `require()` an ES module, so CommonJS callers still work, and a single build avoids the dual-package hazard: with separate CJS and ESM copies, `isMoney()` and `instanceof Money` fail across the two. Node 24 also formats exactly beyond 2^53 (Intl.NumberFormat v3). Browsers set the language floor, not Node: output targets ES2023.
1010
+ - **Withdrawn and non-ISO codes** (ANG, STD, BGN, HRK, ZWL, SLL, CUC, CNH, VEF…) stay out of the core registry, which follows ISO 4217 as currently published. `@ducatjs/money/historic` has an opt-in table of them, with withdrawal dates, that registers nothing until asked. Each takes the exponent ISO 4217 gave it while it was current, not CLDR's display digits, which differ for some (CLDR has SLL, STD, MRO and ZMK at 0, where ISO had 2), because stored minor units must mean what they meant when they were written.
1011
+ - **Processors:** Stripe, Adyen, Checkout.com, PayPal, Braintree and Klarna, each an opt-in entry point (`@ducatjs/money/<processor>`).
1012
+ - **Test matrix:** real Postgres, MySQL and SQLite for the DB helpers; real browsers (Chromium, Firefox, WebKit via Playwright) alongside Node, Bun, Deno and the engine shells.
1013
+
1014
+ ## What's missing (honest list)
1015
+
1016
+ - **Stripe three-decimal rule.** The "last digit must be 0" rule comes from a June 2024 archived copy of Stripe's page. The live page no longer has that section (it is marked unverified in `data/stripe.json`).
1017
+ - **Cash-rounding rules.** Some are backed only by secondary sources (`verified: false`): CHF, DKK, AUD, NZD, ILS, MYR, SGD, FI/IE/IT/SK/LT EUR, GYD, MRU, TZS and several others. MUR has no rule: CLDR says whole rupees, but the Bank of Mauritius doesn't list its coins, and secondary sources say 5 cent coins still circulate. `includeUnverified: false` turns them off.
1018
+ - Tie rules are `halfExpand` except Serbia's (ties down). Singapore's "round down in the customer's favour" practice and USD's state-by-state post-penny rules aren't modelled.
1019
+ - **Checkout.com currency list.** The currency-codes page calls itself the "major currencies we support"; `toCheckout` refuses anything not on it. Its amount limits (the docs say greater than zero and at most nine digits, the API schema allows 0 for card verification and up to ten digits) aren't enforced.
1020
+ - **PayPal.** Its two currency pages disagree: the REST reference page lists RUB and the other doesn't (`toPayPal` accepts RUB). The docs don't say whether a zero fraction such as `"100.00"` counts as a decimal for HUF, JPY or TWD, so `toPayPal` always sends whole numbers for them. BRL, CNY and MYR work for in-country accounts only, and that isn't checked. The schema allows a minus sign and the text says amounts are positive; the sign isn't checked either.
1021
+ - **Braintree.** The currencies page gives only the zero-decimal list, so every other currency is assumed to take its ISO decimals, and the withdrawn codes it still lists (HRK, LTL, SLL, STD, ZMK, ZWD) are assumed to take 2. The GraphQL `Amount` type allows three decimals and the REST reference says `x.xx`; `toBraintree` sends the currency's own decimals, which both accept. Which currencies an account can use depends on its setup, and that isn't checked.
1022
+ - **Klarna.** Amounts follow ISO 4217 minor units, as its docs say, but no Klarna page gives a HUF example, so HUF in fillér is unconfirmed. Each purchase country has one currency, and the country/currency pairing isn't checked. Only the Payments API schema was read; captures and refunds are assumed to use the same units. `toKlarna` returns a bare integer because Klarna sends the currency once per order (`purchase_currency`), not per amount. Klarna's `tax_rate` (a percentage with two implicit decimals, 2000 = 20%) has no helper.
1023
+ - **Processor coverage.** Stripe, Adyen, Checkout.com, PayPal (REST APIs), Braintree and Klarna are covered. There is no Adyen Terminal API (it uses major units). There is no per-account-country Stripe currency availability; the table is the union of all countries.
1024
+ - **Withdrawn and non-ISO codes.** `@ducatjs/money/historic` covers the 54 withdrawn codes whose ISO minor units are sourced, plus CNH. Codes withdrawn before about 1995 whose exponents aren't sourced (ARY, CSK, ZRN…) and the SDR (XDR, which ISO lists without minor units, though Checkout.com accepts it) still need `defineCurrency`. The exponents come from OpenJDK's ISO-derived currency data, because ISO's List Three of withdrawn codes doesn't carry minor units. It gives withdrawal months, not days, and no replacement codes.
1025
+ - **Formatting gaps:**
1026
+ - No pluggable formatter for engines without Intl.
1027
+ - `parse` is lenient about group sizes (it accepts `1,23,4.00` in en-US). It also doesn't check the currency symbol in the text against the `currency` argument.
1028
+ - **Not yet benchmarked:**
1029
+ - Browsers themselves; only their engines were tested via the jsvu shells.
1030
+ - Memory and GC pressure, which were reasoned about but not measured.
1031
+ - dinero.js's bigint calculator.
1032
+ - **No Money arrays or columnar ops** (e.g. summing a `Float64Array`/`BigInt64Array` of minor units).
1033
+ - **The ISO table is always bundled** (about 0.55 KiB gzipped, 1 KiB minified). Per-currency imports like dinero's would make the minimum bundle smaller.
1034
+ - **Minor-unit columns can't round-trip values with a non-ISO scale.** `toMinorColumns` refuses them by design. There is no `scale` column helper.
1035
+ - **No docs site yet.**