From bf66ab1ee6a74cf6753d1a4da249cf9ba2b3f679 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 10 Jun 2026 20:14:13 -0500 Subject: [PATCH] feat(money): migrate services to cent-exact money.js helpers (batch 0.38.3) --- FUTURE.md | 23 +++++ docs/cents-migration-plan.md | 124 +++++++++++++++++++++++++++ routes/bills.js | 9 +- routes/calendar.js | 13 ++- routes/transactions.js | 3 +- services/amountSuggestionService.js | 3 +- services/analyticsService.js | 3 +- services/aprService.js | 6 +- services/billsService.js | 7 +- services/driftService.js | 3 +- services/paymentAccountingService.js | 3 +- services/snowballService.js | 6 +- services/statusService.js | 8 +- services/subscriptionService.js | 23 ++--- services/trackerService.js | 25 +++--- services/transactionMatchService.js | 3 +- 16 files changed, 214 insertions(+), 48 deletions(-) create mode 100644 docs/cents-migration-plan.md diff --git a/FUTURE.md b/FUTURE.md index 5e2eed6..01d5f21 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -31,6 +31,29 @@ Items are grouped under their priority section heading (`## ๐Ÿ”ด CRITICAL`, `## ## Pending Recommendations +## ๐ŸŸ  HIGH + +### ๐ŸŸ  Cents Migration Stage 2 โ€” Schema Flip to Integer Cents โ€” HIGH +**Priority:** HIGH +**Added:** 2026-06-10 by Claude (cents migration stage 1) + +**Description:** +Stage 1 is shipped: `utils/money.js` exists and all server-side money summation/ +rounding is cent-exact. Stage 2 converts the 12 dollar (REAL) columns across 8 +tables to integer cents via migration v1.03 and updates all ~288 query sites. + +**Scope:** +- Apply v1.03 migration + rollback + schema.sql changes per `docs/cents-migration-plan.md` +- Convert reads/writes file-by-file in the documented order, on a branch +- Handle the four hazards: userDbImportService unit detection, CSV/spreadsheet + import inserts, test fixtures (ร—100), CSV export formatting + +**Rationale:** +- Eliminates float dollars at rest before the data grows further +- Unifies units with SimpleFIN transactions/accounts (already cents) +- The full plan, migration SQL, column inventory, and verification checklist are + in `docs/cents-migration-plan.md` โ€” this item is execution only + ## ๐ŸŸก MEDIUM ### ๐ŸŸก Projected Cash Flow โ€” MEDIUM diff --git a/docs/cents-migration-plan.md b/docs/cents-migration-plan.md new file mode 100644 index 0000000..2037ba9 --- /dev/null +++ b/docs/cents-migration-plan.md @@ -0,0 +1,124 @@ +# Cents Migration Plan (v1.03) โ€” dollars (REAL) โ†’ integer cents + +**Status:** Stage 1 shipped (2026-06-10). Stage 2 (schema flip) NOT yet applied. +**Stage 1 (done):** `utils/money.js` cents-core module; every float summation/rounding +site in services/routes now uses `roundMoney` / `sumMoney` / `mulMoney` (cent-exact). +**Stage 2 (this document):** flip storage and compute to integer cents. + +## Why staged + +The schema flip and the code conversion must land **atomically** โ€” migrations run +automatically at boot, so flipping storage with any of the ~288 query sites still +assuming dollars corrupts data 100ร— on write or displays garbage on read. Stage 2 +should be done on a branch, file by file, with the checklist below. + +## Target architecture + +- **DB:** integer cents in all money columns (matches SimpleFIN `transactions` / + `financial_accounts`, which are already cents). +- **Services:** compute in cents end-to-end. +- **API:** keeps returning dollars (convert at route serialization, parse at input). + This keeps the web client (142 money sites) and the mobile app untouched. + +## Column inventory (12 columns, 8 tables) + +| Table | Columns (currently dollars REAL) | +|---|---| +| bills | expected_amount, current_balance, minimum_payment | +| payments | amount, balance_delta | +| monthly_bill_state | actual_amount | +| monthly_starting_amounts | first_amount, fifteenth_amount, other_amount | +| monthly_income | amount | +| spending_budgets | amount | +| snowball_plans | extra_payment | +| users | snowball_extra_payment | + +NOT migrated: `bills.interest_rate` (a percentage), `transactions.amount`, +`financial_accounts.balance/available_balance` (already cents). + +## v1.03 migration (add to the `migrations` array in db/database.js) + +```js +{ + version: 'v1.03', + description: 'money columns: dollars (REAL) โ†’ integer cents', + run() { + const conv = [ + ['bills', ['expected_amount', 'current_balance', 'minimum_payment']], + ['payments', ['amount', 'balance_delta']], + ['monthly_bill_state', ['actual_amount']], + ['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']], + ['monthly_income', ['amount']], + ['spending_budgets', ['amount']], + ['snowball_plans', ['extra_payment']], + ['users', ['snowball_extra_payment']], + ]; + for (const [table, cols] of conv) { + for (const col of cols) { + db.exec(`UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`); + } + } + console.log('[v1.03] money columns converted to integer cents'); + } +} +``` + +Notes: +- SQLite affinity: existing columns stay declared REAL but hold integer values โ€” + exact in both SQLite and JS (integers < 2^53). No table rebuild needed. +- `db/schema.sql` must change the same columns to `INTEGER` + `-- cents` comments + so fresh installs are born in cents (v1.03 then no-ops on zero rows). +- Registration: only the `runMigrations()` array matters. (The + reconcileLegacyMigrations sync assertion never fires โ€” it runs before + `_runMigrationVersions` is populated; initSchema calls handleLegacyDatabase + before runMigrations. Worth fixing while in there.) +- `ROLLBACK_SQL_MAP` entry: same loop with `ROUND(${col} / 100.0, 2)`. + +## Code conversion rules (stage 2) + +1. **Reads:** anything selecting the 12 columns now yields cents. Services treat + them as cents; `roundMoney(x)` calls on these values become `Math.round`, and + most add/subtract/compare logic is unit-consistent and needs no change. +2. **Writes:** route input parsing converts dollars โ†’ `toCents()` once, at + validation (`validateBillData`, `validatePaymentInput`, monthly-state, + starting-amounts, budgets, snowball routes). +3. **API output:** every `res.json` carrying money fields converts with + `fromCents()` โ€” add per-entity serializers (`serializeBill`, `serializePayment`, + ...) in services and use them in routes instead of spreading raw rows. +4. **Unification wins:** existing `cents โ†’ dollars` bridges disappear, e.g. + `Math.round(Math.abs(tx.amount)) / 100` in routes/matches.js and + `dollarsFromTransactionAmount()` in subscriptionService โ€” payments and + transactions will finally share units. +5. **Interest math:** `mulMoney` equivalents work directly in cents: + `Math.round(balanceCents * rate / 100 / 12)`. + +## Query-site inventory (grep `(FROM|INTO|UPDATE) `, server-side) + +bills: 127 ยท payments: 108 ยท monthly_bill_state: 18 ยท snowball_plans: 20 ยท +monthly_starting_amounts: 7 ยท spending_budgets: 6 ยท monthly_income: 2 + +Suggested file order (leaf โ†’ hub): paymentValidation โ†’ billsService โ†’ +paymentAccountingService โ†’ statusService โ†’ trackerService โ†’ routes/payments โ†’ +routes/bills โ†’ snowball/apr โ†’ analytics/spending/summary โ†’ subscription โ†’ +import/export โ†’ notification/calendarFeed. + +## Hazards (each needs explicit handling) + +- **services/userDbImportService.js** copies raw numeric values from uploaded DBs + (lines ~152-219). After v1.03 it MUST check the source DB's `schema_migrations` + for v1.03: present โ†’ copy as-is; absent โ†’ `toCents()` each money field. +- **Spreadsheet/CSV imports** (`spreadsheetImportService`, `csvTransactionImportService`) + parse user dollars โ€” convert at their INSERT statements. +- **Backups/restore:** safe automatically โ€” restore swaps the file, `closeDb()` โ†’ + `getDb()` re-runs migrations, so pre-v1.03 backups get converted at next init. +- **Tests:** ~67 money assertions/fixtures insert dollars via raw SQL โ€” multiply + fixtures and expectations by 100 where they touch the 12 columns. +- **export.js CSV:** `toFixed(2)` on dollars becomes `formatCentsUSD`/`fromCents`. + +## Verification before merging stage 2 + +1. `npm run check:server && npm test` (after fixture updates). +2. Invariant script: snapshot `SUM(col)` per money column pre-migration; assert + post-migration `SUM(col) / 100` matches to the cent. +3. Manual pass: tracker totals, snowball projection, calendar summary, CSV export + against a copy of the production DB (`backups/` has real snapshots to test with). diff --git a/routes/bills.js b/routes/bills.js index bc5b0f8..7128072 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -22,6 +22,7 @@ const { applyBankPaymentAsSourceOfTruth, } = require('../services/paymentAccountingService'); const { localDateString, todayLocal } = require('../utils/dates'); +const { roundMoney, sumMoney } = require('../utils/money'); // โ”€โ”€ GET /api/bills โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ router.get('/', (req, res) => { @@ -701,7 +702,7 @@ router.post('/:id/toggle-paid', (req, res) => { if (currentPayment.balance_delta != null) { const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId); if (freshBill?.current_balance != null) { - const restored = Math.max(0, Math.round((freshBill.current_balance - currentPayment.balance_delta) * 100) / 100); + const restored = Math.max(0, roundMoney(freshBill.current_balance - currentPayment.balance_delta)); db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId); } } @@ -922,8 +923,8 @@ router.get('/:id/amortization', (req, res) => { schedule, summary: { months: schedule.length, - total_interest: Math.round(total_interest * 100) / 100, - total_paid: Math.round((schedule.reduce((s, r) => s + r.payment, 0)) * 100) / 100, + total_interest: roundMoney(total_interest), + total_paid: sumMoney(schedule, r => r.payment), capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0, }, apr_snapshot, @@ -964,7 +965,7 @@ router.patch('/:id/balance', (req, res) => { if (!Number.isFinite(val) || val < 0) { return res.status(400).json(standardizeError('current_balance must be a non-negative number', 'VALIDATION_ERROR', 'current_balance')); } - val = Math.round(val * 100) / 100; + val = roundMoney(val); } db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId); diff --git a/routes/calendar.js b/routes/calendar.js index 25636fb..32270d7 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -14,6 +14,7 @@ const { revokeToken, } = require('../services/calendarFeedService'); const { localDateString } = require('../utils/dates'); +const { roundMoney, sumMoney } = require('../utils/money'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); @@ -225,11 +226,17 @@ router.get('/', (req, res) => { } const activeBills = calendarBills.filter(bill => !bill.is_skipped); - const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0); - const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0); - const remainingTotal = Math.max(0, expectedTotal - paidTotal); + const expectedTotal = sumMoney(activeBills, bill => bill.effective_amount || 0); + const paidTotal = sumMoney(activeBills, bill => bill.paid_amount || 0); + const remainingTotal = Math.max(0, roundMoney(expectedTotal - paidTotal)); const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0; + // Cent-exact: the per-day loops above accumulate floats; settle them here. + for (const day of days) { + day.status_summary.total_paid = roundMoney(day.status_summary.total_paid); + day.status_summary.total_due = roundMoney(day.status_summary.total_due); + } + res.json({ year, month, diff --git a/routes/transactions.js b/routes/transactions.js index 799d255..300d48f 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -15,6 +15,7 @@ const { } = require('../services/transactionMatchService'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { todayLocal } = require('../utils/dates'); +const { roundMoney } = require('../utils/money'); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); @@ -573,7 +574,7 @@ router.post('/unmatch-bulk', (req, res) => { if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance != null) { - const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); + const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta))); db.prepare(` UPDATE bills SET current_balance = ?, diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.js index 21499d0..4d2cac0 100644 --- a/services/amountSuggestionService.js +++ b/services/amountSuggestionService.js @@ -1,6 +1,7 @@ 'use strict'; const { accountingActiveSql } = require('./paymentAccountingService'); +const { roundMoney } = require('../utils/money'); /** * Computes a suggested expected amount for a bill based on the rolling median @@ -47,7 +48,7 @@ function computeAmountSuggestion(db, billId, year, month) { : sorted[mid]; return { - suggestion: Math.round(median * 100) / 100, + suggestion: roundMoney(median), months_used: amounts.length, confidence: amounts.length >= 3 ? 'high' : 'low', }; diff --git a/services/analyticsService.js b/services/analyticsService.js index 7bc42cb..ef5886c 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.js @@ -2,6 +2,7 @@ const { getDb } = require('../db/database'); const { accountingActiveSql } = require('./paymentAccountingService'); +const { sumMoney } = require('../utils/money'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; @@ -181,7 +182,7 @@ function getAnalyticsSummary(userId, query = {}) { const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row])); const monthly_spending = rangeMonths.map(m => { - const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0); + const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0); return { month: m.key, label: m.label, total: Number(total.toFixed(2)) }; }).filter(row => row.total > 0); diff --git a/services/aprService.js b/services/aprService.js index 29ae5ba..063d1b3 100644 --- a/services/aprService.js +++ b/services/aprService.js @@ -1,3 +1,5 @@ + +const { roundMoney, sumMoney } = require('../utils/money'); /** * APR / amortization mathematics. * All functions are pure โ€” no DB access, no side effects. @@ -192,7 +194,7 @@ function calculateMinimumOnly(debts, startDate = new Date()) { })); const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); - const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0); + const totalInterest = sumMoney(active, d => d.totalInterest); return { months_to_freedom: maxMonth || null, @@ -237,7 +239,7 @@ function debtAprSnapshot(bill) { // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function round2(n) { - return Math.round(n * 100) / 100; + return roundMoney(n); } module.exports = { diff --git a/services/billsService.js b/services/billsService.js index c7dfaa3..41c967d 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -1,4 +1,5 @@ const { monthKey } = require('../utils/dates'); +const { roundMoney, mulMoney } = require('../utils/money'); const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const TEMPLATE_FIELDS = [ @@ -481,11 +482,11 @@ function computeBalanceDelta(bill, paymentAmount) { const currentMonth = monthKey(); // "YYYY-MM" (local time) const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; - const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0; + const interestDelta = applyInterest ? mulMoney(bal, rate / 100 / 12) : 0; const raw = bal + interestDelta - amt; - const newBalance = Math.round(Math.max(0, raw) * 100) / 100; - const delta = Math.round((newBalance - bal) * 100) / 100; + const newBalance = roundMoney(Math.max(0, raw)); + const delta = roundMoney(newBalance - bal); return { new_balance: newBalance, diff --git a/services/driftService.js b/services/driftService.js index e404f56..77a92c0 100644 --- a/services/driftService.js +++ b/services/driftService.js @@ -5,6 +5,7 @@ const { getCycleRange } = require('./statusService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { getUserSettings } = require('./userSettings'); const { localDateString } = require('../utils/dates'); +const { roundMoney } = require('../utils/money'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; @@ -91,7 +92,7 @@ function getDriftReport(userId, now = new Date()) { name: bill.name, category_name: bill.category_name ?? null, expected_amount: bill.expected_amount, - recent_amount: Math.round(recentAmount * 100) / 100, + recent_amount: roundMoney(recentAmount), drift_pct: Math.round(driftPct * 10) / 10, direction: delta > 0 ? 'up' : 'down', months_sampled: monthTotals.length, diff --git a/services/paymentAccountingService.js b/services/paymentAccountingService.js index 84fdce9..d14bdfd 100644 --- a/services/paymentAccountingService.js +++ b/services/paymentAccountingService.js @@ -2,6 +2,7 @@ const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { getCycleRange } = require('./statusService'); +const { roundMoney } = require('../utils/money'); const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); @@ -39,7 +40,7 @@ function reversePaymentBalance(db, payment) { const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance == null) return; - const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); + const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta))); db.prepare(` UPDATE bills SET current_balance = ?, diff --git a/services/snowballService.js b/services/snowballService.js index 7b8f49f..fee0eab 100644 --- a/services/snowballService.js +++ b/services/snowballService.js @@ -1,3 +1,5 @@ + +const { roundMoney, sumMoney } = require('../utils/money'); /** * Debt payoff calculators โ€” Snowball and Avalanche methods. * @@ -112,7 +114,7 @@ function _simulate(orderedDebts, extraPayment, startDate) { })); const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); - const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0); + const totalInterest = sumMoney(active, d => d.totalInterest); return { months_to_freedom: maxMonth || null, @@ -152,7 +154,7 @@ function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) { } function round2(n) { - return Math.round(n * 100) / 100; + return roundMoney(n); } module.exports = { calculateSnowball, calculateAvalanche }; diff --git a/services/statusService.js b/services/statusService.js index 73964e1..5cc2182 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -21,9 +21,7 @@ function pad(value) { return String(value).padStart(2, '0'); } -function roundMoney(value) { - return Math.round((Number(value) || 0) * 100) / 100; -} +const { roundMoney, sumMoney } = require('../utils/money'); function dateString(year, month, day) { return `${year}-${pad(month)}-${pad(day)}`; @@ -196,7 +194,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) { const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); const safePayments = Array.isArray(payments) ? payments : []; const expectedAmount = Number(bill.expected_amount) || 0; - const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)); + const totalPaid = sumMoney(safePayments, p => p.amount); if (totalPaid >= expectedAmount) return 'paid'; @@ -225,7 +223,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { const safePayments = Array.isArray(payments) ? payments : []; const status = calculateStatus(bill, safePayments, dueDate, todayStr, options); const expectedAmount = Number(bill.expected_amount) || 0; - const totalPaid = roundMoney(safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)); + const totalPaid = sumMoney(safePayments, p => p.amount); const hasPayment = safePayments.length > 0; const isSettled = status === 'paid' || status === 'autodraft'; const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount)); diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 2d2d8b3..23a77f6 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -2,6 +2,7 @@ const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { localDateString, todayLocal } = require('../utils/dates'); +const { roundMoney, sumMoney, mulMoney } = require('../utils/money'); const SUBSCRIPTION_TYPES = [ 'streaming', 'software', 'cloud', 'music', 'news', @@ -471,7 +472,7 @@ function monthlyEquivalent(amount, cycleType, billingCycle) { ? 'annual' : key; const factor = MONTHLY_FACTORS[key] ?? MONTHLY_FACTORS[fallback] ?? 1; - return Math.round(Number(amount || 0) * factor * 100) / 100; + return mulMoney(Number(amount || 0), factor); } function nextDueDate(bill, now = new Date()) { @@ -499,7 +500,7 @@ function decorateSubscription(bill) { is_subscription: !!bill.is_subscription, active: !!bill.active, monthly_equivalent: monthly, - yearly_equivalent: Math.round(monthly * 12 * 100) / 100, + yearly_equivalent: mulMoney(monthly, 12), next_due_date: nextDueDate(bill), subscription_type: bill.subscription_type || inferType(`${bill.name} ${bill.category_name || ''}`, null), }; @@ -529,7 +530,7 @@ function getSubscriptions(db, userId) { function getSubscriptionSummary(subscriptions) { const active = subscriptions.filter(item => item.active); - const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0); + const monthlyTotal = sumMoney(active, item => item.monthly_equivalent); const typeTotals = new Map(); for (const item of active) { const type = item.subscription_type || 'other'; @@ -539,9 +540,9 @@ function getSubscriptionSummary(subscriptions) { return { active_count: active.length, paused_count: subscriptions.length - active.length, - monthly_total: Math.round(monthlyTotal * 100) / 100, - yearly_total: Math.round(monthlyTotal * 12 * 100) / 100, - top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null, + monthly_total: roundMoney(monthlyTotal), + yearly_total: mulMoney(monthlyTotal, 12), + top_type: topType ? { type: topType[0], monthly_total: roundMoney(topType[1]) } : null, }; } @@ -553,7 +554,7 @@ function existingBillNames(db, userId) { } function dollarsFromTransactionAmount(amount) { - return Math.round((Math.abs(Number(amount || 0)) / 100) * 100) / 100; + return roundMoney(Math.abs(Number(amount || 0)) / 100); } function recommendationAccountLabel(item) { @@ -706,7 +707,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun has_merchant_rule: !!bill.has_merchant_rule, score, strong: score >= 90 || (amountDelta !== null && amountDelta <= 1 && dueDelta <= 2), - amount_delta: amountDelta === null ? null : Math.round(amountDelta * 100) / 100, + amount_delta: amountDelta === null ? null : roundMoney(amountDelta), due_day_delta: dueDelta, reasons, }; @@ -800,7 +801,7 @@ function getSubscriptionRecommendations(db, userId) { .sort((a, b) => String(a.tx_date).localeCompare(String(b.tx_date))); if (sorted.length === 0) continue; - const averageAmount = sorted.reduce((sum, item) => sum + item.amount_dollars, 0) / sorted.length; + const averageAmount = sumMoney(sorted, item => item.amount_dollars) / sorted.length; const maxDelta = sorted.length > 1 ? Math.max(...sorted.map(item => Math.abs(item.amount_dollars - averageAmount))) : 0; @@ -914,7 +915,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString('base64url'), name, subscription_type: subscriptionType, - expected_amount: Math.round(averageAmount * 100) / 100, + expected_amount: roundMoney(averageAmount), monthly_equivalent: monthlyEquivalent(averageAmount, cycleType, cycleType), cycle_type: cycleType, billing_cycle: billingCycleForCycleType(cycleType), @@ -943,7 +944,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma amount_range: sorted.length > 1 ? { min: Math.min(...sorted.map(item => item.amount_dollars)), max: Math.max(...sorted.map(item => item.amount_dollars)), - max_delta: Math.round(maxDelta * 100) / 100, + max_delta: roundMoney(maxDelta), } : null, ambiguity: ambiguityInfo ? { ambiguous: !!ambiguityInfo.ambiguous, diff --git a/services/trackerService.js b/services/trackerService.js index efd3805..a4851df 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -8,6 +8,7 @@ const { computeAmountSuggestion } = require('./amountSuggestionService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates'); +const { sumMoney } = require('../utils/money'); const DEFAULT_PENDING_DAYS = 3; @@ -392,7 +393,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { }); } - const threeMonthAvg = months.reduce((sum, m) => sum + m.payment, 0) / 3; + const threeMonthAvg = sumMoney(months, m => m.payment) / 3; let percentChange = 0; let direction = 'flat'; if (threeMonthAvg > 0) { @@ -478,8 +479,8 @@ function getTracker(userId, query = {}, now = new Date()) { const dayOfMonth = now.getDate(); const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); - const periodPaidTowardDue = roundMoney(periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0)); - const periodOutstandingBalance = roundMoney(periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0)); + const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue); + const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0)); const periodStartingAmount = activeRemainingPeriod === '1st' ? (startingAmounts?.first_amount || 0) : (startingAmounts?.fifteenth_amount || 0); @@ -500,12 +501,12 @@ function getTracker(userId, query = {}, now = new Date()) { const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth; const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`; - const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0)); - const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0)); - const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0)); - const activeOutstandingBalance = roundMoney(activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0)); + const activeTotalPaid = sumMoney(activeRows, r => r.total_paid); + const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue); + const activeTotalExpected = sumMoney(activeRows, rowDueAmount); + const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0)); - const periodBillsTotal = roundMoney(periodRows.reduce((s, r) => s + rowDueAmount(r), 0)); + const periodBillsTotal = sumMoney(periodRows, rowDueAmount); const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length; const periodTotalCount = periodRows.length; @@ -538,10 +539,10 @@ function getTracker(userId, query = {}, now = new Date()) { month_total_count: monthTotalCount, month_projected: monthProjected, }; - const totalOverdue = roundMoney(rows - .filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')) - .reduce((s, r) => s + r.balance, 0)); - const previousMonthTotal = roundMoney(activeRows.reduce((s, r) => s + r.previous_month_paid, 0)); + const totalOverdue = sumMoney( + rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')), + r => r.balance); + const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid); return { year, diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index 375ae1a..c86ffbb 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -10,6 +10,7 @@ const { decorateTransaction, getTransactionForUser, } = require('./transactionService'); +const { roundMoney } = require('../utils/money'); const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match'; @@ -108,7 +109,7 @@ function restorePaymentBalance(db, payment) { const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance == null) return; - const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); + const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta))); // Clear interest_accrued_month when reversing a payment that charged interest, // so the re-applied payment can accrue interest fresh. db.prepare(`