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(`