From 19d0e653a3a1636c523bc9a73b61f226c1a0c370 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 10 Jun 2026 20:09:25 -0500 Subject: [PATCH] feat(utils): add money.js with integer-cents arithmetic helpers (batch 0.38.2) --- utils/money.js | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 utils/money.js diff --git a/utils/money.js b/utils/money.js new file mode 100644 index 0000000..3b64100 --- /dev/null +++ b/utils/money.js @@ -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 };