@ultimat3/money 1.2.0 → 2.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/src/currency.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * The ISO-4217 minor-unit exponent table. Every scale in the package derives from here —
3
- * a hardcoded `/ 100` is a bug in JPY (0 digits) and in KWD (3 digits).
4
- * As of 2026-07.
2
+ * The minor-unit exponent table: the ISO-4217 rows Ultimate ships, plus the rows an app registers.
3
+ * Every scale in the package derives from here — a hardcoded `/ 100` is a bug in JPY (0 digits)
4
+ * and in KWD (3 digits). As of 2026-08.
5
5
  */
6
6
 
7
- import { currencyUnknown } from './errors';
7
+ import { renderCauseValue } from '@ultimat3/core';
8
+ import { isCurrencyCode, isMoneyScale, MAX_MONEY_SCALE } from '@ultimat3/schema';
9
+ import { currencyDeclarationInvalid, currencyRedefined, currencyUnknown } from './errors';
8
10
 
9
11
  /** Uppercase ISO-4217 alphabetic code. */
10
12
  export type CurrencyCode = string;
@@ -76,15 +78,82 @@ const BY_CODE: ReadonlyMap<string, CurrencyInfo> = new Map(
76
78
  TABLE.map((info) => [info.code, info] as const),
77
79
  );
78
80
 
81
+ /**
82
+ * What the app added. Separate from `BY_CODE` so a shipped ISO row can never be overwritten and
83
+ * `CURRENCIES` keeps meaning exactly what it meant: the constant this package ships.
84
+ */
85
+ const REGISTERED = new Map<string, CurrencyInfo>();
86
+
87
+ /**
88
+ * The ISO-4217 rows Ultimate ships — a constant, the same in every process.
89
+ *
90
+ * Not the same question as `currencyCodes()`, which answers for *this* process and includes
91
+ * whatever the app registered. That is why one is a value and the other is a call.
92
+ */
79
93
  export const CURRENCIES: readonly CurrencyInfo[] = TABLE;
80
94
 
95
+ /**
96
+ * Declare a currency the shipped ISO rows do not carry — a local currency, a scrip, a loyalty
97
+ * point, a token. Call it once, at boot, before any amount in that currency is built.
98
+ *
99
+ * The 53 shipped rows are a *convention* — one useful subset of ISO-4217 — and axiom 8 says an app
100
+ * encodes its own by calling a function, never by forking the package. The rest of the framework
101
+ * already agreed: `@ultimat3/schema`'s `moneySchema`, the published OpenAPI contract and
102
+ * `@ultimat3/entity`'s `char(3)` CHECK all accept any `^[A-Z]{3}$`, so an app could take an
103
+ * unregistered code over HTTP and store it, and only arithmetic would refuse it afterwards.
104
+ *
105
+ * Returns the row now in force, so the identical second call is a no-op rather than a crash — a
106
+ * module imported twice must not take the process down.
107
+ */
108
+ export function registerCurrency(info: CurrencyInfo): CurrencyInfo {
109
+ const { code, exponent, name } = info;
110
+ // `isCurrencyCode`, imported for the same reason `isMoneyScale` below is: this is the bound
111
+ // `moneySchema`, the published OpenAPI `pattern` and `@ultimat3/entity`'s CHECK all apply, and a
112
+ // registration accepting a code any of them refuses would put a row in a table the app cannot
113
+ // read back. It also carries the `typeof` half, so an untyped caller's symbol is a refusal here
114
+ // rather than a `TypeError` from `.test()`. Not taste either:
115
+ // `Intl.NumberFormat({ style: 'currency', currency })` throws a `RangeError` on anything else,
116
+ // so a registration that skipped the shape would format nothing.
117
+ if (!isCurrencyCode(code)) {
118
+ throw currencyDeclarationInvalid(
119
+ // `renderCauseValue`, not `String(code)`: the signature says `string` but nothing stops an
120
+ // untyped caller passing a symbol, and `String()` raises on one — the validator would then
121
+ // throw a TypeError instead of the coded refusal it exists to produce.
122
+ `a currency code must be three uppercase letters, got ${renderCauseValue(code)}`,
123
+ 'XBT',
124
+ );
125
+ }
126
+ // `isMoneyScale`, imported rather than restated: an exponent past MAX_MONEY_SCALE names a
127
+ // decimal place `minor` could not count in, and one bound with two declarations is one bound
128
+ // that drifts. It also rejects a non-integer, so a 2.5 cannot round itself into the table.
129
+ if (!isMoneyScale(exponent)) {
130
+ throw currencyDeclarationInvalid(
131
+ `${code} needs a whole number of decimal places between 0 and ${MAX_MONEY_SCALE} — an exponent decides what a stored minor counts, so there is no safe default`,
132
+ code,
133
+ );
134
+ }
135
+ if (typeof name !== 'string' || name.trim() === '') {
136
+ throw currencyDeclarationInvalid(`${code} needs a non-empty name`, code);
137
+ }
138
+
139
+ const existing = BY_CODE.get(code) ?? REGISTERED.get(code);
140
+ if (existing !== undefined) {
141
+ if (existing.exponent === exponent && existing.name === name) return existing;
142
+ throw currencyRedefined(code, existing, { exponent, name });
143
+ }
144
+
145
+ const row: CurrencyInfo = Object.freeze({ code, exponent, name });
146
+ REGISTERED.set(code, row);
147
+ return row;
148
+ }
149
+
81
150
  export function isValidCurrency(currency: string): boolean {
82
- return BY_CODE.has(currency);
151
+ return BY_CODE.has(currency) || REGISTERED.has(currency);
83
152
  }
84
153
 
85
154
  /** Loud lookup: an unknown code is a data bug, not a formatting quirk. */
86
155
  export function currencyInfo(currency: string): CurrencyInfo {
87
- const info = BY_CODE.get(currency);
156
+ const info = BY_CODE.get(currency) ?? REGISTERED.get(currency);
88
157
  if (info === undefined) throw currencyUnknown(currency);
89
158
  return info;
90
159
  }
@@ -103,6 +172,14 @@ export function scaleOf(currency: string): number {
103
172
  return 10 ** exponentOf(currency);
104
173
  }
105
174
 
175
+ /**
176
+ * Every code this process accepts — the shipped rows plus whatever the app registered. Sorted, so
177
+ * two processes with the same registrations print the same list.
178
+ *
179
+ * This is the one enumeration `X_CURRENCY_UNKNOWN`'s fix line points at, so it has to include the
180
+ * registered rows: a fix that named a list the accepted code is missing from would be the same
181
+ * dead end that error used to hand back.
182
+ */
106
183
  export function currencyCodes(): CurrencyCode[] {
107
- return TABLE.map((info) => info.code);
184
+ return [...TABLE.map((info) => info.code), ...REGISTERED.keys()].sort();
108
185
  }
package/src/errors.ts CHANGED
@@ -3,14 +3,18 @@
3
3
  * A money bug that throws is a bug you can fix; one that rounds is a bug you ship.
4
4
  */
5
5
 
6
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
6
+ import { registerErrorCodes, renderFixLiteral, UltimateError } from '@ultimat3/core';
7
+ import { isCurrencyCode, MAX_MONEY_SCALE } from '@ultimat3/schema';
7
8
 
8
9
  export const MONEY_ERROR_CODES = [
9
10
  'X_MONEY_NOT_INTEGER',
10
11
  'X_CURRENCY_UNKNOWN',
11
12
  'X_CURRENCY_MISMATCH',
13
+ 'X_CURRENCY_INVALID',
14
+ 'X_CURRENCY_REDEFINED',
12
15
  'X_ALLOCATION_INVALID',
13
16
  'X_RATE_MISSING',
17
+ 'X_MONEY_SCALE_INVALID',
14
18
  ] as const;
15
19
 
16
20
  export type MoneyErrorCode = (typeof MONEY_ERROR_CODES)[number];
@@ -19,8 +23,11 @@ export const MONEY_ERROR_TITLES: Readonly<Record<MoneyErrorCode, string>> = {
19
23
  X_MONEY_NOT_INTEGER: 'a Money.minor value that is not an integer',
20
24
  X_CURRENCY_UNKNOWN: 'currency code not in the currency table',
21
25
  X_CURRENCY_MISMATCH: 'two Money values in different currencies',
26
+ X_CURRENCY_INVALID: 'a registerCurrency declaration that cannot become a currency',
27
+ X_CURRENCY_REDEFINED: 'one currency code registered twice with different meanings',
22
28
  X_ALLOCATION_INVALID: 'split ratios or part count are unusable',
23
29
  X_RATE_MISSING: 'no FX rate for the pair',
30
+ X_MONEY_SCALE_INVALID: 'a Money.scale that is not a usable decimal exponent',
24
31
  };
25
32
 
26
33
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -41,11 +48,36 @@ export class MoneyError extends UltimateError {
41
48
  }
42
49
  }
43
50
 
51
+ /**
52
+ * A currency code fit to stand in a `fix:` — the caller's when it already is one, `XXX` otherwise.
53
+ * Two rules at once, and every factory below that names a code goes through it.
54
+ *
55
+ * A fix is PASTED AND RUN, so a code carrying a quote closes the literal it sits in and the
56
+ * instruction becomes a syntax error — `assertCurrency('O'Reilly')` — which is worse than no fix,
57
+ * because it looks runnable. And a malformed code echoed back would name a call that raises the
58
+ * error it is answering, the rule `currencyDeclarationInvalid` below already states. Every one of
59
+ * these factories is exported, so the argument is whatever an app passed, not only what this
60
+ * package throws. `bun run error-render` cannot see the class: these parameters are typed
61
+ * `string`, not `unknown`.
62
+ */
63
+ function codeExample(currency: string): string {
64
+ // Uppercased and cut to three because the lookup is case-sensitive, so 'usd' is the common
65
+ // arrival and 'USD' answers it. The `typeof` guard is what keeps an untyped caller's symbol from
66
+ // throwing out of `.toUpperCase()` inside an error factory.
67
+ const upper = typeof currency === 'string' ? currency.toUpperCase().slice(0, 3) : '';
68
+ return isCurrencyCode(upper) ? upper : 'XXX';
69
+ }
70
+
71
+ /** The same code as source. Safe to interpolate: `codeExample` answers `^[A-Z]{3}$`, nothing else. */
72
+ function codeLiteral(currency: string): string {
73
+ return `'${codeExample(currency)}'`;
74
+ }
75
+
44
76
  export function moneyNotInteger(minor: number, currency: string): MoneyError {
45
77
  return new MoneyError({
46
78
  code: 'X_MONEY_NOT_INTEGER',
47
79
  cause: `minor units must be a safe integer, got ${String(minor)} for ${currency}`,
48
- fix: `use fromDecimal('${Number.isFinite(minor) ? minor : 0}', '${currency}') or round explicitly with multiply(m, factor, 'half-up')`,
80
+ fix: `use fromDecimal('${Number.isFinite(minor) ? minor : 0}', ${codeLiteral(currency)}) or round explicitly with multiply(m, factor, 'half-up')`,
49
81
  });
50
82
  }
51
83
 
@@ -62,10 +94,82 @@ export function notRoundable(value: number): MoneyError {
62
94
  }
63
95
 
64
96
  export function decimalTooPrecise(value: string, currency: string, exponent: number): MoneyError {
97
+ const digits = countFractionDigits(value);
98
+ // Past MAX_MONEY_SCALE no scale keeps every digit, so the offer is withdrawn rather than
99
+ // clamped: `{ scale: 19 }` was a fix line that answered X_MONEY_SCALE_INVALID, and an
100
+ // instruction that throws is not one.
101
+ const keepThemAll =
102
+ digits <= MAX_MONEY_SCALE
103
+ ? // `renderFixLiteral`, not `'${value}'`: the amount is a caller's string, and `fromDecimal`
104
+ // trims before it parses — so a value ending in a newline reached the fix line and put a
105
+ // raw line break inside a single-quoted literal, which no reader can paste.
106
+ `fromDecimal(${renderFixLiteral(value.trim(), "'12.9999'")}, ${codeLiteral(currency)}, { scale: ${digits} }) to keep every digit, or `
107
+ : '';
65
108
  return new MoneyError({
66
109
  code: 'X_MONEY_NOT_INTEGER',
67
- cause: `"${value}" has more fraction digits than ${currency} has minor units (${exponent})`,
68
- fix: `pass { rounding: 'half-up' } to fromDecimal to accept the loss of precision on purpose`,
110
+ cause: `"${value}" has more than ${exponent} fraction digit(s), which is all ${currency} is being counted in`,
111
+ fix: `${keepThemAll}pass { rounding: 'half-up' } to fromDecimal to lose the extra digits on purpose`,
112
+ });
113
+ }
114
+
115
+ function countFractionDigits(value: string): number {
116
+ return value.trim().split('.')[1]?.length ?? 0;
117
+ }
118
+
119
+ /** A scale outside 0…MAX_MONEY_SCALE names no decimal place a `minor` could count in. */
120
+ export function scaleInvalid(scale: number): MoneyError {
121
+ return new MoneyError({
122
+ code: 'X_MONEY_SCALE_INVALID',
123
+ cause: `a money scale must be a whole number of decimal places between 0 and ${MAX_MONEY_SCALE}, got ${String(scale)}`,
124
+ fix: `use a scale in range — money(minor, currency, 6) for micros, or omit it for the currency's own minor unit`,
125
+ });
126
+ }
127
+
128
+ /**
129
+ * A widened value that no longer fits a safe integer. Reported under `X_MONEY_SCALE_INVALID`
130
+ * rather than `X_MONEY_NOT_INTEGER` because the caller never wrote a fractional minor — the scale
131
+ * the operation had to meet at is what does not fit, and that code's fix line
132
+ * (`fromDecimal('90071992547409900000', …)`) throws again. Same code as the other scale faults, so
133
+ * the reader lands on the page about scales, which is where the answer is.
134
+ */
135
+ export function scaleOverflow(
136
+ scale: number,
137
+ currency: string,
138
+ fits: number | undefined,
139
+ ): MoneyError {
140
+ return new MoneyError({
141
+ code: 'X_MONEY_SCALE_INVALID',
142
+ cause: `this ${currency} amount needs more digits at scale ${scale} than a safe integer holds`,
143
+ fix:
144
+ fits === undefined
145
+ ? `the amount is too large for any scale — split it, or carry it as two ${codeExample(currency)} values`
146
+ : `rescale(theFinerOperand, ${fits}, 'half-up') before combining — scale ${fits} is the finest that fits`,
147
+ });
148
+ }
149
+
150
+ /** Widening is exact; narrowing is a rounding decision, and `minorAt` does not make those. */
151
+ export function scaleNotWidening(from: number, to: number): MoneyError {
152
+ return new MoneyError({
153
+ code: 'X_MONEY_SCALE_INVALID',
154
+ cause: `cannot restate a value at scale ${from} as scale ${to} without dropping digits`,
155
+ fix: `rescale(amount, ${to}, 'half-up') — narrowing needs the mode named at the call`,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * A narrowing that would drop a non-zero digit. Reported as `X_MONEY_NOT_INTEGER` because that is
161
+ * literally what it would produce — a fractional count of minor units — and the same situation
162
+ * `fromDecimal` already answers with that code.
163
+ */
164
+ export function rescaleNotExact(
165
+ amount: { readonly minor: number; readonly currency: string },
166
+ from: number,
167
+ to: number,
168
+ ): MoneyError {
169
+ return new MoneyError({
170
+ code: 'X_MONEY_NOT_INTEGER',
171
+ cause: `${amount.currency} ${amount.minor} at scale ${from} is not a whole number of minor units at scale ${to}`,
172
+ fix: `rescale(amount, ${to}, 'half-up') — name the mode, or keep the value at scale ${from}`,
69
173
  });
70
174
  }
71
175
 
@@ -73,15 +177,54 @@ export function decimalNotNumeric(value: string, currency: string): MoneyError {
73
177
  return new MoneyError({
74
178
  code: 'X_MONEY_NOT_INTEGER',
75
179
  cause: `"${value}" is not a decimal amount — no grouping separators, no exponent notation`,
76
- fix: `pass a plain decimal string: fromDecimal('12.99', '${currency}')`,
180
+ fix: `pass a plain decimal string: fromDecimal('12.99', ${codeLiteral(currency)})`,
77
181
  });
78
182
  }
79
183
 
80
184
  export function currencyUnknown(currency: string): MoneyError {
185
+ const example = codeLiteral(currency);
186
+ // Two arrivals, one code, so the fix names both doors. The lookup is exact and case-sensitive,
187
+ // which is what makes 'usd' the common one; the other is a currency the shipped ISO rows do not
188
+ // carry, and since 1.2.0 that is a call the app makes rather than a fork of this package.
81
189
  return new MoneyError({
82
190
  code: 'X_CURRENCY_UNKNOWN',
83
- cause: `"${currency}" is not an ISO-4217 code in the currency table`,
84
- fix: `x money add-currency ${currency.toUpperCase().slice(0, 3) || 'XXX'} --exponent 2`,
191
+ cause: `"${currency}" is not a currency this process knows — not in the shipped ISO-4217 rows, and not registered by the app`,
192
+ fix: `pass a code currencyCodes() lists, uppercased — assertCurrency(${example}) or declare it once at boot: registerCurrency({ code: ${example}, exponent: 2, name: ${example} })`,
193
+ });
194
+ }
195
+
196
+ /**
197
+ * A `registerCurrency` declaration that could not become a currency. Never echoes the rejected
198
+ * value back into the `fix:` — `decimalTooPrecise` shipped that shape once, and an instruction
199
+ * that raises the error it is answering is not an instruction.
200
+ */
201
+ export function currencyDeclarationInvalid(reason: string, exampleCode: string): MoneyError {
202
+ return new MoneyError({
203
+ code: 'X_CURRENCY_INVALID',
204
+ cause: reason,
205
+ fix: `registerCurrency({ code: ${codeLiteral(exampleCode)}, exponent: 2, name: ${codeLiteral(exampleCode)} }) — three A–Z letters, a whole exponent from 0 to ${MAX_MONEY_SCALE}, and a non-empty name`,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * One code, one declaration. The exponent is the dangerous half: it decides what a stored `minor`
211
+ * counts, so accepting a second one silently reinterprets every amount already written in that
212
+ * currency by a power of ten. The name matters for a smaller reason that is still a bug —
213
+ * `currencyInfo(code).name` is rendered, and two registrations would make it depend on import
214
+ * order.
215
+ */
216
+ export function currencyRedefined(
217
+ code: string,
218
+ existing: { readonly exponent: number; readonly name: string },
219
+ attempted: { readonly exponent: number; readonly name: string },
220
+ ): MoneyError {
221
+ return new MoneyError({
222
+ code: 'X_CURRENCY_REDEFINED',
223
+ cause: `${code} is already registered as "${existing.name}" with exponent ${existing.exponent}; refusing to redefine it as "${attempted.name}" with exponent ${attempted.exponent}`,
224
+ // `renderFixLiteral` on the name and nowhere else: a name is free text by design — `O'Reilly
225
+ // Points` is a currency an app may legitimately register — and it is the one value here that
226
+ // cannot be gated into a safe shape, so it is escaped instead.
227
+ fix: `keep one registerCurrency({ code: ${codeLiteral(code)}, exponent: ${existing.exponent}, name: ${renderFixLiteral(existing.name, "'the name already registered'")} }) call and delete the other — to change a live exponent you must migrate every stored ${codeExample(code)} amount, because each one shifts by a power of ten`,
85
228
  });
86
229
  }
87
230
 
@@ -97,7 +240,7 @@ export function currencyMismatch(left: string, right: string): MoneyError {
97
240
  return new MoneyError({
98
241
  code: 'X_CURRENCY_MISMATCH',
99
242
  cause: `refusing to combine ${left} and ${right} — two currencies are not one number`,
100
- fix: `convert(rightOperand, '${left}', rate) first, then combine`,
243
+ fix: `convert(rightOperand, ${codeLiteral(left)}, rate) first, then combine`,
101
244
  });
102
245
  }
103
246
 
package/src/factor.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The exact fraction a scaling factor's decimal spelling names.
3
+ * A rate, a quantity or a percentage arrives as an IEEE-754 double, and `1.005` is held as
4
+ * 1.00499999999999989…; scaling first and rounding after therefore shows the rounding mode a
5
+ * value nobody wrote. The shortest round-trip decimal IS what was written, so its expansion is
6
+ * the exact value every scale in this package is taken against.
7
+ */
8
+
9
+ import { notRoundable } from './errors';
10
+
11
+ /** `numerator / denominator`, exactly. `denominator` is always a positive power of ten. */
12
+ export interface Fraction {
13
+ readonly numerator: bigint;
14
+ readonly denominator: bigint;
15
+ }
16
+
17
+ /** The grammar `Number.prototype.toString` emits for every finite double, exponent included. */
18
+ const SPELLING = /^([+-]?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/;
19
+
20
+ export function factorFraction(factor: number): Fraction {
21
+ if (!Number.isFinite(factor)) throw notRoundable(factor);
22
+ const match = SPELLING.exec(String(factor));
23
+ if (match === null) throw notRoundable(factor);
24
+ const [, sign = '', whole = '0', fraction = '', exponent = '0'] = match;
25
+
26
+ let numerator = BigInt(`${whole}${fraction}`);
27
+ let denominator = 10n ** BigInt(fraction.length);
28
+ const shift = BigInt(exponent);
29
+ if (shift > 0n) numerator *= 10n ** shift;
30
+ else if (shift < 0n) denominator *= 10n ** -shift;
31
+
32
+ return { numerator: sign === '-' ? -numerator : numerator, denominator };
33
+ }
package/src/format.ts CHANGED
@@ -5,56 +5,74 @@
5
5
 
6
6
  import { exponentOf } from './currency';
7
7
  import { type Money, toDecimalNumber } from './money';
8
+ import { moneyScale } from './scale';
8
9
 
9
10
  export interface FormatMoneyOptions {
10
11
  /** How the currency appears: `€1,299.00` / `EUR 1,299.00` / `1,299.00 euros`. */
11
12
  display?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
12
- /** Accounting negatives: `(€12.99)` instead of `-€12.99`. */
13
+ /**
14
+ * Accounting negatives — `(€12.99)` in `en-US`. Passed to `Intl` as `currencySign`, so the
15
+ * locale decides the notation: `de-DE` has no parenthesised form in CLDR and keeps `-1.299,00 €`.
16
+ */
13
17
  accounting?: boolean;
14
18
  /** Drop `.00` on whole amounts — price lists, never invoices. */
15
19
  trimZeroFraction?: boolean;
16
- /** Force a digit count; defaults to the currency's minor-unit exponent. */
20
+ /** Force a digit count; defaults to the value's own scale, which is the currency's unless
21
+ * the amount names a finer one. */
17
22
  fractionDigits?: number;
18
23
  /** `never` disables grouping separators. */
19
24
  grouping?: 'auto' | 'never';
20
25
  }
21
26
 
22
- /** `formatMoney(money(129900,'EUR'), 'de-DE')` → `1.299,00 €`. */
27
+ /**
28
+ * `formatMoney(money(129900,'EUR'), 'de-DE')` → `1.299,00 €`.
29
+ *
30
+ * Delegates to `formatMoneyParts` and joins: a UI styling the symbol off the parts and a label
31
+ * rendering the string must not disagree about where the sign goes. Hand-prefixing `-` here put
32
+ * it outside the symbol (`-€ 1.299,00`) where `nl-NL` puts it inside (`€ -1.299,00`), and
33
+ * `accounting` was applied on this path only.
34
+ */
23
35
  export function formatMoney(
24
36
  amount: Money,
25
37
  locale: string,
26
38
  options: FormatMoneyOptions = {},
27
39
  ): string {
28
- const rendered = formatterFor(amount.currency, locale, options).format(
29
- Math.abs(toDecimalNumber(amount)),
30
- );
31
- if (amount.minor < 0) {
32
- return options.accounting === true ? `(${rendered})` : `-${rendered}`;
33
- }
34
- return rendered;
40
+ return formatMoneyParts(amount, locale, options)
41
+ .map((part) => part.value)
42
+ .join('');
35
43
  }
36
44
 
37
45
  /**
38
46
  * Parts, for UI that styles the symbol or the decimals differently (a smaller superscript
39
47
  * cent, a muted currency code). Never re-split a formatted string with a regex.
48
+ *
49
+ * The signed value goes to `Intl`, so sign placement and the accounting notation are the
50
+ * locale's — the one place either is decided.
40
51
  */
41
52
  export function formatMoneyParts(
42
53
  amount: Money,
43
54
  locale: string,
44
55
  options: FormatMoneyOptions = {},
45
56
  ): Intl.NumberFormatPart[] {
46
- return formatterFor(amount.currency, locale, options).formatToParts(toDecimalNumber(amount));
57
+ return formatterFor(amount.currency, locale, options, moneyScale(amount)).formatToParts(
58
+ toDecimalNumber(amount),
59
+ );
47
60
  }
48
61
 
49
62
  /** The symbol alone, e.g. for an input prefix: `€`, `¥`, `KD`. */
50
63
  export function currencySymbol(currency: string, locale: string): string {
51
- const parts = formatterFor(currency, locale, { display: 'narrowSymbol' }).formatToParts(0);
64
+ const parts = formatterFor(
65
+ currency,
66
+ locale,
67
+ { display: 'narrowSymbol' },
68
+ exponentOf(currency),
69
+ ).formatToParts(0);
52
70
  return parts.find((part) => part.type === 'currency')?.value ?? currency;
53
71
  }
54
72
 
55
73
  /** Digits only, no symbol — for editable inputs and CSV exports. */
56
74
  export function formatMoneyDecimal(amount: Money, locale: string): string {
57
- const digits = exponentOf(amount.currency);
75
+ const digits = moneyScale(amount);
58
76
  return new Intl.NumberFormat(locale, {
59
77
  style: 'decimal',
60
78
  minimumFractionDigits: digits,
@@ -65,20 +83,32 @@ export function formatMoneyDecimal(amount: Money, locale: string): string {
65
83
 
66
84
  const cache = new Map<string, Intl.NumberFormat>();
67
85
 
86
+ /**
87
+ * `scale` is the amount's own, not the currency's: rendering $0.000002 with two digits shows
88
+ * `$0.00`, which is the sub-cent bug back again, in the one place a human would read it.
89
+ */
68
90
  function formatterFor(
69
91
  currency: string,
70
92
  locale: string,
71
93
  options: FormatMoneyOptions,
94
+ exponent: number,
72
95
  ): Intl.NumberFormat {
73
- const exponent = exponentOf(currency);
74
96
  const digits =
75
97
  options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
98
+ const sign = options.accounting === true ? 'accounting' : 'standard';
99
+ // `exponent` is in the key because it stopped being derivable from `currency` the moment it
100
+ // started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
101
+ // `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
102
+ // first and 12.990001 EUR then rendered as `12,99 €` — the sub-cent bug back, silently, in the
103
+ // one place a human reads the number.
76
104
  const key = [
77
105
  locale,
78
106
  currency,
79
107
  options.display ?? 'symbol',
80
108
  digits ?? 'auto',
109
+ exponent,
81
110
  options.grouping ?? 'auto',
111
+ sign,
82
112
  ].join('|');
83
113
  const cached = cache.get(key);
84
114
  if (cached !== undefined) return cached;
@@ -87,6 +117,7 @@ function formatterFor(
87
117
  style: 'currency',
88
118
  currency,
89
119
  currencyDisplay: options.display ?? 'symbol',
120
+ currencySign: sign,
90
121
  ...(digits === undefined
91
122
  ? { minimumFractionDigits: 0, maximumFractionDigits: exponent }
92
123
  : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
package/src/index.ts CHANGED
@@ -41,11 +41,14 @@ export {
41
41
  currencyInfo,
42
42
  exponentOf,
43
43
  isValidCurrency,
44
+ registerCurrency,
44
45
  scaleOf,
45
46
  } from './currency';
46
47
  export {
47
48
  allocationInvalid,
49
+ currencyDeclarationInvalid,
48
50
  currencyMismatch,
51
+ currencyRedefined,
49
52
  currencyRequired,
50
53
  currencyUnknown,
51
54
  decimalNotNumeric,
@@ -56,7 +59,12 @@ export {
56
59
  type MoneyErrorCode,
57
60
  moneyNotInteger,
58
61
  rateMissing,
62
+ rescaleNotExact,
63
+ scaleInvalid,
64
+ scaleNotWidening,
59
65
  } from './errors';
66
+ /** `ExchangeRate.ratio` is one of these; a provider with an exact rate writes the pair itself. */
67
+ export type { Fraction } from './factor';
60
68
  export {
61
69
  currencySymbol,
62
70
  type FormatMoneyOptions,
@@ -77,6 +85,7 @@ export {
77
85
  toDecimalString,
78
86
  zero,
79
87
  } from './money';
88
+ export { rescale } from './rescale';
80
89
  export {
81
90
  DEFAULT_ROUNDING,
82
91
  ROUNDING_MODES,
@@ -84,3 +93,4 @@ export {
84
93
  roundToDigits,
85
94
  roundToInteger,
86
95
  } from './rounding';
96
+ export { assertScale, commonScale, MAX_MONEY_SCALE, minorAt, moneyScale } from './scale';