@ultimat3/money 2.0.0 → 4.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 +19 -2
- package/package.json +3 -3
- package/src/arithmetic.ts +14 -2
- package/src/errors.ts +13 -0
- package/src/format.ts +45 -22
- package/src/rounding.ts +18 -3
package/CLAUDE.md
CHANGED
|
@@ -30,7 +30,7 @@ shape is still additive and this is still a minor version.
|
|
|
30
30
|
| `arithmetic.ts` | add/subtract/multiply/compare, refuses mixed currencies |
|
|
31
31
|
| `allocate.ts` | largest-remainder splits that preserve the total |
|
|
32
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`) |
|
|
33
|
+
| `rounding.ts` | explicit modes, no implicit default, over a float (`roundToInteger`) or a ratio (`roundRatio`, and `roundToDigits` through it) |
|
|
34
34
|
| `format.ts` | `Intl.NumberFormat` only, digits from the exponent |
|
|
35
35
|
| `convert.ts` | explicit rate + `RateProvider`, records provenance |
|
|
36
36
|
|
|
@@ -40,6 +40,12 @@ shape is still additive and this is still a minor version.
|
|
|
40
40
|
- Never `/ 100`, and never `exponentOf(amount.currency)` for a value's own precision — that is
|
|
41
41
|
`moneyScale(amount)`, which falls back to the currency and is right for both. `exponentOf` and
|
|
42
42
|
`scaleOf` still answer for a *currency*, which is a different question.
|
|
43
|
+
- **A stated currency is an ASSERTION, never a fallback.** `sum(amounts, currency)` used its
|
|
44
|
+
second argument only when the list was empty and ignored it entirely once a first addend existed:
|
|
45
|
+
`sum([money(1, 'EUR')], 'USD')` answered `{ minor: 1, currency: 'EUR' }`, so a caller who wrote
|
|
46
|
+
down USD received EUR with nothing refused — in the one entry point of a file whose header is
|
|
47
|
+
"Integer arithmetic that refuses to mix currencies". A stated currency the first addend
|
|
48
|
+
contradicts is `X_CURRENCY_MISMATCH`.
|
|
43
49
|
- **Two scales meet at the finer one, never the coarser.** `add`, `subtract` and `compare`
|
|
44
50
|
widen through `minorAt` (bigint, exact) before they do anything else, so a sub-cent fee added to
|
|
45
51
|
a cent survives and a comparison answers where storing the widened value would rightly be
|
|
@@ -63,7 +69,11 @@ shape is still additive and this is still a minor version.
|
|
|
63
69
|
100.49999999999999 `100 * 1.005` produces. A new scaling entry point goes through the same pair
|
|
64
70
|
— `roundToInteger(a * b, mode)` is the bug, written again. `fromDecimal` was the last float
|
|
65
71
|
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.
|
|
72
|
+
is exactly 0.5, so `half-up` saw a tie the written decimal does not have. **`roundToDigits` was
|
|
73
|
+
that rule broken in this very file** — its body was literally `roundToInteger(value * factor,
|
|
74
|
+
mode) / factor`, so `roundToDigits(1.005, 2, 'half-up')` answered 1.00 where 1.01 is owed. It
|
|
75
|
+
goes through `factorFraction` + `roundRatio` as of 2026-08, and a digit count that is not a whole
|
|
76
|
+
number of decimal places is `X_MONEY_SCALE_INVALID`, never a bare `RangeError` out of `BigInt`.
|
|
67
77
|
- **`convert` preserves the amount's own `scale`.** `exponentOf(target)` decides the natural scale
|
|
68
78
|
of a value that names none; a value that names one keeps it, because narrowing $0.000002 to
|
|
69
79
|
EUR's two decimals is the 10,000x reinterpretation `scale` was added to prevent. Same rule as
|
|
@@ -79,6 +89,13 @@ shape is still additive and this is still a minor version.
|
|
|
79
89
|
`USD/EUR: 0.92` names 23/25, so EUR→USD is exactly 25/23, where `1 / 0.92` is a double whose own
|
|
80
90
|
decimal spelling rounds a large amount one minor unit low. `rate` stays the readable number the
|
|
81
91
|
audit trail records; `convert` scales by `ratio` whenever the provider supplied one.
|
|
92
|
+
- **Never cache an `Intl` formatter on a raw caller string.** `locale` arrives from
|
|
93
|
+
`Accept-Language`, so an unbounded `Map` keyed on it is memory the client chooses: 20,000 valid
|
|
94
|
+
`en-US-x-*` tags through `formatMoney` retained +55.1 MB of RSS, at ~2.7 KB per
|
|
95
|
+
`Intl.NumberFormat`. Both halves, always — `canonicalLocale` for the key, `cachedFormatter` for
|
|
96
|
+
the bound, both `@ultimat3/core`'s and shared with `@ultimat3/time` (tier 1 may not import
|
|
97
|
+
sideways, so the mechanism lives a tier down rather than twice). Every formatter in `format.ts`
|
|
98
|
+
goes through that pair; a `new Intl.NumberFormat` outside one is the bug written again.
|
|
82
99
|
- **One place decides a sign.** `formatMoney` is `formatMoneyParts` joined, and `accounting`
|
|
83
100
|
reaches `Intl` as `currencySign` — so the locale places the minus and picks the parenthesised
|
|
84
101
|
form, and a UI styling the parts cannot render a different format from the label beside it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "4.0.0",
|
|
35
|
+
"@ultimat3/schema": "4.0.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/arithmetic.ts
CHANGED
|
@@ -39,9 +39,21 @@ export function subtract(left: Money, right: Money): Money {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Every addend must share one currency; an empty list needs an explicit currency.
|
|
44
|
+
*
|
|
45
|
+
* A stated currency the first addend contradicts is `X_CURRENCY_MISMATCH`, not a silent win for
|
|
46
|
+
* the list: `sum([money(1, 'EUR')], 'USD')` used to answer `{ minor: 1, currency: 'EUR' }`, so a
|
|
47
|
+
* caller who wrote down USD received EUR and nothing refused — the exact failure this file's
|
|
48
|
+
* header exists to rule out, in the one entry point that treated its currency as a fallback rather
|
|
49
|
+
* than as an assertion.
|
|
50
|
+
*/
|
|
43
51
|
export function sum(amounts: readonly Money[], currency?: string): Money {
|
|
44
|
-
const
|
|
52
|
+
const first = amounts[0]?.currency;
|
|
53
|
+
if (first !== undefined && currency !== undefined && first !== currency) {
|
|
54
|
+
throw currencyMismatch(currency, first);
|
|
55
|
+
}
|
|
56
|
+
const base = first ?? currency;
|
|
45
57
|
if (base === undefined) throw currencyRequired('sum([])');
|
|
46
58
|
return amounts.reduce((total, amount) => add(total, amount), money(0, base));
|
|
47
59
|
}
|
package/src/errors.ts
CHANGED
|
@@ -116,6 +116,19 @@ function countFractionDigits(value: string): number {
|
|
|
116
116
|
return value.trim().split('.')[1]?.length ?? 0;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* A `roundToDigits` digit count that names no decimal place. Reported as `X_MONEY_SCALE_INVALID`
|
|
121
|
+
* because a digit count IS a scale — `10 ** 1.5` is not a power of ten, and `BigInt(1.5)` is a
|
|
122
|
+
* bare `RangeError` out of a function whose every other refusal is coded.
|
|
123
|
+
*/
|
|
124
|
+
export function digitsInvalid(digits: number): MoneyError {
|
|
125
|
+
return new MoneyError({
|
|
126
|
+
code: 'X_MONEY_SCALE_INVALID',
|
|
127
|
+
cause: `a digit count must be a whole number between -${MAX_MONEY_SCALE} and ${MAX_MONEY_SCALE}, got ${String(digits)}`,
|
|
128
|
+
fix: "pass an integer digit count — roundToDigits(value, 2, 'half-up') for two decimal places",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
119
132
|
/** A scale outside 0…MAX_MONEY_SCALE names no decimal place a `minor` could count in. */
|
|
120
133
|
export function scaleInvalid(scale: number): MoneyError {
|
|
121
134
|
return new MoneyError({
|
package/src/format.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* JPY renders without decimals and KWD with three, without a per-locale special case.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
|
|
6
7
|
import { exponentOf } from './currency';
|
|
7
8
|
import { type Money, toDecimalNumber } from './money';
|
|
8
9
|
import { moneyScale } from './scale';
|
|
@@ -73,19 +74,40 @@ export function currencySymbol(currency: string, locale: string): string {
|
|
|
73
74
|
/** Digits only, no symbol — for editable inputs and CSV exports. */
|
|
74
75
|
export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
75
76
|
const digits = moneyScale(amount);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
const tag = canonicalTag(locale);
|
|
78
|
+
// Through the same cache as `formatterFor`, for the same reason: this took the caller's raw
|
|
79
|
+
// locale too, and a second way to build a formatter in one file is a second place to forget.
|
|
80
|
+
return cachedFormatter(
|
|
81
|
+
decimalCache,
|
|
82
|
+
`${tag}|${digits}`,
|
|
83
|
+
() =>
|
|
84
|
+
new Intl.NumberFormat(tag, {
|
|
85
|
+
style: 'decimal',
|
|
86
|
+
minimumFractionDigits: digits,
|
|
87
|
+
maximumFractionDigits: digits,
|
|
88
|
+
useGrouping: false,
|
|
89
|
+
}),
|
|
90
|
+
).format(toDecimalNumber(amount));
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A tag `Intl` cannot parse falls through unchanged, so the `Intl.NumberFormat` constructor still
|
|
95
|
+
* raises it — this seam decides a cache key, never whether a locale is acceptable.
|
|
96
|
+
*/
|
|
97
|
+
const canonicalTag = (locale: string): string => canonicalLocale(locale) ?? locale;
|
|
98
|
+
|
|
84
99
|
const cache = new Map<string, Intl.NumberFormat>();
|
|
100
|
+
const decimalCache = new Map<string, Intl.NumberFormat>();
|
|
85
101
|
|
|
86
102
|
/**
|
|
87
103
|
* `scale` is the amount's own, not the currency's: rendering $0.000002 with two digits shows
|
|
88
104
|
* `$0.00`, which is the sub-cent bug back again, in the one place a human would read it.
|
|
105
|
+
*
|
|
106
|
+
* **Canonically keyed and hard-capped, because `locale` arrives from `Accept-Language`.** Keyed
|
|
107
|
+
* raw into an unbounded `Map`, 20,000 valid-but-distinct tags (`en-US-x-a0` …) retained 55 MB —
|
|
108
|
+
* memory the client chooses. `canonicalLocale` collapses `EN-us` and `en-US` onto one key and
|
|
109
|
+
* `cachedFormatter` caps the rest; neither half is sufficient alone, which is why both come from
|
|
110
|
+
* the one place `@ultimat3/time` reads them from too.
|
|
89
111
|
*/
|
|
90
112
|
function formatterFor(
|
|
91
113
|
currency: string,
|
|
@@ -96,13 +118,14 @@ function formatterFor(
|
|
|
96
118
|
const digits =
|
|
97
119
|
options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
|
|
98
120
|
const sign = options.accounting === true ? 'accounting' : 'standard';
|
|
121
|
+
const tag = canonicalTag(locale);
|
|
99
122
|
// `exponent` is in the key because it stopped being derivable from `currency` the moment it
|
|
100
123
|
// started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
|
|
101
124
|
// `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
|
|
102
125
|
// first and 12.990001 EUR then rendered as `12,99 €` — the sub-cent bug back, silently, in the
|
|
103
126
|
// one place a human reads the number.
|
|
104
127
|
const key = [
|
|
105
|
-
|
|
128
|
+
tag,
|
|
106
129
|
currency,
|
|
107
130
|
options.display ?? 'symbol',
|
|
108
131
|
digits ?? 'auto',
|
|
@@ -110,19 +133,19 @@ function formatterFor(
|
|
|
110
133
|
options.grouping ?? 'auto',
|
|
111
134
|
sign,
|
|
112
135
|
].join('|');
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
return cachedFormatter(
|
|
137
|
+
cache,
|
|
138
|
+
key,
|
|
139
|
+
() =>
|
|
140
|
+
new Intl.NumberFormat(tag, {
|
|
141
|
+
style: 'currency',
|
|
142
|
+
currency,
|
|
143
|
+
currencyDisplay: options.display ?? 'symbol',
|
|
144
|
+
currencySign: sign,
|
|
145
|
+
...(digits === undefined
|
|
146
|
+
? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
|
|
147
|
+
: { minimumFractionDigits: digits, maximumFractionDigits: digits }),
|
|
148
|
+
...(options.grouping === 'never' ? { useGrouping: false } : {}),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
128
151
|
}
|
package/src/rounding.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { invariant } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { MAX_MONEY_SCALE } from '@ultimat3/schema';
|
|
8
|
+
import { digitsInvalid, notRoundable } from './errors';
|
|
9
|
+
import { factorFraction } from './factor';
|
|
8
10
|
|
|
9
11
|
export type RoundingMode =
|
|
10
12
|
/** 0.5 away from zero — the commercial default most invoicing rules specify. */
|
|
@@ -103,12 +105,25 @@ export function roundRatio(
|
|
|
103
105
|
/**
|
|
104
106
|
* Round to `digits` decimal places, used when converting a decimal string whose
|
|
105
107
|
* precision exceeds the currency's minor unit.
|
|
108
|
+
*
|
|
109
|
+
* Over `roundRatio`, never `roundToInteger(value * factor, mode)` — that product is the bug this
|
|
110
|
+
* package documents as forbidden: `1.005 * 100` is 100.49999999999999, so half-up answered 1.00
|
|
111
|
+
* where 1.01 is owed. The value reaches the mode as the exact fraction its shortest round-trip
|
|
112
|
+
* decimal names, which is the same path `multiply` and `convert` already take.
|
|
106
113
|
*/
|
|
107
114
|
export function roundToDigits(
|
|
108
115
|
value: number,
|
|
109
116
|
digits: number,
|
|
110
117
|
mode: RoundingMode = DEFAULT_ROUNDING,
|
|
111
118
|
): number {
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
if (!Number.isInteger(digits) || Math.abs(digits) > MAX_MONEY_SCALE) throw digitsInvalid(digits);
|
|
120
|
+
const { numerator, denominator } = factorFraction(value);
|
|
121
|
+
const scale = 10n ** BigInt(Math.abs(digits));
|
|
122
|
+
// A negative digit count rounds to tens or hundreds, so the power moves to the other side of the
|
|
123
|
+
// fraction rather than becoming a fractional bigint, which has no spelling.
|
|
124
|
+
const rounded =
|
|
125
|
+
digits >= 0
|
|
126
|
+
? roundRatio(numerator * scale, denominator, mode)
|
|
127
|
+
: roundRatio(numerator, denominator * scale, mode);
|
|
128
|
+
return digits >= 0 ? rounded / Number(scale) : rounded * Number(scale);
|
|
114
129
|
}
|