@ducatjs/money 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.md +617 -2
- package/assets/CREDITS.md +12 -0
- package/data/adyen-fx.json +19 -0
- package/data/adyen.json +171 -0
- package/data/braintree.json +223 -0
- package/data/cash-rounding.json +399 -0
- package/data/checkout.json +230 -0
- package/data/iso4217-list-one.xml +1956 -0
- package/data/klarna.json +95 -0
- package/data/paypal.json +65 -0
- package/data/stripe-fx.json +17 -0
- package/data/stripe.json +285 -0
- package/dist/adyen-fx.d.ts +25 -0
- package/dist/adyen-fx.d.ts.map +1 -0
- package/dist/adyen-fx.js +122 -0
- package/dist/adyen-fx.js.map +1 -0
- package/dist/adyen.d.ts +25 -0
- package/dist/adyen.d.ts.map +1 -0
- package/dist/adyen.js +34 -0
- package/dist/adyen.js.map +1 -0
- package/dist/allocate.d.ts +72 -0
- package/dist/allocate.d.ts.map +1 -0
- package/dist/allocate.js +314 -0
- package/dist/allocate.js.map +1 -0
- package/dist/bag.d.ts +58 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +122 -0
- package/dist/bag.js.map +1 -0
- package/dist/braintree.d.ts +34 -0
- package/dist/braintree.d.ts.map +1 -0
- package/dist/braintree.js +45 -0
- package/dist/braintree.js.map +1 -0
- package/dist/cash.d.ts +35 -0
- package/dist/cash.d.ts.map +1 -0
- package/dist/cash.js +86 -0
- package/dist/cash.js.map +1 -0
- package/dist/checkout.d.ts +42 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +52 -0
- package/dist/checkout.js.map +1 -0
- package/dist/convert.d.ts +51 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +55 -0
- package/dist/convert.js.map +1 -0
- package/dist/currency.d.ts +22 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +38 -0
- package/dist/currency.js.map +1 -0
- package/dist/decimal.d.ts +52 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +251 -0
- package/dist/decimal.js.map +1 -0
- package/dist/ecb.d.ts +17 -0
- package/dist/ecb.d.ts.map +1 -0
- package/dist/ecb.js +147 -0
- package/dist/ecb.js.map +1 -0
- package/dist/exact.d.ts +62 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +147 -0
- package/dist/exact.js.map +1 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +79 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fixed.d.ts +121 -0
- package/dist/fixed.d.ts.map +1 -0
- package/dist/fixed.js +420 -0
- package/dist/fixed.js.map +1 -0
- package/dist/fn.d.ts +27 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +30 -0
- package/dist/fn.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +90 -0
- package/dist/format.js.map +1 -0
- package/dist/generated/adyen.d.ts +3 -0
- package/dist/generated/adyen.d.ts.map +1 -0
- package/dist/generated/adyen.js +144 -0
- package/dist/generated/adyen.js.map +1 -0
- package/dist/generated/braintree.d.ts +3 -0
- package/dist/generated/braintree.d.ts.map +1 -0
- package/dist/generated/braintree.js +140 -0
- package/dist/generated/braintree.js.map +1 -0
- package/dist/generated/cash.d.ts +12 -0
- package/dist/generated/cash.d.ts.map +1 -0
- package/dist/generated/cash.js +364 -0
- package/dist/generated/cash.js.map +1 -0
- package/dist/generated/checkout.d.ts +3 -0
- package/dist/generated/checkout.d.ts.map +1 -0
- package/dist/generated/checkout.js +151 -0
- package/dist/generated/checkout.js.map +1 -0
- package/dist/generated/iso4217.d.ts +174 -0
- package/dist/generated/iso4217.d.ts.map +1 -0
- package/dist/generated/iso4217.js +175 -0
- package/dist/generated/iso4217.js.map +1 -0
- package/dist/generated/kernel.d.ts +17 -0
- package/dist/generated/kernel.d.ts.map +1 -0
- package/dist/generated/kernel.js +208 -0
- package/dist/generated/kernel.js.map +1 -0
- package/dist/generated/klarna.d.ts +3 -0
- package/dist/generated/klarna.d.ts.map +1 -0
- package/dist/generated/klarna.js +21 -0
- package/dist/generated/klarna.js.map +1 -0
- package/dist/generated/paypal.d.ts +3 -0
- package/dist/generated/paypal.d.ts.map +1 -0
- package/dist/generated/paypal.js +31 -0
- package/dist/generated/paypal.js.map +1 -0
- package/dist/generated/stripe.d.ts +8 -0
- package/dist/generated/stripe.d.ts.map +1 -0
- package/dist/generated/stripe.js +150 -0
- package/dist/generated/stripe.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/klarna.d.ts +31 -0
- package/dist/klarna.d.ts.map +1 -0
- package/dist/klarna.js +38 -0
- package/dist/klarna.js.map +1 -0
- package/dist/money.d.ts +218 -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 +11 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +132 -0
- package/dist/parse.js.map +1 -0
- package/dist/paypal.d.ts +41 -0
- package/dist/paypal.d.ts.map +1 -0
- package/dist/paypal.js +58 -0
- package/dist/paypal.js.map +1 -0
- package/dist/plugins/adyen.d.ts +15 -0
- package/dist/plugins/adyen.d.ts.map +1 -0
- package/dist/plugins/adyen.js +10 -0
- package/dist/plugins/adyen.js.map +1 -0
- package/dist/plugins/all.d.ts +22 -0
- package/dist/plugins/all.d.ts.map +1 -0
- package/dist/plugins/all.js +23 -0
- package/dist/plugins/all.js.map +1 -0
- package/dist/plugins/allocate.d.ts +20 -0
- package/dist/plugins/allocate.d.ts.map +1 -0
- package/dist/plugins/allocate.js +26 -0
- package/dist/plugins/allocate.js.map +1 -0
- package/dist/plugins/braintree.d.ts +12 -0
- package/dist/plugins/braintree.d.ts.map +1 -0
- package/dist/plugins/braintree.js +12 -0
- package/dist/plugins/braintree.js.map +1 -0
- package/dist/plugins/cash.d.ts +19 -0
- package/dist/plugins/cash.d.ts.map +1 -0
- package/dist/plugins/cash.js +16 -0
- package/dist/plugins/cash.js.map +1 -0
- package/dist/plugins/checkout.d.ts +21 -0
- package/dist/plugins/checkout.d.ts.map +1 -0
- package/dist/plugins/checkout.js +16 -0
- package/dist/plugins/checkout.js.map +1 -0
- package/dist/plugins/convert.d.ts +20 -0
- package/dist/plugins/convert.d.ts.map +1 -0
- package/dist/plugins/convert.js +13 -0
- package/dist/plugins/convert.js.map +1 -0
- package/dist/plugins/exact.d.ts +15 -0
- package/dist/plugins/exact.d.ts.map +1 -0
- package/dist/plugins/exact.js +13 -0
- package/dist/plugins/exact.js.map +1 -0
- package/dist/plugins/fixed.d.ts +17 -0
- package/dist/plugins/fixed.d.ts.map +1 -0
- package/dist/plugins/fixed.js +13 -0
- package/dist/plugins/fixed.js.map +1 -0
- package/dist/plugins/format.d.ts +9 -0
- package/dist/plugins/format.d.ts.map +1 -0
- package/dist/plugins/format.js +16 -0
- package/dist/plugins/format.js.map +1 -0
- package/dist/plugins/klarna.d.ts +10 -0
- package/dist/plugins/klarna.d.ts.map +1 -0
- package/dist/plugins/klarna.js +10 -0
- package/dist/plugins/klarna.js.map +1 -0
- package/dist/plugins/paypal.d.ts +14 -0
- package/dist/plugins/paypal.d.ts.map +1 -0
- package/dist/plugins/paypal.js +16 -0
- package/dist/plugins/paypal.js.map +1 -0
- package/dist/plugins/prisma.d.ts +8 -0
- package/dist/plugins/prisma.d.ts.map +1 -0
- package/dist/plugins/prisma.js +13 -0
- package/dist/plugins/prisma.js.map +1 -0
- package/dist/plugins/stripe.d.ts +14 -0
- package/dist/plugins/stripe.d.ts.map +1 -0
- package/dist/plugins/stripe.js +16 -0
- package/dist/plugins/stripe.js.map +1 -0
- package/dist/plugins/tax.d.ts +21 -0
- package/dist/plugins/tax.d.ts.map +1 -0
- package/dist/plugins/tax.js +18 -0
- package/dist/plugins/tax.js.map +1 -0
- package/dist/prisma.d.ts +66 -0
- package/dist/prisma.d.ts.map +1 -0
- package/dist/prisma.js +66 -0
- package/dist/prisma.js.map +1 -0
- package/dist/processor.d.ts +28 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +118 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.d.ts +89 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +240 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate.d.ts +119 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +271 -0
- package/dist/rate.js.map +1 -0
- package/dist/rounding-error.d.ts +9 -0
- package/dist/rounding-error.d.ts.map +1 -0
- package/dist/rounding-error.js +9 -0
- package/dist/rounding-error.js.map +1 -0
- package/dist/rounding.d.ts +50 -0
- package/dist/rounding.d.ts.map +1 -0
- package/dist/rounding.js +87 -0
- package/dist/rounding.js.map +1 -0
- package/dist/stripe-fx.d.ts +22 -0
- package/dist/stripe-fx.d.ts.map +1 -0
- package/dist/stripe-fx.js +30 -0
- package/dist/stripe-fx.js.map +1 -0
- package/dist/stripe.d.ts +36 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +39 -0
- package/dist/stripe.js.map +1 -0
- package/dist/tax.d.ts +29 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +39 -0
- package/dist/tax.js.map +1 -0
- package/package.json +171 -4
- package/src/adyen-fx.ts +154 -0
- package/src/adyen.ts +46 -0
- package/src/allocate.ts +393 -0
- package/src/bag.ts +170 -0
- package/src/braintree.ts +71 -0
- package/src/cash.ts +115 -0
- package/src/checkout.ts +77 -0
- package/src/convert.ts +159 -0
- package/src/currency.ts +61 -0
- package/src/decimal.ts +283 -0
- package/src/ecb.ts +162 -0
- package/src/exact.ts +182 -0
- package/src/fetch.ts +127 -0
- package/src/fixed.ts +558 -0
- package/src/fn.ts +80 -0
- package/src/format.ts +106 -0
- package/src/generated/adyen.ts +144 -0
- package/src/generated/braintree.ts +140 -0
- package/src/generated/cash.ts +375 -0
- package/src/generated/checkout.ts +151 -0
- package/src/generated/iso4217.ts +179 -0
- package/src/generated/kernel.ts +230 -0
- package/src/generated/klarna.ts +21 -0
- package/src/generated/paypal.ts +31 -0
- package/src/generated/stripe.ts +150 -0
- package/src/index.ts +37 -0
- package/src/klarna.ts +54 -0
- package/src/money.ts +781 -0
- package/src/parse.ts +144 -0
- package/src/paypal.ts +93 -0
- package/src/plugins/adyen.ts +25 -0
- package/src/plugins/all.ts +47 -0
- package/src/plugins/allocate.ts +53 -0
- package/src/plugins/braintree.ts +23 -0
- package/src/plugins/cash.ts +33 -0
- package/src/plugins/checkout.ts +32 -0
- package/src/plugins/convert.ts +50 -0
- package/src/plugins/exact.ts +23 -0
- package/src/plugins/fixed.ts +28 -0
- package/src/plugins/format.ts +28 -0
- package/src/plugins/klarna.ts +21 -0
- package/src/plugins/paypal.ts +29 -0
- package/src/plugins/prisma.ts +24 -0
- package/src/plugins/stripe.ts +29 -0
- package/src/plugins/tax.ts +41 -0
- package/src/prisma.ts +118 -0
- package/src/processor.ts +181 -0
- package/src/provider.ts +324 -0
- package/src/rate.ts +377 -0
- package/src/rounding-error.ts +8 -0
- package/src/rounding.ts +122 -0
- package/src/stripe-fx.ts +49 -0
- package/src/stripe.ts +69 -0
- package/src/tax.ts +71 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/stripe`: Stripe's minor-unit view. Importing this module adds `toStripe` and
|
|
3
|
+
* `toStripeAmount`.
|
|
4
|
+
*/
|
|
5
|
+
import { extendMoney, type Money } from '../money.js';
|
|
6
|
+
import { toStripe, toStripeAmount, type StripeAmountOptions } from '../stripe.js';
|
|
7
|
+
|
|
8
|
+
export * from '../stripe.js';
|
|
9
|
+
|
|
10
|
+
declare module '../money.js' {
|
|
11
|
+
interface Money<C extends string> {
|
|
12
|
+
/** The integer `amount` to send to Stripe: `money('5', 'ISK').toStripeAmount()` is 500. */
|
|
13
|
+
toStripeAmount(options?: StripeAmountOptions): number;
|
|
14
|
+
/** `{ amount, currency }` with Stripe's lowercase currency code, ready for a PaymentIntent. */
|
|
15
|
+
toStripe(options?: StripeAmountOptions): { amount: number; currency: string };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
extendMoney(
|
|
20
|
+
{
|
|
21
|
+
toStripeAmount(this: Money, options?: StripeAmountOptions) {
|
|
22
|
+
return toStripeAmount(this, options);
|
|
23
|
+
},
|
|
24
|
+
toStripe(this: Money, options?: StripeAmountOptions) {
|
|
25
|
+
return toStripe(this, options);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{ owner: '@ducatjs/money/stripe' },
|
|
29
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/tax`: percentages, discounts and tax breakdowns. Importing this module adds the
|
|
3
|
+
* methods below.
|
|
4
|
+
*/
|
|
5
|
+
import type { Factor } from '../decimal.js';
|
|
6
|
+
import { extendMoney, type Money } from '../money.js';
|
|
7
|
+
import type { RoundingMode } from '../rounding.js';
|
|
8
|
+
import { addTax, discount, extractTax, percentage, type TaxBreakdown } from '../tax.js';
|
|
9
|
+
|
|
10
|
+
export * from '../tax.js';
|
|
11
|
+
|
|
12
|
+
declare module '../money.js' {
|
|
13
|
+
interface Money<C extends string> {
|
|
14
|
+
/** `percent`% of this, e.g. `price.percentage(15, 'halfEven')` */
|
|
15
|
+
percentage(percent: Factor, rounding?: RoundingMode): Money<C>;
|
|
16
|
+
/** this minus `percent`% */
|
|
17
|
+
discount(percent: Factor, rounding?: RoundingMode): Money<C>;
|
|
18
|
+
/** Treats this as net: `{ net, tax, gross }`, net + tax = gross exactly. */
|
|
19
|
+
addTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
|
|
20
|
+
/** Treats this as gross: `{ net, tax, gross }`, net + tax = gross exactly. */
|
|
21
|
+
extractTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
extendMoney(
|
|
26
|
+
{
|
|
27
|
+
percentage(this: Money, percent: Factor, rounding?: RoundingMode) {
|
|
28
|
+
return percentage(this, percent, rounding);
|
|
29
|
+
},
|
|
30
|
+
discount(this: Money, percent: Factor, rounding?: RoundingMode) {
|
|
31
|
+
return discount(this, percent, rounding);
|
|
32
|
+
},
|
|
33
|
+
addTax(this: Money, percent: Factor, rounding?: RoundingMode) {
|
|
34
|
+
return addTax(this, percent, rounding);
|
|
35
|
+
},
|
|
36
|
+
extractTax(this: Money, percent: Factor, rounding?: RoundingMode) {
|
|
37
|
+
return extractTax(this, percent, rounding);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{ owner: '@ducatjs/money/tax' },
|
|
41
|
+
);
|
package/src/prisma.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma / SQL interop with no dependency on @prisma/client (works with any Decimal class that has
|
|
3
|
+
* toFixed(): Prisma.Decimal, decimal.js, big.js).
|
|
4
|
+
*
|
|
5
|
+
* Storage options (see README "Databases"):
|
|
6
|
+
* 1. two columns: amount_minor BIGINT + currency CHAR(3)
|
|
7
|
+
* -> minorMoneyField / toMinorColumns
|
|
8
|
+
* 2. a DECIMAL/NUMERIC column (+ a currency column or constant)
|
|
9
|
+
* -> decimalMoneyField / toDecimalValue
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
fromMinor,
|
|
13
|
+
money,
|
|
14
|
+
toDecimal,
|
|
15
|
+
toMinor,
|
|
16
|
+
type CurrencyInput,
|
|
17
|
+
type Money,
|
|
18
|
+
type MoneyOptions,
|
|
19
|
+
} from './money.js';
|
|
20
|
+
|
|
21
|
+
/** Structural type of Prisma.Decimal / decimal.js / big.js values. */
|
|
22
|
+
export interface DecimalLike {
|
|
23
|
+
toFixed(): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Money from a Decimal (or decimal string) column. Exact; throws if it has more decimals than the
|
|
28
|
+
* currency.
|
|
29
|
+
*/
|
|
30
|
+
export function fromDecimal<C extends CurrencyInput>(
|
|
31
|
+
value: DecimalLike | string,
|
|
32
|
+
currency: C,
|
|
33
|
+
options?: MoneyOptions,
|
|
34
|
+
): Money<C> {
|
|
35
|
+
return money(typeof value === 'string' ? value : value.toFixed(), currency, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A Decimal for a NUMERIC column: toDecimalValue(m, Prisma.Decimal). */
|
|
39
|
+
export function toDecimalValue<D>(money: Money, Decimal: new (value: string) => D): D {
|
|
40
|
+
return new Decimal(toDecimal(money));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Row<A extends string, V> = { [P in A]: V };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Prisma result-extension field that exposes a Money built from `amount_minor` + `currency`
|
|
47
|
+
* columns:
|
|
48
|
+
*
|
|
49
|
+
* const db = new PrismaClient().$extends({
|
|
50
|
+
* result: { order: { total: minorMoneyField('totalMinor', 'currency') } },
|
|
51
|
+
* });
|
|
52
|
+
* (await db.order.findFirstOrThrow()).total // Money
|
|
53
|
+
*/
|
|
54
|
+
export function minorMoneyField<const A extends string, const K extends string>(
|
|
55
|
+
amount: A,
|
|
56
|
+
currency: K,
|
|
57
|
+
) {
|
|
58
|
+
return {
|
|
59
|
+
needs: { [amount]: true, [currency]: true } as { [P in A | K]: true },
|
|
60
|
+
compute: (row: Row<A, bigint | number> & Row<K, string>): Money =>
|
|
61
|
+
fromMinor(row[amount], row[currency]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A Prisma result-extension field for a Decimal column, with the currency taken from another column
|
|
67
|
+
* (`{ currencyField: 'currency' }`) or fixed (`{ currency: 'EUR' }`).
|
|
68
|
+
*/
|
|
69
|
+
export function decimalMoneyField<const A extends string, const K extends string>(
|
|
70
|
+
amount: A,
|
|
71
|
+
currency: { currencyField: K },
|
|
72
|
+
): { needs: { [P in A | K]: true }; compute: (row: Row<A, DecimalLike> & Row<K, string>) => Money };
|
|
73
|
+
export function decimalMoneyField<const A extends string, const C extends CurrencyInput>(
|
|
74
|
+
amount: A,
|
|
75
|
+
currency: { currency: C },
|
|
76
|
+
): { needs: { [P in A]: true }; compute: (row: Row<A, DecimalLike>) => Money<C> };
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
export function decimalMoneyField(
|
|
79
|
+
amount: string,
|
|
80
|
+
currencySource: { currencyField: string } | { currency: string },
|
|
81
|
+
): { needs: Record<string, true>; compute: (row: any) => Money } {
|
|
82
|
+
if ('currencyField' in currencySource) {
|
|
83
|
+
return {
|
|
84
|
+
needs: { [amount]: true, [currencySource.currencyField]: true },
|
|
85
|
+
compute: (row: Record<string, unknown>) =>
|
|
86
|
+
fromDecimal(row[amount] as DecimalLike, row[currencySource.currencyField] as string),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
needs: { [amount]: true },
|
|
91
|
+
compute: (row: Record<string, unknown>) =>
|
|
92
|
+
fromDecimal(row[amount] as DecimalLike, currencySource.currency),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Column values for writes: `data: { ...toMinorColumns(total, 'totalMinor', 'currency') }`.
|
|
98
|
+
* Minor units are a bigint (Prisma BigInt). Throws if the value has a non-standard scale, because
|
|
99
|
+
* the column cannot record it.
|
|
100
|
+
*/
|
|
101
|
+
export function toMinorColumns<const A extends string, const K extends string>(
|
|
102
|
+
money: Money,
|
|
103
|
+
amount: A,
|
|
104
|
+
currency: K,
|
|
105
|
+
): Row<A, bigint> & Row<K, string> {
|
|
106
|
+
return { [amount]: toMinorAtIsoScale(money), [currency]: money.currency } as Row<A, bigint> &
|
|
107
|
+
Row<K, string>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function toMinorAtIsoScale(money: Money): bigint {
|
|
111
|
+
const iso = fromMinor(0, money.currency).scale;
|
|
112
|
+
if (money.scale !== iso) {
|
|
113
|
+
throw new RangeError(
|
|
114
|
+
`${money} has scale ${money.scale}; round() it to ${iso} decimals before storing minor units`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return toMinor(money);
|
|
118
|
+
}
|
package/src/processor.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { currencyExponent } from './currency.js';
|
|
2
|
+
import { formatScaled, parseDecimal, parsePlainDecimal, normalizeMinor } from './decimal.js';
|
|
3
|
+
import { fromMinor, scaleDown, scaleUp, toScale, type CurrencyInput, type Money } from './money.js';
|
|
4
|
+
import {
|
|
5
|
+
divideAndRound,
|
|
6
|
+
divideAndRoundBigint,
|
|
7
|
+
RoundingError,
|
|
8
|
+
type RoundingMode,
|
|
9
|
+
} from './rounding.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which signs a processor field accepts, checked after rounding:
|
|
13
|
+
* - 'any': negative amounts are documented (Klarna's discount order lines);
|
|
14
|
+
* - 'nonNegative': zero is documented but negatives aren't (refunds are sent as positive amounts);
|
|
15
|
+
* - 'positive': the processor rejects zero too (Braintree's transaction amount).
|
|
16
|
+
*/
|
|
17
|
+
export type SignRule = 'any' | 'nonNegative' | 'positive';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rescales Money's minor units to a processor's exponent, then to a multiple of `multiple` minor
|
|
21
|
+
* units (Checkout.com's CLP, Stripe's ISK). Throws a RoundingError naming the processor when
|
|
22
|
+
* precision would be lost and `rounding` is 'unnecessary'.
|
|
23
|
+
*/
|
|
24
|
+
function rescaleForProcessor(
|
|
25
|
+
processor: string,
|
|
26
|
+
money: Money,
|
|
27
|
+
exponent: number,
|
|
28
|
+
multiple: number,
|
|
29
|
+
rounding: RoundingMode,
|
|
30
|
+
): number | bigint {
|
|
31
|
+
// Going up in scale is always exact; going down (e.g. IDR on Adyen) may need rounding.
|
|
32
|
+
let minor: number | bigint;
|
|
33
|
+
try {
|
|
34
|
+
if (exponent >= money.scale) {
|
|
35
|
+
minor = scaleUp(money.minor, exponent - money.scale);
|
|
36
|
+
} else {
|
|
37
|
+
minor = scaleDown(money.minor, money.scale - exponent, rounding);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof RoundingError) {
|
|
41
|
+
throw new RoundingError(
|
|
42
|
+
`${money} has more precision than ${processor} accepts for ${money.currency} ` +
|
|
43
|
+
`(${exponent} decimals); pass a rounding mode`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (multiple === 1) {
|
|
50
|
+
return minor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isMultiple =
|
|
54
|
+
typeof minor === 'number' ? minor % multiple === 0 : minor % BigInt(multiple) === 0n;
|
|
55
|
+
if (isMultiple) {
|
|
56
|
+
return minor;
|
|
57
|
+
}
|
|
58
|
+
if (rounding === 'unnecessary') {
|
|
59
|
+
throw new RoundingError(
|
|
60
|
+
`${processor} requires ${money.currency} amounts to be a multiple of ${multiple} minor units; ` +
|
|
61
|
+
`${money} is not (pass a rounding mode)`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof minor === 'number') {
|
|
66
|
+
return divideAndRound(minor, multiple, rounding) * multiple;
|
|
67
|
+
}
|
|
68
|
+
const bigMultiple = BigInt(multiple);
|
|
69
|
+
return normalizeMinor(divideAndRoundBigint(minor, bigMultiple, rounding) * bigMultiple);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Throws a RangeError when the rounded amount has a sign the processor doesn't accept. */
|
|
73
|
+
function checkSign(processor: string, money: Money, minor: number | bigint, sign: SignRule): void {
|
|
74
|
+
// Checked on the rounded amount, so -0.004 USD rounded to 0 counts as zero, not as negative.
|
|
75
|
+
if (sign === 'any') {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (minor < 0) {
|
|
79
|
+
throw new RangeError(
|
|
80
|
+
`${processor} amounts can't be negative (${money}); send refunds as positive amounts`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (sign === 'positive' && (minor === 0 || minor === 0n)) {
|
|
84
|
+
throw new RangeError(`${processor} amounts must be greater than zero (${money})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @internal Money -> a processor's integer amount at the processor's exponent.
|
|
90
|
+
* Throws a RangeError when the amount doesn't fit in a safe integer or has a sign `sign` rules out.
|
|
91
|
+
*/
|
|
92
|
+
export function toProcessorAmount(
|
|
93
|
+
processor: string,
|
|
94
|
+
money: Money,
|
|
95
|
+
exponent: number,
|
|
96
|
+
multiple: number,
|
|
97
|
+
rounding: RoundingMode,
|
|
98
|
+
sign: SignRule = 'any',
|
|
99
|
+
): number {
|
|
100
|
+
const minor = rescaleForProcessor(processor, money, exponent, multiple, rounding);
|
|
101
|
+
if (typeof minor !== 'number') {
|
|
102
|
+
throw new RangeError(`${money} is too large for ${processor}`);
|
|
103
|
+
}
|
|
104
|
+
checkSign(processor, money, minor, sign);
|
|
105
|
+
|
|
106
|
+
// `+ 0` turns -0 into 0, so a zero amount never serialises as "-0".
|
|
107
|
+
return minor + 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @internal Money -> a processor's decimal string with exactly `exponent` decimals
|
|
112
|
+
* ("10.00", or "1000" for JPY), built from the integer so it never goes through a float.
|
|
113
|
+
*/
|
|
114
|
+
export function toProcessorDecimal(
|
|
115
|
+
processor: string,
|
|
116
|
+
money: Money,
|
|
117
|
+
exponent: number,
|
|
118
|
+
rounding: RoundingMode,
|
|
119
|
+
sign: SignRule = 'any',
|
|
120
|
+
): string {
|
|
121
|
+
const minor = rescaleForProcessor(processor, money, exponent, 1, rounding);
|
|
122
|
+
checkSign(processor, money, minor, sign);
|
|
123
|
+
|
|
124
|
+
// `+ 0` turns -0 into 0, so a zero amount never serialises as "-0".
|
|
125
|
+
return formatScaled(typeof minor === 'number' ? minor + 0 : minor, exponent);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns Money at the currency's ISO scale when that is exact, and otherwise at the scale it
|
|
130
|
+
* came with, so a processor's extra precision (Stripe's ISK 12.34) is kept rather than lost.
|
|
131
|
+
*/
|
|
132
|
+
function toIsoScaleWhenExact<C extends string>(money: Money<C>, currency: C): Money<C> {
|
|
133
|
+
const isoExponent = currencyExponent(currency);
|
|
134
|
+
if (isoExponent >= money.scale) {
|
|
135
|
+
return toScale(money, isoExponent);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
return toScale(money, isoExponent, 'unnecessary');
|
|
140
|
+
} catch {
|
|
141
|
+
return money;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @internal A processor's integer amount -> Money (see `toIsoScaleWhenExact` for the scale). */
|
|
146
|
+
export function fromProcessorAmount<C extends CurrencyInput>(
|
|
147
|
+
processor: string,
|
|
148
|
+
amount: number | bigint,
|
|
149
|
+
currency: C,
|
|
150
|
+
exponent: number,
|
|
151
|
+
): Money<C> {
|
|
152
|
+
if (typeof amount === 'number' && !Number.isSafeInteger(amount)) {
|
|
153
|
+
throw new RangeError(`${processor} amount must be an integer, got ${amount}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return toIsoScaleWhenExact(fromMinor(amount, currency, exponent), currency);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @internal A processor's decimal string -> Money, exactly (never via a float). `pattern` is the
|
|
161
|
+
* processor's documented format and `maxLength` its length limit, if any. The scale follows
|
|
162
|
+
* `toIsoScaleWhenExact`.
|
|
163
|
+
*/
|
|
164
|
+
export function fromProcessorDecimal<C extends CurrencyInput>(
|
|
165
|
+
processor: string,
|
|
166
|
+
value: string,
|
|
167
|
+
currency: C,
|
|
168
|
+
pattern: RegExp,
|
|
169
|
+
maxLength = Infinity,
|
|
170
|
+
): Money<C> {
|
|
171
|
+
if (typeof value !== 'string' || value.length > maxLength || !pattern.test(value)) {
|
|
172
|
+
throw new SyntaxError(
|
|
173
|
+
`${processor} amount must be a decimal string, got ${JSON.stringify(value)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// PayPal's pattern allows a bare fraction (".5"), which the decimal parsers don't.
|
|
178
|
+
const withLeadingZero = value.replace(/^(-?)\./, '$10.');
|
|
179
|
+
const parsed = parsePlainDecimal(withLeadingZero) ?? parseDecimal(withLeadingZero);
|
|
180
|
+
return toIsoScaleWhenExact(fromMinor(parsed.digits, currency, parsed.scale), currency);
|
|
181
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Rate providers: synchronous lookups over rates you already hold, and combinators over them.
|
|
2
|
+
// Loading rates (network, files, databases) is async and stays outside; `snapshots()` is the one
|
|
3
|
+
// async boundary.
|
|
4
|
+
import type { Factor } from './decimal.js';
|
|
5
|
+
import { CurrencyMismatchError } from './money.js';
|
|
6
|
+
import { exchangeRate, isRate, toIsoString, type Rate, type RateInfo } from './rate.js';
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
|
|
10
|
+
/** When a rate is wanted for. */
|
|
11
|
+
export interface RateQuery {
|
|
12
|
+
/** An ISO-8601 date or instant, or a Date. */
|
|
13
|
+
readonly asOf?: string | Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Looks up a rate synchronously. `undefined` when it has none for the pair (or for `asOf`). */
|
|
17
|
+
export interface RateProvider {
|
|
18
|
+
rate<From extends string, To extends string>(
|
|
19
|
+
from: From,
|
|
20
|
+
to: To,
|
|
21
|
+
query?: RateQuery,
|
|
22
|
+
): Rate<From, To> | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A fixed set of rates, as built by rateTable() and returned by the ECB parsers and fetchers. */
|
|
26
|
+
export interface RateTable extends RateProvider {
|
|
27
|
+
readonly rates: readonly Rate[];
|
|
28
|
+
/** The quoted currency, for a one-base table. */
|
|
29
|
+
readonly base?: string;
|
|
30
|
+
/** The table's date, when it has one. */
|
|
31
|
+
readonly asOf?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Thrown when no provider has a rate for the pair. */
|
|
35
|
+
export class RateUnavailableError extends Error {
|
|
36
|
+
readonly from: string;
|
|
37
|
+
readonly to: string;
|
|
38
|
+
readonly asOf: string | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(from: string, to: string, asOf?: string | Date) {
|
|
41
|
+
let when: string | undefined;
|
|
42
|
+
if (asOf !== undefined) {
|
|
43
|
+
when = toIsoString(asOf);
|
|
44
|
+
}
|
|
45
|
+
let message = `No exchange rate ${from}→${to}`;
|
|
46
|
+
if (when !== undefined) {
|
|
47
|
+
message += ` as of ${when}`;
|
|
48
|
+
}
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'RateUnavailableError';
|
|
51
|
+
this.from = from;
|
|
52
|
+
this.to = to;
|
|
53
|
+
this.asOf = when;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The UTC date ('2026-11-06') of a date, an instant (any offset) or a Date. */
|
|
58
|
+
function dayOf(time: string | Date): string {
|
|
59
|
+
return toIsoString(time).slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The rate for the pair, or RateUnavailableError: a lookup that must succeed. */
|
|
63
|
+
export function rateOf<From extends string, To extends string>(
|
|
64
|
+
provider: RateProvider,
|
|
65
|
+
from: From,
|
|
66
|
+
to: To,
|
|
67
|
+
query?: RateQuery,
|
|
68
|
+
): Rate<From, To> {
|
|
69
|
+
const rate = provider.rate(from, to, query);
|
|
70
|
+
if (rate === undefined) {
|
|
71
|
+
throw new RateUnavailableError(from, to, query?.asOf);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A provider is user code: a wrong pair here would produce Money labelled with the wrong
|
|
75
|
+
// currency.
|
|
76
|
+
if (rate.from !== from) {
|
|
77
|
+
throw new CurrencyMismatchError(from, rate.from);
|
|
78
|
+
}
|
|
79
|
+
if (rate.to !== to) {
|
|
80
|
+
throw new CurrencyMismatchError(to, rate.to);
|
|
81
|
+
}
|
|
82
|
+
return rate;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 1:1 for a currency to itself; nothing otherwise. */
|
|
86
|
+
export function identity(): RateProvider {
|
|
87
|
+
return {
|
|
88
|
+
rate(from, to) {
|
|
89
|
+
// Compared as strings: From and To are independent type parameters.
|
|
90
|
+
if ((from as string) !== to) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return exchangeRate(from, to, 1n);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A one-base table: `rates.USD` is the number of USD per 1 `base`. */
|
|
99
|
+
export interface RateTableSpec extends RateInfo {
|
|
100
|
+
readonly base: string;
|
|
101
|
+
readonly rates: Readonly<Record<string, Factor>>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A fixed table of rates: one base currency against many (ECB style), or a list of Rates. Only the
|
|
106
|
+
* pairs given are answered; wrap it in `reversed()` or `via()` for the others.
|
|
107
|
+
*
|
|
108
|
+
* Lookups are by whole UTC day: a query for a day before a rate's asOf gets nothing, and a query at
|
|
109
|
+
* any time on the rate's own day gets the rate, even before its publication time (the ECB publishes
|
|
110
|
+
* at about 15:00 UTC). If that matters, query with the previous day until the table is published.
|
|
111
|
+
*
|
|
112
|
+
* const ecb = rateTable({ base: 'EUR', source: 'ECB', asOf: '2026-11-06', rates: { USD: '1.1165' } });
|
|
113
|
+
*/
|
|
114
|
+
export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
|
|
115
|
+
let rates: Rate[];
|
|
116
|
+
const header: { base?: string; asOf?: string } = {};
|
|
117
|
+
if (isRateList(spec)) {
|
|
118
|
+
rates = [...spec];
|
|
119
|
+
} else {
|
|
120
|
+
rates = Object.entries(spec.rates).map(([to, rate]) => exchangeRate(spec.base, to, rate, spec));
|
|
121
|
+
header.base = spec.base;
|
|
122
|
+
if (spec.asOf !== undefined) {
|
|
123
|
+
header.asOf = toIsoString(spec.asOf);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const byPair = new Map<string, Rate>();
|
|
128
|
+
for (const rate of rates) {
|
|
129
|
+
byPair.set(`${rate.from}/${rate.to}`, rate);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
...header,
|
|
134
|
+
rates,
|
|
135
|
+
rate<From extends string, To extends string>(from: From, to: To, query?: RateQuery) {
|
|
136
|
+
const rate = byPair.get(`${from}/${to}`);
|
|
137
|
+
if (rate === undefined) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const askedBeforeRateExisted =
|
|
141
|
+
query?.asOf !== undefined &&
|
|
142
|
+
rate.asOf !== undefined &&
|
|
143
|
+
dayOf(query.asOf) < dayOf(rate.asOf);
|
|
144
|
+
if (askedBeforeRateExisted) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// The key matched, so the rate's currencies are exactly `from` and `to`.
|
|
148
|
+
return rate as Rate<From, To>;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isRateList(spec: RateTableSpec | readonly Rate[]): spec is readonly Rate[] {
|
|
154
|
+
return Array.isArray(spec) && spec.every(isRate);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Asks each provider in turn; the first with an answer wins. */
|
|
158
|
+
export function chain(...providers: readonly RateProvider[]): RateProvider {
|
|
159
|
+
return {
|
|
160
|
+
rate(from, to, query) {
|
|
161
|
+
for (const provider of providers) {
|
|
162
|
+
const rate = provider.rate(from, to, query);
|
|
163
|
+
if (rate !== undefined) {
|
|
164
|
+
return rate;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Answers to→from lookups with the exact inverse of the provider's from→to rate. */
|
|
173
|
+
export function reversed(provider: RateProvider): RateProvider {
|
|
174
|
+
return {
|
|
175
|
+
rate(from, to, query) {
|
|
176
|
+
return provider.rate(to, from, query)?.inverse();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Triangulates through `base`: from→base, then base→to, composed exactly (converting rounds once).
|
|
183
|
+
* Each leg is looked up directly or as an exact inverse, so an ECB table (EUR→X only) answers X→Y.
|
|
184
|
+
*/
|
|
185
|
+
export function via(base: string, provider: RateProvider): RateProvider {
|
|
186
|
+
function leg<From extends string, To extends string>(
|
|
187
|
+
from: From,
|
|
188
|
+
to: To,
|
|
189
|
+
query?: RateQuery,
|
|
190
|
+
): Rate<From, To> | undefined {
|
|
191
|
+
return provider.rate(from, to, query) ?? provider.rate(to, from, query)?.inverse();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
rate(from, to, query) {
|
|
196
|
+
if ((from as string) === to) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
if (from === base || to === base) {
|
|
200
|
+
return leg(from, to, query);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const toBase = leg(from, base, query);
|
|
204
|
+
if (toBase === undefined) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const fromBase = leg(base, to, query);
|
|
208
|
+
if (fromBase === undefined) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
return toBase.andThen(fromBase);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Options for snapshots(). */
|
|
217
|
+
export interface SnapshotOptions {
|
|
218
|
+
/**
|
|
219
|
+
* How many earlier days to try when a day has no table (weekends, holidays). Default 4, which
|
|
220
|
+
* covers Good Friday to Easter Monday.
|
|
221
|
+
*/
|
|
222
|
+
readonly lookback?: number;
|
|
223
|
+
/** How many loaded days to keep; the least recently used are dropped. Default 32. */
|
|
224
|
+
readonly max?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Dated tables, loaded on demand. */
|
|
228
|
+
export interface Snapshots {
|
|
229
|
+
/** The latest table at or before `asOf` (a date, an instant or a Date), within the lookback. */
|
|
230
|
+
at(asOf: string | Date): Promise<RateProvider>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Dated tables from a loader you supply: `load('2026-11-06')` resolves to that day's table, or to
|
|
235
|
+
* undefined when there is none. Days are UTC days. Loaded tables are cached by day. Misses and
|
|
236
|
+
* failures are not cached, so a table that isn't published yet is tried again next time. A loader
|
|
237
|
+
* that throws fails the lookup rather than falling back to an older day, so an outage doesn't
|
|
238
|
+
* silently become a stale rate. The rates carry the day they're from.
|
|
239
|
+
*
|
|
240
|
+
* const history = snapshots(async (day) => parseEcbXml(await readFile(`ecb/${day}.xml`, 'utf8'))[0]);
|
|
241
|
+
* const fx = await history.at('2026-11-08'); // a Sunday: Friday 2026-11-06's table
|
|
242
|
+
*/
|
|
243
|
+
export function snapshots(
|
|
244
|
+
load: (day: string) => Promise<RateProvider | undefined>,
|
|
245
|
+
options?: SnapshotOptions,
|
|
246
|
+
): Snapshots {
|
|
247
|
+
const lookback = options?.lookback ?? 4;
|
|
248
|
+
const max = options?.max ?? 32;
|
|
249
|
+
if (!Number.isSafeInteger(lookback) || lookback < 0) {
|
|
250
|
+
throw new RangeError(`lookback must be a non-negative integer, got ${lookback}`);
|
|
251
|
+
}
|
|
252
|
+
if (!Number.isSafeInteger(max) || max < 1) {
|
|
253
|
+
throw new RangeError(`max must be a positive integer, got ${max}`);
|
|
254
|
+
}
|
|
255
|
+
// Map iteration order is insertion order, so re-inserting on every use makes it an LRU.
|
|
256
|
+
const cache = new Map<string, Promise<RateProvider | undefined>>();
|
|
257
|
+
|
|
258
|
+
// Only drop the entry this load created: a newer load for the same day may have replaced it.
|
|
259
|
+
function forget(day: string, loading: Promise<RateProvider | undefined>): void {
|
|
260
|
+
if (cache.get(day) === loading) {
|
|
261
|
+
cache.delete(day);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function loadDay(day: string): Promise<RateProvider | undefined> {
|
|
266
|
+
let pending = cache.get(day);
|
|
267
|
+
if (pending === undefined) {
|
|
268
|
+
const loading: Promise<RateProvider | undefined> = load(day).then((table) => {
|
|
269
|
+
if (table === undefined) {
|
|
270
|
+
forget(day, loading);
|
|
271
|
+
}
|
|
272
|
+
return table;
|
|
273
|
+
});
|
|
274
|
+
loading.catch(() => forget(day, loading));
|
|
275
|
+
pending = loading;
|
|
276
|
+
} else {
|
|
277
|
+
cache.delete(day);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
cache.set(day, pending);
|
|
281
|
+
if (cache.size > max) {
|
|
282
|
+
const leastRecentlyUsed = cache.keys().next().value!;
|
|
283
|
+
cache.delete(leastRecentlyUsed);
|
|
284
|
+
}
|
|
285
|
+
return pending;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
async at(asOf) {
|
|
290
|
+
const firstDay = dayOf(asOf);
|
|
291
|
+
const firstDayMs = Date.parse(`${firstDay}T00:00:00Z`);
|
|
292
|
+
if (Number.isNaN(firstDayMs)) {
|
|
293
|
+
throw new RangeError(`Invalid asOf ${String(asOf)}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i <= lookback; i++) {
|
|
297
|
+
const day = new Date(firstDayMs - i * DAY_MS).toISOString().slice(0, 10);
|
|
298
|
+
// Sequential on purpose: stop at the first day that has a table, without loading older
|
|
299
|
+
// days. oxlint-disable-next-line no-await-in-loop
|
|
300
|
+
const table = await loadDay(day);
|
|
301
|
+
if (table !== undefined) {
|
|
302
|
+
return table;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw new RateUnavailableError('*', '*', firstDay);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Snapshots over tables you already hold (e.g. an ECB 90-day history), keyed by their asOf day.
|
|
312
|
+
*
|
|
313
|
+
* const history = snapshotsFrom(parseEcbXml(xml90d));
|
|
314
|
+
* const fx = await history.at('2026-11-08');
|
|
315
|
+
*/
|
|
316
|
+
export function snapshotsFrom(tables: readonly RateTable[], options?: SnapshotOptions): Snapshots {
|
|
317
|
+
const byDay = new Map<string, RateTable>();
|
|
318
|
+
for (const table of tables) {
|
|
319
|
+
if (table.asOf !== undefined) {
|
|
320
|
+
byDay.set(dayOf(table.asOf), table);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return snapshots(async (day) => byDay.get(day), options);
|
|
324
|
+
}
|