@ducatjs/money 0.1.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/README.md +479 -62
- package/data/cash-rounding.json +123 -2
- package/data/cldr-cash.json +101 -0
- package/data/historic.json +78 -0
- package/data/iso4217-list-three.xml +1185 -0
- package/dist/adyen-fx.d.ts.map +1 -1
- package/dist/adyen-fx.js +22 -4
- package/dist/adyen-fx.js.map +1 -1
- 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/cash.d.ts +30 -1
- package/dist/cash.d.ts.map +1 -1
- package/dist/cash.js +50 -2
- package/dist/cash.js.map +1 -1
- package/dist/convert.d.ts +10 -9
- package/dist/convert.d.ts.map +1 -1
- package/dist/convert.js +13 -3
- package/dist/convert.js.map +1 -1
- 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 +37 -2
- package/dist/ecb.d.ts.map +1 -1
- package/dist/ecb.js +148 -10
- package/dist/ecb.js.map +1 -1
- 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 +45 -0
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +167 -24
- package/dist/fetch.js.map +1 -1
- package/dist/fn.d.ts +8 -1
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +8 -1
- package/dist/fn.js.map +1 -1
- package/dist/generated/cash.d.ts +2 -0
- package/dist/generated/cash.d.ts.map +1 -1
- package/dist/generated/cash.js +101 -0
- package/dist/generated/cash.js.map +1 -1
- 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/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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- 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 +93 -9
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +135 -9
- package/dist/money.js.map +1 -1
- package/dist/parse.d.ts +45 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +290 -3
- package/dist/parse.js.map +1 -1
- 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/all.d.ts +8 -1
- package/dist/plugins/all.d.ts.map +1 -1
- package/dist/plugins/all.js +8 -1
- package/dist/plugins/all.js.map +1 -1
- package/dist/plugins/convert.d.ts +3 -4
- package/dist/plugins/convert.d.ts.map +1 -1
- package/dist/plugins/convert.js.map +1 -1
- package/dist/plugins/tax.d.ts +4 -5
- package/dist/plugins/tax.d.ts.map +1 -1
- package/dist/plugins/tax.js.map +1 -1
- 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 +25 -14
- package/dist/prisma.d.ts.map +1 -1
- package/dist/prisma.js +29 -19
- package/dist/prisma.js.map +1 -1
- package/dist/provider.d.ts +21 -7
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +51 -11
- package/dist/provider.js.map +1 -1
- 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 +61 -5
- package/dist/rate.d.ts.map +1 -1
- package/dist/rate.js +133 -33
- package/dist/rate.js.map +1 -1
- package/dist/rounding.js +1 -1
- package/dist/rounding.js.map +1 -1
- 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/tax.d.ts +13 -9
- package/dist/tax.d.ts.map +1 -1
- package/dist/tax.js +20 -4
- package/dist/tax.js.map +1 -1
- 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 +116 -5
- package/src/adyen-fx.ts +25 -4
- package/src/boc.ts +136 -0
- package/src/boe.ts +131 -0
- package/src/cash.ts +70 -2
- package/src/convert.ts +33 -15
- 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 +179 -13
- package/src/fed.ts +146 -0
- package/src/fetch.ts +207 -28
- package/src/fn.ts +14 -0
- package/src/generated/cash.ts +103 -0
- package/src/generated/historic.ts +67 -0
- package/src/historic.ts +77 -0
- package/src/history.ts +333 -0
- package/src/hmrc.ts +125 -0
- package/src/index.ts +4 -0
- package/src/kysely.ts +588 -0
- package/src/money.ts +210 -12
- package/src/parse.ts +371 -4
- package/src/pg.ts +410 -0
- package/src/plugins/all.ts +10 -0
- package/src/plugins/convert.ts +4 -5
- package/src/plugins/tax.ts +9 -10
- package/src/policy.ts +124 -0
- package/src/prisma.ts +91 -10
- package/src/provider.ts +81 -16
- package/src/rate-text.ts +151 -0
- package/src/rate.ts +205 -36
- package/src/rounding.ts +1 -1
- package/src/schema.ts +119 -0
- package/src/sql.ts +178 -0
- package/src/tax.ts +40 -9
- 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/prisma.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 2. a DECIMAL/NUMERIC column (+ a currency column or constant)
|
|
9
9
|
* -> decimalMoneyField / toDecimalValue
|
|
10
10
|
*/
|
|
11
|
+
import { MAX_DECIMAL_SCALE } from './decimal.js';
|
|
11
12
|
import {
|
|
12
13
|
fromMinor,
|
|
13
14
|
money,
|
|
@@ -42,23 +43,84 @@ export function toDecimalValue<D>(money: Money, Decimal: new (value: string) =>
|
|
|
42
43
|
|
|
43
44
|
type Row<A extends string, V> = { [P in A]: V };
|
|
44
45
|
|
|
46
|
+
/** Options for `minorMoneyField` and `toMinorColumns`: a column that records each amount's scale. */
|
|
47
|
+
export interface ScaleColumnOption<S extends string> {
|
|
48
|
+
/**
|
|
49
|
+
* The integer column holding the number of decimals the minor units are counted in (2 for
|
|
50
|
+
* cents). With it, any scale round-trips, and stored amounts keep their meaning even if a
|
|
51
|
+
* currency's exponent changes.
|
|
52
|
+
*/
|
|
53
|
+
readonly scale: S;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A scale read from a row: a safe, non-negative integer (a SMALLINT or INT column). */
|
|
57
|
+
function readScale(value: unknown, column: string): number {
|
|
58
|
+
const scale = typeof value === 'bigint' ? Number(value) : value;
|
|
59
|
+
// capped like parsed decimals: a corrupt row must not make toDecimal build a huge power of ten
|
|
60
|
+
const isValid =
|
|
61
|
+
typeof scale === 'number' &&
|
|
62
|
+
Number.isInteger(scale) &&
|
|
63
|
+
scale >= 0 &&
|
|
64
|
+
scale <= MAX_DECIMAL_SCALE;
|
|
65
|
+
if (!isValid) {
|
|
66
|
+
throw new RangeError(
|
|
67
|
+
`Expected an integer scale from 0 to ${MAX_DECIMAL_SCALE} in "${column}", got ${String(value)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return scale;
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
/**
|
|
46
74
|
* A Prisma result-extension field that exposes a Money built from `amount_minor` + `currency`
|
|
47
|
-
* columns:
|
|
75
|
+
* columns, and optionally a scale column:
|
|
48
76
|
*
|
|
49
77
|
* const db = new PrismaClient().$extends({
|
|
50
78
|
* result: { order: { total: minorMoneyField('totalMinor', 'currency') } },
|
|
51
79
|
* });
|
|
52
80
|
* (await db.order.findFirstOrThrow()).total // Money
|
|
81
|
+
*
|
|
82
|
+
* Without a scale column the minor units are read at the currency's current exponent.
|
|
53
83
|
*/
|
|
54
84
|
export function minorMoneyField<const A extends string, const K extends string>(
|
|
55
85
|
amount: A,
|
|
56
86
|
currency: K,
|
|
57
|
-
) {
|
|
87
|
+
): {
|
|
88
|
+
needs: { [P in A | K]: true };
|
|
89
|
+
compute: (row: Row<A, bigint | number> & Row<K, string>) => Money;
|
|
90
|
+
};
|
|
91
|
+
export function minorMoneyField<
|
|
92
|
+
const A extends string,
|
|
93
|
+
const K extends string,
|
|
94
|
+
const S extends string,
|
|
95
|
+
>(
|
|
96
|
+
amount: A,
|
|
97
|
+
currency: K,
|
|
98
|
+
options: ScaleColumnOption<S>,
|
|
99
|
+
): {
|
|
100
|
+
needs: { [P in A | K | S]: true };
|
|
101
|
+
compute: (row: Row<A, bigint | number> & Row<K, string> & Row<S, bigint | number>) => Money;
|
|
102
|
+
};
|
|
103
|
+
export function minorMoneyField(
|
|
104
|
+
amount: string,
|
|
105
|
+
currency: string,
|
|
106
|
+
options?: ScaleColumnOption<string>,
|
|
107
|
+
): { needs: Record<string, true>; compute: (row: Record<string, unknown>) => Money } {
|
|
108
|
+
if (options === undefined) {
|
|
109
|
+
return {
|
|
110
|
+
needs: { [amount]: true, [currency]: true },
|
|
111
|
+
compute: (row) => fromMinor(row[amount] as bigint | number, row[currency] as string),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const scaleColumn = options.scale;
|
|
58
116
|
return {
|
|
59
|
-
needs: { [amount]: true, [currency]: true
|
|
60
|
-
compute: (row
|
|
61
|
-
fromMinor(
|
|
117
|
+
needs: { [amount]: true, [currency]: true, [scaleColumn]: true },
|
|
118
|
+
compute: (row) =>
|
|
119
|
+
fromMinor(
|
|
120
|
+
row[amount] as bigint | number,
|
|
121
|
+
row[currency] as string,
|
|
122
|
+
readScale(row[scaleColumn], scaleColumn),
|
|
123
|
+
),
|
|
62
124
|
};
|
|
63
125
|
}
|
|
64
126
|
|
|
@@ -95,16 +157,35 @@ export function decimalMoneyField(
|
|
|
95
157
|
|
|
96
158
|
/**
|
|
97
159
|
* Column values for writes: `data: { ...toMinorColumns(total, 'totalMinor', 'currency') }`.
|
|
98
|
-
* Minor units are a bigint (Prisma BigInt).
|
|
99
|
-
* the
|
|
160
|
+
* Minor units are a bigint (Prisma BigInt). Without a scale column, throws if the value has a
|
|
161
|
+
* non-standard scale, because the columns cannot record it. With `{ scale: 'amountScale' }`, any
|
|
162
|
+
* scale is stored, next to the minor units it counts.
|
|
100
163
|
*/
|
|
101
164
|
export function toMinorColumns<const A extends string, const K extends string>(
|
|
102
165
|
money: Money,
|
|
103
166
|
amount: A,
|
|
104
167
|
currency: K,
|
|
105
|
-
): Row<A, bigint> & Row<K, string
|
|
106
|
-
|
|
107
|
-
|
|
168
|
+
): Row<A, bigint> & Row<K, string>;
|
|
169
|
+
export function toMinorColumns<
|
|
170
|
+
const A extends string,
|
|
171
|
+
const K extends string,
|
|
172
|
+
const S extends string,
|
|
173
|
+
>(
|
|
174
|
+
money: Money,
|
|
175
|
+
amount: A,
|
|
176
|
+
currency: K,
|
|
177
|
+
options: ScaleColumnOption<S>,
|
|
178
|
+
): Row<A, bigint> & Row<K, string> & Row<S, number>;
|
|
179
|
+
export function toMinorColumns(
|
|
180
|
+
money: Money,
|
|
181
|
+
amount: string,
|
|
182
|
+
currency: string,
|
|
183
|
+
options?: ScaleColumnOption<string>,
|
|
184
|
+
): Record<string, unknown> {
|
|
185
|
+
if (options === undefined) {
|
|
186
|
+
return { [amount]: toMinorAtIsoScale(money), [currency]: money.currency };
|
|
187
|
+
}
|
|
188
|
+
return { [amount]: toMinor(money), [currency]: money.currency, [options.scale]: money.scale };
|
|
108
189
|
}
|
|
109
190
|
|
|
110
191
|
function toMinorAtIsoScale(money: Money): bigint {
|
package/src/provider.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// async boundary.
|
|
4
4
|
import type { Factor } from './decimal.js';
|
|
5
5
|
import { CurrencyMismatchError } from './money.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
exchangeRate,
|
|
8
|
+
hasEnded,
|
|
9
|
+
hasStarted,
|
|
10
|
+
isRate,
|
|
11
|
+
toIsoString,
|
|
12
|
+
type Rate,
|
|
13
|
+
type RateInfo,
|
|
14
|
+
} from './rate.js';
|
|
7
15
|
|
|
8
16
|
const DAY_MS = 86_400_000;
|
|
9
17
|
|
|
@@ -29,6 +37,12 @@ export interface RateTable extends RateProvider {
|
|
|
29
37
|
readonly base?: string;
|
|
30
38
|
/** The table's date, when it has one. */
|
|
31
39
|
readonly asOf?: string;
|
|
40
|
+
/** When the table was published, when that is known. */
|
|
41
|
+
readonly availableAt?: string;
|
|
42
|
+
/** The last day (or instant) the table applies to, for a table valid for a period. */
|
|
43
|
+
readonly validUntil?: string;
|
|
44
|
+
/** Currencies the source listed without a value (holidays, `ND`, `N/A`). */
|
|
45
|
+
readonly missing?: readonly string[];
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
/** Thrown when no provider has a rate for the pair. */
|
|
@@ -95,33 +109,68 @@ export function identity(): RateProvider {
|
|
|
95
109
|
};
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
112
|
+
/**
|
|
113
|
+
* A one-base table: `rates.USD` is the number of USD per 1 `base` (or a base → USD Rate), or null
|
|
114
|
+
* when the source has no value for the day. For rates published the other way round or per 100
|
|
115
|
+
* units, see `quotedTable` in `@ducatjs/money/convert/history`.
|
|
116
|
+
*/
|
|
117
|
+
export interface RateTableSpec extends Omit<RateInfo, 'quote'> {
|
|
100
118
|
readonly base: string;
|
|
101
|
-
readonly rates: Readonly<Record<string, Factor>>;
|
|
119
|
+
readonly rates: Readonly<Record<string, Factor | Rate | null>>;
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
/**
|
|
105
123
|
* A fixed table of rates: one base currency against many (ECB style), or a list of Rates. Only the
|
|
106
124
|
* pairs given are answered; wrap it in `reversed()` or `via()` for the others.
|
|
107
125
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
126
|
+
* A query with an `asOf` gets nothing from a rate that doesn't apply then: one dated after it, one
|
|
127
|
+
* published (`availableAt`) after it, or one whose `validUntil` has passed. Days are UTC days, and
|
|
128
|
+
* a date query covers the whole day: without an `availableAt`, a query at any time on the rate's
|
|
129
|
+
* own day gets the rate, even before the source published it (the ECB publishes at about 15:00
|
|
130
|
+
* UTC). Give `availableAt` to rule that out.
|
|
111
131
|
*
|
|
112
132
|
* const ecb = rateTable({ base: 'EUR', source: 'ECB', asOf: '2026-11-06', rates: { USD: '1.1165' } });
|
|
113
133
|
*/
|
|
114
134
|
export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
|
|
115
135
|
let rates: Rate[];
|
|
116
|
-
const header: {
|
|
136
|
+
const header: {
|
|
137
|
+
base?: string;
|
|
138
|
+
asOf?: string;
|
|
139
|
+
availableAt?: string;
|
|
140
|
+
validUntil?: string;
|
|
141
|
+
missing?: string[];
|
|
142
|
+
} = {};
|
|
117
143
|
if (isRateList(spec)) {
|
|
118
144
|
rates = [...spec];
|
|
119
145
|
} else {
|
|
120
|
-
rates =
|
|
146
|
+
rates = [];
|
|
147
|
+
const missing: string[] = [];
|
|
148
|
+
for (const [currency, entry] of Object.entries(spec.rates)) {
|
|
149
|
+
if (entry === null) {
|
|
150
|
+
missing.push(currency);
|
|
151
|
+
} else if (isRate(entry)) {
|
|
152
|
+
// A ready-made Rate is looked up under its own pair, so it must be the one its key says.
|
|
153
|
+
if (entry.from !== spec.base || entry.to !== currency) {
|
|
154
|
+
throw new CurrencyMismatchError(`${spec.base}→${currency}`, `${entry.from}→${entry.to}`);
|
|
155
|
+
}
|
|
156
|
+
rates.push(entry);
|
|
157
|
+
} else {
|
|
158
|
+
rates.push(exchangeRate(spec.base, currency, entry, spec));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
121
161
|
header.base = spec.base;
|
|
122
162
|
if (spec.asOf !== undefined) {
|
|
123
163
|
header.asOf = toIsoString(spec.asOf);
|
|
124
164
|
}
|
|
165
|
+
if (spec.availableAt !== undefined) {
|
|
166
|
+
header.availableAt = toIsoString(spec.availableAt);
|
|
167
|
+
}
|
|
168
|
+
if (spec.validUntil !== undefined) {
|
|
169
|
+
header.validUntil = toIsoString(spec.validUntil);
|
|
170
|
+
}
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
header.missing = missing;
|
|
173
|
+
}
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
const byPair = new Map<string, Rate>();
|
|
@@ -137,11 +186,9 @@ export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
|
|
|
137
186
|
if (rate === undefined) {
|
|
138
187
|
return undefined;
|
|
139
188
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
dayOf(query.asOf) < dayOf(rate.asOf);
|
|
144
|
-
if (askedBeforeRateExisted) {
|
|
189
|
+
// The table's own dates count too: a ready-made Rate entry doesn't carry them.
|
|
190
|
+
const asOf = query?.asOf;
|
|
191
|
+
if (asOf !== undefined && (!appliesAt(rate, asOf) || !appliesAt(header, asOf))) {
|
|
145
192
|
return undefined;
|
|
146
193
|
}
|
|
147
194
|
// The key matched, so the rate's currencies are exactly `from` and `to`.
|
|
@@ -150,6 +197,20 @@ export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
|
|
|
150
197
|
};
|
|
151
198
|
}
|
|
152
199
|
|
|
200
|
+
/** Whether a rate (or a table) applies at `at`: dated, published and still valid then. */
|
|
201
|
+
function appliesAt(
|
|
202
|
+
rate: { readonly asOf?: string; readonly availableAt?: string; readonly validUntil?: string },
|
|
203
|
+
at: string | Date,
|
|
204
|
+
): boolean {
|
|
205
|
+
if (rate.asOf !== undefined && dayOf(at) < dayOf(rate.asOf)) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (rate.availableAt !== undefined && !hasStarted(rate.availableAt, at)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
return rate.validUntil === undefined || !hasEnded(rate.validUntil, at);
|
|
212
|
+
}
|
|
213
|
+
|
|
153
214
|
function isRateList(spec: RateTableSpec | readonly Rate[]): spec is readonly Rate[] {
|
|
154
215
|
return Array.isArray(spec) && spec.every(isRate);
|
|
155
216
|
}
|
|
@@ -235,7 +296,9 @@ export interface Snapshots {
|
|
|
235
296
|
* undefined when there is none. Days are UTC days. Loaded tables are cached by day. Misses and
|
|
236
297
|
* failures are not cached, so a table that isn't published yet is tried again next time. A loader
|
|
237
298
|
* 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.
|
|
299
|
+
* silently become a stale rate. The rates carry the day they're from. A table with an
|
|
300
|
+
* `availableAt` after an instant asked for is passed over, so a lookup at 09:00 on a publication
|
|
301
|
+
* day gets the day before.
|
|
239
302
|
*
|
|
240
303
|
* const history = snapshots(async (day) => parseEcbXml(await readFile(`ecb/${day}.xml`, 'utf8'))[0]);
|
|
241
304
|
* const fx = await history.at('2026-11-08'); // a Sunday: Friday 2026-11-06's table
|
|
@@ -298,7 +361,9 @@ export function snapshots(
|
|
|
298
361
|
// Sequential on purpose: stop at the first day that has a table, without loading older
|
|
299
362
|
// days. oxlint-disable-next-line no-await-in-loop
|
|
300
363
|
const table = await loadDay(day);
|
|
301
|
-
|
|
364
|
+
// A table not yet published at the instant asked for is passed over like a missing one.
|
|
365
|
+
const availableAt = (table as Partial<RateTable> | undefined)?.availableAt;
|
|
366
|
+
if (table !== undefined && (availableAt === undefined || hasStarted(availableAt, asOf))) {
|
|
302
367
|
return table;
|
|
303
368
|
}
|
|
304
369
|
}
|
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
|
+
}
|