@ducatjs/money 0.1.0 → 0.2.1
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/NOTICE +73 -0
- 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/adyen.d.ts +1 -1
- package/dist/plugins/adyen.d.ts.map +1 -1
- package/dist/plugins/adyen.js.map +1 -1
- 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/allocate.d.ts +1 -1
- package/dist/plugins/allocate.d.ts.map +1 -1
- package/dist/plugins/allocate.js.map +1 -1
- package/dist/plugins/braintree.d.ts +1 -1
- package/dist/plugins/braintree.d.ts.map +1 -1
- package/dist/plugins/braintree.js.map +1 -1
- package/dist/plugins/cash.d.ts +1 -1
- package/dist/plugins/cash.d.ts.map +1 -1
- package/dist/plugins/cash.js.map +1 -1
- package/dist/plugins/checkout.d.ts +1 -1
- package/dist/plugins/checkout.d.ts.map +1 -1
- package/dist/plugins/checkout.js.map +1 -1
- package/dist/plugins/convert.d.ts +5 -5
- package/dist/plugins/convert.d.ts.map +1 -1
- package/dist/plugins/convert.js.map +1 -1
- package/dist/plugins/exact.d.ts +1 -1
- package/dist/plugins/exact.d.ts.map +1 -1
- package/dist/plugins/exact.js.map +1 -1
- package/dist/plugins/fixed.d.ts +1 -1
- package/dist/plugins/fixed.d.ts.map +1 -1
- package/dist/plugins/fixed.js.map +1 -1
- package/dist/plugins/format.d.ts +1 -1
- package/dist/plugins/format.d.ts.map +1 -1
- package/dist/plugins/format.js.map +1 -1
- package/dist/plugins/klarna.d.ts +1 -1
- package/dist/plugins/klarna.d.ts.map +1 -1
- package/dist/plugins/klarna.js.map +1 -1
- package/dist/plugins/paypal.d.ts +1 -1
- package/dist/plugins/paypal.d.ts.map +1 -1
- package/dist/plugins/paypal.js +0 -4
- package/dist/plugins/paypal.js.map +1 -1
- package/dist/plugins/prisma.d.ts +1 -1
- package/dist/plugins/prisma.d.ts.map +1 -1
- package/dist/plugins/prisma.js +0 -4
- package/dist/plugins/prisma.js.map +1 -1
- package/dist/plugins/stripe.d.ts +1 -1
- package/dist/plugins/stripe.d.ts.map +1 -1
- package/dist/plugins/stripe.js +0 -4
- package/dist/plugins/stripe.js.map +1 -1
- package/dist/plugins/tax.d.ts +6 -6
- 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/adyen.ts +3 -2
- package/src/plugins/all.ts +10 -0
- package/src/plugins/allocate.ts +3 -2
- package/src/plugins/braintree.ts +3 -2
- package/src/plugins/cash.ts +3 -2
- package/src/plugins/checkout.ts +3 -2
- package/src/plugins/convert.ts +6 -6
- package/src/plugins/exact.ts +3 -2
- package/src/plugins/fixed.ts +3 -2
- package/src/plugins/format.ts +3 -2
- package/src/plugins/klarna.ts +3 -2
- package/src/plugins/paypal.ts +3 -2
- package/src/plugins/prisma.ts +3 -2
- package/src/plugins/stripe.ts +3 -2
- package/src/plugins/tax.ts +11 -11
- 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/rate.ts
CHANGED
|
@@ -12,7 +12,15 @@ import {
|
|
|
12
12
|
type Factor,
|
|
13
13
|
type Ratio,
|
|
14
14
|
} from './decimal.js';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
CurrencyMismatchError,
|
|
17
|
+
make,
|
|
18
|
+
roundWithPolicy,
|
|
19
|
+
type CurrencyInput,
|
|
20
|
+
type Money,
|
|
21
|
+
type RoundingInput,
|
|
22
|
+
type RoundingPolicy,
|
|
23
|
+
} from './money.js';
|
|
16
24
|
import {
|
|
17
25
|
DEFAULT_ROUNDING,
|
|
18
26
|
divideAndRound,
|
|
@@ -26,13 +34,39 @@ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
|
|
|
26
34
|
export type RateType = 'mid' | 'bid' | 'ask' | (string & {});
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
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.
|
|
30
55
|
*/
|
|
31
56
|
export interface RateInfo {
|
|
32
57
|
readonly source?: string;
|
|
58
|
+
/** The date or time the rate applies to. */
|
|
33
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). */
|
|
34
66
|
readonly validUntil?: string | Date;
|
|
35
67
|
readonly type?: RateType;
|
|
68
|
+
/** The rate as the source published it. */
|
|
69
|
+
readonly quote?: RateQuote;
|
|
36
70
|
}
|
|
37
71
|
|
|
38
72
|
/** Options for converting an amount. */
|
|
@@ -64,12 +98,22 @@ export interface RateJSON {
|
|
|
64
98
|
rate: string;
|
|
65
99
|
source?: string;
|
|
66
100
|
asOf?: string;
|
|
101
|
+
availableAt?: string;
|
|
67
102
|
validUntil?: string;
|
|
68
103
|
type?: string;
|
|
104
|
+
/** Present only when it says more than `rate` does: a `per` or an inverted quote. */
|
|
105
|
+
quote?: RateQuote;
|
|
69
106
|
derivedFrom?: RateJSON[];
|
|
70
107
|
}
|
|
71
108
|
|
|
72
|
-
type MutableInfo = {
|
|
109
|
+
type MutableInfo = {
|
|
110
|
+
source?: string;
|
|
111
|
+
asOf?: string;
|
|
112
|
+
availableAt?: string;
|
|
113
|
+
validUntil?: string;
|
|
114
|
+
type?: RateType;
|
|
115
|
+
quote?: RateQuote;
|
|
116
|
+
};
|
|
73
117
|
/** RateInfo that may also hold explicit undefineds (as optional parameters do). */
|
|
74
118
|
type LooseInfo = { readonly [Key in keyof RateInfo]?: RateInfo[Key] | undefined };
|
|
75
119
|
|
|
@@ -93,18 +137,52 @@ export function toIsoString(time: string | Date): string {
|
|
|
93
137
|
return date.toISOString();
|
|
94
138
|
}
|
|
95
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
|
+
|
|
96
145
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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.
|
|
99
170
|
*/
|
|
100
|
-
function earlier(
|
|
171
|
+
function earlier(
|
|
172
|
+
first: string | undefined,
|
|
173
|
+
second: string | undefined,
|
|
174
|
+
later?: boolean,
|
|
175
|
+
): string | undefined {
|
|
101
176
|
if (first === undefined) {
|
|
102
177
|
return second;
|
|
103
178
|
}
|
|
104
|
-
if (second === undefined
|
|
179
|
+
if (second === undefined) {
|
|
105
180
|
return first;
|
|
106
181
|
}
|
|
107
|
-
|
|
182
|
+
if (later === true) {
|
|
183
|
+
return first >= second ? first : second;
|
|
184
|
+
}
|
|
185
|
+
return first <= second ? first : second;
|
|
108
186
|
}
|
|
109
187
|
|
|
110
188
|
/**
|
|
@@ -163,8 +241,10 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
163
241
|
// field semantics would otherwise define them all as undefined.
|
|
164
242
|
declare readonly source?: string;
|
|
165
243
|
declare readonly asOf?: string;
|
|
244
|
+
declare readonly availableAt?: string;
|
|
166
245
|
declare readonly validUntil?: string;
|
|
167
246
|
declare readonly type?: RateType;
|
|
247
|
+
declare readonly quote?: RateQuote;
|
|
168
248
|
declare readonly derivedFrom?: readonly Rate[];
|
|
169
249
|
|
|
170
250
|
/** @internal use exchangeRate() */
|
|
@@ -181,17 +261,19 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
181
261
|
|
|
182
262
|
// Only defined fields are assigned, so rates compare and serialise without undefined keys.
|
|
183
263
|
const writable = this as { -readonly [Key in keyof Rate]?: unknown };
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|
|
192
274
|
}
|
|
193
|
-
if (info
|
|
194
|
-
|
|
275
|
+
if (info !== undefined) {
|
|
276
|
+
Object.assign(this, infoOf(info));
|
|
195
277
|
}
|
|
196
278
|
if (derivedFrom !== undefined) {
|
|
197
279
|
writable.derivedFrom = derivedFrom;
|
|
@@ -205,7 +287,15 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
205
287
|
type = OPPOSITE_SIDE[type] ?? type;
|
|
206
288
|
}
|
|
207
289
|
const reciprocal = ratio(this.ratio.den, this.ratio.num);
|
|
208
|
-
|
|
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]);
|
|
209
299
|
}
|
|
210
300
|
|
|
211
301
|
/**
|
|
@@ -223,9 +313,12 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
223
313
|
if (this.type === next.type) {
|
|
224
314
|
type = this.type;
|
|
225
315
|
}
|
|
316
|
+
// A composite is known only once both legs are, and is valid only while both are.
|
|
226
317
|
const asOf = earlier(this.asOf, next.asOf);
|
|
318
|
+
const availableAt = earlier(this.availableAt, next.availableAt, true);
|
|
227
319
|
const validUntil = earlier(this.validUntil, next.validUntil);
|
|
228
|
-
|
|
320
|
+
const info = timing(asOf, availableAt, validUntil, type);
|
|
321
|
+
return new Rate(this.from, next.to, product, info, [this, next]);
|
|
229
322
|
}
|
|
230
323
|
|
|
231
324
|
/** This rate × (1 + margin), exactly: `withMargin('-0.02')` gives 2% less `to` per `from`. */
|
|
@@ -251,14 +344,26 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
251
344
|
return formatScaled(scaled, places);
|
|
252
345
|
}
|
|
253
346
|
|
|
254
|
-
/**
|
|
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
|
+
*/
|
|
255
351
|
toString(): string {
|
|
352
|
+
const quote = this.quote;
|
|
353
|
+
if (quote !== undefined && quote.per === undefined && quote.inverted !== true) {
|
|
354
|
+
return quote.rate;
|
|
355
|
+
}
|
|
256
356
|
return ratioToDecimal(this.ratio) ?? `${this.ratio.num}/${this.ratio.den}`;
|
|
257
357
|
}
|
|
258
358
|
|
|
259
359
|
/** The rate, its metadata and its audit chain as JSON. `rateFromJSON` reverses it. */
|
|
260
360
|
toJSON(): RateJSON {
|
|
261
|
-
const
|
|
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
|
+
}
|
|
262
367
|
if (this.derivedFrom !== undefined) {
|
|
263
368
|
json.derivedFrom = this.derivedFrom.map((input) => input.toJSON());
|
|
264
369
|
}
|
|
@@ -273,20 +378,27 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
273
378
|
if (this.validUntil === undefined) {
|
|
274
379
|
return false;
|
|
275
380
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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;
|
|
282
391
|
}
|
|
283
|
-
return
|
|
392
|
+
return hasStarted(this.availableAt, at);
|
|
284
393
|
}
|
|
285
394
|
|
|
286
|
-
/**
|
|
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
|
+
*/
|
|
287
399
|
convert(
|
|
288
400
|
money: Money<From>,
|
|
289
|
-
rounding:
|
|
401
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
290
402
|
options?: ConvertOptions,
|
|
291
403
|
): Money<To> {
|
|
292
404
|
if (money.currency !== this.from) {
|
|
@@ -295,12 +407,35 @@ export class Rate<From extends string = string, To extends string = string> {
|
|
|
295
407
|
if (options?.strictValidity === true && this.validUntil !== undefined && this.isExpired()) {
|
|
296
408
|
throw new RateExpiredError(this.from, this.to, this.validUntil);
|
|
297
409
|
}
|
|
410
|
+
if (typeof rounding === 'function') {
|
|
411
|
+
return convertWithPolicy(money, this.to, this.ratio, rounding, options);
|
|
412
|
+
}
|
|
298
413
|
const scale = options?.scale ?? currencyExponent(this.to);
|
|
299
414
|
const minor = multiplyByRatioAtScale(money.minor, this.ratio, scale - money.scale, rounding);
|
|
300
415
|
return make(this.to, minor, scale);
|
|
301
416
|
}
|
|
302
417
|
}
|
|
303
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
|
+
|
|
304
439
|
/** The metadata fields of `info` that are defined (no undefined keys). */
|
|
305
440
|
function infoOf(info: LooseInfo): MutableInfo {
|
|
306
441
|
const defined: MutableInfo = {};
|
|
@@ -310,24 +445,57 @@ function infoOf(info: LooseInfo): MutableInfo {
|
|
|
310
445
|
if (info.asOf !== undefined) {
|
|
311
446
|
defined.asOf = toIsoString(info.asOf);
|
|
312
447
|
}
|
|
448
|
+
if (info.availableAt !== undefined) {
|
|
449
|
+
defined.availableAt = toIsoString(info.availableAt);
|
|
450
|
+
}
|
|
313
451
|
if (info.validUntil !== undefined) {
|
|
314
452
|
defined.validUntil = toIsoString(info.validUntil);
|
|
315
453
|
}
|
|
316
454
|
if (info.type !== undefined) {
|
|
317
455
|
defined.type = info.type;
|
|
318
456
|
}
|
|
457
|
+
if (info.quote !== undefined) {
|
|
458
|
+
defined.quote = info.quote;
|
|
459
|
+
}
|
|
319
460
|
return defined;
|
|
320
461
|
}
|
|
321
462
|
|
|
322
|
-
function timing(
|
|
323
|
-
|
|
463
|
+
function timing(
|
|
464
|
+
asOf?: string,
|
|
465
|
+
availableAt?: string,
|
|
466
|
+
validUntil?: string,
|
|
467
|
+
type?: RateType,
|
|
468
|
+
): RateInfo {
|
|
469
|
+
return infoOf({ asOf, availableAt, validUntil, type });
|
|
324
470
|
}
|
|
325
471
|
|
|
326
472
|
/**
|
|
327
473
|
* A derived rate keeps the timing of its input; the input's source stays with it, in derivedFrom.
|
|
328
474
|
*/
|
|
329
475
|
function timingOf(input: Rate, type?: RateType): RateInfo {
|
|
330
|
-
return timing(input.asOf, input.validUntil, type);
|
|
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;
|
|
331
499
|
}
|
|
332
500
|
|
|
333
501
|
/**
|
|
@@ -345,13 +513,14 @@ export function exchangeRate<From extends CurrencyInput, To extends CurrencyInpu
|
|
|
345
513
|
rate: Factor,
|
|
346
514
|
info?: RateInfo,
|
|
347
515
|
): Rate<From, To> {
|
|
348
|
-
return new Rate(from, to, ratio(rate), info);
|
|
516
|
+
return keepDigits(new Rate(from, to, ratio(rate), info), rate);
|
|
349
517
|
}
|
|
350
518
|
|
|
351
519
|
/** Rebuilds a Rate, with its audit chain, from `rate.toJSON()`. */
|
|
352
520
|
export function rateFromJSON(json: RateJSON): Rate {
|
|
353
521
|
const derivedFrom = json.derivedFrom?.map(rateFromJSON);
|
|
354
|
-
|
|
522
|
+
const rate = new Rate(json.from, json.to, ratio(json.rate), infoOf(json), derivedFrom);
|
|
523
|
+
return keepDigits(rate, json.rate);
|
|
355
524
|
}
|
|
356
525
|
|
|
357
526
|
/** Whether `value` is a Rate. */
|
package/src/rounding.ts
CHANGED
|
@@ -65,7 +65,7 @@ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
|
|
|
65
65
|
*/
|
|
66
66
|
export function divideAndRound(dividend: number, divisor: number, mode: RoundingMode): number {
|
|
67
67
|
if (divisor === 1) {
|
|
68
|
-
return dividend;
|
|
68
|
+
return dividend + 0; // + 0 normalises -0 (0 * -1 from a caller)
|
|
69
69
|
}
|
|
70
70
|
// Beyond MAX_SAFE_MINOR the product quotient * divisor below could round, so take the bigint
|
|
71
71
|
// path.
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { isCurrency } from './currency.js';
|
|
2
|
+
import { MAX_DECIMAL_SCALE } from './decimal.js';
|
|
3
|
+
import { money, type Money, type MoneyJSON } from './money.js';
|
|
4
|
+
import { Rounding, type RoundingMode } from './rounding.js';
|
|
5
|
+
|
|
6
|
+
/** Options for the Zod and Valibot money schemas. */
|
|
7
|
+
export interface MoneySchemaOptions<C extends string = string> {
|
|
8
|
+
/**
|
|
9
|
+
* The currency, or the currencies, to accept. Without it, any ISO 4217 code or code registered
|
|
10
|
+
* with `defineCurrency` is accepted, and nothing else.
|
|
11
|
+
*/
|
|
12
|
+
readonly currency?: C | readonly C[];
|
|
13
|
+
/** Decimal places to keep (default: the currency's exponent), as in `money()`. */
|
|
14
|
+
readonly scale?: number;
|
|
15
|
+
/**
|
|
16
|
+
* How to round an amount with more decimals than the scale. The default, 'unnecessary', rejects
|
|
17
|
+
* such amounts, as `fromJSON` does.
|
|
18
|
+
*/
|
|
19
|
+
readonly rounding?: RoundingMode;
|
|
20
|
+
/**
|
|
21
|
+
* The most digits the amount may have, not counting the sign and the decimal point (default
|
|
22
|
+
* 50). It keeps an oversized request body from building a huge bigint.
|
|
23
|
+
*/
|
|
24
|
+
readonly maxDigits?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The default `maxDigits`: far beyond any real amount, small enough to be cheap to reject. */
|
|
28
|
+
export const DEFAULT_MAX_DIGITS = 50;
|
|
29
|
+
|
|
30
|
+
const MODES = new Set<unknown>(Object.values(Rounding));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @internal Checks the options when the schema is built, so a mistake there throws at once
|
|
34
|
+
* instead of turning every input into a validation error.
|
|
35
|
+
*/
|
|
36
|
+
export function checkSchemaOptions(options: MoneySchemaOptions | undefined): void {
|
|
37
|
+
if (options === undefined) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const allowed = options.currency;
|
|
41
|
+
const codes = typeof allowed === 'string' || allowed === undefined ? [allowed] : allowed;
|
|
42
|
+
for (const code of codes) {
|
|
43
|
+
if (code !== undefined && !isCurrency(code)) {
|
|
44
|
+
throw new RangeError(`Unknown currency ${String(code)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const scale = options.scale;
|
|
48
|
+
if (
|
|
49
|
+
scale !== undefined &&
|
|
50
|
+
!(Number.isInteger(scale) && scale >= 0 && scale <= MAX_DECIMAL_SCALE)
|
|
51
|
+
) {
|
|
52
|
+
throw new RangeError(`Invalid scale ${scale}`);
|
|
53
|
+
}
|
|
54
|
+
if (options.rounding !== undefined && !MODES.has(options.rounding)) {
|
|
55
|
+
throw new RangeError(`Invalid rounding mode ${String(options.rounding)}`);
|
|
56
|
+
}
|
|
57
|
+
const maxDigits = options.maxDigits;
|
|
58
|
+
if (maxDigits !== undefined && !(Number.isSafeInteger(maxDigits) && maxDigits >= 1)) {
|
|
59
|
+
throw new RangeError(`Invalid maxDigits ${maxDigits}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The Money type a schema produces: one Money per allowed currency, not a mixed-currency Money. */
|
|
64
|
+
export type MoneyFor<C extends string> = C extends string ? Money<C> : never;
|
|
65
|
+
|
|
66
|
+
/** @internal A problem with one field of the input, for the schema libraries' issue paths. */
|
|
67
|
+
export interface MoneyIssue {
|
|
68
|
+
readonly field: 'amount' | 'currency';
|
|
69
|
+
readonly message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The exact decimal strings `toJSON` writes: an optional minus, digits, and an optional fraction.
|
|
73
|
+
// No exponent, no plus sign, no whitespace and no grouping, so the wire format stays unambiguous.
|
|
74
|
+
const DECIMAL = /^-?\d+(?:\.\d+)?$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @internal Reads `{ amount, currency }` (already known to have two string fields) into Money, or
|
|
78
|
+
* says what is wrong with it. Shared by the Zod and Valibot entries, so both accept exactly the
|
|
79
|
+
* same inputs.
|
|
80
|
+
*/
|
|
81
|
+
export function readMoneyJSON<C extends string>(
|
|
82
|
+
json: MoneyJSON,
|
|
83
|
+
options: MoneySchemaOptions<C> | undefined,
|
|
84
|
+
): Money | MoneyIssue {
|
|
85
|
+
const currency = json.currency;
|
|
86
|
+
const allowed = options?.currency;
|
|
87
|
+
|
|
88
|
+
if (allowed === undefined) {
|
|
89
|
+
if (!isCurrency(currency)) {
|
|
90
|
+
return { field: 'currency', message: `Unknown currency "${currency}"` };
|
|
91
|
+
}
|
|
92
|
+
} else if (
|
|
93
|
+
typeof allowed === 'string' ? currency !== allowed : !allowed.includes(currency as C)
|
|
94
|
+
) {
|
|
95
|
+
const expected = typeof allowed === 'string' ? allowed : allowed.join(', ');
|
|
96
|
+
return { field: 'currency', message: `Expected currency ${expected}, got "${currency}"` };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const amount = json.amount;
|
|
100
|
+
// Checked before the pattern, so a huge string is rejected without scanning it.
|
|
101
|
+
const maxLength = (options?.maxDigits ?? DEFAULT_MAX_DIGITS) + 2;
|
|
102
|
+
if (amount.length > maxLength || !DECIMAL.test(amount)) {
|
|
103
|
+
return { field: 'amount', message: 'Expected a decimal string such as "12.34"' };
|
|
104
|
+
}
|
|
105
|
+
const digitCount = amount.length - (amount[0] === '-' ? 1 : 0) - (amount.includes('.') ? 1 : 0);
|
|
106
|
+
if (digitCount > (options?.maxDigits ?? DEFAULT_MAX_DIGITS)) {
|
|
107
|
+
return {
|
|
108
|
+
field: 'amount',
|
|
109
|
+
message: `More than ${options?.maxDigits ?? DEFAULT_MAX_DIGITS} digits`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
return money(amount, currency, options);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// the options are checked up front, so this is too many decimals for the scale
|
|
117
|
+
return { field: 'amount', message: (error as Error).message };
|
|
118
|
+
}
|
|
119
|
+
}
|