@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/testing/arbitraries`: fast-check arbitraries for property tests. They cover what
|
|
3
|
+
* hand-written cases tend to miss: amounts beyond 2^53 (bigint minor units), currencies with
|
|
4
|
+
* unusual exponents (JPY 0, KWD 3, CLF 4) and scales above the currency's exponent. fast-check is
|
|
5
|
+
* an optional peer dependency, needed only for this entry.
|
|
6
|
+
*/
|
|
7
|
+
import fc from 'fast-check';
|
|
8
|
+
import { currencyExponent, ISO_EXPONENTS } from '../currency.js';
|
|
9
|
+
import { MAX_DECIMAL_SCALE } from '../decimal.js';
|
|
10
|
+
import { fromMinor, money, type Money } from '../money.js';
|
|
11
|
+
|
|
12
|
+
/** A mix of exponents: the common two-decimal currencies and every unusual one. */
|
|
13
|
+
const DEFAULT_CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'KRW', 'KWD', 'BHD', 'TND', 'CLF'];
|
|
14
|
+
|
|
15
|
+
/** Options for `arbCurrency`. */
|
|
16
|
+
export interface ArbCurrencyOptions {
|
|
17
|
+
/** Only currencies with one of these exponents (default: the representative mix). */
|
|
18
|
+
readonly exponents?: readonly number[];
|
|
19
|
+
/** Draw from every ISO 4217 code instead of the representative mix. */
|
|
20
|
+
readonly all?: boolean;
|
|
21
|
+
/** More codes to draw from, such as ones registered with `defineCurrency`. */
|
|
22
|
+
readonly extra?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Currency codes, by default a representative mix of exponents 0, 2, 3 and 4 (JPY, EUR, KWD,
|
|
27
|
+
* CLF...). `{ all: true }` draws from every ISO 4217 code, `extra` adds codes (custom ones from
|
|
28
|
+
* `defineCurrency`), and `exponents` narrows whichever pool that is.
|
|
29
|
+
*/
|
|
30
|
+
export function arbCurrency(options: ArbCurrencyOptions = {}): fc.Arbitrary<string> {
|
|
31
|
+
const base = options.all === true ? Object.keys(ISO_EXPONENTS) : DEFAULT_CURRENCIES;
|
|
32
|
+
const pool = [...base, ...(options.extra ?? [])];
|
|
33
|
+
const exponents = options.exponents;
|
|
34
|
+
const codes =
|
|
35
|
+
exponents === undefined
|
|
36
|
+
? pool
|
|
37
|
+
: pool.filter((code) => exponents.includes(currencyExponent(code)));
|
|
38
|
+
if (codes.length === 0) {
|
|
39
|
+
throw new RangeError('No currency has the requested exponents');
|
|
40
|
+
}
|
|
41
|
+
return fc.constantFrom(...codes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Options for `arbMoney`. */
|
|
45
|
+
export interface ArbMoneyOptions<C extends string = string> {
|
|
46
|
+
/** The currency, or an arbitrary of currencies (default: `arbCurrency()`). */
|
|
47
|
+
readonly currency?: C | fc.Arbitrary<C>;
|
|
48
|
+
/** The smallest amount, in major units (default: none, down to -10^30 minor units). */
|
|
49
|
+
readonly min?: string | number | bigint;
|
|
50
|
+
/** The largest amount, in major units (default: none, up to 10^30 minor units). */
|
|
51
|
+
readonly max?: string | number | bigint;
|
|
52
|
+
/**
|
|
53
|
+
* The scale, or an arbitrary of scales. By default mostly the currency's exponent, sometimes up
|
|
54
|
+
* to three more decimals.
|
|
55
|
+
*/
|
|
56
|
+
readonly scale?: number | fc.Arbitrary<number>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const BIG = 10n ** 30n;
|
|
60
|
+
const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
61
|
+
|
|
62
|
+
/** Minor units weighted toward the interesting places: small, near 2^53, and far beyond it. */
|
|
63
|
+
function arbMinor(min: bigint, max: bigint): fc.Arbitrary<bigint> {
|
|
64
|
+
const clamp = (low: bigint, high: bigint) =>
|
|
65
|
+
fc.bigInt({ min: low > min ? low : min, max: high < max ? high : max });
|
|
66
|
+
const parts: fc.Arbitrary<bigint>[] = [];
|
|
67
|
+
if (min <= 10_000n && max >= -10_000n) {
|
|
68
|
+
parts.push(clamp(-10_000n, 10_000n));
|
|
69
|
+
}
|
|
70
|
+
if (min <= SAFE && max >= -SAFE) {
|
|
71
|
+
parts.push(clamp(-SAFE, SAFE));
|
|
72
|
+
}
|
|
73
|
+
// within 1000 of ±2^53, where number and bigint meet
|
|
74
|
+
for (const edge of [SAFE, -SAFE]) {
|
|
75
|
+
if (min <= edge + 1000n && max >= edge - 1000n) {
|
|
76
|
+
parts.push(clamp(edge - 1000n, edge + 1000n));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
parts.push(fc.bigInt({ min, max }));
|
|
80
|
+
return fc.oneof(...parts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Money values for property tests: `arbMoney()`, `arbMoney({ currency: 'EUR', min: '0', max:
|
|
85
|
+
* '100' })`. With bounds, every value is within them; without, minor units reach ±10^30.
|
|
86
|
+
*/
|
|
87
|
+
export function arbMoney<C extends string = string>(
|
|
88
|
+
options: ArbMoneyOptions<C> = {},
|
|
89
|
+
): fc.Arbitrary<Money<C>> {
|
|
90
|
+
const given = options.currency;
|
|
91
|
+
let currencies: fc.Arbitrary<C>;
|
|
92
|
+
if (given === undefined) {
|
|
93
|
+
currencies = arbCurrency() as fc.Arbitrary<C>;
|
|
94
|
+
} else if (typeof given === 'string') {
|
|
95
|
+
currencies = fc.constant(given);
|
|
96
|
+
} else {
|
|
97
|
+
currencies = given;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return currencies.chain((currency) => {
|
|
101
|
+
const exponent = currencyExponent(currency);
|
|
102
|
+
const scaleOption = options.scale;
|
|
103
|
+
let scales: fc.Arbitrary<number>;
|
|
104
|
+
if (typeof scaleOption === 'number') {
|
|
105
|
+
scales = fc.constant(scaleOption);
|
|
106
|
+
} else if (scaleOption === undefined) {
|
|
107
|
+
scales = fc.oneof(
|
|
108
|
+
{ weight: 3, arbitrary: fc.constant(exponent) },
|
|
109
|
+
{ weight: 1, arbitrary: fc.integer({ min: exponent + 1, max: exponent + 3 }) },
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
scales = scaleOption;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return scales.chain((scale) => {
|
|
116
|
+
if (!(Number.isInteger(scale) && scale >= 0 && scale <= MAX_DECIMAL_SCALE)) {
|
|
117
|
+
throw new RangeError(`Invalid scale ${scale}`);
|
|
118
|
+
}
|
|
119
|
+
// Bounds in minor units at this scale; a bound between two units rounds inward.
|
|
120
|
+
const min =
|
|
121
|
+
options.min === undefined
|
|
122
|
+
? -BIG
|
|
123
|
+
: BigInt(money(options.min, currency, { scale, rounding: 'ceil' }).toMinor());
|
|
124
|
+
const max =
|
|
125
|
+
options.max === undefined
|
|
126
|
+
? BIG
|
|
127
|
+
: BigInt(money(options.max, currency, { scale, rounding: 'floor' }).toMinor());
|
|
128
|
+
if (min > max) {
|
|
129
|
+
throw new RangeError(`No ${currency} amount at scale ${scale} between min and max`);
|
|
130
|
+
}
|
|
131
|
+
return arbMinor(min, max).map((minor) => fromMinor(minor, currency, scale));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/testing`: makes Money pleasant to test with Vitest and Jest. It provides value
|
|
3
|
+
* equality for `toEqual`, readable snapshots and failure output (`12.34 EUR`), and money matchers.
|
|
4
|
+
* Call `installMoneyMatchers(expect)` in a setup file, or import `@ducatjs/money/testing/vitest`
|
|
5
|
+
* or `@ducatjs/money/testing/jest`, which do that for you. Nothing here changes production code.
|
|
6
|
+
*/
|
|
7
|
+
import { currencyExponent } from '../currency.js';
|
|
8
|
+
import { parseDecimal } from '../decimal.js';
|
|
9
|
+
import { compare, equals, isMoney, money, sum, zero, type Money } from '../money.js';
|
|
10
|
+
|
|
11
|
+
/** An equality tester, as Vitest and Jest's `expect.addEqualityTesters` take them. */
|
|
12
|
+
export type MoneyEqualityTester = (left: unknown, right: unknown) => boolean | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Money compares by value in `toEqual`, `toStrictEqual` and nested structures: same currency and
|
|
16
|
+
* the same amount, whatever the scale (`1.0` and `1.00`) and whether the minor units are a number
|
|
17
|
+
* or a bigint. For anything other than two Money values it returns undefined, so the usual
|
|
18
|
+
* comparison applies.
|
|
19
|
+
*/
|
|
20
|
+
export const moneyEquality: MoneyEqualityTester = (left, right) => {
|
|
21
|
+
if (isMoney(left) && isMoney(right)) {
|
|
22
|
+
return equals(left, right);
|
|
23
|
+
}
|
|
24
|
+
// Anything else, including a Money from a second installed copy of the package (which isMoney
|
|
25
|
+
// does not recognise), is left to the runner's own field-by-field comparison.
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A snapshot serializer that prints Money as `12.34 EUR` (unquoted, so it isn't a string). */
|
|
30
|
+
export const moneySerializer = {
|
|
31
|
+
test: (value: unknown): boolean => isMoney(value),
|
|
32
|
+
serialize: (value: unknown): string => String(value as Money),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** What the matchers receive as `this`, the part of Vitest's and Jest's matcher state used here. */
|
|
36
|
+
export interface MoneyMatcherContext {
|
|
37
|
+
readonly isNot: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A matcher's result, as Vitest and Jest expect it. */
|
|
41
|
+
export interface MoneyMatcherResult {
|
|
42
|
+
pass: boolean;
|
|
43
|
+
message: () => string;
|
|
44
|
+
actual?: unknown;
|
|
45
|
+
expected?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The money matchers, typed for `declare module` augmentation of `Assertion` / `Matchers`. */
|
|
49
|
+
export interface MoneyMatchers<R = unknown> {
|
|
50
|
+
/** The value is Money equal to `amount` in `currency`, at any scale: `toBeMoney('12.34', 'EUR')`. */
|
|
51
|
+
toBeMoney(amount: string | number | bigint, currency: string): R;
|
|
52
|
+
/** The value is Money in `currency`. */
|
|
53
|
+
toHaveCurrency(currency: string): R;
|
|
54
|
+
/** The value is Money greater than `other` (same currency). */
|
|
55
|
+
toBeGreaterThanMoney(other: Money): R;
|
|
56
|
+
/** The value is Money greater than or equal to `other` (same currency). */
|
|
57
|
+
toBeGreaterThanOrEqualMoney(other: Money): R;
|
|
58
|
+
/** The value is Money less than `other` (same currency). */
|
|
59
|
+
toBeLessThanMoney(other: Money): R;
|
|
60
|
+
/** The value is Money less than or equal to `other` (same currency). */
|
|
61
|
+
toBeLessThanOrEqualMoney(other: Money): R;
|
|
62
|
+
/** The value is a list of Money that sums exactly to `total`, as allocations must. */
|
|
63
|
+
toSumTo(total: Money): R;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Prints a received value for a failure message. JSON.stringify would throw on a bigint or a
|
|
68
|
+
* cycle, and a matcher must fail cleanly, not throw while explaining the failure.
|
|
69
|
+
*/
|
|
70
|
+
function show(value: unknown, seen: Set<unknown> = new Set()): string {
|
|
71
|
+
if (isMoney(value)) {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
if (typeof value === 'bigint') {
|
|
75
|
+
return `${value}n`;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === 'string') {
|
|
78
|
+
return JSON.stringify(value);
|
|
79
|
+
}
|
|
80
|
+
if (typeof value !== 'object' || value === null) {
|
|
81
|
+
return String(value);
|
|
82
|
+
}
|
|
83
|
+
if (seen.has(value)) {
|
|
84
|
+
return '[Circular]';
|
|
85
|
+
}
|
|
86
|
+
seen.add(value);
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
return `[${value.map((item) => show(item, seen)).join(', ')}]`;
|
|
89
|
+
}
|
|
90
|
+
const fields = Object.entries(value).map(([key, field]) => `${key}: ${show(field, seen)}`);
|
|
91
|
+
return `{ ${fields.join(', ')} }`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function result(
|
|
95
|
+
context: MoneyMatcherContext,
|
|
96
|
+
pass: boolean,
|
|
97
|
+
received: unknown,
|
|
98
|
+
expectation: string,
|
|
99
|
+
expected?: unknown,
|
|
100
|
+
): MoneyMatcherResult {
|
|
101
|
+
return {
|
|
102
|
+
pass,
|
|
103
|
+
message: () => `expected ${show(received)} ${context.isNot ? 'not ' : ''}${expectation}`,
|
|
104
|
+
actual: received,
|
|
105
|
+
expected,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Comparing amounts in different currencies, or something that isn't Money, is a mistake in the
|
|
111
|
+
* test, so it throws instead of failing; otherwise `.not` would quietly pass on it.
|
|
112
|
+
*/
|
|
113
|
+
function requireComparable(matcher: string, received: unknown, other: unknown): void {
|
|
114
|
+
if (!isMoney(received) || !isMoney(other)) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`${matcher}: expected two Money values, got ${show(received)} and ${show(other)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (received.currency !== other.currency) {
|
|
120
|
+
throw new TypeError(`${matcher}: cannot compare ${received.currency} with ${other.currency}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Decimal places in an amount as `money()` takes it, so `toBeMoney` can keep every digit. */
|
|
125
|
+
function decimalsOf(amount: string | number | bigint): number {
|
|
126
|
+
// parseDecimal understands exponent notation ('1.5e-7'), as money() does
|
|
127
|
+
return typeof amount === 'bigint' ? 0 : parseDecimal(String(amount)).scale;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function comparison(
|
|
131
|
+
name: string,
|
|
132
|
+
wording: string,
|
|
133
|
+
accepts: (order: -1 | 0 | 1) => boolean,
|
|
134
|
+
): (this: MoneyMatcherContext, received: unknown, other: Money) => MoneyMatcherResult {
|
|
135
|
+
return function (this: MoneyMatcherContext, received: unknown, other: Money) {
|
|
136
|
+
requireComparable(name, received, other);
|
|
137
|
+
const pass = accepts(compare(received as Money, other));
|
|
138
|
+
return result(this, pass, received, `${wording} ${show(other)}`);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The matcher implementations, for `expect.extend`. */
|
|
143
|
+
export const moneyMatchers = {
|
|
144
|
+
toBeMoney(
|
|
145
|
+
this: MoneyMatcherContext,
|
|
146
|
+
received: unknown,
|
|
147
|
+
amount: string | number | bigint,
|
|
148
|
+
currency: string,
|
|
149
|
+
): MoneyMatcherResult {
|
|
150
|
+
const scale = Math.max(decimalsOf(amount), currencyExponent(currency));
|
|
151
|
+
const expected = money(amount, currency, { scale });
|
|
152
|
+
const pass = isMoney(received) && equals(received, expected);
|
|
153
|
+
return result(this, pass, received, `to be ${expected}`, expected);
|
|
154
|
+
},
|
|
155
|
+
toHaveCurrency(
|
|
156
|
+
this: MoneyMatcherContext,
|
|
157
|
+
received: unknown,
|
|
158
|
+
currency: string,
|
|
159
|
+
): MoneyMatcherResult {
|
|
160
|
+
const pass = isMoney(received) && received.currency === currency;
|
|
161
|
+
return result(this, pass, received, `to have currency ${currency}`);
|
|
162
|
+
},
|
|
163
|
+
toBeGreaterThanMoney: comparison(
|
|
164
|
+
'toBeGreaterThanMoney',
|
|
165
|
+
'to be greater than',
|
|
166
|
+
(order) => order > 0,
|
|
167
|
+
),
|
|
168
|
+
toBeGreaterThanOrEqualMoney: comparison(
|
|
169
|
+
'toBeGreaterThanOrEqualMoney',
|
|
170
|
+
'to be greater than or equal to',
|
|
171
|
+
(order) => order >= 0,
|
|
172
|
+
),
|
|
173
|
+
toBeLessThanMoney: comparison('toBeLessThanMoney', 'to be less than', (order) => order < 0),
|
|
174
|
+
toBeLessThanOrEqualMoney: comparison(
|
|
175
|
+
'toBeLessThanOrEqualMoney',
|
|
176
|
+
'to be less than or equal to',
|
|
177
|
+
(order) => order <= 0,
|
|
178
|
+
),
|
|
179
|
+
toSumTo(this: MoneyMatcherContext, received: unknown, total: Money): MoneyMatcherResult {
|
|
180
|
+
// Not a list of Money, or parts in another currency, is a mistake in the test: throw.
|
|
181
|
+
if (!Array.isArray(received) || !isMoney(total)) {
|
|
182
|
+
throw new TypeError(
|
|
183
|
+
`toSumTo: expected a list of Money and a Money total, got ${show(received)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
for (const part of received) {
|
|
187
|
+
requireComparable('toSumTo', part, total);
|
|
188
|
+
}
|
|
189
|
+
const partsSum = sum(received as Money[], total.currency);
|
|
190
|
+
const pass = equals(partsSum, total);
|
|
191
|
+
return {
|
|
192
|
+
pass,
|
|
193
|
+
message: () =>
|
|
194
|
+
`expected ${show(received)} ${this.isNot ? 'not ' : ''}to sum to ${show(total)}` +
|
|
195
|
+
(this.isNot ? '' : `; they sum to ${partsSum}`),
|
|
196
|
+
actual: partsSum,
|
|
197
|
+
expected: total,
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** The parts of Vitest's and Jest's `expect` that `installMoneyMatchers` uses. */
|
|
203
|
+
export interface ExpectWithExtensions {
|
|
204
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- each runner types these its own way
|
|
205
|
+
extend(matchers: any): void;
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
207
|
+
addEqualityTesters?(testers: any[]): void;
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
209
|
+
addSnapshotSerializer?(serializer: any): void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const INSPECT = Symbol.for('nodejs.util.inspect.custom');
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Installs everything on a test runner's `expect`: the matchers, value equality and the snapshot
|
|
216
|
+
* serializer. It also gives Money a Node inspect hook, so `console.log(price)` prints
|
|
217
|
+
* `Money(12.34 EUR)`. Safe to call more than once. A runner that lacks `addEqualityTesters` or
|
|
218
|
+
* `addSnapshotSerializer` (Bun supports neither) gets the matchers and the inspect hook.
|
|
219
|
+
*/
|
|
220
|
+
export function installMoneyMatchers(expect: ExpectWithExtensions): void {
|
|
221
|
+
expect.extend(moneyMatchers);
|
|
222
|
+
// Optional runner features: Bun has no equality testers, and its addSnapshotSerializer throws
|
|
223
|
+
// "Not implemented". The matchers work without either.
|
|
224
|
+
try {
|
|
225
|
+
expect.addEqualityTesters?.([moneyEquality]);
|
|
226
|
+
} catch {
|
|
227
|
+
// not supported by this runner
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
expect.addSnapshotSerializer?.(moneySerializer);
|
|
231
|
+
} catch {
|
|
232
|
+
// not supported by this runner
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The inspect hook is the one change to Money itself; only test setups call this function.
|
|
236
|
+
const prototype = Object.getPrototypeOf(zero('EUR')) as object;
|
|
237
|
+
if (!Object.hasOwn(prototype, INSPECT)) {
|
|
238
|
+
Object.defineProperty(prototype, INSPECT, {
|
|
239
|
+
value(this: Money) {
|
|
240
|
+
return `Money(${String(this)})`;
|
|
241
|
+
},
|
|
242
|
+
writable: true,
|
|
243
|
+
configurable: true,
|
|
244
|
+
enumerable: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/testing/jest`: one import in a Jest setup file (`setupFilesAfterEnv`) installs
|
|
3
|
+
* the money matchers, value equality and the snapshot serializer on Jest's global `expect`. It
|
|
4
|
+
* needs no Jest package of its own. For the types, add `MoneyMatchers` to Jest's `Matchers` in a
|
|
5
|
+
* declaration file (see the README): augmenting Jest's global namespace from here would clash
|
|
6
|
+
* with Vitest's types in projects that have both.
|
|
7
|
+
*/
|
|
8
|
+
import { installMoneyMatchers, type ExpectWithExtensions } from './index.js';
|
|
9
|
+
|
|
10
|
+
export * from './index.js';
|
|
11
|
+
|
|
12
|
+
const globalExpect = (globalThis as { expect?: ExpectWithExtensions }).expect;
|
|
13
|
+
if (globalExpect === undefined) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'@ducatjs/money/testing/jest needs Jest’s global expect; import it from a setup file, or call installMoneyMatchers(expect)',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
installMoneyMatchers(globalExpect);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/testing/vitest`: one import in a Vitest setup file installs the money matchers,
|
|
3
|
+
* value equality and the snapshot serializer, and types the matchers on `expect`:
|
|
4
|
+
*
|
|
5
|
+
* // vitest.config.ts: test: { setupFiles: ['./test/setup.ts'] }
|
|
6
|
+
* import '@ducatjs/money/testing/vitest';
|
|
7
|
+
*/
|
|
8
|
+
import { expect } from 'vitest';
|
|
9
|
+
import { installMoneyMatchers, type MoneyMatchers } from './index.js';
|
|
10
|
+
|
|
11
|
+
export * from './index.js';
|
|
12
|
+
|
|
13
|
+
declare module 'vitest' {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation
|
|
15
|
+
interface Matchers<T = any> extends MoneyMatchers<T> {}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
installMoneyMatchers(expect);
|
package/src/valibot.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/valibot`: Valibot schemas for the JSON form of Money, `{ "amount": "12.34",
|
|
3
|
+
* "currency": "EUR" }`, for API boundaries. Valibot is an optional peer dependency: only this entry
|
|
4
|
+
* imports it.
|
|
5
|
+
*/
|
|
6
|
+
import * as valibot from 'valibot';
|
|
7
|
+
import { isMoney, type Money, type MoneyJSON } from './money.js';
|
|
8
|
+
import {
|
|
9
|
+
checkSchemaOptions,
|
|
10
|
+
readMoneyJSON,
|
|
11
|
+
type MoneyFor,
|
|
12
|
+
type MoneyIssue,
|
|
13
|
+
type MoneySchemaOptions,
|
|
14
|
+
} from './schema.js';
|
|
15
|
+
|
|
16
|
+
export type { MoneyFor, MoneySchemaOptions } from './schema.js';
|
|
17
|
+
|
|
18
|
+
const jsonShape = valibot.object({ amount: valibot.string(), currency: valibot.string() });
|
|
19
|
+
|
|
20
|
+
/** The schema type both functions return: the JSON shape, then a transformation. */
|
|
21
|
+
export type MoneySchema<TOutput> = valibot.SchemaWithPipe<
|
|
22
|
+
readonly [typeof jsonShape, valibot.RawTransformAction<MoneyJSON, TOutput>]
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
/** A Valibot issue path pointing at the field that is wrong. */
|
|
26
|
+
function pathTo(json: MoneyJSON, issue: MoneyIssue): [valibot.ObjectPathItem] {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
type: 'object',
|
|
30
|
+
origin: 'value',
|
|
31
|
+
input: json as unknown as Record<string, unknown>,
|
|
32
|
+
key: issue.field,
|
|
33
|
+
value: json[issue.field],
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parses `{ amount, currency }` into Money, accepting exactly what the Zod entry accepts (see its
|
|
40
|
+
* `moneySchema`): an exact decimal string with no more decimals than the scale and at most
|
|
41
|
+
* `maxDigits` digits, padded to the scale, and a listed or known currency. Problems are Valibot
|
|
42
|
+
* issues on `amount` or `currency`; other keys are dropped. Bad options throw when the schema is
|
|
43
|
+
* built. Valibot has no codecs, so encode with `money.toJSON()` (or `JSON.stringify`), which this
|
|
44
|
+
* schema reads back.
|
|
45
|
+
*
|
|
46
|
+
* const Order = valibot.object({ total: moneySchema({ currency: 'EUR' }) })
|
|
47
|
+
* v.parse(Order, { total: { amount: '12.34', currency: 'EUR' } }).total // Money<'EUR'>
|
|
48
|
+
*/
|
|
49
|
+
export function moneySchema<C extends string = string>(
|
|
50
|
+
options?: MoneySchemaOptions<C>,
|
|
51
|
+
): MoneySchema<MoneyFor<C>> {
|
|
52
|
+
checkSchemaOptions(options);
|
|
53
|
+
return valibot.pipe(
|
|
54
|
+
jsonShape,
|
|
55
|
+
valibot.rawTransform<MoneyJSON, MoneyFor<C>>(({ dataset, addIssue, NEVER }) => {
|
|
56
|
+
const json = dataset.value;
|
|
57
|
+
const result = readMoneyJSON(json, options);
|
|
58
|
+
if (isMoney(result)) {
|
|
59
|
+
return result as Money as MoneyFor<C>;
|
|
60
|
+
}
|
|
61
|
+
addIssue({ message: result.message, path: pathTo(json, result) });
|
|
62
|
+
return NEVER;
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Like `moneySchema`, but the output stays the JSON object: it checks that the input would parse,
|
|
69
|
+
* for code that passes the JSON on rather than computing with it.
|
|
70
|
+
*/
|
|
71
|
+
export function moneyJSONSchema<C extends string = string>(
|
|
72
|
+
options?: MoneySchemaOptions<C>,
|
|
73
|
+
): MoneySchema<MoneyJSON<C>> {
|
|
74
|
+
checkSchemaOptions(options);
|
|
75
|
+
return valibot.pipe(
|
|
76
|
+
jsonShape,
|
|
77
|
+
valibot.rawTransform<MoneyJSON, MoneyJSON<C>>(({ dataset, addIssue, NEVER }) => {
|
|
78
|
+
const json = dataset.value;
|
|
79
|
+
const result = readMoneyJSON(json, options);
|
|
80
|
+
if (isMoney(result)) {
|
|
81
|
+
return json as MoneyJSON<C>;
|
|
82
|
+
}
|
|
83
|
+
addIssue({ message: result.message, path: pathTo(json, result) });
|
|
84
|
+
return NEVER;
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/zod.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/zod`: Zod schemas for the JSON form of Money, `{ "amount": "12.34", "currency":
|
|
3
|
+
* "EUR" }`, for API boundaries. Zod is an optional peer dependency: only this entry imports it.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { isMoney, type Money, type MoneyJSON } from './money.js';
|
|
7
|
+
import {
|
|
8
|
+
checkSchemaOptions,
|
|
9
|
+
readMoneyJSON,
|
|
10
|
+
type MoneyFor,
|
|
11
|
+
type MoneySchemaOptions,
|
|
12
|
+
} from './schema.js';
|
|
13
|
+
|
|
14
|
+
export type { MoneyFor, MoneySchemaOptions } from './schema.js';
|
|
15
|
+
|
|
16
|
+
const jsonShape = z.object({ amount: z.string(), currency: z.string() });
|
|
17
|
+
|
|
18
|
+
/** The schema `moneySchema` returns: a Zod codec between the JSON form and Money. */
|
|
19
|
+
export type MoneyCodec<C extends string> = z.ZodCodec<typeof jsonShape, z.ZodCustom<MoneyFor<C>>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parses `{ amount, currency }` into Money, and encodes Money back to that JSON form. It is a Zod
|
|
23
|
+
* codec, so `schema.parse(json)` (or `z.decode`) gives Money and `z.encode(schema, money)` gives
|
|
24
|
+
* `{ amount, currency }`, written at the amount's own scale.
|
|
25
|
+
*
|
|
26
|
+
* The amount must be an exact decimal string with no more decimals than the scale (the
|
|
27
|
+
* currency's exponent unless `scale` is given; `rounding` allows more), and at most `maxDigits`
|
|
28
|
+
* digits. The currency must be one of `currency`, or any known code. Shorter amounts are padded to
|
|
29
|
+
* the scale ("1.5" EUR gives 1.50 EUR). Problems are Zod issues on `amount` or `currency`; keys
|
|
30
|
+
* other than those two are dropped. Bad options throw when the schema is built.
|
|
31
|
+
*
|
|
32
|
+
* const Order = z.object({ total: moneySchema({ currency: 'EUR' }) })
|
|
33
|
+
* Order.parse({ total: { amount: '12.34', currency: 'EUR' } }).total // Money<'EUR'>
|
|
34
|
+
*/
|
|
35
|
+
export function moneySchema<C extends string = string>(
|
|
36
|
+
options?: MoneySchemaOptions<C>,
|
|
37
|
+
): MoneyCodec<C> {
|
|
38
|
+
checkSchemaOptions(options);
|
|
39
|
+
return z.codec(
|
|
40
|
+
jsonShape,
|
|
41
|
+
z.custom<MoneyFor<C>>((value) => isMoney(value)),
|
|
42
|
+
{
|
|
43
|
+
decode: (json, payload) => {
|
|
44
|
+
const result = readMoneyJSON(json, options);
|
|
45
|
+
if (isMoney(result)) {
|
|
46
|
+
return result as Money as MoneyFor<C>;
|
|
47
|
+
}
|
|
48
|
+
payload.issues.push({
|
|
49
|
+
code: 'custom',
|
|
50
|
+
message: result.message,
|
|
51
|
+
input: json,
|
|
52
|
+
path: [result.field],
|
|
53
|
+
});
|
|
54
|
+
return z.NEVER;
|
|
55
|
+
},
|
|
56
|
+
encode: (value) => (value as Money).toJSON(),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Like `moneySchema`, but the output stays the JSON object (with only `amount` and `currency`): it
|
|
63
|
+
* checks that the input would parse, for code that passes the JSON on rather than computing with
|
|
64
|
+
* it.
|
|
65
|
+
*/
|
|
66
|
+
export function moneyJSONSchema<C extends string = string>(
|
|
67
|
+
options?: MoneySchemaOptions<C>,
|
|
68
|
+
): z.ZodType<MoneyJSON<C>, MoneyJSON> {
|
|
69
|
+
checkSchemaOptions(options);
|
|
70
|
+
return jsonShape.transform((json, context) => {
|
|
71
|
+
const result = readMoneyJSON(json, options);
|
|
72
|
+
if (isMoney(result)) {
|
|
73
|
+
return json as MoneyJSON<C>;
|
|
74
|
+
}
|
|
75
|
+
context.addIssue({ code: 'custom', message: result.message, path: [result.field] });
|
|
76
|
+
return z.NEVER;
|
|
77
|
+
});
|
|
78
|
+
}
|