@ultimat3/money 23.0.0 → 24.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 +8 -0
- package/README.md +8 -0
- package/package.json +3 -3
- package/src/allocate.ts +13 -0
- package/src/errors.ts +12 -0
- package/src/format.ts +36 -14
- package/src/index.ts +2 -0
package/CLAUDE.md
CHANGED
|
@@ -154,6 +154,14 @@ the wide shape and answer with the row type.
|
|
|
154
154
|
`currencyCodes()` is what this process accepts, registrations included, and it is the list
|
|
155
155
|
`X_CURRENCY_UNKNOWN`'s fix line names — so it must include them or that fix is the dead end it
|
|
156
156
|
used to be.
|
|
157
|
+
- **`trimZeroFraction` is `trailingZeroDisplay: 'stripIfInteger'`, never `minimumFractionDigits:
|
|
158
|
+
0`.** Min = max = the amount's scale, always; the min-0 form trimmed EVERY trailing zero and
|
|
159
|
+
rendered 1250 cents as `$12.5`. `trim` is in the formatter cache key because it no longer changes
|
|
160
|
+
the digit count.
|
|
161
|
+
- **Every bound a caller names is screened before a built-in sees it.** `fractionDigits` outside
|
|
162
|
+
0…`MAX_FRACTION_DIGITS` is `X_MONEY_SCALE_INVALID` (`Intl` raised a bare `RangeError`), and
|
|
163
|
+
`allocate(m, parts)` past `MAX_ALLOCATION_PARTS` is `X_ALLOCATION_INVALID` (`new Array(1e10)`
|
|
164
|
+
did). Defaults are taken on `=== undefined`, never `??`, so a blanked `null` is refused.
|
|
157
165
|
- Adding a currency to the *shipped* rows: one row in `currency.ts` with its correct exponent, plus
|
|
158
166
|
a format test. An app never needs this — that is what `registerCurrency` is for.
|
|
159
167
|
|
package/README.md
CHANGED
|
@@ -37,6 +37,11 @@ add(price, money(500, 'USD')); // throws X_CURRENCY_MISMATCH
|
|
|
37
37
|
`fromDecimal` scales by it (`'1.234'` KWD → 1234), `toDecimalString` reverses it, and
|
|
38
38
|
`formatMoney` sets the fraction digits from it. Hardcoding `/ 100` is a JPY bug and a KWD bug.
|
|
39
39
|
|
|
40
|
+
`formatMoney(amount, locale, { trimZeroFraction: true })` drops the fraction of a WHOLE amount and
|
|
41
|
+
nothing else — 1200 USD is `$12`, 1250 USD is `$12.50`, never `$12.5` (`trailingZeroDisplay:
|
|
42
|
+
'stripIfInteger'`, with min = max = the amount's scale). `fractionDigits` is a whole number from 0
|
|
43
|
+
to `MAX_FRACTION_DIGITS` (100, `Intl`'s own ceiling); anything else is `X_MONEY_SCALE_INVALID`.
|
|
44
|
+
|
|
40
45
|
## A currency the shipped rows do not carry
|
|
41
46
|
|
|
42
47
|
`As of 2026-08`, 53 ISO-4217 rows ship. They are a *convention* — one useful subset — so an app
|
|
@@ -100,6 +105,9 @@ fiction. The alternative was a second money type.
|
|
|
100
105
|
then hand out the leftover units one at a time, biggest fractional remainder first.
|
|
101
106
|
`round(100 / 3)` either loses a cent or invents one, and an invoice that does that fails
|
|
102
107
|
reconciliation forever. `allocateByRatios` does the same for revenue shares and line splits.
|
|
108
|
+
A part count is a positive integer of at most `MAX_ALLOCATION_PARTS` (1,000,000) — every part is a
|
|
109
|
+
`Money` held at once, so `allocate(m, 1e10)` is `X_ALLOCATION_INVALID`, where it was a bare
|
|
110
|
+
`RangeError` out of `new Array`. More parts than minor units is fine: the surplus parts are zero.
|
|
103
111
|
|
|
104
112
|
## Rounding is never implicit
|
|
105
113
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/core": "24.0.0",
|
|
39
|
+
"@ultimat3/schema": "24.0.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/allocate.ts
CHANGED
|
@@ -14,11 +14,24 @@ import { factorFraction } from './factor';
|
|
|
14
14
|
import { formatMoneyDebug, type Money, money } from './money';
|
|
15
15
|
import { minorAt, moneyScale } from './scale';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The most equal shares one `allocate` call hands back. A bound on memory, not on business: every
|
|
19
|
+
* part is a `Money` held at once, so `allocate(m, 1e10)` was a bare `RangeError` out of `new
|
|
20
|
+
* Array` and a count just under that an out-of-memory kill. A split wider than this is paged by
|
|
21
|
+
* the caller — `allocateByRatios` over each page's share.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_ALLOCATION_PARTS = 1_000_000;
|
|
24
|
+
|
|
17
25
|
/** Split into `parts` equal shares. `allocate(money(100,'USD'), 3)` → 34, 33, 33. */
|
|
18
26
|
export function allocate(amount: Money, parts: number): Money[] {
|
|
19
27
|
if (!Number.isSafeInteger(parts) || parts <= 0) {
|
|
20
28
|
throw allocationInvalid(`part count must be a positive integer, got ${String(parts)}`);
|
|
21
29
|
}
|
|
30
|
+
if (parts > MAX_ALLOCATION_PARTS) {
|
|
31
|
+
throw allocationInvalid(
|
|
32
|
+
`part count ${String(parts)} is over the ${MAX_ALLOCATION_PARTS} shares one call holds in memory`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
22
35
|
return allocateByRatios(amount, new Array<number>(parts).fill(1));
|
|
23
36
|
}
|
|
24
37
|
|
package/src/errors.ts
CHANGED
|
@@ -165,6 +165,18 @@ export function digitsInvalid(digits: number): MoneyError {
|
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A `formatMoney` digit count `Intl.NumberFormat` cannot be built with. Never echoes the rejected
|
|
170
|
+
* value into the `fix:` — an instruction that raises the error it is answering is not one.
|
|
171
|
+
*/
|
|
172
|
+
export function fractionDigitsInvalid(digits: number, max: number): MoneyError {
|
|
173
|
+
return new MoneyError({
|
|
174
|
+
code: 'X_MONEY_SCALE_INVALID',
|
|
175
|
+
cause: `fractionDigits must be a whole number between 0 and ${max}, got ${String(digits)}`,
|
|
176
|
+
fix: "formatMoney(amount, locale, { fractionDigits: 2 }) — or omit fractionDigits and take the amount's own scale",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
168
180
|
/** A scale outside 0…MAX_MONEY_SCALE names no decimal place a `minor` could count in. */
|
|
169
181
|
export function scaleInvalid(scale: number): MoneyError {
|
|
170
182
|
return new MoneyError({
|
package/src/format.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { assertLocale, cachedFormatter } from '@ultimat3/core';
|
|
7
7
|
import { exponentOf } from './currency';
|
|
8
|
+
import { fractionDigitsInvalid } from './errors';
|
|
8
9
|
import { type Money, toDecimalString } from './money';
|
|
9
10
|
import { moneyScale } from './scale';
|
|
10
11
|
|
|
@@ -16,10 +17,13 @@ export interface FormatMoneyOptions {
|
|
|
16
17
|
* locale decides the notation: `de-DE` has no parenthesised form in CLDR and keeps `-1.299,00 €`.
|
|
17
18
|
*/
|
|
18
19
|
accounting?: boolean;
|
|
19
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Drop `.00` on whole amounts — price lists, never invoices. A fractional amount keeps every
|
|
22
|
+
* digit: 1250 cents is `$12.50`, never `$12.5`.
|
|
23
|
+
*/
|
|
20
24
|
trimZeroFraction?: boolean;
|
|
21
|
-
/** Force a digit count
|
|
22
|
-
* the amount names a finer one. */
|
|
25
|
+
/** Force a digit count, 0…`MAX_FRACTION_DIGITS` (`X_MONEY_SCALE_INVALID` otherwise); defaults
|
|
26
|
+
* to the value's own scale, which is the currency's unless the amount names a finer one. */
|
|
23
27
|
fractionDigits?: number;
|
|
24
28
|
/** `never` disables grouping separators. */
|
|
25
29
|
grouping?: 'auto' | 'never';
|
|
@@ -98,6 +102,20 @@ export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
|
98
102
|
*/
|
|
99
103
|
const exactDecimal = (amount: Money): `${number}` => toDecimalString(amount) as `${number}`;
|
|
100
104
|
|
|
105
|
+
/** `Intl.NumberFormat`'s own ceiling on a fraction digit count. */
|
|
106
|
+
export const MAX_FRACTION_DIGITS = 100;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `Intl` refuses a digit count outside 0…100, a fraction and `NaN` with a bare `RangeError`,
|
|
110
|
+
* several frames from the call that named it. A digit count IS a scale, so it is refused as one.
|
|
111
|
+
*/
|
|
112
|
+
function assertFractionDigits(digits: number): number {
|
|
113
|
+
if (!Number.isInteger(digits) || digits < 0 || digits > MAX_FRACTION_DIGITS) {
|
|
114
|
+
throw fractionDigitsInvalid(digits, MAX_FRACTION_DIGITS);
|
|
115
|
+
}
|
|
116
|
+
return digits;
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
const cache = new Map<string, Intl.NumberFormat>();
|
|
102
120
|
const decimalCache = new Map<string, Intl.NumberFormat>();
|
|
103
121
|
|
|
@@ -123,21 +141,22 @@ function formatterFor(
|
|
|
123
141
|
options: FormatMoneyOptions,
|
|
124
142
|
exponent: number,
|
|
125
143
|
): Intl.NumberFormat {
|
|
144
|
+
// `=== undefined`, never `??`: `??` coalesces on `null` too, so an untyped caller's blanked
|
|
145
|
+
// option took the default instead of the refusal beside it.
|
|
126
146
|
const digits =
|
|
127
|
-
options.fractionDigits
|
|
147
|
+
options.fractionDigits === undefined ? exponent : assertFractionDigits(options.fractionDigits);
|
|
148
|
+
const trim = options.trimZeroFraction === true;
|
|
128
149
|
const sign = options.accounting === true ? 'accounting' : 'standard';
|
|
129
150
|
const tag = assertLocale(locale);
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// first and 12.990001 EUR then rendered as `12,99 €` — the sub-cent bug back, silently, in the
|
|
134
|
-
// one place a human reads the number.
|
|
151
|
+
// `trim` is in the key because it no longer changes `digits`: a trimmed and an untrimmed
|
|
152
|
+
// formatter at one digit count are two formatters, and sharing an entry rendered whichever was
|
|
153
|
+
// built first.
|
|
135
154
|
const key = [
|
|
136
155
|
tag,
|
|
137
156
|
currency,
|
|
138
157
|
options.display ?? 'symbol',
|
|
139
|
-
digits
|
|
140
|
-
|
|
158
|
+
digits,
|
|
159
|
+
trim ? 'trim' : 'keep',
|
|
141
160
|
options.grouping ?? 'auto',
|
|
142
161
|
sign,
|
|
143
162
|
].join('|');
|
|
@@ -150,9 +169,12 @@ function formatterFor(
|
|
|
150
169
|
currency,
|
|
151
170
|
currencyDisplay: options.display ?? 'symbol',
|
|
152
171
|
currencySign: sign,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
172
|
+
// min = max, always: the digit count is the value's scale and never a range. Trimming is
|
|
173
|
+
// `stripIfInteger` — drop the fraction of a WHOLE amount — because `minimumFractionDigits:
|
|
174
|
+
// 0` trimmed every trailing zero and rendered 1250 cents as `$12.5`.
|
|
175
|
+
minimumFractionDigits: digits,
|
|
176
|
+
maximumFractionDigits: digits,
|
|
177
|
+
...(trim ? { trailingZeroDisplay: 'stripIfInteger' as const } : {}),
|
|
156
178
|
...(options.grouping === 'never' ? { useGrouping: false } : {}),
|
|
157
179
|
}),
|
|
158
180
|
);
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
allocateByPercentages,
|
|
6
6
|
allocateByRatios,
|
|
7
7
|
assertAllocationSums,
|
|
8
|
+
MAX_ALLOCATION_PARTS,
|
|
8
9
|
} from './allocate';
|
|
9
10
|
export {
|
|
10
11
|
absolute,
|
|
@@ -71,6 +72,7 @@ export {
|
|
|
71
72
|
formatMoney,
|
|
72
73
|
formatMoneyDecimal,
|
|
73
74
|
formatMoneyParts,
|
|
75
|
+
MAX_FRACTION_DIGITS,
|
|
74
76
|
} from './format';
|
|
75
77
|
export {
|
|
76
78
|
currencyOf,
|