@ultimat3/money 1.2.0 → 3.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 +125 -0
- package/README.md +83 -6
- package/package.json +4 -2
- package/src/allocate.ts +53 -26
- package/src/arithmetic.ts +50 -11
- package/src/convert.ts +81 -13
- package/src/currency.ts +84 -7
- package/src/errors.ts +151 -8
- package/src/factor.ts +33 -0
- package/src/format.ts +89 -35
- package/src/index.ts +10 -0
- package/src/money.ts +72 -23
- package/src/rescale.ts +31 -0
- package/src/rounding.ts +64 -6
- package/src/scale.ts +73 -0
package/src/currency.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* a hardcoded `/ 100` is a bug in JPY (0 digits)
|
|
4
|
-
* As of 2026-
|
|
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 {
|
|
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}',
|
|
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
|
|
68
|
-
fix:
|
|
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',
|
|
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
|
|
84
|
-
fix: `
|
|
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,
|
|
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
|
@@ -3,95 +3,149 @@
|
|
|
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';
|
|
9
|
+
import { moneyScale } from './scale';
|
|
8
10
|
|
|
9
11
|
export interface FormatMoneyOptions {
|
|
10
12
|
/** How the currency appears: `€1,299.00` / `EUR 1,299.00` / `1,299.00 euros`. */
|
|
11
13
|
display?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
|
|
12
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Accounting negatives — `(€12.99)` in `en-US`. Passed to `Intl` as `currencySign`, so the
|
|
16
|
+
* locale decides the notation: `de-DE` has no parenthesised form in CLDR and keeps `-1.299,00 €`.
|
|
17
|
+
*/
|
|
13
18
|
accounting?: boolean;
|
|
14
19
|
/** Drop `.00` on whole amounts — price lists, never invoices. */
|
|
15
20
|
trimZeroFraction?: boolean;
|
|
16
|
-
/** Force a digit count; defaults to the
|
|
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. */
|
|
17
23
|
fractionDigits?: number;
|
|
18
24
|
/** `never` disables grouping separators. */
|
|
19
25
|
grouping?: 'auto' | 'never';
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* `formatMoney(money(129900,'EUR'), 'de-DE')` → `1.299,00 €`.
|
|
30
|
+
*
|
|
31
|
+
* Delegates to `formatMoneyParts` and joins: a UI styling the symbol off the parts and a label
|
|
32
|
+
* rendering the string must not disagree about where the sign goes. Hand-prefixing `-` here put
|
|
33
|
+
* it outside the symbol (`-€ 1.299,00`) where `nl-NL` puts it inside (`€ -1.299,00`), and
|
|
34
|
+
* `accounting` was applied on this path only.
|
|
35
|
+
*/
|
|
23
36
|
export function formatMoney(
|
|
24
37
|
amount: Money,
|
|
25
38
|
locale: string,
|
|
26
39
|
options: FormatMoneyOptions = {},
|
|
27
40
|
): string {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (amount.minor < 0) {
|
|
32
|
-
return options.accounting === true ? `(${rendered})` : `-${rendered}`;
|
|
33
|
-
}
|
|
34
|
-
return rendered;
|
|
41
|
+
return formatMoneyParts(amount, locale, options)
|
|
42
|
+
.map((part) => part.value)
|
|
43
|
+
.join('');
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/**
|
|
38
47
|
* Parts, for UI that styles the symbol or the decimals differently (a smaller superscript
|
|
39
48
|
* cent, a muted currency code). Never re-split a formatted string with a regex.
|
|
49
|
+
*
|
|
50
|
+
* The signed value goes to `Intl`, so sign placement and the accounting notation are the
|
|
51
|
+
* locale's — the one place either is decided.
|
|
40
52
|
*/
|
|
41
53
|
export function formatMoneyParts(
|
|
42
54
|
amount: Money,
|
|
43
55
|
locale: string,
|
|
44
56
|
options: FormatMoneyOptions = {},
|
|
45
57
|
): Intl.NumberFormatPart[] {
|
|
46
|
-
return formatterFor(amount.currency, locale, options).formatToParts(
|
|
58
|
+
return formatterFor(amount.currency, locale, options, moneyScale(amount)).formatToParts(
|
|
59
|
+
toDecimalNumber(amount),
|
|
60
|
+
);
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
/** The symbol alone, e.g. for an input prefix: `€`, `¥`, `KD`. */
|
|
50
64
|
export function currencySymbol(currency: string, locale: string): string {
|
|
51
|
-
const parts = formatterFor(
|
|
65
|
+
const parts = formatterFor(
|
|
66
|
+
currency,
|
|
67
|
+
locale,
|
|
68
|
+
{ display: 'narrowSymbol' },
|
|
69
|
+
exponentOf(currency),
|
|
70
|
+
).formatToParts(0);
|
|
52
71
|
return parts.find((part) => part.type === 'currency')?.value ?? currency;
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
/** Digits only, no symbol — for editable inputs and CSV exports. */
|
|
56
75
|
export function formatMoneyDecimal(amount: Money, locale: string): string {
|
|
57
|
-
const digits =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
const digits = moneyScale(amount);
|
|
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));
|
|
64
91
|
}
|
|
65
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
|
+
|
|
66
99
|
const cache = new Map<string, Intl.NumberFormat>();
|
|
100
|
+
const decimalCache = new Map<string, Intl.NumberFormat>();
|
|
67
101
|
|
|
102
|
+
/**
|
|
103
|
+
* `scale` is the amount's own, not the currency's: rendering $0.000002 with two digits shows
|
|
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.
|
|
111
|
+
*/
|
|
68
112
|
function formatterFor(
|
|
69
113
|
currency: string,
|
|
70
114
|
locale: string,
|
|
71
115
|
options: FormatMoneyOptions,
|
|
116
|
+
exponent: number,
|
|
72
117
|
): Intl.NumberFormat {
|
|
73
|
-
const exponent = exponentOf(currency);
|
|
74
118
|
const digits =
|
|
75
119
|
options.fractionDigits ?? (options.trimZeroFraction === true ? undefined : exponent);
|
|
120
|
+
const sign = options.accounting === true ? 'accounting' : 'standard';
|
|
121
|
+
const tag = canonicalTag(locale);
|
|
122
|
+
// `exponent` is in the key because it stopped being derivable from `currency` the moment it
|
|
123
|
+
// started coming from the amount's own scale. On the `trimZeroFraction` path `digits` is
|
|
124
|
+
// `undefined`, so without it every scale of one currency shared a formatter: format 12.99 EUR
|
|
125
|
+
// first and 12.990001 EUR then rendered as `12,99 €` — the sub-cent bug back, silently, in the
|
|
126
|
+
// one place a human reads the number.
|
|
76
127
|
const key = [
|
|
77
|
-
|
|
128
|
+
tag,
|
|
78
129
|
currency,
|
|
79
130
|
options.display ?? 'symbol',
|
|
80
131
|
digits ?? 'auto',
|
|
132
|
+
exponent,
|
|
81
133
|
options.grouping ?? 'auto',
|
|
134
|
+
sign,
|
|
82
135
|
].join('|');
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
);
|
|
97
151
|
}
|
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';
|