@ultimat3/money 22.15.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 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": "22.15.0",
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",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "!src/**/*-fixture.ts",
25
26
  "CLAUDE.md",
26
27
  "README.md",
27
28
  "LICENSE"
@@ -34,7 +35,7 @@
34
35
  "test": "bun test"
35
36
  },
36
37
  "dependencies": {
37
- "@ultimat3/core": "22.15.0",
38
- "@ultimat3/schema": "22.15.0"
38
+ "@ultimat3/core": "24.0.0",
39
+ "@ultimat3/schema": "24.0.0"
39
40
  }
40
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
- /** 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';
@@ -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,