@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/exchange.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Currency conversion with exact rates. A rate is a fraction, never a float:
|
|
3
|
+
* `1 EUR = 1.0832 USD` is stored as `677/625`. Inverse and cross
|
|
4
|
+
* rates stay exact, and a conversion rounds once, at the end.
|
|
5
|
+
*
|
|
6
|
+
* There is no network code. Fetch rates however you like (ECB, a bank, your
|
|
7
|
+
* own table), then build rates from them here.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const converter = createConverter(ratesFrom({ base: 'EUR', rates: { USD: '1.0832', GBP: '0.8571' } }), {
|
|
11
|
+
* inverse: true,
|
|
12
|
+
* via: 'EUR',
|
|
13
|
+
* });
|
|
14
|
+
* converter.convert(money(100, 'USD'), 'GBP', { rounding: 'halfEven' }); // USD → EUR → GBP, rounded once
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CurrencyLike } from './currency.js';
|
|
19
|
+
import type { Units } from './internal/units.js';
|
|
20
|
+
import type { Amount, MoneyOptions } from './money.js';
|
|
21
|
+
import { assertSameCurrency, Currency, toCurrency } from './currency.js';
|
|
22
|
+
import { ExchangeRateError } from './errors.js';
|
|
23
|
+
import { divExact, gcdUnits, mulDiv, mulUnits, pow10, quantize } from './internal/units.js';
|
|
24
|
+
import { Money, toFraction } from './money.js';
|
|
25
|
+
|
|
26
|
+
const BRAND = Symbol('ExchangeRate');
|
|
27
|
+
|
|
28
|
+
/** An exact, directed rate: one unit of `base` buys `numerator / denominator` units of `quote`. */
|
|
29
|
+
export class ExchangeRate<B extends string = string, Q extends string = string> {
|
|
30
|
+
readonly base: Currency<B>;
|
|
31
|
+
readonly quote: Currency<Q>;
|
|
32
|
+
/** Positive, in lowest terms with `denominator`. */
|
|
33
|
+
readonly numerator: Units;
|
|
34
|
+
/** Positive. */
|
|
35
|
+
readonly denominator: Units;
|
|
36
|
+
|
|
37
|
+
private constructor(
|
|
38
|
+
base: Currency<B>,
|
|
39
|
+
quote: Currency<Q>,
|
|
40
|
+
numerator: Units,
|
|
41
|
+
denominator: Units,
|
|
42
|
+
brand: typeof BRAND,
|
|
43
|
+
) {
|
|
44
|
+
if (brand !== BRAND) throw new TypeError('Use exchangeRate(base, quote, value) instead of new ExchangeRate()');
|
|
45
|
+
this.base = base;
|
|
46
|
+
this.quote = quote;
|
|
47
|
+
const divisor = gcdUnits(numerator, denominator);
|
|
48
|
+
this.numerator = divExact(numerator, divisor);
|
|
49
|
+
this.denominator = divExact(denominator, divisor);
|
|
50
|
+
Object.freeze(this);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @internal */
|
|
54
|
+
static create<B extends string, Q extends string>(
|
|
55
|
+
base: Currency<B>,
|
|
56
|
+
quote: Currency<Q>,
|
|
57
|
+
numerator: Units,
|
|
58
|
+
denominator: Units,
|
|
59
|
+
): ExchangeRate<B, Q> {
|
|
60
|
+
return new ExchangeRate(base, quote, numerator, denominator, BRAND);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The reverse rate, exact: `EUR/USD 1.0832` → `USD/EUR 625/677`. */
|
|
64
|
+
inverse(): ExchangeRate<Q, B> {
|
|
65
|
+
return new ExchangeRate(this.quote, this.base, this.denominator, this.numerator, BRAND);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Chains two rates, exact: `EUR→USD` then `USD→JPY` gives `EUR→JPY`. */
|
|
69
|
+
chain<R extends string>(next: ExchangeRate<Q, R>): ExchangeRate<B, R> {
|
|
70
|
+
assertSameCurrency(this.quote, next.base);
|
|
71
|
+
return new ExchangeRate(
|
|
72
|
+
this.base,
|
|
73
|
+
next.quote,
|
|
74
|
+
mulUnits(this.numerator, next.numerator),
|
|
75
|
+
mulUnits(this.denominator, next.denominator),
|
|
76
|
+
BRAND,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `true` for the same pair and the same exact value. */
|
|
81
|
+
eq(other: ExchangeRate): boolean {
|
|
82
|
+
return (
|
|
83
|
+
this.base.eq(other.base) &&
|
|
84
|
+
this.quote.eq(other.quote) &&
|
|
85
|
+
this.numerator === other.numerator &&
|
|
86
|
+
this.denominator === other.denominator
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `'EUR/USD 1.0832'`, or `'USD/EUR 625/677'` when the rate has no finite decimal form. */
|
|
91
|
+
toString(): string {
|
|
92
|
+
return `${this.base.code}/${this.quote.code} ${rateText(this.numerator, this.denominator)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
96
|
+
return `ExchangeRate(${this.toString()})`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[Symbol.for('Deno.customInspect')](): string {
|
|
100
|
+
return `ExchangeRate(${this.toString()})`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rateText(numerator: Units, denominator: Units): string {
|
|
105
|
+
// A fraction has a finite decimal form when its denominator has no prime factors other than 2 and 5.
|
|
106
|
+
let rest = BigInt(denominator);
|
|
107
|
+
let twos = 0;
|
|
108
|
+
let fives = 0;
|
|
109
|
+
while (rest % 2n === 0n) {
|
|
110
|
+
rest /= 2n;
|
|
111
|
+
twos++;
|
|
112
|
+
}
|
|
113
|
+
while (rest % 5n === 0n) {
|
|
114
|
+
rest /= 5n;
|
|
115
|
+
fives++;
|
|
116
|
+
}
|
|
117
|
+
if (rest !== 1n) return `${String(numerator)}/${String(denominator)}`;
|
|
118
|
+
const scale = Math.max(twos, fives);
|
|
119
|
+
const digits = String(mulDiv(numerator, pow10(scale), denominator)).padStart(scale + 1, '0');
|
|
120
|
+
return scale === 0 ? digits : `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `exchangeRate('EUR', 'USD', '1.0832')`: one euro buys 1.0832 dollars. The value must be positive. */
|
|
124
|
+
export function exchangeRate<const B extends string, const Q extends string>(
|
|
125
|
+
base: CurrencyLike<B>,
|
|
126
|
+
quote: CurrencyLike<Q>,
|
|
127
|
+
value: Amount,
|
|
128
|
+
): ExchangeRate<B, Q> {
|
|
129
|
+
const from = toCurrency(base);
|
|
130
|
+
const to = toCurrency(quote);
|
|
131
|
+
const [numerator, denominator] = toFraction(value);
|
|
132
|
+
if (!(numerator > 0)) throw new ExchangeRateError(`An exchange rate must be positive (${from.code}/${to.code})`);
|
|
133
|
+
if (from.eq(to) && numerator !== denominator) {
|
|
134
|
+
throw new ExchangeRateError(`A rate from ${from.code} to itself must be 1`);
|
|
135
|
+
}
|
|
136
|
+
return ExchangeRate.create(from, to, numerator, denominator);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A table of rates from one base currency, as central banks publish them. */
|
|
140
|
+
export type RateTable = {
|
|
141
|
+
base: CurrencyLike;
|
|
142
|
+
/** Quote code → units of that currency one unit of `base` buys. */
|
|
143
|
+
rates: Readonly<Record<string, Amount>>;
|
|
144
|
+
/** Custom currencies used as quote codes. */
|
|
145
|
+
currencies?: readonly Currency[];
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** `ratesFrom({ base: 'EUR', rates: { USD: '1.0832' } })` → `[EUR/USD 1.0832]`. */
|
|
149
|
+
export function ratesFrom(table: RateTable): ExchangeRate[] {
|
|
150
|
+
const base = toCurrency(table.base);
|
|
151
|
+
return Object.entries(table.rates).map(([code, value]) => {
|
|
152
|
+
const quote = table.currencies?.find(currency => currency.code === code) ?? Currency.of(code);
|
|
153
|
+
return exchangeRate(base, quote, value);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Converts with one rate. The rate's base must be the amount's currency. The
|
|
159
|
+
* exact result is rounded once to the target scale (the quote currency's,
|
|
160
|
+
* or `options.scale`); an inexact result needs `options.rounding`.
|
|
161
|
+
*/
|
|
162
|
+
export function convert<B extends string, Q extends string>(
|
|
163
|
+
amount: Money<B>,
|
|
164
|
+
rate: ExchangeRate<NoInfer<B>, Q>,
|
|
165
|
+
options?: MoneyOptions,
|
|
166
|
+
): Money<Q> {
|
|
167
|
+
assertSameCurrency(amount.currency, rate.base);
|
|
168
|
+
const scale = options?.scale ?? rate.quote.scale;
|
|
169
|
+
return Money.fromMinor(convertUnits(amount, rate, scale, options), rate.quote, scale);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The one rounding of a conversion: `units × rate × 10^(target − source scale)`. */
|
|
173
|
+
function convertUnits(amount: Money, rate: ExchangeRate, scale: number, options: MoneyOptions | undefined): Units {
|
|
174
|
+
return mulDiv(
|
|
175
|
+
amount.units,
|
|
176
|
+
mulUnits(rate.numerator, pow10(scale)),
|
|
177
|
+
mulUnits(rate.denominator, pow10(amount.scale)),
|
|
178
|
+
options?.rounding,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ConverterOptions = {
|
|
183
|
+
/** Also use `B→A` reversed when only `A→B` is known. Off by default: a bid rate reversed is not an ask rate. */
|
|
184
|
+
inverse?: boolean;
|
|
185
|
+
/** Route through this currency when no direct rate exists: `USD→GBP` as `USD→EUR→GBP`. */
|
|
186
|
+
via?: CurrencyLike;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** A fixed set of rates with lookups. Build it with `createConverter`. Immutable. */
|
|
190
|
+
export class Converter {
|
|
191
|
+
private readonly rates: ReadonlyMap<string, ExchangeRate>;
|
|
192
|
+
private readonly inverse: boolean;
|
|
193
|
+
private readonly via: Currency | undefined;
|
|
194
|
+
/** Resolved lookups, including misses. Rates never change, so the cache never goes stale. */
|
|
195
|
+
private readonly resolved = new Map<string, ExchangeRate | null>();
|
|
196
|
+
|
|
197
|
+
constructor(rates: Iterable<ExchangeRate>, options: ConverterOptions = {}) {
|
|
198
|
+
const map = new Map<string, ExchangeRate>();
|
|
199
|
+
for (const rate of rates) {
|
|
200
|
+
if (!(rate instanceof ExchangeRate)) throw new TypeError('Expected ExchangeRate values');
|
|
201
|
+
const key = pairKey(rate.base, rate.quote);
|
|
202
|
+
const existing = map.get(key);
|
|
203
|
+
if (existing !== undefined && !existing.eq(rate)) {
|
|
204
|
+
throw new ExchangeRateError(`Two different rates for ${rate.base.code}/${rate.quote.code}`);
|
|
205
|
+
}
|
|
206
|
+
map.set(key, rate);
|
|
207
|
+
}
|
|
208
|
+
this.rates = map;
|
|
209
|
+
this.inverse = options.inverse === true;
|
|
210
|
+
this.via = options.via === undefined ? undefined : toCurrency(options.via);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The exact rate used for `from → to`. Throws `ExchangeRateError` when none can be found. */
|
|
214
|
+
rate<const F extends string, const T extends string>(from: CurrencyLike<F>, to: CurrencyLike<T>): ExchangeRate<F, T> {
|
|
215
|
+
const a = toCurrency(from);
|
|
216
|
+
const b = toCurrency(to);
|
|
217
|
+
const found = this.resolve(a, b);
|
|
218
|
+
return ExchangeRate.create(a, b, found.numerator, found.denominator);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `true` when `rate(from, to)` would succeed. */
|
|
222
|
+
has(from: CurrencyLike, to: CurrencyLike): boolean {
|
|
223
|
+
const a = toCurrency(from);
|
|
224
|
+
const b = toCurrency(to);
|
|
225
|
+
try {
|
|
226
|
+
this.resolve(a, b);
|
|
227
|
+
return true;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof ExchangeRateError) return false;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Converts and rounds once. Same-currency conversion needs no rate and only changes the scale. */
|
|
235
|
+
convert<const T extends string>(amount: Money, to: CurrencyLike<T>, options?: MoneyOptions): Money<T> {
|
|
236
|
+
const target = toCurrency(to);
|
|
237
|
+
if (amount.currency.eq(target)) {
|
|
238
|
+
const scale = options?.scale ?? target.scale;
|
|
239
|
+
return Money.fromMinor(quantize(amount.units, amount.scale, scale, options?.rounding), target, scale);
|
|
240
|
+
}
|
|
241
|
+
const scale = options?.scale ?? target.scale;
|
|
242
|
+
return Money.fromMinor(convertUnits(amount, this.resolve(amount.currency, target), scale, options), target, scale);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private resolve(from: Currency, to: Currency): ExchangeRate {
|
|
246
|
+
const key = pairKey(from, to);
|
|
247
|
+
let found = this.resolved.get(key);
|
|
248
|
+
if (found === undefined) {
|
|
249
|
+
found = this.lookup(from, to) ?? this.lookupVia(from, to) ?? null;
|
|
250
|
+
this.resolved.set(key, found);
|
|
251
|
+
}
|
|
252
|
+
if (found === null) throw new ExchangeRateError(`No exchange rate from ${from.code} to ${to.code}`);
|
|
253
|
+
return found;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private lookup(from: Currency, to: Currency): ExchangeRate | undefined {
|
|
257
|
+
if (from.eq(to)) return ExchangeRate.create(from, to, 1, 1);
|
|
258
|
+
const direct = this.rates.get(pairKey(from, to));
|
|
259
|
+
if (direct !== undefined) return direct;
|
|
260
|
+
if (this.inverse) return this.rates.get(pairKey(to, from))?.inverse();
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private lookupVia(from: Currency, to: Currency): ExchangeRate | undefined {
|
|
265
|
+
const via = this.via;
|
|
266
|
+
if (via === undefined || via.eq(from) || via.eq(to)) return undefined;
|
|
267
|
+
const first = this.lookup(from, via);
|
|
268
|
+
const second = this.lookup(via, to);
|
|
269
|
+
return first === undefined || second === undefined ? undefined : first.chain(second);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pairKey(from: Currency, to: Currency): string {
|
|
274
|
+
return `${from.code}>${to.code}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `createConverter(rates, { inverse: true, via: 'EUR' })`. */
|
|
278
|
+
export function createConverter(rates: Iterable<ExchangeRate>, options?: ConverterOptions): Converter {
|
|
279
|
+
return new Converter(rates, options);
|
|
280
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized display through `Intl.NumberFormat`. The amount reaches `Intl` as
|
|
3
|
+
* exact decimal text, never as a float, so digits past 2^53 survive on runtimes
|
|
4
|
+
* that support string input (ES2023). Where a runtime lacks a feature you asked
|
|
5
|
+
* for (React Native's Hermes on iOS lacks some), the call throws
|
|
6
|
+
* `UnsupportedFeatureError` instead of quietly printing something else.
|
|
7
|
+
*
|
|
8
|
+
* Rounding for display is done exactly by this package, not by `Intl`, so it
|
|
9
|
+
* matches arithmetic rounding everywhere.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RoundingMode } from '@arki/math/rounding';
|
|
13
|
+
import { UnsupportedFeatureError } from '@arki/math/errors';
|
|
14
|
+
|
|
15
|
+
import type { Money } from './money.js';
|
|
16
|
+
|
|
17
|
+
export type FormatOptions = {
|
|
18
|
+
/** BCP 47 locale. Defaults to `'en-US'`; there is no global locale. */
|
|
19
|
+
locale?: string;
|
|
20
|
+
/** How to show the currency. Defaults to `'symbol'` for ISO currencies and `'code'` for custom ones. */
|
|
21
|
+
display?: 'symbol' | 'narrowSymbol' | 'code' | 'name';
|
|
22
|
+
/** `($5.00)` instead of `-$5.00` where the locale has an accounting style. */
|
|
23
|
+
accounting?: boolean;
|
|
24
|
+
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'negative' | 'never';
|
|
25
|
+
/** Defaults to `true`. */
|
|
26
|
+
useGrouping?: boolean;
|
|
27
|
+
/** Decimals to show. Defaults to the amount's scale, so nothing is hidden. */
|
|
28
|
+
fractionDigits?: number;
|
|
29
|
+
/** Needed when `fractionDigits` drops non-zero digits. */
|
|
30
|
+
rounding?: RoundingMode;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MoneyFormatter = {
|
|
34
|
+
format(money: Money): string;
|
|
35
|
+
formatToParts(money: Money): Intl.NumberFormatPart[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CACHE_LIMIT = 64;
|
|
39
|
+
const cache = new Map<string, Intl.NumberFormat>();
|
|
40
|
+
let exactStrings: boolean | undefined;
|
|
41
|
+
|
|
42
|
+
function isNumericText(text: string): text is `${number}` {
|
|
43
|
+
return /^-?\d+(?:\.\d+)?$/.test(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `format('9007199254740993')` keeps every digit (ES2023 string input). */
|
|
47
|
+
function supportsExactStrings(): boolean {
|
|
48
|
+
if (exactStrings === undefined) {
|
|
49
|
+
try {
|
|
50
|
+
exactStrings =
|
|
51
|
+
new Intl.NumberFormat('en-US', { useGrouping: false, maximumFractionDigits: 0 }).format('9007199254740993') ===
|
|
52
|
+
'9007199254740993';
|
|
53
|
+
} catch {
|
|
54
|
+
exactStrings = false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return exactStrings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatterFor(money: Money, options: FormatOptions, digits: number): Intl.NumberFormat {
|
|
61
|
+
const currency = money.currency;
|
|
62
|
+
const locale = options.locale ?? 'en-US';
|
|
63
|
+
const display = options.display ?? (currency.iso ? 'symbol' : 'code');
|
|
64
|
+
if (!currency.iso && display !== 'code') {
|
|
65
|
+
throw new UnsupportedFeatureError(`${currency.code} is a custom currency; only display: 'code' is available`);
|
|
66
|
+
}
|
|
67
|
+
const key = `${locale}|${currency.iso ? currency.code : ''}|${display}|${options.accounting === true ? 1 : 0}|${options.signDisplay ?? ''}|${options.useGrouping === false ? 0 : 1}|${digits}`;
|
|
68
|
+
const cached = cache.get(key);
|
|
69
|
+
if (cached !== undefined) return cached;
|
|
70
|
+
|
|
71
|
+
const intlOptions: Intl.NumberFormatOptions = {
|
|
72
|
+
minimumFractionDigits: digits,
|
|
73
|
+
maximumFractionDigits: digits,
|
|
74
|
+
useGrouping: options.useGrouping !== false,
|
|
75
|
+
};
|
|
76
|
+
if (currency.iso) {
|
|
77
|
+
intlOptions.style = 'currency';
|
|
78
|
+
intlOptions.currency = currency.code;
|
|
79
|
+
intlOptions.currencyDisplay = display;
|
|
80
|
+
if (options.accounting === true) intlOptions.currencySign = 'accounting';
|
|
81
|
+
}
|
|
82
|
+
if (options.signDisplay !== undefined) intlOptions.signDisplay = options.signDisplay;
|
|
83
|
+
|
|
84
|
+
let formatter: Intl.NumberFormat;
|
|
85
|
+
try {
|
|
86
|
+
formatter = new Intl.NumberFormat(locale, intlOptions);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new UnsupportedFeatureError(`This runtime's Intl cannot format with these options`, { cause: error });
|
|
89
|
+
}
|
|
90
|
+
const resolved = formatter.resolvedOptions();
|
|
91
|
+
if (options.accounting === true && resolved.currencySign !== 'accounting') {
|
|
92
|
+
throw new UnsupportedFeatureError('This runtime does not support accounting currency format');
|
|
93
|
+
}
|
|
94
|
+
if (options.signDisplay !== undefined && resolved.signDisplay !== options.signDisplay) {
|
|
95
|
+
throw new UnsupportedFeatureError(`This runtime does not support signDisplay: '${options.signDisplay}'`);
|
|
96
|
+
}
|
|
97
|
+
if (resolved.maximumFractionDigits !== digits) {
|
|
98
|
+
throw new UnsupportedFeatureError(`This runtime cannot show ${digits} decimals`);
|
|
99
|
+
}
|
|
100
|
+
if (cache.size >= CACHE_LIMIT) cache.clear();
|
|
101
|
+
cache.set(key, formatter);
|
|
102
|
+
return formatter;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function prepare(money: Money, options: FormatOptions): [Intl.NumberFormat, `${number}` | number] {
|
|
106
|
+
const digits = options.fractionDigits ?? money.scale;
|
|
107
|
+
const shown = digits === money.scale ? money : money.toScale(digits, options.rounding);
|
|
108
|
+
const formatter = formatterFor(shown, options, digits);
|
|
109
|
+
const text = shown.toDecimalString();
|
|
110
|
+
if (!isNumericText(text)) throw new TypeError(`Unexpected decimal text ${text}`);
|
|
111
|
+
if (supportsExactStrings()) return [formatter, text];
|
|
112
|
+
// Without string input, Intl converts to a double; up to 15 significant digits survive that exactly.
|
|
113
|
+
if (text.replaceAll(/[-.]/g, '').replace(/^0+/, '').length > 15) {
|
|
114
|
+
throw new UnsupportedFeatureError('This runtime cannot format more than 15 significant digits exactly');
|
|
115
|
+
}
|
|
116
|
+
return [formatter, Number(text)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `formatMoney(money('1234.5', 'EUR'), { locale: 'de-DE' })` → `'1.234,50 €'`.
|
|
121
|
+
* Custom currencies print the number and the code: `'0.00012000 BTC'`.
|
|
122
|
+
*/
|
|
123
|
+
export function formatMoney(money: Money, options: FormatOptions = {}): string {
|
|
124
|
+
const [formatter, value] = prepare(money, options);
|
|
125
|
+
const text = formatter.format(value);
|
|
126
|
+
return money.currency.iso ? text : `${text} ${money.currency.code}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The parts `Intl` produces (currency, integer, group, decimal, fraction, ...), for custom rendering. */
|
|
130
|
+
export function formatMoneyToParts(money: Money, options: FormatOptions = {}): Intl.NumberFormatPart[] {
|
|
131
|
+
const [formatter, value] = prepare(money, options);
|
|
132
|
+
if (typeof formatter.formatToParts !== 'function') {
|
|
133
|
+
throw new UnsupportedFeatureError('This runtime does not support Intl.NumberFormat#formatToParts');
|
|
134
|
+
}
|
|
135
|
+
const parts = formatter.formatToParts(value);
|
|
136
|
+
if (!money.currency.iso)
|
|
137
|
+
parts.push({ type: 'literal', value: ' ' }, { type: 'currency', value: money.currency.code });
|
|
138
|
+
return parts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Binds options once for loops: `const eur = createMoneyFormatter({ locale: 'de-DE' }); eur.format(price)`. */
|
|
142
|
+
export function createMoneyFormatter(options: FormatOptions = {}): MoneyFormatter {
|
|
143
|
+
const bound = { ...options };
|
|
144
|
+
return {
|
|
145
|
+
format: money => formatMoney(money, bound),
|
|
146
|
+
formatToParts: money => formatMoneyToParts(money, bound),
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { Currency, ISO_4217_VERSION } from './currency.js';
|
|
2
|
+
export type { CurrencyDefinition, CurrencyLike } from './currency.js';
|
|
3
|
+
export { Factor, Money, average, factor, isMoney, max, min, money, sum } from './money.js';
|
|
4
|
+
export type {
|
|
5
|
+
AllocateOptions,
|
|
6
|
+
Amount,
|
|
7
|
+
DecimalLike,
|
|
8
|
+
FactorInput,
|
|
9
|
+
MoneyJSON,
|
|
10
|
+
MoneyOptions,
|
|
11
|
+
RationalLike,
|
|
12
|
+
RemainderPolicy,
|
|
13
|
+
Units,
|
|
14
|
+
} from './money.js';
|
|
15
|
+
export * from './errors.js';
|
|
16
|
+
export type { RoundingMode } from '@arki/math/rounding';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value conversions behind the Drizzle columns, shared by every SQL
|
|
3
|
+
* dialect. Each dialect file only says which column type to declare.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { InvalidArgumentError, NumericRangeError } from '@arki/math/errors';
|
|
7
|
+
|
|
8
|
+
import type { Currency } from '../currency.js';
|
|
9
|
+
import type { Money } from '../money.js';
|
|
10
|
+
import type { Units } from './units.js';
|
|
11
|
+
import { assertSameCurrency, resolveCurrency } from '../currency.js';
|
|
12
|
+
import { Money as MoneyClass } from '../money.js';
|
|
13
|
+
import { quantize } from './units.js';
|
|
14
|
+
|
|
15
|
+
/** What drivers hand back for an integer column. */
|
|
16
|
+
export type IntegerDriverValue = number | bigint | string;
|
|
17
|
+
|
|
18
|
+
/** Minor units at the column's scale, in canonical form. Throws if the value is in another currency or needs rounding. */
|
|
19
|
+
export function minorToDriver(value: Money, currency: Currency, scale: number): Units {
|
|
20
|
+
assertSameCurrency(value.currency, currency);
|
|
21
|
+
return quantize(value.units, value.scale, scale);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads an integer column. A `number` that is not a safe integer was already
|
|
26
|
+
* rounded by the driver, so it throws instead of becoming a wrong amount.
|
|
27
|
+
*/
|
|
28
|
+
export function minorFromDriver<C extends string>(
|
|
29
|
+
value: IntegerDriverValue,
|
|
30
|
+
currency: Currency<C>,
|
|
31
|
+
scale: number,
|
|
32
|
+
): Money<C> {
|
|
33
|
+
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
|
|
34
|
+
throw new NumericRangeError(
|
|
35
|
+
`The database driver returned ${value}, which a JavaScript number cannot hold exactly; ` +
|
|
36
|
+
'configure the driver to return big integers as strings or bigint',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return MoneyClass.fromMinor(
|
|
40
|
+
typeof value === 'number' || typeof value === 'bigint' ? value : String(value),
|
|
41
|
+
currency,
|
|
42
|
+
scale,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Exact decimal text at the column's scale. */
|
|
47
|
+
export function decimalToDriver(value: Money, currency: Currency, scale: number): string {
|
|
48
|
+
assertSameCurrency(value.currency, currency);
|
|
49
|
+
return value.toScale(scale).toDecimalString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reads decimal text (or a driver's number for a whole value) at the column's scale. */
|
|
53
|
+
export function decimalFromDriver<C extends string>(
|
|
54
|
+
value: string | number,
|
|
55
|
+
currency: Currency<C>,
|
|
56
|
+
scale: number,
|
|
57
|
+
): Money<C> {
|
|
58
|
+
return MoneyClass.of(typeof value === 'number' ? value : String(value), currency, { scale });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `{ amount, currency }` as JSON text. */
|
|
62
|
+
export function jsonToDriver(value: Money): string {
|
|
63
|
+
return JSON.stringify(value.toJSON());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reads JSON text, or an object for drivers that parse JSON columns themselves. */
|
|
67
|
+
export function jsonFromDriver(value: unknown, currencies: readonly Currency[] | undefined): Money {
|
|
68
|
+
const parsed: unknown = typeof value === 'string' ? JSON.parse(value) : value;
|
|
69
|
+
if (typeof parsed !== 'object' || parsed === null || !('currency' in parsed) || typeof parsed.currency !== 'string') {
|
|
70
|
+
throw new InvalidArgumentError('A money JSON column holds { amount, currency }');
|
|
71
|
+
}
|
|
72
|
+
resolveCurrency(parsed.currency, currencies);
|
|
73
|
+
return MoneyClass.fromJSON(parsed, currencies);
|
|
74
|
+
}
|