@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/sql.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Conversions between Money and the values SQL drivers send and receive, shared by the ORM and
|
|
2
|
+
// query-builder integrations (`/drizzle/*`, `/kysely`). Every reader is strict: a driver value that
|
|
3
|
+
// may already have lost digits (an unsafe number, a float for a DECIMAL) is rejected rather than
|
|
4
|
+
// trusted, and every writer refuses a value the column would change.
|
|
5
|
+
import { currencyExponent } from './currency.js';
|
|
6
|
+
import {
|
|
7
|
+
CurrencyMismatchError,
|
|
8
|
+
fromJSON,
|
|
9
|
+
fromMinor,
|
|
10
|
+
money as parseMoney,
|
|
11
|
+
toDecimal,
|
|
12
|
+
toMinor,
|
|
13
|
+
type Money,
|
|
14
|
+
type MoneyJSON,
|
|
15
|
+
} from './money.js';
|
|
16
|
+
|
|
17
|
+
const INTEGER_TEXT = /^-?\d+$/;
|
|
18
|
+
// a BIGINT (SQLite INTEGER) column's range: MySQL outside strict mode clips anything beyond it
|
|
19
|
+
const BIGINT_MIN = -(2n ** 63n);
|
|
20
|
+
const BIGINT_MAX = 2n ** 63n - 1n;
|
|
21
|
+
|
|
22
|
+
/** The declared size of a NUMERIC/DECIMAL column. */
|
|
23
|
+
export interface DecimalColumnSize {
|
|
24
|
+
/** Total number of significant digits the column holds. */
|
|
25
|
+
readonly precision: number;
|
|
26
|
+
/** Digits after the decimal point. */
|
|
27
|
+
readonly scale: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** NUMERIC(19,4): 15 integer digits and 4 decimals, enough for every ISO 4217 exponent. */
|
|
31
|
+
export const DEFAULT_DECIMAL_SIZE: DecimalColumnSize = { precision: 19, scale: 4 };
|
|
32
|
+
|
|
33
|
+
/** Throws unless the money is in the column's currency (when the column has a fixed currency). */
|
|
34
|
+
export function checkCurrency(money: Money, currency: string | undefined): void {
|
|
35
|
+
if (currency !== undefined && money.currency !== currency) {
|
|
36
|
+
throw new CurrencyMismatchError(currency, money.currency);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Throws unless the money is at its currency's exponent. Columns store amounts at the currency's
|
|
42
|
+
* scale, so a value with sub-minor digits would have to be rounded, and the caller should choose how.
|
|
43
|
+
*/
|
|
44
|
+
export function checkIsoScale(money: Money): void {
|
|
45
|
+
const exponent = currencyExponent(money.currency);
|
|
46
|
+
if (money.scale !== exponent) {
|
|
47
|
+
throw new RangeError(
|
|
48
|
+
`${money} has scale ${money.scale}; round() it to ${exponent} decimals before storing it`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minor units for a BIGINT column, as a bigint. Throws beyond the column's range, ±9.2·10^18:
|
|
55
|
+
* MySQL outside strict mode would store the nearest limit instead.
|
|
56
|
+
*/
|
|
57
|
+
export function minorToDriver(money: Money, currency: string | undefined): bigint {
|
|
58
|
+
checkCurrency(money, currency);
|
|
59
|
+
checkIsoScale(money);
|
|
60
|
+
const minor = toMinor(money);
|
|
61
|
+
|
|
62
|
+
if (minor < BIGINT_MIN || minor > BIGINT_MAX) {
|
|
63
|
+
throw new RangeError(`${money} is ${minor} minor units, beyond the range of a BIGINT column`);
|
|
64
|
+
}
|
|
65
|
+
return minor;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Money from a BIGINT driver value. Accepts a bigint, a safe-integer number, or an integer string
|
|
70
|
+
* (node-postgres returns int8 as a string). A number beyond 2^53 has already lost digits, so it
|
|
71
|
+
* throws.
|
|
72
|
+
*/
|
|
73
|
+
export function minorFromDriver<C extends string>(value: unknown, currency: C): Money<C> {
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
if (!INTEGER_TEXT.test(value)) {
|
|
76
|
+
throw new SyntaxError(`Expected an integer from a BIGINT column, got "${value}"`);
|
|
77
|
+
}
|
|
78
|
+
return fromMinor(BigInt(value), currency);
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
81
|
+
return fromMinor(value, currency);
|
|
82
|
+
}
|
|
83
|
+
throw new TypeError(`Expected an integer from a BIGINT column, got ${typeof value}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Throws if a decimal column of this size would round amounts in this currency. */
|
|
87
|
+
export function checkDecimalSize(currency: string, size: DecimalColumnSize): void {
|
|
88
|
+
const { precision, scale } = size;
|
|
89
|
+
const isValidSize =
|
|
90
|
+
Number.isInteger(precision) &&
|
|
91
|
+
Number.isInteger(scale) &&
|
|
92
|
+
precision >= 1 &&
|
|
93
|
+
scale >= 0 &&
|
|
94
|
+
scale <= precision;
|
|
95
|
+
if (!isValidSize) {
|
|
96
|
+
throw new RangeError(`NUMERIC(${precision},${scale}) is not a valid column size`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const exponent = currencyExponent(currency);
|
|
100
|
+
if (size.scale < exponent) {
|
|
101
|
+
throw new RangeError(
|
|
102
|
+
`NUMERIC(${size.precision},${size.scale}) would round ${currency}, which has ${exponent} decimals`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The exact decimal string for a NUMERIC/DECIMAL column. Throws if the column could not hold it
|
|
109
|
+
* unchanged: MySQL outside strict mode clamps an out-of-range value instead of failing.
|
|
110
|
+
*/
|
|
111
|
+
export function decimalToDriver(
|
|
112
|
+
money: Money,
|
|
113
|
+
currency: string | undefined,
|
|
114
|
+
size: DecimalColumnSize | undefined,
|
|
115
|
+
): string {
|
|
116
|
+
checkCurrency(money, currency);
|
|
117
|
+
checkIsoScale(money);
|
|
118
|
+
const decimal = toDecimal(money);
|
|
119
|
+
|
|
120
|
+
if (size !== undefined) {
|
|
121
|
+
checkIntegerDigits(money, decimal, size);
|
|
122
|
+
}
|
|
123
|
+
return decimal;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Throws if the decimal (the money's `toDecimal`) has more integer digits than a column of this
|
|
128
|
+
* size holds: Postgres rejects it, and MySQL outside strict mode clamps it.
|
|
129
|
+
*/
|
|
130
|
+
export function checkIntegerDigits(money: Money, decimal: string, size: DecimalColumnSize): void {
|
|
131
|
+
const integerPart = decimal.replace('-', '').split('.')[0]!;
|
|
132
|
+
// "0.12" has no integer digits: NUMERIC(4,4) holds it
|
|
133
|
+
const integerDigits = integerPart === '0' ? 0 : integerPart.length;
|
|
134
|
+
if (integerDigits > size.precision - size.scale) {
|
|
135
|
+
throw new RangeError(
|
|
136
|
+
`${money} does not fit NUMERIC(${size.precision},${size.scale}), which holds ` +
|
|
137
|
+
`${size.precision - size.scale} integer digits`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Money from a NUMERIC/DECIMAL driver value, which must be a string: a number has been through a
|
|
144
|
+
* float. Trailing zeros (`12.3400` from NUMERIC(19,4)) are fine; more decimals than the currency
|
|
145
|
+
* has, `NaN` and `Infinity` throw.
|
|
146
|
+
*/
|
|
147
|
+
export function decimalFromDriver<C extends string>(value: unknown, currency: C): Money<C> {
|
|
148
|
+
if (typeof value !== 'string') {
|
|
149
|
+
throw new TypeError(
|
|
150
|
+
`Expected a string from a DECIMAL column, got ${typeof value}; ` +
|
|
151
|
+
'configure the driver to return decimals as strings',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return parseMoney(value, currency);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** `{"amount":"12.34","currency":"EUR"}` for a JSON, JSONB or TEXT column. */
|
|
158
|
+
export function jsonToDriver(money: Money, currency: string | undefined): string {
|
|
159
|
+
checkCurrency(money, currency);
|
|
160
|
+
checkIsoScale(money);
|
|
161
|
+
return JSON.stringify(money);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Money from a JSON column: the parsed object (JSONB in node-postgres, JSON in mysql2) or the
|
|
166
|
+
* text (SQLite).
|
|
167
|
+
*/
|
|
168
|
+
export function jsonFromDriver<C extends string>(
|
|
169
|
+
value: unknown,
|
|
170
|
+
currency: C | undefined,
|
|
171
|
+
): Money<C> {
|
|
172
|
+
if (typeof value !== 'string' && (typeof value !== 'object' || value === null)) {
|
|
173
|
+
throw new TypeError(`Expected JSON from a money column, got ${String(value)}`);
|
|
174
|
+
}
|
|
175
|
+
const parsed = fromJSON(value as MoneyJSON | string) as Money<C>;
|
|
176
|
+
checkCurrency(parsed, currency);
|
|
177
|
+
return parsed;
|
|
178
|
+
}
|
package/src/tax.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { ratio, type Factor, type Ratio } from './decimal.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
add,
|
|
4
|
+
multiplyByRatio,
|
|
5
|
+
roundWithPolicy,
|
|
6
|
+
subtract,
|
|
7
|
+
type Money,
|
|
8
|
+
type RoundingInput,
|
|
9
|
+
} from './money.js';
|
|
10
|
+
import { DEFAULT_ROUNDING } from './rounding.js';
|
|
4
11
|
|
|
5
12
|
const percentRatio = (percent: Factor): Ratio => {
|
|
6
13
|
const asRatio = ratio(percent);
|
|
@@ -14,7 +21,7 @@ const percentRatio = (percent: Factor): Ratio => {
|
|
|
14
21
|
export function percentage<C extends string>(
|
|
15
22
|
money: Money<C>,
|
|
16
23
|
percent: Factor,
|
|
17
|
-
rounding:
|
|
24
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
18
25
|
): Money<C> {
|
|
19
26
|
return multiplyByRatio(money, percentRatio(percent), rounding);
|
|
20
27
|
}
|
|
@@ -29,13 +36,25 @@ export interface TaxBreakdown<C extends string> {
|
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Tax-exclusive: adds percent% tax to a net amount. addTax(EUR 10, '20', 'halfEven') -> net 10, tax
|
|
32
|
-
* 2, gross 12.
|
|
39
|
+
* 2, gross 12. A rounding mode rounds the tax; a rounding policy rounds the gross (the amount
|
|
40
|
+
* charged) to its scale and increment. The other part is always the exact difference.
|
|
33
41
|
*/
|
|
34
42
|
export function addTax<C extends string>(
|
|
35
43
|
net: Money<C>,
|
|
36
44
|
percent: Factor,
|
|
37
|
-
rounding:
|
|
45
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
38
46
|
): TaxBreakdown<C> {
|
|
47
|
+
// A rounding mode rounds the tax; a policy rounds the gross, the amount charged, so it lands on
|
|
48
|
+
// the policy's increment (CHF 0.05 for cash). Either way the other part is the exact difference.
|
|
49
|
+
if (typeof rounding === 'function') {
|
|
50
|
+
const percentAsRatio = ratio(percent);
|
|
51
|
+
const gross = roundWithPolicy(
|
|
52
|
+
net,
|
|
53
|
+
ratio(100n * percentAsRatio.den + percentAsRatio.num, 100n * percentAsRatio.den),
|
|
54
|
+
rounding,
|
|
55
|
+
);
|
|
56
|
+
return { net, tax: subtract(gross, net), gross };
|
|
57
|
+
}
|
|
39
58
|
const tax = multiplyByRatio(net, percentRatio(percent), rounding);
|
|
40
59
|
const gross = add(net, tax);
|
|
41
60
|
return { net, tax, gross };
|
|
@@ -44,12 +63,13 @@ export function addTax<C extends string>(
|
|
|
44
63
|
/**
|
|
45
64
|
* Tax-inclusive: extracts percent% tax contained in a gross amount (VAT extraction).
|
|
46
65
|
* net = gross / (1 + percent/100) rounded; tax = gross - net, so the parts always add up.
|
|
47
|
-
* extractTax(EUR 12, '20', 'halfEven') -> net 10.00, tax 2.00.
|
|
66
|
+
* extractTax(EUR 12, '20', 'halfEven') -> net 10.00, tax 2.00. A rounding policy rounds the net; the
|
|
67
|
+
* tax is on the policy's increment too whenever the gross is.
|
|
48
68
|
*/
|
|
49
69
|
export function extractTax<C extends string>(
|
|
50
70
|
gross: Money<C>,
|
|
51
71
|
percent: Factor,
|
|
52
|
-
rounding:
|
|
72
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
53
73
|
): TaxBreakdown<C> {
|
|
54
74
|
const percentAsRatio = ratio(percent);
|
|
55
75
|
// 1 / (1 + p/100) = 100 den / (100 den + num)
|
|
@@ -61,11 +81,22 @@ export function extractTax<C extends string>(
|
|
|
61
81
|
return { net, tax: subtract(gross, net), gross };
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Price after a percent% discount. A rounding mode rounds the discount amount; a rounding policy
|
|
86
|
+
* rounds the discounted price itself, so the price lands on the policy's scale and increment.
|
|
87
|
+
*/
|
|
65
88
|
export function discount<C extends string>(
|
|
66
89
|
price: Money<C>,
|
|
67
90
|
percent: Factor,
|
|
68
|
-
rounding:
|
|
91
|
+
rounding: RoundingInput = DEFAULT_ROUNDING,
|
|
69
92
|
): Money<C> {
|
|
93
|
+
if (typeof rounding === 'function') {
|
|
94
|
+
const percentAsRatio = ratio(percent);
|
|
95
|
+
return roundWithPolicy(
|
|
96
|
+
price,
|
|
97
|
+
ratio(100n * percentAsRatio.den - percentAsRatio.num, 100n * percentAsRatio.den),
|
|
98
|
+
rounding,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
70
101
|
return subtract(price, multiplyByRatio(price, percentRatio(percent), rounding));
|
|
71
102
|
}
|
|
@@ -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);
|