@ultimat3/money 29.4.0 → 30.1.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/README.md +2 -0
- package/package.json +12 -3
- package/src/errors.ts +9 -1
- package/src/index.ts +1 -0
- package/src/parse-input.ts +138 -0
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
`0.1 + 0.2 !== 0.3`, so no amount is ever a float. `Money` carries `minor` and `currency` together
|
|
5
5
|
— plus an optional `scale` — and arithmetic across two currencies throws instead of guessing.
|
|
6
6
|
|
|
7
|
+
`bun add @ultimat3/money` · guide: [Money](https://github.com/developerz-ai/ultimate/wiki/Money)
|
|
8
|
+
|
|
7
9
|
`Money` **is** `@ultimat3/schema`'s `MoneyValue`, and so is `@ultimat3/entity`'s: one declaration
|
|
8
10
|
at tier 0, aliased twice, never restated. A row a `money()` column decodes is therefore a `Money`
|
|
9
11
|
already — `add(row.price, shipping)` and `formatMoney(row.price, locale)` take it with no cast.
|
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/money",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.1.0",
|
|
4
4
|
"description": "Integer minor units with an attached currency: arithmetic, allocation, rounding, Intl formatting",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"money",
|
|
10
|
+
"currency",
|
|
11
|
+
"intl"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Money",
|
|
5
14
|
"license": "MIT",
|
|
6
15
|
"type": "module",
|
|
7
16
|
"sideEffects": [
|
|
@@ -35,7 +44,7 @@
|
|
|
35
44
|
"test": "bun test"
|
|
36
45
|
},
|
|
37
46
|
"dependencies": {
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/schema": "
|
|
47
|
+
"@ultimat3/core": "30.1.0",
|
|
48
|
+
"@ultimat3/schema": "30.1.0"
|
|
40
49
|
}
|
|
41
50
|
}
|
package/src/errors.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const MONEY_ERROR_CODES = [
|
|
|
15
15
|
'X_ALLOCATION_INVALID',
|
|
16
16
|
'X_RATE_MISSING',
|
|
17
17
|
'X_MONEY_SCALE_INVALID',
|
|
18
|
+
'X_MONEY_INPUT_INVALID',
|
|
18
19
|
] as const;
|
|
19
20
|
|
|
20
21
|
export type MoneyErrorCode = (typeof MONEY_ERROR_CODES)[number];
|
|
@@ -28,6 +29,7 @@ export const MONEY_ERROR_TITLES: Readonly<Record<MoneyErrorCode, string>> = {
|
|
|
28
29
|
X_ALLOCATION_INVALID: 'split ratios or part count are unusable',
|
|
29
30
|
X_RATE_MISSING: 'no FX rate for the pair',
|
|
30
31
|
X_MONEY_SCALE_INVALID: 'a Money.scale that is not a usable decimal exponent',
|
|
32
|
+
X_MONEY_INPUT_INVALID: 'a typed amount could not be read in this locale',
|
|
31
33
|
};
|
|
32
34
|
|
|
33
35
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -38,11 +40,17 @@ registerErrorCodes(
|
|
|
38
40
|
);
|
|
39
41
|
|
|
40
42
|
export class MoneyError extends UltimateError {
|
|
41
|
-
constructor(init: {
|
|
43
|
+
constructor(init: {
|
|
44
|
+
code: MoneyErrorCode;
|
|
45
|
+
cause: string;
|
|
46
|
+
fix: string;
|
|
47
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
48
|
+
}) {
|
|
42
49
|
super({
|
|
43
50
|
code: init.code,
|
|
44
51
|
cause: init.cause,
|
|
45
52
|
fix: init.fix,
|
|
53
|
+
...(init.meta === undefined ? {} : { meta: init.meta }),
|
|
46
54
|
});
|
|
47
55
|
}
|
|
48
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Single responsibility: an amount a PERSON typed, read in their locale — `1.234,56` in `de-DE`,
|
|
2
|
+
// `1,234.56` in `en-US`, Arabic-Indic digits in `ar-EG` — into `Money`, or refused. The separators
|
|
3
|
+
// and digits are `Intl`'s, never a table here; an ambiguous or over-precise reading is refused,
|
|
4
|
+
// never rounded, and no float is ever made: the digits go on to `fromDecimal` as a string.
|
|
5
|
+
|
|
6
|
+
import { assertLocale, cachedFormatter, renderCauseValue } from '@ultimat3/core';
|
|
7
|
+
import { assertCurrency, exponentOf } from './currency';
|
|
8
|
+
import { MoneyError } from './errors';
|
|
9
|
+
import type { Money } from './money';
|
|
10
|
+
import { fromDecimal } from './money';
|
|
11
|
+
|
|
12
|
+
export interface ParseAmountOptions {
|
|
13
|
+
readonly currency: string;
|
|
14
|
+
/** The person's locale: whose separators and digits the text is read with. */
|
|
15
|
+
readonly locale: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface LocaleNumerals {
|
|
19
|
+
readonly decimal: string;
|
|
20
|
+
readonly group: string;
|
|
21
|
+
readonly minus: string;
|
|
22
|
+
/** Each of the locale's ten digits → its ASCII digit. */
|
|
23
|
+
readonly digits: ReadonlyMap<string, string>;
|
|
24
|
+
/** The locale's own grouping of an integer, in ASCII digits: what a typed group must match. */
|
|
25
|
+
readonly grouped: (integer: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const formatters = new Map<string, Intl.NumberFormat>();
|
|
29
|
+
/** Every formatter through core's bounded cache, keyed on the screened tag: `format.ts`'s rule. */
|
|
30
|
+
const formatterOf = (tag: string, kind: string): Intl.NumberFormat =>
|
|
31
|
+
cachedFormatter(formatters, `${tag}|${kind}`, () =>
|
|
32
|
+
kind === 'grouped' || kind === 'plain'
|
|
33
|
+
? new Intl.NumberFormat(tag, { useGrouping: kind === 'grouped', maximumFractionDigits: 0 })
|
|
34
|
+
: kind === 'parts'
|
|
35
|
+
? new Intl.NumberFormat(tag)
|
|
36
|
+
: new Intl.NumberFormat(tag, { style: 'currency', currency: kind }),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** The spaces a group separator may be typed as: a keyboard has no narrow no-break space. */
|
|
40
|
+
const SPACES = new Set([' ', ' ', ' ', ' ']);
|
|
41
|
+
|
|
42
|
+
function numeralsOf(tag: string): LocaleNumerals {
|
|
43
|
+
const parts = formatterOf(tag, 'parts').formatToParts(-1234567.5);
|
|
44
|
+
const part = (type: string, fallback: string): string =>
|
|
45
|
+
parts.find((each) => each.type === type)?.value ?? fallback;
|
|
46
|
+
const plain = formatterOf(tag, 'plain');
|
|
47
|
+
const digits = new Map<string, string>();
|
|
48
|
+
for (let digit = 0; digit <= 9; digit += 1) digits.set(plain.format(digit), String(digit));
|
|
49
|
+
const toAscii = (text: string): string =>
|
|
50
|
+
[...text].map((char) => digits.get(char) ?? char).join('');
|
|
51
|
+
const grouping = formatterOf(tag, 'grouped');
|
|
52
|
+
return {
|
|
53
|
+
decimal: part('decimal', '.'),
|
|
54
|
+
group: part('group', ','),
|
|
55
|
+
minus: part('minusSign', '-'),
|
|
56
|
+
digits,
|
|
57
|
+
// `BigInt` formats an integer of any length exactly: no float between the digits and `Intl`.
|
|
58
|
+
grouped: (integer) => toAscii(grouping.format(BigInt(integer))),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const refused = (text: string, locale: string, currency: string, reason: string): MoneyError =>
|
|
63
|
+
new MoneyError({
|
|
64
|
+
code: 'X_MONEY_INPUT_INVALID',
|
|
65
|
+
cause: `the amount typed could not be read as ${currency} in ${locale}: ${reason}`,
|
|
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 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `parseAmount('1.234,56', { currency: 'EUR', locale: 'de-DE' })` → 123456 EUR. A currency symbol
|
|
72
|
+
* or code the locale writes is allowed and ignored; a sign is the locale's minus or `-`. Refused
|
|
73
|
+
* (`X_MONEY_INPUT_INVALID`): a character the locale does not use in a number, a group separator out
|
|
74
|
+
* of the locale's own grouping (`1.5` in `de-DE` is not 1.5 and not 15), a second decimal mark, and
|
|
75
|
+
* more fraction digits than the currency has — `1,234` in `de-DE` is 1.234, and EUR has two.
|
|
76
|
+
*/
|
|
77
|
+
export function parseAmount(text: string, options: ParseAmountOptions): Money {
|
|
78
|
+
const tag = assertLocale(options.locale);
|
|
79
|
+
const code = assertCurrency(options.currency);
|
|
80
|
+
const fail = (reason: string): MoneyError => refused(text, tag, code, reason);
|
|
81
|
+
const numerals = numeralsOf(tag);
|
|
82
|
+
const symbols = formatterOf(tag, code)
|
|
83
|
+
.formatToParts(1)
|
|
84
|
+
.filter((each) => each.type === 'currency')
|
|
85
|
+
.map((each) => each.value);
|
|
86
|
+
|
|
87
|
+
let rest = text.trim();
|
|
88
|
+
for (const symbol of [...symbols, code]) rest = rest.replace(symbol, '').trim();
|
|
89
|
+
// Bidi marks travel with a copied right-to-left amount; they are not characters of the number.
|
|
90
|
+
rest = rest.replace(/[]/g, '');
|
|
91
|
+
let negative = false;
|
|
92
|
+
if (rest.startsWith(numerals.minus) || rest.startsWith('-')) {
|
|
93
|
+
negative = true;
|
|
94
|
+
rest = rest.slice(rest.startsWith(numerals.minus) ? numerals.minus.length : 1).trim();
|
|
95
|
+
}
|
|
96
|
+
if (rest === '') throw fail('there are no digits');
|
|
97
|
+
|
|
98
|
+
const isGroup = (char: string): boolean =>
|
|
99
|
+
char === numerals.group || (SPACES.has(numerals.group) && SPACES.has(char));
|
|
100
|
+
let integer = '';
|
|
101
|
+
let typedInteger = '';
|
|
102
|
+
let fraction: string | undefined;
|
|
103
|
+
for (const char of rest) {
|
|
104
|
+
const digit = numerals.digits.get(char) ?? (/^[0-9]$/.test(char) ? char : undefined);
|
|
105
|
+
if (digit !== undefined) {
|
|
106
|
+
if (fraction === undefined) {
|
|
107
|
+
integer += digit;
|
|
108
|
+
typedInteger += digit;
|
|
109
|
+
} else fraction += digit;
|
|
110
|
+
} else if (char === numerals.decimal) {
|
|
111
|
+
if (fraction !== undefined) throw fail(`${renderCauseValue(char)} appears twice`);
|
|
112
|
+
fraction = '';
|
|
113
|
+
} else if (isGroup(char)) {
|
|
114
|
+
if (fraction !== undefined) {
|
|
115
|
+
throw fail(`${renderCauseValue(char)} groups digits after the decimal mark`);
|
|
116
|
+
}
|
|
117
|
+
typedInteger += numerals.group;
|
|
118
|
+
} else {
|
|
119
|
+
throw fail(`${renderCauseValue(char)} is not part of a number in ${tag}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (integer === '' && (fraction === undefined || fraction === '')) {
|
|
123
|
+
throw fail('there are no digits');
|
|
124
|
+
}
|
|
125
|
+
// Grouped as the locale groups, or not grouped at all: anything else is a reading two people
|
|
126
|
+
// would disagree on, and the person is asked again rather than one of them being guessed.
|
|
127
|
+
if (typedInteger !== integer && typedInteger !== numerals.grouped(integer)) {
|
|
128
|
+
throw fail(`${renderCauseValue(numerals.group)} does not group the digits the way ${tag} does`);
|
|
129
|
+
}
|
|
130
|
+
const exponent = exponentOf(code);
|
|
131
|
+
if (fraction !== undefined && fraction.length > exponent) {
|
|
132
|
+
throw fail(`${fraction.length} digits after the decimal mark, and ${code} has ${exponent}`);
|
|
133
|
+
}
|
|
134
|
+
const decimal = `${negative ? '-' : ''}${integer === '' ? '0' : integer}${
|
|
135
|
+
fraction === undefined || fraction === '' ? '' : `.${fraction}`
|
|
136
|
+
}`;
|
|
137
|
+
return fromDecimal(decimal, code);
|
|
138
|
+
}
|