@ducatjs/money 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.md +617 -2
- package/assets/CREDITS.md +12 -0
- package/data/adyen-fx.json +19 -0
- package/data/adyen.json +171 -0
- package/data/braintree.json +223 -0
- package/data/cash-rounding.json +399 -0
- package/data/checkout.json +230 -0
- package/data/iso4217-list-one.xml +1956 -0
- package/data/klarna.json +95 -0
- package/data/paypal.json +65 -0
- package/data/stripe-fx.json +17 -0
- package/data/stripe.json +285 -0
- package/dist/adyen-fx.d.ts +25 -0
- package/dist/adyen-fx.d.ts.map +1 -0
- package/dist/adyen-fx.js +122 -0
- package/dist/adyen-fx.js.map +1 -0
- package/dist/adyen.d.ts +25 -0
- package/dist/adyen.d.ts.map +1 -0
- package/dist/adyen.js +34 -0
- package/dist/adyen.js.map +1 -0
- package/dist/allocate.d.ts +72 -0
- package/dist/allocate.d.ts.map +1 -0
- package/dist/allocate.js +314 -0
- package/dist/allocate.js.map +1 -0
- package/dist/bag.d.ts +58 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +122 -0
- package/dist/bag.js.map +1 -0
- package/dist/braintree.d.ts +34 -0
- package/dist/braintree.d.ts.map +1 -0
- package/dist/braintree.js +45 -0
- package/dist/braintree.js.map +1 -0
- package/dist/cash.d.ts +35 -0
- package/dist/cash.d.ts.map +1 -0
- package/dist/cash.js +86 -0
- package/dist/cash.js.map +1 -0
- package/dist/checkout.d.ts +42 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +52 -0
- package/dist/checkout.js.map +1 -0
- package/dist/convert.d.ts +51 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +55 -0
- package/dist/convert.js.map +1 -0
- package/dist/currency.d.ts +22 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +38 -0
- package/dist/currency.js.map +1 -0
- package/dist/decimal.d.ts +52 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +251 -0
- package/dist/decimal.js.map +1 -0
- package/dist/ecb.d.ts +17 -0
- package/dist/ecb.d.ts.map +1 -0
- package/dist/ecb.js +147 -0
- package/dist/ecb.js.map +1 -0
- package/dist/exact.d.ts +62 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +147 -0
- package/dist/exact.js.map +1 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +79 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fixed.d.ts +121 -0
- package/dist/fixed.d.ts.map +1 -0
- package/dist/fixed.js +420 -0
- package/dist/fixed.js.map +1 -0
- package/dist/fn.d.ts +27 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +30 -0
- package/dist/fn.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +90 -0
- package/dist/format.js.map +1 -0
- package/dist/generated/adyen.d.ts +3 -0
- package/dist/generated/adyen.d.ts.map +1 -0
- package/dist/generated/adyen.js +144 -0
- package/dist/generated/adyen.js.map +1 -0
- package/dist/generated/braintree.d.ts +3 -0
- package/dist/generated/braintree.d.ts.map +1 -0
- package/dist/generated/braintree.js +140 -0
- package/dist/generated/braintree.js.map +1 -0
- package/dist/generated/cash.d.ts +12 -0
- package/dist/generated/cash.d.ts.map +1 -0
- package/dist/generated/cash.js +364 -0
- package/dist/generated/cash.js.map +1 -0
- package/dist/generated/checkout.d.ts +3 -0
- package/dist/generated/checkout.d.ts.map +1 -0
- package/dist/generated/checkout.js +151 -0
- package/dist/generated/checkout.js.map +1 -0
- package/dist/generated/iso4217.d.ts +174 -0
- package/dist/generated/iso4217.d.ts.map +1 -0
- package/dist/generated/iso4217.js +175 -0
- package/dist/generated/iso4217.js.map +1 -0
- package/dist/generated/kernel.d.ts +17 -0
- package/dist/generated/kernel.d.ts.map +1 -0
- package/dist/generated/kernel.js +208 -0
- package/dist/generated/kernel.js.map +1 -0
- package/dist/generated/klarna.d.ts +3 -0
- package/dist/generated/klarna.d.ts.map +1 -0
- package/dist/generated/klarna.js +21 -0
- package/dist/generated/klarna.js.map +1 -0
- package/dist/generated/paypal.d.ts +3 -0
- package/dist/generated/paypal.d.ts.map +1 -0
- package/dist/generated/paypal.js +31 -0
- package/dist/generated/paypal.js.map +1 -0
- package/dist/generated/stripe.d.ts +8 -0
- package/dist/generated/stripe.d.ts.map +1 -0
- package/dist/generated/stripe.js +150 -0
- package/dist/generated/stripe.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/klarna.d.ts +31 -0
- package/dist/klarna.d.ts.map +1 -0
- package/dist/klarna.js +38 -0
- package/dist/klarna.js.map +1 -0
- package/dist/money.d.ts +218 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +577 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +11 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +132 -0
- package/dist/parse.js.map +1 -0
- package/dist/paypal.d.ts +41 -0
- package/dist/paypal.d.ts.map +1 -0
- package/dist/paypal.js +58 -0
- package/dist/paypal.js.map +1 -0
- package/dist/plugins/adyen.d.ts +15 -0
- package/dist/plugins/adyen.d.ts.map +1 -0
- package/dist/plugins/adyen.js +10 -0
- package/dist/plugins/adyen.js.map +1 -0
- package/dist/plugins/all.d.ts +22 -0
- package/dist/plugins/all.d.ts.map +1 -0
- package/dist/plugins/all.js +23 -0
- package/dist/plugins/all.js.map +1 -0
- package/dist/plugins/allocate.d.ts +20 -0
- package/dist/plugins/allocate.d.ts.map +1 -0
- package/dist/plugins/allocate.js +26 -0
- package/dist/plugins/allocate.js.map +1 -0
- package/dist/plugins/braintree.d.ts +12 -0
- package/dist/plugins/braintree.d.ts.map +1 -0
- package/dist/plugins/braintree.js +12 -0
- package/dist/plugins/braintree.js.map +1 -0
- package/dist/plugins/cash.d.ts +19 -0
- package/dist/plugins/cash.d.ts.map +1 -0
- package/dist/plugins/cash.js +16 -0
- package/dist/plugins/cash.js.map +1 -0
- package/dist/plugins/checkout.d.ts +21 -0
- package/dist/plugins/checkout.d.ts.map +1 -0
- package/dist/plugins/checkout.js +16 -0
- package/dist/plugins/checkout.js.map +1 -0
- package/dist/plugins/convert.d.ts +20 -0
- package/dist/plugins/convert.d.ts.map +1 -0
- package/dist/plugins/convert.js +13 -0
- package/dist/plugins/convert.js.map +1 -0
- package/dist/plugins/exact.d.ts +15 -0
- package/dist/plugins/exact.d.ts.map +1 -0
- package/dist/plugins/exact.js +13 -0
- package/dist/plugins/exact.js.map +1 -0
- package/dist/plugins/fixed.d.ts +17 -0
- package/dist/plugins/fixed.d.ts.map +1 -0
- package/dist/plugins/fixed.js +13 -0
- package/dist/plugins/fixed.js.map +1 -0
- package/dist/plugins/format.d.ts +9 -0
- package/dist/plugins/format.d.ts.map +1 -0
- package/dist/plugins/format.js +16 -0
- package/dist/plugins/format.js.map +1 -0
- package/dist/plugins/klarna.d.ts +10 -0
- package/dist/plugins/klarna.d.ts.map +1 -0
- package/dist/plugins/klarna.js +10 -0
- package/dist/plugins/klarna.js.map +1 -0
- package/dist/plugins/paypal.d.ts +14 -0
- package/dist/plugins/paypal.d.ts.map +1 -0
- package/dist/plugins/paypal.js +16 -0
- package/dist/plugins/paypal.js.map +1 -0
- package/dist/plugins/prisma.d.ts +8 -0
- package/dist/plugins/prisma.d.ts.map +1 -0
- package/dist/plugins/prisma.js +13 -0
- package/dist/plugins/prisma.js.map +1 -0
- package/dist/plugins/stripe.d.ts +14 -0
- package/dist/plugins/stripe.d.ts.map +1 -0
- package/dist/plugins/stripe.js +16 -0
- package/dist/plugins/stripe.js.map +1 -0
- package/dist/plugins/tax.d.ts +21 -0
- package/dist/plugins/tax.d.ts.map +1 -0
- package/dist/plugins/tax.js +18 -0
- package/dist/plugins/tax.js.map +1 -0
- package/dist/prisma.d.ts +66 -0
- package/dist/prisma.d.ts.map +1 -0
- package/dist/prisma.js +66 -0
- package/dist/prisma.js.map +1 -0
- package/dist/processor.d.ts +28 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +118 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.d.ts +89 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +240 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate.d.ts +119 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +271 -0
- package/dist/rate.js.map +1 -0
- package/dist/rounding-error.d.ts +9 -0
- package/dist/rounding-error.d.ts.map +1 -0
- package/dist/rounding-error.js +9 -0
- package/dist/rounding-error.js.map +1 -0
- package/dist/rounding.d.ts +50 -0
- package/dist/rounding.d.ts.map +1 -0
- package/dist/rounding.js +87 -0
- package/dist/rounding.js.map +1 -0
- package/dist/stripe-fx.d.ts +22 -0
- package/dist/stripe-fx.d.ts.map +1 -0
- package/dist/stripe-fx.js +30 -0
- package/dist/stripe-fx.js.map +1 -0
- package/dist/stripe.d.ts +36 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +39 -0
- package/dist/stripe.js.map +1 -0
- package/dist/tax.d.ts +29 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +39 -0
- package/dist/tax.js.map +1 -0
- package/package.json +171 -4
- package/src/adyen-fx.ts +154 -0
- package/src/adyen.ts +46 -0
- package/src/allocate.ts +393 -0
- package/src/bag.ts +170 -0
- package/src/braintree.ts +71 -0
- package/src/cash.ts +115 -0
- package/src/checkout.ts +77 -0
- package/src/convert.ts +159 -0
- package/src/currency.ts +61 -0
- package/src/decimal.ts +283 -0
- package/src/ecb.ts +162 -0
- package/src/exact.ts +182 -0
- package/src/fetch.ts +127 -0
- package/src/fixed.ts +558 -0
- package/src/fn.ts +80 -0
- package/src/format.ts +106 -0
- package/src/generated/adyen.ts +144 -0
- package/src/generated/braintree.ts +140 -0
- package/src/generated/cash.ts +375 -0
- package/src/generated/checkout.ts +151 -0
- package/src/generated/iso4217.ts +179 -0
- package/src/generated/kernel.ts +230 -0
- package/src/generated/klarna.ts +21 -0
- package/src/generated/paypal.ts +31 -0
- package/src/generated/stripe.ts +150 -0
- package/src/index.ts +37 -0
- package/src/klarna.ts +54 -0
- package/src/money.ts +781 -0
- package/src/parse.ts +144 -0
- package/src/paypal.ts +93 -0
- package/src/plugins/adyen.ts +25 -0
- package/src/plugins/all.ts +47 -0
- package/src/plugins/allocate.ts +53 -0
- package/src/plugins/braintree.ts +23 -0
- package/src/plugins/cash.ts +33 -0
- package/src/plugins/checkout.ts +32 -0
- package/src/plugins/convert.ts +50 -0
- package/src/plugins/exact.ts +23 -0
- package/src/plugins/fixed.ts +28 -0
- package/src/plugins/format.ts +28 -0
- package/src/plugins/klarna.ts +21 -0
- package/src/plugins/paypal.ts +29 -0
- package/src/plugins/prisma.ts +24 -0
- package/src/plugins/stripe.ts +29 -0
- package/src/plugins/tax.ts +41 -0
- package/src/prisma.ts +118 -0
- package/src/processor.ts +181 -0
- package/src/provider.ts +324 -0
- package/src/rate.ts +377 -0
- package/src/rounding-error.ts +8 -0
- package/src/rounding.ts +122 -0
- package/src/stripe-fx.ts +49 -0
- package/src/stripe.ts +69 -0
- package/src/tax.ts +71 -0
package/README.md
CHANGED
|
@@ -1,3 +1,618 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
+
# ducat
|
|
5
|
+
|
|
6
|
+
[](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** | 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 |
|
|
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, 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
|
|
65
|
+
[docs/COMPARISON.md](docs/COMPARISON.md#fixed-point-decimal-libraries).
|
|
66
|
+
- **Not the smallest:** currency.js, bigint-money, dinero.js 2, @screeny05/ts-money, big.js and dinero.js 1 are smaller
|
|
67
|
+
for the same snippet. ducat's core includes exact decimal parsing, bigint support and the ISO table; everything else
|
|
68
|
+
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.
|
|
71
|
+
|
|
72
|
+
## Design
|
|
73
|
+
|
|
74
|
+
### Representation: integer minor units in a JS number, with a bigint fallback
|
|
75
|
+
|
|
76
|
+
A `Money<C>` has three readonly fields:
|
|
77
|
+
|
|
78
|
+
| field | meaning |
|
|
79
|
+
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `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. |
|
|
81
|
+
| `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. |
|
|
82
|
+
| `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. |
|
|
83
|
+
|
|
84
|
+
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)):
|
|
85
|
+
|
|
86
|
+
| ns/op, indicative | **A: number + bigint fallback** | B: bigint everywhere | C: decimal string | A + `Object.freeze` |
|
|
87
|
+
| ------------------------------------ | ------------------------------: | -------------------: | --------------------: | ------------------: |
|
|
88
|
+
| add (V8 / JSC) | **12–34 / 16–24** | 25–31 / 38–59 | 806–838 / 1107–1275 | 124–173 / 67–73 |
|
|
89
|
+
| multiply by ratio + round (V8 / JSC) | **23–37 / 16–19** | 49 / 223–262 | 415–444 / 1085–1506 | 145–246 / 115–124 |
|
|
90
|
+
| allocate into 3 (V8 / JSC) | **91–109 / 116–173** | 288–301 / 474–699 | 1122–1131 / 2250–4100 | 584–788 / 330–528 |
|
|
91
|
+
| compare (V8 / JSC) | **3–4 / 7–8** | 9.5–10 / 10–13 | 397–735 / 589–1034 | 5–11 / 4 |
|
|
92
|
+
|
|
93
|
+
- **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.
|
|
94
|
+
- **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.
|
|
95
|
+
- **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.
|
|
96
|
+
- **`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"`).
|
|
97
|
+
|
|
98
|
+
Engine notes (V8 vs JSC vs SpiderMonkey):
|
|
99
|
+
|
|
100
|
+
- 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.
|
|
101
|
+
- 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.
|
|
102
|
+
- Engines handle `Intl.NumberFormat` differently in two ways:
|
|
103
|
+
- **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).
|
|
104
|
+
- **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.
|
|
105
|
+
- Hot paths avoid allocation:
|
|
106
|
+
- `parse` (`money('12.34','EUR')`) uses a hand-rolled character loop with no regex and no bigint for inputs of 15 digits or fewer.
|
|
107
|
+
- Ratios like `'1.075'` are parsed once and cached. You can also pre-build them with `ratio()`.
|
|
108
|
+
- `allocate` uses a number fast path with in-place selection instead of a sort.
|
|
109
|
+
|
|
110
|
+
### Correctness rules
|
|
111
|
+
|
|
112
|
+
- **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.
|
|
113
|
+
- **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.
|
|
114
|
+
- **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.
|
|
115
|
+
- **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`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## API
|
|
120
|
+
|
|
121
|
+
### Modules
|
|
122
|
+
|
|
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 |
|
|
146
|
+
|
|
147
|
+
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
|
+
|
|
149
|
+
Your own module can add methods the same way:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { extendMoney, money, type Money } from '@ducatjs/money';
|
|
153
|
+
|
|
154
|
+
declare module '@ducatjs/money' {
|
|
155
|
+
interface Money<C extends string> {
|
|
156
|
+
half(): Money<C>;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
extendMoney(
|
|
160
|
+
{
|
|
161
|
+
half(this: Money) {
|
|
162
|
+
return this.divide(2);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{ owner: 'my-app/half' },
|
|
166
|
+
);
|
|
167
|
+
money('10', 'EUR').half(); // → 5.00 EUR
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`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.
|
|
171
|
+
|
|
172
|
+
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)`).
|
|
173
|
+
|
|
174
|
+
**Create**
|
|
175
|
+
|
|
176
|
+
- `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
|
+
- `fromMinor(minor: number | bigint, currency, scale?)`: from cents, e.g. a BIGINT column.
|
|
178
|
+
- `zero(currency)`, `fromJSON({ amount, currency } | string)`, `isMoney(x)`
|
|
179
|
+
|
|
180
|
+
**Arithmetic.** All of these are immutable and return new values.
|
|
181
|
+
|
|
182
|
+
- `add`, `subtract`, `sum(list, currency?)`, `negate`, `abs`
|
|
183
|
+
- `multiply(m, factor, rounding?)`, `divide(m, divisor, rounding?)`
|
|
184
|
+
- `round(m, rounding, scale = ISO exponent)`, `toScale(m, scale, rounding?)`
|
|
185
|
+
- `percentage(m, percent, rounding?)`, `discount(price, percent, rounding?)`
|
|
186
|
+
- `addTax(net, percent, rounding?)` and `extractTax(gross, percent, rounding?)` both return `{ net, tax, gross }`, and net + tax is always exactly gross.
|
|
187
|
+
- `convert(m, to, rate, rounding?, { scale? })` at a decimal-string rate, or `convert(m, exchangeRate(...), rounding?)`. See [Currency conversion](#currency-conversion).
|
|
188
|
+
|
|
189
|
+
**Compare**
|
|
190
|
+
|
|
191
|
+
- `compare` (returns −1, 0 or 1), `equals`, `lessThan`, `lessThanOrEqual`, `greaterThan`, `greaterThanOrEqual`
|
|
192
|
+
- `isZero`, `isPositive`, `isNegative`, `sign`, `min`, `max`, `clamp`
|
|
193
|
+
|
|
194
|
+
**Allocate and split**
|
|
195
|
+
|
|
196
|
+
- `allocate(m, weights, { remainder })`. Weights are integers, bigints or decimal strings (`'33.3'`). The remainder strategies:
|
|
197
|
+
- `'largest'` (the default, Hamilton/largest remainder)
|
|
198
|
+
- `'first'` and `'last'`, which spread one unit at a time
|
|
199
|
+
- `'toFirst'` and `'toLast'`, which give the whole remainder to one bucket
|
|
200
|
+
- Zero weights never receive anything. A negative amount is allocated as the mirror image of the positive one.
|
|
201
|
+
- `split(m, n, { remainder })`
|
|
202
|
+
- `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.
|
|
203
|
+
- `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.
|
|
204
|
+
- `chunk(total, max)`: capped tranches, e.g. 1000 with max 300 gives 300, 300, 300, 100.
|
|
205
|
+
|
|
206
|
+
**Rounding.** The `RoundingMode` strings are the same ones `Intl.NumberFormat` uses for its `roundingMode` option:
|
|
207
|
+
|
|
208
|
+
| `Rounding.*` alias | string | behaviour |
|
|
209
|
+
| ---------------------- | --------------- | -------------------------------------------------- |
|
|
210
|
+
| `HalfEven` / `Bankers` | `'halfEven'` | ties go to the even neighbour |
|
|
211
|
+
| `HalfUp` | `'halfExpand'` | ties go away from zero |
|
|
212
|
+
| `HalfDown` | `'halfTrunc'` | ties go toward zero |
|
|
213
|
+
| `HalfCeil` | `'halfCeil'` | ties go toward +∞ |
|
|
214
|
+
| `HalfFloor` | `'halfFloor'` | ties go toward −∞ |
|
|
215
|
+
| `Up` | `'expand'` | always away from zero |
|
|
216
|
+
| `Down` | `'trunc'` | always toward zero |
|
|
217
|
+
| `Ceil` | `'ceil'` | always toward +∞ |
|
|
218
|
+
| `Floor` | `'floor'` | always toward −∞ |
|
|
219
|
+
| `Unnecessary` | `'unnecessary'` | throws `RoundingError` if rounding would be needed |
|
|
220
|
+
|
|
221
|
+
**Cash rounding**
|
|
222
|
+
|
|
223
|
+
- `cashRound(m, { country?, rounding?, includeUnverified? })` uses the table in `data/cash-rounding.json`:
|
|
224
|
+
- CHF 0.05, SEK and NOK 1.00, DKK 0.50, CAD and AUD 0.05, NZD 0.10
|
|
225
|
+
- HUF 5, CZK 1, CLP 10, ILS 0.10, MYR 0.05
|
|
226
|
+
- EUR 0.05 in BE, NL, FI, IE, IT, SK, EE and LT, and more.
|
|
227
|
+
- Call it **only for cash tenders**: card payments are never rounded in any of these jurisdictions.
|
|
228
|
+
- For EUR it needs the `country`, because there is no euro-wide rule.
|
|
229
|
+
- `roundToIncrement(m, increment, rounding = 'halfExpand')` and `cashRule(currency, country?)` are also exported.
|
|
230
|
+
|
|
231
|
+
**Format and parse**
|
|
232
|
+
|
|
233
|
+
- `format(m, locale?, Intl.NumberFormatOptions?)` and `formatToParts(...)`. Formatters are cached, and the digits shown always match the value's scale.
|
|
234
|
+
- `parse('1.234,50 €', 'EUR', 'de-DE')` handles:
|
|
235
|
+
- group and decimal separators per locale
|
|
236
|
+
- NBSP and narrow NBSP, Swiss `’`
|
|
237
|
+
- Arabic-Indic, Persian, Devanagari and Thai digits
|
|
238
|
+
- 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.
|
|
240
|
+
|
|
241
|
+
**Serialise**
|
|
242
|
+
|
|
243
|
+
- `toDecimal(m)` gives `"12.34"` for NUMERIC/DECIMAL and wire formats.
|
|
244
|
+
- `toMinor(m)` gives a bigint for BIGINT columns. `toMinorNumber(m)` gives a number and throws if it isn't safe.
|
|
245
|
+
- `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
|
+
|
|
247
|
+
**Currencies**
|
|
248
|
+
|
|
249
|
+
- `currencyExponent(code)`, `isCurrency(code)`, `ISO_EXPONENTS`, `ISO_4217_PUBLISHED`
|
|
250
|
+
- `defineCurrency('BTC', 8)` adds a non-ISO code. To also get it in the `CurrencyCode` type, augment `interface CustomCurrencies`.
|
|
251
|
+
|
|
252
|
+
### Currency conversion
|
|
253
|
+
|
|
254
|
+
`@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.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { money } from '@ducatjs/money';
|
|
258
|
+
import { exchangeRate } from '@ducatjs/money/convert';
|
|
259
|
+
|
|
260
|
+
const eurUsd = exchangeRate('EUR', 'USD', '1.1165', {
|
|
261
|
+
source: 'ECB',
|
|
262
|
+
asOf: '2026-11-06',
|
|
263
|
+
type: 'reference',
|
|
264
|
+
});
|
|
265
|
+
const usdJpy = exchangeRate('USD', 'JPY', '158.33');
|
|
266
|
+
eurUsd.convert(money('100', 'EUR'), 'halfEven'); // → 111.65 USD
|
|
267
|
+
eurUsd.inverse().toString(); // → '2000/2233': exact, not 0.8956...
|
|
268
|
+
eurUsd.andThen(usdJpy); // EUR → JPY, exact, with both rates kept in `derivedFrom`
|
|
269
|
+
eurUsd.withMargin('-0.02'); // 2% less USD per EUR, exactly
|
|
270
|
+
```
|
|
271
|
+
|
|
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.
|
|
273
|
+
|
|
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.
|
|
275
|
+
|
|
276
|
+
<!-- doctest: setup import { readFileSync } from 'node:fs'; const xml90Days = readFileSync('test/fixtures/ecb/eurofxref-hist-2d.xml', 'utf8'); -->
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { money } from '@ducatjs/money';
|
|
280
|
+
import { snapshotsFrom, via } from '@ducatjs/money/convert';
|
|
281
|
+
import { parseEcbXml } from '@ducatjs/money/convert/ecb';
|
|
282
|
+
|
|
283
|
+
const history = snapshotsFrom(parseEcbXml(xml90Days));
|
|
284
|
+
const fx = via('EUR', await history.at('2026-11-08')); // a Sunday: Friday's table
|
|
285
|
+
money('100', 'USD').convert('JPY', fx, 'halfEven'); // → 15833 JPY, through EUR, rounded once
|
|
286
|
+
```
|
|
287
|
+
|
|
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`.
|
|
289
|
+
|
|
290
|
+
**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
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import { money } from '@ducatjs/money';
|
|
294
|
+
import { convertDetailed, exchangeRate } from '@ducatjs/money/convert';
|
|
295
|
+
|
|
296
|
+
const eurUsd = exchangeRate('EUR', 'USD', '1.1165');
|
|
297
|
+
const { value, residual } = convertDetailed(money('0.07', 'EUR'), eurUsd, 'halfEven');
|
|
298
|
+
value; // → 0.08 USD
|
|
299
|
+
residual; // → -369/200000 USD (value + residual is the exact conversion)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**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.
|
|
303
|
+
|
|
304
|
+
### Fixed-point decimals
|
|
305
|
+
|
|
306
|
+
`@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.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { fixed, fromScaled } from '@ducatjs/money/fixed';
|
|
310
|
+
|
|
311
|
+
const unitPrice = fixed('0.0425'); // → 0.0425 (scale 4)
|
|
312
|
+
const total = unitPrice.multiply(1200); // → 51.0000, exact
|
|
313
|
+
const perDay = fixed('100.00').divide(3, 'halfEven'); // → 33.33: rounded once, to this value's scale
|
|
314
|
+
fixed('1.5').add('0.25'); // → 1.75: mixed scales align to the larger one
|
|
315
|
+
fixed('2.5').round('halfEven'); // → 2: round() defaults to scale 0
|
|
316
|
+
fromScaled(12345, 3).toString(); // → '12.345'
|
|
317
|
+
JSON.stringify({ unitPrice }); // → '{"unitPrice":"0.0425"}'
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
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.
|
|
321
|
+
|
|
322
|
+
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.
|
|
323
|
+
|
|
324
|
+
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.
|
|
325
|
+
|
|
326
|
+
### Payment processors: the amount you send
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { money } from '@ducatjs/money';
|
|
330
|
+
import { toStripe, toStripeAmount, fromStripe } from '@ducatjs/money/stripe';
|
|
331
|
+
import { toAdyen, fromAdyen } from '@ducatjs/money/adyen';
|
|
332
|
+
import { toCheckout, fromCheckout } from '@ducatjs/money/checkout';
|
|
333
|
+
import { toPayPal, fromPayPal } from '@ducatjs/money/paypal';
|
|
334
|
+
import { toBraintree, fromBraintree } from '@ducatjs/money/braintree';
|
|
335
|
+
import { toKlarna, fromKlarna } from '@ducatjs/money/klarna';
|
|
336
|
+
|
|
337
|
+
toStripe(money('10.99', 'USD')); // → { amount: 1099, currency: 'usd' }
|
|
338
|
+
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
|
|
340
|
+
toStripeAmount(money('5.124', 'KWD'), { rounding: 'halfEven' }); // → 5120 (last digit must be 0)
|
|
341
|
+
toAdyen(money('15000', 'IDR')); // → { value: 15000, currency: 'IDR' } (Adyen: IDR has 0 decimals)
|
|
342
|
+
toAdyen(money('5000', 'CLP')); // → { value: 500000, currency: 'CLP' } (Adyen: CLP has 2 decimals)
|
|
343
|
+
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
|
|
345
|
+
toPayPal(money('10.99', 'USD')); // → { currency_code: 'USD', value: '10.99' } (PayPal: exact decimal string)
|
|
346
|
+
toPayPal(money('1000', 'HUF')); // → { currency_code: 'HUF', value: '1000' } (PayPal: HUF takes no decimals)
|
|
347
|
+
toBraintree(money('5000', 'LAK')); // → '5000' (Braintree: LAK is zero-decimal; the merchant account sets the currency)
|
|
348
|
+
toKlarna(money('1000', 'HUF')); // → 100000 (Klarna: ISO minor units, so HUF in fillér)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The same 1000 units of each currency on the wire (from `test/processors.test.ts`):
|
|
352
|
+
|
|
353
|
+
| | ISO exp | `toStripeAmount` | `toAdyen().value` | `toCheckoutAmount` | `toPayPalValue` | `toBraintree` | `toKlarna` |
|
|
354
|
+
| --- | ------- | ---------------: | ----------------: | -----------------: | --------------: | ------------: | ---------: |
|
|
355
|
+
| USD | 2 | 100000 | 100000 | 100000 | "1000.00" | "1000.00" | 100000 |
|
|
356
|
+
| JPY | 0 | 1000 | 1000 | 1000 | "1000" | "1000" | - |
|
|
357
|
+
| ISK | 0 | 100000 | 100000 | 1000 | - | "1000" | - |
|
|
358
|
+
| IDR | 2 | 100000 | 1000 | 100000 | - | "1000.00" | - |
|
|
359
|
+
| CLP | 0 | 1000 | 100000 | 100000 | - | "1000" | - |
|
|
360
|
+
| KWD | 3 | 1000000 | 1000000 | 1000000 | - | - | - |
|
|
361
|
+
| HUF | 2 | 100000 | 100000 | 100000 | "1000" | "1000.00" | 100000 |
|
|
362
|
+
|
|
363
|
+
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.
|
|
364
|
+
|
|
365
|
+
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").
|
|
366
|
+
|
|
367
|
+
### Databases
|
|
368
|
+
|
|
369
|
+
| storage | Postgres | MySQL | SQLite | helpers |
|
|
370
|
+
| ------------------------------------ | ------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
371
|
+
| **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` |
|
|
372
|
+
| **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(...)` |
|
|
373
|
+
| **JSON** | `jsonb` `{amount: "12.34", currency}` | `JSON` | `TEXT` | `JSON.stringify(m)`, `fromJSON` |
|
|
374
|
+
|
|
375
|
+
- 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
|
+
- Choose a NUMERIC scale of at least the largest exponent you store: 4 covers CLF/UYW, and 3 covers BHD/KWD.
|
|
377
|
+
- Prisma maps `BigInt` to JS `bigint`. The helpers accept it, and `fromMinor` normalises it back to a number when it is safe.
|
|
378
|
+
- `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
|
+
|
|
380
|
+
Prisma client extension (from `examples/prisma`, which runs end to end against SQLite):
|
|
381
|
+
|
|
382
|
+
<!-- doctest: skip (needs a database; examples/prisma runs it) -->
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
const db = new PrismaClient().$extends({
|
|
386
|
+
result: {
|
|
387
|
+
order: {
|
|
388
|
+
total: minorMoneyField('totalMinor', 'currency'), // two columns -> Money
|
|
389
|
+
shippingMoney: decimalMoneyField('shipping', { currency: 'EUR' }), // Decimal column -> Money<'EUR'>
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
await db.order.create({
|
|
394
|
+
data: {
|
|
395
|
+
...toMinorColumns(total, 'totalMinor', 'currency'),
|
|
396
|
+
shipping: toDecimalValue(fee, Prisma.Decimal),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
(await db.order.findFirstOrThrow()).total; // Money
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`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
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Currency data: source, generation, verification
|
|
407
|
+
|
|
408
|
+
| file | source |
|
|
409
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
410
|
+
| `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. |
|
|
411
|
+
| `data/stripe.json` | https://docs.stripe.com/currencies, retrieved 2026-11-07, with verbatim quotes for every special case |
|
|
412
|
+
| `data/adyen.json` | https://docs.adyen.com/development-resources/currency-codes, retrieved 2026-11-07 |
|
|
413
|
+
| `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 |
|
|
414
|
+
| `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 |
|
|
415
|
+
| `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
|
+
| `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
|
+
| `data/cash-rounding.json` | Central-bank, statute and government pages per rule. Each entry has a `source` URL and a `verified` flag. |
|
|
418
|
+
|
|
419
|
+
- `pnpm gen` (`scripts/gen-currencies.mjs`) turns these into `src/generated/*.ts`.
|
|
420
|
+
- `pnpm gen:check` fails if the generated files are stale. It also runs inside the test suite.
|
|
421
|
+
- `pnpm gen:fetch` downloads the live SIX list and diffs it against the snapshot (no differences on 2026-11-07).
|
|
422
|
+
- Tests assert every ISO exponent against the published list of non-2 exponents:
|
|
423
|
+
- 0: BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF
|
|
424
|
+
- 3: BHD IQD JOD KWD LYD OMR TND
|
|
425
|
+
- 4: CLF UYW
|
|
426
|
+
- 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
|
+
|
|
428
|
+
Recent ISO changes that are already encoded:
|
|
429
|
+
|
|
430
|
+
- Added: ZWG, SLE, VES/VED, XCG (which replaced ANG in 2025-03) and XAD.
|
|
431
|
+
- Withdrawn: BGN (Bulgaria moved to the euro on 2026-01-01), HRK, ZWL, SLL, CUC.
|
|
432
|
+
- dinero.js 2.0.2 still ships BGN.
|
|
433
|
+
|
|
434
|
+
Processor differences encoded:
|
|
435
|
+
|
|
436
|
+
| | ISO | Stripe | Adyen | Checkout.com | PayPal | Braintree | Klarna |
|
|
437
|
+
| ------------------- | --------- | ---------------------------------------------------------------------------- | ------------- | -------------------------------------------------- | -------------------------------------- | ------------------------------------------ | ------------------------------------ |
|
|
438
|
+
| ISK | 0 | 2, amount must be a multiple of 100 | 2 | 0 | n/a | 0 | n/a |
|
|
439
|
+
| UGX | 0 | 2, amount must be a multiple of 100 (confirmed, see [Decisions](#decisions)) | 0 | 0 | n/a | 0 | n/a |
|
|
440
|
+
| CLP | 0 | 0 | **2** | **2**, payments must be a multiple of 100 | n/a | 0 | n/a |
|
|
441
|
+
| IDR | 2 | 2 | **0** | 2 | n/a | 2 | n/a |
|
|
442
|
+
| CVE | 2 | 2 | **0** | 2 | n/a | 2 | n/a |
|
|
443
|
+
| MGA | 2 | **0** | not supported | 2 | n/a | n/a | n/a |
|
|
444
|
+
| 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) |
|
|
445
|
+
| 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 |
|
|
446
|
+
| IQD, LYD | 3 | not supported | 3 | 3, last digit must be 0 | n/a | n/a | n/a |
|
|
447
|
+
| RUB | 2 | 2 | 2 | n/a | 2 (one of its two currency pages only) | 2 | n/a |
|
|
448
|
+
| LAK | 2 | 2 | 2 | 2 | n/a | **0** | n/a |
|
|
449
|
+
| HRK LTL ZMK ZWD | withdrawn | n/a | n/a | n/a | n/a | still listed (2) | n/a |
|
|
450
|
+
| amount format | | integer | integer | integer | decimal string | decimal string, no sign, no currency field | integer; negative for discount lines |
|
|
451
|
+
| ANG, STD | withdrawn | still listed (2) | n/a | n/a | n/a | STD still listed (2) | n/a |
|
|
452
|
+
| SLL | withdrawn | n/a | n/a | still listed (2) | n/a | still listed (2) | n/a |
|
|
453
|
+
| CNH, VEF | not ISO | n/a | listed (2) | n/a | n/a | n/a | n/a |
|
|
454
|
+
| XDR | N.A. | n/a | n/a | listed (2) | n/a | n/a | n/a |
|
|
455
|
+
| 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 |
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## What is proved
|
|
460
|
+
|
|
461
|
+
`proofs/` holds [Lean 4](https://lean-lang.org) proofs (with Mathlib) of two kinds:
|
|
462
|
+
|
|
463
|
+
- **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).
|
|
464
|
+
- **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).
|
|
465
|
+
|
|
466
|
+
`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.
|
|
467
|
+
|
|
468
|
+
### Rounding kernels: proved code (`proofs/Ducat/Rounding.lean`)
|
|
469
|
+
|
|
470
|
+
`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`:
|
|
471
|
+
|
|
472
|
+
- `kernel_sound`: whatever the generated kernel returns is correctly rounded.
|
|
473
|
+
- `rounds_unique`: the specification has at most one answer, so "correctly rounded" means "the right answer".
|
|
474
|
+
- `kernel_fails_iff`: the kernel throws exactly for `unnecessary` on an inexact division, and never otherwise.
|
|
475
|
+
- `kernel_correct`: so whenever a correctly rounded answer exists, the kernel returns it.
|
|
476
|
+
|
|
477
|
+
### Allocation: proved model (`proofs/Ducat/Allocate.lean`)
|
|
478
|
+
|
|
479
|
+
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.
|
|
480
|
+
|
|
481
|
+
- `alloc_sum`: the parts add up to `x`, for every remainder strategy.
|
|
482
|
+
- `alloc_zero`: a bucket with zero weight gets zero.
|
|
483
|
+
- `alloc_near_largest`: with `'largest'` (Hamilton's method), each part is less than one unit from its exact share `x·w/T`.
|
|
484
|
+
- `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.
|
|
485
|
+
- `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.
|
|
486
|
+
- 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.
|
|
487
|
+
|
|
488
|
+
### Instalments, chunks and schedules: proved models (`proofs/Ducat/Schedule.lean`)
|
|
489
|
+
|
|
490
|
+
For a non-negative total; negative totals are the mirror image, as in `allocate`.
|
|
491
|
+
|
|
492
|
+
- `inst_sum`: the instalments add up to the total, for every remainder mode.
|
|
493
|
+
- `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.
|
|
494
|
+
- `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).
|
|
495
|
+
- `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.
|
|
496
|
+
- `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.
|
|
497
|
+
|
|
498
|
+
### Cash rounding: proved model (`proofs/Ducat/Cash.lean`)
|
|
499
|
+
|
|
500
|
+
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.
|
|
501
|
+
|
|
502
|
+
- `increment_multiple`: the result is a whole number of increments.
|
|
503
|
+
- `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.
|
|
504
|
+
- `increment_exact`, `increment_idem`: an amount that already is a multiple comes back unchanged in every mode, so rounding twice is rounding once.
|
|
505
|
+
|
|
506
|
+
### Tax, scale and conversion: proved model (`proofs/Ducat/Tax.lean`)
|
|
507
|
+
|
|
508
|
+
These are stated over the proved kernel (`divRound`):
|
|
509
|
+
|
|
510
|
+
- `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.)
|
|
511
|
+
- `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.
|
|
512
|
+
- `toScale_roundtrip`: raising the scale and lowering it again gives back the original value in every mode, and `unnecessary` does not throw.
|
|
513
|
+
- `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.
|
|
514
|
+
|
|
515
|
+
### Tested, not proved
|
|
516
|
+
|
|
517
|
+
- **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`).
|
|
518
|
+
- **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.
|
|
519
|
+
- **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`).
|
|
520
|
+
- **The TypeScript tax, conversion and `multiplyByRatio` code:**
|
|
521
|
+
- that each rounds with exactly one kernel call on the exact product;
|
|
522
|
+
- the scale shifts;
|
|
523
|
+
- that every call site passes a divisor `d > 0`, with the sign moved to the numerator.
|
|
524
|
+
|
|
525
|
+
These are covered by unit vectors and by property tests against decimal.js at 200 significant digits (`test/oracle.test.ts`).
|
|
526
|
+
|
|
527
|
+
- **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
|
+
- `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
|
+
|
|
530
|
+
## Tests
|
|
531
|
+
|
|
532
|
+
`pnpm test` runs the vitest suite.
|
|
533
|
+
|
|
534
|
+
- Unit tests use real-world vectors:
|
|
535
|
+
- the `java.math.RoundingMode` table for all modes
|
|
536
|
+
- UK VAT 1/6 extraction, German 19% and 7% VAT
|
|
537
|
+
- the HMRC 9.99 case, the classic `2.675` float trap
|
|
538
|
+
- the allocation splits from Fowler and dinero
|
|
539
|
+
- Stripe and Adyen doc examples, the statutory cash-rounding thresholds for SE, NO, DK, HU and CA
|
|
540
|
+
- Intl output for de, fr, ja, ar and en-IN
|
|
541
|
+
- fast-check property tests cover:
|
|
542
|
+
- `divideAndRound` against an independent floor-based bigint oracle over the full safe range, including float-quotient edge cases
|
|
543
|
+
- rounding-mode invariants (floor ≤ x ≤ ceil, half modes within ½)
|
|
544
|
+
- add associativity, commutativity and identity, and agreement with bigint arithmetic
|
|
545
|
+
- the number-iff-safe representation invariant
|
|
546
|
+
- decimal, JSON and minor-unit round trips
|
|
547
|
+
- multiply against exact rationals
|
|
548
|
+
- allocation: sums are exact, each part is within one unit of its ideal share, results are deterministic, and largest-remainder order holds
|
|
549
|
+
- instalment sums, tax breakdowns that add up
|
|
550
|
+
- `parse(format(x))` across 14 locales
|
|
551
|
+
- the Intl hand-off being bit-identical
|
|
552
|
+
- `pnpm typecheck` compiles `test/types.ts`. Its `@ts-expect-error` lines prove that currency mismatch, readonly fields and unknown rounding modes are compile errors.
|
|
553
|
+
- `pnpm test:runtimes` runs:
|
|
554
|
+
- the **full suite on Node 24, Bun 1.4 (via `bun test`) and Deno 2.9**: all pass
|
|
555
|
+
- 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).
|
|
556
|
+
- `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.
|
|
557
|
+
- `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.
|
|
558
|
+
- `pnpm test:mutation` runs Stryker over `src/` with property counts capped; CI runs it nightly and keeps the report.
|
|
559
|
+
|
|
560
|
+
## Benchmarks
|
|
561
|
+
|
|
562
|
+
- Clean-machine numbers against the other money and fixed-point libraries are in [How it compares](#how-it-compares) and
|
|
563
|
+
[docs/COMPARISON.md](docs/COMPARISON.md), generated from [compare/](compare) on one quiet AMD EPYC Genoa vCPU under
|
|
564
|
+
Node, Bun and Deno.
|
|
565
|
+
- CodSpeed measures every pull request by instruction count, so speed regressions show up even on noisy CI machines.
|
|
566
|
+
- The benchmarks in `bench/` are for quick local comparisons; on a shared machine their run-to-run variation is often
|
|
567
|
+
±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
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Decisions
|
|
576
|
+
|
|
577
|
+
- **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:
|
|
578
|
+
- Processors disagree with ISO. Stripe sends ISK and UGX as two-decimal; Adyen has its own exceptions.
|
|
579
|
+
- ISO exponents change over time.
|
|
580
|
+
- Generic consumers (other languages, `NUMERIC` and `Decimal` columns, logs) expect a decimal amount.
|
|
581
|
+
|
|
582
|
+
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.
|
|
583
|
+
|
|
584
|
+
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).
|
|
585
|
+
|
|
586
|
+
- **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.
|
|
587
|
+
- **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).
|
|
588
|
+
- **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
|
+
- **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
|
+
- **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`.
|
|
591
|
+
- **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
|
+
- **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`.
|
|
594
|
+
- **Processors:** Stripe, Adyen, Checkout.com, PayPal, Braintree and Klarna, each an opt-in entry point (`@ducatjs/money/<processor>`).
|
|
595
|
+
- **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
|
+
|
|
597
|
+
## What's missing (honest list)
|
|
598
|
+
|
|
599
|
+
- **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.
|
|
602
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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
|
+
- **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).
|
|
608
|
+
- **Formatting gaps:**
|
|
609
|
+
- No pluggable formatter for engines without Intl.
|
|
610
|
+
- `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.
|
|
611
|
+
- **Not yet benchmarked:**
|
|
612
|
+
- Browsers themselves; only their engines were tested via the jsvu shells.
|
|
613
|
+
- Memory and GC pressure, which were reasoned about but not measured.
|
|
614
|
+
- dinero.js's bigint calculator.
|
|
615
|
+
- **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.
|
|
617
|
+
- **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
|
+
- **No docs site yet.**
|