feat(money): migrate services to cent-exact money.js helpers (batch 0.38.3)
This commit is contained in:
parent
19d0e653a3
commit
bf66ab1ee6
23
FUTURE.md
23
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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = ?,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = ?,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
Loading…
Reference in New Issue