feat(utils): add money.js with integer-cents arithmetic helpers (batch 0.38.2)
This commit is contained in:
parent
947fa3bdf8
commit
19d0e653a3
|
|
@ -0,0 +1,88 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money utilities — integer-cents core.
|
||||||
|
*
|
||||||
|
* All arithmetic runs in integer cents so sums and rounding are exact;
|
||||||
|
* floats only appear at the edges (DB columns that still store dollar
|
||||||
|
* REALs, API payloads, display formatting).
|
||||||
|
*
|
||||||
|
* Storage units today:
|
||||||
|
* - bills / payments / monthly_bill_state / budgets / income: dollars (REAL)
|
||||||
|
* - SimpleFIN transactions / financial_accounts: cents (INTEGER)
|
||||||
|
*
|
||||||
|
* The planned v1.03 migration (see docs/cents-migration-plan.md) converts the
|
||||||
|
* dollar columns to integer cents. Until then, services work in dollars at
|
||||||
|
* their boundaries and these helpers guarantee cent-exact math internally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dollars (number or string like "$1,234.56") → integer cents.
|
||||||
|
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
||||||
|
*/
|
||||||
|
function toCents(dollars) {
|
||||||
|
if (dollars === null || dollars === undefined || dollars === '') return null;
|
||||||
|
const n = typeof dollars === 'string'
|
||||||
|
? Number(dollars.replace(/[$,\s]/g, ''))
|
||||||
|
: Number(dollars);
|
||||||
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return Math.round(n * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
||||||
|
function fromCents(cents) {
|
||||||
|
if (cents === null || cents === undefined) return null;
|
||||||
|
const n = Number(cents);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return n / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round a dollar amount to the nearest cent, exactly.
|
||||||
|
* Drop-in replacement for the old `Math.round(x * 100) / 100` helpers.
|
||||||
|
*/
|
||||||
|
function roundMoney(value) {
|
||||||
|
const cents = toCents(value);
|
||||||
|
return cents === null || Number.isNaN(cents) ? 0 : cents / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cent-exact sum of dollar amounts. Replaces float `reduce((s, x) => s + x)`
|
||||||
|
* chains, which accumulate binary representation error.
|
||||||
|
*
|
||||||
|
* sumMoney([0.1, 0.2]) → 0.3 (not 0.30000000000000004)
|
||||||
|
* sumMoney(rows, r => r.amount) → picks a field
|
||||||
|
*/
|
||||||
|
function sumMoney(values, pick) {
|
||||||
|
let total = 0;
|
||||||
|
for (const v of values) {
|
||||||
|
const raw = pick ? pick(v) : v;
|
||||||
|
const cents = toCents(raw);
|
||||||
|
if (cents !== null && !Number.isNaN(cents)) total += cents;
|
||||||
|
}
|
||||||
|
return total / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiply a dollar amount by a factor (interest rate, proration, etc.),
|
||||||
|
* rounding the result to the nearest cent.
|
||||||
|
*/
|
||||||
|
function mulMoney(dollars, factor) {
|
||||||
|
const cents = toCents(dollars);
|
||||||
|
if (cents === null || Number.isNaN(cents) || !Number.isFinite(factor)) return 0;
|
||||||
|
return Math.round(cents * factor) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
/** Dollar number → "$1,234.56". null/undefined → "$0.00". */
|
||||||
|
function formatUSD(dollars) {
|
||||||
|
return '$' + _usd.format(Number(dollars) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer cents → "$1,234.56". */
|
||||||
|
function formatCentsUSD(cents) {
|
||||||
|
return formatUSD(fromCents(cents) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD };
|
||||||
Loading…
Reference in New Issue