@ultimat3/money 7.0.0 → 9.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 +14 -0
- package/package.json +3 -3
- package/src/currency.ts +70 -58
- package/src/errors.ts +38 -1
package/CLAUDE.md
CHANGED
|
@@ -120,6 +120,20 @@ shape is still additive and this is still a minor version.
|
|
|
120
120
|
or an empty name is `X_CURRENCY_INVALID`; a second declaration of one code is
|
|
121
121
|
`X_CURRENCY_REDEFINED`, and an **identical** one is a no-op so a twice-imported module is not a
|
|
122
122
|
crash. A shipped ISO row cannot be redefined at all.
|
|
123
|
+
- **A shipped row is frozen, and `CurrencyInfo`'s three fields are `readonly`.** `currencyInfo()`
|
|
124
|
+
hands the row itself out by reference, and `exponent` decides what every stored `minor` in that
|
|
125
|
+
currency counts — one `currencyInfo('USD').exponent = 3` silently rescales every USD amount in
|
|
126
|
+
the process by a power of ten, through the one door `registerCurrency` already refuses
|
|
127
|
+
(`X_CURRENCY_REDEFINED`). The compiler is the first guard, `Object.freeze` on the array AND on
|
|
128
|
+
every row the second, for the caller that has no types. Both halves: a frozen array of writable
|
|
129
|
+
rows guards the list and leaves every value in it open.
|
|
130
|
+
- **A `fix:` naming `fromDecimal` must name a call that RUNS.** `moneyNotInteger` emitted
|
|
131
|
+
`fromDecimal('<minor>', '<ccy>')` for every arrival: it threw this same code straight back for
|
|
132
|
+
anything past the currency's own digits, read `fromDecimal('1e+21', …)` for a magnitude
|
|
133
|
+
`DECIMAL` refuses, and read `fromDecimal('0', …)` for `NaN`, which runs and invents an amount.
|
|
134
|
+
Three arrivals, three instructions — a rounding call for a fractional minor, `{ scale: d }` or
|
|
135
|
+
`{ rounding: 'half-up' }` when the value is really a major-unit amount, and NO call at all where
|
|
136
|
+
none could work. `errors.test.ts` executes every call a `X_MONEY_NOT_INTEGER` fix line names.
|
|
123
137
|
- **Two enumerations, two questions.** `CURRENCIES` is the constant this package ships;
|
|
124
138
|
`currencyCodes()` is what this process accepts, registrations included, and it is the list
|
|
125
139
|
`X_CURRENCY_UNKNOWN`'s fix line names — so it must include them or that fix is the dead end it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
37
|
+
"@ultimat3/core": "9.0.0",
|
|
38
|
+
"@ultimat3/schema": "9.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/currency.ts
CHANGED
|
@@ -11,68 +11,80 @@ import { currencyDeclarationInvalid, currencyRedefined, currencyUnknown } from '
|
|
|
11
11
|
/** Uppercase ISO-4217 alphabetic code. */
|
|
12
12
|
export type CurrencyCode = string;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* `readonly` on all three, because `currencyInfo()` hands the SHIPPED row out by reference and
|
|
16
|
+
* `exponent` decides what every stored `minor` in that currency counts: one
|
|
17
|
+
* `currencyInfo('USD').exponent = 3` silently rescales every USD amount in the process by a power
|
|
18
|
+
* of ten. The compiler is the first of the two guards; `Object.freeze` on every row below is the
|
|
19
|
+
* second, for the caller that has no types.
|
|
20
|
+
*/
|
|
14
21
|
export interface CurrencyInfo {
|
|
15
|
-
code: CurrencyCode;
|
|
22
|
+
readonly code: CurrencyCode;
|
|
16
23
|
/** Number of decimal digits in the minor unit: USD 2, JPY 0, KWD 3. */
|
|
17
|
-
exponent: number;
|
|
18
|
-
name: string;
|
|
24
|
+
readonly exponent: number;
|
|
25
|
+
readonly name: string;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
const TABLE: readonly CurrencyInfo[] =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
28
|
+
const TABLE: readonly CurrencyInfo[] = Object.freeze(
|
|
29
|
+
[
|
|
30
|
+
{ code: 'AED', exponent: 2, name: 'UAE Dirham' },
|
|
31
|
+
{ code: 'ARS', exponent: 2, name: 'Argentine Peso' },
|
|
32
|
+
{ code: 'AUD', exponent: 2, name: 'Australian Dollar' },
|
|
33
|
+
{ code: 'BGN', exponent: 2, name: 'Bulgarian Lev' },
|
|
34
|
+
{ code: 'BHD', exponent: 3, name: 'Bahraini Dinar' },
|
|
35
|
+
{ code: 'BRL', exponent: 2, name: 'Brazilian Real' },
|
|
36
|
+
{ code: 'CAD', exponent: 2, name: 'Canadian Dollar' },
|
|
37
|
+
{ code: 'CHF', exponent: 2, name: 'Swiss Franc' },
|
|
38
|
+
{ code: 'CLP', exponent: 0, name: 'Chilean Peso' },
|
|
39
|
+
{ code: 'CNY', exponent: 2, name: 'Yuan Renminbi' },
|
|
40
|
+
{ code: 'COP', exponent: 2, name: 'Colombian Peso' },
|
|
41
|
+
{ code: 'CZK', exponent: 2, name: 'Czech Koruna' },
|
|
42
|
+
{ code: 'DKK', exponent: 2, name: 'Danish Krone' },
|
|
43
|
+
{ code: 'EGP', exponent: 2, name: 'Egyptian Pound' },
|
|
44
|
+
{ code: 'EUR', exponent: 2, name: 'Euro' },
|
|
45
|
+
{ code: 'GBP', exponent: 2, name: 'Pound Sterling' },
|
|
46
|
+
{ code: 'HKD', exponent: 2, name: 'Hong Kong Dollar' },
|
|
47
|
+
{ code: 'HUF', exponent: 2, name: 'Forint' },
|
|
48
|
+
{ code: 'IDR', exponent: 2, name: 'Rupiah' },
|
|
49
|
+
{ code: 'ILS', exponent: 2, name: 'New Israeli Sheqel' },
|
|
50
|
+
{ code: 'INR', exponent: 2, name: 'Indian Rupee' },
|
|
51
|
+
{ code: 'ISK', exponent: 0, name: 'Iceland Krona' },
|
|
52
|
+
{ code: 'JOD', exponent: 3, name: 'Jordanian Dinar' },
|
|
53
|
+
{ code: 'JPY', exponent: 0, name: 'Yen' },
|
|
54
|
+
{ code: 'KES', exponent: 2, name: 'Kenyan Shilling' },
|
|
55
|
+
{ code: 'KRW', exponent: 0, name: 'Won' },
|
|
56
|
+
{ code: 'KWD', exponent: 3, name: 'Kuwaiti Dinar' },
|
|
57
|
+
{ code: 'MAD', exponent: 2, name: 'Moroccan Dirham' },
|
|
58
|
+
{ code: 'MXN', exponent: 2, name: 'Mexican Peso' },
|
|
59
|
+
{ code: 'MYR', exponent: 2, name: 'Malaysian Ringgit' },
|
|
60
|
+
{ code: 'NGN', exponent: 2, name: 'Naira' },
|
|
61
|
+
{ code: 'NOK', exponent: 2, name: 'Norwegian Krone' },
|
|
62
|
+
{ code: 'NZD', exponent: 2, name: 'New Zealand Dollar' },
|
|
63
|
+
{ code: 'OMR', exponent: 3, name: 'Rial Omani' },
|
|
64
|
+
{ code: 'PEN', exponent: 2, name: 'Sol' },
|
|
65
|
+
{ code: 'PHP', exponent: 2, name: 'Philippine Peso' },
|
|
66
|
+
{ code: 'PKR', exponent: 2, name: 'Pakistan Rupee' },
|
|
67
|
+
{ code: 'PLN', exponent: 2, name: 'Zloty' },
|
|
68
|
+
{ code: 'RON', exponent: 2, name: 'Romanian Leu' },
|
|
69
|
+
{ code: 'RSD', exponent: 2, name: 'Serbian Dinar' },
|
|
70
|
+
{ code: 'SAR', exponent: 2, name: 'Saudi Riyal' },
|
|
71
|
+
{ code: 'SEK', exponent: 2, name: 'Swedish Krona' },
|
|
72
|
+
{ code: 'SGD', exponent: 2, name: 'Singapore Dollar' },
|
|
73
|
+
{ code: 'THB', exponent: 2, name: 'Baht' },
|
|
74
|
+
{ code: 'TND', exponent: 3, name: 'Tunisian Dinar' },
|
|
75
|
+
{ code: 'TRY', exponent: 2, name: 'Turkish Lira' },
|
|
76
|
+
{ code: 'TWD', exponent: 2, name: 'New Taiwan Dollar' },
|
|
77
|
+
{ code: 'UAH', exponent: 2, name: 'Hryvnia' },
|
|
78
|
+
{ code: 'USD', exponent: 2, name: 'US Dollar' },
|
|
79
|
+
{ code: 'UYU', exponent: 2, name: 'Peso Uruguayo' },
|
|
80
|
+
{ code: 'VND', exponent: 0, name: 'Dong' },
|
|
81
|
+
{ code: 'XOF', exponent: 0, name: 'CFA Franc BCEAO' },
|
|
82
|
+
{ code: 'ZAR', exponent: 2, name: 'Rand' },
|
|
83
|
+
// Each ROW frozen too, not just the array: `CURRENCIES[0].exponent = 3` reaches the same
|
|
84
|
+
// object `currencyInfo` returns, and a frozen array holding writable rows guards the list
|
|
85
|
+
// while leaving every value in it open. `registerCurrency` already freezes what an app adds.
|
|
86
|
+
].map((row) => Object.freeze(row)),
|
|
87
|
+
);
|
|
76
88
|
|
|
77
89
|
const BY_CODE: ReadonlyMap<string, CurrencyInfo> = new Map(
|
|
78
90
|
TABLE.map((info) => [info.code, info] as const),
|
package/src/errors.ts
CHANGED
|
@@ -73,14 +73,51 @@ function codeLiteral(currency: string): string {
|
|
|
73
73
|
return `'${codeExample(currency)}'`;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** A number whose `String()` is source a decimal parser accepts — never `1e+21`, `1e-7` or `NaN`. */
|
|
77
|
+
const PLAIN_DECIMAL = /^-?\d+(?:\.\d+)?$/;
|
|
78
|
+
|
|
76
79
|
export function moneyNotInteger(minor: number, currency: string): MoneyError {
|
|
77
80
|
return new MoneyError({
|
|
78
81
|
code: 'X_MONEY_NOT_INTEGER',
|
|
79
82
|
cause: `minor units must be a safe integer, got ${String(minor)} for ${currency}`,
|
|
80
|
-
fix:
|
|
83
|
+
fix: notIntegerFix(minor, currency),
|
|
81
84
|
});
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Three arrivals reach `money()` with an unusable `minor`, and each needs its own instruction.
|
|
89
|
+
* One line answered all three — `fromDecimal('<minor>', '<ccy>')` — and it raised THIS code back
|
|
90
|
+
* at the reader for every value past the currency's own digits, which is the anti-pattern
|
|
91
|
+
* `currencyDeclarationInvalid` below already names; for `1e21` it read `fromDecimal('1e+21', …)`,
|
|
92
|
+
* a spelling `fromDecimal`'s own `DECIMAL` refuses outright; and for `NaN` it read
|
|
93
|
+
* `fromDecimal('0', …)`, which runs and invents an amount nobody wrote.
|
|
94
|
+
*/
|
|
95
|
+
function notIntegerFix(minor: number, currency: string): string {
|
|
96
|
+
if (!Number.isFinite(minor)) {
|
|
97
|
+
return 'no rounding recovers a non-finite amount — trace it back to the division or float multiply upstream, then build the value from a decimal string';
|
|
98
|
+
}
|
|
99
|
+
// `Math.round` is offered only when its RESULT is storable: past a safe integer it answers the
|
|
100
|
+
// same unusable magnitude, so the instruction would raise the error it is answering.
|
|
101
|
+
if (!Number.isSafeInteger(Math.round(minor))) {
|
|
102
|
+
return `${String(minor)} is more minor units than a safe integer holds — split the amount, or carry it as two ${codeExample(currency)} values`;
|
|
103
|
+
}
|
|
104
|
+
const code = codeLiteral(currency);
|
|
105
|
+
// Safe to interpolate un-escaped, and only here: `PLAIN_DECIMAL` has just proved the spelling is
|
|
106
|
+
// digits, one dot and one leading `-`, so it closes no literal `renderFixLiteral` would escape.
|
|
107
|
+
const spelling = String(minor);
|
|
108
|
+
const rounded = `money(Math.round(${spelling}), ${code})`;
|
|
109
|
+
if (!PLAIN_DECIMAL.test(spelling)) {
|
|
110
|
+
return `${rounded} — the value counts minor units, and that is the whole one nearest it`;
|
|
111
|
+
}
|
|
112
|
+
const digits = countFractionDigits(spelling);
|
|
113
|
+
// `{ scale: d }` on a value with exactly `d` fraction digits can never be too precise for
|
|
114
|
+
// itself, whatever the currency's exponent is — so this branch needs no exponent and loses no
|
|
115
|
+
// digit. Past MAX_MONEY_SCALE no scale holds them all and rounding is the only offer left, the
|
|
116
|
+
// same withdrawal `decimalTooPrecise` makes.
|
|
117
|
+
const asAmount = digits <= MAX_MONEY_SCALE ? `{ scale: ${digits} }` : "{ rounding: 'half-up' }";
|
|
118
|
+
return `${rounded} if ${spelling} counts minor units, or fromDecimal('${spelling}', ${code}, ${asAmount}) if it is a ${codeExample(currency)} amount — a fractional minor is usually an unrounded multiply, whose third argument names the mode`;
|
|
119
|
+
}
|
|
120
|
+
|
|
84
121
|
/**
|
|
85
122
|
* A non-finite input to rounding is upstream float arithmetic that already lost the amount —
|
|
86
123
|
* rounding it would invent a number, so it throws instead.
|