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 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.63 USD"
17
- console.log(total.amount) // "21.63" (string, safe for JSON/DB)
18
- console.log(total.toNumber()) // 21.63 (number, for calculations)
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
- - **No rounding rules**: Currency-specific rounding is application-specific
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
 
@@ -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 currencyDef = (0, currency_js_1.getCurrency)(targetCurrency);
60
- const convertedAmount = Number(money.amount) * Number(rate.rate);
61
- const rounded = convertedAmount.toFixed(currencyDef.decimalDigits);
62
- return new money_js_1.Money(targetCurrency, rounded);
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
- * Result is rounded to the currency's decimal places using banker's rounding.
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, _Money_fromInternal, _Money_assertSameCurrency, _Money_getInternalValue, _Money_createFromInternal, _Money_formatInternalValue;
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
- * Result is rounded to the currency's decimal places using banker's rounding.
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 result = (__classPrivateFieldGet(this, _Money_value, "f") * factorBigInt) / PRECISION_MULTIPLIER;
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 / divisor;
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.1",
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
- "tag": "git tag v$(jq -r '.version' package.json)",
24
- "prepare-release": "npm run build && git add dist && git commit -m \"Build $(jq -r '.version' package.json)\" && npm run tag",
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 test && npm run build"
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",