@ultimat3/money 23.0.0 → 25.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 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([money(1, 'EUR')], 'USD')` answered `{ minor: 1, currency: 'EUR' }`, so a caller who wrote
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
- - **`money()` 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 (`money(5, 'USD', 0)`, whole
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 `money()` refuse the raw number reported a fractional minor
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
- - **`money()` normalises `-0` to `0`.** One amount must not have two identities: `JSON.stringify`
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.
@@ -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
@@ -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, money } from '@ultimat3/money';
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, money(500, 'EUR')); // 1799
27
+ const total = add(price, fromMinor(500, 'EUR')); // 1799
28
28
  formatMoney(total, 'de-DE'); // "17,99 €"
29
- formatMoney(money(1200, 'JPY'), 'en-US'); // "¥1,200" — 0 decimals
30
- formatMoney(money(1234, 'KWD'), 'en-US'); // "KWD 1.234" — 3 decimals
31
- add(price, money(500, 'USD')); // throws X_CURRENCY_MISMATCH
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
@@ -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
@@ -54,7 +59,7 @@ Once, at boot, before the first amount in that currency is built. The rules, eac
54
59
  | Rule | Refusal |
55
60
  |---|---|
56
61
  | 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` |
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` |
58
63
  | a non-empty name | `X_CURRENCY_INVALID` |
59
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` |
60
65
  | a shipped ISO row is not the app's to redefine | `X_CURRENCY_REDEFINED` |
@@ -64,22 +69,22 @@ included. That is why one is a value and the other is a call.
64
69
 
65
70
  ## Sub-cent amounts carry a scale
66
71
 
67
- `money(2, 'USD', 6)` is $0.000002 — `minor` counting 10⁻⁶ instead of the currency's own 10⁻².
72
+ `fromMinor(2, 'USD', 6)` is $0.000002 — `minor` counting 10⁻⁶ instead of the currency's own 10⁻².
68
73
  A value that names no scale means the currency's, which is every amount that already exists, so
69
74
  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)`
75
+ *equal* to the currency's is dropped, so a deliberately coarser one is kept too: `fromMinor(5, 'USD', 0)`
71
76
  is $5 counted in whole dollars, and `rescale()` produces such values legitimately.
72
77
 
73
78
  ```ts
74
- import { add, fromDecimal, money, moneyScale, rescale } from '@ultimat3/money';
79
+ import { add, fromDecimal, fromMinor, moneyScale, rescale } from '@ultimat3/money';
75
80
 
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
+ 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
81
86
  fromDecimal('0.000002', 'USD', { scale: 6 });
82
- add(money(1, 'USD'), money(2, 'USD', 6)); // meets at scale 6: 10002, nothing lost
87
+ add(fromMinor(1, 'USD'), fromMinor(2, 'USD', 6)); // meets at scale 6: 10002, nothing lost
83
88
  ```
84
89
 
85
90
  Arithmetic normalises to the *finer* of two scales, never the coarser — adding a sub-cent fee to
@@ -96,10 +101,13 @@ fiction. The alternative was a second money type.
96
101
 
97
102
  ## Allocation
98
103
 
99
- `allocate(money(100, 'USD'), 3)` → `34, 33, 33`. Largest-remainder split: floor every part,
104
+ `allocate(fromMinor(100, 'USD'), 3)` → `34, 33, 33`. Largest-remainder split: floor every part,
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
 
@@ -120,7 +128,7 @@ scales by that when it is there. It is how a derived direction stays exact: a ta
120
128
  double `1 / 0.92`, whose own decimal spelling rounds a large amount one minor unit low. `rate`
121
129
  stays the readable number the audit trail records.
122
130
 
123
- `convert` preserves the amount's own `scale`. `convert(money(2, 'USD', 6), 'EUR', parity)` is
131
+ `convert` preserves the amount's own `scale`. `convert(fromMinor(2, 'USD', 6), 'EUR', parity)` is
124
132
  €0.000002, not €0.02 — the target currency's minor unit decides nothing about a value that
125
133
  already carries its own precision. `convertWith` on a same-currency pair stamps `at` from an
126
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": "23.0.0",
3
+ "version": "25.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",
@@ -28,14 +28,14 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "engines": {
31
- "bun": ">=1.4.0"
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": "23.0.0",
39
- "@ultimat3/schema": "23.0.0"
38
+ "@ultimat3/core": "25.0.0",
39
+ "@ultimat3/schema": "25.0.0"
40
40
  }
41
41
  }
package/src/allocate.ts CHANGED
@@ -11,19 +11,32 @@
11
11
  import { assertSameCurrency } from './arithmetic';
12
12
  import { allocationInvalid } from './errors';
13
13
  import { factorFraction } from './factor';
14
- import { formatMoneyDebug, type Money, money } from './money';
14
+ import { formatMoneyDebug, fromMinor, type Money } from './money';
15
15
  import { minorAt, moneyScale } from './scale';
16
16
 
17
- /** Split into `parts` equal shares. `allocate(money(100,'USD'), 3)` → 34, 33, 33. */
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
+
25
+ /** Split into `parts` equal shares. `allocate(fromMinor(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
 
25
38
  /**
26
- * Split by weights. `allocateByRatios(money(1000,'USD'), [70, 20, 10])` → 700, 200, 100;
39
+ * Split by weights. `allocateByRatios(fromMinor(1000,'USD'), [70, 20, 10])` → 700, 200, 100;
27
40
  * `[1, 1, 1]` over 100 → 34, 33, 33. Weights need not sum to anything in particular.
28
41
  */
29
42
  export function allocateByRatios(amount: Money, ratios: readonly number[]): Money[] {
@@ -61,7 +74,7 @@ export function allocateByRatios(amount: Money, ratios: readonly number[]): Mone
61
74
  leftover -= 1n;
62
75
  }
63
76
 
64
- return floors.map((minor) => money(sign * Number(minor), amount.currency, amount.scale));
77
+ return floors.map((minor) => fromMinor(sign * Number(minor), amount.currency, amount.scale));
65
78
  }
66
79
 
67
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, money } from './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 money(
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 money(
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([money(1, 'EUR')], 'USD')` used to answer `{ minor: 1, currency: 'EUR' }`, so a
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), money(0, base));
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 money(
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 money(
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 money(-amount.minor, amount.currency, amount.scale);
105
+ return fromMinor(-amount.minor, amount.currency, amount.scale);
106
106
  }
107
107
 
108
108
  export function absolute(amount: Money): Money {
109
- return money(Math.abs(amount.minor), amount.currency, amount.scale);
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, money } from './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(money(1000,'USD'), 'EUR', { rate: 0.92, ... })`.
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: money(converted, target, resultScale),
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 `money()` with an unusable `minor`, and each needs its own instruction.
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 = `money(Math.round(${spelling}), ${code})`;
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
  }
@@ -165,12 +165,24 @@ 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({
171
183
  code: 'X_MONEY_SCALE_INVALID',
172
184
  cause: `a money scale must be a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, got ${String(scale)}`,
173
- fix: `use a scale in range — money(minor, currency, 6) for micros, or omit it for the currency's own minor unit`,
185
+ fix: `use a scale in range — fromMinor(minor, currency, 6) for micros, or omit it for the currency's own minor unit`,
174
186
  });
175
187
  }
176
188
 
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,17 +17,20 @@ 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
- /** Drop `.00` on whole amounts — price lists, never invoices. */
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; defaults to the value's own scale, which is the currency's unless
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';
26
30
  }
27
31
 
28
32
  /**
29
- * `formatMoney(money(129900,'EUR'), 'de-DE')` → `1.299,00 €`.
33
+ * `formatMoney(fromMinor(129900,'EUR'), 'de-DE')` → `1.299,00 €`.
30
34
  *
31
35
  * Delegates to `formatMoneyParts` and joins: a UI styling the symbol off the parts and a label
32
36
  * rendering the string must not disagree about where the sign goes. Hand-prefixing `-` here put
@@ -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 ?? (options.trimZeroFraction === true ? undefined : exponent);
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
- // `exponent` is in the key because it stopped being derivable from `currency` the moment it
131
- // started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
132
- // `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
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 ?? 'auto',
140
- exponent,
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
- ...(digits === undefined
154
- ? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
155
- : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
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,
@@ -78,9 +80,9 @@ export {
78
80
  type FromDecimalOptions,
79
81
  formatMoneyDebug,
80
82
  fromDecimal,
83
+ fromMinor,
81
84
  isMoney,
82
85
  type Money,
83
- money,
84
86
  toDecimalNumber,
85
87
  toDecimalString,
86
88
  zero,
@@ -93,4 +95,4 @@ export {
93
95
  roundToDigits,
94
96
  roundToInteger,
95
97
  } from './rounding';
96
- export { assertScale, commonScale, MAX_MONEY_SCALE, minorAt, moneyScale } from './scale';
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
- * `money(2, 'USD', 6)` is $0.000002. Omitted — and canonically omitted again when it says nothing
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 money(minor: number, currency: string, scale?: number): Money {
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 money(0, currency);
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 money(negative ? -minor : minor, code, options.scale);
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
- * `money(9007199254740991, 'USD', 6)` as `…740992`. Kept as public API for a chart axis or a sort
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, money } from './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(money(80, 'USD'), 8)` → 80,000,000 hundred-millionths, the granularity a per-token
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 money(toMinor(minorAt(amount, scale), scale, amount.currency), amount.currency, scale);
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 money(roundRatio(numerator, divisor, mode), amount.currency, scale);
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 { invariant } from '@ultimat3/core';
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
- invariant(
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 `money()` refuses as X_MONEY_NOT_INTEGER.
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, MAX_MONEY_SCALE } from '@ultimat3/schema';
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 `money()` rightly refuses to
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
- * `money()` as a plain out-of-range amount — reported as a fractional minor nobody wrote, with a
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 {