@arki/money 0.0.0-stage → 0.0.4
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/LICENSE +21 -0
- package/README.md +238 -2
- package/dist/bag.d.ts +48 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +110 -0
- package/dist/bag.js.map +1 -0
- package/dist/currency.d.ts +64 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +148 -0
- package/dist/currency.js.map +1 -0
- package/dist/data/iso4217.d.ts +9 -0
- package/dist/data/iso4217.d.ts.map +1 -0
- package/dist/data/iso4217.js +11 -0
- package/dist/data/iso4217.js.map +1 -0
- package/dist/drizzle/config.d.ts +27 -0
- package/dist/drizzle/config.d.ts.map +1 -0
- package/dist/drizzle/config.js +16 -0
- package/dist/drizzle/config.js.map +1 -0
- package/dist/drizzle/mysql.d.ts +36 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +45 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +38 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +49 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +38 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +45 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/drizzle.d.ts +56 -0
- package/dist/drizzle.d.ts.map +1 -0
- package/dist/drizzle.js +77 -0
- package/dist/drizzle.js.map +1 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/exact.d.ts +20 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +23 -0
- package/dist/exact.js.map +1 -0
- package/dist/exchange.d.ts +84 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +215 -0
- package/dist/exchange.js.map +1 -0
- package/dist/format.d.ts +41 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +122 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/columns.d.ts +25 -0
- package/dist/internal/columns.d.ts.map +1 -0
- package/dist/internal/columns.js +47 -0
- package/dist/internal/columns.js.map +1 -0
- package/dist/internal/units.d.ts +66 -0
- package/dist/internal/units.d.ts.map +1 -0
- package/dist/internal/units.js +205 -0
- package/dist/internal/units.js.map +1 -0
- package/dist/money.d.ts +180 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +577 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +53 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +222 -0
- package/dist/parse.js.map +1 -0
- package/dist/provider.d.ts +22 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +56 -0
- package/dist/provider.js.map +1 -0
- package/dist/rational.d.ts +67 -0
- package/dist/rational.d.ts.map +1 -0
- package/dist/rational.js +169 -0
- package/dist/rational.js.map +1 -0
- package/dist/stripe.d.ts +37 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +75 -0
- package/dist/stripe.js.map +1 -0
- package/dist/zod.d.ts +54 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +96 -0
- package/dist/zod.js.map +1 -0
- package/package.json +137 -4
- package/src/bag.ts +128 -0
- package/src/currency.ts +174 -0
- package/src/data/iso4217.ts +13 -0
- package/src/drizzle/config.ts +39 -0
- package/src/drizzle/mysql.ts +61 -0
- package/src/drizzle/pg.ts +64 -0
- package/src/drizzle/sqlite.ts +61 -0
- package/src/errors.ts +61 -0
- package/src/exact.ts +29 -0
- package/src/exchange.ts +280 -0
- package/src/format.ts +148 -0
- package/src/index.ts +16 -0
- package/src/internal/columns.ts +74 -0
- package/src/internal/units.ts +223 -0
- package/src/money.ts +692 -0
- package/src/parse.ts +278 -0
- package/src/provider.ts +67 -0
- package/src/rational.ts +214 -0
- package/src/stripe.ts +84 -0
- package/src/zod.ts +123 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict parsing of money typed by people: `'1.234,56 €'` in German,
|
|
3
|
+
* `'$1,234.56'` in English, `'₹1,23,456.78'` in Indian English.
|
|
4
|
+
*
|
|
5
|
+
* The locale's separators, digits, minus sign and currency symbols come from
|
|
6
|
+
* `Intl.NumberFormat#formatToParts`. You always say which currency you expect,
|
|
7
|
+
* so `'$10'` never has to guess between US, Canadian or Australian dollars.
|
|
8
|
+
*
|
|
9
|
+
* Strict means it refuses instead of guessing: grouping in the wrong place
|
|
10
|
+
* (`'1,23,4'`), a decimal separator the locale does not use, two signs, mixed
|
|
11
|
+
* digit scripts, a different currency's symbol, or any trailing text all throw
|
|
12
|
+
* `NumberFormatError`. Text without grouping is always fine: `'1234.5'`.
|
|
13
|
+
*
|
|
14
|
+
* Runtimes without `formatToParts` (React Native's Hermes on iOS) throw
|
|
15
|
+
* `UnsupportedFeatureError`; pass `symbols` to parse without `Intl`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RoundingMode } from '@arki/math/rounding';
|
|
19
|
+
import {
|
|
20
|
+
NumberFormatError,
|
|
21
|
+
ResourceLimitError,
|
|
22
|
+
RoundingNecessaryError,
|
|
23
|
+
UnsupportedFeatureError,
|
|
24
|
+
} from '@arki/math/errors';
|
|
25
|
+
|
|
26
|
+
import type { Currency, CurrencyLike } from './currency.js';
|
|
27
|
+
import { toCurrency } from './currency.js';
|
|
28
|
+
import { MAX_INPUT_LENGTH } from './internal/units.js';
|
|
29
|
+
import { Money } from './money.js';
|
|
30
|
+
|
|
31
|
+
export type ParseSymbols = {
|
|
32
|
+
/** Decimal separator, such as `','`. */
|
|
33
|
+
decimal: string;
|
|
34
|
+
/** Group separator, such as `'.'`. Omit when the input never has grouping. */
|
|
35
|
+
group?: string;
|
|
36
|
+
/** Digits per group: `[3]` for most locales, `[3, 2]` for Indian grouping. Defaults to `[3]`. */
|
|
37
|
+
groupSizes?: readonly [primary: number, secondary?: number];
|
|
38
|
+
/** Currency marks to accept besides the code, such as `['€']`. */
|
|
39
|
+
currency?: readonly string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ParseOptions = {
|
|
43
|
+
/** BCP 47 locale. Defaults to `'en-US'`; there is no global locale. */
|
|
44
|
+
locale?: string;
|
|
45
|
+
/** How to drop digits beyond the scale. Without it, extra digits throw `RoundingNecessaryError`. */
|
|
46
|
+
rounding?: RoundingMode;
|
|
47
|
+
/** Decimals to keep. Defaults to the currency's. */
|
|
48
|
+
scale?: number;
|
|
49
|
+
/** Separators to use instead of asking `Intl`, for runtimes without `formatToParts`. */
|
|
50
|
+
symbols?: ParseSymbols;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type MoneyParser<C extends string> = {
|
|
54
|
+
/** Parses or throws `NumberFormatError` (bad text) or `RoundingNecessaryError` (too many decimals). */
|
|
55
|
+
parse(text: string): Money<C>;
|
|
56
|
+
/** Parses or returns `undefined`, for form validation. Still throws for runtime capability errors. */
|
|
57
|
+
tryParse(text: string): Money<C> | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type Grammar = {
|
|
61
|
+
decimal: string;
|
|
62
|
+
group: string | undefined;
|
|
63
|
+
primary: number;
|
|
64
|
+
secondary: number;
|
|
65
|
+
/** The locale's ten digits, or `undefined` for ASCII digits. */
|
|
66
|
+
digits: string | undefined;
|
|
67
|
+
minus: readonly string[];
|
|
68
|
+
/** Currency marks, longest first. */
|
|
69
|
+
marks: readonly string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const SPACES = /\s/g;
|
|
73
|
+
const BIDI = /[\u061C\u200E\u200F]/g;
|
|
74
|
+
const ASCII_DIGITS = /^\d+$/;
|
|
75
|
+
|
|
76
|
+
function normalizeSpaces(text: string): string {
|
|
77
|
+
return text.replaceAll(SPACES, ' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function grammarFromSymbols(currency: Currency, symbols: ParseSymbols): Grammar {
|
|
81
|
+
const [primary, secondary] = symbols.groupSizes ?? [3];
|
|
82
|
+
return {
|
|
83
|
+
decimal: normalizeSpaces(symbols.decimal),
|
|
84
|
+
group: symbols.group === undefined ? undefined : normalizeSpaces(symbols.group),
|
|
85
|
+
primary,
|
|
86
|
+
secondary: secondary ?? primary,
|
|
87
|
+
digits: undefined,
|
|
88
|
+
minus: ['-', '\u2212'],
|
|
89
|
+
marks: sortMarks([currency.code, ...(symbols.currency ?? [])]),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sortMarks(marks: readonly string[]): string[] {
|
|
94
|
+
return [...new Set(marks.map(mark => normalizeSpaces(mark).trim()))]
|
|
95
|
+
.filter(mark => mark !== '')
|
|
96
|
+
.sort((a, b) => b.length - a.length);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function grammarFromIntl(currency: Currency, locale: string): Grammar {
|
|
100
|
+
let plain: Intl.NumberFormat;
|
|
101
|
+
try {
|
|
102
|
+
plain = new Intl.NumberFormat(locale, { minimumFractionDigits: 1, useGrouping: true });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new UnsupportedFeatureError(`This runtime's Intl does not support the locale ${locale}`, { cause: error });
|
|
105
|
+
}
|
|
106
|
+
if (typeof plain.formatToParts !== 'function') {
|
|
107
|
+
throw new UnsupportedFeatureError('This runtime lacks Intl.NumberFormat#formatToParts; pass options.symbols');
|
|
108
|
+
}
|
|
109
|
+
const parts = plain.formatToParts(-1_234_567_890.5);
|
|
110
|
+
const find = (type: Intl.NumberFormatPartTypes) => parts.find(part => part.type === type)?.value;
|
|
111
|
+
const integers = parts.filter(part => part.type === 'integer').map(part => part.value.length);
|
|
112
|
+
const primary = integers.at(-1) ?? 3;
|
|
113
|
+
const secondary = integers.length > 2 ? (integers.at(-2) ?? primary) : primary;
|
|
114
|
+
|
|
115
|
+
const digitFormat = new Intl.NumberFormat(locale, { useGrouping: false });
|
|
116
|
+
const localDigits = Array.from({ length: 10 }, (_, digit) => digitFormat.format(digit)).join('');
|
|
117
|
+
|
|
118
|
+
// The locale's symbols plus the English ones: people in Japan often type `¥` where Intl writes `¥`.
|
|
119
|
+
// Safe, because the caller has already said which currency to expect.
|
|
120
|
+
const marks: string[] = [currency.code];
|
|
121
|
+
if (currency.iso) {
|
|
122
|
+
for (const markLocale of [locale, 'en']) {
|
|
123
|
+
for (const currencyDisplay of ['symbol', 'narrowSymbol'] as const) {
|
|
124
|
+
try {
|
|
125
|
+
const formatted = new Intl.NumberFormat(markLocale, {
|
|
126
|
+
style: 'currency',
|
|
127
|
+
currency: currency.code,
|
|
128
|
+
currencyDisplay,
|
|
129
|
+
});
|
|
130
|
+
for (const part of formatted.formatToParts(1)) if (part.type === 'currency') marks.push(part.value);
|
|
131
|
+
} catch {
|
|
132
|
+
// A display the runtime lacks simply adds no mark; the ISO code is always accepted.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const group = find('group');
|
|
139
|
+
return {
|
|
140
|
+
decimal: normalizeSpaces(find('decimal') ?? '.'),
|
|
141
|
+
group: group === undefined ? undefined : normalizeSpaces(group),
|
|
142
|
+
primary,
|
|
143
|
+
secondary,
|
|
144
|
+
digits: localDigits === '0123456789' ? undefined : localDigits,
|
|
145
|
+
minus: [...new Set([find('minusSign') ?? '-', '-', '\u2212'])],
|
|
146
|
+
marks: sortMarks(marks),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fail(text: string, reason: string): never {
|
|
151
|
+
throw new NumberFormatError(`Cannot read ${JSON.stringify(text.slice(0, 40))} as money: ${reason}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Turns locale text into plain `'-1234.56'`, or throws `NumberFormatError`. */
|
|
155
|
+
function toPlainDecimal(input: string, grammar: Grammar): string {
|
|
156
|
+
if (input.length > MAX_INPUT_LENGTH) {
|
|
157
|
+
throw new ResourceLimitError(`Money text is longer than ${MAX_INPUT_LENGTH} characters`);
|
|
158
|
+
}
|
|
159
|
+
let text = normalizeSpaces(input.replaceAll(BIDI, '')).trim();
|
|
160
|
+
let negative = false;
|
|
161
|
+
if (text.startsWith('(') && text.endsWith(')')) {
|
|
162
|
+
negative = true;
|
|
163
|
+
text = text.slice(1, -1).trim();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Leading sign and currency mark, in either order; a trailing currency mark.
|
|
167
|
+
let sawSign = false;
|
|
168
|
+
let sawMark = false;
|
|
169
|
+
for (let round = 0; round < 2; round++) {
|
|
170
|
+
const sign = [...grammar.minus, '+'].find(candidate => text.startsWith(candidate));
|
|
171
|
+
if (sign !== undefined && !sawSign) {
|
|
172
|
+
sawSign = true;
|
|
173
|
+
if (sign !== '+') negative = !negative;
|
|
174
|
+
text = text.slice(sign.length).trim();
|
|
175
|
+
}
|
|
176
|
+
const mark = grammar.marks.find(candidate => text.startsWith(candidate));
|
|
177
|
+
if (mark !== undefined && !sawMark) {
|
|
178
|
+
sawMark = true;
|
|
179
|
+
text = text.slice(mark.length).trim();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const trailing = grammar.marks.find(candidate => text.endsWith(candidate));
|
|
183
|
+
if (trailing !== undefined) {
|
|
184
|
+
if (sawMark) fail(input, 'the currency appears twice');
|
|
185
|
+
text = text.slice(0, -trailing.length).trim();
|
|
186
|
+
}
|
|
187
|
+
if (sawSign && negative && input.trim().startsWith('(')) fail(input, 'it has both parentheses and a minus sign');
|
|
188
|
+
|
|
189
|
+
// Local digits → ASCII, refusing a mix of scripts.
|
|
190
|
+
if (grammar.digits !== undefined) {
|
|
191
|
+
let local = false;
|
|
192
|
+
let ascii = false;
|
|
193
|
+
let converted = '';
|
|
194
|
+
for (const char of text) {
|
|
195
|
+
const index = grammar.digits.indexOf(char);
|
|
196
|
+
if (index === -1) {
|
|
197
|
+
if (char >= '0' && char <= '9') ascii = true;
|
|
198
|
+
converted += char;
|
|
199
|
+
} else {
|
|
200
|
+
local = true;
|
|
201
|
+
converted += String(index);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (local && ascii) fail(input, 'it mixes digit scripts');
|
|
205
|
+
text = converted;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pieces = text.split(grammar.decimal);
|
|
209
|
+
if (pieces.length > 2) fail(input, `the decimal separator ${JSON.stringify(grammar.decimal)} appears twice`);
|
|
210
|
+
const [whole = '', fraction] = pieces;
|
|
211
|
+
if (fraction !== undefined && !ASCII_DIGITS.test(fraction)) fail(input, 'the decimals are not digits');
|
|
212
|
+
|
|
213
|
+
const group = grammar.group;
|
|
214
|
+
if (!ASCII_DIGITS.test(group === undefined ? whole : whole.replaceAll(group, ''))) {
|
|
215
|
+
fail(input, 'it is not a number in this locale');
|
|
216
|
+
}
|
|
217
|
+
let digits = whole;
|
|
218
|
+
if (group !== undefined && whole.includes(group)) {
|
|
219
|
+
const groups = whole.split(group);
|
|
220
|
+
const last = groups.length - 1;
|
|
221
|
+
const valid = groups.every((group, index) => {
|
|
222
|
+
if (!ASCII_DIGITS.test(group)) return false;
|
|
223
|
+
if (index === last) return group.length === grammar.primary;
|
|
224
|
+
if (index === 0) return group.length <= (last === 1 ? grammar.primary : grammar.secondary);
|
|
225
|
+
return group.length === grammar.secondary;
|
|
226
|
+
});
|
|
227
|
+
if (!valid) fail(input, 'the digit grouping does not match the locale');
|
|
228
|
+
digits = groups.join('');
|
|
229
|
+
}
|
|
230
|
+
if (!ASCII_DIGITS.test(digits)) fail(input, 'it is not a number in this locale');
|
|
231
|
+
return `${negative ? '-' : ''}${digits}${fraction === undefined ? '' : `.${fraction}`}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Prepares the locale's grammar once, for forms and imports:
|
|
236
|
+
* `const parse = createMoneyParser('EUR', { locale: 'de-DE' }); parse.parse('1.234,56 €')`.
|
|
237
|
+
*/
|
|
238
|
+
export function createMoneyParser<const C extends string>(
|
|
239
|
+
currency: CurrencyLike<C>,
|
|
240
|
+
options: ParseOptions = {},
|
|
241
|
+
): MoneyParser<C> {
|
|
242
|
+
const target = toCurrency(currency);
|
|
243
|
+
const grammar =
|
|
244
|
+
options.symbols === undefined
|
|
245
|
+
? grammarFromIntl(target, options.locale ?? 'en-US')
|
|
246
|
+
: grammarFromSymbols(target, options.symbols);
|
|
247
|
+
const settle = { rounding: options.rounding, scale: options.scale };
|
|
248
|
+
const parse = (text: string): Money<C> => {
|
|
249
|
+
if (typeof text !== 'string') throw new TypeError('Expected text to parse');
|
|
250
|
+
return Money.of(toPlainDecimal(text, grammar), target, settle);
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
parse,
|
|
254
|
+
tryParse(text) {
|
|
255
|
+
try {
|
|
256
|
+
return parse(text);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (
|
|
259
|
+
error instanceof NumberFormatError ||
|
|
260
|
+
error instanceof RoundingNecessaryError ||
|
|
261
|
+
error instanceof ResourceLimitError
|
|
262
|
+
) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One-shot form of `createMoneyParser`: `parseMoney('1.234,56 €', 'EUR', { locale: 'de-DE' })`. */
|
|
272
|
+
export function parseMoney<const C extends string>(
|
|
273
|
+
text: string,
|
|
274
|
+
currency: CurrencyLike<C>,
|
|
275
|
+
options?: ParseOptions,
|
|
276
|
+
): Money<C> {
|
|
277
|
+
return createMoneyParser(currency, options).parse(text);
|
|
278
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment providers count amounts in their own units, which are not always
|
|
3
|
+
* ISO's. A rule says how many decimals the provider's integer carries and,
|
|
4
|
+
* optionally, which multiple it must be. Conversion never rounds: an amount
|
|
5
|
+
* the provider cannot express throws `ProviderAmountError`.
|
|
6
|
+
*
|
|
7
|
+
* `@arki/money/stripe` ships Stripe's rules. Describe any other provider with
|
|
8
|
+
* a `ProviderUnitRule` and these two functions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NumericRangeError, RoundingNecessaryError } from '@arki/math/errors';
|
|
12
|
+
|
|
13
|
+
import type { CurrencyLike } from './currency.js';
|
|
14
|
+
import type { Units } from './internal/units.js';
|
|
15
|
+
import { toCurrency } from './currency.js';
|
|
16
|
+
import { ProviderAmountError } from './errors.js';
|
|
17
|
+
import { quantize } from './internal/units.js';
|
|
18
|
+
import { Money } from './money.js';
|
|
19
|
+
|
|
20
|
+
export type ProviderUnitRule = {
|
|
21
|
+
/** Decimals in the provider's integer: 2 means `1099` is `10.99`. */
|
|
22
|
+
scale: number;
|
|
23
|
+
/** The integer must be a multiple of this: `100` for whole-unit-only currencies sent with two decimals. */
|
|
24
|
+
step?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function checkStep(units: Units, rule: ProviderUnitRule, what: string): void {
|
|
28
|
+
if (rule.step !== undefined && BigInt(units) % BigInt(rule.step) !== 0n) {
|
|
29
|
+
throw new ProviderAmountError(`${what} must be a multiple of ${rule.step} in the provider's units`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `toProviderUnits(money('10.99', 'USD'), { scale: 2 })` → `1099`. Throws instead of rounding. */
|
|
34
|
+
export function toProviderUnits(amount: Money, rule: ProviderUnitRule): number {
|
|
35
|
+
let units: Units;
|
|
36
|
+
try {
|
|
37
|
+
units = quantize(amount.units, amount.scale, rule.scale);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof RoundingNecessaryError)) throw error;
|
|
40
|
+
throw new ProviderAmountError(`${amount.toString()} has more decimals than the provider accepts (${rule.scale})`, {
|
|
41
|
+
cause: error,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
checkStep(units, rule, amount.toString());
|
|
45
|
+
if (typeof units !== 'number') throw new NumericRangeError(`${amount.toString()} is too large for a provider amount`);
|
|
46
|
+
return units;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The reverse of `toProviderUnits`, at the currency's own scale. Validates the step. */
|
|
50
|
+
export function fromProviderUnits<const C extends string>(
|
|
51
|
+
units: number | bigint | string,
|
|
52
|
+
currency: CurrencyLike<C>,
|
|
53
|
+
rule: ProviderUnitRule,
|
|
54
|
+
): Money<C> {
|
|
55
|
+
const target = toCurrency(currency);
|
|
56
|
+
const raw = Money.fromMinor(units, target, rule.scale);
|
|
57
|
+
checkStep(raw.units, rule, `The provider amount ${String(units)}`);
|
|
58
|
+
if (rule.scale <= target.scale) return raw.toScale(target.scale);
|
|
59
|
+
try {
|
|
60
|
+
return raw.toScale(target.scale);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof RoundingNecessaryError)) throw error;
|
|
63
|
+
throw new ProviderAmountError(`The provider amount ${String(units)} has a fraction ${target.code} does not have`, {
|
|
64
|
+
cause: error,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/rational.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact in-between money. A `RationalMoney` is a fraction of major units plus
|
|
3
|
+
* a currency, so a chain of steps (split a third, add tax, convert) never
|
|
4
|
+
* rounds until you settle it with `toMoney`, and the currency travels with
|
|
5
|
+
* the value the whole way: EUR and USD still cannot be mixed.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const share = RationalMoney.from(money(100, 'EUR')).dividedBy(3).times('1.19');
|
|
9
|
+
* share.toString(); // '119/3 EUR'
|
|
10
|
+
* share.toMoney({ rounding: 'halfEven' }); // 39.67 EUR, rounded once
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Rational } from '@arki/math/rational';
|
|
15
|
+
import { InvalidArgumentError } from '@arki/math/errors';
|
|
16
|
+
import { ratio } from '@arki/math/rational';
|
|
17
|
+
|
|
18
|
+
import type { Currency, CurrencyLike } from './currency.js';
|
|
19
|
+
import type { Amount, FactorInput, MoneyJSON, MoneyOptions } from './money.js';
|
|
20
|
+
import { assertSameCurrency, resolveCurrency, toCurrency } from './currency.js';
|
|
21
|
+
import { Money, toFraction } from './money.js';
|
|
22
|
+
|
|
23
|
+
const BRAND = Symbol('RationalMoney');
|
|
24
|
+
|
|
25
|
+
/** Something with an exact value in a currency: a `Money` or a `RationalMoney`. */
|
|
26
|
+
export type ExactMoney<C extends string = string> = Money<C> | RationalMoney<C>;
|
|
27
|
+
|
|
28
|
+
function fraction(value: FactorInput): Rational {
|
|
29
|
+
const [numerator, denominator] = toFraction(value);
|
|
30
|
+
return ratio(BigInt(numerator), BigInt(denominator));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toRationalAmount(value: ExactMoney): Rational {
|
|
34
|
+
return value instanceof RationalMoney ? value.amount : ratio(BigInt(value.units), 10n ** BigInt(value.scale));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function refuseCoercion(): never {
|
|
38
|
+
throw new TypeError('RationalMoney cannot be turned into a number implicitly; settle it with toMoney()');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An immutable exact amount: a fraction of major units in one currency. */
|
|
42
|
+
export class RationalMoney<C extends string = string> {
|
|
43
|
+
/** Major units as a reduced fraction. */
|
|
44
|
+
readonly amount: Rational;
|
|
45
|
+
readonly currency: Currency<C>;
|
|
46
|
+
|
|
47
|
+
private constructor(amount: Rational, currency: Currency<C>, brand: typeof BRAND) {
|
|
48
|
+
if (brand !== BRAND) throw new TypeError('Use RationalMoney.of() or RationalMoney.from() instead of new');
|
|
49
|
+
this.amount = amount;
|
|
50
|
+
this.currency = currency;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `RationalMoney.of('1/3', 'EUR')`, or any `Amount` (`'19.99'`, `ratio(1, 3)`). Never rounds. */
|
|
54
|
+
static of<const C extends string>(amount: Amount | ExactMoney, currency: CurrencyLike<C>): RationalMoney<C> {
|
|
55
|
+
const cur = toCurrency(currency);
|
|
56
|
+
if (amount instanceof Money || amount instanceof RationalMoney) {
|
|
57
|
+
assertSameCurrency(amount.currency, cur);
|
|
58
|
+
return new RationalMoney(toRationalAmount(amount), cur, BRAND);
|
|
59
|
+
}
|
|
60
|
+
if (typeof amount === 'string' && amount.includes('/')) return new RationalMoney(ratio(amount), cur, BRAND);
|
|
61
|
+
return new RationalMoney(fraction(amount), cur, BRAND);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The exact value of a `Money`. */
|
|
65
|
+
static from<C extends string>(value: Money<C>): RationalMoney<C> {
|
|
66
|
+
return new RationalMoney(toRationalAmount(value), value.currency, BRAND);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Zero in this currency. */
|
|
70
|
+
static zero<const C extends string>(currency: CurrencyLike<C>): RationalMoney<C> {
|
|
71
|
+
return new RationalMoney(ratio(0), toCurrency(currency), BRAND);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reads `{ amount: '1/3', currency: 'EUR' }`, as written by `toJSON`. */
|
|
75
|
+
static fromJSON(value: unknown, currencies?: readonly Currency[]): RationalMoney {
|
|
76
|
+
if (typeof value !== 'object' || value === null || !('amount' in value) || !('currency' in value)) {
|
|
77
|
+
throw new InvalidArgumentError('Expected { amount: string, currency: string }');
|
|
78
|
+
}
|
|
79
|
+
const { amount, currency } = value;
|
|
80
|
+
if (typeof amount !== 'string' || typeof currency !== 'string') {
|
|
81
|
+
throw new InvalidArgumentError('Expected { amount: string, currency: string }');
|
|
82
|
+
}
|
|
83
|
+
return RationalMoney.of(amount, resolveCurrency(currency, currencies));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @internal */
|
|
87
|
+
static create<C extends string>(amount: Rational, currency: Currency<C>): RationalMoney<C> {
|
|
88
|
+
return new RationalMoney(amount, currency, BRAND);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Arithmetic: always exact ------------------------------------------------
|
|
92
|
+
|
|
93
|
+
plus(other: ExactMoney<C>): RationalMoney<C> {
|
|
94
|
+
assertSameCurrency(this.currency, other.currency);
|
|
95
|
+
return new RationalMoney(this.amount.plus(toRationalAmount(other)), this.currency, BRAND);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
minus(other: ExactMoney<C>): RationalMoney<C> {
|
|
99
|
+
assertSameCurrency(this.currency, other.currency);
|
|
100
|
+
return new RationalMoney(this.amount.minus(toRationalAmount(other)), this.currency, BRAND);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
times(multiplier: FactorInput): RationalMoney<C> {
|
|
104
|
+
return new RationalMoney(this.amount.times(fraction(multiplier)), this.currency, BRAND);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Zero throws `DivisionByZeroError`. */
|
|
108
|
+
dividedBy(divisor: FactorInput): RationalMoney<C> {
|
|
109
|
+
return new RationalMoney(this.amount.dividedBy(fraction(divisor)), this.currency, BRAND);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `percentage(15)` is `times(15 / 100)`. */
|
|
113
|
+
percentage(percent: FactorInput): RationalMoney<C> {
|
|
114
|
+
return new RationalMoney(this.amount.times(fraction(percent)).dividedBy(100), this.currency, BRAND);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
negated(): RationalMoney<C> {
|
|
118
|
+
return new RationalMoney(this.amount.negated(), this.currency, BRAND);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
abs(): RationalMoney<C> {
|
|
122
|
+
return this.amount.isNegative() ? this.negated() : this;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Comparison --------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
compareTo(other: ExactMoney<C>): -1 | 0 | 1 {
|
|
128
|
+
assertSameCurrency(this.currency, other.currency);
|
|
129
|
+
return this.amount.compareTo(toRationalAmount(other));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
eq(other: ExactMoney<C>): boolean {
|
|
133
|
+
return this.compareTo(other) === 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
lt(other: ExactMoney<C>): boolean {
|
|
137
|
+
return this.compareTo(other) < 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lte(other: ExactMoney<C>): boolean {
|
|
141
|
+
return this.compareTo(other) <= 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
gt(other: ExactMoney<C>): boolean {
|
|
145
|
+
return this.compareTo(other) > 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
gte(other: ExactMoney<C>): boolean {
|
|
149
|
+
return this.compareTo(other) >= 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isZero(): boolean {
|
|
153
|
+
return this.amount.isZero();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
isPositive(): boolean {
|
|
157
|
+
return this.amount.isPositive();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isNegative(): boolean {
|
|
161
|
+
return this.amount.isNegative();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sign(): -1 | 0 | 1 {
|
|
165
|
+
return this.amount.sign();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Settling and output -----------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rounds once to a `Money` at the currency's scale (or `options.scale`).
|
|
172
|
+
* An inexact value needs `options.rounding`: `'1/3 EUR'` throws without it.
|
|
173
|
+
*/
|
|
174
|
+
toMoney(options?: MoneyOptions): Money<C> {
|
|
175
|
+
return Money.of(this.amount, this.currency, options);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `'1/3 EUR'`, `'5 EUR'`. */
|
|
179
|
+
toString(): string {
|
|
180
|
+
return `${this.amount.toString()} ${this.currency.code}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `{ amount: '1/3', currency: 'EUR' }`. */
|
|
184
|
+
toJSON(): MoneyJSON {
|
|
185
|
+
return { amount: this.amount.toString(), currency: this.currency.code };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Throws. Settle with `toMoney()` first. */
|
|
189
|
+
valueOf(): never {
|
|
190
|
+
return refuseCoercion();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
[Symbol.toPrimitive](hint: string): string {
|
|
194
|
+
return hint === 'string' ? this.toString() : refuseCoercion();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A getter lives on the prototype; a field would add a slot to every value.
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
|
|
199
|
+
get [Symbol.toStringTag](): string {
|
|
200
|
+
return 'RationalMoney';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
204
|
+
return `RationalMoney(${this.toString()})`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
[Symbol.for('Deno.customInspect')](): string {
|
|
208
|
+
return `RationalMoney(${this.toString()})`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isRationalMoney(value: unknown): value is RationalMoney {
|
|
213
|
+
return value instanceof RationalMoney;
|
|
214
|
+
}
|
package/src/stripe.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe amounts. Stripe sends integers in its own units, which differ from
|
|
3
|
+
* ISO 4217 for some currencies. Rules from docs.stripe.com/currencies, read
|
|
4
|
+
* on 2026-10-12:
|
|
5
|
+
*
|
|
6
|
+
* - Zero-decimal: BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF VND VUV XAF XOF XPF
|
|
7
|
+
* (`500` is 500 JPY). MGA has 2 decimals in ISO but 0 at Stripe.
|
|
8
|
+
* - ISK and UGX: sent with two decimals that are always `00` (`500` is 5 ISK).
|
|
9
|
+
* - HUF and TWD: two-decimal charges; payouts must be divisible by 100.
|
|
10
|
+
* - Other currencies with 2 ISO decimals: two decimals.
|
|
11
|
+
*
|
|
12
|
+
* Stripe documents no rule for currencies with 3 or 4 ISO decimals, so those
|
|
13
|
+
* throw `ProviderAmountError` instead of being guessed. Whether your account
|
|
14
|
+
* can charge a currency, and its minimum and maximum amounts, are Stripe's to
|
|
15
|
+
* enforce; this module only gets the units right.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Currency, CurrencyLike } from './currency.js';
|
|
19
|
+
import type { Money } from './money.js';
|
|
20
|
+
import type { ProviderUnitRule } from './provider.js';
|
|
21
|
+
import { toCurrency } from './currency.js';
|
|
22
|
+
import { ProviderAmountError } from './errors.js';
|
|
23
|
+
import { fromProviderUnits, toProviderUnits } from './provider.js';
|
|
24
|
+
|
|
25
|
+
/** When Stripe's rules in this module were last checked. */
|
|
26
|
+
export const STRIPE_RULES_VERSION = '2026-10-12';
|
|
27
|
+
|
|
28
|
+
export type StripeOperation = 'charge' | 'payout';
|
|
29
|
+
|
|
30
|
+
const ZERO_DECIMAL = new Set([
|
|
31
|
+
'BIF',
|
|
32
|
+
'CLP',
|
|
33
|
+
'DJF',
|
|
34
|
+
'GNF',
|
|
35
|
+
'JPY',
|
|
36
|
+
'KMF',
|
|
37
|
+
'KRW',
|
|
38
|
+
'MGA',
|
|
39
|
+
'PYG',
|
|
40
|
+
'RWF',
|
|
41
|
+
'VND',
|
|
42
|
+
'VUV',
|
|
43
|
+
'XAF',
|
|
44
|
+
'XOF',
|
|
45
|
+
'XPF',
|
|
46
|
+
]);
|
|
47
|
+
const WHOLE_UNITS_TWO_DECIMALS = new Set(['ISK', 'UGX']);
|
|
48
|
+
const WHOLE_UNIT_PAYOUTS = new Set(['HUF', 'TWD']);
|
|
49
|
+
|
|
50
|
+
/** Stripe's unit rule for a currency and operation. Throws `ProviderAmountError` where Stripe documents none. */
|
|
51
|
+
export function stripeRule(currency: CurrencyLike, operation: StripeOperation = 'charge'): ProviderUnitRule {
|
|
52
|
+
const target: Currency = toCurrency(currency);
|
|
53
|
+
const code = target.code;
|
|
54
|
+
if (!target.iso) throw new ProviderAmountError(`${code} is a custom currency; Stripe has no rule for it`);
|
|
55
|
+
if (ZERO_DECIMAL.has(code)) return { scale: 0 };
|
|
56
|
+
if (WHOLE_UNITS_TWO_DECIMALS.has(code)) return { scale: 2, step: 100 };
|
|
57
|
+
if (WHOLE_UNIT_PAYOUTS.has(code)) return operation === 'payout' ? { scale: 2, step: 100 } : { scale: 2 };
|
|
58
|
+
if (target.scale === 2) return { scale: 2 };
|
|
59
|
+
throw new ProviderAmountError(`Stripe documents no amount rule for ${code} (${target.scale} decimals in ISO 4217)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `toStripeAmount(money('10.99', 'USD'))` → `1099`; `money(5, 'ISK')` → `500`.
|
|
64
|
+
* Throws `ProviderAmountError` for a negative amount or one Stripe cannot express (`10.50 MGA`).
|
|
65
|
+
*/
|
|
66
|
+
export function toStripeAmount(amount: Money, operation: StripeOperation = 'charge'): number {
|
|
67
|
+
if (amount.isNegative()) throw new ProviderAmountError('Stripe amounts are never negative');
|
|
68
|
+
return toProviderUnits(amount, stripeRule(amount.currency, operation));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `fromStripeAmount(1099, 'usd')` → `10.99 USD`. Accepts Stripe's lower-case codes. */
|
|
72
|
+
export function fromStripeAmount(
|
|
73
|
+
amount: number | bigint | string,
|
|
74
|
+
currency: string | Currency,
|
|
75
|
+
operation: StripeOperation = 'charge',
|
|
76
|
+
): Money {
|
|
77
|
+
const target = typeof currency === 'string' ? toCurrency(currency.toUpperCase()) : currency;
|
|
78
|
+
return fromProviderUnits(amount, target, stripeRule(target, operation));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The `{ amount, currency }` pair a Stripe request takes: `{ amount: 1099, currency: 'usd' }`. */
|
|
82
|
+
export function toStripe(amount: Money, operation: StripeOperation = 'charge'): { amount: number; currency: string } {
|
|
83
|
+
return { amount: toStripeAmount(amount, operation), currency: amount.currency.code.toLowerCase() };
|
|
84
|
+
}
|