@ducatjs/money 0.0.0 → 0.2.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 +1034 -3
- 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 +520 -0
- package/data/checkout.json +230 -0
- package/data/cldr-cash.json +101 -0
- package/data/historic.json +78 -0
- package/data/iso4217-list-one.xml +1956 -0
- package/data/iso4217-list-three.xml +1185 -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 +140 -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/boc.d.ts +31 -0
- package/dist/boc.d.ts.map +1 -0
- package/dist/boc.js +114 -0
- package/dist/boc.js.map +1 -0
- package/dist/boe.d.ts +33 -0
- package/dist/boe.d.ts.map +1 -0
- package/dist/boe.js +114 -0
- package/dist/boe.js.map +1 -0
- package/dist/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 +64 -0
- package/dist/cash.d.ts.map +1 -0
- package/dist/cash.js +134 -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 +52 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +65 -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/drizzle/mysql.d.ts +105 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +60 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +103 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +60 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +102 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +59 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/ecb.d.ts +52 -0
- package/dist/ecb.d.ts.map +1 -0
- package/dist/ecb.js +285 -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/fed.d.ts +35 -0
- package/dist/fed.d.ts.map +1 -0
- package/dist/fed.js +134 -0
- package/dist/fed.js.map +1 -0
- package/dist/fetch.d.ts +103 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +222 -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 +34 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +37 -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 +14 -0
- package/dist/generated/cash.d.ts.map +1 -0
- package/dist/generated/cash.js +465 -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/historic.d.ts +337 -0
- package/dist/generated/historic.d.ts.map +1 -0
- package/dist/generated/historic.js +66 -0
- package/dist/generated/historic.js.map +1 -0
- package/dist/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/historic.d.ts +40 -0
- package/dist/historic.d.ts.map +1 -0
- package/dist/historic.js +43 -0
- package/dist/historic.js.map +1 -0
- package/dist/history.d.ts +96 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +213 -0
- package/dist/history.js.map +1 -0
- package/dist/hmrc.d.ts +28 -0
- package/dist/hmrc.d.ts.map +1 -0
- package/dist/hmrc.js +108 -0
- package/dist/hmrc.js.map +1 -0
- package/dist/index.d.ts +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/kysely.d.ts +120 -0
- package/dist/kysely.d.ts.map +1 -0
- package/dist/kysely.js +309 -0
- package/dist/kysely.js.map +1 -0
- package/dist/money.d.ts +302 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +703 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +55 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +419 -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/pg.d.ts +100 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +343 -0
- package/dist/pg.js.map +1 -0
- package/dist/plugins/adyen.d.ts +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 +29 -0
- package/dist/plugins/all.d.ts.map +1 -0
- package/dist/plugins/all.js +30 -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 +19 -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 +20 -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/policy.d.ts +34 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +90 -0
- package/dist/policy.js.map +1 -0
- package/dist/prisma.d.ts +77 -0
- package/dist/prisma.d.ts.map +1 -0
- package/dist/prisma.js +76 -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 +103 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +280 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate-text.d.ts +38 -0
- package/dist/rate-text.d.ts.map +1 -0
- package/dist/rate-text.js +143 -0
- package/dist/rate-text.js.map +1 -0
- package/dist/rate.d.ts +175 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +371 -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/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +54 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +131 -0
- package/dist/sql.js.map +1 -0
- package/dist/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 +33 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +55 -0
- package/dist/tax.js.map +1 -0
- package/dist/testing/arbitraries.d.ts +43 -0
- package/dist/testing/arbitraries.d.ts.map +1 -0
- package/dist/testing/arbitraries.js +98 -0
- package/dist/testing/arbitraries.js.map +1 -0
- package/dist/testing/index.d.ts +67 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +159 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/jest.d.ts +2 -0
- package/dist/testing/jest.d.ts.map +1 -0
- package/dist/testing/jest.js +15 -0
- package/dist/testing/jest.js.map +1 -0
- package/dist/testing/vitest.d.ts +7 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +12 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/valibot.d.ts +33 -0
- package/dist/valibot.d.ts.map +1 -0
- package/dist/valibot.js +61 -0
- package/dist/valibot.js.map +1 -0
- package/dist/zod.d.ts +36 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +58 -0
- package/dist/zod.js.map +1 -0
- package/package.json +280 -5
- package/src/adyen-fx.ts +175 -0
- package/src/adyen.ts +46 -0
- package/src/allocate.ts +393 -0
- package/src/bag.ts +170 -0
- package/src/boc.ts +136 -0
- package/src/boe.ts +131 -0
- package/src/braintree.ts +71 -0
- package/src/cash.ts +183 -0
- package/src/checkout.ts +77 -0
- package/src/convert.ts +177 -0
- package/src/currency.ts +61 -0
- package/src/decimal.ts +283 -0
- package/src/drizzle/mysql.ts +78 -0
- package/src/drizzle/pg.ts +78 -0
- package/src/drizzle/sqlite.ts +70 -0
- package/src/ecb.ts +328 -0
- package/src/exact.ts +182 -0
- package/src/fed.ts +146 -0
- package/src/fetch.ts +306 -0
- package/src/fixed.ts +558 -0
- package/src/fn.ts +94 -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 +478 -0
- package/src/generated/checkout.ts +151 -0
- package/src/generated/historic.ts +67 -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/historic.ts +77 -0
- package/src/history.ts +333 -0
- package/src/hmrc.ts +125 -0
- package/src/index.ts +41 -0
- package/src/klarna.ts +54 -0
- package/src/kysely.ts +588 -0
- package/src/money.ts +979 -0
- package/src/parse.ts +511 -0
- package/src/paypal.ts +93 -0
- package/src/pg.ts +410 -0
- package/src/plugins/adyen.ts +25 -0
- package/src/plugins/all.ts +57 -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 +49 -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 +40 -0
- package/src/policy.ts +124 -0
- package/src/prisma.ts +199 -0
- package/src/processor.ts +181 -0
- package/src/provider.ts +389 -0
- package/src/rate-text.ts +151 -0
- package/src/rate.ts +546 -0
- package/src/rounding-error.ts +8 -0
- package/src/rounding.ts +122 -0
- package/src/schema.ts +119 -0
- package/src/sql.ts +178 -0
- package/src/stripe-fx.ts +49 -0
- package/src/stripe.ts +69 -0
- package/src/tax.ts +102 -0
- package/src/testing/arbitraries.ts +134 -0
- package/src/testing/index.ts +247 -0
- package/src/testing/jest.ts +18 -0
- package/src/testing/vitest.ts +18 -0
- package/src/valibot.ts +87 -0
- package/src/zod.ts +78 -0
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Network fetchers for published reference rates, using the platform fetch. Opt-in: nothing else in
|
|
2
|
+
// the library imports this module, so core and @ducatjs/money/convert never touch the
|
|
3
|
+
// network. You decide when to call it and how to cache.
|
|
4
|
+
import { parseBankOfCanadaJson } from './boc.js';
|
|
5
|
+
import { parseEcbSdmxCsv, parseEcbSdmxDecimals, parseEcbXml } from './ecb.js';
|
|
6
|
+
import type { RateTable } from './provider.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The part of the platform AbortSignal used here. Declared structurally so the library needs no DOM
|
|
10
|
+
* or Node types; any real AbortSignal fits.
|
|
11
|
+
*/
|
|
12
|
+
export interface AbortSignalLike {
|
|
13
|
+
readonly aborted: boolean;
|
|
14
|
+
readonly reason?: unknown;
|
|
15
|
+
addEventListener(type: 'abort', listener: () => void, options?: { once?: boolean }): void;
|
|
16
|
+
removeEventListener(type: 'abort', listener: () => void): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The subset of the platform fetch used here (Node 24+, Deno, Bun, browsers, workers). */
|
|
20
|
+
export type Fetch = (
|
|
21
|
+
url: string,
|
|
22
|
+
init?: { signal?: AbortSignalLike; headers?: Record<string, string> },
|
|
23
|
+
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
24
|
+
|
|
25
|
+
/** The platform's AbortSignal statics, where they exist. */
|
|
26
|
+
type AbortSignalStatics = {
|
|
27
|
+
timeout?: (milliseconds: number) => AbortSignalLike;
|
|
28
|
+
any?: (signals: AbortSignalLike[]) => AbortSignalLike;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
/** The ECB's eurofxref files: the latest day, the last 90 days, and the full history since 1999. */
|
|
34
|
+
export const ECB_URLS = {
|
|
35
|
+
daily: 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml',
|
|
36
|
+
'90d': 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml',
|
|
37
|
+
all: 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml',
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/** Options for fetching ECB reference rates. */
|
|
41
|
+
export interface FetchEcbOptions {
|
|
42
|
+
/** 'daily' (default): the latest day; '90d': the last 90 days; 'all': since 1999 (several MB). */
|
|
43
|
+
readonly history?: keyof typeof ECB_URLS;
|
|
44
|
+
/** An AbortSignal to cancel the request; it is combined with the timeout. */
|
|
45
|
+
readonly signal?: AbortSignalLike;
|
|
46
|
+
/**
|
|
47
|
+
* Milliseconds before the request is aborted (default 30 000; 0 for no timeout). It needs the
|
|
48
|
+
* platform's AbortSignal.timeout, which Node 24, Deno, Bun and current browsers all have.
|
|
49
|
+
*/
|
|
50
|
+
readonly timeout?: number;
|
|
51
|
+
/** A fetch to use instead of the global one (for proxies, retries or tests). */
|
|
52
|
+
readonly fetch?: Fetch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Thrown when the HTTP request completes without a 2xx status. */
|
|
56
|
+
export class FetchRatesError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly url: string;
|
|
59
|
+
constructor(url: string, status: number) {
|
|
60
|
+
super(`GET ${url} failed with HTTP ${status}`);
|
|
61
|
+
this.name = 'FetchRatesError';
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.url = url;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetches the ECB euro reference rates and parses them with parseEcbXml: EUR-based tables,
|
|
69
|
+
* newest first. The ECB publishes once per TARGET working day at about 16:00 CET and states
|
|
70
|
+
* that its rates are for information purposes only.
|
|
71
|
+
*
|
|
72
|
+
* const [latest] = await fetchEcb(); // the RateTable for the last published day
|
|
73
|
+
* const hist = snapshotsFrom(await fetchEcb({ history: '90d' }));
|
|
74
|
+
*/
|
|
75
|
+
export async function fetchEcb(options?: FetchEcbOptions): Promise<RateTable[]> {
|
|
76
|
+
const url = ECB_URLS[options?.history ?? 'daily'];
|
|
77
|
+
const body = await getText(url, 'application/xml', options);
|
|
78
|
+
return parsedFrom(url, () => parseEcbXml(body));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The caller's signal, a timeout signal, or both combined. */
|
|
82
|
+
function requestSignal(
|
|
83
|
+
signal: AbortSignalLike | undefined,
|
|
84
|
+
timeout: number,
|
|
85
|
+
): AbortSignalLike | undefined {
|
|
86
|
+
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
87
|
+
throw new RangeError(`timeout must be a non-negative number of milliseconds, got ${timeout}`);
|
|
88
|
+
}
|
|
89
|
+
const statics = (globalThis as unknown as { AbortSignal?: AbortSignalStatics }).AbortSignal;
|
|
90
|
+
if (timeout === 0 || statics?.timeout === undefined) {
|
|
91
|
+
return signal;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const timeoutSignal = statics.timeout(timeout);
|
|
95
|
+
if (signal === undefined) {
|
|
96
|
+
return timeoutSignal;
|
|
97
|
+
}
|
|
98
|
+
if (statics.any === undefined) {
|
|
99
|
+
// Without AbortSignal.any the caller's signal wins: it is the one they can control.
|
|
100
|
+
return signal;
|
|
101
|
+
}
|
|
102
|
+
return statics.any([signal, timeoutSignal]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The ECB Data Portal's SDMX REST API (no key, and CORS-enabled, so it works in browsers). */
|
|
106
|
+
export const ECB_DATA_API = 'https://data-api.ecb.europa.eu/service/data/EXR';
|
|
107
|
+
|
|
108
|
+
/** Options for fetching a date range of ECB reference rates. */
|
|
109
|
+
export interface FetchEcbRangeOptions extends Omit<FetchEcbOptions, 'history'> {
|
|
110
|
+
/** The first day ('1999-01-04' at the earliest). */
|
|
111
|
+
readonly start: string | Date;
|
|
112
|
+
/** The last day (default: the latest published). */
|
|
113
|
+
readonly end?: string | Date;
|
|
114
|
+
/** ISO codes to fetch (default: every currency, including discontinued series). */
|
|
115
|
+
readonly currencies?: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A request's UTC day: a 'YYYY-MM-DD' string that is a real calendar day, or a Date (any
|
|
120
|
+
* offset) as its UTC day. Anything else is a RangeError, before any request is made.
|
|
121
|
+
*/
|
|
122
|
+
function requestDay(time: string | Date, name: string): string {
|
|
123
|
+
let day: string;
|
|
124
|
+
if (typeof time === 'string') {
|
|
125
|
+
day = time;
|
|
126
|
+
} else {
|
|
127
|
+
day = Number.isNaN(time.getTime()) ? '' : time.toISOString().slice(0, 10);
|
|
128
|
+
}
|
|
129
|
+
const match = /^(\d{4})-(\d\d)-(\d\d)$/.exec(day);
|
|
130
|
+
const date =
|
|
131
|
+
match === null ? undefined : new Date(Date.UTC(+match[1]!, +match[2]! - 1, +match[3]!));
|
|
132
|
+
// Date.UTC rolls 30 February over into March, so a real day must come back unchanged.
|
|
133
|
+
if (date === undefined || date.toISOString().slice(0, 10) !== day) {
|
|
134
|
+
throw new RangeError(`${name} must be a date, got ${String(time)}`);
|
|
135
|
+
}
|
|
136
|
+
return day;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Wraps a parser's SyntaxError with the URL the bad file came from (an error page, usually). */
|
|
140
|
+
function parsedFrom<T>(url: string, parse: () => T): T {
|
|
141
|
+
try {
|
|
142
|
+
return parse();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof SyntaxError) {
|
|
145
|
+
throw new SyntaxError(`${error.message} (from ${url})`, { cause: error });
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The platform's AbortController, where it exists. */
|
|
152
|
+
type AbortControllerLike = { readonly signal: AbortSignalLike; abort(reason?: unknown): void };
|
|
153
|
+
|
|
154
|
+
/** GETs `url` as text, with the timeout and signal handling every fetcher here shares. */
|
|
155
|
+
async function getText(
|
|
156
|
+
url: string,
|
|
157
|
+
accept: string,
|
|
158
|
+
options: Omit<FetchEcbOptions, 'history'> | undefined,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
return (await getTextOrNotFound(url, accept, options, false))!;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** getText, or undefined for HTTP 404 when `notFoundIsEmpty` (an API's "no results"). */
|
|
164
|
+
async function getTextOrNotFound(
|
|
165
|
+
url: string,
|
|
166
|
+
accept: string,
|
|
167
|
+
options: Omit<FetchEcbOptions, 'history'> | undefined,
|
|
168
|
+
notFoundIsEmpty: boolean,
|
|
169
|
+
): Promise<string | undefined> {
|
|
170
|
+
const fetchFunction = options?.fetch ?? (globalThis as { fetch?: Fetch }).fetch;
|
|
171
|
+
if (fetchFunction === undefined) {
|
|
172
|
+
throw new TypeError('No fetch available: pass options.fetch');
|
|
173
|
+
}
|
|
174
|
+
const init: { signal?: AbortSignalLike; headers: Record<string, string> } = {
|
|
175
|
+
headers: { accept },
|
|
176
|
+
};
|
|
177
|
+
const signal = requestSignal(options?.signal, options?.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
178
|
+
if (signal !== undefined) {
|
|
179
|
+
init.signal = signal;
|
|
180
|
+
}
|
|
181
|
+
const response = await fetchFunction(url, init);
|
|
182
|
+
if (notFoundIsEmpty && response.status === 404) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
throw new FetchRatesError(url, response.status);
|
|
187
|
+
}
|
|
188
|
+
return response.text();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fetches a date range of ECB euro reference rates from the ECB Data Portal and parses them with
|
|
193
|
+
* parseEcbSdmxCsv: EUR-based tables, one per TARGET working day, newest first, with the published
|
|
194
|
+
* digits. It makes two requests: the series' DECIMALS (small) and the observations without
|
|
195
|
+
* attributes, which keeps the full history since 1999 to about 15 MB. Give a few days before the
|
|
196
|
+
* first day you need, so that a lookback from it has a table to fall back on. A range with no
|
|
197
|
+
* rates (a weekend, or after the latest day) gives []. Both requests share one timeout and are
|
|
198
|
+
* cancelled together: if one fails, the other is aborted. The ECB states that its rates are for
|
|
199
|
+
* information purposes only.
|
|
200
|
+
*
|
|
201
|
+
* const tables = await fetchEcbRange({ start: '2026-01-01', currencies: ['USD', 'JPY'] });
|
|
202
|
+
* const history = rateHistory(tables); // from @ducatjs/money/convert/history
|
|
203
|
+
*/
|
|
204
|
+
export async function fetchEcbRange(options: FetchEcbRangeOptions): Promise<RateTable[]> {
|
|
205
|
+
const currencies = options.currencies ?? [];
|
|
206
|
+
for (const currency of currencies) {
|
|
207
|
+
if (!/^[A-Z]{3}$/.test(currency)) {
|
|
208
|
+
throw new RangeError(`Not an ISO currency code: ${currency}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const start = requestDay(options.start, 'start');
|
|
212
|
+
if (start < '1999-01-04') {
|
|
213
|
+
throw new RangeError(`The ECB's rates start on 1999-01-04, not ${start}`);
|
|
214
|
+
}
|
|
215
|
+
let query = `?format=csvdata&startPeriod=${start}`;
|
|
216
|
+
if (options.end !== undefined) {
|
|
217
|
+
const end = requestDay(options.end, 'end');
|
|
218
|
+
if (end < start) {
|
|
219
|
+
throw new RangeError(`end ${end} is before start ${start}`);
|
|
220
|
+
}
|
|
221
|
+
query += `&endPeriod=${end}`;
|
|
222
|
+
}
|
|
223
|
+
const url = `${ECB_DATA_API}/D.${currencies.join('+')}.EUR.SP00.A`;
|
|
224
|
+
const seriesUrl = `${url}?format=csvdata&detail=nodata`;
|
|
225
|
+
const dataUrl = `${url}${query}&detail=dataonly`;
|
|
226
|
+
|
|
227
|
+
// One controller for both requests, tied to the caller's signal and the timeout, so that a
|
|
228
|
+
// failure of either cancels the other (the data can be ~15 MB) instead of letting it run on.
|
|
229
|
+
let shared: Omit<FetchEcbOptions, 'history'> = options;
|
|
230
|
+
let controller: AbortControllerLike | undefined;
|
|
231
|
+
const Controller = (globalThis as { AbortController?: new () => AbortControllerLike })
|
|
232
|
+
.AbortController;
|
|
233
|
+
if (Controller !== undefined) {
|
|
234
|
+
const own = new Controller();
|
|
235
|
+
controller = own;
|
|
236
|
+
const outer = requestSignal(options.signal, options.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
237
|
+
if (outer?.aborted === true) {
|
|
238
|
+
own.abort(outer.reason);
|
|
239
|
+
} else {
|
|
240
|
+
outer?.addEventListener('abort', () => own.abort(outer.reason), { once: true });
|
|
241
|
+
}
|
|
242
|
+
shared = { ...options, signal: own.signal, timeout: 0 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let series: string;
|
|
246
|
+
let data: string | undefined;
|
|
247
|
+
try {
|
|
248
|
+
[series, data] = await Promise.all([
|
|
249
|
+
getText(seriesUrl, 'text/csv', shared),
|
|
250
|
+
getTextOrNotFound(dataUrl, 'text/csv', shared, true),
|
|
251
|
+
]);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
controller?.abort(error);
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
if (data === undefined) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const decimals = parsedFrom(seriesUrl, () => parseEcbSdmxDecimals(series));
|
|
260
|
+
return parsedFrom(dataUrl, () => parseEcbSdmxCsv(data, { decimals }));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The Bank of Canada's Valet API for its daily exchange rates (no key; CORS-enabled). */
|
|
264
|
+
export const BANK_OF_CANADA_URL =
|
|
265
|
+
'https://www.bankofcanada.ca/valet/observations/group/FX_RATES_DAILY/json';
|
|
266
|
+
|
|
267
|
+
/** Options for fetching Bank of Canada rates. */
|
|
268
|
+
export interface FetchBankOfCanadaOptions extends Omit<FetchEcbOptions, 'history'> {
|
|
269
|
+
/** The first day (2017-01-03 at the earliest). Default: only the latest published day. */
|
|
270
|
+
readonly start?: string | Date;
|
|
271
|
+
/** The last day (default: the latest published). Needs a `start`. */
|
|
272
|
+
readonly end?: string | Date;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Fetches the Bank of Canada's daily exchange rates from its Valet API and parses them with
|
|
277
|
+
* parseBankOfCanadaJson: CAD-based tables, newest first. Without `start`, only the latest day;
|
|
278
|
+
* without `end`, up to the latest. A range with no business days in it gives [].
|
|
279
|
+
* The Bank asks users to keep within its request limits, which it doesn't publish, and its rates
|
|
280
|
+
* are "released for statistical and analytical purposes only", not for transactions.
|
|
281
|
+
*
|
|
282
|
+
* const [latest] = await fetchBankOfCanada();
|
|
283
|
+
* const year = await fetchBankOfCanada({ start: '2026-01-01' });
|
|
284
|
+
*/
|
|
285
|
+
export async function fetchBankOfCanada(options?: FetchBankOfCanadaOptions): Promise<RateTable[]> {
|
|
286
|
+
let query = '?recent=1';
|
|
287
|
+
if (options?.start !== undefined) {
|
|
288
|
+
const start = requestDay(options.start, 'start');
|
|
289
|
+
if (start < '2017-01-03') {
|
|
290
|
+
throw new RangeError(`Valet's daily rates start on 2017-01-03, not ${start}`);
|
|
291
|
+
}
|
|
292
|
+
query = `?start_date=${start}`;
|
|
293
|
+
if (options.end !== undefined) {
|
|
294
|
+
const end = requestDay(options.end, 'end');
|
|
295
|
+
if (end < start) {
|
|
296
|
+
throw new RangeError(`end ${end} is before start ${start}`);
|
|
297
|
+
}
|
|
298
|
+
query += `&end_date=${end}`;
|
|
299
|
+
}
|
|
300
|
+
} else if (options?.end !== undefined) {
|
|
301
|
+
throw new RangeError('end needs a start');
|
|
302
|
+
}
|
|
303
|
+
const url = `${BANK_OF_CANADA_URL}${query}`;
|
|
304
|
+
const body = await getText(url, 'application/json', options);
|
|
305
|
+
return parsedFrom(url, () => parseBankOfCanadaJson(body));
|
|
306
|
+
}
|