@ultimat3/money 1.2.0 → 2.0.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/CLAUDE.md +118 -0
- package/README.md +83 -6
- package/package.json +4 -2
- package/src/allocate.ts +53 -26
- package/src/arithmetic.ts +50 -11
- package/src/convert.ts +81 -13
- package/src/currency.ts +84 -7
- package/src/errors.ts +151 -8
- package/src/factor.ts +33 -0
- package/src/format.ts +45 -14
- package/src/index.ts +10 -0
- package/src/money.ts +72 -23
- package/src/rescale.ts +31 -0
- package/src/rounding.ts +64 -6
- package/src/scale.ts +73 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @ultimat3/money — agent notes
|
|
2
|
+
|
|
3
|
+
**Tier 1.** May import `@ultimat3/core`, `@ultimat3/schema`. No external deps, ever.
|
|
4
|
+
`Money` is the shape the whole framework passes around, and the shape itself is declared once, in
|
|
5
|
+
`packages/schema/src/money-value.ts` — read it there rather than trusting a copy here, which is
|
|
6
|
+
the same reason `type-pins.ts` pins invariants instead of a snapshot. What this package adds is
|
|
7
|
+
the *meaning* of the optional `scale`: the decimal exponent `minor` counts in when it is not the
|
|
8
|
+
currency's own. Absent on every value that predates it, and absent again whenever it would only
|
|
9
|
+
restate the currency, so an amount at the natural scale has exactly one encoding and existing
|
|
10
|
+
JSON is untouched.
|
|
11
|
+
|
|
12
|
+
**`Money` is an alias, not a declaration.** It is `@ultimat3/schema`'s `MoneyValue` — tier 0, the
|
|
13
|
+
only tier every package may import — and `@ultimat3/entity`'s `MoneyValue` is the same alias. Never
|
|
14
|
+
restate the shape here: it was three structural copies, the entity layer's had a `bigint` `minor`,
|
|
15
|
+
and a row that layer decoded therefore threw inside `JSON.stringify` and failed `t.money`. That is
|
|
16
|
+
also why `minor` is a `number` and stays one — money crosses every wire this framework projects,
|
|
17
|
+
and `JSON.stringify` refuses a bigint. `packages/entity/src/type-pins.ts` fails the build if the
|
|
18
|
+
alias is re-declared, if `minor` widens back to a `bigint`, if any field loses `readonly`, if a
|
|
19
|
+
fourth field appears — or if `scale` ever stops being optional, which is the pin that says the
|
|
20
|
+
shape is still additive and this is still a minor version.
|
|
21
|
+
|
|
22
|
+
## Boundary
|
|
23
|
+
|
|
24
|
+
| File | Single responsibility |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `money.ts` | the value type + constructors (`money`, `fromDecimal`, `toDecimalString`) |
|
|
27
|
+
| `currency.ts` | the currency table — the ISO-4217 rows shipped, the rows an app registers, and the minor-unit exponent every natural scale derives from |
|
|
28
|
+
| `scale.ts` | what decimal place a value's `minor` counts (`moneyScale`), which scales are legal (`assertScale`), and the exact bigint widening every comparison starts with (`minorAt`) |
|
|
29
|
+
| `rescale.ts` | moving between scales: widening exact, lossy narrowing only with a named mode |
|
|
30
|
+
| `arithmetic.ts` | add/subtract/multiply/compare, refuses mixed currencies |
|
|
31
|
+
| `allocate.ts` | largest-remainder splits that preserve the total |
|
|
32
|
+
| `factor.ts` | the exact fraction a scaling factor's decimal spelling names. `factorFraction` is internal — never exported; the `Fraction` **type** is public, because `ExchangeRate.ratio` is one |
|
|
33
|
+
| `rounding.ts` | explicit modes, no implicit default, over a float (`roundToInteger`) or a ratio (`roundRatio`) |
|
|
34
|
+
| `format.ts` | `Intl.NumberFormat` only, digits from the exponent |
|
|
35
|
+
| `convert.ts` | explicit rate + `RateProvider`, records provenance |
|
|
36
|
+
|
|
37
|
+
## Rules
|
|
38
|
+
|
|
39
|
+
- Never a float in a stored or returned amount. `fromDecimal` takes a **string**.
|
|
40
|
+
- Never `/ 100`, and never `exponentOf(amount.currency)` for a value's own precision — that is
|
|
41
|
+
`moneyScale(amount)`, which falls back to the currency and is right for both. `exponentOf` and
|
|
42
|
+
`scaleOf` still answer for a *currency*, which is a different question.
|
|
43
|
+
- **Two scales meet at the finer one, never the coarser.** `add`, `subtract` and `compare`
|
|
44
|
+
widen through `minorAt` (bigint, exact) before they do anything else, so a sub-cent fee added to
|
|
45
|
+
a cent survives and a comparison answers where storing the widened value would rightly be
|
|
46
|
+
refused. Rounding down to the coarser scale would silently delete the smaller operand.
|
|
47
|
+
- **Lossy narrowing names its mode at the call.** `rescale(m, 2)` throws rather than drop a
|
|
48
|
+
non-zero digit; `rescale(m, 2, 'half-up')` is the same rule `fromDecimal` applies to excess
|
|
49
|
+
precision. A narrowing that loses nothing needs no mode — nothing is being decided.
|
|
50
|
+
- **`money()` is the only place the canonical form is decided.** It drops a `scale` equal to the
|
|
51
|
+
currency's exponent — only equal, so a deliberately *coarser* scale (`money(5, 'USD', 0)`, whole
|
|
52
|
+
dollars) is kept exactly as a finer one is. Every constructor, every arithmetic result and every
|
|
53
|
+
allocation part therefore agree on one encoding without any of them repeating the rule.
|
|
54
|
+
- **A widened value that will not fit is a scale error, not a fractional-minor one.** `add`,
|
|
55
|
+
`subtract` and `rescale` convert through `toMinor`, which throws `X_MONEY_SCALE_INVALID` naming
|
|
56
|
+
the finest scale that fits. Letting `money()` refuse the raw number reported a fractional minor
|
|
57
|
+
nobody wrote, with a `fromDecimal` fix line that threw the same error again.
|
|
58
|
+
- Never combine currencies without `convert()` first.
|
|
59
|
+
- Never round without naming a `RoundingMode` in the call or accepting the stated default.
|
|
60
|
+
- **Never scale in floats and round after.** `multiply`, `divide`, `convert` **and `fromDecimal`**
|
|
61
|
+
take the decimal spelling as an exact fraction (`factorFraction`, or the digit strings
|
|
62
|
+
themselves) and hand it to `roundRatio`, so the mode judges 100.5 and not the
|
|
63
|
+
100.49999999999999 `100 * 1.005` produces. A new scaling entry point goes through the same pair
|
|
64
|
+
— `roundToInteger(a * b, mode)` is the bug, written again. `fromDecimal` was the last float
|
|
65
|
+
path and it is the one every user-typed price goes through: `Number('0.4999999999999999999')`
|
|
66
|
+
is exactly 0.5, so `half-up` saw a tie the written decimal does not have.
|
|
67
|
+
- **`convert` preserves the amount's own `scale`.** `exponentOf(target)` decides the natural scale
|
|
68
|
+
of a value that names none; a value that names one keeps it, because narrowing $0.000002 to
|
|
69
|
+
EUR's two decimals is the 10,000x reinterpretation `scale` was added to prevent. Same rule as
|
|
70
|
+
`multiply` and `divide`, which already kept theirs.
|
|
71
|
+
- **`money()` normalises `-0` to `0`.** One amount must not have two identities: `JSON.stringify`
|
|
72
|
+
writes `-0` as `0` while `Object.is` and any keyed `Map` see something else, so a refund
|
|
73
|
+
rounding to nothing produced a value its own wire format cannot reproduce. `roundToInteger`
|
|
74
|
+
refuses to produce it either — `sign * 0` is the source.
|
|
75
|
+
- **An audit timestamp is never fabricated.** `convertWith`'s identity branch takes `{ at }` or an
|
|
76
|
+
injected `{ clock }` (default `systemClock`); `new Date(0)` claimed a parity observed in 1970.
|
|
77
|
+
- **A derived rate carries its fraction, never its reciprocal.** `fixedRateProvider` answers the
|
|
78
|
+
inverse direction by swapping `ExchangeRate.ratio`'s numerator and denominator: a table naming
|
|
79
|
+
`USD/EUR: 0.92` names 23/25, so EUR→USD is exactly 25/23, where `1 / 0.92` is a double whose own
|
|
80
|
+
decimal spelling rounds a large amount one minor unit low. `rate` stays the readable number the
|
|
81
|
+
audit trail records; `convert` scales by `ratio` whenever the provider supplied one.
|
|
82
|
+
- **One place decides a sign.** `formatMoney` is `formatMoneyParts` joined, and `accounting`
|
|
83
|
+
reaches `Intl` as `currencySign` — so the locale places the minus and picks the parenthesised
|
|
84
|
+
form, and a UI styling the parts cannot render a different format from the label beside it.
|
|
85
|
+
- **The table is open, and it is opened by a call.** `registerCurrency({ code, exponent, name })` is
|
|
86
|
+
how an app adds a currency the shipped rows do not carry — axiom 8: `As of 2026-08`, 53 of ~180
|
|
87
|
+
ISO codes is a *convention*, and a convention an app cannot extend is a fork waiting to happen.
|
|
88
|
+
The rest of the framework already treated the set as open — `@ultimat3/schema`'s `moneySchema`,
|
|
89
|
+
the published OpenAPI `pattern` and `@ultimat3/entity`'s `char(3)` CHECK all accept any
|
|
90
|
+
`^[A-Z]{3}$` — so an unregistered code could arrive over HTTP and reach a row, and only
|
|
91
|
+
arithmetic refused it.
|
|
92
|
+
- **What a well-formed code IS lives in `packages/schema/src/money-value.ts`, and nothing here
|
|
93
|
+
restates it.** `CURRENCY_CODE_PATTERN` is the pattern *source* — a string, because the two
|
|
94
|
+
projections that cannot call a predicate need it: the published OpenAPI `pattern` and
|
|
95
|
+
`@ultimat3/entity`'s Postgres `~` CHECK — and `isCurrencyCode` is the predicate over it,
|
|
96
|
+
`isMoneyScale`'s twin, taking `unknown` because every caller is a boundary. `registerCurrency`
|
|
97
|
+
imports it for exactly the reason `assertScale` imports `isMoneyScale`, and `currency.test.ts`
|
|
98
|
+
asserts the shipped table through it rather than through a local regex. Never write
|
|
99
|
+
`/^[A-Z]{3}$/` in this package: it was seven copies across the repo, each individually correct,
|
|
100
|
+
and the only place a divergence would have surfaced is a psql session or a generated client.
|
|
101
|
+
- **A registration is refused, never defaulted.** No exponent is guessable: a silent 2 reads
|
|
102
|
+
`1.23456789 XBT` as `1.23` and a stored `minor` shifts by a power of ten. Bad shape, bad exponent
|
|
103
|
+
or an empty name is `X_CURRENCY_INVALID`; a second declaration of one code is
|
|
104
|
+
`X_CURRENCY_REDEFINED`, and an **identical** one is a no-op so a twice-imported module is not a
|
|
105
|
+
crash. A shipped ISO row cannot be redefined at all.
|
|
106
|
+
- **Two enumerations, two questions.** `CURRENCIES` is the constant this package ships;
|
|
107
|
+
`currencyCodes()` is what this process accepts, registrations included, and it is the list
|
|
108
|
+
`X_CURRENCY_UNKNOWN`'s fix line names — so it must include them or that fix is the dead end it
|
|
109
|
+
used to be.
|
|
110
|
+
- Adding a currency to the *shipped* rows: one row in `currency.ts` with its correct exponent, plus
|
|
111
|
+
a format test. An app never needs this — that is what `registerCurrency` is for.
|
|
112
|
+
|
|
113
|
+
## Commands
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
bun test packages/money
|
|
117
|
+
bun run --filter @ultimat3/money typecheck
|
|
118
|
+
```
|
package/README.md
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# 💶 @ultimat3/money
|
|
2
2
|
|
|
3
3
|
**Golden rule: integer minor units, currency always attached, `Intl` at the edge.**
|
|
4
|
-
`0.1 + 0.2 !== 0.3`, so no amount is ever a float. `Money`
|
|
5
|
-
|
|
4
|
+
`0.1 + 0.2 !== 0.3`, so no amount is ever a float. `Money` carries `minor` and `currency` together
|
|
5
|
+
— plus an optional `scale` — and arithmetic across two currencies throws instead of guessing.
|
|
6
|
+
|
|
7
|
+
`Money` **is** `@ultimat3/schema`'s `MoneyValue`, and so is `@ultimat3/entity`'s: one declaration
|
|
8
|
+
at tier 0, aliased twice, never restated. A row a `money()` column decodes is therefore a `Money`
|
|
9
|
+
already — `add(row.price, shipping)` and `formatMoney(row.price, locale)` take it with no cast.
|
|
10
|
+
`minor` is a `number` because money is projected onto every wire the framework generates and
|
|
11
|
+
`JSON.stringify` refuses a bigint; the `bigint` column that backs it refuses a value past ±2^53 on
|
|
12
|
+
read rather than rounding it. → [Money](https://github.com/developerz-ai/ultimate/wiki/Money)
|
|
6
13
|
|
|
7
14
|
| Concern | Store | Format |
|
|
8
15
|
|---|---|---|
|
|
9
16
|
| Amount | integer minor units (`1299`) | `Intl.NumberFormat`, `style: 'currency'` |
|
|
10
|
-
| Currency |
|
|
11
|
-
| Scale |
|
|
17
|
+
| Currency | a 3-letter code (`'EUR'`) — shipped ISO-4217 or `registerCurrency`'d | fraction digits derived from its exponent |
|
|
18
|
+
| Scale | whenever it differs from the currency's, finer or coarser (`scale: 6`) | `10 ** moneyScale(amount)` — never a literal `/ 100` |
|
|
12
19
|
| FX rate | explicit argument + timestamp | recorded on the converted value |
|
|
13
20
|
|
|
14
21
|
## Use
|
|
@@ -30,6 +37,63 @@ add(price, money(500, 'USD')); // throws X_CURRENCY_MISMATCH
|
|
|
30
37
|
`fromDecimal` scales by it (`'1.234'` KWD → 1234), `toDecimalString` reverses it, and
|
|
31
38
|
`formatMoney` sets the fraction digits from it. Hardcoding `/ 100` is a JPY bug and a KWD bug.
|
|
32
39
|
|
|
40
|
+
## A currency the shipped rows do not carry
|
|
41
|
+
|
|
42
|
+
`As of 2026-08`, 53 ISO-4217 rows ship. They are a *convention* — one useful subset — so an app
|
|
43
|
+
adds its own with a call rather than a fork: a local currency, a scrip, a loyalty point, a token.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { fromDecimal, registerCurrency } from '@ultimat3/money';
|
|
47
|
+
|
|
48
|
+
registerCurrency({ code: 'XBT', exponent: 8, name: 'Bitcoin' });
|
|
49
|
+
fromDecimal('1.23456789', 'XBT'); // { minor: 123456789, currency: 'XBT' }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Once, at boot, before the first amount in that currency is built. The rules, each a refusal:
|
|
53
|
+
|
|
54
|
+
| Rule | Refusal |
|
|
55
|
+
|---|---|
|
|
56
|
+
| three A–Z letters — `Intl` throws a `RangeError` on anything else | `X_CURRENCY_INVALID` |
|
|
57
|
+
| a whole exponent from 0 to `MAX_MONEY_SCALE` — there is no safe default, and a silent 2 is the corrupted maths this package exists to prevent | `X_CURRENCY_INVALID` |
|
|
58
|
+
| a non-empty name | `X_CURRENCY_INVALID` |
|
|
59
|
+
| one code, one declaration — a second exponent reinterprets every stored amount by a power of ten, and a second name makes `currencyInfo().name` depend on import order. An **identical** re-registration is a no-op, so a module imported twice is not a crash | `X_CURRENCY_REDEFINED` |
|
|
60
|
+
| a shipped ISO row is not the app's to redefine | `X_CURRENCY_REDEFINED` |
|
|
61
|
+
|
|
62
|
+
`CURRENCIES` stays the shipped constant; `currencyCodes()` answers for *this* process, registrations
|
|
63
|
+
included. That is why one is a value and the other is a call.
|
|
64
|
+
|
|
65
|
+
## Sub-cent amounts carry a scale
|
|
66
|
+
|
|
67
|
+
`money(2, 'USD', 6)` is $0.000002 — `minor` counting 10⁻⁶ instead of the currency's own 10⁻².
|
|
68
|
+
A value that names no scale means the currency's, which is every amount that already exists, so
|
|
69
|
+
nothing about `{ minor, currency }` changes: same shape, same JSON, same columns. Only a scale
|
|
70
|
+
*equal* to the currency's is dropped, so a deliberately coarser one is kept too: `money(5, 'USD', 0)`
|
|
71
|
+
is $5 counted in whole dollars, and `rescale()` produces such values legitimately.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { add, fromDecimal, money, moneyScale, rescale } from '@ultimat3/money';
|
|
75
|
+
|
|
76
|
+
moneyScale(money(1299, 'EUR')); // 2 — the currency's own
|
|
77
|
+
moneyScale(money(2, 'USD', 6)); // 6
|
|
78
|
+
rescale(money(80, 'USD'), 8); // $0.80 as 80,000,000 hundred-millionths
|
|
79
|
+
rescale(money(1_234_567, 'USD', 6), 2); // throws X_MONEY_NOT_INTEGER — digits would go
|
|
80
|
+
rescale(money(1_234_567, 'USD', 6), 2, 'half-up'); // 123¢, the loss named at the call
|
|
81
|
+
fromDecimal('0.000002', 'USD', { scale: 6 });
|
|
82
|
+
add(money(1, 'USD'), money(2, 'USD', 6)); // meets at scale 6: 10002, nothing lost
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Arithmetic normalises to the *finer* of two scales, never the coarser — adding a sub-cent fee to
|
|
86
|
+
a cent cannot round the fee away. `compare` and `equals` read the value rather than the encoding,
|
|
87
|
+
so 1299 EUR and 12,990,000 EUR at scale 6 are one amount. `multiply`, `divide`, `negate`,
|
|
88
|
+
`allocate` **and `convert`** keep the scale they were handed — a micro-priced amount is still
|
|
89
|
+
micro-priced in the target currency. Widening is exact and free; a *lossy* narrowing needs
|
|
90
|
+
a `RoundingMode` at the call site, exactly as excess precision does in `fromDecimal` — a narrowing
|
|
91
|
+
that drops only zeros is exact and needs no mode.
|
|
92
|
+
|
|
93
|
+
It exists because whole cents could not name the cost of a model call: 200 tokens at $0.80 per
|
|
94
|
+
million is $0.00016, and rounding that up to 1¢ bills 62x — a budget built on that number is
|
|
95
|
+
fiction. The alternative was a second money type.
|
|
96
|
+
|
|
33
97
|
## Allocation
|
|
34
98
|
|
|
35
99
|
`allocate(money(100, 'USD'), 3)` → `34, 33, 33`. Largest-remainder split: floor every part,
|
|
@@ -50,12 +114,25 @@ returns the source amount, the rate, and its timestamp alongside the result —
|
|
|
50
114
|
audit has to be able to reproduce the number. Implement `RateProvider` for a live feed;
|
|
51
115
|
`fixedRateProvider()` covers tests, seeds and manually agreed invoice rates.
|
|
52
116
|
|
|
117
|
+
A rate may also carry `ratio` — the exact `Fraction` its `rate` approximates — and `convert`
|
|
118
|
+
scales by that when it is there. It is how a derived direction stays exact: a table naming
|
|
119
|
+
`USD/EUR: 0.92` names 23/25, so `fixedRateProvider` answers EUR→USD with 25/23 rather than the
|
|
120
|
+
double `1 / 0.92`, whose own decimal spelling rounds a large amount one minor unit low. `rate`
|
|
121
|
+
stays the readable number the audit trail records.
|
|
122
|
+
|
|
123
|
+
`convert` preserves the amount's own `scale`. `convert(money(2, 'USD', 6), 'EUR', parity)` is
|
|
124
|
+
€0.000002, not €0.02 — the target currency's minor unit decides nothing about a value that
|
|
125
|
+
already carries its own precision. `convertWith` on a same-currency pair stamps `at` from an
|
|
126
|
+
injected `Clock` (`{ clock }`, default `systemClock`) or from an explicit `{ at }`, never from
|
|
127
|
+
the epoch: `ExchangeRate.at` is the audit trail.
|
|
128
|
+
|
|
53
129
|
## Errors
|
|
54
130
|
|
|
55
131
|
| Code | When |
|
|
56
132
|
|---|---|
|
|
57
|
-
| `X_MONEY_NOT_INTEGER` | fractional minor units,
|
|
58
|
-
| `
|
|
133
|
+
| `X_MONEY_NOT_INTEGER` | fractional minor units, a decimal string more precise than the scale, or a `rescale` that would drop a digit with no mode named |
|
|
134
|
+
| `X_MONEY_SCALE_INVALID` | a scale that is not a whole number of decimal places in 0…15, or a widening whose result no longer fits a safe integer |
|
|
135
|
+
| `X_CURRENCY_UNKNOWN` | a code neither shipped nor registered by this process — `currencyCodes()` is the list |
|
|
59
136
|
| `X_CURRENCY_MISMATCH` | arithmetic across two currencies |
|
|
60
137
|
| `X_ALLOCATION_INVALID` | bad part count, empty/negative/all-zero ratios, percentages ≠ 100 |
|
|
61
138
|
| `X_RATE_MISSING` | no rate for the pair — never assumes parity |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "2.0.0",
|
|
35
|
+
"@ultimat3/schema": "2.0.0"
|
|
34
36
|
}
|
|
35
37
|
}
|
package/src/allocate.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { assertSameCurrency } from './arithmetic';
|
|
12
12
|
import { allocationInvalid } from './errors';
|
|
13
|
-
import {
|
|
13
|
+
import { factorFraction } from './factor';
|
|
14
|
+
import { formatMoneyDebug, type Money, money } from './money';
|
|
15
|
+
import { minorAt, moneyScale } from './scale';
|
|
14
16
|
|
|
15
17
|
/** Split into `parts` equal shares. `allocate(money(100,'USD'), 3)` → 34, 33, 33. */
|
|
16
18
|
export function allocate(amount: Money, parts: number): Money[] {
|
|
@@ -26,26 +28,22 @@ export function allocate(amount: Money, parts: number): Money[] {
|
|
|
26
28
|
*/
|
|
27
29
|
export function allocateByRatios(amount: Money, ratios: readonly number[]): Money[] {
|
|
28
30
|
if (ratios.length === 0) throw allocationInvalid('ratios must not be empty');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
total += ratio;
|
|
35
|
-
}
|
|
36
|
-
if (total <= 0) throw allocationInvalid('ratios must not all be zero');
|
|
31
|
+
const weights = weigh(ratios);
|
|
32
|
+
let total = 0n;
|
|
33
|
+
for (const weight of weights) total += weight;
|
|
34
|
+
if (total <= 0n) throw allocationInvalid('ratios must not all be zero');
|
|
37
35
|
|
|
38
36
|
const sign = amount.minor < 0 ? -1 : 1;
|
|
39
|
-
const magnitude = Math.abs(amount.minor);
|
|
37
|
+
const magnitude = BigInt(Math.abs(amount.minor));
|
|
40
38
|
|
|
41
|
-
const floors:
|
|
42
|
-
const remainders:
|
|
43
|
-
let assigned =
|
|
44
|
-
for (const
|
|
45
|
-
const exact =
|
|
46
|
-
const floor =
|
|
39
|
+
const floors: bigint[] = [];
|
|
40
|
+
const remainders: bigint[] = [];
|
|
41
|
+
let assigned = 0n;
|
|
42
|
+
for (const weight of weights) {
|
|
43
|
+
const exact = magnitude * weight;
|
|
44
|
+
const floor = exact / total;
|
|
47
45
|
floors.push(floor);
|
|
48
|
-
remainders.push(exact
|
|
46
|
+
remainders.push(exact % total);
|
|
49
47
|
assigned += floor;
|
|
50
48
|
}
|
|
51
49
|
|
|
@@ -54,14 +52,38 @@ export function allocateByRatios(amount: Money, ratios: readonly number[]): Mone
|
|
|
54
52
|
let leftover = magnitude - assigned;
|
|
55
53
|
const order = remainders
|
|
56
54
|
.map((remainder, index) => ({ remainder, index }))
|
|
57
|
-
.sort((a, b) =>
|
|
55
|
+
.sort((a, b) =>
|
|
56
|
+
a.remainder === b.remainder ? a.index - b.index : a.remainder < b.remainder ? 1 : -1,
|
|
57
|
+
);
|
|
58
58
|
for (const { index } of order) {
|
|
59
|
-
if (leftover <=
|
|
60
|
-
floors[index] = (floors[index] ??
|
|
61
|
-
leftover -=
|
|
59
|
+
if (leftover <= 0n) break;
|
|
60
|
+
floors[index] = (floors[index] ?? 0n) + 1n;
|
|
61
|
+
leftover -= 1n;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
return floors.map((minor) => money(sign * minor, amount.currency));
|
|
64
|
+
return floors.map((minor) => money(sign * Number(minor), amount.currency, amount.scale));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The ratios as exact integer weights over one common denominator.
|
|
69
|
+
*
|
|
70
|
+
* `(magnitude * ratio) / total` in floats was only exact while the product stayed under 2^53 —
|
|
71
|
+
* true of most cent amounts and false of the same invoice held in micros, where the floor came
|
|
72
|
+
* out one unit low and largest-remainder handed the difference to the wrong part. Every
|
|
73
|
+
* denominator `factorFraction` produces is a power of ten, so the common one is just the largest.
|
|
74
|
+
*/
|
|
75
|
+
function weigh(ratios: readonly number[]): bigint[] {
|
|
76
|
+
const fractions = ratios.map((ratio) => {
|
|
77
|
+
if (!Number.isFinite(ratio) || ratio < 0) {
|
|
78
|
+
throw allocationInvalid(`ratios must be finite and non-negative, got ${String(ratio)}`);
|
|
79
|
+
}
|
|
80
|
+
return factorFraction(ratio);
|
|
81
|
+
});
|
|
82
|
+
let common = 1n;
|
|
83
|
+
for (const fraction of fractions) {
|
|
84
|
+
if (fraction.denominator > common) common = fraction.denominator;
|
|
85
|
+
}
|
|
86
|
+
return fractions.map((fraction) => fraction.numerator * (common / fraction.denominator));
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
/**
|
|
@@ -78,14 +100,19 @@ export function allocateByPercentages(amount: Money, percentages: readonly numbe
|
|
|
78
100
|
|
|
79
101
|
/** Guard for callers building their own splits: parts must reconstruct the whole. */
|
|
80
102
|
export function assertAllocationSums(amount: Money, parts: readonly Money[]): void {
|
|
81
|
-
let
|
|
103
|
+
let scale = moneyScale(amount);
|
|
82
104
|
for (const part of parts) {
|
|
83
105
|
assertSameCurrency(amount, part);
|
|
84
|
-
|
|
106
|
+
scale = Math.max(scale, moneyScale(part));
|
|
85
107
|
}
|
|
86
|
-
|
|
108
|
+
// Summed at the finest scale present, so parts split finer than the whole still reconcile
|
|
109
|
+
// against it rather than reading as a total that lost everything below a cent.
|
|
110
|
+
let total = 0n;
|
|
111
|
+
for (const part of parts) total += minorAt(part, scale);
|
|
112
|
+
const whole = minorAt(amount, scale);
|
|
113
|
+
if (total !== whole) {
|
|
87
114
|
throw allocationInvalid(
|
|
88
|
-
`allocation of ${amount
|
|
115
|
+
`allocation of ${formatMoneyDebug(amount)} sums to ${total} at scale ${scale} — ${whole - total} minor unit(s) lost`,
|
|
89
116
|
);
|
|
90
117
|
}
|
|
91
118
|
}
|
package/src/arithmetic.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { allocationInvalid, currencyMismatch, currencyRequired } from './errors';
|
|
7
|
+
import { factorFraction } from './factor';
|
|
7
8
|
import { type Money, money } from './money';
|
|
8
|
-
import { DEFAULT_ROUNDING, type RoundingMode,
|
|
9
|
+
import { DEFAULT_ROUNDING, type RoundingMode, roundRatio } from './rounding';
|
|
10
|
+
import { commonScale, minorAt, toMinor } from './scale';
|
|
9
11
|
|
|
10
12
|
/** Throws `X_CURRENCY_MISMATCH` unless both operands carry the same currency. */
|
|
11
13
|
export function assertSameCurrency(left: Money, right: Money): string {
|
|
@@ -13,14 +15,28 @@ export function assertSameCurrency(left: Money, right: Money): string {
|
|
|
13
15
|
return left.currency;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Two operands meet at the finer of their scales, which is exact for both — the coarser one is
|
|
20
|
+
* widened, never the finer one rounded, so adding a sub-cent fee to a cent cannot lose the fee.
|
|
21
|
+
*/
|
|
16
22
|
export function add(left: Money, right: Money): Money {
|
|
17
23
|
const currency = assertSameCurrency(left, right);
|
|
18
|
-
|
|
24
|
+
const scale = commonScale(left, right);
|
|
25
|
+
return money(
|
|
26
|
+
toMinor(minorAt(left, scale) + minorAt(right, scale), scale, currency),
|
|
27
|
+
currency,
|
|
28
|
+
scale,
|
|
29
|
+
);
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
export function subtract(left: Money, right: Money): Money {
|
|
22
33
|
const currency = assertSameCurrency(left, right);
|
|
23
|
-
|
|
34
|
+
const scale = commonScale(left, right);
|
|
35
|
+
return money(
|
|
36
|
+
toMinor(minorAt(left, scale) - minorAt(right, scale), scale, currency),
|
|
37
|
+
currency,
|
|
38
|
+
scale,
|
|
39
|
+
);
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/** Every addend must share one currency; an empty list needs an explicit currency. */
|
|
@@ -33,18 +49,29 @@ export function sum(amounts: readonly Money[], currency?: string): Money {
|
|
|
33
49
|
/**
|
|
34
50
|
* Scale by a plain number (a tax rate, a quantity, a percentage). The result is rounded
|
|
35
51
|
* to whole minor units with an explicit mode — the default is stated, not implied.
|
|
52
|
+
*
|
|
53
|
+
* The scale is taken as the exact fraction `factor`'s decimal spelling names, never as a float
|
|
54
|
+
* product: `100 * 1.005` is 100.49999999999999, so multiplying first hid the exact 100.5 from
|
|
55
|
+
* `half-up` and billed a 0.5% fee on €1.00 as nothing.
|
|
36
56
|
*/
|
|
37
57
|
export function multiply(
|
|
38
58
|
amount: Money,
|
|
39
59
|
factor: number,
|
|
40
60
|
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
41
61
|
): Money {
|
|
42
|
-
|
|
62
|
+
const ratio = factorFraction(factor);
|
|
63
|
+
// Scale-preserving: a fee on a micro-priced amount stays a micro-priced amount.
|
|
64
|
+
return money(
|
|
65
|
+
roundRatio(BigInt(amount.minor) * ratio.numerator, ratio.denominator, mode),
|
|
66
|
+
amount.currency,
|
|
67
|
+
amount.scale,
|
|
68
|
+
);
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
/**
|
|
46
72
|
* Divide into a single share. Use `allocate` when the whole must be preserved —
|
|
47
|
-
* `divide` alone loses the remainder by design.
|
|
73
|
+
* `divide` alone loses the remainder by design. Exact for the same reason `multiply` is:
|
|
74
|
+
* dividing by `d` is scaling by the reciprocal of the fraction `d` names.
|
|
48
75
|
*/
|
|
49
76
|
export function divide(
|
|
50
77
|
amount: Money,
|
|
@@ -54,22 +81,34 @@ export function divide(
|
|
|
54
81
|
if (divisor === 0) {
|
|
55
82
|
throw allocationInvalid('cannot divide money by zero — use allocate() to split a total');
|
|
56
83
|
}
|
|
57
|
-
|
|
84
|
+
const ratio = factorFraction(divisor);
|
|
85
|
+
return money(
|
|
86
|
+
roundRatio(BigInt(amount.minor) * ratio.denominator, ratio.numerator, mode),
|
|
87
|
+
amount.currency,
|
|
88
|
+
amount.scale,
|
|
89
|
+
);
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
export function negate(amount: Money): Money {
|
|
61
|
-
return money(-amount.minor, amount.currency);
|
|
93
|
+
return money(-amount.minor, amount.currency, amount.scale);
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
export function absolute(amount: Money): Money {
|
|
65
|
-
return money(Math.abs(amount.minor), amount.currency);
|
|
97
|
+
return money(Math.abs(amount.minor), amount.currency, amount.scale);
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* `-1 | 0 | 1`, comparable currencies only — and comparing the value, not the encoding, so a
|
|
102
|
+
* finer scale is not automatically the larger number. Widened as bigints on purpose: a comparison
|
|
103
|
+
* must answer where storing the widened value would rightly be refused.
|
|
104
|
+
*/
|
|
69
105
|
export function compare(left: Money, right: Money): -1 | 0 | 1 {
|
|
70
106
|
assertSameCurrency(left, right);
|
|
71
|
-
|
|
72
|
-
|
|
107
|
+
const scale = commonScale(left, right);
|
|
108
|
+
const leftMinor = minorAt(left, scale);
|
|
109
|
+
const rightMinor = minorAt(right, scale);
|
|
110
|
+
if (leftMinor < rightMinor) return -1;
|
|
111
|
+
return leftMinor > rightMinor ? 1 : 0;
|
|
73
112
|
}
|
|
74
113
|
|
|
75
114
|
export function isZero(amount: Money): boolean {
|
package/src/convert.ts
CHANGED
|
@@ -4,16 +4,27 @@
|
|
|
4
4
|
* and every converted amount records which rate produced it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
8
|
import { assertCurrency, exponentOf } from './currency';
|
|
8
9
|
import { rateMissing } from './errors';
|
|
10
|
+
import { type Fraction, factorFraction } from './factor';
|
|
9
11
|
import { type Money, money } from './money';
|
|
10
|
-
import { DEFAULT_ROUNDING, type RoundingMode,
|
|
12
|
+
import { DEFAULT_ROUNDING, type RoundingMode, roundRatio } from './rounding';
|
|
13
|
+
import { moneyScale } from './scale';
|
|
11
14
|
|
|
12
15
|
export interface ExchangeRate {
|
|
13
16
|
from: string;
|
|
14
17
|
to: string;
|
|
15
18
|
/** Major units of `to` per one major unit of `from`. */
|
|
16
19
|
rate: number;
|
|
20
|
+
/**
|
|
21
|
+
* The exact value `rate` approximates, for a provider that knows one. `rate` is the number the
|
|
22
|
+
* audit trail records and a human reads; a reciprocal cannot be both. `1 / 0.92` is the double
|
|
23
|
+
* 1.0869565217391304, whose decimal spelling is NOT 25/23 — so scaling by it loses a minor unit
|
|
24
|
+
* on a large amount, and the table that named `USD/EUR = 0.92` never wrote that number at all.
|
|
25
|
+
* Omit it and `convert` expands `rate`'s own decimal spelling, which is exact for a direct rate.
|
|
26
|
+
*/
|
|
27
|
+
ratio?: Fraction;
|
|
17
28
|
/** When the rate was observed — part of the audit trail, not decoration. */
|
|
18
29
|
at: Date;
|
|
19
30
|
/** Where it came from: `ecb`, `openexchange`, `manual:invoice-4711`. */
|
|
@@ -37,7 +48,8 @@ export interface ConvertOptions {
|
|
|
37
48
|
/**
|
|
38
49
|
* `convert(money(1000,'USD'), 'EUR', { rate: 0.92, ... })`.
|
|
39
50
|
* Scales across differing minor-unit exponents (USD 2 → JPY 0) instead of assuming both
|
|
40
|
-
* sides have cents
|
|
51
|
+
* sides have cents, and **preserves the amount's own `scale`**: a micro-priced amount stays
|
|
52
|
+
* micro-priced in the target currency, exactly as `multiply` and `divide` keep theirs.
|
|
41
53
|
*/
|
|
42
54
|
export function convert(
|
|
43
55
|
amount: Money,
|
|
@@ -52,15 +64,28 @@ export function convert(
|
|
|
52
64
|
if (!Number.isFinite(rate.rate) || rate.rate <= 0) {
|
|
53
65
|
throw rateMissing(amount.currency, target);
|
|
54
66
|
}
|
|
67
|
+
if (rate.ratio !== undefined && (rate.ratio.numerator <= 0n || rate.ratio.denominator <= 0n)) {
|
|
68
|
+
throw rateMissing(amount.currency, target);
|
|
69
|
+
}
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
// Exact, not a float product: `minor * rate * scale` shows the rounding mode a value IEEE-754
|
|
72
|
+
// has already moved, and a converted invoice line is off by a minor unit with nothing to trace.
|
|
73
|
+
// The provider's own fraction wins when it has one — see `ExchangeRate.ratio`.
|
|
74
|
+
const fraction = rate.ratio ?? factorFraction(rate.rate);
|
|
75
|
+
// The value's OWN precision, never the currency's: `moneyScale`, per this package's rule. A
|
|
76
|
+
// value carrying an explicit `scale` keeps it, because narrowing $0.000002 to EUR's two
|
|
77
|
+
// decimals is the 10,000x reinterpretation `scale` was added to prevent — and a value carrying
|
|
78
|
+
// none meets the target's natural scale, exactly as every conversion always has.
|
|
79
|
+
const resultScale = amount.scale ?? exponentOf(target);
|
|
80
|
+
const exponent = resultScale - moneyScale(amount);
|
|
81
|
+
let numerator = BigInt(amount.minor) * fraction.numerator;
|
|
82
|
+
let denominator = fraction.denominator;
|
|
83
|
+
if (exponent > 0) numerator *= 10n ** BigInt(exponent);
|
|
84
|
+
else if (exponent < 0) denominator *= 10n ** BigInt(-exponent);
|
|
85
|
+
const converted = roundRatio(numerator, denominator, options.rounding ?? DEFAULT_ROUNDING);
|
|
61
86
|
|
|
62
87
|
return {
|
|
63
|
-
amount: money(converted, target),
|
|
88
|
+
amount: money(converted, target, resultScale),
|
|
64
89
|
source: amount,
|
|
65
90
|
rate: rate.rate,
|
|
66
91
|
at: rate.at.toISOString(),
|
|
@@ -80,11 +105,14 @@ export async function convertWith(
|
|
|
80
105
|
provider: RateProvider,
|
|
81
106
|
amount: Money,
|
|
82
107
|
to: string,
|
|
83
|
-
options: ConvertOptions & { at?: Date } = {},
|
|
108
|
+
options: ConvertOptions & { at?: Date; clock?: Clock } = {},
|
|
84
109
|
): Promise<ConvertedMoney> {
|
|
85
110
|
const target = assertCurrency(to);
|
|
86
111
|
if (amount.currency === target) {
|
|
87
|
-
|
|
112
|
+
// The instant the parity was asserted, never `new Date(0)`: `ExchangeRate.at` is the audit
|
|
113
|
+
// trail, and a ledger row claiming its rate was observed on 1970-01-01 is a claim nobody
|
|
114
|
+
// made. The clock is injected for the same reason the rest of the framework injects one.
|
|
115
|
+
const at = (options.at ?? (options.clock ?? systemClock).now()).toISOString();
|
|
88
116
|
return { amount, source: amount, rate: 1, at, provider: 'identity' };
|
|
89
117
|
}
|
|
90
118
|
const rate = await provider.rateFor(amount.currency, target, options.at);
|
|
@@ -95,9 +123,23 @@ export async function convertWith(
|
|
|
95
123
|
};
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The exact fraction a table entry names, or `undefined` for a number that is not a usable rate.
|
|
128
|
+
* `convert` refuses those on `rate.rate` with `X_RATE_MISSING`; expanding them here first would
|
|
129
|
+
* answer `X_NOT_ROUNDABLE` for the same mistake.
|
|
130
|
+
*/
|
|
131
|
+
function exactRate(value: number): Fraction | undefined {
|
|
132
|
+
return Number.isFinite(value) && value > 0 ? factorFraction(value) : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
98
135
|
/**
|
|
99
136
|
* Fixed-table provider for tests, seeds and manual invoice rates.
|
|
100
137
|
* Keys are `FROM/TO`; the inverse is derived so a table needs one direction only.
|
|
138
|
+
*
|
|
139
|
+
* A table holds ONE observation, so a `wanted` instant other than its own is a rate this
|
|
140
|
+
* provider does not have — `undefined`, per `RateProvider`. Answering with today's number
|
|
141
|
+
* stamped `at: today` repriced a historical invoice against a rate nobody asked for and wrote a
|
|
142
|
+
* date into the audit trail that contradicted the request.
|
|
101
143
|
*/
|
|
102
144
|
export function fixedRateProvider(
|
|
103
145
|
rates: Readonly<Record<string, number>>,
|
|
@@ -106,12 +148,38 @@ export function fixedRateProvider(
|
|
|
106
148
|
): RateProvider {
|
|
107
149
|
return {
|
|
108
150
|
name,
|
|
109
|
-
async rateFor(from: string, to: string): Promise<ExchangeRate | undefined> {
|
|
151
|
+
async rateFor(from: string, to: string, wanted?: Date): Promise<ExchangeRate | undefined> {
|
|
152
|
+
if (wanted !== undefined && wanted.getTime() !== at.getTime()) return undefined;
|
|
110
153
|
const direct = rates[`${from}/${to}`];
|
|
111
|
-
if (direct !== undefined)
|
|
154
|
+
if (direct !== undefined) {
|
|
155
|
+
const ratio = exactRate(direct);
|
|
156
|
+
return {
|
|
157
|
+
from,
|
|
158
|
+
to,
|
|
159
|
+
rate: direct,
|
|
160
|
+
at,
|
|
161
|
+
source: name,
|
|
162
|
+
...(ratio === undefined ? {} : { ratio }),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
112
165
|
const inverse = rates[`${to}/${from}`];
|
|
113
166
|
if (inverse !== undefined && inverse !== 0) {
|
|
114
|
-
|
|
167
|
+
// Swapped, never divided. A table holding `USD/EUR: 0.92` names 23/25, so the EUR/USD
|
|
168
|
+
// direction is exactly 25/23 — where `1 / 0.92` is a double whose own decimal spelling
|
|
169
|
+
// rounds a large amount one minor unit low. `rate` keeps the readable approximation.
|
|
170
|
+
const named = exactRate(inverse);
|
|
171
|
+
const ratio =
|
|
172
|
+
named === undefined
|
|
173
|
+
? undefined
|
|
174
|
+
: { numerator: named.denominator, denominator: named.numerator };
|
|
175
|
+
return {
|
|
176
|
+
from,
|
|
177
|
+
to,
|
|
178
|
+
rate: 1 / inverse,
|
|
179
|
+
at,
|
|
180
|
+
source: name,
|
|
181
|
+
...(ratio === undefined ? {} : { ratio }),
|
|
182
|
+
};
|
|
115
183
|
}
|
|
116
184
|
return undefined;
|
|
117
185
|
},
|