@ducatjs/money 0.1.0 → 0.2.1
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/NOTICE +73 -0
- package/README.md +479 -62
- package/data/cash-rounding.json +123 -2
- package/data/cldr-cash.json +101 -0
- package/data/historic.json +78 -0
- package/data/iso4217-list-three.xml +1185 -0
- package/dist/adyen-fx.d.ts.map +1 -1
- package/dist/adyen-fx.js +22 -4
- package/dist/adyen-fx.js.map +1 -1
- package/dist/boc.d.ts +31 -0
- package/dist/boc.d.ts.map +1 -0
- package/dist/boc.js +114 -0
- package/dist/boc.js.map +1 -0
- package/dist/boe.d.ts +33 -0
- package/dist/boe.d.ts.map +1 -0
- package/dist/boe.js +114 -0
- package/dist/boe.js.map +1 -0
- package/dist/cash.d.ts +30 -1
- package/dist/cash.d.ts.map +1 -1
- package/dist/cash.js +50 -2
- package/dist/cash.js.map +1 -1
- package/dist/convert.d.ts +10 -9
- package/dist/convert.d.ts.map +1 -1
- package/dist/convert.js +13 -3
- package/dist/convert.js.map +1 -1
- package/dist/drizzle/mysql.d.ts +105 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +60 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +103 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +60 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +102 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +59 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/ecb.d.ts +37 -2
- package/dist/ecb.d.ts.map +1 -1
- package/dist/ecb.js +148 -10
- package/dist/ecb.js.map +1 -1
- package/dist/fed.d.ts +35 -0
- package/dist/fed.d.ts.map +1 -0
- package/dist/fed.js +134 -0
- package/dist/fed.js.map +1 -0
- package/dist/fetch.d.ts +45 -0
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +167 -24
- package/dist/fetch.js.map +1 -1
- package/dist/fn.d.ts +8 -1
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +8 -1
- package/dist/fn.js.map +1 -1
- package/dist/generated/cash.d.ts +2 -0
- package/dist/generated/cash.d.ts.map +1 -1
- package/dist/generated/cash.js +101 -0
- package/dist/generated/cash.js.map +1 -1
- package/dist/generated/historic.d.ts +337 -0
- package/dist/generated/historic.d.ts.map +1 -0
- package/dist/generated/historic.js +66 -0
- package/dist/generated/historic.js.map +1 -0
- package/dist/historic.d.ts +40 -0
- package/dist/historic.d.ts.map +1 -0
- package/dist/historic.js +43 -0
- package/dist/historic.js.map +1 -0
- package/dist/history.d.ts +96 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +213 -0
- package/dist/history.js.map +1 -0
- package/dist/hmrc.d.ts +28 -0
- package/dist/hmrc.d.ts.map +1 -0
- package/dist/hmrc.js +108 -0
- package/dist/hmrc.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kysely.d.ts +120 -0
- package/dist/kysely.d.ts.map +1 -0
- package/dist/kysely.js +309 -0
- package/dist/kysely.js.map +1 -0
- package/dist/money.d.ts +93 -9
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +135 -9
- package/dist/money.js.map +1 -1
- package/dist/parse.d.ts +45 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +290 -3
- package/dist/parse.js.map +1 -1
- package/dist/pg.d.ts +100 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +343 -0
- package/dist/pg.js.map +1 -0
- package/dist/plugins/adyen.d.ts +1 -1
- package/dist/plugins/adyen.d.ts.map +1 -1
- package/dist/plugins/adyen.js.map +1 -1
- package/dist/plugins/all.d.ts +8 -1
- package/dist/plugins/all.d.ts.map +1 -1
- package/dist/plugins/all.js +8 -1
- package/dist/plugins/all.js.map +1 -1
- package/dist/plugins/allocate.d.ts +1 -1
- package/dist/plugins/allocate.d.ts.map +1 -1
- package/dist/plugins/allocate.js.map +1 -1
- package/dist/plugins/braintree.d.ts +1 -1
- package/dist/plugins/braintree.d.ts.map +1 -1
- package/dist/plugins/braintree.js.map +1 -1
- package/dist/plugins/cash.d.ts +1 -1
- package/dist/plugins/cash.d.ts.map +1 -1
- package/dist/plugins/cash.js.map +1 -1
- package/dist/plugins/checkout.d.ts +1 -1
- package/dist/plugins/checkout.d.ts.map +1 -1
- package/dist/plugins/checkout.js.map +1 -1
- package/dist/plugins/convert.d.ts +5 -5
- package/dist/plugins/convert.d.ts.map +1 -1
- package/dist/plugins/convert.js.map +1 -1
- package/dist/plugins/exact.d.ts +1 -1
- package/dist/plugins/exact.d.ts.map +1 -1
- package/dist/plugins/exact.js.map +1 -1
- package/dist/plugins/fixed.d.ts +1 -1
- package/dist/plugins/fixed.d.ts.map +1 -1
- package/dist/plugins/fixed.js.map +1 -1
- package/dist/plugins/format.d.ts +1 -1
- package/dist/plugins/format.d.ts.map +1 -1
- package/dist/plugins/format.js.map +1 -1
- package/dist/plugins/klarna.d.ts +1 -1
- package/dist/plugins/klarna.d.ts.map +1 -1
- package/dist/plugins/klarna.js.map +1 -1
- package/dist/plugins/paypal.d.ts +1 -1
- package/dist/plugins/paypal.d.ts.map +1 -1
- package/dist/plugins/paypal.js +0 -4
- package/dist/plugins/paypal.js.map +1 -1
- package/dist/plugins/prisma.d.ts +1 -1
- package/dist/plugins/prisma.d.ts.map +1 -1
- package/dist/plugins/prisma.js +0 -4
- package/dist/plugins/prisma.js.map +1 -1
- package/dist/plugins/stripe.d.ts +1 -1
- package/dist/plugins/stripe.d.ts.map +1 -1
- package/dist/plugins/stripe.js +0 -4
- package/dist/plugins/stripe.js.map +1 -1
- package/dist/plugins/tax.d.ts +6 -6
- package/dist/plugins/tax.d.ts.map +1 -1
- package/dist/plugins/tax.js.map +1 -1
- package/dist/policy.d.ts +34 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +90 -0
- package/dist/policy.js.map +1 -0
- package/dist/prisma.d.ts +25 -14
- package/dist/prisma.d.ts.map +1 -1
- package/dist/prisma.js +29 -19
- package/dist/prisma.js.map +1 -1
- package/dist/provider.d.ts +21 -7
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +51 -11
- package/dist/provider.js.map +1 -1
- package/dist/rate-text.d.ts +38 -0
- package/dist/rate-text.d.ts.map +1 -0
- package/dist/rate-text.js +143 -0
- package/dist/rate-text.js.map +1 -0
- package/dist/rate.d.ts +61 -5
- package/dist/rate.d.ts.map +1 -1
- package/dist/rate.js +133 -33
- package/dist/rate.js.map +1 -1
- package/dist/rounding.js +1 -1
- package/dist/rounding.js.map +1 -1
- package/dist/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +54 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +131 -0
- package/dist/sql.js.map +1 -0
- package/dist/tax.d.ts +13 -9
- package/dist/tax.d.ts.map +1 -1
- package/dist/tax.js +20 -4
- package/dist/tax.js.map +1 -1
- package/dist/testing/arbitraries.d.ts +43 -0
- package/dist/testing/arbitraries.d.ts.map +1 -0
- package/dist/testing/arbitraries.js +98 -0
- package/dist/testing/arbitraries.js.map +1 -0
- package/dist/testing/index.d.ts +67 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +159 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/jest.d.ts +2 -0
- package/dist/testing/jest.d.ts.map +1 -0
- package/dist/testing/jest.js +15 -0
- package/dist/testing/jest.js.map +1 -0
- package/dist/testing/vitest.d.ts +7 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +12 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/valibot.d.ts +33 -0
- package/dist/valibot.d.ts.map +1 -0
- package/dist/valibot.js +61 -0
- package/dist/valibot.js.map +1 -0
- package/dist/zod.d.ts +36 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +58 -0
- package/dist/zod.js.map +1 -0
- package/package.json +116 -5
- package/src/adyen-fx.ts +25 -4
- package/src/boc.ts +136 -0
- package/src/boe.ts +131 -0
- package/src/cash.ts +70 -2
- package/src/convert.ts +33 -15
- package/src/drizzle/mysql.ts +78 -0
- package/src/drizzle/pg.ts +78 -0
- package/src/drizzle/sqlite.ts +70 -0
- package/src/ecb.ts +179 -13
- package/src/fed.ts +146 -0
- package/src/fetch.ts +207 -28
- package/src/fn.ts +14 -0
- package/src/generated/cash.ts +103 -0
- package/src/generated/historic.ts +67 -0
- package/src/historic.ts +77 -0
- package/src/history.ts +333 -0
- package/src/hmrc.ts +125 -0
- package/src/index.ts +4 -0
- package/src/kysely.ts +588 -0
- package/src/money.ts +210 -12
- package/src/parse.ts +371 -4
- package/src/pg.ts +410 -0
- package/src/plugins/adyen.ts +3 -2
- package/src/plugins/all.ts +10 -0
- package/src/plugins/allocate.ts +3 -2
- package/src/plugins/braintree.ts +3 -2
- package/src/plugins/cash.ts +3 -2
- package/src/plugins/checkout.ts +3 -2
- package/src/plugins/convert.ts +6 -6
- package/src/plugins/exact.ts +3 -2
- package/src/plugins/fixed.ts +3 -2
- package/src/plugins/format.ts +3 -2
- package/src/plugins/klarna.ts +3 -2
- package/src/plugins/paypal.ts +3 -2
- package/src/plugins/prisma.ts +3 -2
- package/src/plugins/stripe.ts +3 -2
- package/src/plugins/tax.ts +11 -11
- package/src/policy.ts +124 -0
- package/src/prisma.ts +91 -10
- package/src/provider.ts +81 -16
- package/src/rate-text.ts +151 -0
- package/src/rate.ts +205 -36
- package/src/rounding.ts +1 -1
- package/src/schema.ts +119 -0
- package/src/sql.ts +178 -0
- package/src/tax.ts +40 -9
- package/src/testing/arbitraries.ts +134 -0
- package/src/testing/index.ts +247 -0
- package/src/testing/jest.ts +18 -0
- package/src/testing/vitest.ts +18 -0
- package/src/valibot.ts +87 -0
- package/src/zod.ts +78 -0
package/src/parse.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { ISO_EXPONENTS, ISO_NON_MONETARY } from './currency.js';
|
|
1
2
|
import { money, type CurrencyInput, type Money, type MoneyOptions } from './money.js';
|
|
2
3
|
|
|
3
4
|
interface Symbols {
|
|
4
5
|
group: string;
|
|
5
6
|
decimal: string;
|
|
7
|
+
/** The locale's group sizes, looked up the first time a group separator is seen. */
|
|
8
|
+
groupSizes?: readonly GroupSizes[];
|
|
6
9
|
}
|
|
7
10
|
const symCache = new Map<string, Symbols>();
|
|
8
11
|
|
|
@@ -28,12 +31,17 @@ function symbols(locale: string | undefined, currency: string): Symbols {
|
|
|
28
31
|
return found;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
function separatorParts(
|
|
34
|
+
function separatorParts(
|
|
35
|
+
locale: string | undefined,
|
|
36
|
+
currency: string,
|
|
37
|
+
currencySign: 'standard' | 'accounting' = 'standard',
|
|
38
|
+
): Intl.NumberFormatPart[] {
|
|
32
39
|
try {
|
|
33
40
|
// Two fraction digits even for JPY and the like, so the decimal separator always appears.
|
|
34
41
|
return new Intl.NumberFormat(locale, {
|
|
35
42
|
style: 'currency',
|
|
36
43
|
currency,
|
|
44
|
+
currencySign,
|
|
37
45
|
useGrouping: true,
|
|
38
46
|
minimumFractionDigits: 2,
|
|
39
47
|
maximumFractionDigits: 2,
|
|
@@ -83,14 +91,19 @@ function asciiDigit(cp: number): number {
|
|
|
83
91
|
* the text). A minus sign (ASCII or U+2212) or accounting parentheses anywhere in the affixes make
|
|
84
92
|
* it negative. Inside the number only digits, the locale's group separator (before the decimal
|
|
85
93
|
* separator) and one decimal separator are allowed.
|
|
94
|
+
*
|
|
95
|
+
* Group separators must be where the locale puts them (`1.234,50` in de-DE, `1,23,456.78` in
|
|
96
|
+
* en-IN), because a misplaced one is usually a decimal separator from another locale: `78.00` in
|
|
97
|
+
* de-DE throws instead of becoming 7800.00. Pass `{ lenient: true }` to accept them anywhere.
|
|
86
98
|
*/
|
|
87
99
|
export function parse<C extends CurrencyInput>(
|
|
88
100
|
input: string,
|
|
89
101
|
currency: C,
|
|
90
102
|
locale?: string,
|
|
91
|
-
options?:
|
|
103
|
+
options?: ParseOptions,
|
|
92
104
|
): Money<C> {
|
|
93
|
-
const
|
|
105
|
+
const localeSymbols = symbols(locale, currency);
|
|
106
|
+
const { group, decimal } = localeSymbols;
|
|
94
107
|
const chars = Array.from(input);
|
|
95
108
|
let first = -1;
|
|
96
109
|
let last = -1;
|
|
@@ -119,26 +132,380 @@ export function parse<C extends CurrencyInput>(
|
|
|
119
132
|
ch === '٬';
|
|
120
133
|
let out = prefix.endsWith(decimal) ? '0.' : '';
|
|
121
134
|
let seenDecimal = out !== '';
|
|
135
|
+
// The integer part's digit runs between group separators, as counters (most input has none, and
|
|
136
|
+
// then nothing is checked): the first run, the length every middle run shares, and the last run.
|
|
137
|
+
let separators = 0;
|
|
138
|
+
let firstRun = 0;
|
|
139
|
+
let middleRun = -1;
|
|
140
|
+
let middleRunsAgree = true;
|
|
141
|
+
let lastRun = -1;
|
|
142
|
+
let digitsInGroup = 0;
|
|
122
143
|
for (let i = first; i <= last; i++) {
|
|
123
144
|
const ch = chars[i]!;
|
|
124
145
|
const digit = asciiDigit(ch.codePointAt(0)!);
|
|
125
146
|
if (digit >= 0) {
|
|
126
147
|
out += digit;
|
|
148
|
+
digitsInGroup++;
|
|
127
149
|
} else if (ch === decimal || ch === '٫') {
|
|
128
150
|
if (seenDecimal) {
|
|
129
151
|
throw new SyntaxError(`Cannot parse "${input}": more than one decimal separator`);
|
|
130
152
|
}
|
|
131
153
|
seenDecimal = true;
|
|
132
154
|
out += '.';
|
|
155
|
+
lastRun = digitsInGroup;
|
|
133
156
|
} else if (isGroup(ch)) {
|
|
134
157
|
if (seenDecimal) {
|
|
135
158
|
throw new SyntaxError(
|
|
136
159
|
`Cannot parse "${input}": group separator after the decimal separator`,
|
|
137
160
|
);
|
|
138
161
|
}
|
|
162
|
+
if (separators === 0) {
|
|
163
|
+
firstRun = digitsInGroup;
|
|
164
|
+
} else if (middleRun < 0) {
|
|
165
|
+
middleRun = digitsInGroup;
|
|
166
|
+
} else if (middleRun !== digitsInGroup) {
|
|
167
|
+
middleRunsAgree = false;
|
|
168
|
+
}
|
|
169
|
+
separators++;
|
|
170
|
+
digitsInGroup = 0;
|
|
139
171
|
} else {
|
|
140
172
|
throw new SyntaxError(`Cannot parse "${input}": unexpected "${ch}"`);
|
|
141
173
|
}
|
|
142
174
|
}
|
|
143
|
-
|
|
175
|
+
if (separators > 0 && options?.lenient !== true) {
|
|
176
|
+
if (lastRun < 0) {
|
|
177
|
+
lastRun = digitsInGroup;
|
|
178
|
+
}
|
|
179
|
+
localeSymbols.groupSizes ??= groupSizes(locale, currency);
|
|
180
|
+
checkRuns(input, localeSymbols.groupSizes, firstRun, middleRunsAgree ? middleRun : -2, lastRun);
|
|
181
|
+
}
|
|
182
|
+
// pass options through as they are unless they carry `lenient`, so the common call allocates nothing
|
|
183
|
+
let moneyOptions: MoneyOptions | undefined = options;
|
|
184
|
+
if (options !== undefined && 'lenient' in options) {
|
|
185
|
+
const { lenient: _lenient, ...rest } = options;
|
|
186
|
+
moneyOptions = rest;
|
|
187
|
+
}
|
|
188
|
+
return money((neg ? '-' : '') + out, currency, moneyOptions);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Options for `parse`. */
|
|
192
|
+
export interface ParseOptions extends MoneyOptions {
|
|
193
|
+
/** Accept group separators in any position, as before 0.2 (`1.5` in de-DE is then 15.00). */
|
|
194
|
+
readonly lenient?: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Options for `parseMoney`. */
|
|
198
|
+
export interface ParseMoneyOptions<C extends string = string> extends MoneyOptions {
|
|
199
|
+
/**
|
|
200
|
+
* The expected currency. It settles a symbol several currencies share (`$`, `kr`, `£`, `¥`), and
|
|
201
|
+
* a text naming another currency is an error. With `lenient`, a bare number is read in it.
|
|
202
|
+
*/
|
|
203
|
+
readonly hint?: C;
|
|
204
|
+
/**
|
|
205
|
+
* Your own meanings for symbols, which win over the locale's: `{ $: 'CAD' }` for a shop that only
|
|
206
|
+
* prices in Canadian dollars.
|
|
207
|
+
*/
|
|
208
|
+
readonly symbols?: Readonly<Record<string, C>>;
|
|
209
|
+
/** The currencies to detect (default: every ISO 4217 currency). Others are not recognised. */
|
|
210
|
+
readonly currencies?: readonly C[];
|
|
211
|
+
/**
|
|
212
|
+
* Accept group separators in any positions (as `parse` does with `lenient`), and a bare number when a `hint`
|
|
213
|
+
* is given. It never makes the currency a guess.
|
|
214
|
+
*/
|
|
215
|
+
readonly lenient?: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const symbolTables = new Map<string, Map<string, Set<string>>>();
|
|
219
|
+
const groupSizeCache = new Map<string, GroupSizes[]>();
|
|
220
|
+
|
|
221
|
+
/** The size of the last group, and of the groups before it: [3, 3] for 1,234,567, [3, 2] for 12,34,567. */
|
|
222
|
+
type GroupSizes = readonly [number, number];
|
|
223
|
+
const supportedLocales = new Map<string, boolean>();
|
|
224
|
+
|
|
225
|
+
/** Strips what surrounds a symbol in formatted output: spaces, no-break spaces and bidi marks. */
|
|
226
|
+
const normalizeSymbol = (text: string) => text.replace(/[\s\p{Z}\p{Cf}]/gu, '');
|
|
227
|
+
|
|
228
|
+
function currencySymbol(
|
|
229
|
+
locale: string,
|
|
230
|
+
currency: string,
|
|
231
|
+
display: 'symbol' | 'narrowSymbol',
|
|
232
|
+
): string | undefined {
|
|
233
|
+
try {
|
|
234
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
235
|
+
style: 'currency',
|
|
236
|
+
currency,
|
|
237
|
+
currencyDisplay: display,
|
|
238
|
+
}).formatToParts(1);
|
|
239
|
+
const value = parts.find((part) => part.type === 'currency')?.value;
|
|
240
|
+
return value === undefined ? undefined : normalizeSymbol(value);
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Language and region of a locale tag, the part Intl must have data for. */
|
|
247
|
+
function languageAndRegion(tag: string): string {
|
|
248
|
+
const parsed = new Intl.Locale(tag);
|
|
249
|
+
return `${parsed.language}-${parsed.region ?? ''}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Throws unless Intl has data for the locale's language and region. Intl silently falls back to
|
|
254
|
+
* another locale (an unsupported tag, or a runtime with reduced ICU data), and that locale's
|
|
255
|
+
* separators and symbols would then decide the amount and the currency.
|
|
256
|
+
*/
|
|
257
|
+
function requireLocaleData(input: string, locale: string): void {
|
|
258
|
+
let isSupported = supportedLocales.get(locale);
|
|
259
|
+
if (isSupported === undefined) {
|
|
260
|
+
const resolved = new Intl.NumberFormat(locale).resolvedOptions().locale;
|
|
261
|
+
isSupported = languageAndRegion(resolved) === languageAndRegion(locale);
|
|
262
|
+
if (supportedLocales.size >= 64) {
|
|
263
|
+
supportedLocales.clear();
|
|
264
|
+
}
|
|
265
|
+
supportedLocales.set(locale, isSupported);
|
|
266
|
+
}
|
|
267
|
+
if (!isSupported) {
|
|
268
|
+
throw new RangeError(
|
|
269
|
+
`Cannot parse "${input}": this runtime has no Intl data for the locale ${locale}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* symbol → every currency it can mean in one locale (symbol and narrow symbol), from Intl's own
|
|
276
|
+
* data, so it follows the runtime's CLDR version. Built on first use, cached per locale (at most
|
|
277
|
+
* 16), and narrowed by a `currencies` option rather than rebuilt.
|
|
278
|
+
*/
|
|
279
|
+
function symbolTable(locale: string): Map<string, Set<string>> {
|
|
280
|
+
let table = symbolTables.get(locale);
|
|
281
|
+
if (table !== undefined) {
|
|
282
|
+
return table;
|
|
283
|
+
}
|
|
284
|
+
table = new Map();
|
|
285
|
+
for (const currency of defaultCandidates()) {
|
|
286
|
+
for (const display of ['symbol', 'narrowSymbol'] as const) {
|
|
287
|
+
const symbol = currencySymbol(locale, currency, display);
|
|
288
|
+
// A symbol that is just the code is handled as a code.
|
|
289
|
+
if (symbol !== undefined && symbol !== currency) {
|
|
290
|
+
let meanings = table.get(symbol);
|
|
291
|
+
if (meanings === undefined) {
|
|
292
|
+
meanings = new Set();
|
|
293
|
+
table.set(symbol, meanings);
|
|
294
|
+
}
|
|
295
|
+
meanings.add(currency);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (symbolTables.size >= 16) {
|
|
300
|
+
symbolTables.clear();
|
|
301
|
+
}
|
|
302
|
+
symbolTables.set(locale, table);
|
|
303
|
+
return table;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Where the English symbols come from: en has CA$, A$ and the like, en-CA has US$. */
|
|
307
|
+
const ENGLISH_SYMBOL_LOCALES = ['en', 'en-CA'];
|
|
308
|
+
|
|
309
|
+
let isoCodes: string[] | undefined;
|
|
310
|
+
|
|
311
|
+
/** Every ISO 4217 currency that money is counted in (not gold, test or fund codes). */
|
|
312
|
+
function defaultCandidates(): string[] {
|
|
313
|
+
isoCodes ??= Object.keys(ISO_EXPONENTS).filter(
|
|
314
|
+
(code) => !(ISO_NON_MONETARY as readonly string[]).includes(code),
|
|
315
|
+
);
|
|
316
|
+
return isoCodes;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The currencies one piece of affix text can mean; an empty list if it isn't a currency. */
|
|
320
|
+
function meaningsOf(
|
|
321
|
+
piece: string,
|
|
322
|
+
locale: string,
|
|
323
|
+
candidates: ReadonlySet<string> | undefined,
|
|
324
|
+
symbols: Readonly<Record<string, string>> | undefined,
|
|
325
|
+
): string[] {
|
|
326
|
+
const isCandidate = (code: string) =>
|
|
327
|
+
candidates === undefined ? defaultCandidates().includes(code) : candidates.has(code);
|
|
328
|
+
if (/^[A-Z]{3}$/.test(piece) && isCandidate(piece)) {
|
|
329
|
+
return [piece];
|
|
330
|
+
}
|
|
331
|
+
if (symbols !== undefined && Object.hasOwn(symbols, piece)) {
|
|
332
|
+
return [symbols[piece]!];
|
|
333
|
+
}
|
|
334
|
+
// The locale's own symbols first, then English ones (US$, CA$, A$...), which many texts use.
|
|
335
|
+
for (const source of [locale, ...ENGLISH_SYMBOL_LOCALES]) {
|
|
336
|
+
const found = symbolTable(source).get(piece);
|
|
337
|
+
const meanings = found === undefined ? [] : [...found].filter(isCandidate);
|
|
338
|
+
if (meanings.length > 0) {
|
|
339
|
+
return meanings.sort();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The group sizes the locale formats this currency with, in its standard and its accounting
|
|
347
|
+
* format: they can differ (en-IN writes JP¥1,00,000, but (JP¥100,000) in accounting style).
|
|
348
|
+
*/
|
|
349
|
+
function groupSizes(locale: string | undefined, currency: string): readonly GroupSizes[] {
|
|
350
|
+
const key = `${locale ?? ''}|${currency}`;
|
|
351
|
+
let sizes = groupSizeCache.get(key);
|
|
352
|
+
if (sizes === undefined) {
|
|
353
|
+
sizes = [];
|
|
354
|
+
for (const currencySign of ['standard', 'accounting'] as const) {
|
|
355
|
+
const found = sizesOf(separatorParts(locale, currency, currencySign));
|
|
356
|
+
if (!sizes.some((known) => known[0] === found[0] && known[1] === found[1])) {
|
|
357
|
+
sizes.push(found);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (groupSizeCache.size >= 256) {
|
|
361
|
+
groupSizeCache.clear();
|
|
362
|
+
}
|
|
363
|
+
groupSizeCache.set(key, sizes);
|
|
364
|
+
}
|
|
365
|
+
return sizes;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function sizesOf(parts: Intl.NumberFormatPart[]): GroupSizes {
|
|
369
|
+
const integers = parts.filter((part) => part.type === 'integer').map((part) => part.value.length);
|
|
370
|
+
// 1234567.5 formats as 1|234|567 (sizes 3, 3) or 12|34|567 (3, 2)
|
|
371
|
+
const primary = integers[integers.length - 1] ?? 3;
|
|
372
|
+
const secondary = integers.length >= 3 ? integers[integers.length - 2]! : primary;
|
|
373
|
+
return [primary, secondary];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Throws unless the integer part's groups follow one of the locale's sizes (when it has groups). */
|
|
377
|
+
function checkGroups(input: string, integerPart: string, allSizes: readonly GroupSizes[]): void {
|
|
378
|
+
// the caller has turned every separator into a comma
|
|
379
|
+
const runs = integerPart.split(',');
|
|
380
|
+
if (runs.length <= 1) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const middle = runs.slice(1, -1).map((run) => run.length);
|
|
384
|
+
let middleRun = -1;
|
|
385
|
+
if (middle.length > 0) {
|
|
386
|
+
middleRun = middle.every((length) => length === middle[0]) ? middle[0]! : -2;
|
|
387
|
+
}
|
|
388
|
+
checkRuns(input, allSizes, runs[0]!.length, middleRun, runs[runs.length - 1]!.length);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Throws unless digit runs between group separators fit one of the sizes: 1,234,567 or 12,34,567
|
|
393
|
+
* has a leading run of 1 up to `secondary` digits, middle runs of `secondary` and a last run of
|
|
394
|
+
* `primary`. `middleRun` is the length all middle runs share, -1 for none, -2 if they differ.
|
|
395
|
+
*/
|
|
396
|
+
function checkRuns(
|
|
397
|
+
input: string,
|
|
398
|
+
allSizes: readonly GroupSizes[],
|
|
399
|
+
firstRun: number,
|
|
400
|
+
middleRun: number,
|
|
401
|
+
lastRun: number,
|
|
402
|
+
): void {
|
|
403
|
+
for (let option = 0; option < allSizes.length; option++) {
|
|
404
|
+
const [primary, secondary] = allSizes[option]!;
|
|
405
|
+
if (
|
|
406
|
+
lastRun === primary &&
|
|
407
|
+
firstRun >= 1 &&
|
|
408
|
+
firstRun <= secondary &&
|
|
409
|
+
(middleRun === -1 || middleRun === secondary)
|
|
410
|
+
) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
throw new SyntaxError(`Cannot parse "${input}": group separators in the wrong places`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Parses a formatted amount and detects its currency from the text: `parseMoney('CHF 1’234.50',
|
|
419
|
+
* 'de-CH')`, `parseMoney('1.234,50 €', 'de-DE')`. Like the rest of ducat it never guesses:
|
|
420
|
+
*
|
|
421
|
+
* - it reads ISO codes (uppercase, among `currencies`) and symbols that mean one currency in the
|
|
422
|
+
* locale (or in English: US$, CA$, A$);
|
|
423
|
+
* - a symbol several currencies share (`$`, `kr`, `£`, `¥`) needs a `hint` or a `symbols` entry;
|
|
424
|
+
* - a currency other than the `hint`, two different currencies, or text that isn't a currency
|
|
425
|
+
* throws;
|
|
426
|
+
* - so does a locale this runtime has no Intl data for, and group separators in the wrong places
|
|
427
|
+
* (`lenient: true` accepts those, and a bare number when a `hint` is given).
|
|
428
|
+
*
|
|
429
|
+
* The number is read as `parse` reads it, with the detected currency's separators.
|
|
430
|
+
*/
|
|
431
|
+
export function parseMoney<C extends string = string>(
|
|
432
|
+
input: string,
|
|
433
|
+
locale: string,
|
|
434
|
+
options?: ParseMoneyOptions<C>,
|
|
435
|
+
): Money<C> {
|
|
436
|
+
const hint = options?.hint;
|
|
437
|
+
const lenient = options?.lenient === true;
|
|
438
|
+
const candidates =
|
|
439
|
+
options?.currencies === undefined ? undefined : new Set<string>(options.currencies);
|
|
440
|
+
requireLocaleData(input, locale);
|
|
441
|
+
|
|
442
|
+
// The text around the number: everything before the first digit and after the last.
|
|
443
|
+
const chars = Array.from(input);
|
|
444
|
+
let first = -1;
|
|
445
|
+
let last = -1;
|
|
446
|
+
for (let i = 0; i < chars.length; i++) {
|
|
447
|
+
if (asciiDigit(chars[i]!.codePointAt(0)!) >= 0) {
|
|
448
|
+
if (first < 0) {
|
|
449
|
+
first = i;
|
|
450
|
+
}
|
|
451
|
+
last = i;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (first < 0) {
|
|
455
|
+
throw new SyntaxError(`Cannot parse "${input}": no digits`);
|
|
456
|
+
}
|
|
457
|
+
const affix = `${chars.slice(0, first).join('')} ${chars.slice(last + 1).join('')}`;
|
|
458
|
+
|
|
459
|
+
// Each piece of the affix must be a sign or a currency, and every currency must agree. A lone
|
|
460
|
+
// separator next to the number (".50 USD") belongs to the number, which parse reads.
|
|
461
|
+
let detected: string | undefined;
|
|
462
|
+
const pieces = affix
|
|
463
|
+
.split(/[\s\p{Z}\p{Cf}()+\-−]+/u)
|
|
464
|
+
.filter((piece) => piece !== '' && !/^[.,٫]$/u.test(piece));
|
|
465
|
+
for (const piece of pieces) {
|
|
466
|
+
const meanings = meaningsOf(piece, locale, candidates, options?.symbols);
|
|
467
|
+
let currency: string;
|
|
468
|
+
if (meanings.length === 1) {
|
|
469
|
+
currency = meanings[0]!;
|
|
470
|
+
} else if (meanings.length === 0) {
|
|
471
|
+
throw new SyntaxError(`Cannot parse "${input}": "${piece}" is not a currency`);
|
|
472
|
+
} else if (hint !== undefined && meanings.includes(hint)) {
|
|
473
|
+
currency = hint;
|
|
474
|
+
} else {
|
|
475
|
+
throw new SyntaxError(
|
|
476
|
+
`Cannot parse "${input}": "${piece}" could be ${meanings.join(', ')}; pass a hint or symbols`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
if (detected !== undefined && detected !== currency) {
|
|
480
|
+
throw new SyntaxError(`Cannot parse "${input}": both ${detected} and ${currency}`);
|
|
481
|
+
}
|
|
482
|
+
detected = currency;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (detected === undefined) {
|
|
486
|
+
if (hint === undefined || !lenient) {
|
|
487
|
+
throw new SyntaxError(
|
|
488
|
+
`Cannot parse "${input}": no currency in the text` +
|
|
489
|
+
(hint === undefined ? '' : '; pass lenient: true to read a bare number in the hint'),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
detected = hint;
|
|
493
|
+
} else if (hint !== undefined && detected !== hint) {
|
|
494
|
+
// a text that names another currency than expected is never a correct parse
|
|
495
|
+
throw new SyntaxError(`Cannot parse "${input}": ${detected}, expected ${hint}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!lenient) {
|
|
499
|
+
const { decimal } = symbols(locale, detected);
|
|
500
|
+
const number = chars.slice(first, last + 1).join('');
|
|
501
|
+
const decimalAt = Math.max(number.indexOf(decimal), number.indexOf('٫'));
|
|
502
|
+
const integerPart = decimalAt === -1 ? number : number.slice(0, decimalAt);
|
|
503
|
+
const asciiInteger = Array.from(integerPart, (ch) => {
|
|
504
|
+
const digit = asciiDigit(ch.codePointAt(0)!);
|
|
505
|
+
return digit >= 0 ? String(digit) : ',';
|
|
506
|
+
}).join('');
|
|
507
|
+
checkGroups(input, asciiInteger, groupSizes(locale, detected));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return parse(input, detected as C, locale, options);
|
|
144
511
|
}
|