@ultimat3/money 1.2.0 → 3.0.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/src/money.ts CHANGED
@@ -3,20 +3,45 @@
3
3
  * There is no float anywhere in this package, and no amount without a currency.
4
4
  */
5
5
 
6
- import { assertCurrency, type CurrencyCode, exponentOf, scaleOf } from './currency';
6
+ import { isMoneyScale, type MoneyValue } from '@ultimat3/schema';
7
+ import { assertCurrency, type CurrencyCode, exponentOf } from './currency';
7
8
  import { decimalNotNumeric, decimalTooPrecise, moneyNotInteger } from './errors';
8
- import { type RoundingMode, roundToInteger } from './rounding';
9
+ import { type RoundingMode, roundRatio } from './rounding';
10
+ import { assertScale, commonScale, minorAt, moneyScale } from './scale';
9
11
 
10
- /** `{ minor: 129900, currency: 'EUR' }` is €1,299.00. Treat instances as immutable. */
11
- export type Money = { minor: number; currency: string };
12
+ /**
13
+ * `{ minor: 129900, currency: 'EUR' }` is €1,299.00. Instances are immutable, and now enforced
14
+ * rather than asked for: both fields are `readonly`.
15
+ *
16
+ * An **alias**, never a restatement. `@ultimat3/schema`'s `MoneyValue` is the framework's one
17
+ * declaration (tier 0, so every package may reach it) and `@ultimat3/entity`'s `MoneyValue` is
18
+ * the same alias — a second structural copy is what let `minor` drift to `bigint` in the entity
19
+ * layer, so a row it decoded satisfied neither `t.money` nor `JSON.stringify`.
20
+ */
21
+ export type Money = MoneyValue;
12
22
 
13
23
  const DECIMAL = /^([+-])?(\d+)(?:\.(\d+))?$/;
14
24
 
15
- /** The only constructor. Validates the currency and rejects fractional minor units. */
16
- export function money(minor: number, currency: string): Money {
25
+ /**
26
+ * The only constructor. Validates the currency and rejects fractional minor units.
27
+ *
28
+ * `scale` names how many decimal places `minor` counts when the currency's own are not enough:
29
+ * `money(2, 'USD', 6)` is $0.000002. Omitted — and canonically omitted again when it says nothing
30
+ * the currency does not already say — so a value at the natural scale serializes byte-for-byte as
31
+ * it always has, and there is exactly one encoding of it.
32
+ */
33
+ export function money(minor: number, currency: string, scale?: number): Money {
17
34
  const code = assertCurrency(currency);
18
35
  if (!Number.isSafeInteger(minor)) throw moneyNotInteger(minor, code);
19
- return { minor, currency: code };
36
+ // `-0` is one amount with two identities: `JSON.stringify` writes `0` while `Object.is` and any
37
+ // keyed `Map` see something else, so a ledger reconciles against a value its own wire format
38
+ // cannot reproduce. Normalised here because this is the one place canonical form is decided.
39
+ const value = minor === 0 ? 0 : minor;
40
+ if (scale === undefined) return { minor: value, currency: code };
41
+ assertScale(scale);
42
+ return scale === exponentOf(code)
43
+ ? { minor: value, currency: code }
44
+ : { minor: value, currency: code, scale };
20
45
  }
21
46
 
22
47
  export function zero(currency: string): Money {
@@ -24,8 +49,13 @@ export function zero(currency: string): Money {
24
49
  }
25
50
 
26
51
  export interface FromDecimalOptions {
27
- /** Required to accept a value with more precision than the currency has. */
52
+ /** Required to accept a value with more precision than the target scale has. */
28
53
  rounding?: RoundingMode;
54
+ /**
55
+ * Decimal places to keep, when the currency's own are not enough:
56
+ * `fromDecimal('0.000002', 'USD', { scale: 6 })`. Omitted, the currency decides, as before.
57
+ */
58
+ scale?: number;
29
59
  }
30
60
 
31
61
  /**
@@ -45,7 +75,7 @@ export function fromDecimal(
45
75
  throw decimalNotNumeric(value, code);
46
76
  }
47
77
 
48
- const exponent = exponentOf(code);
78
+ const exponent = options.scale === undefined ? exponentOf(code) : assertScale(options.scale);
49
79
  const negative = match[1] === '-';
50
80
  const fractionPart = match[3] ?? '';
51
81
 
@@ -56,18 +86,23 @@ export function fromDecimal(
56
86
  } else {
57
87
  const mode = options.rounding;
58
88
  if (mode === undefined) throw decimalTooPrecise(value, code, exponent);
59
- const kept = Number(`${integerPart}${fractionPart.slice(0, exponent)}`);
60
- const remainder = Number(`0.${fractionPart.slice(exponent)}`);
61
- minor = roundToInteger(kept + remainder, mode);
89
+ // The exact fraction the digits spell, never a float: `Number('0.4999999999999999999')` is
90
+ // exactly 0.5, so the float path showed `half-up` a tie the written decimal does not have and
91
+ // billed 101 for an amount that owes 100. Same `roundRatio` multiply/divide/convert use.
92
+ const dropped = fractionPart.slice(exponent);
93
+ minor = roundRatio(
94
+ BigInt(`${integerPart}${fractionPart}`),
95
+ 10n ** BigInt(dropped.length),
96
+ mode,
97
+ );
62
98
  }
63
99
 
64
- if (!Number.isSafeInteger(minor)) throw moneyNotInteger(minor, code);
65
- return { minor: negative ? -minor : minor, currency: code };
100
+ return money(negative ? -minor : minor, code, options.scale);
66
101
  }
67
102
 
68
- /** `1299 EUR` → `'12.99'`; `1200 JPY` → `'1200'`; `1234 KWD` → `'1.234'`. */
103
+ /** `1299 EUR` → `'12.99'`; `1200 JPY` → `'1200'`; `2 USD @ scale 6` → `'0.000002'`. */
69
104
  export function toDecimalString(amount: Money): string {
70
- const exponent = exponentOf(amount.currency);
105
+ const exponent = moneyScale(amount);
71
106
  const sign = amount.minor < 0 ? '-' : '';
72
107
  const digits = Math.abs(amount.minor)
73
108
  .toString()
@@ -81,23 +116,37 @@ export function toDecimalString(amount: Money): string {
81
116
  * currency scale is legitimate, because `Intl.NumberFormat` takes a number.
82
117
  */
83
118
  export function toDecimalNumber(amount: Money): number {
84
- return amount.minor / scaleOf(amount.currency);
119
+ return amount.minor / 10 ** moneyScale(amount);
85
120
  }
86
121
 
87
122
  export function isMoney(value: unknown): value is Money {
88
123
  if (value === null || typeof value !== 'object') return false;
89
- const candidate = value as { minor?: unknown; currency?: unknown };
90
- return Number.isSafeInteger(candidate.minor) && typeof candidate.currency === 'string';
124
+ const candidate = value as { minor?: unknown; currency?: unknown; scale?: unknown };
125
+ if (!Number.isSafeInteger(candidate.minor) || typeof candidate.currency !== 'string')
126
+ return false;
127
+ return candidate.scale === undefined || isMoneyScale(candidate.scale);
91
128
  }
92
129
 
93
- /** Same currency, same minor units. */
130
+ /**
131
+ * Same currency, same value. Same *value*, not the same encoding: 1299 EUR and 12,990,000 EUR at
132
+ * scale 6 are one amount written two ways, and a ledger that called them different would
133
+ * reconcile against itself.
134
+ */
94
135
  export function equals(left: Money, right: Money): boolean {
95
- return left.currency === right.currency && left.minor === right.minor;
136
+ if (left.currency !== right.currency) return false;
137
+ const scale = commonScale(left, right);
138
+ return minorAt(left, scale) === minorAt(right, scale);
96
139
  }
97
140
 
98
- /** Stable serialization for logs, JSON columns and the manifest: `EUR 1299`. */
141
+ /**
142
+ * Stable serialization for logs, JSON columns and the manifest: `EUR 1299`, and `USD 2e-6` for a
143
+ * value whose scale is not the currency's — unchanged for every value that carries none.
144
+ */
99
145
  export function formatMoneyDebug(amount: Money): string {
100
- return `${amount.currency} ${amount.minor}`;
146
+ const scale = amount.scale;
147
+ return scale === undefined
148
+ ? `${amount.currency} ${amount.minor}`
149
+ : `${amount.currency} ${amount.minor}e-${scale}`;
101
150
  }
102
151
 
103
152
  export function currencyOf(amount: Money): CurrencyCode {
package/src/rescale.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Moving a money value between decimal scales.
3
+ * Widening is exact and free. Narrowing throws away digits, so it happens only when the caller
4
+ * names the rounding mode — the same bar `fromDecimal` sets for excess precision.
5
+ */
6
+
7
+ import { rescaleNotExact } from './errors';
8
+ import { type Money, money } from './money';
9
+ import { type RoundingMode, roundRatio } from './rounding';
10
+ import { assertScale, minorAt, moneyScale, toMinor } from './scale';
11
+
12
+ /**
13
+ * `rescale(money(80, 'USD'), 8)` → 80,000,000 hundred-millionths, the granularity a per-token
14
+ * price needs. `rescale(m, 2, 'half-up')` brings it back to cents, having named who pays for the
15
+ * digits that go.
16
+ */
17
+ export function rescale(amount: Money, scale: number, mode?: RoundingMode): Money {
18
+ assertScale(scale);
19
+ const from = moneyScale(amount);
20
+ if (scale >= from) {
21
+ return money(toMinor(minorAt(amount, scale), scale, amount.currency), amount.currency, scale);
22
+ }
23
+
24
+ const divisor = 10n ** BigInt(from - scale);
25
+ const numerator = BigInt(amount.minor);
26
+ // An exact narrowing needs no mode: nothing is being decided, so nothing has to be declared.
27
+ if (mode === undefined && numerator % divisor !== 0n) {
28
+ throw rescaleNotExact(amount, from, scale);
29
+ }
30
+ return money(roundRatio(numerator, divisor, mode), amount.currency, scale);
31
+ }
package/src/rounding.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * whichever one `Math.round` happens to implement is not an answer.
4
4
  */
5
5
 
6
+ import { invariant } from '@ultimat3/core';
6
7
  import { notRoundable } from './errors';
7
8
 
8
9
  export type RoundingMode =
@@ -29,19 +30,76 @@ export function roundToInteger(value: number, mode: RoundingMode = DEFAULT_ROUND
29
30
 
30
31
  switch (mode) {
31
32
  case 'down':
32
- return sign * floor;
33
+ return signed(sign, floor);
33
34
  case 'up':
34
- return sign * (fraction > 0 ? floor + 1 : floor);
35
+ return signed(sign, fraction > 0 ? floor + 1 : floor);
35
36
  case 'half-up':
36
- return sign * (fraction >= 0.5 ? floor + 1 : floor);
37
+ return signed(sign, fraction >= 0.5 ? floor + 1 : floor);
37
38
  case 'half-even': {
38
- if (fraction > 0.5) return sign * (floor + 1);
39
- if (fraction < 0.5) return sign * floor;
40
- return sign * (floor % 2 === 0 ? floor : floor + 1);
39
+ if (fraction > 0.5) return signed(sign, floor + 1);
40
+ if (fraction < 0.5) return signed(sign, floor);
41
+ return signed(sign, floor % 2 === 0 ? floor : floor + 1);
41
42
  }
42
43
  }
43
44
  }
44
45
 
46
+ /**
47
+ * `sign * magnitude`, except that a magnitude of zero stays `0`. `-1 * 0` is `-0`, which
48
+ * `JSON.stringify` writes as `0` while `Object.is` and any keyed `Map` see a different value —
49
+ * so a refund rounding to nothing produced an amount its own wire format cannot reproduce.
50
+ */
51
+ function signed(sign: number, magnitude: number): number {
52
+ return magnitude === 0 ? 0 : sign * magnitude;
53
+ }
54
+
55
+ /**
56
+ * Round the exact rational `numerator / denominator` with the same four modes.
57
+ *
58
+ * The float path above can only judge a value IEEE-754 has already moved: `100 * 1.005` is
59
+ * 100.49999999999999, so `half-up` answers 100 where the exact 100.5 owes 101 — a 0.5% fee on
60
+ * €1.00 charged as nothing. A scale therefore reaches a mode as a fraction, never as a product.
61
+ */
62
+ export function roundRatio(
63
+ numerator: bigint,
64
+ denominator: bigint,
65
+ mode: RoundingMode = DEFAULT_ROUNDING,
66
+ ): number {
67
+ invariant(
68
+ denominator !== 0n,
69
+ 'X_INVARIANT',
70
+ 'cannot round a ratio whose denominator is zero',
71
+ 'roundRatio(numerator, 1n, mode) # a zero denominator names no value; divide(amount, 0) is refused before it reaches here, so this is a caller building the fraction itself',
72
+ );
73
+ // One sign, carried out front, so each mode sees a magnitude exactly as `roundToInteger` does.
74
+ const negative = numerator < 0n !== denominator < 0n;
75
+ const top = numerator < 0n ? -numerator : numerator;
76
+ const bottom = denominator < 0n ? -denominator : denominator;
77
+ const whole = top / bottom;
78
+ const remainder = top % bottom;
79
+ // `remainder / bottom` vs `1/2` without leaving the integers: compare `2 * remainder` to `bottom`.
80
+ const twice = remainder * 2n;
81
+
82
+ let rounded: bigint;
83
+ switch (mode) {
84
+ case 'down':
85
+ rounded = whole;
86
+ break;
87
+ case 'up':
88
+ rounded = remainder > 0n ? whole + 1n : whole;
89
+ break;
90
+ case 'half-up':
91
+ rounded = twice >= bottom ? whole + 1n : whole;
92
+ break;
93
+ case 'half-even':
94
+ if (twice > bottom) rounded = whole + 1n;
95
+ else if (twice < bottom) rounded = whole;
96
+ else rounded = whole % 2n === 0n ? whole : whole + 1n;
97
+ break;
98
+ }
99
+ // Past 2^53 the `Number` is already approximate, which `money()` refuses as X_MONEY_NOT_INTEGER.
100
+ return Number(negative ? -rounded : rounded);
101
+ }
102
+
45
103
  /**
46
104
  * Round to `digits` decimal places, used when converting a decimal string whose
47
105
  * precision exceeds the currency's minor unit.
package/src/scale.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What decimal place a money value's `minor` counts, and which scales are legal.
3
+ * A value naming none counts the currency's own minor unit — the shape every amount already had,
4
+ * which is why nothing that predates this file has to change to keep meaning what it meant.
5
+ */
6
+
7
+ import { isMoneyScale, MAX_MONEY_SCALE } from '@ultimat3/schema';
8
+ import { exponentOf } from './currency';
9
+ import { scaleInvalid, scaleNotWidening, scaleOverflow } from './errors';
10
+ import type { Money } from './money';
11
+
12
+ export { MAX_MONEY_SCALE };
13
+
14
+ /**
15
+ * The decimal exponent this value's `minor` counts in — its own, or the currency's.
16
+ *
17
+ * Not to be confused with `scaleOf(currency)`, which is the multiplier `10 ** exponentOf(currency)`.
18
+ * This one is a count of digits, like `exponentOf`.
19
+ */
20
+ export function moneyScale(amount: Money): number {
21
+ return amount.scale ?? exponentOf(amount.currency);
22
+ }
23
+
24
+ /** A scale that names no decimal place is a data bug, not a formatting preference. */
25
+ export function assertScale(scale: number): number {
26
+ if (!isMoneyScale(scale)) throw scaleInvalid(scale);
27
+ return scale;
28
+ }
29
+
30
+ /**
31
+ * `amount.minor` restated at `scale`, exactly, as a bigint.
32
+ *
33
+ * A bigint because widening is what a comparison does first, and a comparison must not throw:
34
+ * `MAX_SAFE_INTEGER` cents restated in micros is past 2^53, which `money()` rightly refuses to
35
+ * *store* and which says nothing about whether it is larger than the value beside it.
36
+ *
37
+ * Widening only. Narrowing drops digits, and which digits go is a decision with a mode attached —
38
+ * `rescale()` owns that, out loud.
39
+ */
40
+ export function minorAt(amount: Money, scale: number): bigint {
41
+ assertScale(scale);
42
+ const from = moneyScale(amount);
43
+ if (scale < from) throw scaleNotWidening(from, scale);
44
+ return BigInt(amount.minor) * 10n ** BigInt(scale - from);
45
+ }
46
+
47
+ /** The finer of two scales: where two values have to meet before they can be one number. */
48
+ export function commonScale(left: Money, right: Money): number {
49
+ return Math.max(moneyScale(left), moneyScale(right));
50
+ }
51
+
52
+ /**
53
+ * A widened bigint back to a storable `minor`, or a scale error naming the finest scale that
54
+ * would fit. The one place that conversion happens, because `Number(widened)` alone reached
55
+ * `money()` as a plain out-of-range amount — reported as a fractional minor nobody wrote, with a
56
+ * `fromDecimal` fix line that throws the same error again.
57
+ */
58
+ export function toMinor(widened: bigint, scale: number, currency: string): number {
59
+ const minor = Number(widened);
60
+ if (Number.isSafeInteger(minor)) return minor;
61
+ throw scaleOverflow(scale, currency, finestFitting(widened, scale));
62
+ }
63
+
64
+ /** How coarse the scale has to get before the magnitude fits; `undefined` if it never does. */
65
+ function finestFitting(widened: bigint, scale: number): number | undefined {
66
+ const limit = BigInt(Number.MAX_SAFE_INTEGER);
67
+ let magnitude = widened < 0n ? -widened : widened;
68
+ for (let fitted = scale; fitted >= 0; fitted -= 1) {
69
+ if (magnitude <= limit) return fitted;
70
+ magnitude /= 10n;
71
+ }
72
+ return undefined;
73
+ }