@ducatjs/money 0.1.0 → 0.2.1

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 (253) hide show
  1. package/NOTICE +73 -0
  2. package/README.md +479 -62
  3. package/data/cash-rounding.json +123 -2
  4. package/data/cldr-cash.json +101 -0
  5. package/data/historic.json +78 -0
  6. package/data/iso4217-list-three.xml +1185 -0
  7. package/dist/adyen-fx.d.ts.map +1 -1
  8. package/dist/adyen-fx.js +22 -4
  9. package/dist/adyen-fx.js.map +1 -1
  10. package/dist/boc.d.ts +31 -0
  11. package/dist/boc.d.ts.map +1 -0
  12. package/dist/boc.js +114 -0
  13. package/dist/boc.js.map +1 -0
  14. package/dist/boe.d.ts +33 -0
  15. package/dist/boe.d.ts.map +1 -0
  16. package/dist/boe.js +114 -0
  17. package/dist/boe.js.map +1 -0
  18. package/dist/cash.d.ts +30 -1
  19. package/dist/cash.d.ts.map +1 -1
  20. package/dist/cash.js +50 -2
  21. package/dist/cash.js.map +1 -1
  22. package/dist/convert.d.ts +10 -9
  23. package/dist/convert.d.ts.map +1 -1
  24. package/dist/convert.js +13 -3
  25. package/dist/convert.js.map +1 -1
  26. package/dist/drizzle/mysql.d.ts +105 -0
  27. package/dist/drizzle/mysql.d.ts.map +1 -0
  28. package/dist/drizzle/mysql.js +60 -0
  29. package/dist/drizzle/mysql.js.map +1 -0
  30. package/dist/drizzle/pg.d.ts +103 -0
  31. package/dist/drizzle/pg.d.ts.map +1 -0
  32. package/dist/drizzle/pg.js +60 -0
  33. package/dist/drizzle/pg.js.map +1 -0
  34. package/dist/drizzle/sqlite.d.ts +102 -0
  35. package/dist/drizzle/sqlite.d.ts.map +1 -0
  36. package/dist/drizzle/sqlite.js +59 -0
  37. package/dist/drizzle/sqlite.js.map +1 -0
  38. package/dist/ecb.d.ts +37 -2
  39. package/dist/ecb.d.ts.map +1 -1
  40. package/dist/ecb.js +148 -10
  41. package/dist/ecb.js.map +1 -1
  42. package/dist/fed.d.ts +35 -0
  43. package/dist/fed.d.ts.map +1 -0
  44. package/dist/fed.js +134 -0
  45. package/dist/fed.js.map +1 -0
  46. package/dist/fetch.d.ts +45 -0
  47. package/dist/fetch.d.ts.map +1 -1
  48. package/dist/fetch.js +167 -24
  49. package/dist/fetch.js.map +1 -1
  50. package/dist/fn.d.ts +8 -1
  51. package/dist/fn.d.ts.map +1 -1
  52. package/dist/fn.js +8 -1
  53. package/dist/fn.js.map +1 -1
  54. package/dist/generated/cash.d.ts +2 -0
  55. package/dist/generated/cash.d.ts.map +1 -1
  56. package/dist/generated/cash.js +101 -0
  57. package/dist/generated/cash.js.map +1 -1
  58. package/dist/generated/historic.d.ts +337 -0
  59. package/dist/generated/historic.d.ts.map +1 -0
  60. package/dist/generated/historic.js +66 -0
  61. package/dist/generated/historic.js.map +1 -0
  62. package/dist/historic.d.ts +40 -0
  63. package/dist/historic.d.ts.map +1 -0
  64. package/dist/historic.js +43 -0
  65. package/dist/historic.js.map +1 -0
  66. package/dist/history.d.ts +96 -0
  67. package/dist/history.d.ts.map +1 -0
  68. package/dist/history.js +213 -0
  69. package/dist/history.js.map +1 -0
  70. package/dist/hmrc.d.ts +28 -0
  71. package/dist/hmrc.d.ts.map +1 -0
  72. package/dist/hmrc.js +108 -0
  73. package/dist/hmrc.js.map +1 -0
  74. package/dist/index.d.ts +1 -1
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/kysely.d.ts +120 -0
  79. package/dist/kysely.d.ts.map +1 -0
  80. package/dist/kysely.js +309 -0
  81. package/dist/kysely.js.map +1 -0
  82. package/dist/money.d.ts +93 -9
  83. package/dist/money.d.ts.map +1 -1
  84. package/dist/money.js +135 -9
  85. package/dist/money.js.map +1 -1
  86. package/dist/parse.d.ts +45 -1
  87. package/dist/parse.d.ts.map +1 -1
  88. package/dist/parse.js +290 -3
  89. package/dist/parse.js.map +1 -1
  90. package/dist/pg.d.ts +100 -0
  91. package/dist/pg.d.ts.map +1 -0
  92. package/dist/pg.js +343 -0
  93. package/dist/pg.js.map +1 -0
  94. package/dist/plugins/adyen.d.ts +1 -1
  95. package/dist/plugins/adyen.d.ts.map +1 -1
  96. package/dist/plugins/adyen.js.map +1 -1
  97. package/dist/plugins/all.d.ts +8 -1
  98. package/dist/plugins/all.d.ts.map +1 -1
  99. package/dist/plugins/all.js +8 -1
  100. package/dist/plugins/all.js.map +1 -1
  101. package/dist/plugins/allocate.d.ts +1 -1
  102. package/dist/plugins/allocate.d.ts.map +1 -1
  103. package/dist/plugins/allocate.js.map +1 -1
  104. package/dist/plugins/braintree.d.ts +1 -1
  105. package/dist/plugins/braintree.d.ts.map +1 -1
  106. package/dist/plugins/braintree.js.map +1 -1
  107. package/dist/plugins/cash.d.ts +1 -1
  108. package/dist/plugins/cash.d.ts.map +1 -1
  109. package/dist/plugins/cash.js.map +1 -1
  110. package/dist/plugins/checkout.d.ts +1 -1
  111. package/dist/plugins/checkout.d.ts.map +1 -1
  112. package/dist/plugins/checkout.js.map +1 -1
  113. package/dist/plugins/convert.d.ts +5 -5
  114. package/dist/plugins/convert.d.ts.map +1 -1
  115. package/dist/plugins/convert.js.map +1 -1
  116. package/dist/plugins/exact.d.ts +1 -1
  117. package/dist/plugins/exact.d.ts.map +1 -1
  118. package/dist/plugins/exact.js.map +1 -1
  119. package/dist/plugins/fixed.d.ts +1 -1
  120. package/dist/plugins/fixed.d.ts.map +1 -1
  121. package/dist/plugins/fixed.js.map +1 -1
  122. package/dist/plugins/format.d.ts +1 -1
  123. package/dist/plugins/format.d.ts.map +1 -1
  124. package/dist/plugins/format.js.map +1 -1
  125. package/dist/plugins/klarna.d.ts +1 -1
  126. package/dist/plugins/klarna.d.ts.map +1 -1
  127. package/dist/plugins/klarna.js.map +1 -1
  128. package/dist/plugins/paypal.d.ts +1 -1
  129. package/dist/plugins/paypal.d.ts.map +1 -1
  130. package/dist/plugins/paypal.js +0 -4
  131. package/dist/plugins/paypal.js.map +1 -1
  132. package/dist/plugins/prisma.d.ts +1 -1
  133. package/dist/plugins/prisma.d.ts.map +1 -1
  134. package/dist/plugins/prisma.js +0 -4
  135. package/dist/plugins/prisma.js.map +1 -1
  136. package/dist/plugins/stripe.d.ts +1 -1
  137. package/dist/plugins/stripe.d.ts.map +1 -1
  138. package/dist/plugins/stripe.js +0 -4
  139. package/dist/plugins/stripe.js.map +1 -1
  140. package/dist/plugins/tax.d.ts +6 -6
  141. package/dist/plugins/tax.d.ts.map +1 -1
  142. package/dist/plugins/tax.js.map +1 -1
  143. package/dist/policy.d.ts +34 -0
  144. package/dist/policy.d.ts.map +1 -0
  145. package/dist/policy.js +90 -0
  146. package/dist/policy.js.map +1 -0
  147. package/dist/prisma.d.ts +25 -14
  148. package/dist/prisma.d.ts.map +1 -1
  149. package/dist/prisma.js +29 -19
  150. package/dist/prisma.js.map +1 -1
  151. package/dist/provider.d.ts +21 -7
  152. package/dist/provider.d.ts.map +1 -1
  153. package/dist/provider.js +51 -11
  154. package/dist/provider.js.map +1 -1
  155. package/dist/rate-text.d.ts +38 -0
  156. package/dist/rate-text.d.ts.map +1 -0
  157. package/dist/rate-text.js +143 -0
  158. package/dist/rate-text.js.map +1 -0
  159. package/dist/rate.d.ts +61 -5
  160. package/dist/rate.d.ts.map +1 -1
  161. package/dist/rate.js +133 -33
  162. package/dist/rate.js.map +1 -1
  163. package/dist/rounding.js +1 -1
  164. package/dist/rounding.js.map +1 -1
  165. package/dist/schema.d.ts +43 -0
  166. package/dist/schema.d.ts.map +1 -0
  167. package/dist/schema.js +77 -0
  168. package/dist/schema.js.map +1 -0
  169. package/dist/sql.d.ts +54 -0
  170. package/dist/sql.d.ts.map +1 -0
  171. package/dist/sql.js +131 -0
  172. package/dist/sql.js.map +1 -0
  173. package/dist/tax.d.ts +13 -9
  174. package/dist/tax.d.ts.map +1 -1
  175. package/dist/tax.js +20 -4
  176. package/dist/tax.js.map +1 -1
  177. package/dist/testing/arbitraries.d.ts +43 -0
  178. package/dist/testing/arbitraries.d.ts.map +1 -0
  179. package/dist/testing/arbitraries.js +98 -0
  180. package/dist/testing/arbitraries.js.map +1 -0
  181. package/dist/testing/index.d.ts +67 -0
  182. package/dist/testing/index.d.ts.map +1 -0
  183. package/dist/testing/index.js +159 -0
  184. package/dist/testing/index.js.map +1 -0
  185. package/dist/testing/jest.d.ts +2 -0
  186. package/dist/testing/jest.d.ts.map +1 -0
  187. package/dist/testing/jest.js +15 -0
  188. package/dist/testing/jest.js.map +1 -0
  189. package/dist/testing/vitest.d.ts +7 -0
  190. package/dist/testing/vitest.d.ts.map +1 -0
  191. package/dist/testing/vitest.js +12 -0
  192. package/dist/testing/vitest.js.map +1 -0
  193. package/dist/valibot.d.ts +33 -0
  194. package/dist/valibot.d.ts.map +1 -0
  195. package/dist/valibot.js +61 -0
  196. package/dist/valibot.js.map +1 -0
  197. package/dist/zod.d.ts +36 -0
  198. package/dist/zod.d.ts.map +1 -0
  199. package/dist/zod.js +58 -0
  200. package/dist/zod.js.map +1 -0
  201. package/package.json +116 -5
  202. package/src/adyen-fx.ts +25 -4
  203. package/src/boc.ts +136 -0
  204. package/src/boe.ts +131 -0
  205. package/src/cash.ts +70 -2
  206. package/src/convert.ts +33 -15
  207. package/src/drizzle/mysql.ts +78 -0
  208. package/src/drizzle/pg.ts +78 -0
  209. package/src/drizzle/sqlite.ts +70 -0
  210. package/src/ecb.ts +179 -13
  211. package/src/fed.ts +146 -0
  212. package/src/fetch.ts +207 -28
  213. package/src/fn.ts +14 -0
  214. package/src/generated/cash.ts +103 -0
  215. package/src/generated/historic.ts +67 -0
  216. package/src/historic.ts +77 -0
  217. package/src/history.ts +333 -0
  218. package/src/hmrc.ts +125 -0
  219. package/src/index.ts +4 -0
  220. package/src/kysely.ts +588 -0
  221. package/src/money.ts +210 -12
  222. package/src/parse.ts +371 -4
  223. package/src/pg.ts +410 -0
  224. package/src/plugins/adyen.ts +3 -2
  225. package/src/plugins/all.ts +10 -0
  226. package/src/plugins/allocate.ts +3 -2
  227. package/src/plugins/braintree.ts +3 -2
  228. package/src/plugins/cash.ts +3 -2
  229. package/src/plugins/checkout.ts +3 -2
  230. package/src/plugins/convert.ts +6 -6
  231. package/src/plugins/exact.ts +3 -2
  232. package/src/plugins/fixed.ts +3 -2
  233. package/src/plugins/format.ts +3 -2
  234. package/src/plugins/klarna.ts +3 -2
  235. package/src/plugins/paypal.ts +3 -2
  236. package/src/plugins/prisma.ts +3 -2
  237. package/src/plugins/stripe.ts +3 -2
  238. package/src/plugins/tax.ts +11 -11
  239. package/src/policy.ts +124 -0
  240. package/src/prisma.ts +91 -10
  241. package/src/provider.ts +81 -16
  242. package/src/rate-text.ts +151 -0
  243. package/src/rate.ts +205 -36
  244. package/src/rounding.ts +1 -1
  245. package/src/schema.ts +119 -0
  246. package/src/sql.ts +178 -0
  247. package/src/tax.ts +40 -9
  248. package/src/testing/arbitraries.ts +134 -0
  249. package/src/testing/index.ts +247 -0
  250. package/src/testing/jest.ts +18 -0
  251. package/src/testing/vitest.ts +18 -0
  252. package/src/valibot.ts +87 -0
  253. package/src/zod.ts +78 -0
package/README.md CHANGED
@@ -37,37 +37,38 @@ The same 41 correctness vectors, the same benchmark and the same bundle snippet
37
37
  other libraries' bugs, the fixed-point libraries and the money libraries in other languages are in
38
38
  [docs/COMPARISON.md](docs/COMPARISON.md), regenerated from [compare/](compare) for each release.
39
39
 
40
- | library | correct (of 41) | wrong, silently | bundle (gzip) |
41
- | ---------------------------------- | --------------: | --------------: | ------------------: |
42
- | **ducat** | **41** | **0** | 3.8 kB |
43
- | dinero.js 2 (bigint calculator) | 34 | 0 | 1.8 kB |
44
- | monetra | 34 | 3 | 4.5 kB |
45
- | ts-money / js-money | 31 | 7 / 6 | 30.7 / 8.8 kB |
46
- | bigint-money | 30 | 0 | 1.5 kB |
47
- | dinero.js 2 (number calculator) | 30 | 4 | 1.8 kB |
48
- | decimal.js / big.js / bignumber.js | 22 | 1 | 12.6 / 2.9 / 8.5 kB |
49
- | currency.js | 16 | 6 | 1.3 kB |
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
50
 
51
51
  - **Correct everywhere:** ducat is the only library that passes every vector, including amounts beyond 2⁵³ minor
52
52
  units, float traps and negative allocations.
53
- - **Fastest on arithmetic:** with its methods API, it is the fastest money library on creating, parsing, add, multiply,
54
- 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.07× (compare on Node: 7.39 against 7.91 ns) to about 8.7× (allocate on Node: 48
56
- against bigint-money's 415 ns). Against dinero.js 2 it ranges from 1.6× (creating on Node) to about 30× (add on
57
- Bun). dinero.js 2 measured unexpectedly slow on Deno in this run (up to 6× slower than in the previous one, while
58
- every other library got faster), so its Deno numbers are left out of this range.
59
- - **Where it isn't fastest:** formatting on Node and Deno, where currency.js (formatting a float) is 8% and 2% faster
60
- (on Bun, ducat and dinero.js 1 are level, 269 against 272 ns). On Bun, libraries that compare plain numbers are
61
- faster at compare (currency.js 6.9 against 10.6 ns).
62
- - **Fixed-point decimals:** ducat is not the fastest fixed-point library. bigdecimal.js is faster on parse, multiply
63
- and divide, js-big-decimal on parse and `toString`, @formatjs/bigdecimal on parse on Node and Deno, and ethers on
64
- `toString`; see
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
65
66
  [docs/COMPARISON.md](docs/COMPARISON.md#fixed-point-decimal-libraries).
66
67
  - **Not the smallest:** currency.js, bigint-money, dinero.js 2, @screeny05/ts-money, big.js and dinero.js 1 are smaller
67
68
  for the same snippet. ducat's core includes exact decimal parsing, bigint support and the ISO table; everything else
68
69
  is opt-in.
69
- - **Measurement:** speed is from one quiet AMD EPYC Genoa vCPU, on ducat 0.1.0 (commit 69d7fe9) under Node 24.22.2,
70
- Bun 1.4.4 and Deno 2.9.8; sizes are gzipped KiB.
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.
71
72
 
72
73
  ## Design
73
74
 
@@ -120,29 +121,44 @@ Engine notes (V8 vs JSC vs SpiderMonkey):
120
121
 
121
122
  ### Modules
122
123
 
123
- | import | adds to `Money` | also exports |
124
- | --------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------- |
125
- | `@ducatjs/money` | the core: arithmetic, rounding, comparison, `toDecimal`, `toJSON`… | `money`, `fromMinor`, `zero`, `sum`, `fromJSON`, ISO exponents |
126
- | `@ducatjs/money/allocate` | `allocate`, `split`, `instalments`, `schedule`, `chunk` | the same as plain functions |
127
- | `@ducatjs/money/tax` | `percentage`, `discount`, `addTax`, `extractTax` | the same |
128
- | `@ducatjs/money/convert` | `convert` | `exchangeRate`, providers, `bag`, `comparator`… |
129
- | `@ducatjs/money/exact` | `exact` | `ExactMoney`, `sumExact` |
130
- | `.../convert/ecb` | nothing | `parseEcbXml`, `parseEcbCsv` |
131
- | `.../convert/fetch` | nothing | `fetchEcb` (the only module that uses the network) |
132
- | `.../stripe-fx`, `adyen-fx` | nothing | `stripeRate`, `stripeImpliedRate`, `adyenRate` |
133
- | `@ducatjs/money/cash` | `cashRound`, `roundToIncrement` | the same, `cashRule`, `CASH_RULES` |
134
- | `@ducatjs/money/format` | `format`, `formatToParts` | the same |
135
- | `@ducatjs/money/parse` | nothing | `parse` |
136
- | `@ducatjs/money/stripe` | `toStripe`, `toStripeAmount` | the same, `fromStripe`, `stripeExponent`… |
137
- | `@ducatjs/money/adyen` | `toAdyen` | the same, `fromAdyen`, `adyenExponent`… |
138
- | `@ducatjs/money/checkout` | `toCheckout`, `toCheckoutAmount` | the same, `fromCheckout`, `checkoutExponent`… |
139
- | `@ducatjs/money/paypal` | `toPayPal`, `toPayPalValue` | the same, `fromPayPal`, `paypalExponent`… |
140
- | `@ducatjs/money/braintree` | `toBraintree` | the same, `fromBraintree`, `braintreeExponent`… |
141
- | `@ducatjs/money/klarna` | `toKlarna` | the same, `fromKlarna`, `klarnaExponent`… |
142
- | `@ducatjs/money/prisma` | `toDecimalValue` | the same, `fromDecimal`, the field helpers |
143
- | `@ducatjs/money/fixed` | `toFixedPoint` | `Fixed`, `fixed`, `fromScaled`, `isFixed`, `moneyToFixed` |
144
- | `@ducatjs/money/all` | everything above | everything |
145
- | `@ducatjs/money/fn` | nothing | every operation as a plain function, core and modules |
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 |
146
162
 
147
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.
148
164
 
@@ -176,6 +192,20 @@ The sections below list each operation with its plain-function signature, as `@d
176
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).
177
193
  - `fromMinor(minor: number | bigint, currency, scale?)`: from cents, e.g. a BIGINT column.
178
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
+ ```
179
209
 
180
210
  **Arithmetic.** All of these are immutable and return new values.
181
211
 
@@ -185,11 +215,28 @@ The sections below list each operation with its plain-function signature, as `@d
185
215
  - `percentage(m, percent, rounding?)`, `discount(price, percent, rounding?)`
186
216
  - `addTax(net, percent, rounding?)` and `extractTax(gross, percent, rounding?)` both return `{ net, tax, gross }`, and net + tax is always exactly gross.
187
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
+ ```
188
226
 
189
227
  **Compare**
190
228
 
191
229
  - `compare` (returns −1, 0 or 1), `equals`, `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual`
192
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
+ ```
193
240
 
194
241
  **Allocate and split**
195
242
 
@@ -218,12 +265,38 @@ The sections below list each operation with its plain-function signature, as `@d
218
265
  | `Floor` | `'floor'` | always toward −∞ |
219
266
  | `Unnecessary` | `'unnecessary'` | throws `RoundingError` if rounding would be needed |
220
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
+
221
290
  **Cash rounding**
222
291
 
223
292
  - `cashRound(m, { country?, rounding?, includeUnverified? })` uses the table in `data/cash-rounding.json`:
224
293
  - CHF 0.05, SEK and NOK 1.00, DKK 0.50, CAD and AUD 0.05, NZD 0.10
225
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
226
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.
227
300
  - Call it **only for cash tenders**: card payments are never rounded in any of these jurisdictions.
228
301
  - For EUR it needs the `country`, because there is no euro-wide rule.
229
302
  - `roundToIncrement(m, increment, rounding = 'halfExpand')` and `cashRule(currency, country?)` are also exported.
@@ -236,18 +309,85 @@ The sections below list each operation with its plain-function signature, as `@d
236
309
  - NBSP and narrow NBSP, Swiss `’`
237
310
  - Arabic-Indic, Persian, Devanagari and Thai digits
238
311
  - accounting parentheses and U+2212 minus
239
- - The currency always comes from the argument and is never guessed from the text. A `parse(format(x)) == x` property test runs across 14 locales.
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
+ ```
240
332
 
241
333
  **Serialise**
242
334
 
243
335
  - `toDecimal(m)` gives `"12.34"` for NUMERIC/DECIMAL and wire formats.
244
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`.
245
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.
246
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
+
247
365
  **Currencies**
248
366
 
249
367
  - `currencyExponent(code)`, `isCurrency(code)`, `ISO_EXPONENTS`, `ISO_4217_PUBLISHED`
250
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
+ ```
251
391
 
252
392
  ### Currency conversion
253
393
 
@@ -269,9 +409,18 @@ eurUsd.andThen(usdJpy); // EUR → JPY, exact, with both rates kept in `derivedF
269
409
  eurUsd.withMargin('-0.02'); // 2% less USD per EUR, exactly
270
410
  ```
271
411
 
272
- 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 carries optional metadata: `source`, `asOf`, `validUntil` and `type` (`'mid'`, `'bid'`, `'ask'`, `'reference'`...). Instants are stored in UTC, and dates stay as given. A derived rate takes the oldest `asOf` 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.
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:
273
413
 
274
- **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.
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`.
275
424
 
276
425
  <!-- doctest: setup import { readFileSync } from 'node:fs'; const xml90Days = readFileSync('test/fixtures/ecb/eurofxref-hist-2d.xml', 'utf8'); -->
277
426
 
@@ -285,7 +434,80 @@ const fx = via('EUR', await history.at('2026-11-08')); // a Sunday: Friday's tab
285
434
  money('100', 'USD').convert('JPY', fx, 'halfEven'); // → 15833 JPY, through EUR, rounded once
286
435
  ```
287
436
 
288
- **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 XML and CSV files) and one opt-in fetcher (`@ducatjs/money/convert/fetch`, which uses the platform `fetch` and has 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'`. 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`.
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
+ ```
289
511
 
290
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:
291
513
 
@@ -336,12 +558,12 @@ import { toKlarna, fromKlarna } from '@ducatjs/money/klarna';
336
558
 
337
559
  toStripe(money('10.99', 'USD')); // → { amount: 1099, currency: 'usd' }
338
560
  toStripeAmount(money('5', 'ISK')); // → 500 (Stripe: two-decimal value ending in 00)
339
- toStripeAmount(money('10.45', 'HUF'), { payout: true }); // throws: payouts must be whole HUF
561
+ toStripeAmount(money('10.45', 'HUF'), { payout: true }); // throws RoundingError: payouts must be whole HUF
340
562
  toStripeAmount(money('5.124', 'KWD'), { rounding: 'halfEven' }); // → 5120 (last digit must be 0)
341
563
  toAdyen(money('15000', 'IDR')); // → { value: 15000, currency: 'IDR' } (Adyen: IDR has 0 decimals)
342
564
  toAdyen(money('5000', 'CLP')); // → { value: 500000, currency: 'CLP' } (Adyen: CLP has 2 decimals)
343
565
  toCheckout(money('5000', 'CLP')); // → { amount: 500000, currency: 'CLP' } (Checkout.com: 2 decimals, must end in 00)
344
- toCheckout(money('1.001', 'BHD')); // throws: Checkout.com needs the last digit 0
566
+ toCheckout(money('1.001', 'BHD')); // throws RoundingError: Checkout.com needs the last digit 0
345
567
  toPayPal(money('10.99', 'USD')); // → { currency_code: 'USD', value: '10.99' } (PayPal: exact decimal string)
346
568
  toPayPal(money('1000', 'HUF')); // → { currency_code: 'HUF', value: '1000' } (PayPal: HUF takes no decimals)
347
569
  toBraintree(money('5000', 'LAK')); // → '5000' (Braintree: LAK is zero-decimal; the merchant account sets the currency)
@@ -375,6 +597,8 @@ Signs follow each processor's docs and are checked after rounding. Checkout.com
375
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.
376
598
  - Choose a NUMERIC scale of at least the largest exponent you store: 4 covers CLF/UYW, and 3 covers BHD/KWD.
377
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`.
378
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.
379
603
 
380
604
  Prisma client extension (from `examples/prisma`, which runs end to end against SQLite):
@@ -401,6 +625,132 @@ await db.order.create({
401
625
 
402
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.
403
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
+
404
754
  ---
405
755
 
406
756
  ## Currency data: source, generation, verification
@@ -415,6 +765,7 @@ await db.order.create({
415
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 |
416
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 |
417
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) |
418
769
 
419
770
  - `pnpm gen` (`scripts/gen-currencies.mjs`) turns these into `src/generated/*.ts`.
420
771
  - `pnpm gen:check` fails if the generated files are stale. It also runs inside the test suite.
@@ -425,6 +776,8 @@ await db.order.create({
425
776
  - 4: CLF UYW
426
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.
427
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
+
428
781
  Recent ISO changes that are already encoded:
429
782
 
430
783
  - Added: ZWG, SLE, VES/VED, XCG (which replaced ANG in 2025-03) and XAD.
@@ -527,6 +880,67 @@ These are stated over the proved kernel (`divRound`):
527
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.
528
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.
529
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
+
530
944
  ## Tests
531
945
 
532
946
  `pnpm test` runs the vitest suite.
@@ -565,10 +979,10 @@ These are stated over the proved kernel (`divRound`):
565
979
  - CodSpeed measures every pull request by instruction count, so speed regressions show up even on noisy CI machines.
566
980
  - The benchmarks in `bench/` are for quick local comparisons; on a shared machine their run-to-run variation is often
567
981
  ±20–30%.
568
- - Bundle size in KiB (esbuild, minified + gzip; `pnpm size` checks every entry point and snippet against `scripts/size-budgets.json`):
569
- - `money('1.00', 'EUR').add(…).multiply('1.075', 'halfEven').toDecimal()`: **3.8 KiB**, of which about 0.5 KiB is the ISO table
570
- - the core entry point: 4.6 KiB; everything (`@ducatjs/money/all`): 18.7 KiB
571
- - the same snippet with dinero.js 2 (EUR only): 1.8 KiB
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
572
986
 
573
987
  ---
574
988
 
@@ -588,23 +1002,26 @@ These are stated over the proved kernel (`divRound`):
588
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.
589
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.
590
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.
591
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.
592
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.
593
- - **Withdrawn and non-ISO codes** (ANG, STD, BGN, HRK, ZWL, SLL, CUC, CNH, VEF…) stay out of the core registry. An opt-in table of them with withdrawal dates is planned for a later release; until then, register any you need with `defineCurrency`.
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.
594
1011
  - **Processors:** Stripe, Adyen, Checkout.com, PayPal, Braintree and Klarna, each an opt-in entry point (`@ducatjs/money/<processor>`).
595
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.
596
1013
 
597
1014
  ## What's missing (honest list)
598
1015
 
599
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`).
600
- - **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 and several others. `includeUnverified: false` turns them off.
601
- - Per-jurisdiction tie rules are all `halfExpand`. Singapore's "round down in the customer's favour" practice and USD's state-by-state post-penny rules aren't modelled.
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.
602
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.
603
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.
604
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.
605
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.
606
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.
607
- - **Withdrawn and non-ISO codes** (ANG, STD, CNH, VEF, SLL, XDR, HRK, LTL, ZMK, ZWD) aren't in the core registry. Stripe, Adyen, Checkout.com and Braintree list them, so `defineCurrency` is needed to hold them. There is no historical ISO data (validity dates).
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.
608
1025
  - **Formatting gaps:**
609
1026
  - No pluggable formatter for engines without Intl.
610
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.
@@ -613,6 +1030,6 @@ These are stated over the proved kernel (`divRound`):
613
1030
  - Memory and GC pressure, which were reasoned about but not measured.
614
1031
  - dinero.js's bigint calculator.
615
1032
  - **No Money arrays or columnar ops** (e.g. summing a `Float64Array`/`BigInt64Array` of minor units).
616
- - **The ISO table is always bundled** (about 0.5 KiB gzipped, 1 KiB minified). Per-currency imports like dinero's would make the minimum bundle smaller.
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.
617
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.
618
1035
  - **No docs site yet.**