Files
wownerogue/src/money/atomic.js
T

101 lines
3.6 KiB
JavaScript

/**
* Money — atomic-unit arithmetic on BigInt.
*
* The single source of truth for money math. All monetary values in this app are integer
* atomic units (1 WOW = 10^11, 1 XMR = 10^12). JavaScript `number` is float64 and loses
* precision above 2^53, so sums/products of large atomic amounts must NOT be done with `+`
* or `*` on numbers. Everything here operates on BigInt and only narrows back to `number`
* when the value is provably exact (<= MAX_SAFE_INTEGER).
*/
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
/**
* Coerce a string | number | bigint atomic amount to BigInt.
* Rejects non-integer numbers and non-numeric strings (money is never fractional atomic).
* @param {string|number|bigint} v
* @returns {bigint}
*/
function toBig(v) {
if (typeof v === 'bigint') return v;
if (typeof v === 'number') {
if (!Number.isFinite(v) || !Number.isSafeInteger(v)) {
throw new Error(`Invalid atomic amount (not an exact safe integer number): ${v}`);
}
return BigInt(v);
}
if (typeof v === 'string') {
const s = v.trim();
if (!/^-?\d+$/.test(s)) {
throw new Error(`Invalid atomic amount (not an integer string): ${v}`);
}
return BigInt(s);
}
throw new Error(`Invalid atomic amount type: ${typeof v}`);
}
/**
* Narrow a BigInt atomic amount to a JS number when it is exactly representable,
* otherwise return its decimal string. This keeps small amounts ergonomic (and
* backward compatible with existing number-typed call sites and tests) while never
* silently losing precision on large amounts.
* @param {bigint} big
* @returns {number|string}
*/
function toSafe(big) {
const b = toBig(big);
return (b <= MAX_SAFE && b >= -MAX_SAFE) ? Number(b) : b.toString();
}
/** Sum a list of atomic amounts exactly. @returns {bigint} */
function sum(values) {
return (values || []).reduce((acc, v) => acc + toBig(v), 0n);
}
/** Add two atomic amounts exactly. @returns {bigint} */
function add(a, b) {
return toBig(a) + toBig(b);
}
/**
* Multiply an atomic amount by a non-negative decimal multiplier (e.g. 2, 3, 1.5, 2.5)
* using exact integer math with half-up rounding — no float multiplication.
* @param {string|number|bigint} atomic
* @param {number|string} multiplier
* @returns {bigint}
*/
function mulByDecimal(atomic, multiplier) {
const a = toBig(atomic);
const s = String(multiplier);
if (!/^\d+(\.\d+)?$/.test(s)) {
throw new Error(`Invalid multiplier (expected non-negative decimal): ${multiplier}`);
}
const [intPart, fracPart = ''] = s.split('.');
const places = fracPart.length;
const scale = 10n ** BigInt(places);
const numerator = BigInt(intPart + fracPart); // "2.5" -> 25n, scale 10n
// Half-up rounding: add half the denominator before truncating division.
return (a * numerator + scale / 2n) / scale;
}
/**
* Format an atomic amount as a human-readable decimal string for the given currency
* decimals (e.g. format(150000000000n, 11) -> "1.5"). Trims trailing fractional zeros.
* @param {string|number|bigint} atomic
* @param {number} decimals
* @returns {string}
*/
function format(atomic, decimals) {
const a = toBig(atomic);
const neg = a < 0n;
const abs = neg ? -a : a;
const d = BigInt(decimals);
const divisor = 10n ** d;
const whole = abs / divisor;
const frac = abs % divisor;
let fracStr = frac.toString().padStart(Number(decimals), '0').replace(/0+$/, '');
return (neg ? '-' : '') + whole.toString() + (fracStr ? '.' + fracStr : '');
}
module.exports = { toBig, toSafe, sum, add, mulByDecimal, format, MAX_SAFE };