feat(money): migrate services to cent-exact money.js helpers (batch 0.38.3)

This commit is contained in:
null 2026-06-10 20:14:13 -05:00
parent 19d0e653a3
commit bf66ab1ee6
16 changed files with 214 additions and 48 deletions

View File

@ -31,6 +31,29 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
## Pending Recommendations ## 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 ## 🟡 MEDIUM
### 🟡 Projected Cash Flow — MEDIUM ### 🟡 Projected Cash Flow — MEDIUM

View File

@ -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) <table>`, 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).

View File

@ -22,6 +22,7 @@ const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService');
const { localDateString, todayLocal } = require('../utils/dates'); const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney } = require('../utils/money');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -701,7 +702,7 @@ router.post('/:id/toggle-paid', (req, res) => {
if (currentPayment.balance_delta != null) { if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId); const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) { 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); 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, schedule,
summary: { summary: {
months: schedule.length, months: schedule.length,
total_interest: Math.round(total_interest * 100) / 100, total_interest: roundMoney(total_interest),
total_paid: Math.round((schedule.reduce((s, r) => s + r.payment, 0)) * 100) / 100, total_paid: sumMoney(schedule, r => r.payment),
capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0, capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0,
}, },
apr_snapshot, apr_snapshot,
@ -964,7 +965,7 @@ router.patch('/:id/balance', (req, res) => {
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
return res.status(400).json(standardizeError('current_balance must be a non-negative number', 'VALIDATION_ERROR', 'current_balance')); 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); db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);

View File

@ -14,6 +14,7 @@ const {
revokeToken, revokeToken,
} = require('../services/calendarFeedService'); } = require('../services/calendarFeedService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { roundMoney, sumMoney } = require('../utils/money');
function clampDay(year, month, day) { function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate(); 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 activeBills = calendarBills.filter(bill => !bill.is_skipped);
const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0); const expectedTotal = sumMoney(activeBills, bill => bill.effective_amount || 0);
const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0); const paidTotal = sumMoney(activeBills, bill => bill.paid_amount || 0);
const remainingTotal = Math.max(0, expectedTotal - paidTotal); const remainingTotal = Math.max(0, roundMoney(expectedTotal - paidTotal));
const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0; 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({ res.json({
year, year,
month, month,

View File

@ -15,6 +15,7 @@ const {
} = require('../services/transactionMatchService'); } = require('../services/transactionMatchService');
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
const { todayLocal } = require('../utils/dates'); const { todayLocal } = require('../utils/dates');
const { roundMoney } = require('../utils/money');
const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); 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) { if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) { 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(` db.prepare(`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,

View File

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { roundMoney } = require('../utils/money');
/** /**
* Computes a suggested expected amount for a bill based on the rolling median * 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]; : sorted[mid];
return { return {
suggestion: Math.round(median * 100) / 100, suggestion: roundMoney(median),
months_used: amounts.length, months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low', confidence: amounts.length >= 3 ? 'high' : 'low',
}; };

View File

@ -2,6 +2,7 @@
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { sumMoney } = require('../utils/money');
function parseInteger(value, fallback) { function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return 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 stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
const monthly_spending = rangeMonths.map(m => { 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)) }; return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
}).filter(row => row.total > 0); }).filter(row => row.total > 0);

View File

@ -1,3 +1,5 @@
const { roundMoney, sumMoney } = require('../utils/money');
/** /**
* APR / amortization mathematics. * APR / amortization mathematics.
* All functions are pure no DB access, no side effects. * 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 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 { return {
months_to_freedom: maxMonth || null, months_to_freedom: maxMonth || null,
@ -237,7 +239,7 @@ function debtAprSnapshot(bill) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function round2(n) { function round2(n) {
return Math.round(n * 100) / 100; return roundMoney(n);
} }
module.exports = { module.exports = {

View File

@ -1,4 +1,5 @@
const { monthKey } = require('../utils/dates'); const { monthKey } = require('../utils/dates');
const { roundMoney, mulMoney } = require('../utils/money');
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [ const TEMPLATE_FIELDS = [
@ -481,11 +482,11 @@ function computeBalanceDelta(bill, paymentAmount) {
const currentMonth = monthKey(); // "YYYY-MM" (local time) const currentMonth = monthKey(); // "YYYY-MM" (local time)
const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; 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 raw = bal + interestDelta - amt;
const newBalance = Math.round(Math.max(0, raw) * 100) / 100; const newBalance = roundMoney(Math.max(0, raw));
const delta = Math.round((newBalance - bal) * 100) / 100; const delta = roundMoney(newBalance - bal);
return { return {
new_balance: newBalance, new_balance: newBalance,

View File

@ -5,6 +5,7 @@ const { getCycleRange } = require('./statusService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { roundMoney } = require('../utils/money');
const MONTHS_BACK = 3; const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2; const MIN_PAID_MONTHS = 2;
@ -91,7 +92,7 @@ function getDriftReport(userId, now = new Date()) {
name: bill.name, name: bill.name,
category_name: bill.category_name ?? null, category_name: bill.category_name ?? null,
expected_amount: bill.expected_amount, expected_amount: bill.expected_amount,
recent_amount: Math.round(recentAmount * 100) / 100, recent_amount: roundMoney(recentAmount),
drift_pct: Math.round(driftPct * 10) / 10, drift_pct: Math.round(driftPct * 10) / 10,
direction: delta > 0 ? 'up' : 'down', direction: delta > 0 ? 'up' : 'down',
months_sampled: monthTotals.length, months_sampled: monthTotals.length,

View File

@ -2,6 +2,7 @@
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { getCycleRange } = require('./statusService'); const { getCycleRange } = require('./statusService');
const { roundMoney } = require('../utils/money');
const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0';
const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); 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); const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return; 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(` db.prepare(`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,

View File

@ -1,3 +1,5 @@
const { roundMoney, sumMoney } = require('../utils/money');
/** /**
* Debt payoff calculators Snowball and Avalanche methods. * 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 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 { return {
months_to_freedom: maxMonth || null, months_to_freedom: maxMonth || null,
@ -152,7 +154,7 @@ function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) {
} }
function round2(n) { function round2(n) {
return Math.round(n * 100) / 100; return roundMoney(n);
} }
module.exports = { calculateSnowball, calculateAvalanche }; module.exports = { calculateSnowball, calculateAvalanche };

View File

@ -21,9 +21,7 @@ function pad(value) {
return String(value).padStart(2, '0'); return String(value).padStart(2, '0');
} }
function roundMoney(value) { const { roundMoney, sumMoney } = require('../utils/money');
return Math.round((Number(value) || 0) * 100) / 100;
}
function dateString(year, month, day) { function dateString(year, month, day) {
return `${year}-${pad(month)}-${pad(day)}`; return `${year}-${pad(month)}-${pad(day)}`;
@ -196,7 +194,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const expectedAmount = Number(bill.expected_amount) || 0; 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'; if (totalPaid >= expectedAmount) return 'paid';
@ -225,7 +223,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options); const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
const expectedAmount = Number(bill.expected_amount) || 0; 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 hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft'; const isSettled = status === 'paid' || status === 'autodraft';
const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount)); const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount));

View File

@ -2,6 +2,7 @@
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
const { localDateString, todayLocal } = require('../utils/dates'); const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney, mulMoney } = require('../utils/money');
const SUBSCRIPTION_TYPES = [ const SUBSCRIPTION_TYPES = [
'streaming', 'software', 'cloud', 'music', 'news', 'streaming', 'software', 'cloud', 'music', 'news',
@ -471,7 +472,7 @@ function monthlyEquivalent(amount, cycleType, billingCycle) {
? 'annual' ? 'annual'
: key; : key;
const factor = MONTHLY_FACTORS[key] ?? MONTHLY_FACTORS[fallback] ?? 1; 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()) { function nextDueDate(bill, now = new Date()) {
@ -499,7 +500,7 @@ function decorateSubscription(bill) {
is_subscription: !!bill.is_subscription, is_subscription: !!bill.is_subscription,
active: !!bill.active, active: !!bill.active,
monthly_equivalent: monthly, monthly_equivalent: monthly,
yearly_equivalent: Math.round(monthly * 12 * 100) / 100, yearly_equivalent: mulMoney(monthly, 12),
next_due_date: nextDueDate(bill), next_due_date: nextDueDate(bill),
subscription_type: bill.subscription_type || inferType(`${bill.name} ${bill.category_name || ''}`, null), subscription_type: bill.subscription_type || inferType(`${bill.name} ${bill.category_name || ''}`, null),
}; };
@ -529,7 +530,7 @@ function getSubscriptions(db, userId) {
function getSubscriptionSummary(subscriptions) { function getSubscriptionSummary(subscriptions) {
const active = subscriptions.filter(item => item.active); 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(); const typeTotals = new Map();
for (const item of active) { for (const item of active) {
const type = item.subscription_type || 'other'; const type = item.subscription_type || 'other';
@ -539,9 +540,9 @@ function getSubscriptionSummary(subscriptions) {
return { return {
active_count: active.length, active_count: active.length,
paused_count: subscriptions.length - active.length, paused_count: subscriptions.length - active.length,
monthly_total: Math.round(monthlyTotal * 100) / 100, monthly_total: roundMoney(monthlyTotal),
yearly_total: Math.round(monthlyTotal * 12 * 100) / 100, yearly_total: mulMoney(monthlyTotal, 12),
top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null, top_type: topType ? { type: topType[0], monthly_total: roundMoney(topType[1]) } : null,
}; };
} }
@ -553,7 +554,7 @@ function existingBillNames(db, userId) {
} }
function dollarsFromTransactionAmount(amount) { 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) { function recommendationAccountLabel(item) {
@ -706,7 +707,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun
has_merchant_rule: !!bill.has_merchant_rule, has_merchant_rule: !!bill.has_merchant_rule,
score, score,
strong: score >= 90 || (amountDelta !== null && amountDelta <= 1 && dueDelta <= 2), 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, due_day_delta: dueDelta,
reasons, reasons,
}; };
@ -800,7 +801,7 @@ function getSubscriptionRecommendations(db, userId) {
.sort((a, b) => String(a.tx_date).localeCompare(String(b.tx_date))); .sort((a, b) => String(a.tx_date).localeCompare(String(b.tx_date)));
if (sorted.length === 0) continue; 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 const maxDelta = sorted.length > 1
? Math.max(...sorted.map(item => Math.abs(item.amount_dollars - averageAmount))) ? Math.max(...sorted.map(item => Math.abs(item.amount_dollars - averageAmount)))
: 0; : 0;
@ -914,7 +915,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString('base64url'), id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString('base64url'),
name, name,
subscription_type: subscriptionType, subscription_type: subscriptionType,
expected_amount: Math.round(averageAmount * 100) / 100, expected_amount: roundMoney(averageAmount),
monthly_equivalent: monthlyEquivalent(averageAmount, cycleType, cycleType), monthly_equivalent: monthlyEquivalent(averageAmount, cycleType, cycleType),
cycle_type: cycleType, cycle_type: cycleType,
billing_cycle: billingCycleForCycleType(cycleType), billing_cycle: billingCycleForCycleType(cycleType),
@ -943,7 +944,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
amount_range: sorted.length > 1 ? { amount_range: sorted.length > 1 ? {
min: Math.min(...sorted.map(item => item.amount_dollars)), min: Math.min(...sorted.map(item => item.amount_dollars)),
max: Math.max(...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, } : null,
ambiguity: ambiguityInfo ? { ambiguity: ambiguityInfo ? {
ambiguous: !!ambiguityInfo.ambiguous, ambiguous: !!ambiguityInfo.ambiguous,

View File

@ -8,6 +8,7 @@ const { computeAmountSuggestion } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { sumMoney } = require('../utils/money');
const DEFAULT_PENDING_DAYS = 3; 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 percentChange = 0;
let direction = 'flat'; let direction = 'flat';
if (threeMonthAvg > 0) { if (threeMonthAvg > 0) {
@ -478,8 +479,8 @@ function getTracker(userId, query = {}, now = new Date()) {
const dayOfMonth = now.getDate(); const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = roundMoney(periodRows.reduce((s, r) => s + rowPaidTowardDue(r), 0)); const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = roundMoney(periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0)); const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
const periodStartingAmount = activeRemainingPeriod === '1st' const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0) ? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0); : (startingAmounts?.fifteenth_amount || 0);
@ -500,12 +501,12 @@ function getTracker(userId, query = {}, now = new Date()) {
const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth; const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth;
const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`; 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 activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0)); const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0)); const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = roundMoney(activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0)); 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 periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const periodTotalCount = periodRows.length; const periodTotalCount = periodRows.length;
@ -538,10 +539,10 @@ function getTracker(userId, query = {}, now = new Date()) {
month_total_count: monthTotalCount, month_total_count: monthTotalCount,
month_projected: monthProjected, month_projected: monthProjected,
}; };
const totalOverdue = roundMoney(rows const totalOverdue = sumMoney(
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')) rows.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')),
.reduce((s, r) => s + r.balance, 0)); r => r.balance);
const previousMonthTotal = roundMoney(activeRows.reduce((s, r) => s + r.previous_month_paid, 0)); const previousMonthTotal = sumMoney(activeRows, r => r.previous_month_paid);
return { return {
year, year,

View File

@ -10,6 +10,7 @@ const {
decorateTransaction, decorateTransaction,
getTransactionForUser, getTransactionForUser,
} = require('./transactionService'); } = require('./transactionService');
const { roundMoney } = require('../utils/money');
const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = '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); const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return; 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, // Clear interest_accrued_month when reversing a payment that charged interest,
// so the re-applied payment can accrue interest fresh. // so the re-applied payment can accrue interest fresh.
db.prepare(` db.prepare(`