@ultimat3/money 30.9.0 → 31.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 +8 -1
- package/README.md +9 -2
- package/package.json +3 -3
- package/src/allocate.ts +5 -0
- package/src/convert.ts +22 -4
- package/src/errors.ts +34 -4
- package/src/index.ts +3 -14
- package/src/money.ts +3 -0
- package/src/parse-input.ts +5 -2
package/CLAUDE.md
CHANGED
|
@@ -40,10 +40,17 @@ the wide shape and answer with the row type.
|
|
|
40
40
|
| `rounding.ts` | explicit modes, no implicit default, over a float (`roundToInteger`) or a ratio (`roundRatio`, and `roundToDigits` through it) |
|
|
41
41
|
| `format.ts` | `Intl.NumberFormat` only, digits from the exponent |
|
|
42
42
|
| `convert.ts` | explicit rate + `RateProvider`, records provenance |
|
|
43
|
+
| `index.ts` | the app API only (v31): no code list, no internal assert, and no error factory except five — `currencyUnknown`, `decimalNotNumeric`, `decimalTooPrecise`, `moneyNotInteger`, `rateUnusable` — the refusals an app's own `RateProvider` or amount parser raises, so it throws money's cause rather than a copy (README, Conversion). Match an error on `MoneyError` + its `code` |
|
|
43
44
|
|
|
44
45
|
## Rules
|
|
45
46
|
|
|
46
47
|
- Never a float in a stored or returned amount. `fromDecimal` takes a **string**.
|
|
48
|
+
Anything else — a JSON body's `12.99`, `null`, `undefined` — is `X_MONEY_NOT_DECIMAL`, as is a
|
|
49
|
+
string that is not a plain decimal (`'1,299.00'`, `'1e3'`); `parseAmount` refuses a non-string as
|
|
50
|
+
`X_MONEY_INPUT_INVALID`. Never a bare `TypeError` from `.trim()`, which a route answered as a 500.
|
|
51
|
+
The refused input reaches the `cause:` through `renderCauseValue` only: bounded, one line.
|
|
52
|
+
`X_MONEY_NOT_INTEGER` stays for what it names — a fractional minor, excess precision, an inexact
|
|
53
|
+
narrowing — and stopped answering parse failures in 31.0.
|
|
47
54
|
- Never `/ 100`, and never `exponentOf(amount.currency)` for a value's own precision — that is
|
|
48
55
|
`moneyScale(amount)`, which falls back to the currency and is right for both. `exponentOf` and
|
|
49
56
|
`scaleOf` still answer for a *currency*, which is a different question.
|
|
@@ -168,6 +175,6 @@ the wide shape and answer with the row type.
|
|
|
168
175
|
## Commands
|
|
169
176
|
|
|
170
177
|
```
|
|
171
|
-
bun test packages/money
|
|
178
|
+
bun test ./packages/money/
|
|
172
179
|
bun run --filter @ultimat3/money typecheck
|
|
173
180
|
```
|
package/README.md
CHANGED
|
@@ -136,6 +136,12 @@ already carries its own precision. `convertWith` on a same-currency pair stamps
|
|
|
136
136
|
injected `Clock` (`{ clock }`, default `systemClock`) or from an explicit `{ at }`, never from
|
|
137
137
|
the epoch: `ExchangeRate.at` is the audit trail.
|
|
138
138
|
|
|
139
|
+
| Export | Does |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `convert(amount, to, rate, { rounding? })` | exact conversion with the rate given; a rate for another pair is `X_RATE_MISSING` |
|
|
142
|
+
| `convertWith(provider, amount, to, { at?, clock?, rounding? })` | asks a `RateProvider`, then `convert`s; a pair it does not answer is `X_RATE_MISSING`, never parity |
|
|
143
|
+
| `rateUnusable(from, to, field)` | the `X_RATE_MISSING` a rate earns when its `rate`, `at` or `ratio` cannot be used — the cause names the field |
|
|
144
|
+
|
|
139
145
|
## Errors
|
|
140
146
|
|
|
141
147
|
| Code | When |
|
|
@@ -145,7 +151,8 @@ the epoch: `ExchangeRate.at` is the audit trail.
|
|
|
145
151
|
| `X_CURRENCY_UNKNOWN` | a code neither shipped nor registered by this process — `currencyCodes()` is the list |
|
|
146
152
|
| `X_CURRENCY_MISMATCH` | arithmetic across two currencies |
|
|
147
153
|
| `X_ALLOCATION_INVALID` | bad part count, empty/negative/all-zero ratios, percentages ≠ 100 |
|
|
148
|
-
| `
|
|
154
|
+
| `X_MONEY_NOT_DECIMAL` | `fromDecimal` handed a non-string or a string that is not a plain decimal |
|
|
155
|
+
| `X_RATE_MISSING` | no rate for the pair — never assumes parity — or one whose `rate`, `at` or `ratio` is unusable (`rateUnusable`) |
|
|
149
156
|
|
|
150
157
|
### Error classes
|
|
151
158
|
|
|
@@ -154,7 +161,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
154
161
|
|
|
155
162
|
| Class | Code | Declared in |
|
|
156
163
|
|---|---|---|
|
|
157
|
-
| `MoneyError` | any `MoneyErrorCode` —
|
|
164
|
+
| `MoneyError` | any `MoneyErrorCode` — the codes in the table above | `src/errors.ts` |
|
|
158
165
|
|
|
159
166
|
## Why it exists
|
|
160
167
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"test": "bun test"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@ultimat3/core": "
|
|
48
|
-
"@ultimat3/schema": "
|
|
47
|
+
"@ultimat3/core": "31.0.0",
|
|
48
|
+
"@ultimat3/schema": "31.0.0"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/allocate.ts
CHANGED
|
@@ -40,6 +40,10 @@ export function allocate(amount: Money, parts: number): Money[] {
|
|
|
40
40
|
* `[1, 1, 1]` over 100 → 34, 33, 33. Weights need not sum to anything in particular.
|
|
41
41
|
*/
|
|
42
42
|
export function allocateByRatios(amount: Money, ratios: readonly number[]): Money[] {
|
|
43
|
+
// `Money` is a type, not a check: a hand-built `{ minor: 12.5 }` reached `BigInt()` below and left
|
|
44
|
+
// as a bare `RangeError`. The constructor is the one place a value is judged.
|
|
45
|
+
fromMinor(amount.minor, amount.currency, amount.scale);
|
|
46
|
+
if (!Array.isArray(ratios)) throw allocationInvalid('ratios must be a list of numbers');
|
|
43
47
|
if (ratios.length === 0) throw allocationInvalid('ratios must not be empty');
|
|
44
48
|
const weights = weigh(ratios);
|
|
45
49
|
let total = 0n;
|
|
@@ -104,6 +108,7 @@ function weigh(ratios: readonly number[]): bigint[] {
|
|
|
104
108
|
* where a mis-typed percentage should be an error, not a silent re-normalization.
|
|
105
109
|
*/
|
|
106
110
|
export function allocateByPercentages(amount: Money, percentages: readonly number[]): Money[] {
|
|
111
|
+
if (!Array.isArray(percentages)) throw allocationInvalid('percentages must be a list of numbers');
|
|
107
112
|
const total = percentages.reduce((sum, percentage) => sum + percentage, 0);
|
|
108
113
|
if (Math.abs(total - 100) > 1e-9) {
|
|
109
114
|
throw allocationInvalid(`percentages must sum to 100, got ${String(total)}`);
|
package/src/convert.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { type Clock, systemClock } from '@ultimat3/core';
|
|
8
8
|
import { assertCurrency, exponentOf } from './currency';
|
|
9
|
-
import { rateMissing } from './errors';
|
|
9
|
+
import { rateMissing, rateUnusable } from './errors';
|
|
10
10
|
import { type Fraction, factorFraction } from './factor';
|
|
11
11
|
import { fromMinor, type Money } from './money';
|
|
12
12
|
import { DEFAULT_ROUNDING, type RoundingMode, roundRatio } from './rounding';
|
|
@@ -58,14 +58,21 @@ export function convert(
|
|
|
58
58
|
options: ConvertOptions = {},
|
|
59
59
|
): ConvertedMoney {
|
|
60
60
|
const target = assertCurrency(to);
|
|
61
|
+
// `Money` is a type, not a check: a hand-built `{ minor: 1.5 }` reached `BigInt()` below and left
|
|
62
|
+
// as a bare `RangeError`. The constructor is the one place a value is judged.
|
|
63
|
+
fromMinor(amount.minor, amount.currency, amount.scale);
|
|
61
64
|
if (rate.from !== amount.currency || rate.to !== target) {
|
|
62
65
|
throw rateMissing(amount.currency, target);
|
|
63
66
|
}
|
|
64
67
|
if (!Number.isFinite(rate.rate) || rate.rate <= 0) {
|
|
65
|
-
throw
|
|
68
|
+
throw rateUnusable(amount.currency, target, 'rate');
|
|
66
69
|
}
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
// `at` is the audit trail: an Invalid Date threw a bare `RangeError` out of `toISOString`.
|
|
71
|
+
if (!(rate.at instanceof Date) || Number.isNaN(rate.at.getTime())) {
|
|
72
|
+
throw rateUnusable(amount.currency, target, 'at');
|
|
73
|
+
}
|
|
74
|
+
if (rate.ratio !== undefined && !isPositiveFraction(rate.ratio)) {
|
|
75
|
+
throw rateUnusable(amount.currency, target, 'ratio');
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
// Exact, not a float product: `minor * rate * scale` shows the rounding mode a value IEEE-754
|
|
@@ -93,6 +100,17 @@ export function convert(
|
|
|
93
100
|
};
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
/** Both terms positive BIGINTS — a provider's `{ numerator: 23 }` mixed a number into bigint maths. */
|
|
104
|
+
function isPositiveFraction(ratio: Fraction): boolean {
|
|
105
|
+
const { numerator, denominator } = ratio as { numerator: unknown; denominator: unknown };
|
|
106
|
+
return (
|
|
107
|
+
typeof numerator === 'bigint' &&
|
|
108
|
+
typeof denominator === 'bigint' &&
|
|
109
|
+
numerator > 0n &&
|
|
110
|
+
denominator > 0n
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
/** Rate lookup. Implemented by the app — the framework ships no live provider. */
|
|
97
115
|
export interface RateProvider {
|
|
98
116
|
readonly name: string;
|
package/src/errors.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
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 {
|
|
6
|
+
import {
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderFixLiteral,
|
|
10
|
+
UltimateError,
|
|
11
|
+
} from '@ultimat3/core';
|
|
7
12
|
import { isCurrencyCode, MAX_MONEY_SCALE } from '@ultimat3/schema';
|
|
8
13
|
|
|
9
14
|
export const MONEY_ERROR_CODES = [
|
|
@@ -16,6 +21,7 @@ export const MONEY_ERROR_CODES = [
|
|
|
16
21
|
'X_RATE_MISSING',
|
|
17
22
|
'X_MONEY_SCALE_INVALID',
|
|
18
23
|
'X_MONEY_INPUT_INVALID',
|
|
24
|
+
'X_MONEY_NOT_DECIMAL',
|
|
19
25
|
] as const;
|
|
20
26
|
|
|
21
27
|
export type MoneyErrorCode = (typeof MONEY_ERROR_CODES)[number];
|
|
@@ -30,6 +36,7 @@ export const MONEY_ERROR_TITLES: Readonly<Record<MoneyErrorCode, string>> = {
|
|
|
30
36
|
X_RATE_MISSING: 'no FX rate for the pair',
|
|
31
37
|
X_MONEY_SCALE_INVALID: 'a Money.scale that is not a usable decimal exponent',
|
|
32
38
|
X_MONEY_INPUT_INVALID: 'a typed amount could not be read in this locale',
|
|
39
|
+
X_MONEY_NOT_DECIMAL: 'a fromDecimal input that is not a plain decimal string',
|
|
33
40
|
};
|
|
34
41
|
|
|
35
42
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -242,10 +249,20 @@ export function rescaleNotExact(
|
|
|
242
249
|
});
|
|
243
250
|
}
|
|
244
251
|
|
|
245
|
-
|
|
252
|
+
/**
|
|
253
|
+
* A `fromDecimal` input that is not a plain decimal string: a parse failure, so its own code — the
|
|
254
|
+
* value never reached the point where "a fractional minor" could describe it. `unknown` because a
|
|
255
|
+
* JSON body hands over a number as readily as a string, and the input is rendered bounded and on
|
|
256
|
+
* one line: it is caller text of any length.
|
|
257
|
+
*/
|
|
258
|
+
export function decimalNotNumeric(value: unknown, currency: string): MoneyError {
|
|
259
|
+
const shown = renderCauseValue(value);
|
|
246
260
|
return new MoneyError({
|
|
247
|
-
code: '
|
|
248
|
-
cause:
|
|
261
|
+
code: 'X_MONEY_NOT_DECIMAL',
|
|
262
|
+
cause:
|
|
263
|
+
typeof value === 'string'
|
|
264
|
+
? `${shown} is not a decimal amount — no grouping separators, no exponent notation`
|
|
265
|
+
: `${shown} is not a string — fromDecimal reads a decimal string, never a ${value === null ? 'null' : typeof value}`,
|
|
249
266
|
fix: `pass a plain decimal string: fromDecimal('12.99', ${codeLiteral(currency)})`,
|
|
250
267
|
});
|
|
251
268
|
}
|
|
@@ -321,6 +338,19 @@ export function allocationInvalid(cause: string): MoneyError {
|
|
|
321
338
|
});
|
|
322
339
|
}
|
|
323
340
|
|
|
341
|
+
/**
|
|
342
|
+
* A rate for the pair that cannot be used: a non-positive or non-finite `rate`, an `at` that is not
|
|
343
|
+
* a valid `Date`, a `ratio` whose terms are not positive bigints. No usable rate is a missing rate —
|
|
344
|
+
* the same code — but the cause names the field, so the provider's bug is findable.
|
|
345
|
+
*/
|
|
346
|
+
export function rateUnusable(from: string, to: string, field: string): MoneyError {
|
|
347
|
+
return new MoneyError({
|
|
348
|
+
code: 'X_RATE_MISSING',
|
|
349
|
+
cause: `the ${from}→${to} rate cannot be used: its \`${field}\` is not valid`,
|
|
350
|
+
fix: `convert(amount, ${codeLiteral(to)}, { from: ${codeLiteral(from)}, to: ${codeLiteral(to)}, rate: 0.92, at: new Date('2026-03-14T00:00:00Z') }) # a finite positive rate, a valid Date, and a ratio of positive bigints if any`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
324
354
|
export function rateMissing(from: string, to: string): MoneyError {
|
|
325
355
|
return new MoneyError({
|
|
326
356
|
code: 'X_RATE_MISSING',
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@ export {
|
|
|
4
4
|
allocate,
|
|
5
5
|
allocateByPercentages,
|
|
6
6
|
allocateByRatios,
|
|
7
|
-
assertAllocationSums,
|
|
8
7
|
MAX_ALLOCATION_PARTS,
|
|
9
8
|
} from './allocate';
|
|
10
9
|
export {
|
|
@@ -29,6 +28,7 @@ export {
|
|
|
29
28
|
type ConvertedMoney,
|
|
30
29
|
type ConvertOptions,
|
|
31
30
|
convert,
|
|
31
|
+
convertWith,
|
|
32
32
|
type ExchangeRate,
|
|
33
33
|
fixedRateProvider,
|
|
34
34
|
type RateProvider,
|
|
@@ -46,28 +46,17 @@ export {
|
|
|
46
46
|
scaleOf,
|
|
47
47
|
} from './currency';
|
|
48
48
|
export {
|
|
49
|
-
allocationInvalid,
|
|
50
|
-
currencyDeclarationInvalid,
|
|
51
|
-
currencyMismatch,
|
|
52
|
-
currencyRedefined,
|
|
53
|
-
currencyRequired,
|
|
54
49
|
currencyUnknown,
|
|
55
50
|
decimalNotNumeric,
|
|
56
51
|
decimalTooPrecise,
|
|
57
|
-
MONEY_ERROR_CODES,
|
|
58
|
-
MONEY_ERROR_TITLES,
|
|
59
52
|
MoneyError,
|
|
60
53
|
type MoneyErrorCode,
|
|
61
54
|
moneyNotInteger,
|
|
62
|
-
|
|
63
|
-
rescaleNotExact,
|
|
64
|
-
scaleInvalid,
|
|
65
|
-
scaleNotWidening,
|
|
55
|
+
rateUnusable,
|
|
66
56
|
} from './errors';
|
|
67
57
|
/** `ExchangeRate.ratio` is one of these; a provider with an exact rate writes the pair itself. */
|
|
68
58
|
export type { Fraction } from './factor';
|
|
69
59
|
export {
|
|
70
|
-
currencySymbol,
|
|
71
60
|
type FormatMoneyOptions,
|
|
72
61
|
formatMoney,
|
|
73
62
|
formatMoneyDecimal,
|
|
@@ -96,4 +85,4 @@ export {
|
|
|
96
85
|
roundToDigits,
|
|
97
86
|
roundToInteger,
|
|
98
87
|
} from './rounding';
|
|
99
|
-
export {
|
|
88
|
+
export { commonScale, minorAt, moneyScale } from './scale';
|
package/src/money.ts
CHANGED
|
@@ -69,6 +69,9 @@ export function fromDecimal(
|
|
|
69
69
|
options: FromDecimalOptions = {},
|
|
70
70
|
): Money {
|
|
71
71
|
const code = assertCurrency(currency);
|
|
72
|
+
// The signature says `string`; a JSON body does not read it. `12.99` arriving as a number was a
|
|
73
|
+
// bare TypeError from `.trim()` — a 500 for a caller mistake.
|
|
74
|
+
if (typeof value !== 'string') throw decimalNotNumeric(value, code);
|
|
72
75
|
const match = DECIMAL.exec(value.trim());
|
|
73
76
|
const integerPart = match?.[2];
|
|
74
77
|
if (match === null || integerPart === undefined) {
|
package/src/parse-input.ts
CHANGED
|
@@ -59,12 +59,12 @@ function numeralsOf(tag: string): LocaleNumerals {
|
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
const refused = (text:
|
|
62
|
+
const refused = (text: unknown, locale: string, currency: string, reason: string): MoneyError =>
|
|
63
63
|
new MoneyError({
|
|
64
64
|
code: 'X_MONEY_INPUT_INVALID',
|
|
65
65
|
cause: `the amount typed could not be read as ${currency} in ${locale}: ${reason}`,
|
|
66
66
|
fix: `parseAmount(text, { currency: '${currency}', locale: '${locale}' }) # re-prompt the person for the amount; nothing was rounded or guessed`,
|
|
67
|
-
meta: { locale, currency, length: text.length },
|
|
67
|
+
meta: { locale, currency, length: typeof text === 'string' ? text.length : 0 },
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
/**
|
|
@@ -78,6 +78,9 @@ export function parseAmount(text: string, options: ParseAmountOptions): Money {
|
|
|
78
78
|
const tag = assertLocale(options.locale);
|
|
79
79
|
const code = assertCurrency(options.currency);
|
|
80
80
|
const fail = (reason: string): MoneyError => refused(text, tag, code, reason);
|
|
81
|
+
// The signature says `string`; a JSON body does not read it, and `.trim()` on a number was a
|
|
82
|
+
// bare TypeError — a 500 for an amount the person can simply be asked for again.
|
|
83
|
+
if (typeof text !== 'string') throw fail(`${renderCauseValue(text)} is not text`);
|
|
81
84
|
const numerals = numeralsOf(tag);
|
|
82
85
|
const symbols = formatterOf(tag, code)
|
|
83
86
|
.formatToParts(1)
|