@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/rate-text.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Reading rate cells from published files, shared by the source parsers so that every source
|
|
2
|
+
// handles missing values and stripped digits the same way.
|
|
3
|
+
|
|
4
|
+
// What sources write for "no value this day": blank (ECB CSV, BoE), ND (Fed H.10), N/A (ECB CSV),
|
|
5
|
+
// NA (IMF), and dashes and NaN in other files. Compared upper-cased.
|
|
6
|
+
const MISSING_MARKERS = new Set(['', 'ND', 'N/A', 'NA', 'N.A.', '-', '–', '—', 'NAN', 'NULL']);
|
|
7
|
+
const PLAIN_DECIMAL = /^\d+(?:\.\d+)?$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @internal
|
|
11
|
+
* A published rate cell: the decimal text, or null when the source marks the value as missing
|
|
12
|
+
* (holidays, `ND`, `N/A`). Anything else throws SyntaxError: a cell that can't be read must not
|
|
13
|
+
* look like "not quoted".
|
|
14
|
+
*/
|
|
15
|
+
export function readRateCell(cell: string, where: string): string | null {
|
|
16
|
+
const text = cell.trim();
|
|
17
|
+
if (MISSING_MARKERS.has(text.toUpperCase())) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!PLAIN_DECIMAL.test(text)) {
|
|
21
|
+
throw new SyntaxError(`Not a rate: ${JSON.stringify(cell)} (${where})`);
|
|
22
|
+
}
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @internal
|
|
28
|
+
* The published digits of a rate from a feed that strips trailing zeros, given the number of
|
|
29
|
+
* decimals the publisher states: '1.175' with 4 decimals is '1.1750'. Text with more decimals than
|
|
30
|
+
* stated is kept as it is, since dropping digits would change the rate.
|
|
31
|
+
*/
|
|
32
|
+
export function withDecimals(text: string, decimals: number): string {
|
|
33
|
+
if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 30) {
|
|
34
|
+
throw new RangeError(`decimals must be an integer from 0 to 30, got ${decimals}`);
|
|
35
|
+
}
|
|
36
|
+
const point = text.indexOf('.');
|
|
37
|
+
const present = point === -1 ? 0 : text.length - point - 1;
|
|
38
|
+
if (present >= decimals) {
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
const integerPart = point === -1 ? text : text.slice(0, point);
|
|
42
|
+
const fraction = point === -1 ? '' : text.slice(point + 1);
|
|
43
|
+
return `${integerPart}.${fraction.padEnd(decimals, '0')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @internal
|
|
48
|
+
* The records of a CSV file, RFC 4180 style: a cell may be quoted ("a, b", with "" for a quote),
|
|
49
|
+
* and a quote anywhere else, or one left open, is a SyntaxError, so a stray quote can't swallow
|
|
50
|
+
* the lines after it. Blank lines are skipped.
|
|
51
|
+
*/
|
|
52
|
+
export function csvRows(text: string): string[][] {
|
|
53
|
+
const rows: string[][] = [];
|
|
54
|
+
let row: string[] = [];
|
|
55
|
+
let cell = '';
|
|
56
|
+
// 'start' of a cell, inside an unquoted one, inside quotes, or just after the closing quote
|
|
57
|
+
let state: 'start' | 'plain' | 'quoted' | 'closed' = 'start';
|
|
58
|
+
const endRow = () => {
|
|
59
|
+
row.push(cell);
|
|
60
|
+
if (row.length > 1 || row[0]!.trim() !== '') {
|
|
61
|
+
rows.push(row);
|
|
62
|
+
}
|
|
63
|
+
row = [];
|
|
64
|
+
cell = '';
|
|
65
|
+
state = 'start';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < text.length; i++) {
|
|
69
|
+
const char = text[i]!;
|
|
70
|
+
if (state === 'quoted') {
|
|
71
|
+
if (char !== '"') {
|
|
72
|
+
cell += char;
|
|
73
|
+
} else if (text[i + 1] === '"') {
|
|
74
|
+
cell += '"';
|
|
75
|
+
i++;
|
|
76
|
+
} else {
|
|
77
|
+
state = 'closed';
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === ',') {
|
|
82
|
+
row.push(cell);
|
|
83
|
+
cell = '';
|
|
84
|
+
state = 'start';
|
|
85
|
+
} else if (char === '\n') {
|
|
86
|
+
endRow();
|
|
87
|
+
} else if (char === '\r' && text[i + 1] === '\n') {
|
|
88
|
+
continue;
|
|
89
|
+
} else if (char === '"' && state === 'start') {
|
|
90
|
+
state = 'quoted';
|
|
91
|
+
} else if (char === '"' || state === 'closed') {
|
|
92
|
+
throw new SyntaxError(
|
|
93
|
+
`Malformed CSV: unexpected ${JSON.stringify(char)} on line ${rows.length + 1}`,
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
cell += char;
|
|
97
|
+
state = 'plain';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (state === 'quoted') {
|
|
101
|
+
throw new SyntaxError('Malformed CSV: a quoted cell is never closed');
|
|
102
|
+
}
|
|
103
|
+
if (cell !== '' || row.length > 0) {
|
|
104
|
+
endRow();
|
|
105
|
+
}
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ISO_DAY = /^(\d{4})-(\d\d)-(\d\d)$/;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @internal
|
|
113
|
+
* Whether `text` is a real calendar day in ISO form. '2026-02-30' is not, although Date.parse rolls
|
|
114
|
+
* it over to 2 March.
|
|
115
|
+
*/
|
|
116
|
+
export function isCalendarDay(text: string): boolean {
|
|
117
|
+
const match = ISO_DAY.exec(text);
|
|
118
|
+
if (match === null) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
|
122
|
+
return date.toISOString().slice(0, 10) === text;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @internal
|
|
127
|
+
* Records a currency's rate (or null, missing) for a day. The same currency twice on one day is
|
|
128
|
+
* either a repeat, which is fine, or a contradiction, which throws: a parser never picks one.
|
|
129
|
+
*/
|
|
130
|
+
export function addDayRate(
|
|
131
|
+
days: Map<string, Record<string, string | null>>,
|
|
132
|
+
day: string,
|
|
133
|
+
currency: string,
|
|
134
|
+
rate: string | null,
|
|
135
|
+
): void {
|
|
136
|
+
let rates = days.get(day);
|
|
137
|
+
if (rates === undefined) {
|
|
138
|
+
rates = {};
|
|
139
|
+
days.set(day, rates);
|
|
140
|
+
}
|
|
141
|
+
const earlier = rates[currency];
|
|
142
|
+
if (earlier !== undefined && earlier !== rate) {
|
|
143
|
+
throw new SyntaxError(`Two different values for ${currency} on ${day}: ${earlier} and ${rate}`);
|
|
144
|
+
}
|
|
145
|
+
rates[currency] = rate;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @internal Dated tables, newest first (ISO dates sort as strings). */
|
|
149
|
+
export function newestFirst<T extends { readonly asOf?: string }>(tables: T[]): T[] {
|
|
150
|
+
return tables.sort((table, other) => other.asOf!.localeCompare(table.asOf!));
|
|
151
|
+
}
|
package/src/rate.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// Exchange rates as exact rationals with metadata. A Rate never loses digits: inverses,
|
|
2
|
+
// compositions and margins are exact products of ratios, and rounding happens once, when an amount
|
|
3
|
+
// is converted.
|
|
4
|
+
import { currencyExponent } from './currency.js';
|
|
5
|
+
import {
|
|
6
|
+
formatScaled,
|
|
7
|
+
POW10,
|
|
8
|
+
pow10n,
|
|
9
|
+
ratio,
|
|
10
|
+
ratioToDecimal,
|
|
11
|
+
normalizeMinor,
|
|
12
|
+
type Factor,
|
|
13
|
+
type Ratio,
|
|
14
|
+
} from './decimal.js';
|
|
15
|
+
import {
|
|
16
|
+
CurrencyMismatchError,
|
|
17
|
+
make,
|
|
18
|
+
roundWithPolicy,
|
|
19
|
+
type CurrencyInput,
|
|
20
|
+
type Money,
|
|
21
|
+
type RoundingInput,
|
|
22
|
+
type RoundingPolicy,
|
|
23
|
+
} from './money.js';
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_ROUNDING,
|
|
26
|
+
divideAndRound,
|
|
27
|
+
divideAndRoundBigint,
|
|
28
|
+
type RoundingMode,
|
|
29
|
+
} from './rounding.js';
|
|
30
|
+
|
|
31
|
+
const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
|
|
32
|
+
|
|
33
|
+
/** How a rate was quoted. `inverse()` swaps 'bid' and 'ask'. */
|
|
34
|
+
export type RateType = 'mid' | 'bid' | 'ask' | (string & {});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How a source published a rate, kept with the Rate for audit and display. The published direction
|
|
38
|
+
* is `from` → `to`, or `to` → `from` when `inverted`.
|
|
39
|
+
*/
|
|
40
|
+
export interface RateQuote {
|
|
41
|
+
/** The number exactly as published, trailing zeros included ('136.80'). */
|
|
42
|
+
readonly rate: string;
|
|
43
|
+
/**
|
|
44
|
+
* How many units of the published direction's first currency the number is for: 100 for "NOK
|
|
45
|
+
* per 100 JPY". Omitted for 1.
|
|
46
|
+
*/
|
|
47
|
+
readonly per?: number;
|
|
48
|
+
/** The source quoted `to` → `from`; this Rate is its exact inverse. */
|
|
49
|
+
readonly inverted?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where a rate came from. `asOf`, `availableAt` and `validUntil` are ISO-8601 dates ('2026-11-06')
|
|
54
|
+
* or instants.
|
|
55
|
+
*/
|
|
56
|
+
export interface RateInfo {
|
|
57
|
+
readonly source?: string;
|
|
58
|
+
/** The date or time the rate applies to. */
|
|
59
|
+
readonly asOf?: string | Date;
|
|
60
|
+
/**
|
|
61
|
+
* When the rate was published. A lookup for an earlier time doesn't get it, so a historical
|
|
62
|
+
* lookup sees only what was known then. A date means from the start of that UTC day.
|
|
63
|
+
*/
|
|
64
|
+
readonly availableAt?: string | Date;
|
|
65
|
+
/** The last date or time the rate applies to. A date is valid all day (UTC). */
|
|
66
|
+
readonly validUntil?: string | Date;
|
|
67
|
+
readonly type?: RateType;
|
|
68
|
+
/** The rate as the source published it. */
|
|
69
|
+
readonly quote?: RateQuote;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for converting an amount. */
|
|
73
|
+
export interface ConvertOptions {
|
|
74
|
+
/** Result scale (default: the target currency's exponent). */
|
|
75
|
+
readonly scale?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Throw RateExpiredError when the rate has a `validUntil` that has passed (by the system clock).
|
|
78
|
+
* Off by default: ducat reads no clock unless asked to.
|
|
79
|
+
*/
|
|
80
|
+
readonly strictValidity?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Thrown by a strict-validity conversion with a rate past its `validUntil`. */
|
|
84
|
+
export class RateExpiredError extends RangeError {
|
|
85
|
+
readonly validUntil: string;
|
|
86
|
+
|
|
87
|
+
constructor(from: string, to: string, validUntil: string) {
|
|
88
|
+
super(`Exchange rate ${from}→${to} expired at ${validUntil}`);
|
|
89
|
+
this.name = 'RateExpiredError';
|
|
90
|
+
this.validUntil = validUntil;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The JSON form of a Rate: the exact decimal string, or "num/den" when it doesn't terminate. */
|
|
95
|
+
export interface RateJSON {
|
|
96
|
+
from: string;
|
|
97
|
+
to: string;
|
|
98
|
+
rate: string;
|
|
99
|
+
source?: string;
|
|
100
|
+
asOf?: string;
|
|
101
|
+
availableAt?: string;
|
|
102
|
+
validUntil?: string;
|
|
103
|
+
type?: string;
|
|
104
|
+
/** Present only when it says more than `rate` does: a `per` or an inverted quote. */
|
|
105
|
+
quote?: RateQuote;
|
|
106
|
+
derivedFrom?: RateJSON[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type MutableInfo = {
|
|
110
|
+
source?: string;
|
|
111
|
+
asOf?: string;
|
|
112
|
+
availableAt?: string;
|
|
113
|
+
validUntil?: string;
|
|
114
|
+
type?: RateType;
|
|
115
|
+
quote?: RateQuote;
|
|
116
|
+
};
|
|
117
|
+
/** RateInfo that may also hold explicit undefineds (as optional parameters do). */
|
|
118
|
+
type LooseInfo = { readonly [Key in keyof RateInfo]?: RateInfo[Key] | undefined };
|
|
119
|
+
|
|
120
|
+
const OPPOSITE_SIDE: Record<string, RateType> = { bid: 'ask', ask: 'bid' };
|
|
121
|
+
|
|
122
|
+
const ISO_DATE = /^\d{4}-\d\d-\d\d$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @internal
|
|
126
|
+
* A date stays as given ('2026-11-06'); an instant becomes UTC ISO ('2026-11-06T15:00:00.000Z'), so
|
|
127
|
+
* that times with different offsets compare correctly as strings.
|
|
128
|
+
*/
|
|
129
|
+
export function toIsoString(time: string | Date): string {
|
|
130
|
+
if (typeof time === 'string' && ISO_DATE.test(time)) {
|
|
131
|
+
return time;
|
|
132
|
+
}
|
|
133
|
+
const date = time instanceof Date ? time : new Date(time);
|
|
134
|
+
if (Number.isNaN(date.getTime())) {
|
|
135
|
+
throw new RangeError(`Invalid date or time ${String(time)}`);
|
|
136
|
+
}
|
|
137
|
+
return date.toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A date as the first instant of its UTC day; an instant as it is. */
|
|
141
|
+
function asInstant(time: string): string {
|
|
142
|
+
return ISO_DATE.test(time) ? `${time}T00:00:00.000Z` : time;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @internal
|
|
147
|
+
* Whether `time` has come by `at`. A date `at` means the whole of that UTC day, so anything on or
|
|
148
|
+
* before that day counts; a date `time` means the start of its day.
|
|
149
|
+
*/
|
|
150
|
+
export function hasStarted(time: string, at: string | Date): boolean {
|
|
151
|
+
const instant = toIsoString(at);
|
|
152
|
+
if (ISO_DATE.test(instant)) {
|
|
153
|
+
return time.slice(0, 10) <= instant;
|
|
154
|
+
}
|
|
155
|
+
return asInstant(time) <= instant;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @internal Whether `validUntil` (a date: valid all day, UTC) has passed at `at`. */
|
|
159
|
+
export function hasEnded(validUntil: string, at: string | Date): boolean {
|
|
160
|
+
const instant = asInstant(toIsoString(at));
|
|
161
|
+
if (ISO_DATE.test(validUntil)) {
|
|
162
|
+
return instant.slice(0, 10) > validUntil;
|
|
163
|
+
}
|
|
164
|
+
return instant > validUntil;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The earlier of two ISO-8601 strings (one format compares lexicographically), or the later one
|
|
169
|
+
* with `later`; undefined = no limit.
|
|
170
|
+
*/
|
|
171
|
+
function earlier(
|
|
172
|
+
first: string | undefined,
|
|
173
|
+
second: string | undefined,
|
|
174
|
+
later?: boolean,
|
|
175
|
+
): string | undefined {
|
|
176
|
+
if (first === undefined) {
|
|
177
|
+
return second;
|
|
178
|
+
}
|
|
179
|
+
if (second === undefined) {
|
|
180
|
+
return first;
|
|
181
|
+
}
|
|
182
|
+
if (later === true) {
|
|
183
|
+
return first >= second ? first : second;
|
|
184
|
+
}
|
|
185
|
+
return first <= second ? first : second;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* minor × rate × 10^scaleShift, rounded once with `rounding`. This is the kernel of every
|
|
190
|
+
* conversion: `scaleShift` is the result scale minus the input scale. Pure, and `rate.den > 0` by
|
|
191
|
+
* construction.
|
|
192
|
+
*/
|
|
193
|
+
export function multiplyByRatioAtScale(
|
|
194
|
+
minor: number | bigint,
|
|
195
|
+
rate: Ratio,
|
|
196
|
+
scaleShift: number,
|
|
197
|
+
rounding: RoundingMode,
|
|
198
|
+
): number | bigint {
|
|
199
|
+
// Fast path: all factors are safe integers, so a product that stays in the safe range is exact.
|
|
200
|
+
// `rate.n === rate.n` is false only for NaN, which marks a numerator that isn't a safe integer.
|
|
201
|
+
const rateIsSafe = rate.n === rate.n && rate.d === rate.d;
|
|
202
|
+
if (typeof minor === 'number' && rateIsSafe && scaleShift >= -15 && scaleShift <= 15) {
|
|
203
|
+
let numerator = rate.n;
|
|
204
|
+
let denominator = rate.d;
|
|
205
|
+
if (scaleShift > 0) {
|
|
206
|
+
numerator *= POW10[scaleShift]!;
|
|
207
|
+
} else if (scaleShift < 0) {
|
|
208
|
+
denominator *= POW10[-scaleShift]!;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (numerator <= MAX_SAFE_MINOR && denominator <= MAX_SAFE_MINOR) {
|
|
212
|
+
const product = minor * numerator;
|
|
213
|
+
if (product <= MAX_SAFE_MINOR && product >= -MAX_SAFE_MINOR) {
|
|
214
|
+
return divideAndRound(product, denominator, rounding);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Slow path: the same single division, in bigint.
|
|
220
|
+
let numerator = BigInt(minor) * rate.num;
|
|
221
|
+
let denominator = rate.den;
|
|
222
|
+
if (scaleShift > 0) {
|
|
223
|
+
numerator *= pow10n(scaleShift);
|
|
224
|
+
} else if (scaleShift < 0) {
|
|
225
|
+
denominator *= pow10n(-scaleShift);
|
|
226
|
+
}
|
|
227
|
+
return normalizeMinor(divideAndRoundBigint(numerator, denominator, rounding));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* An exact exchange rate: one unit of `from` is worth `ratio` units of `to`. Build it with
|
|
232
|
+
* `exchangeRate()`. Derived rates (`inverse`, `andThen`, `withMargin`, `round`) keep their inputs
|
|
233
|
+
* in `derivedFrom`, the audit chain. Their `asOf` is the oldest input's, so staleness can't hide,
|
|
234
|
+
* and their `validUntil` is the earliest.
|
|
235
|
+
*/
|
|
236
|
+
export class Rate<From extends string = string, To extends string = string> {
|
|
237
|
+
readonly from: From;
|
|
238
|
+
readonly to: To;
|
|
239
|
+
readonly ratio: Ratio;
|
|
240
|
+
// The optional fields are `declare`d, so only the ones that are set exist on an instance: class
|
|
241
|
+
// field semantics would otherwise define them all as undefined.
|
|
242
|
+
declare readonly source?: string;
|
|
243
|
+
declare readonly asOf?: string;
|
|
244
|
+
declare readonly availableAt?: string;
|
|
245
|
+
declare readonly validUntil?: string;
|
|
246
|
+
declare readonly type?: RateType;
|
|
247
|
+
declare readonly quote?: RateQuote;
|
|
248
|
+
declare readonly derivedFrom?: readonly Rate[];
|
|
249
|
+
|
|
250
|
+
/** @internal use exchangeRate() */
|
|
251
|
+
constructor(from: From, to: To, rate: Ratio, info?: RateInfo, derivedFrom?: readonly Rate[]) {
|
|
252
|
+
// Rebuilt so that a hand-made Ratio object is reduced, has a positive denominator, and has
|
|
253
|
+
// number fields that match its bigints (the fast path relies on them).
|
|
254
|
+
const normalized = ratio(rate.num, rate.den);
|
|
255
|
+
if (normalized.num <= 0n) {
|
|
256
|
+
throw new RangeError('Exchange rate must be positive');
|
|
257
|
+
}
|
|
258
|
+
this.from = from;
|
|
259
|
+
this.to = to;
|
|
260
|
+
this.ratio = normalized;
|
|
261
|
+
|
|
262
|
+
// Only defined fields are assigned, so rates compare and serialise without undefined keys.
|
|
263
|
+
const writable = this as { -readonly [Key in keyof Rate]?: unknown };
|
|
264
|
+
const quote = info?.quote;
|
|
265
|
+
if (quote !== undefined) {
|
|
266
|
+
// toString prints a one-for-one quote, so it must be this rate. (rateFromQuote derives a
|
|
267
|
+
// scaled or inverted rate from its quote, so that one can't disagree.)
|
|
268
|
+
const direct = quote.per === undefined && quote.inverted !== true;
|
|
269
|
+
// ratio() caches strings, so for a rate built from the same text this is two lookups.
|
|
270
|
+
const quoted = direct ? ratio(quote.rate) : normalized;
|
|
271
|
+
if (quoted.num !== normalized.num || quoted.den !== normalized.den) {
|
|
272
|
+
throw new RangeError(`Quote ${quote.rate} isn't the rate ${from}→${to}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (info !== undefined) {
|
|
276
|
+
Object.assign(this, infoOf(info));
|
|
277
|
+
}
|
|
278
|
+
if (derivedFrom !== undefined) {
|
|
279
|
+
writable.derivedFrom = derivedFrom;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The exact reciprocal, `to` → `from`. A bid becomes an ask and vice versa. */
|
|
284
|
+
inverse(): Rate<To, From> {
|
|
285
|
+
let type = this.type;
|
|
286
|
+
if (type !== undefined) {
|
|
287
|
+
type = OPPOSITE_SIDE[type] ?? type;
|
|
288
|
+
}
|
|
289
|
+
const reciprocal = ratio(this.ratio.den, this.ratio.num);
|
|
290
|
+
let info = timingOf(this, type);
|
|
291
|
+
// The published number reads the other way round now, so the inverse of a quoted rate keeps
|
|
292
|
+
// its digits too: the inverse of CAD→USD quoted as 1.3650 inverted prints '1.3650'.
|
|
293
|
+
const quote = this.quote;
|
|
294
|
+
if (quote !== undefined) {
|
|
295
|
+
const { inverted, ...rest } = quote;
|
|
296
|
+
info = { ...info, quote: inverted === true ? rest : { ...rest, inverted: true } };
|
|
297
|
+
}
|
|
298
|
+
return new Rate(this.to, this.from, reciprocal, info, [this]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The exact composite `from` → `next.to`. Nothing is rounded: converting with it rounds once.
|
|
303
|
+
* (Named `andThen`, not `then`, so that a Rate is never mistaken for a promise.)
|
|
304
|
+
*/
|
|
305
|
+
andThen<Next extends string>(next: Rate<To, Next>): Rate<From, Next> {
|
|
306
|
+
if (next.from !== this.to) {
|
|
307
|
+
throw new CurrencyMismatchError(this.to, next.from);
|
|
308
|
+
}
|
|
309
|
+
const product = ratio(this.ratio.num * next.ratio.num, this.ratio.den * next.ratio.den);
|
|
310
|
+
|
|
311
|
+
// A composite keeps a type only when both legs agree, so it can't be mislabelled.
|
|
312
|
+
let type: RateType | undefined;
|
|
313
|
+
if (this.type === next.type) {
|
|
314
|
+
type = this.type;
|
|
315
|
+
}
|
|
316
|
+
// A composite is known only once both legs are, and is valid only while both are.
|
|
317
|
+
const asOf = earlier(this.asOf, next.asOf);
|
|
318
|
+
const availableAt = earlier(this.availableAt, next.availableAt, true);
|
|
319
|
+
const validUntil = earlier(this.validUntil, next.validUntil);
|
|
320
|
+
const info = timing(asOf, availableAt, validUntil, type);
|
|
321
|
+
return new Rate(this.from, next.to, product, info, [this, next]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** This rate × (1 + margin), exactly: `withMargin('-0.02')` gives 2% less `to` per `from`. */
|
|
325
|
+
withMargin(margin: Factor): Rate<From, To> {
|
|
326
|
+
const marginRatio = ratio(margin);
|
|
327
|
+
const numerator = this.ratio.num * (marginRatio.den + marginRatio.num);
|
|
328
|
+
const denominator = this.ratio.den * marginRatio.den;
|
|
329
|
+
return new Rate(this.from, this.to, ratio(numerator, denominator), timingOf(this), [this]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A new Rate rounded to `places` decimals, for businesses that quote rounded (e.g. inverse)
|
|
334
|
+
* rates.
|
|
335
|
+
*/
|
|
336
|
+
round(places: number, rounding: RoundingMode = 'halfEven'): Rate<From, To> {
|
|
337
|
+
const rounded = ratio(this.toDecimal(places, rounding));
|
|
338
|
+
return new Rate(this.from, this.to, rounded, timingOf(this, this.type), [this]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The rate with exactly `places` decimals, for display. The Rate itself keeps every digit. */
|
|
342
|
+
toDecimal(places: number, rounding: RoundingMode = 'halfEven'): string {
|
|
343
|
+
const scaled = divideAndRoundBigint(this.ratio.num * pow10n(places), this.ratio.den, rounding);
|
|
344
|
+
return formatScaled(scaled, places);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The exact rate: '1.1165', or '2000/2233' when its decimal expansion doesn't terminate. A rate
|
|
349
|
+
* published one unit for one unit keeps the published digits ('136.80').
|
|
350
|
+
*/
|
|
351
|
+
toString(): string {
|
|
352
|
+
const quote = this.quote;
|
|
353
|
+
if (quote !== undefined && quote.per === undefined && quote.inverted !== true) {
|
|
354
|
+
return quote.rate;
|
|
355
|
+
}
|
|
356
|
+
return ratioToDecimal(this.ratio) ?? `${this.ratio.num}/${this.ratio.den}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The rate, its metadata and its audit chain as JSON. `rateFromJSON` reverses it. */
|
|
360
|
+
toJSON(): RateJSON {
|
|
361
|
+
const { quote, ...info } = infoOf(this);
|
|
362
|
+
const json: RateJSON = { from: this.from, to: this.to, rate: this.toString(), ...info };
|
|
363
|
+
// A direct quote is the `rate` string itself, so only a scaled or inverted one is written.
|
|
364
|
+
if (quote !== undefined && (quote.per !== undefined || quote.inverted === true)) {
|
|
365
|
+
json.quote = quote;
|
|
366
|
+
}
|
|
367
|
+
if (this.derivedFrom !== undefined) {
|
|
368
|
+
json.derivedFrom = this.derivedFrom.map((input) => input.toJSON());
|
|
369
|
+
}
|
|
370
|
+
return json;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Whether `validUntil` has passed at `now` (default: the system clock). A date is valid all day
|
|
375
|
+
* (UTC).
|
|
376
|
+
*/
|
|
377
|
+
isExpired(now: string | Date = new Date()): boolean {
|
|
378
|
+
if (this.validUntil === undefined) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
return hasEnded(this.validUntil, now);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether the rate had been published by `at`: always, without an `availableAt`. A date `at`
|
|
386
|
+
* means by the end of that UTC day.
|
|
387
|
+
*/
|
|
388
|
+
isAvailable(at: string | Date): boolean {
|
|
389
|
+
if (this.availableAt === undefined) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
return hasStarted(this.availableAt, at);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Converts `money`, rounding once: with a mode, to the target currency's exponent (or
|
|
397
|
+
* `options.scale`); with a rounding policy, to the policy's scale and increment.
|
|
398
|
+
*/
|
|
399
|
+
convert(
|
|
400
|
+
money: Money<From>,
|
|
401
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
402
|
+
options?: ConvertOptions,
|
|
403
|
+
): Money<To> {
|
|
404
|
+
if (money.currency !== this.from) {
|
|
405
|
+
throw new CurrencyMismatchError(this.from, money.currency);
|
|
406
|
+
}
|
|
407
|
+
if (options?.strictValidity === true && this.validUntil !== undefined && this.isExpired()) {
|
|
408
|
+
throw new RateExpiredError(this.from, this.to, this.validUntil);
|
|
409
|
+
}
|
|
410
|
+
if (typeof rounding === 'function') {
|
|
411
|
+
return convertWithPolicy(money, this.to, this.ratio, rounding, options);
|
|
412
|
+
}
|
|
413
|
+
const scale = options?.scale ?? currencyExponent(this.to);
|
|
414
|
+
const minor = multiplyByRatioAtScale(money.minor, this.ratio, scale - money.scale, rounding);
|
|
415
|
+
return make(this.to, minor, scale);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @internal
|
|
421
|
+
* money × rate in `to`, rounded once by a policy. The amount is relabelled `to` at its own scale,
|
|
422
|
+
* so the policy rounds the product exactly as it would a multiplication in `to`: to its scale
|
|
423
|
+
* (default `to`'s exponent) and increment, checking its currency against `to`.
|
|
424
|
+
*/
|
|
425
|
+
export function convertWithPolicy<To extends string>(
|
|
426
|
+
money: Money,
|
|
427
|
+
to: To,
|
|
428
|
+
rate: Ratio,
|
|
429
|
+
policy: RoundingPolicy,
|
|
430
|
+
options?: ConvertOptions,
|
|
431
|
+
): Money<To> {
|
|
432
|
+
// Two scales would leave it unclear which one the result has.
|
|
433
|
+
if (options?.scale !== undefined) {
|
|
434
|
+
throw new TypeError('options.scale with a rounding policy');
|
|
435
|
+
}
|
|
436
|
+
return roundWithPolicy(make(to, money.minor, money.scale), rate, policy);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The metadata fields of `info` that are defined (no undefined keys). */
|
|
440
|
+
function infoOf(info: LooseInfo): MutableInfo {
|
|
441
|
+
const defined: MutableInfo = {};
|
|
442
|
+
if (info.source !== undefined) {
|
|
443
|
+
defined.source = info.source;
|
|
444
|
+
}
|
|
445
|
+
if (info.asOf !== undefined) {
|
|
446
|
+
defined.asOf = toIsoString(info.asOf);
|
|
447
|
+
}
|
|
448
|
+
if (info.availableAt !== undefined) {
|
|
449
|
+
defined.availableAt = toIsoString(info.availableAt);
|
|
450
|
+
}
|
|
451
|
+
if (info.validUntil !== undefined) {
|
|
452
|
+
defined.validUntil = toIsoString(info.validUntil);
|
|
453
|
+
}
|
|
454
|
+
if (info.type !== undefined) {
|
|
455
|
+
defined.type = info.type;
|
|
456
|
+
}
|
|
457
|
+
if (info.quote !== undefined) {
|
|
458
|
+
defined.quote = info.quote;
|
|
459
|
+
}
|
|
460
|
+
return defined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function timing(
|
|
464
|
+
asOf?: string,
|
|
465
|
+
availableAt?: string,
|
|
466
|
+
validUntil?: string,
|
|
467
|
+
type?: RateType,
|
|
468
|
+
): RateInfo {
|
|
469
|
+
return infoOf({ asOf, availableAt, validUntil, type });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* A derived rate keeps the timing of its input; the input's source stays with it, in derivedFrom.
|
|
474
|
+
*/
|
|
475
|
+
function timingOf(input: Rate, type?: RateType): RateInfo {
|
|
476
|
+
return timing(input.asOf, input.availableAt, input.validUntil, type);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// A published decimal whose canonical form drops digits: trailing fraction zeros only. Leading
|
|
480
|
+
// zeros ('01.50') aren't published digits, so those are left to the canonical form.
|
|
481
|
+
const PUBLISHED_DECIMAL = /^(?:0|[1-9]\d*)\.\d*0$/;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Keeps the published digits of `text` on a Rate just built from it, when its canonical form would
|
|
485
|
+
* drop some ('1.1650' prints as '1.165'). Assigned directly: the quote is the rate's own text, so
|
|
486
|
+
* there is nothing to check, and building a rate inline stays cheap.
|
|
487
|
+
*/
|
|
488
|
+
function keepDigits<R extends Rate>(rate: R, text: Factor): R {
|
|
489
|
+
// Only a last character of '0' can be dropped, so most rates are settled by one comparison.
|
|
490
|
+
const dropsDigits =
|
|
491
|
+
typeof text === 'string' &&
|
|
492
|
+
text.charCodeAt(text.length - 1) === 48 &&
|
|
493
|
+
rate.quote === undefined &&
|
|
494
|
+
PUBLISHED_DECIMAL.test(text);
|
|
495
|
+
if (dropsDigits) {
|
|
496
|
+
(rate as { quote?: RateQuote }).quote = { rate: text };
|
|
497
|
+
}
|
|
498
|
+
return rate;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* An exact rate pinned to a currency pair, so converting the wrong currency is a type error. Give
|
|
503
|
+
* the rate as a decimal string ('1.1165', every digit kept), a "num/den" string, a bigint or a
|
|
504
|
+
* Ratio. Numbers are read at their shortest decimal form (1.234 is exactly 1234/1000).
|
|
505
|
+
*
|
|
506
|
+
* const eurUsd = exchangeRate('EUR', 'USD', '1.1165', { source: 'ECB', asOf: '2026-11-06' });
|
|
507
|
+
* eurUsd.convert(money('100', 'EUR'), 'halfEven'); // 111.65 USD
|
|
508
|
+
* eurUsd.inverse().ratio // 2000/2233, exactly
|
|
509
|
+
*/
|
|
510
|
+
export function exchangeRate<From extends CurrencyInput, To extends CurrencyInput>(
|
|
511
|
+
from: From,
|
|
512
|
+
to: To,
|
|
513
|
+
rate: Factor,
|
|
514
|
+
info?: RateInfo,
|
|
515
|
+
): Rate<From, To> {
|
|
516
|
+
return keepDigits(new Rate(from, to, ratio(rate), info), rate);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Rebuilds a Rate, with its audit chain, from `rate.toJSON()`. */
|
|
520
|
+
export function rateFromJSON(json: RateJSON): Rate {
|
|
521
|
+
const derivedFrom = json.derivedFrom?.map(rateFromJSON);
|
|
522
|
+
const rate = new Rate(json.from, json.to, ratio(json.rate), infoOf(json), derivedFrom);
|
|
523
|
+
return keepDigits(rate, json.rate);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Whether `value` is a Rate. */
|
|
527
|
+
export function isRate(value: unknown): value is Rate {
|
|
528
|
+
return value instanceof Rate;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The exact rate a processor actually applied: settled ÷ presented. Use it to audit a quoted rate,
|
|
533
|
+
* which after the processor's own rounding often doesn't reproduce the settled amount.
|
|
534
|
+
*
|
|
535
|
+
* impliedRate(money('10.00', 'EUR'), money('12.34', 'USD')) // EUR→USD 1.234
|
|
536
|
+
*/
|
|
537
|
+
export function impliedRate<From extends string, To extends string>(
|
|
538
|
+
presented: Money<From>,
|
|
539
|
+
settled: Money<To>,
|
|
540
|
+
info?: RateInfo,
|
|
541
|
+
): Rate<From, To> {
|
|
542
|
+
// (settled.minor / 10^settled.scale) ÷ (presented.minor / 10^presented.scale)
|
|
543
|
+
const numerator = BigInt(settled.minor) * pow10n(presented.scale);
|
|
544
|
+
const denominator = BigInt(presented.minor) * pow10n(settled.scale);
|
|
545
|
+
return new Rate(presented.currency, settled.currency, ratio(numerator, denominator), info);
|
|
546
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when an operation would have to round but the rounding mode is `'unnecessary'`. It lives
|
|
3
|
+
* in its own module so the generated kernels can throw it without importing `rounding.ts`, which
|
|
4
|
+
* imports them.
|
|
5
|
+
*/
|
|
6
|
+
export class RoundingError extends RangeError {
|
|
7
|
+
override name = 'RoundingError';
|
|
8
|
+
}
|