@ultimat3/money 24.0.0 → 25.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/CLAUDE.md +5 -5
- package/README.md +17 -17
- package/package.json +4 -4
- package/src/allocate.ts +4 -4
- package/src/arithmetic.ts +9 -9
- package/src/convert.ts +3 -3
- package/src/errors.ts +3 -3
- package/src/format.ts +1 -1
- package/src/index.ts +2 -2
- package/src/money.ts +5 -5
- package/src/rescale.ts +8 -4
- package/src/rounding.ts +3 -3
- package/src/scale.ts +3 -5
package/CLAUDE.md
CHANGED
|
@@ -49,7 +49,7 @@ the wide shape and answer with the row type.
|
|
|
49
49
|
`scaleOf` still answer for a *currency*, which is a different question.
|
|
50
50
|
- **A stated currency is an ASSERTION, never a fallback.** `sum(amounts, currency)` used its
|
|
51
51
|
second argument only when the list was empty and ignored it entirely once a first addend existed:
|
|
52
|
-
`sum([
|
|
52
|
+
`sum([fromMinor(1, 'EUR')], 'USD')` answered `{ minor: 1, currency: 'EUR' }`, so a caller who wrote
|
|
53
53
|
down USD received EUR with nothing refused — in the one entry point of a file whose header is
|
|
54
54
|
"Integer arithmetic that refuses to mix currencies". A stated currency the first addend
|
|
55
55
|
contradicts is `X_CURRENCY_MISMATCH`.
|
|
@@ -60,13 +60,13 @@ the wide shape and answer with the row type.
|
|
|
60
60
|
- **Lossy narrowing names its mode at the call.** `rescale(m, 2)` throws rather than drop a
|
|
61
61
|
non-zero digit; `rescale(m, 2, 'half-up')` is the same rule `fromDecimal` applies to excess
|
|
62
62
|
precision. A narrowing that loses nothing needs no mode — nothing is being decided.
|
|
63
|
-
- **`
|
|
64
|
-
currency's exponent — only equal, so a deliberately *coarser* scale (`
|
|
63
|
+
- **`fromMinor()` is the only place the canonical form is decided.** It drops a `scale` equal to the
|
|
64
|
+
currency's exponent — only equal, so a deliberately *coarser* scale (`fromMinor(5, 'USD', 0)`, whole
|
|
65
65
|
dollars) is kept exactly as a finer one is. Every constructor, every arithmetic result and every
|
|
66
66
|
allocation part therefore agree on one encoding without any of them repeating the rule.
|
|
67
67
|
- **A widened value that will not fit is a scale error, not a fractional-minor one.** `add`,
|
|
68
68
|
`subtract` and `rescale` convert through `toMinor`, which throws `X_MONEY_SCALE_INVALID` naming
|
|
69
|
-
the finest scale that fits. Letting `
|
|
69
|
+
the finest scale that fits. Letting `fromMinor()` refuse the raw number reported a fractional minor
|
|
70
70
|
nobody wrote, with a `fromDecimal` fix line that threw the same error again.
|
|
71
71
|
- Never combine currencies without `convert()` first.
|
|
72
72
|
- Never round without naming a `RoundingMode` in the call or accepting the stated default.
|
|
@@ -85,7 +85,7 @@ the wide shape and answer with the row type.
|
|
|
85
85
|
of a value that names none; a value that names one keeps it, because narrowing $0.000002 to
|
|
86
86
|
EUR's two decimals is the 10,000x reinterpretation `scale` was added to prevent. Same rule as
|
|
87
87
|
`multiply` and `divide`, which already kept theirs.
|
|
88
|
-
- **`
|
|
88
|
+
- **`fromMinor()` normalises `-0` to `0`.** One amount must not have two identities: `JSON.stringify`
|
|
89
89
|
writes `-0` as `0` while `Object.is` and any keyed `Map` see something else, so a refund
|
|
90
90
|
rounding to nothing produced a value its own wire format cannot reproduce. `roundToInteger`
|
|
91
91
|
refuses to produce it either — `sign * 0` is the source.
|
package/README.md
CHANGED
|
@@ -21,14 +21,14 @@ read rather than rounding it. → [Money](https://github.com/developerz-ai/ultim
|
|
|
21
21
|
## Use
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
|
-
import { add, allocate, formatMoney, fromDecimal,
|
|
24
|
+
import { add, allocate, formatMoney, fromDecimal, fromMinor } from '@ultimat3/money';
|
|
25
25
|
|
|
26
26
|
const price = fromDecimal('12.99', 'EUR'); // { minor: 1299, currency: 'EUR' }
|
|
27
|
-
const total = add(price,
|
|
27
|
+
const total = add(price, fromMinor(500, 'EUR')); // 1799
|
|
28
28
|
formatMoney(total, 'de-DE'); // "17,99 €"
|
|
29
|
-
formatMoney(
|
|
30
|
-
formatMoney(
|
|
31
|
-
add(price,
|
|
29
|
+
formatMoney(fromMinor(1200, 'JPY'), 'en-US'); // "¥1,200" — 0 decimals
|
|
30
|
+
formatMoney(fromMinor(1234, 'KWD'), 'en-US'); // "KWD 1.234" — 3 decimals
|
|
31
|
+
add(price, fromMinor(500, 'USD')); // throws X_CURRENCY_MISMATCH
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Minor units are not always cents
|
|
@@ -59,7 +59,7 @@ Once, at boot, before the first amount in that currency is built. The rules, eac
|
|
|
59
59
|
| Rule | Refusal |
|
|
60
60
|
|---|---|
|
|
61
61
|
| three A–Z letters — `Intl` throws a `RangeError` on anything else | `X_CURRENCY_INVALID` |
|
|
62
|
-
| 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` |
|
|
62
|
+
| a whole exponent from 0 to `@ultimat3/schema`'s `MAX_MONEY_SCALE` — there is no safe default, and a silent 2 is the corrupted maths this package exists to prevent | `X_CURRENCY_INVALID` |
|
|
63
63
|
| a non-empty name | `X_CURRENCY_INVALID` |
|
|
64
64
|
| 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` |
|
|
65
65
|
| a shipped ISO row is not the app's to redefine | `X_CURRENCY_REDEFINED` |
|
|
@@ -69,22 +69,22 @@ included. That is why one is a value and the other is a call.
|
|
|
69
69
|
|
|
70
70
|
## Sub-cent amounts carry a scale
|
|
71
71
|
|
|
72
|
-
`
|
|
72
|
+
`fromMinor(2, 'USD', 6)` is $0.000002 — `minor` counting 10⁻⁶ instead of the currency's own 10⁻².
|
|
73
73
|
A value that names no scale means the currency's, which is every amount that already exists, so
|
|
74
74
|
nothing about `{ minor, currency }` changes: same shape, same JSON, same columns. Only a scale
|
|
75
|
-
*equal* to the currency's is dropped, so a deliberately coarser one is kept too: `
|
|
75
|
+
*equal* to the currency's is dropped, so a deliberately coarser one is kept too: `fromMinor(5, 'USD', 0)`
|
|
76
76
|
is $5 counted in whole dollars, and `rescale()` produces such values legitimately.
|
|
77
77
|
|
|
78
78
|
```ts
|
|
79
|
-
import { add, fromDecimal,
|
|
79
|
+
import { add, fromDecimal, fromMinor, moneyScale, rescale } from '@ultimat3/money';
|
|
80
80
|
|
|
81
|
-
moneyScale(
|
|
82
|
-
moneyScale(
|
|
83
|
-
rescale(
|
|
84
|
-
rescale(
|
|
85
|
-
rescale(
|
|
81
|
+
moneyScale(fromMinor(1299, 'EUR')); // 2 — the currency's own
|
|
82
|
+
moneyScale(fromMinor(2, 'USD', 6)); // 6
|
|
83
|
+
rescale(fromMinor(80, 'USD'), 8); // $0.80 as 80,000,000 hundred-millionths
|
|
84
|
+
rescale(fromMinor(1_234_567, 'USD', 6), 2); // throws X_MONEY_NOT_INTEGER — digits would go
|
|
85
|
+
rescale(fromMinor(1_234_567, 'USD', 6), 2, 'half-up'); // 123¢, the loss named at the call
|
|
86
86
|
fromDecimal('0.000002', 'USD', { scale: 6 });
|
|
87
|
-
add(
|
|
87
|
+
add(fromMinor(1, 'USD'), fromMinor(2, 'USD', 6)); // meets at scale 6: 10002, nothing lost
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Arithmetic normalises to the *finer* of two scales, never the coarser — adding a sub-cent fee to
|
|
@@ -101,7 +101,7 @@ fiction. The alternative was a second money type.
|
|
|
101
101
|
|
|
102
102
|
## Allocation
|
|
103
103
|
|
|
104
|
-
`allocate(
|
|
104
|
+
`allocate(fromMinor(100, 'USD'), 3)` → `34, 33, 33`. Largest-remainder split: floor every part,
|
|
105
105
|
then hand out the leftover units one at a time, biggest fractional remainder first.
|
|
106
106
|
`round(100 / 3)` either loses a cent or invents one, and an invoice that does that fails
|
|
107
107
|
reconciliation forever. `allocateByRatios` does the same for revenue shares and line splits.
|
|
@@ -128,7 +128,7 @@ scales by that when it is there. It is how a derived direction stays exact: a ta
|
|
|
128
128
|
double `1 / 0.92`, whose own decimal spelling rounds a large amount one minor unit low. `rate`
|
|
129
129
|
stays the readable number the audit trail records.
|
|
130
130
|
|
|
131
|
-
`convert` preserves the amount's own `scale`. `convert(
|
|
131
|
+
`convert` preserves the amount's own `scale`. `convert(fromMinor(2, 'USD', 6), 'EUR', parity)` is
|
|
132
132
|
€0.000002, not €0.02 — the target currency's minor unit decides nothing about a value that
|
|
133
133
|
already carries its own precision. `convertWith` on a same-currency pair stamps `at` from an
|
|
134
134
|
injected `Clock` (`{ clock }`, default `systemClock`) or from an explicit `{ at }`, never from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.1.0",
|
|
4
4
|
"description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,14 +28,14 @@
|
|
|
28
28
|
"LICENSE"
|
|
29
29
|
],
|
|
30
30
|
"engines": {
|
|
31
|
-
"bun": ">=1.4.
|
|
31
|
+
"bun": ">=1.4.2"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/core": "25.1.0",
|
|
39
|
+
"@ultimat3/schema": "25.1.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/allocate.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { assertSameCurrency } from './arithmetic';
|
|
12
12
|
import { allocationInvalid } from './errors';
|
|
13
13
|
import { factorFraction } from './factor';
|
|
14
|
-
import { formatMoneyDebug, type Money
|
|
14
|
+
import { formatMoneyDebug, fromMinor, type Money } from './money';
|
|
15
15
|
import { minorAt, moneyScale } from './scale';
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -22,7 +22,7 @@ import { minorAt, moneyScale } from './scale';
|
|
|
22
22
|
*/
|
|
23
23
|
export const MAX_ALLOCATION_PARTS = 1_000_000;
|
|
24
24
|
|
|
25
|
-
/** Split into `parts` equal shares. `allocate(
|
|
25
|
+
/** Split into `parts` equal shares. `allocate(fromMinor(100,'USD'), 3)` → 34, 33, 33. */
|
|
26
26
|
export function allocate(amount: Money, parts: number): Money[] {
|
|
27
27
|
if (!Number.isSafeInteger(parts) || parts <= 0) {
|
|
28
28
|
throw allocationInvalid(`part count must be a positive integer, got ${String(parts)}`);
|
|
@@ -36,7 +36,7 @@ export function allocate(amount: Money, parts: number): Money[] {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Split by weights. `allocateByRatios(
|
|
39
|
+
* Split by weights. `allocateByRatios(fromMinor(1000,'USD'), [70, 20, 10])` → 700, 200, 100;
|
|
40
40
|
* `[1, 1, 1]` over 100 → 34, 33, 33. Weights need not sum to anything in particular.
|
|
41
41
|
*/
|
|
42
42
|
export function allocateByRatios(amount: Money, ratios: readonly number[]): Money[] {
|
|
@@ -74,7 +74,7 @@ export function allocateByRatios(amount: Money, ratios: readonly number[]): Mone
|
|
|
74
74
|
leftover -= 1n;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
return floors.map((minor) =>
|
|
77
|
+
return floors.map((minor) => fromMinor(sign * Number(minor), amount.currency, amount.scale));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
package/src/arithmetic.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { allocationInvalid, currencyMismatch, currencyRequired } from './errors';
|
|
7
7
|
import { factorFraction } from './factor';
|
|
8
|
-
import { type Money
|
|
8
|
+
import { fromMinor, type Money } from './money';
|
|
9
9
|
import { DEFAULT_ROUNDING, type RoundingMode, roundRatio } from './rounding';
|
|
10
10
|
import { commonScale, minorAt, toMinor } from './scale';
|
|
11
11
|
|
|
@@ -22,7 +22,7 @@ export function assertSameCurrency(left: Money, right: Money): string {
|
|
|
22
22
|
export function add(left: Money, right: Money): Money {
|
|
23
23
|
const currency = assertSameCurrency(left, right);
|
|
24
24
|
const scale = commonScale(left, right);
|
|
25
|
-
return
|
|
25
|
+
return fromMinor(
|
|
26
26
|
toMinor(minorAt(left, scale) + minorAt(right, scale), scale, currency),
|
|
27
27
|
currency,
|
|
28
28
|
scale,
|
|
@@ -32,7 +32,7 @@ export function add(left: Money, right: Money): Money {
|
|
|
32
32
|
export function subtract(left: Money, right: Money): Money {
|
|
33
33
|
const currency = assertSameCurrency(left, right);
|
|
34
34
|
const scale = commonScale(left, right);
|
|
35
|
-
return
|
|
35
|
+
return fromMinor(
|
|
36
36
|
toMinor(minorAt(left, scale) - minorAt(right, scale), scale, currency),
|
|
37
37
|
currency,
|
|
38
38
|
scale,
|
|
@@ -43,7 +43,7 @@ export function subtract(left: Money, right: Money): Money {
|
|
|
43
43
|
* Every addend must share one currency; an empty list needs an explicit currency.
|
|
44
44
|
*
|
|
45
45
|
* A stated currency the first addend contradicts is `X_CURRENCY_MISMATCH`, not a silent win for
|
|
46
|
-
* the list: `sum([
|
|
46
|
+
* the list: `sum([fromMinor(1, 'EUR')], 'USD')` used to answer `{ minor: 1, currency: 'EUR' }`, so a
|
|
47
47
|
* caller who wrote down USD received EUR and nothing refused — the exact failure this file's
|
|
48
48
|
* header exists to rule out, in the one entry point that treated its currency as a fallback rather
|
|
49
49
|
* than as an assertion.
|
|
@@ -55,7 +55,7 @@ export function sum(amounts: readonly Money[], currency?: string): Money {
|
|
|
55
55
|
}
|
|
56
56
|
const base = first ?? currency;
|
|
57
57
|
if (base === undefined) throw currencyRequired('sum([])');
|
|
58
|
-
return amounts.reduce((total, amount) => add(total, amount),
|
|
58
|
+
return amounts.reduce((total, amount) => add(total, amount), fromMinor(0, base));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
@@ -73,7 +73,7 @@ export function multiply(
|
|
|
73
73
|
): Money {
|
|
74
74
|
const ratio = factorFraction(factor);
|
|
75
75
|
// Scale-preserving: a fee on a micro-priced amount stays a micro-priced amount.
|
|
76
|
-
return
|
|
76
|
+
return fromMinor(
|
|
77
77
|
roundRatio(BigInt(amount.minor) * ratio.numerator, ratio.denominator, mode),
|
|
78
78
|
amount.currency,
|
|
79
79
|
amount.scale,
|
|
@@ -94,7 +94,7 @@ export function divide(
|
|
|
94
94
|
throw allocationInvalid('cannot divide money by zero — use allocate() to split a total');
|
|
95
95
|
}
|
|
96
96
|
const ratio = factorFraction(divisor);
|
|
97
|
-
return
|
|
97
|
+
return fromMinor(
|
|
98
98
|
roundRatio(BigInt(amount.minor) * ratio.denominator, ratio.numerator, mode),
|
|
99
99
|
amount.currency,
|
|
100
100
|
amount.scale,
|
|
@@ -102,11 +102,11 @@ export function divide(
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
export function negate(amount: Money): Money {
|
|
105
|
-
return
|
|
105
|
+
return fromMinor(-amount.minor, amount.currency, amount.scale);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export function absolute(amount: Money): Money {
|
|
109
|
-
return
|
|
109
|
+
return fromMinor(Math.abs(amount.minor), amount.currency, amount.scale);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
package/src/convert.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { type Clock, systemClock } from '@ultimat3/core';
|
|
|
8
8
|
import { assertCurrency, exponentOf } from './currency';
|
|
9
9
|
import { rateMissing } from './errors';
|
|
10
10
|
import { type Fraction, factorFraction } from './factor';
|
|
11
|
-
import { type Money
|
|
11
|
+
import { fromMinor, type Money } from './money';
|
|
12
12
|
import { DEFAULT_ROUNDING, type RoundingMode, roundRatio } from './rounding';
|
|
13
13
|
import { moneyScale } from './scale';
|
|
14
14
|
|
|
@@ -46,7 +46,7 @@ export interface ConvertOptions {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* `convert(
|
|
49
|
+
* `convert(fromMinor(1000,'USD'), 'EUR', { rate: 0.92, ... })`.
|
|
50
50
|
* Scales across differing minor-unit exponents (USD 2 → JPY 0) instead of assuming both
|
|
51
51
|
* sides have cents, and **preserves the amount's own `scale`**: a micro-priced amount stays
|
|
52
52
|
* micro-priced in the target currency, exactly as `multiply` and `divide` keep theirs.
|
|
@@ -85,7 +85,7 @@ export function convert(
|
|
|
85
85
|
const converted = roundRatio(numerator, denominator, options.rounding ?? DEFAULT_ROUNDING);
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
-
amount:
|
|
88
|
+
amount: fromMinor(converted, target, resultScale),
|
|
89
89
|
source: amount,
|
|
90
90
|
rate: rate.rate,
|
|
91
91
|
at: rate.at.toISOString(),
|
package/src/errors.ts
CHANGED
|
@@ -84,7 +84,7 @@ export function moneyNotInteger(minor: number, currency: string): MoneyError {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* Three arrivals reach `
|
|
87
|
+
* Three arrivals reach `fromMinor()` with an unusable `minor`, and each needs its own instruction.
|
|
88
88
|
* One line answered all three — `fromDecimal('<minor>', '<ccy>')` — and it raised THIS code back
|
|
89
89
|
* at the reader for every value past the currency's own digits, which is the anti-pattern
|
|
90
90
|
* `currencyDeclarationInvalid` below already names; for `1e21` it read `fromDecimal('1e+21', …)`,
|
|
@@ -104,7 +104,7 @@ function notIntegerFix(minor: number, currency: string): string {
|
|
|
104
104
|
// Safe to interpolate un-escaped, and only here: `PLAIN_DECIMAL` has just proved the spelling is
|
|
105
105
|
// digits, one dot and one leading `-`, so it closes no literal `renderFixLiteral` would escape.
|
|
106
106
|
const spelling = String(minor);
|
|
107
|
-
const rounded = `
|
|
107
|
+
const rounded = `fromMinor(Math.round(${spelling}), ${code})`;
|
|
108
108
|
if (!PLAIN_DECIMAL.test(spelling)) {
|
|
109
109
|
return `${rounded} — the value counts minor units, and that is the whole one nearest it`;
|
|
110
110
|
}
|
|
@@ -182,7 +182,7 @@ export function scaleInvalid(scale: number): MoneyError {
|
|
|
182
182
|
return new MoneyError({
|
|
183
183
|
code: 'X_MONEY_SCALE_INVALID',
|
|
184
184
|
cause: `a money scale must be a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, got ${String(scale)}`,
|
|
185
|
-
fix: `use a scale in range —
|
|
185
|
+
fix: `use a scale in range — fromMinor(minor, currency, 6) for micros, or omit it for the currency's own minor unit`,
|
|
186
186
|
});
|
|
187
187
|
}
|
|
188
188
|
|
package/src/format.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface FormatMoneyOptions {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* `formatMoney(
|
|
33
|
+
* `formatMoney(fromMinor(129900,'EUR'), 'de-DE')` → `1.299,00 €`.
|
|
34
34
|
*
|
|
35
35
|
* Delegates to `formatMoneyParts` and joins: a UI styling the symbol off the parts and a label
|
|
36
36
|
* rendering the string must not disagree about where the sign goes. Hand-prefixing `-` here put
|
package/src/index.ts
CHANGED
|
@@ -80,9 +80,9 @@ export {
|
|
|
80
80
|
type FromDecimalOptions,
|
|
81
81
|
formatMoneyDebug,
|
|
82
82
|
fromDecimal,
|
|
83
|
+
fromMinor,
|
|
83
84
|
isMoney,
|
|
84
85
|
type Money,
|
|
85
|
-
money,
|
|
86
86
|
toDecimalNumber,
|
|
87
87
|
toDecimalString,
|
|
88
88
|
zero,
|
|
@@ -95,4 +95,4 @@ export {
|
|
|
95
95
|
roundToDigits,
|
|
96
96
|
roundToInteger,
|
|
97
97
|
} from './rounding';
|
|
98
|
-
export { assertScale, commonScale,
|
|
98
|
+
export { assertScale, commonScale, minorAt, moneyScale } from './scale';
|
package/src/money.ts
CHANGED
|
@@ -26,11 +26,11 @@ const DECIMAL = /^([+-])?(\d+)(?:\.(\d+))?$/;
|
|
|
26
26
|
* The only constructor. Validates the currency and rejects fractional minor units.
|
|
27
27
|
*
|
|
28
28
|
* `scale` names how many decimal places `minor` counts when the currency's own are not enough:
|
|
29
|
-
* `
|
|
29
|
+
* `fromMinor(2, 'USD', 6)` is $0.000002. Omitted — and canonically omitted again when it says nothing
|
|
30
30
|
* the currency does not already say — so a value at the natural scale serializes byte-for-byte as
|
|
31
31
|
* it always has, and there is exactly one encoding of it.
|
|
32
32
|
*/
|
|
33
|
-
export function
|
|
33
|
+
export function fromMinor(minor: number, currency: string, scale?: number): Money {
|
|
34
34
|
const code = assertCurrency(currency);
|
|
35
35
|
if (!Number.isSafeInteger(minor)) throw moneyNotInteger(minor, code);
|
|
36
36
|
// `-0` is one amount with two identities: `JSON.stringify` writes `0` while `Object.is` and any
|
|
@@ -45,7 +45,7 @@ export function money(minor: number, currency: string, scale?: number): Money {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function zero(currency: string): Money {
|
|
48
|
-
return
|
|
48
|
+
return fromMinor(0, currency);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export interface FromDecimalOptions {
|
|
@@ -97,7 +97,7 @@ export function fromDecimal(
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
return
|
|
100
|
+
return fromMinor(negative ? -minor : minor, code, options.scale);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** `1299 EUR` → `'12.99'`; `1200 JPY` → `'1200'`; `2 USD @ scale 6` → `'0.000002'`. */
|
|
@@ -114,7 +114,7 @@ export function toDecimalString(amount: Money): string {
|
|
|
114
114
|
/**
|
|
115
115
|
* Major units as a float — lossy past 2^53 / 10^scale, so never for arithmetic and no longer for
|
|
116
116
|
* formatting either: `format.ts` hands `Intl` the exact `toDecimalString`, since a float rendered
|
|
117
|
-
* `
|
|
117
|
+
* `fromMinor(9007199254740991, 'USD', 6)` as `…740992`. Kept as public API for a chart axis or a sort
|
|
118
118
|
* key, where an approximation is the point.
|
|
119
119
|
*/
|
|
120
120
|
export function toDecimalNumber(amount: Money): number {
|
package/src/rescale.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { rescaleNotExact } from './errors';
|
|
8
|
-
import { type Money
|
|
8
|
+
import { fromMinor, type Money } from './money';
|
|
9
9
|
import { type RoundingMode, roundRatio } from './rounding';
|
|
10
10
|
import { assertScale, minorAt, moneyScale, toMinor } from './scale';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* `rescale(
|
|
13
|
+
* `rescale(fromMinor(80, 'USD'), 8)` → 80,000,000 hundred-millionths, the granularity a per-token
|
|
14
14
|
* price needs. `rescale(m, 2, 'half-up')` brings it back to cents, having named who pays for the
|
|
15
15
|
* digits that go.
|
|
16
16
|
*/
|
|
@@ -18,7 +18,11 @@ export function rescale(amount: Money, scale: number, mode?: RoundingMode): Mone
|
|
|
18
18
|
assertScale(scale);
|
|
19
19
|
const from = moneyScale(amount);
|
|
20
20
|
if (scale >= from) {
|
|
21
|
-
return
|
|
21
|
+
return fromMinor(
|
|
22
|
+
toMinor(minorAt(amount, scale), scale, amount.currency),
|
|
23
|
+
amount.currency,
|
|
24
|
+
scale,
|
|
25
|
+
);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
const divisor = 10n ** BigInt(from - scale);
|
|
@@ -27,5 +31,5 @@ export function rescale(amount: Money, scale: number, mode?: RoundingMode): Mone
|
|
|
27
31
|
if (mode === undefined && numerator % divisor !== 0n) {
|
|
28
32
|
throw rescaleNotExact(amount, from, scale);
|
|
29
33
|
}
|
|
30
|
-
return
|
|
34
|
+
return fromMinor(roundRatio(numerator, divisor, mode), amount.currency, scale);
|
|
31
35
|
}
|
package/src/rounding.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* whichever one `Math.round` happens to implement is not an answer.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { assertCoded } from '@ultimat3/core';
|
|
7
7
|
import { MAX_MONEY_SCALE } from '@ultimat3/schema';
|
|
8
8
|
import { digitsInvalid, notRoundable } from './errors';
|
|
9
9
|
import { factorFraction } from './factor';
|
|
@@ -66,7 +66,7 @@ export function roundRatio(
|
|
|
66
66
|
denominator: bigint,
|
|
67
67
|
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
68
68
|
): number {
|
|
69
|
-
|
|
69
|
+
assertCoded(
|
|
70
70
|
denominator !== 0n,
|
|
71
71
|
'X_INVARIANT',
|
|
72
72
|
'cannot round a ratio whose denominator is zero',
|
|
@@ -98,7 +98,7 @@ export function roundRatio(
|
|
|
98
98
|
else rounded = whole % 2n === 0n ? whole : whole + 1n;
|
|
99
99
|
break;
|
|
100
100
|
}
|
|
101
|
-
// Past 2^53 the `Number` is already approximate, which `
|
|
101
|
+
// Past 2^53 the `Number` is already approximate, which `fromMinor()` refuses as X_MONEY_NOT_INTEGER.
|
|
102
102
|
return Number(negative ? -rounded : rounded);
|
|
103
103
|
}
|
|
104
104
|
|
package/src/scale.ts
CHANGED
|
@@ -4,13 +4,11 @@
|
|
|
4
4
|
* which is why nothing that predates this file has to change to keep meaning what it meant.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { isMoneyScale
|
|
7
|
+
import { isMoneyScale } from '@ultimat3/schema';
|
|
8
8
|
import { exponentOf } from './currency';
|
|
9
9
|
import { scaleInvalid, scaleNotWidening, scaleOverflow } from './errors';
|
|
10
10
|
import type { Money } from './money';
|
|
11
11
|
|
|
12
|
-
export { MAX_MONEY_SCALE };
|
|
13
|
-
|
|
14
12
|
/**
|
|
15
13
|
* The decimal exponent this value's `minor` counts in — its own, or the currency's.
|
|
16
14
|
*
|
|
@@ -31,7 +29,7 @@ export function assertScale(scale: number): number {
|
|
|
31
29
|
* `amount.minor` restated at `scale`, exactly, as a bigint.
|
|
32
30
|
*
|
|
33
31
|
* A bigint because widening is what a comparison does first, and a comparison must not throw:
|
|
34
|
-
* `MAX_SAFE_INTEGER` cents restated in micros is past 2^53, which `
|
|
32
|
+
* `MAX_SAFE_INTEGER` cents restated in micros is past 2^53, which `fromMinor()` rightly refuses to
|
|
35
33
|
* *store* and which says nothing about whether it is larger than the value beside it.
|
|
36
34
|
*
|
|
37
35
|
* Widening only. Narrowing drops digits, and which digits go is a decision with a mode attached —
|
|
@@ -52,7 +50,7 @@ export function commonScale(left: Money, right: Money): number {
|
|
|
52
50
|
/**
|
|
53
51
|
* A widened bigint back to a storable `minor`, or a scale error naming the finest scale that
|
|
54
52
|
* would fit. The one place that conversion happens, because `Number(widened)` alone reached
|
|
55
|
-
* `
|
|
53
|
+
* `fromMinor()` as a plain out-of-range amount — reported as a fractional minor nobody wrote, with a
|
|
56
54
|
* `fromDecimal` fix line that throws the same error again.
|
|
57
55
|
*/
|
|
58
56
|
export function toMinor(widened: bigint, scale: number, currency: string): number {
|