subunit-money 2.0.1 → 2.1.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 +6 -4
- package/dist/money-converter.js +39 -6
- package/dist/money.d.ts +9 -1
- package/dist/money.js +38 -18
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -13,9 +13,9 @@ const price = new Money('USD', '19.99')
|
|
|
13
13
|
const tax = price.multiply(0.0825)
|
|
14
14
|
const total = price.add(tax)
|
|
15
15
|
|
|
16
|
-
console.log(total.toString()) // "21.
|
|
17
|
-
console.log(total.amount) // "21.
|
|
18
|
-
console.log(total.toNumber()) // 21.
|
|
16
|
+
console.log(total.toString()) // "21.64 USD"
|
|
17
|
+
console.log(total.amount) // "21.64" (string, safe for JSON/DB)
|
|
18
|
+
console.log(total.toNumber()) // 21.64 (number, for calculations)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Why Model Money as a Value Object?
|
|
@@ -28,6 +28,8 @@ Naive JavaScript math fails for monetary values in subtle but critical ways:
|
|
|
28
28
|
|
|
29
29
|
**The Split Penny Problem**: Imagine charging tax ($1.649175) on 10 items. If you round per-item (legally required on receipts), that's $1.65 × 10 = $16.50. But if you defer rounding, 10 × $1.649175 = $16.49. That missing penny is a real problem. Money objects round immediately after multiplication to prevent this.
|
|
30
30
|
|
|
31
|
+
**Banker's Rounding**: When a value is exactly halfway (like $0.545), should it round up or down? Simple "round half up" always rounds up, creating systematic bias—over millions of transactions, you're consistently overcharging. This library uses "round half to even" (banker's rounding): $0.545 rounds to $0.54 (4 is even), but $0.555 rounds to $0.56 (5 is odd, round to even 6). This eliminates bias and is the IEEE 754-2008 standard for financial calculations.
|
|
32
|
+
|
|
31
33
|
This library uses BigInt internally to store currency in subunits (cents, satoshis, etc.), making all arithmetic exact.
|
|
32
34
|
|
|
33
35
|
## Features
|
|
@@ -205,7 +207,7 @@ try {
|
|
|
205
207
|
- **Minimal surface area**: A value object should be just a value object
|
|
206
208
|
- **Fail fast**: Errors thrown immediately, not silently propagated
|
|
207
209
|
- **No localization**: Formatting for display is your app's concern
|
|
208
|
-
- **
|
|
210
|
+
- **Banker's rounding**: Uses IEEE 754-2008 round-half-to-even to eliminate systematic bias
|
|
209
211
|
|
|
210
212
|
## Why String Amounts?
|
|
211
213
|
|
package/dist/money-converter.js
CHANGED
|
@@ -18,7 +18,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
18
18
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
19
19
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
20
20
|
};
|
|
21
|
-
var _MoneyConverter_rateService;
|
|
21
|
+
var _MoneyConverter_instances, _MoneyConverter_rateService, _MoneyConverter_bankersRound;
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
23
|
exports.MoneyConverter = void 0;
|
|
24
24
|
const money_js_1 = require("./money.js");
|
|
@@ -37,6 +37,7 @@ const currency_js_1 = require("./currency.js");
|
|
|
37
37
|
*/
|
|
38
38
|
class MoneyConverter {
|
|
39
39
|
constructor(rateService) {
|
|
40
|
+
_MoneyConverter_instances.add(this);
|
|
40
41
|
_MoneyConverter_rateService.set(this, void 0);
|
|
41
42
|
__classPrivateFieldSet(this, _MoneyConverter_rateService, rateService, "f");
|
|
42
43
|
}
|
|
@@ -52,14 +53,26 @@ class MoneyConverter {
|
|
|
52
53
|
if (money.currency === targetCurrency) {
|
|
53
54
|
return money;
|
|
54
55
|
}
|
|
56
|
+
const currencyDef = (0, currency_js_1.getCurrency)(targetCurrency);
|
|
57
|
+
if (!currencyDef) {
|
|
58
|
+
throw new errors_js_1.CurrencyUnknownError(targetCurrency);
|
|
59
|
+
}
|
|
55
60
|
const rate = __classPrivateFieldGet(this, _MoneyConverter_rateService, "f").getRate(money.currency, targetCurrency);
|
|
56
61
|
if (!rate) {
|
|
57
62
|
throw new errors_js_1.ExchangeRateError(money.currency, targetCurrency);
|
|
58
63
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
64
|
+
const sourceCurrencyDef = (0, currency_js_1.getCurrency)(money.currency);
|
|
65
|
+
const sourceSubunits = money.toSubunits();
|
|
66
|
+
const sourceMultiplier = 10n ** BigInt(sourceCurrencyDef.decimalDigits);
|
|
67
|
+
const targetMultiplier = 10n ** BigInt(currencyDef.decimalDigits);
|
|
68
|
+
const RATE_PRECISION = 15n;
|
|
69
|
+
const rateMultiplier = 10n ** RATE_PRECISION;
|
|
70
|
+
const rateValue = Number(rate.rate);
|
|
71
|
+
const rateBigInt = BigInt(Math.round(rateValue * Number(rateMultiplier)));
|
|
72
|
+
const product = sourceSubunits * rateBigInt * targetMultiplier;
|
|
73
|
+
const divisor = rateMultiplier * sourceMultiplier;
|
|
74
|
+
const targetSubunits = __classPrivateFieldGet(this, _MoneyConverter_instances, "m", _MoneyConverter_bankersRound).call(this, product, divisor);
|
|
75
|
+
return money_js_1.Money.fromSubunits(targetSubunits, targetCurrency);
|
|
63
76
|
}
|
|
64
77
|
/**
|
|
65
78
|
* Add two Money amounts, converting as needed.
|
|
@@ -136,4 +149,24 @@ class MoneyConverter {
|
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
exports.MoneyConverter = MoneyConverter;
|
|
139
|
-
_MoneyConverter_rateService = new WeakMap()
|
|
152
|
+
_MoneyConverter_rateService = new WeakMap(), _MoneyConverter_instances = new WeakSet(), _MoneyConverter_bankersRound = function _MoneyConverter_bankersRound(numerator, denominator) {
|
|
153
|
+
if (denominator === 1n)
|
|
154
|
+
return numerator;
|
|
155
|
+
const quotient = numerator / denominator;
|
|
156
|
+
const remainder = numerator % denominator;
|
|
157
|
+
if (remainder === 0n)
|
|
158
|
+
return quotient;
|
|
159
|
+
const halfDenominator = denominator / 2n;
|
|
160
|
+
const absRemainder = remainder < 0n ? -remainder : remainder;
|
|
161
|
+
if (absRemainder > halfDenominator) {
|
|
162
|
+
return numerator < 0n ? quotient - 1n : quotient + 1n;
|
|
163
|
+
}
|
|
164
|
+
if (absRemainder === halfDenominator) {
|
|
165
|
+
const isQuotientEven = quotient % 2n === 0n;
|
|
166
|
+
if (isQuotientEven) {
|
|
167
|
+
return quotient;
|
|
168
|
+
}
|
|
169
|
+
return numerator < 0n ? quotient - 1n : quotient + 1n;
|
|
170
|
+
}
|
|
171
|
+
return quotient;
|
|
172
|
+
};
|
package/dist/money.d.ts
CHANGED
|
@@ -57,7 +57,15 @@ export declare class Money<C extends string = string> {
|
|
|
57
57
|
subtract(other: Money<C>): Money<C>;
|
|
58
58
|
/**
|
|
59
59
|
* Multiply by a factor.
|
|
60
|
-
*
|
|
60
|
+
*
|
|
61
|
+
* DESIGN: Rounds immediately after multiplication using banker's rounding
|
|
62
|
+
* (round half-to-even). This prevents the "split penny problem" where
|
|
63
|
+
* line-item rounding differs from deferred rounding:
|
|
64
|
+
* Per-item: $1.65 tax × 10 items = $16.50 ✓ (matches receipt)
|
|
65
|
+
* Deferred: 10 × $1.649175 = $16.49 ✗ (missing penny)
|
|
66
|
+
*
|
|
67
|
+
* For chained calculations without intermediate rounding, perform arithmetic
|
|
68
|
+
* in Number space first, then create a Money object with the final result.
|
|
61
69
|
*/
|
|
62
70
|
multiply(factor: number): Money<C>;
|
|
63
71
|
/**
|
package/dist/money.js
CHANGED
|
@@ -19,7 +19,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
19
19
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
20
20
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
21
21
|
};
|
|
22
|
-
var _Money_instances, _a, _Money_value, _Money_currencyDef, _Money_parseAmount,
|
|
22
|
+
var _Money_instances, _a, _Money_value, _Money_currencyDef, _Money_parseAmount, _Money_assertSameCurrency, _Money_getInternalValue, _Money_roundedDivide, _Money_createFromInternal, _Money_formatInternalValue;
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.Money = void 0;
|
|
25
25
|
const errors_js_1 = require("./errors.js");
|
|
@@ -103,16 +103,24 @@ class Money {
|
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Multiply by a factor.
|
|
106
|
-
*
|
|
106
|
+
*
|
|
107
|
+
* DESIGN: Rounds immediately after multiplication using banker's rounding
|
|
108
|
+
* (round half-to-even). This prevents the "split penny problem" where
|
|
109
|
+
* line-item rounding differs from deferred rounding:
|
|
110
|
+
* Per-item: $1.65 tax × 10 items = $16.50 ✓ (matches receipt)
|
|
111
|
+
* Deferred: 10 × $1.649175 = $16.49 ✗ (missing penny)
|
|
112
|
+
*
|
|
113
|
+
* For chained calculations without intermediate rounding, perform arithmetic
|
|
114
|
+
* in Number space first, then create a Money object with the final result.
|
|
107
115
|
*/
|
|
108
116
|
multiply(factor) {
|
|
109
117
|
if (typeof factor !== 'number' || !Number.isFinite(factor)) {
|
|
110
118
|
throw new TypeError(`Factor must be a finite number, got: ${factor}`);
|
|
111
119
|
}
|
|
112
|
-
// Convert factor to BigInt-compatible form
|
|
113
120
|
const factorStr = factor.toFixed(INTERNAL_PRECISION);
|
|
114
121
|
const factorBigInt = BigInt(factorStr.replace('.', ''));
|
|
115
|
-
const
|
|
122
|
+
const product = __classPrivateFieldGet(this, _Money_value, "f") * factorBigInt;
|
|
123
|
+
const result = __classPrivateFieldGet(_a, _a, "m", _Money_roundedDivide).call(_a, product, PRECISION_MULTIPLIER);
|
|
116
124
|
return __classPrivateFieldGet(_a, _a, "m", _Money_createFromInternal).call(_a, result, this.currency, __classPrivateFieldGet(this, _Money_currencyDef, "f"));
|
|
117
125
|
}
|
|
118
126
|
/**
|
|
@@ -130,6 +138,11 @@ class Money {
|
|
|
130
138
|
if (!Array.isArray(proportions) || proportions.length === 0) {
|
|
131
139
|
throw new TypeError('Proportions must be a non-empty array');
|
|
132
140
|
}
|
|
141
|
+
for (const p of proportions) {
|
|
142
|
+
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0) {
|
|
143
|
+
throw new TypeError('All proportions must be non-negative finite numbers');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
const total = proportions.reduce((sum, p) => sum + p, 0);
|
|
134
147
|
if (total <= 0) {
|
|
135
148
|
throw new TypeError('Sum of proportions must be positive');
|
|
@@ -314,25 +327,32 @@ _a = Money, _Money_value = new WeakMap(), _Money_currencyDef = new WeakMap(), _M
|
|
|
314
327
|
const paddedFrac = frac.padEnd(INTERNAL_PRECISION, '0');
|
|
315
328
|
const combined = BigInt(whole + paddedFrac);
|
|
316
329
|
return sign === '-' ? -combined : combined;
|
|
317
|
-
}, _Money_fromInternal = function _Money_fromInternal(value, currency) {
|
|
318
|
-
const currencyDef = (0, currency_js_1.getCurrency)(currency);
|
|
319
|
-
if (!currencyDef) {
|
|
320
|
-
throw new errors_js_1.CurrencyUnknownError(currency);
|
|
321
|
-
}
|
|
322
|
-
// Create instance without parsing
|
|
323
|
-
const instance = Object.create(_a.prototype);
|
|
324
|
-
Object.defineProperty(instance, 'currency', { value: currency, enumerable: true });
|
|
325
|
-
Object.defineProperty(instance, '#value', { value });
|
|
326
|
-
Object.defineProperty(instance, '#currencyDef', { value: currencyDef });
|
|
327
|
-
instance['#value'] = value;
|
|
328
|
-
instance['#currencyDef'] = currencyDef;
|
|
329
|
-
return instance;
|
|
330
330
|
}, _Money_assertSameCurrency = function _Money_assertSameCurrency(other) {
|
|
331
331
|
if (this.currency !== other.currency) {
|
|
332
332
|
throw new errors_js_1.CurrencyMismatchError(this.currency, other.currency);
|
|
333
333
|
}
|
|
334
334
|
}, _Money_getInternalValue = function _Money_getInternalValue() {
|
|
335
335
|
return __classPrivateFieldGet(this, _Money_value, "f");
|
|
336
|
+
}, _Money_roundedDivide = function _Money_roundedDivide(numerator, denominator) {
|
|
337
|
+
if (denominator === 1n)
|
|
338
|
+
return numerator;
|
|
339
|
+
const quotient = numerator / denominator;
|
|
340
|
+
const remainder = numerator % denominator;
|
|
341
|
+
if (remainder === 0n)
|
|
342
|
+
return quotient;
|
|
343
|
+
const halfDenominator = denominator / 2n;
|
|
344
|
+
const absRemainder = remainder < 0n ? -remainder : remainder;
|
|
345
|
+
if (absRemainder > halfDenominator) {
|
|
346
|
+
return numerator < 0n ? quotient - 1n : quotient + 1n;
|
|
347
|
+
}
|
|
348
|
+
if (absRemainder === halfDenominator) {
|
|
349
|
+
const isQuotientEven = quotient % 2n === 0n;
|
|
350
|
+
if (isQuotientEven) {
|
|
351
|
+
return quotient;
|
|
352
|
+
}
|
|
353
|
+
return numerator < 0n ? quotient - 1n : quotient + 1n;
|
|
354
|
+
}
|
|
355
|
+
return quotient;
|
|
336
356
|
}, _Money_createFromInternal = function _Money_createFromInternal(value, currency, currencyDef) {
|
|
337
357
|
const instance = Object.create(_a.prototype);
|
|
338
358
|
// Use Object.defineProperties for proper initialization
|
|
@@ -346,7 +366,7 @@ _a = Money, _Money_value = new WeakMap(), _Money_currencyDef = new WeakMap(), _M
|
|
|
346
366
|
}, _Money_formatInternalValue = function _Money_formatInternalValue(value, currencyDef) {
|
|
347
367
|
const decimals = currencyDef.decimalDigits;
|
|
348
368
|
const divisor = 10n ** BigInt(INTERNAL_PRECISION - decimals);
|
|
349
|
-
const adjusted = value
|
|
369
|
+
const adjusted = __classPrivateFieldGet(_a, _a, "m", _Money_roundedDivide).call(_a, value, divisor);
|
|
350
370
|
const isNegative = adjusted < 0n;
|
|
351
371
|
const abs = isNegative ? -adjusted : adjusted;
|
|
352
372
|
if (decimals === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "subunit-money",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "A type-safe value object for monetary amounts with currency conversion support",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,18 +14,20 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
-
"currencymap.json"
|
|
17
|
+
"currencymap.json",
|
|
18
|
+
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsc",
|
|
21
22
|
"test": "node --import tsx --test test/*.test.ts",
|
|
22
23
|
"lint": "tsc --noEmit",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"preversion": "npm run test && npm run lint",
|
|
26
|
-
"version": "npm run build && git add -A dist",
|
|
24
|
+
"check-clean": "git diff --quiet && git diff --cached --quiet",
|
|
25
|
+
"preversion": "npm run check-clean && npm test",
|
|
27
26
|
"postversion": "git push && git push --tags",
|
|
28
|
-
"prepublishOnly": "npm run
|
|
27
|
+
"prepublishOnly": "npm run check-clean && npm run build",
|
|
28
|
+
"release:patch": "npm version patch && npm publish",
|
|
29
|
+
"release:minor": "npm version minor && npm publish",
|
|
30
|
+
"release:major": "npm version major && npm publish"
|
|
29
31
|
},
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|