@arki/money 0.0.0-stage → 0.0.4
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/README.md +238 -2
- package/dist/bag.d.ts +48 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +110 -0
- package/dist/bag.js.map +1 -0
- package/dist/currency.d.ts +64 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +148 -0
- package/dist/currency.js.map +1 -0
- package/dist/data/iso4217.d.ts +9 -0
- package/dist/data/iso4217.d.ts.map +1 -0
- package/dist/data/iso4217.js +11 -0
- package/dist/data/iso4217.js.map +1 -0
- package/dist/drizzle/config.d.ts +27 -0
- package/dist/drizzle/config.d.ts.map +1 -0
- package/dist/drizzle/config.js +16 -0
- package/dist/drizzle/config.js.map +1 -0
- package/dist/drizzle/mysql.d.ts +36 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +45 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +38 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +49 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +38 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +45 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/drizzle.d.ts +56 -0
- package/dist/drizzle.d.ts.map +1 -0
- package/dist/drizzle.js +77 -0
- package/dist/drizzle.js.map +1 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/exact.d.ts +20 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +23 -0
- package/dist/exact.js.map +1 -0
- package/dist/exchange.d.ts +84 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +215 -0
- package/dist/exchange.js.map +1 -0
- package/dist/format.d.ts +41 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +122 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/columns.d.ts +25 -0
- package/dist/internal/columns.d.ts.map +1 -0
- package/dist/internal/columns.js +47 -0
- package/dist/internal/columns.js.map +1 -0
- package/dist/internal/units.d.ts +66 -0
- package/dist/internal/units.d.ts.map +1 -0
- package/dist/internal/units.js +205 -0
- package/dist/internal/units.js.map +1 -0
- package/dist/money.d.ts +180 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +577 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +53 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +222 -0
- package/dist/parse.js.map +1 -0
- package/dist/provider.d.ts +22 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +56 -0
- package/dist/provider.js.map +1 -0
- package/dist/rational.d.ts +67 -0
- package/dist/rational.d.ts.map +1 -0
- package/dist/rational.js +169 -0
- package/dist/rational.js.map +1 -0
- package/dist/stripe.d.ts +37 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +75 -0
- package/dist/stripe.js.map +1 -0
- package/dist/zod.d.ts +54 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +96 -0
- package/dist/zod.js.map +1 -0
- package/package.json +137 -4
- package/src/bag.ts +128 -0
- package/src/currency.ts +174 -0
- package/src/data/iso4217.ts +13 -0
- package/src/drizzle/config.ts +39 -0
- package/src/drizzle/mysql.ts +61 -0
- package/src/drizzle/pg.ts +64 -0
- package/src/drizzle/sqlite.ts +61 -0
- package/src/errors.ts +61 -0
- package/src/exact.ts +29 -0
- package/src/exchange.ts +280 -0
- package/src/format.ts +148 -0
- package/src/index.ts +16 -0
- package/src/internal/columns.ts +74 -0
- package/src/internal/units.ts +223 -0
- package/src/money.ts +692 -0
- package/src/parse.ts +278 -0
- package/src/provider.ts +67 -0
- package/src/rational.ts +214 -0
- package/src/stripe.ts +84 -0
- package/src/zod.ts +123 -0
package/src/money.ts
ADDED
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact money. A `Money` is a whole number of units, a scale (how many of the
|
|
3
|
+
* digits are decimals) and a currency: `19.99 EUR` is `1999` units at scale 2.
|
|
4
|
+
*
|
|
5
|
+
* Rules that protect you from silent loss:
|
|
6
|
+
* - Nothing rounds unless you pass a rounding mode. An operation whose exact
|
|
7
|
+
* result does not fit throws `RoundingNecessaryError`.
|
|
8
|
+
* - Mixing currencies throws `CurrencyMismatchError`. With literal codes the
|
|
9
|
+
* compiler already rejects it: `Money<'EUR'>` cannot be added to `Money<'USD'>`.
|
|
10
|
+
* - `allocate` and `split` return parts that add back to the whole exactly.
|
|
11
|
+
* - A `Money` never becomes a float by accident: `+price` throws.
|
|
12
|
+
*
|
|
13
|
+
* Speed: units are a plain `number` while they are a safe integer (up to 90
|
|
14
|
+
* trillion in a two-decimal currency) and a `bigint` only beyond that. Values
|
|
15
|
+
* are immutable through `readonly` and a private constructor; they are not
|
|
16
|
+
* frozen, because `Object.freeze` costs more per operation than the arithmetic.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { RoundingMode } from '@arki/math/rounding';
|
|
20
|
+
import {
|
|
21
|
+
DivisionByZeroError,
|
|
22
|
+
EmptyDataError,
|
|
23
|
+
InvalidArgumentError,
|
|
24
|
+
NumberFormatError,
|
|
25
|
+
NumericRangeError,
|
|
26
|
+
ResourceLimitError,
|
|
27
|
+
RoundingNecessaryError,
|
|
28
|
+
} from '@arki/math/errors';
|
|
29
|
+
|
|
30
|
+
import type { Currency, CurrencyLike } from './currency.js';
|
|
31
|
+
import type { Units } from './internal/units.js';
|
|
32
|
+
import { assertSameCurrency, resolveCurrency, toCurrency } from './currency.js';
|
|
33
|
+
import {
|
|
34
|
+
addUnits,
|
|
35
|
+
canon,
|
|
36
|
+
compareUnits,
|
|
37
|
+
MAX_INPUT_LENGTH,
|
|
38
|
+
MAX_PARTS,
|
|
39
|
+
MAX_SCALE,
|
|
40
|
+
mulDiv,
|
|
41
|
+
mulUnits,
|
|
42
|
+
negUnits,
|
|
43
|
+
parseDecimal,
|
|
44
|
+
parseNumber,
|
|
45
|
+
pow10,
|
|
46
|
+
quantize,
|
|
47
|
+
} from './internal/units.js';
|
|
48
|
+
|
|
49
|
+
export type { Units } from './internal/units.js';
|
|
50
|
+
|
|
51
|
+
/** An exact decimal such as `@arki/math`'s `Decimal`: `coefficient × 10^-scale`. */
|
|
52
|
+
export type DecimalLike = { readonly coefficient: bigint; readonly scale: number };
|
|
53
|
+
/** An exact fraction such as `@arki/math`'s `Rational`. */
|
|
54
|
+
export type RationalLike = { readonly numerator: bigint; readonly denominator: bigint };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An amount in major units: `'19.99'`, `19.99`, `20n`, a `Decimal` or a `Rational`.
|
|
58
|
+
* A `number` is read as written (`19.99` is exactly 19.99); earlier float
|
|
59
|
+
* arithmetic cannot be undone, so prefer strings for computed values.
|
|
60
|
+
*/
|
|
61
|
+
export type Amount = number | string | bigint | DecimalLike | RationalLike;
|
|
62
|
+
|
|
63
|
+
/** A multiplier or divisor: an `Amount` or a `Factor` prepared once with `factor()`. */
|
|
64
|
+
export type FactorInput = Amount | Factor;
|
|
65
|
+
|
|
66
|
+
export type MoneyOptions = {
|
|
67
|
+
/** How to drop digits that do not fit. Without it, extra digits throw `RoundingNecessaryError`. */
|
|
68
|
+
rounding?: RoundingMode;
|
|
69
|
+
/** Number of decimals to keep, 0–100. Defaults to the currency's (2 for EUR). Use 4 for unit prices. */
|
|
70
|
+
scale?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Who gets the leftover units when an amount does not divide evenly. */
|
|
74
|
+
export type RemainderPolicy = 'largest' | 'first' | 'last';
|
|
75
|
+
|
|
76
|
+
export type AllocateOptions = {
|
|
77
|
+
/**
|
|
78
|
+
* `'largest'` (default): one unit each to the parts whose exact share lost
|
|
79
|
+
* the most, earlier parts first on ties. `'first'` / `'last'`: one unit each
|
|
80
|
+
* from the start / end. Parts with a zero ratio never receive a unit.
|
|
81
|
+
*/
|
|
82
|
+
remainder?: RemainderPolicy;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type MoneyJSON = { amount: string; currency: string };
|
|
86
|
+
|
|
87
|
+
const BRAND = Symbol('Money');
|
|
88
|
+
const MAX = Number.MAX_SAFE_INTEGER;
|
|
89
|
+
const INTEGER = /^[+-]?\d+$/;
|
|
90
|
+
|
|
91
|
+
// `read()` leaves a factor's fraction here instead of allocating an object.
|
|
92
|
+
let num: Units = 0;
|
|
93
|
+
let den: Units = 1;
|
|
94
|
+
|
|
95
|
+
/** A multiplier read once, for loops: `const vat = factor('0.19')`. */
|
|
96
|
+
export class Factor {
|
|
97
|
+
readonly numerator: Units;
|
|
98
|
+
/** Always positive. */
|
|
99
|
+
readonly denominator: Units;
|
|
100
|
+
|
|
101
|
+
private constructor(numerator: Units, denominator: Units, brand: typeof BRAND) {
|
|
102
|
+
if (brand !== BRAND) throw new TypeError('Use factor(value) instead of new Factor()');
|
|
103
|
+
this.numerator = numerator;
|
|
104
|
+
this.denominator = denominator;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @internal */
|
|
108
|
+
static create(numerator: Units, denominator: Units): Factor {
|
|
109
|
+
return new Factor(numerator, denominator, BRAND);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Prepares a multiplier once so `times` skips parsing: `price.times(vat, 'halfEven')`. */
|
|
114
|
+
export function factor(value: Amount): Factor {
|
|
115
|
+
read(value);
|
|
116
|
+
return Factor.create(num, den);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function read(value: FactorInput): void {
|
|
120
|
+
if (typeof value === 'number') {
|
|
121
|
+
if (Number.isSafeInteger(value)) {
|
|
122
|
+
num = value + 0;
|
|
123
|
+
den = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const parsed = parseNumber(value);
|
|
127
|
+
num = parsed.coefficient;
|
|
128
|
+
den = pow10(parsed.fraction);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (typeof value === 'string') {
|
|
132
|
+
const parsed = parseDecimal(value);
|
|
133
|
+
num = parsed.coefficient;
|
|
134
|
+
den = pow10(parsed.fraction);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (typeof value === 'bigint') {
|
|
138
|
+
num = canon(value);
|
|
139
|
+
den = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (value instanceof Factor) {
|
|
143
|
+
num = value.numerator;
|
|
144
|
+
den = value.denominator;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- plain JavaScript can pass null
|
|
148
|
+
if (typeof value === 'object' && value !== null) {
|
|
149
|
+
if ('coefficient' in value && typeof value.coefficient === 'bigint' && typeof value.scale === 'number') {
|
|
150
|
+
if (!Number.isInteger(value.scale) || value.scale < 0 || value.scale > MAX_INPUT_LENGTH * 10) {
|
|
151
|
+
throw new InvalidArgumentError(`Unsupported decimal scale ${value.scale}`);
|
|
152
|
+
}
|
|
153
|
+
num = canon(value.coefficient);
|
|
154
|
+
den = pow10(value.scale);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if ('numerator' in value && typeof value.numerator === 'bigint' && typeof value.denominator === 'bigint') {
|
|
158
|
+
if (value.denominator === 0n) throw new DivisionByZeroError('A ratio cannot have a zero denominator');
|
|
159
|
+
const negative = value.denominator < 0n;
|
|
160
|
+
num = canon(negative ? -value.numerator : value.numerator);
|
|
161
|
+
den = canon(negative ? -value.denominator : value.denominator);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw new TypeError('Expected a number, string, bigint, Decimal, Rational or Factor');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function checkScale(scale: number): number {
|
|
169
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
|
|
170
|
+
throw new InvalidArgumentError(`A scale is a whole number from 0 to ${MAX_SCALE}, got ${scale}`);
|
|
171
|
+
}
|
|
172
|
+
return scale;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function refuseCoercion(): never {
|
|
176
|
+
throw new TypeError(
|
|
177
|
+
'Money cannot be turned into a number implicitly; use toDecimalString(), toMinorUnits() or toNumber()',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** An immutable amount of money. Create one with `money()` or the `Money.*` factories. */
|
|
182
|
+
export class Money<C extends string = string> {
|
|
183
|
+
/** The amount as a whole number of `10^-scale` units, in canonical form: a `number` exactly when it is a safe integer. */
|
|
184
|
+
readonly units: Units;
|
|
185
|
+
/** Number of decimals the amount carries. Equals `currency.scale` unless chosen otherwise. */
|
|
186
|
+
readonly scale: number;
|
|
187
|
+
readonly currency: Currency<C>;
|
|
188
|
+
|
|
189
|
+
private constructor(units: Units, scale: number, currency: Currency<C>, brand: typeof BRAND) {
|
|
190
|
+
if (brand !== BRAND) throw new TypeError('Use money(amount, currency) instead of new Money()');
|
|
191
|
+
this.units = units;
|
|
192
|
+
this.scale = scale;
|
|
193
|
+
this.currency = currency;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @internal Builds a value from already-validated parts. */
|
|
197
|
+
static create<C extends string>(units: Units, scale: number, currency: Currency<C>): Money<C> {
|
|
198
|
+
return new Money(units, scale, currency, BRAND);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Construction -----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `Money.of('19.99', 'EUR')`. Extra digits throw `RoundingNecessaryError`
|
|
205
|
+
* unless `options.rounding` is given: `Money.of('1.005', 'EUR', { rounding: 'halfEven' })` → `1.00 EUR`.
|
|
206
|
+
* A `Money` input must be in the same currency; this also narrows `Money<string>` to `Money<'EUR'>`.
|
|
207
|
+
*/
|
|
208
|
+
static of<const C extends string>(
|
|
209
|
+
amount: Amount | Money,
|
|
210
|
+
currency: CurrencyLike<C>,
|
|
211
|
+
options?: MoneyOptions,
|
|
212
|
+
): Money<C> {
|
|
213
|
+
const cur = toCurrency(currency);
|
|
214
|
+
const scale = options?.scale === undefined ? cur.scale : checkScale(options.scale);
|
|
215
|
+
const rounding = options?.rounding;
|
|
216
|
+
if (amount instanceof Money) {
|
|
217
|
+
assertSameCurrency(amount.currency, cur);
|
|
218
|
+
return new Money(quantize(amount.units, amount.scale, scale, rounding), scale, cur, BRAND);
|
|
219
|
+
}
|
|
220
|
+
read(amount);
|
|
221
|
+
return new Money(mulDiv(num, pow10(scale), den, rounding), scale, cur, BRAND);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* From whole minor units: `Money.fromMinor(1999, 'EUR')` → `19.99 EUR`. Accepts a
|
|
226
|
+
* safe-integer `number`, a `bigint`, or an integer string (as databases return
|
|
227
|
+
* `BIGINT`). `scale` says how many decimals the units carry; it defaults to the currency's.
|
|
228
|
+
*/
|
|
229
|
+
static fromMinor<const C extends string>(
|
|
230
|
+
units: number | bigint | string,
|
|
231
|
+
currency: CurrencyLike<C>,
|
|
232
|
+
scale?: number,
|
|
233
|
+
): Money<C> {
|
|
234
|
+
const cur = toCurrency(currency);
|
|
235
|
+
return new Money(toUnits(units), scale === undefined ? cur.scale : checkScale(scale), cur, BRAND);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Zero in this currency. */
|
|
239
|
+
static zero<const C extends string>(currency: CurrencyLike<C>, scale?: number): Money<C> {
|
|
240
|
+
const cur = toCurrency(currency);
|
|
241
|
+
return new Money(0, scale === undefined ? cur.scale : checkScale(scale), cur, BRAND);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Reads what `toString()` writes: `'19.99 EUR'`, also `'EUR 19.99'`. Keeps the
|
|
246
|
+
* text's decimals when there are more than the currency's (`'1.2345 EUR'`
|
|
247
|
+
* stays at scale 4). Pass `currencies` to resolve custom codes.
|
|
248
|
+
*/
|
|
249
|
+
static parse(text: string, currencies?: readonly Currency[]): Money {
|
|
250
|
+
if (typeof text !== 'string') throw new TypeError('Money.parse expects a string');
|
|
251
|
+
const parts = text.trim().split(/\s+/);
|
|
252
|
+
const [first = '', second = ''] = parts;
|
|
253
|
+
if (parts.length !== 2) {
|
|
254
|
+
throw new NumberFormatError(`Cannot read ${JSON.stringify(text.slice(0, 40))} as money; expected '19.99 EUR'`);
|
|
255
|
+
}
|
|
256
|
+
const amountFirst = /^[+-]?\d/.test(first);
|
|
257
|
+
return fromText(amountFirst ? first : second, resolveCurrency(amountFirst ? second : first, currencies));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Reads `{ amount: '19.99', currency: 'EUR' }`, keeping the amount's decimals. Validates the shape. */
|
|
261
|
+
static fromJSON(value: unknown, currencies?: readonly Currency[]): Money {
|
|
262
|
+
if (typeof value !== 'object' || value === null || !('amount' in value) || !('currency' in value)) {
|
|
263
|
+
throw new InvalidArgumentError('Expected { amount: string, currency: string }');
|
|
264
|
+
}
|
|
265
|
+
const { amount, currency } = value;
|
|
266
|
+
if (typeof amount !== 'string' || typeof currency !== 'string') {
|
|
267
|
+
throw new InvalidArgumentError('Expected { amount: string, currency: string }');
|
|
268
|
+
}
|
|
269
|
+
return fromText(amount, resolveCurrency(currency, currencies));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Comparator for sorting: `prices.sort(Money.compare)`. Throws on mixed currencies. */
|
|
273
|
+
static compare(this: void, a: Money, b: Money): -1 | 0 | 1 {
|
|
274
|
+
return a.compareTo(b);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Arithmetic -------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/** Exact sum. Different scales are aligned upward, never rounded. */
|
|
280
|
+
plus(other: Money<C>): Money<C> {
|
|
281
|
+
const cur = this.currency;
|
|
282
|
+
if (cur !== other.currency) assertSameCurrency(cur, other.currency);
|
|
283
|
+
const scale = this.scale;
|
|
284
|
+
if (scale !== other.scale) return combine(this, other, false);
|
|
285
|
+
const a = this.units;
|
|
286
|
+
const b = other.units;
|
|
287
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
288
|
+
const sum = a + b;
|
|
289
|
+
if (sum <= MAX && sum >= -MAX) return new Money(sum, scale, cur, BRAND);
|
|
290
|
+
}
|
|
291
|
+
return new Money(addUnits(a, b), scale, cur, BRAND);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Exact difference. Different scales are aligned upward, never rounded. */
|
|
295
|
+
minus(other: Money<C>): Money<C> {
|
|
296
|
+
const cur = this.currency;
|
|
297
|
+
if (cur !== other.currency) assertSameCurrency(cur, other.currency);
|
|
298
|
+
const scale = this.scale;
|
|
299
|
+
if (scale !== other.scale) return combine(this, other, true);
|
|
300
|
+
const a = this.units;
|
|
301
|
+
const b = other.units;
|
|
302
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
303
|
+
const difference = a - b;
|
|
304
|
+
if (difference <= MAX && difference >= -MAX) return new Money(difference, scale, cur, BRAND);
|
|
305
|
+
}
|
|
306
|
+
return new Money(addUnits(a, negUnits(b)), scale, cur, BRAND);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Multiplies and keeps this scale. `money('10.00', 'EUR').times(3)` → `30.00`.
|
|
311
|
+
* `times('1.19')` on `9.99` is `11.8881`, which does not fit two decimals, so
|
|
312
|
+
* it throws unless you pass a rounding mode: `times('1.19', 'halfEven')` → `11.89`.
|
|
313
|
+
*/
|
|
314
|
+
times(multiplier: FactorInput, rounding?: RoundingMode): Money<C> {
|
|
315
|
+
if (typeof multiplier === 'number' && Number.isSafeInteger(multiplier)) {
|
|
316
|
+
return new Money(mulUnits(this.units, multiplier), this.scale, this.currency, BRAND);
|
|
317
|
+
}
|
|
318
|
+
read(multiplier);
|
|
319
|
+
return new Money(mulDiv(this.units, num, den, rounding), this.scale, this.currency, BRAND);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Divides and keeps this scale. Inexact results need a rounding mode. Zero throws `DivisionByZeroError`. */
|
|
323
|
+
dividedBy(divisor: FactorInput, rounding?: RoundingMode): Money<C> {
|
|
324
|
+
read(divisor);
|
|
325
|
+
const n = num;
|
|
326
|
+
const d = den;
|
|
327
|
+
if (n === 0) throw new DivisionByZeroError('Division by zero');
|
|
328
|
+
const units = n < 0 ? mulDiv(negUnits(this.units), d, negUnits(n), rounding) : mulDiv(this.units, d, n, rounding);
|
|
329
|
+
return new Money(units, this.scale, this.currency, BRAND);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `money(200, 'EUR').percentage(15)` → `30.00 EUR`. Same as `times(percent / 100)`. */
|
|
333
|
+
percentage(percent: FactorInput, rounding?: RoundingMode): Money<C> {
|
|
334
|
+
read(percent);
|
|
335
|
+
return new Money(mulDiv(this.units, num, mulUnits(den, 100), rounding), this.scale, this.currency, BRAND);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
negated(): Money<C> {
|
|
339
|
+
return new Money(negUnits(this.units), this.scale, this.currency, BRAND);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
abs(): Money<C> {
|
|
343
|
+
return this.units < 0 ? this.negated() : this;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Changes the number of decimals. Dropping non-zero digits needs a rounding mode. */
|
|
347
|
+
toScale(scale: number, rounding?: RoundingMode): Money<C> {
|
|
348
|
+
checkScale(scale);
|
|
349
|
+
if (scale === this.scale) return this;
|
|
350
|
+
return new Money(quantize(this.units, this.scale, scale, rounding), scale, this.currency, BRAND);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Rounds to a multiple of `step`, for cash: `chf.roundToStep('0.05', 'halfUp')`.
|
|
355
|
+
* Cash steps are set by each country's law (CHF 0.05, SEK 1, EUR 0.05 in some
|
|
356
|
+
* euro countries), so the package does not guess them. The step must fit this scale.
|
|
357
|
+
*/
|
|
358
|
+
roundToStep(step: FactorInput, rounding?: RoundingMode): Money<C> {
|
|
359
|
+
read(step);
|
|
360
|
+
if (!(num > 0)) throw new InvalidArgumentError('A rounding step must be positive');
|
|
361
|
+
let stepUnits: Units;
|
|
362
|
+
try {
|
|
363
|
+
stepUnits = mulDiv(num, pow10(this.scale), den);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!(error instanceof RoundingNecessaryError)) throw error;
|
|
366
|
+
throw new InvalidArgumentError(`The step has more decimals than this amount's ${this.scale}`, { cause: error });
|
|
367
|
+
}
|
|
368
|
+
const units = mulUnits(mulDiv(this.units, 1, stepUnits, rounding), stepUnits);
|
|
369
|
+
return new Money(units, this.scale, this.currency, BRAND);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Allocation -------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Splits by ratios into parts that add back to this amount exactly:
|
|
376
|
+
* `money(100, 'EUR').allocate([1, 1, 1])` → `[33.34, 33.33, 33.33]`.
|
|
377
|
+
* Ratios are non-negative numbers, bigints or decimal strings, not all zero.
|
|
378
|
+
* A negative amount is split like a positive one and each part re-signed.
|
|
379
|
+
*/
|
|
380
|
+
allocate(ratios: readonly (number | bigint | string)[], options?: AllocateOptions): Money<C>[] {
|
|
381
|
+
const count = ratios.length;
|
|
382
|
+
if (count === 0) throw new InvalidArgumentError('allocate needs at least one ratio');
|
|
383
|
+
if (count > MAX_PARTS) throw new ResourceLimitError(`allocate makes at most ${MAX_PARTS} parts`);
|
|
384
|
+
const coefficients: Units[] = [];
|
|
385
|
+
const fractions: number[] = [];
|
|
386
|
+
let common = 0;
|
|
387
|
+
for (const ratio of ratios) {
|
|
388
|
+
const parsed =
|
|
389
|
+
typeof ratio === 'number'
|
|
390
|
+
? parseNumber(ratio)
|
|
391
|
+
: typeof ratio === 'string'
|
|
392
|
+
? parseDecimal(ratio)
|
|
393
|
+
: typeof ratio === 'bigint'
|
|
394
|
+
? { coefficient: canon(ratio), fraction: 0 }
|
|
395
|
+
: badRatio();
|
|
396
|
+
if (parsed.coefficient < 0) throw new InvalidArgumentError(`Ratios must not be negative, got ${String(ratio)}`);
|
|
397
|
+
coefficients.push(parsed.coefficient);
|
|
398
|
+
fractions.push(parsed.fraction);
|
|
399
|
+
if (parsed.fraction > common) common = parsed.fraction;
|
|
400
|
+
}
|
|
401
|
+
const weights: Units[] = [];
|
|
402
|
+
let total: Units = 0;
|
|
403
|
+
for (let i = 0; i < count; i++) {
|
|
404
|
+
const weight = mulUnits(coefficients[i] ?? 0, pow10(common - (fractions[i] ?? 0)));
|
|
405
|
+
weights.push(weight);
|
|
406
|
+
total = addUnits(total, weight);
|
|
407
|
+
}
|
|
408
|
+
if (total === 0) throw new InvalidArgumentError('Ratios must not all be zero');
|
|
409
|
+
return distribute(this, weights, total, options?.remainder ?? 'largest');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Splits into `parts` equal shares that differ by at most one unit: `money(10, 'EUR').split(3)` → `[3.34, 3.33, 3.33]`. */
|
|
413
|
+
split(parts: number, options?: AllocateOptions): Money<C>[] {
|
|
414
|
+
if (!Number.isInteger(parts) || parts < 1)
|
|
415
|
+
throw new InvalidArgumentError(`split needs a whole number of parts ≥ 1, got ${parts}`);
|
|
416
|
+
if (parts > MAX_PARTS) throw new ResourceLimitError(`split makes at most ${MAX_PARTS} parts`);
|
|
417
|
+
const negative = this.units < 0;
|
|
418
|
+
const magnitude = negative ? negUnits(this.units) : this.units;
|
|
419
|
+
const share = mulDiv(magnitude, 1, parts, 'down');
|
|
420
|
+
const left = Number(addUnits(magnitude, negUnits(mulUnits(share, parts))));
|
|
421
|
+
const bigger = addUnits(share, 1);
|
|
422
|
+
const fromEnd = options?.remainder === 'last';
|
|
423
|
+
const result: Money<C>[] = [];
|
|
424
|
+
for (let i = 0; i < parts; i++) {
|
|
425
|
+
const gets = fromEnd ? i >= parts - left : i < left;
|
|
426
|
+
const units = gets ? bigger : share;
|
|
427
|
+
result.push(new Money(negative ? negUnits(units) : units, this.scale, this.currency, BRAND));
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Comparison -------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
/** Compares values across scales (`1.5` equals `1.50`). Throws on mixed currencies. */
|
|
435
|
+
compareTo(other: Money<C>): -1 | 0 | 1 {
|
|
436
|
+
if (this.currency !== other.currency) assertSameCurrency(this.currency, other.currency);
|
|
437
|
+
if (this.scale === other.scale) return compareUnits(this.units, other.units);
|
|
438
|
+
const scale = Math.max(this.scale, other.scale);
|
|
439
|
+
return compareUnits(
|
|
440
|
+
mulUnits(this.units, pow10(scale - this.scale)),
|
|
441
|
+
mulUnits(other.units, pow10(scale - other.scale)),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
eq(other: Money<C>): boolean {
|
|
446
|
+
return this.compareTo(other) === 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
lt(other: Money<C>): boolean {
|
|
450
|
+
return this.compareTo(other) < 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
lte(other: Money<C>): boolean {
|
|
454
|
+
return this.compareTo(other) <= 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
gt(other: Money<C>): boolean {
|
|
458
|
+
return this.compareTo(other) > 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
gte(other: Money<C>): boolean {
|
|
462
|
+
return this.compareTo(other) >= 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
isZero(): boolean {
|
|
466
|
+
return this.units === 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
isPositive(): boolean {
|
|
470
|
+
return this.units > 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
isNegative(): boolean {
|
|
474
|
+
return this.units < 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
sign(): -1 | 0 | 1 {
|
|
478
|
+
return compareUnits(this.units, 0);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Same currency. Never throws. */
|
|
482
|
+
hasSameCurrency(other: Money): boolean {
|
|
483
|
+
return this.currency.eq(other.currency);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Output -----------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
/** Units at the currency's scale: `19.99 EUR` → `1999`. Throws `RoundingNecessaryError` if sub-cent digits are not zero. */
|
|
489
|
+
toMinorUnits(): Units {
|
|
490
|
+
return quantize(this.units, this.scale, this.currency.scale);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `toMinorUnits()` as a `bigint`. */
|
|
494
|
+
toMinorBigInt(): bigint {
|
|
495
|
+
return BigInt(this.toMinorUnits());
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** `toMinorUnits()` as a `number`, as payment APIs want. Throws `NumericRangeError` past 2^53. */
|
|
499
|
+
toMinorNumber(): number {
|
|
500
|
+
const units = this.toMinorUnits();
|
|
501
|
+
if (typeof units !== 'number')
|
|
502
|
+
throw new NumericRangeError(`${this.toString()} has too many minor units for a number`);
|
|
503
|
+
return units;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Plain decimal text with exactly `scale` decimals: `'19.99'`, `'-0.50'`, `'1000'` for JPY. For databases. */
|
|
507
|
+
toDecimalString(): string {
|
|
508
|
+
const units = this.units;
|
|
509
|
+
const negative = units < 0;
|
|
510
|
+
let digits = String(negative ? negUnits(units) : units);
|
|
511
|
+
const scale = this.scale;
|
|
512
|
+
if (scale === 0) return negative ? `-${digits}` : digits;
|
|
513
|
+
if (digits.length <= scale) digits = '0'.repeat(scale - digits.length + 1) + digits;
|
|
514
|
+
const text = `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
|
|
515
|
+
return negative ? `-${text}` : text;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The nearest double. For charts and display math only, never for more money math. */
|
|
519
|
+
toNumber(): number {
|
|
520
|
+
return Number(this.toDecimalString());
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** `'19.99 EUR'`. Locale-free; `Money.parse` reads it back. */
|
|
524
|
+
toString(): string {
|
|
525
|
+
return `${this.toDecimalString()} ${this.currency.code}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** `{ amount: '19.99', currency: 'EUR' }`. The amount is a string, so no digit is ever lost. */
|
|
529
|
+
toJSON(): MoneyJSON {
|
|
530
|
+
return { amount: this.toDecimalString(), currency: this.currency.code };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Throws. A `Money` never silently becomes a float. */
|
|
534
|
+
valueOf(): never {
|
|
535
|
+
return refuseCoercion();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
[Symbol.toPrimitive](hint: string): string {
|
|
539
|
+
return hint === 'string' ? this.toString() : refuseCoercion();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// A getter lives on the prototype; a field would add a slot to every Money.
|
|
543
|
+
// eslint-disable-next-line @typescript-eslint/class-literal-property-style
|
|
544
|
+
get [Symbol.toStringTag](): string {
|
|
545
|
+
return 'Money';
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
549
|
+
return `Money(${this.toString()})`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
[Symbol.for('Deno.customInspect')](): string {
|
|
553
|
+
return `Money(${this.toString()})`;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function badRatio(): never {
|
|
558
|
+
throw new TypeError('Ratios must be numbers, bigints or decimal strings');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function toUnits(value: number | bigint | string): Units {
|
|
562
|
+
if (typeof value === 'number') {
|
|
563
|
+
if (!Number.isSafeInteger(value)) throw new NumericRangeError(`Minor units must be a safe integer, got ${value}`);
|
|
564
|
+
return value + 0;
|
|
565
|
+
}
|
|
566
|
+
if (typeof value === 'bigint') return canon(value);
|
|
567
|
+
if (typeof value === 'string' && value.length <= MAX_INPUT_LENGTH && INTEGER.test(value)) return canon(BigInt(value));
|
|
568
|
+
throw new NumberFormatError(`Minor units must be a whole number, got ${JSON.stringify(String(value).slice(0, 40))}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function fromText(text: string, currency: Currency): Money {
|
|
572
|
+
const parsed = parseDecimal(text);
|
|
573
|
+
const scale = checkScale(Math.max(parsed.fraction, currency.scale));
|
|
574
|
+
return Money.create(quantize(parsed.coefficient, parsed.fraction, scale), scale, currency);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function combine<C extends string>(a: Money<C>, b: Money<C>, subtract: boolean): Money<C> {
|
|
578
|
+
const scale = Math.max(a.scale, b.scale);
|
|
579
|
+
const left = mulUnits(a.units, pow10(scale - a.scale));
|
|
580
|
+
const right = mulUnits(b.units, pow10(scale - b.scale));
|
|
581
|
+
return Money.create(addUnits(left, subtract ? negUnits(right) : right), scale, a.currency);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function distribute<C extends string>(
|
|
585
|
+
whole: Money<C>,
|
|
586
|
+
weights: readonly Units[],
|
|
587
|
+
total: Units,
|
|
588
|
+
policy: RemainderPolicy,
|
|
589
|
+
): Money<C>[] {
|
|
590
|
+
const negative = whole.units < 0;
|
|
591
|
+
const magnitude = negative ? negUnits(whole.units) : whole.units;
|
|
592
|
+
const count = weights.length;
|
|
593
|
+
const shares: Units[] = [];
|
|
594
|
+
const rests: Units[] = [];
|
|
595
|
+
let given: Units = 0;
|
|
596
|
+
for (let i = 0; i < count; i++) {
|
|
597
|
+
const exact = mulUnits(magnitude, weights[i] ?? 0);
|
|
598
|
+
const share = mulDiv(exact, 1, total, 'down');
|
|
599
|
+
shares.push(share);
|
|
600
|
+
rests.push(addUnits(exact, negUnits(mulUnits(share, total))));
|
|
601
|
+
given = addUnits(given, share);
|
|
602
|
+
}
|
|
603
|
+
// Fewer than `count` units are left, and only parts with a non-zero rest can need one.
|
|
604
|
+
let left = Number(addUnits(magnitude, negUnits(given)));
|
|
605
|
+
if (left > 0) {
|
|
606
|
+
const order: number[] = [];
|
|
607
|
+
for (let i = 0; i < count; i++) if (weights[i] !== 0) order.push(i);
|
|
608
|
+
if (policy === 'largest') order.sort((x, y) => compareUnits(rests[y] ?? 0, rests[x] ?? 0) || x - y);
|
|
609
|
+
else if (policy === 'last') order.reverse();
|
|
610
|
+
for (const index of order) {
|
|
611
|
+
if (left === 0) break;
|
|
612
|
+
shares[index] = addUnits(shares[index] ?? 0, 1);
|
|
613
|
+
left -= 1;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return shares.map(share => Money.create(negative ? negUnits(share) : share, whole.scale, whole.currency));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Shorthands and aggregates ------------------------------------------------
|
|
620
|
+
|
|
621
|
+
/** Shorthand for `Money.of`: `money('19.99', 'EUR')`. */
|
|
622
|
+
export function money<const C extends string>(
|
|
623
|
+
amount: Amount | Money,
|
|
624
|
+
currency: CurrencyLike<C>,
|
|
625
|
+
options?: MoneyOptions,
|
|
626
|
+
): Money<C> {
|
|
627
|
+
return Money.of(amount, currency, options);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function isMoney(value: unknown): value is Money {
|
|
631
|
+
return value instanceof Money;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Exact total of one currency, with one allocation for the result. An empty
|
|
636
|
+
* list needs `currency` and returns zero; otherwise it throws `InvalidArgumentError`.
|
|
637
|
+
*/
|
|
638
|
+
export function sum<const C extends string>(items: Iterable<Money<C>>, currency?: CurrencyLike<C>): Money<C> {
|
|
639
|
+
let cur: Currency<C> | undefined = currency === undefined ? undefined : toCurrency(currency);
|
|
640
|
+
let scale = cur?.scale ?? -1;
|
|
641
|
+
let units: Units = 0;
|
|
642
|
+
for (const item of items) {
|
|
643
|
+
if (cur === undefined) {
|
|
644
|
+
cur = item.currency;
|
|
645
|
+
scale = item.scale;
|
|
646
|
+
} else if (cur !== item.currency) {
|
|
647
|
+
assertSameCurrency(cur, item.currency);
|
|
648
|
+
}
|
|
649
|
+
let value = item.units;
|
|
650
|
+
if (item.scale > scale) {
|
|
651
|
+
units = mulUnits(units, pow10(item.scale - scale));
|
|
652
|
+
scale = item.scale;
|
|
653
|
+
} else if (item.scale < scale) {
|
|
654
|
+
value = mulUnits(value, pow10(scale - item.scale));
|
|
655
|
+
}
|
|
656
|
+
if (typeof units === 'number' && typeof value === 'number') {
|
|
657
|
+
const next: number = units + value;
|
|
658
|
+
units = next <= MAX && next >= -MAX ? next : addUnits(units, value);
|
|
659
|
+
} else {
|
|
660
|
+
units = addUnits(units, value);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (cur === undefined) throw new InvalidArgumentError('sum of an empty list needs a currency');
|
|
664
|
+
return Money.create(units, scale, cur);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Mean of one currency. Inexact results need a rounding mode. An empty list throws `EmptyDataError`. */
|
|
668
|
+
export function average<const C extends string>(items: Iterable<Money<C>>, rounding?: RoundingMode): Money<C> {
|
|
669
|
+
const list = [...items];
|
|
670
|
+
if (list.length === 0) throw new EmptyDataError('average of an empty list');
|
|
671
|
+
return sum(list).dividedBy(list.length, rounding);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The smallest value; ties return the first. */
|
|
675
|
+
export function min<const C extends string>(first: Money<C>, ...rest: Money<C>[]): Money<C> {
|
|
676
|
+
let best = first;
|
|
677
|
+
for (const item of rest) if (item.lt(best)) best = item;
|
|
678
|
+
return best;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** The largest value; ties return the first. */
|
|
682
|
+
export function max<const C extends string>(first: Money<C>, ...rest: Money<C>[]): Money<C> {
|
|
683
|
+
let best = first;
|
|
684
|
+
for (const item of rest) if (item.gt(best)) best = item;
|
|
685
|
+
return best;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** @internal Reads an amount or factor as an exact fraction whose denominator is positive. */
|
|
689
|
+
export function toFraction(value: FactorInput): [numerator: Units, denominator: Units] {
|
|
690
|
+
read(value);
|
|
691
|
+
return [num, den];
|
|
692
|
+
}
|