From d6639f138523b333e040b7b0e8392003ba356779 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 11 Jun 2026 20:12:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(money):=20cents=20migration=20stage=202=20?= =?UTF-8?q?=E2=80=94=20schema=20flip=20to=20integer=20cents=20(batch=200.3?= =?UTF-8?q?8.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/database.js | 67 +++++++++++++++++++++++++ db/schema.sql | 16 +++--- routes/bills.js | 70 ++++++++++++++++----------- routes/calendar.js | 9 ++-- routes/export.js | 24 ++++++--- routes/matches.js | 5 +- routes/monthly-starting-amounts.js | 17 ++++--- routes/payments.js | 39 +++++++-------- routes/snowball.js | 55 ++++++++++++++------- routes/subscriptions.js | 7 +-- routes/summary.js | 31 ++++++------ services/amountSuggestionService.js | 4 +- services/analyticsService.js | 12 ++--- services/billMerchantRuleService.js | 15 ++++-- services/billsService.js | 46 ++++++++++++------ services/calendarFeedService.js | 7 +-- services/driftService.js | 13 ++--- services/matchSuggestionService.js | 5 +- services/notificationService.js | 5 +- services/paymentAccountingService.js | 3 +- services/paymentValidation.js | 26 ++++++++-- services/spendingService.js | 8 +-- services/spreadsheetImportService.js | 18 ++++--- services/statusService.js | 30 ++++++------ services/subscriptionService.js | 12 +++-- services/trackerService.js | 29 ++++++----- services/transactionMatchService.js | 12 ++--- services/userDbImportService.js | 52 ++++++++++++++------ tests/billReorder.test.js | 2 +- tests/calendarFeedService.test.js | 2 +- tests/statusService.test.js | 4 +- tests/subscriptionService.test.js | 4 +- tests/transactionMatchService.test.js | 10 ++-- 33 files changed, 430 insertions(+), 229 deletions(-) diff --git a/db/database.js b/db/database.js index 0ba2312..85cf10d 100644 --- a/db/database.js +++ b/db/database.js @@ -3367,6 +3367,46 @@ function runMigrations() { console.log('[v1.02] users.geolocation_enabled added'); } }, + { + version: 'v1.03', + description: 'money columns: dollars (REAL) -> integer cents', + run() { + const conv = [ + ['bills', ['expected_amount', 'current_balance', 'minimum_payment']], + ['payments', ['amount', 'balance_delta', 'interest_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'); + } + }, + { + version: 'v1.04', + description: 'bill_templates.data JSON: money fields dollars -> integer cents', + run() { + // v1.03 converted table columns but not money values embedded in the + // bill_templates.data JSON blob. Templates saved before v1.03 hold + // dollars; the template code now reads cents (serializeTemplateData). + for (const field of ['expected_amount', 'current_balance', 'minimum_payment']) { + db.exec(` + UPDATE bill_templates + SET data = json_set(data, '$.${field}', + CAST(ROUND(json_extract(data, '$.${field}') * 100) AS INTEGER)) + WHERE json_extract(data, '$.${field}') IS NOT NULL + `); + } + console.log('[v1.04] bill_templates.data money fields converted to integer cents'); + } + }, ]; // ── users: notification columns ─────────────────────────────────────────── @@ -3711,6 +3751,33 @@ function getDbPath() { // Rollback SQL definitions const ROLLBACK_SQL_MAP = { + 'v1.04': { + description: 'bill_templates.data JSON: money fields dollars -> integer cents', + sql: [ + "UPDATE bill_templates SET data = json_set(data, '$.expected_amount', ROUND(json_extract(data, '$.expected_amount') / 100.0, 2)) WHERE json_extract(data, '$.expected_amount') IS NOT NULL", + "UPDATE bill_templates SET data = json_set(data, '$.current_balance', ROUND(json_extract(data, '$.current_balance') / 100.0, 2)) WHERE json_extract(data, '$.current_balance') IS NOT NULL", + "UPDATE bill_templates SET data = json_set(data, '$.minimum_payment', ROUND(json_extract(data, '$.minimum_payment') / 100.0, 2)) WHERE json_extract(data, '$.minimum_payment') IS NOT NULL", + ] + }, + 'v1.03': { + description: 'money columns: dollars (REAL) -> integer cents', + sql: [ + 'UPDATE bills SET expected_amount = ROUND(expected_amount / 100.0, 2) WHERE expected_amount IS NOT NULL', + 'UPDATE bills SET current_balance = ROUND(current_balance / 100.0, 2) WHERE current_balance IS NOT NULL', + 'UPDATE bills SET minimum_payment = ROUND(minimum_payment / 100.0, 2) WHERE minimum_payment IS NOT NULL', + 'UPDATE payments SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL', + 'UPDATE payments SET balance_delta = ROUND(balance_delta / 100.0, 2) WHERE balance_delta IS NOT NULL', + 'UPDATE payments SET interest_delta = ROUND(interest_delta / 100.0, 2) WHERE interest_delta IS NOT NULL', + 'UPDATE monthly_bill_state SET actual_amount = ROUND(actual_amount / 100.0, 2) WHERE actual_amount IS NOT NULL', + 'UPDATE monthly_starting_amounts SET first_amount = ROUND(first_amount / 100.0, 2) WHERE first_amount IS NOT NULL', + 'UPDATE monthly_starting_amounts SET fifteenth_amount = ROUND(fifteenth_amount / 100.0, 2) WHERE fifteenth_amount IS NOT NULL', + 'UPDATE monthly_starting_amounts SET other_amount = ROUND(other_amount / 100.0, 2) WHERE other_amount IS NOT NULL', + 'UPDATE monthly_income SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL', + 'UPDATE spending_budgets SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL', + 'UPDATE snowball_plans SET extra_payment = ROUND(extra_payment / 100.0, 2) WHERE extra_payment IS NOT NULL', + 'UPDATE users SET snowball_extra_payment = ROUND(snowball_extra_payment / 100.0, 2) WHERE snowball_extra_payment IS NOT NULL', + ] + }, 'v0.98': { description: 'payments: bank override metadata for provisional manual payments', sql: [ diff --git a/db/schema.sql b/db/schema.sql index d123ead..01f0054 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS bills ( due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31), override_due_date TEXT, bucket TEXT CHECK(bucket IN ('1st', '15th')), - expected_amount REAL NOT NULL DEFAULT 0, + expected_amount INTEGER NOT NULL DEFAULT 0, -- cents interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)), billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')), cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')), @@ -32,8 +32,8 @@ CREATE TABLE IF NOT EXISTS bills ( account_info TEXT, has_2fa INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, - current_balance REAL, - minimum_payment REAL, + current_balance INTEGER, -- cents + minimum_payment INTEGER, -- cents snowball_order INTEGER, sort_order INTEGER, snowball_include INTEGER NOT NULL DEFAULT 0, @@ -53,11 +53,11 @@ CREATE TABLE IF NOT EXISTS bills ( CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, - amount REAL NOT NULL, + amount INTEGER NOT NULL, -- cents paid_date TEXT NOT NULL, method TEXT, notes TEXT, - balance_delta REAL, + balance_delta INTEGER, -- cents payment_source TEXT NOT NULL DEFAULT 'manual', transaction_id INTEGER, accounting_excluded INTEGER NOT NULL DEFAULT 0, @@ -85,7 +85,7 @@ CREATE TABLE IF NOT EXISTS users ( is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, - snowball_extra_payment REAL NOT NULL DEFAULT 0, + snowball_extra_payment INTEGER NOT NULL DEFAULT 0, -- cents notify_amount_change INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) @@ -228,7 +228,7 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state ( bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), - actual_amount REAL, -- NULL = use bill.expected_amount for this month + actual_amount INTEGER, -- cents; NULL = use bill.expected_amount for this month notes TEXT, -- month-specific notes, NULL = no notes is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only snoozed_until TEXT, -- ISO date: hide from overdue command center until this date @@ -291,7 +291,7 @@ CREATE TABLE IF NOT EXISTS snowball_plans ( started_at TEXT NOT NULL DEFAULT (datetime('now')), paused_at TEXT, completed_at TEXT, - extra_payment REAL NOT NULL DEFAULT 0, + extra_payment INTEGER NOT NULL DEFAULT 0, -- cents plan_snapshot TEXT NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/routes/bills.js b/routes/bills.js index 7128072..7f63e94 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -5,6 +5,7 @@ const { auditBillsForUser, categoryBelongsToUser, insertBill, + serializeBill, parseTemplateData, sanitizeTemplateData, validateBillData, @@ -13,7 +14,7 @@ const { } = require('../services/billsService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { standardizeError } = require('../middleware/errorFormatter'); -const { validatePaymentInput } = require('../services/paymentValidation'); +const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); @@ -22,7 +23,7 @@ const { applyBankPaymentAsSourceOfTruth, } = require('../services/paymentAccountingService'); const { localDateString, todayLocal } = require('../utils/dates'); -const { roundMoney, sumMoney } = require('../utils/money'); +const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money'); // ── GET /api/bills ──────────────────────────────────────────────────────────── router.get('/', (req, res) => { @@ -45,7 +46,7 @@ router.get('/', (req, res) => { ${includeInactive ? '' : 'AND b.active = 1'} ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC `).all(req.user.id); - res.json(bills); + res.json(bills.map(serializeBill)); }); // ── PUT /api/bills/reorder ─────────────────────────────────────────────────── @@ -92,7 +93,7 @@ router.put('/reorder', (req, res) => { ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC `).all(req.user.id); - res.json({ success: true, bills }); + res.json({ success: true, bills: bills.map(serializeBill) }); }); // ── GET /api/bills/audit?inactive=true ─────────────────────────────────────── @@ -146,6 +147,18 @@ router.post('/:id/snooze-drift', (req, res) => { res.json({ ok: true, drift_snoozed_until: untilStr }); }); +// Bill templates store money fields (expected_amount, current_balance, minimum_payment) +// in integer cents, matching validateBillData's normalized output. Convert back to +// dollars for API responses, mirroring serializeBill. +function serializeTemplateData(data) { + if (!data) return data; + const out = { ...data }; + if (out.expected_amount != null) out.expected_amount = fromCents(out.expected_amount); + if (out.current_balance != null) out.current_balance = fromCents(out.current_balance); + if (out.minimum_payment != null) out.minimum_payment = fromCents(out.minimum_payment); + return out; +} + // ── GET /api/bills/templates ───────────────────────────────────────────────── router.get('/templates', (req, res) => { const db = getDb(); @@ -158,7 +171,7 @@ router.get('/templates', (req, res) => { res.json(rows.map(row => ({ ...row, - data: parseTemplateData(row.data), + data: serializeTemplateData(parseTemplateData(row.data)), }))); }); @@ -200,7 +213,7 @@ router.post('/templates', (req, res) => { res.status(result.changes > 0 ? 201 : 200).json({ ...template, - data: parseTemplateData(template.data), + data: serializeTemplateData(parseTemplateData(template.data)), }); }); @@ -228,7 +241,7 @@ router.post('/:id/duplicate', (req, res) => { if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const draft = { - ...sanitizeTemplateData(source), + ...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(body), name: String(body.name || `${source.name} (Copy)`).trim(), }; @@ -242,7 +255,7 @@ router.post('/:id/duplicate', (req, res) => { return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); } - res.status(201).json(insertBill(db, req.user.id, normalized)); + res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); }); // ── GET /api/bills/:id/monthly-state?year=&month= ───────────────────────────── @@ -267,7 +280,7 @@ router.get('/:id/monthly-state', (req, res) => { bill_id: billId, year, month, - actual_amount: mbs?.actual_amount ?? null, + actual_amount: fromCents(mbs?.actual_amount), notes: mbs?.notes ?? null, is_skipped: !!(mbs?.is_skipped), }); @@ -306,7 +319,7 @@ router.put('/:id/monthly-state', (req, res) => { 'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' ).get(billId, y, m); - const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : (existing?.actual_amount ?? null); + const amt = actual_amount !== undefined ? (actual_amount === null ? null : toCents(actual_amount)) : (existing?.actual_amount ?? null); const noteVal = notes !== undefined ? (notes || null) : (existing?.notes ?? null); const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : (existing?.is_skipped ?? 0); const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : (existing?.snoozed_until ?? null); @@ -330,7 +343,7 @@ router.put('/:id/monthly-state', (req, res) => { bill_id: saved.bill_id, year: saved.year, month: saved.month, - actual_amount: saved.actual_amount, + actual_amount: fromCents(saved.actual_amount), notes: saved.notes, is_skipped: !!saved.is_skipped, snoozed_until: saved.snoozed_until ?? null, @@ -377,7 +390,7 @@ router.get('/:id', (req, res) => { }; } - res.json({ ...bill, autopay_stats }); + res.json({ ...serializeBill(bill), autopay_stats }); }); // ── POST /api/bills/:id/verify-autopay ─────────────────────────────────────── @@ -411,7 +424,7 @@ router.post('/', (req, res) => { const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id); if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id')); payload = { - ...sanitizeTemplateData(source), + ...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(body), name: String(body.name || `${source.name} (Copy)`).trim(), }; @@ -431,7 +444,7 @@ router.post('/', (req, res) => { return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id')); } - res.status(201).json(insertBill(db, req.user.id, normalized)); + res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); }); // ── PUT /api/bills/:id ──────────────────────────────────────────────────────── @@ -508,7 +521,7 @@ router.put('/:id', (req, res) => { ); const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id); - res.json(updated); + res.json(serializeBill(updated)); }); // ── PUT /api/bills/:id/archived ────────────────────────────────────────────── @@ -526,7 +539,7 @@ router.put('/:id/archived', (req, res) => { .run(archived ? 0 : 1, id, req.user.id); const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id); - res.json({ ...updated, archived: !updated.active }); + res.json({ ...serializeBill(updated), archived: !updated.active }); }); // ── DELETE /api/bills/:id — soft delete for 30-day recovery ─────────────────── @@ -555,7 +568,7 @@ router.post('/:id/restore', (req, res) => { db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?") .run(req.params.id, req.user.id); - res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)); + res.json(serializeBill(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id))); }); // POST /api/bills/:id/sync-simplefin-payments @@ -600,7 +613,7 @@ router.get('/:id/payments', (req, res) => { page, limit, pages: Math.ceil(total / limit), - payments: items, + payments: items.map(serializePayment), }); }); @@ -647,7 +660,7 @@ router.get('/:id/transactions', (req, res) => { ...row, linked_payment: row.linked_payment_id ? { id: row.linked_payment_id, - amount: row.linked_payment_amount, + amount: fromCents(row.linked_payment_amount), paid_date: row.linked_payment_date, payment_source: row.linked_payment_source, method: row.linked_payment_method, @@ -702,7 +715,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, roundMoney(freshBill.current_balance - currentPayment.balance_delta)); + const restored = Math.max(0, freshBill.current_balance - currentPayment.balance_delta); db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId); } } @@ -718,7 +731,7 @@ router.post('/:id/toggle-paid', (req, res) => { // If unpaid, create payment → Paid // Use expected_amount if no amount provided - const amount = req.body.amount !== undefined ? req.body.amount : bill.expected_amount; + const amount = req.body.amount !== undefined ? req.body.amount : fromCents(bill.expected_amount); // Determine paid_date let paidDate = req.body.paid_date; @@ -755,7 +768,7 @@ router.post('/:id/toggle-paid', (req, res) => { success: true, isPaid: true, action: 'created_payment', - payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid), + payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)), }); }); @@ -886,9 +899,9 @@ router.get('/:id/amortization', (req, res) => { const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); - const balance = Number(bill.current_balance); + const balance = fromCents(Number(bill.current_balance)); const apr = Number(bill.interest_rate) || 0; - const minPmt = Number(bill.minimum_payment) || 0; + const minPmt = fromCents(Number(bill.minimum_payment) || 0); // Optional override: ?payment=X lets callers model "what if I pay more?" let payment = minPmt; @@ -912,7 +925,7 @@ router.get('/:id/amortization', (req, res) => { } const schedule = amortizationSchedule(balance, apr, payment, maxMonths); - const apr_snapshot = debtAprSnapshot(bill); + const apr_snapshot = debtAprSnapshot({ ...bill, current_balance: balance, minimum_payment: minPmt }); const total_interest = schedule.reduce((s, r) => s + r.interest, 0); res.json({ @@ -968,7 +981,7 @@ router.patch('/:id/balance', (req, res) => { 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(toCents(val), billId); res.json({ id: billId, current_balance: val }); }); @@ -1221,10 +1234,11 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); if (!paidDate) continue; - const amount = Math.round(Math.abs(tx.amount)) / 100; + const amountCents = Math.round(Math.abs(tx.amount)); + const amount = fromCents(amountCents); const billRow = getBill.get(billId); - const result = insertPayment.run(billId, amount, paidDate, txId); + const result = insertPayment.run(billId, amountCents, paidDate, txId); if (result.changes > 0) { const insertedPayment = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId); updateTx.run(billId, txId); diff --git a/routes/calendar.js b/routes/calendar.js index 32270d7..489f79d 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -14,7 +14,7 @@ const { revokeToken, } = require('../services/calendarFeedService'); const { localDateString } = require('../utils/dates'); -const { roundMoney, sumMoney } = require('../utils/money'); +const { roundMoney, sumMoney, fromCents } = require('../utils/money'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); @@ -161,16 +161,17 @@ router.get('/', (req, res) => { for (const payment of payments) { const day = dayByDate.get(payment.paid_date); if (day) { + const amount = fromCents(payment.amount); day.payments.push({ payment_id: payment.payment_id, bill_id: payment.bill_id, bill_name: payment.bill_name, - amount: payment.amount, + amount, paid_date: payment.paid_date, method: payment.method || null, notes: payment.notes || null, }); - day.status_summary.total_paid += payment.amount || 0; + day.status_summary.total_paid += amount || 0; } } @@ -183,7 +184,7 @@ router.get('/', (req, res) => { if (!row) return null; const monthlyState = monthlyStateStmt.get(bill.id, year, month); - const actualAmount = monthlyState?.actual_amount ?? null; + const actualAmount = fromCents(monthlyState?.actual_amount); const isSkipped = !!monthlyState?.is_skipped; const effectiveAmount = actualAmount ?? row.expected_amount; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount; diff --git a/routes/export.js b/routes/export.js index 35f2974..e02808c 100644 --- a/routes/export.js +++ b/routes/export.js @@ -7,6 +7,7 @@ const fs = require('fs'); const Database = require('better-sqlite3'); const xlsx = require('xlsx'); const { getDb } = require('../db/database'); +const { fromCents } = require('../utils/money'); // GET /api/export?year=2026&format=csv router.get('/', (req, res) => { @@ -58,11 +59,11 @@ router.get('/', (req, res) => { r.paid_date, escCsv(r.bill_name), escCsv(r.category), - r.expected_amount.toFixed(2), - r.paid_amount.toFixed(2), + fromCents(r.expected_amount).toFixed(2), + fromCents(r.paid_amount).toFixed(2), escCsv(r.method), escCsv(r.notes), - mbs?.actual_amount != null ? mbs.actual_amount.toFixed(2) : '', + mbs?.actual_amount != null ? fromCents(mbs.actual_amount).toFixed(2) : '', escCsv(mbs?.notes ?? null), ].join(','); }).join('\n'); @@ -79,7 +80,9 @@ router.get('/', (req, res) => { const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth); return { ...r, - actual_amount: mbs?.actual_amount ?? null, + expected_amount: fromCents(r.expected_amount), + paid_amount: fromCents(r.paid_amount), + actual_amount: fromCents(mbs?.actual_amount ?? null), monthly_notes: mbs?.notes ?? null, }; }); @@ -96,7 +99,7 @@ function getUserExportData(userId) { FROM bills WHERE user_id = ? AND deleted_at IS NULL ORDER BY active DESC, due_day ASC, name ASC - `).all(userId); + `).all(userId).map(b => ({ ...b, expected_amount: fromCents(b.expected_amount) })); const payments = db.prepare(` SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, CASE WHEN p.payment_source = 'transaction_match' THEN 'manual' ELSE p.payment_source END AS payment_source, @@ -105,20 +108,25 @@ function getUserExportData(userId) { JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, p.id ASC - `).all(userId); + `).all(userId).map(p => ({ ...p, amount: fromCents(p.amount) })); const monthlyState = db.prepare(` SELECT m.id, m.bill_id, m.year, m.month, m.actual_amount, m.notes, m.is_skipped, m.created_at, m.updated_at FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ? AND b.deleted_at IS NULL ORDER BY m.year, m.month, m.bill_id - `).all(userId); + `).all(userId).map(m => ({ ...m, actual_amount: fromCents(m.actual_amount) })); const monthlyStartingAmounts = db.prepare(` SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at FROM monthly_starting_amounts WHERE user_id = ? ORDER BY year, month - `).all(userId); + `).all(userId).map(r => ({ + ...r, + first_amount: fromCents(r.first_amount), + fifteenth_amount: fromCents(r.fifteenth_amount), + other_amount: fromCents(r.other_amount), + })); const historyRanges = db.prepare(` SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at FROM bill_history_ranges diff --git a/routes/matches.js b/routes/matches.js index f672b83..9b211bd 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -7,6 +7,7 @@ const { rejectMatchSuggestion, } = require('../services/matchSuggestionService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); +const { serializePayment } = require('../services/paymentValidation'); const { todayLocal } = require('../utils/dates'); function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { @@ -57,7 +58,7 @@ router.post('/confirm', (req, res) => { if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); - const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars + const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents try { db.exec('BEGIN'); @@ -87,7 +88,7 @@ router.post('/confirm', (req, res) => { LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL WHERE t.id = ? `).get(txId); - res.json({ transaction: updated, payment }); + res.json({ transaction: updated, payment: serializePayment(payment) }); } catch (err) { try { db.exec('ROLLBACK'); } catch {} return sendMatchError(res, err, 'Failed to confirm match'); diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js index f18b53b..84bf28e 100644 --- a/routes/monthly-starting-amounts.js +++ b/routes/monthly-starting-amounts.js @@ -3,6 +3,7 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { getCycleRange } = require('../services/statusService'); const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { toCents, fromCents } = require('../utils/money'); function parseYearMonth(source) { const now = new Date(); @@ -32,9 +33,9 @@ function getStartingAmounts(db, userId, year, month) { `).get(userId, year, month); return { - first_amount: money(row?.first_amount || 0), - fifteenth_amount: money(row?.fifteenth_amount || 0), - other_amount: money(row?.other_amount || 0), + first_amount: fromCents(row?.first_amount || 0), + fifteenth_amount: fromCents(row?.fifteenth_amount || 0), + other_amount: fromCents(row?.other_amount || 0), }; } @@ -88,10 +89,10 @@ function calculatePaidDeductions(db, userId, year, month) { `).get(userId, start, end); return { - paid_from_first: money(firstPaid.paid), - paid_from_fifteenth: money(fifteenthPaid.paid), - paid_from_other: money(otherPaid.paid), - paid_total: money(totalPaid.paid), + paid_from_first: fromCents(firstPaid.paid), + paid_from_fifteenth: fromCents(fifteenthPaid.paid), + paid_from_other: fromCents(otherPaid.paid), + paid_total: fromCents(totalPaid.paid), }; } @@ -156,7 +157,7 @@ router.put('/', (req, res) => { fifteenth_amount = excluded.fifteenth_amount, other_amount = excluded.other_amount, updated_at = datetime('now') - `).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount); + `).run(req.user.id, parsed.year, parsed.month, toCents(firstAmount), toCents(fifteenthAmount), toCents(otherAmount)); res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month)); }); diff --git a/routes/payments.js b/routes/payments.js index 6b06f5d..45b73a1 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -3,13 +3,14 @@ const { standardizeError } = require('../middleware/errorFormatter'); const router = require('express').Router(); const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); -const { validatePaymentInput } = require('../services/paymentValidation'); +const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, } = require('../services/paymentAccountingService'); const { todayLocal } = require('../utils/dates'); +const { fromCents } = require('../utils/money'); // SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied. // It cannot be a bind parameter (SQL fragments are not parameterisable — only @@ -66,7 +67,7 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) { if (!dueDate) { return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 }; } - const amount = state?.actual_amount ?? bill.expected_amount; + const amount = fromCents(state?.actual_amount ?? bill.expected_amount); return { bill, dueDate, amount }; } @@ -107,7 +108,7 @@ router.get('/', (req, res) => { } query += ' ORDER BY p.paid_date DESC'; - res.json(db.prepare(query).all(...params)); + res.json(db.prepare(query).all(...params).map(serializePayment)); }); // GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days @@ -130,7 +131,7 @@ router.get('/recent-auto', (req, res) => { ORDER BY p.created_at DESC LIMIT 50 `).all(req.user.id); - res.json(rows); + res.json(rows.map(serializePayment)); }); // GET /api/payments/:id @@ -138,7 +139,7 @@ router.get('/:id', (req, res) => { const db = getDb(); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); - res.json(payment); + res.json(serializePayment(payment)); }); // POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match @@ -164,7 +165,7 @@ router.post('/:id/undo-auto', (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, Number(bill.current_balance) - Number(payment.balance_delta)); db.prepare(` UPDATE bills SET current_balance = ?, @@ -212,7 +213,7 @@ router.post('/', (req, res) => { applyBalanceDelta(db, bill.id, balCalc); - res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); + res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid))); }); // POST /api/payments/quick — pay a bill (expected amount, today) @@ -230,7 +231,7 @@ router.post('/quick', (req, res) => { const paymentValidation = validatePaymentInput( { - amount: amount != null ? amount : bill.expected_amount, + amount: amount != null ? amount : fromCents(bill.expected_amount), paid_date: paid_date || todayLocal(), payment_source: payment_source ?? 'manual', }, @@ -251,7 +252,7 @@ router.post('/quick', (req, res) => { applyBalanceDelta(db, bill.id, balCalc); - res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); + res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid))); }); // POST /api/payments/autopay-suggestions/:billId/confirm @@ -296,7 +297,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => { if (existing) { db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') .run(req.user.id, bill.id, ym.year, ym.month); - return res.json({ created: false, payment: existing }); + return res.json({ created: false, payment: serializePayment(existing) }); } const balCalc = computeBalanceDelta(bill, suggestedPayment.amount); @@ -318,7 +319,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => { db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') .run(req.user.id, bill.id, ym.year, ym.month); - res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) }); + res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) }); }); // POST /api/payments/autopay-suggestions/:billId/dismiss @@ -407,7 +408,7 @@ router.post('/bulk', (req, res) => { // Check for duplicates using composite key (bill_id + paid_date + amount) const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt); if (isDuplicate) { - skipped.push({ bill_id, paid_date, amount: parsedAmt }); + skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) }); continue; } @@ -421,7 +422,7 @@ router.post('/bulk', (req, res) => { const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source); applyBalanceDelta(db, bill_id, balCalc); - created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)); + created.push(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid))); } }); @@ -460,7 +461,7 @@ router.put('/:id', (req, res) => { const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null; let restoredBalance = bill.current_balance; if (paymentPortion != null && bill.current_balance != null) { - restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100); + restoredBalance = Math.max(0, bill.current_balance - paymentPortion); } // interest_accrued_month is still set to this month (if interest was charged) so @@ -499,7 +500,7 @@ router.put('/:id', (req, res) => { req.user.id, ); - res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)); + res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id))); }); // DELETE /api/payments/:id — soft delete (sets deleted_at) @@ -515,7 +516,7 @@ router.delete('/:id', (req, res) => { if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); if (bill?.current_balance != null) { - const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100); + const restored = Math.max(0, bill.current_balance - payment.balance_delta); db.prepare(` UPDATE bills SET current_balance = ?, @@ -543,7 +544,7 @@ router.post('/:id/restore', (req, res) => { if (!payment.accounting_excluded && payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); if (bill?.current_balance != null) { - const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100); + const reapplied = Math.max(0, bill.current_balance + payment.balance_delta); const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null; db.prepare(` UPDATE bills @@ -556,7 +557,7 @@ router.post('/:id/restore', (req, res) => { } db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id); - res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)); + res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id))); }); // PATCH /api/payments/:id/attribute-to-month @@ -617,7 +618,7 @@ router.patch('/:id/attribute-to-month', (req, res) => { if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date }); })(); - res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id)); + res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id))); } catch (err) { console.error('[payments] attribute-to-month error:', err.message); res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR')); diff --git a/routes/snowball.js b/routes/snowball.js index 0c6435a..91a69f7 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -4,6 +4,8 @@ const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService'); +const { serializeBill } = require('../services/billsService'); +const { toCents, fromCents } = require('../utils/money'); const DEBT_LIKE_CLAUSES = `( b.snowball_include = 1 @@ -84,7 +86,7 @@ function getDebtBills(userId, ramseyMode) { // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order router.get('/', (req, res) => { const ramseyMode = isRamseyMode(req.user.id); - res.json(getDebtBills(req.user.id, ramseyMode)); + res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill)); }); // GET /api/snowball/settings — extra monthly payment for this user @@ -92,7 +94,7 @@ router.get('/settings', (req, res) => { const db = getDb(); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); res.json({ - extra_payment: user?.snowball_extra_payment ?? 0, + extra_payment: fromCents(user?.snowball_extra_payment ?? 0), ramsey_mode: isRamseyMode(req.user.id), ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'), ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'), @@ -118,7 +120,7 @@ router.patch('/settings', (req, res) => { const db = getDb(); const save = db.transaction(() => { if (extra_payment !== undefined) { - db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id); + db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(toCents(val), req.user.id); } if (ramsey_mode !== undefined) { @@ -137,7 +139,7 @@ router.patch('/settings', (req, res) => { const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); res.json({ - extra_payment: user?.snowball_extra_payment ?? 0, + extra_payment: fromCents(user?.snowball_extra_payment ?? 0), // Use body value when ramsey_mode was just saved; fall back to DB read if not in request ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id), ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'), @@ -154,16 +156,24 @@ router.get('/projection', (req, res) => { const bills = getDebtBills(req.user.id, ramseyMode); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); + // Money fields on `bills` are stored as integer cents; the snowball/APR math + // and the API response are dollar-denominated, so convert before computing. + const billsForMath = bills.map(b => ({ + ...b, + current_balance: fromCents(b.current_balance), + minimum_payment: fromCents(b.minimum_payment), + })); + // Allow an optional ?extra=N override so the client can preview an unsaved // extra payment without a round-trip save. Falls back to the stored value. const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN; const extra = Number.isFinite(queryExtra) && queryExtra >= 0 ? queryExtra - : (user?.snowball_extra_payment ?? 0); + : fromCents(user?.snowball_extra_payment ?? 0); // Build a lookup of APR snapshots keyed by bill id (computed once from current balances) const aprByBill = {}; - for (const b of bills) { + for (const b of billsForMath) { const snap = debtAprSnapshot(b); if (snap) aprByBill[b.id] = snap; } @@ -180,9 +190,9 @@ router.get('/projection', (req, res) => { } const now = new Date(); - const snowball = enrich(calculateSnowball(bills, extra, now)); - const avalanche = enrich(calculateAvalanche(bills, extra, now)); - const minimum_only = enrich(calculateMinimumOnly(bills, now)); + const snowball = enrich(calculateSnowball(billsForMath, extra, now)); + const avalanche = enrich(calculateAvalanche(billsForMath, extra, now)); + const minimum_only = enrich(calculateMinimumOnly(billsForMath, now)); // Comparison: what does the snowball save vs just paying minimums? const comparison = buildComparison(snowball, minimum_only); @@ -270,7 +280,7 @@ function enrichPlanWithProgress(db, plan) { const currentDebts = (snapshot?.debts ?? []).map(d => { const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id); - const currentBalance = bill && !bill.deleted_at ? (bill.current_balance ?? null) : null; + const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null; const startingBalance = d.starting_balance ?? 0; const progressPct = startingBalance > 0 && currentBalance !== null ? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100))) @@ -281,7 +291,7 @@ function enrichPlanWithProgress(db, plan) { const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now(); const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30)); - return { ...plan, plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts }; + return { ...plan, extra_payment: fromCents(plan.extra_payment), plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts }; } // POST /api/snowball/plans — start a new snowball plan @@ -301,15 +311,24 @@ router.post('/plans', (req, res) => { return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' }); } - const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); - const extra = user?.snowball_extra_payment ?? 0; - const now = new Date(); + // Money fields on `debts` are stored as integer cents; the snowball/APR + // math and plan_snapshot are dollar-denominated, so convert before computing. + const debtsForMath = debts.map(b => ({ + ...b, + current_balance: fromCents(b.current_balance), + minimum_payment: fromCents(b.minimum_payment), + })); - const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now); - const minOnly = calculateMinimumOnly(debts, now); + const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); + const extraCents = user?.snowball_extra_payment ?? 0; + const extra = fromCents(extraCents); + const now = new Date(); + + const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now); + const minOnly = calculateMinimumOnly(debtsForMath, now); const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100); - const debtSnaps = debts.map((b, i) => { + const debtSnaps = debtsForMath.map((b, i) => { const proj = snowball.debts?.find(d => d.id === b.id); return { bill_id: b.id, @@ -342,7 +361,7 @@ router.post('/plans', (req, res) => { const result = db.prepare(` INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) - `).run(userId, planName, planMethod, extra, planSnapshot, notes || null); + `).run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid); res.status(201).json(enrichPlanWithProgress(db, plan)); diff --git a/routes/subscriptions.js b/routes/subscriptions.js index d59509c..84a79b3 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); +const { fromCents } = require('../utils/money'); const { createSubscriptionFromRecommendation, declineRecommendation, @@ -105,7 +106,7 @@ router.post('/recommendations/match-bill', (req, res) => { db.transaction(() => { for (const tx of txRows) { const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); - const amount = Math.round(Math.abs(tx.amount)) / 100; + const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents matchedCount += updateTx.run(billId, tx.id, req.user.id).changes; if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id); } @@ -213,9 +214,9 @@ router.get('/catalog', (req, res) => { matched_bill: bill ? { id: bill.id, name: bill.name, - expected_amount: bill.expected_amount, + expected_amount: fromCents(bill.expected_amount), active: !!bill.active, - monthly_equivalent: monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle), + monthly_equivalent: monthlyEquivalent(fromCents(bill.expected_amount), bill.cycle_type, bill.billing_cycle), } : null, user_descriptors: userDescsByCatalogId.get(entry.id) ?? [], }; diff --git a/routes/summary.js b/routes/summary.js index 1feac9b..481ea84 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -4,6 +4,7 @@ const { getDb } = require('../db/database'); const { getCycleRange } = require('../services/statusService'); const { getUserSettings } = require('../services/userSettings'); const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { toCents, fromCents } = require('../utils/money'); const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_PENDING_DAYS = 3; @@ -74,9 +75,9 @@ function buildBankTrackingSummary(db, userId, year, month) { `).get(year, month, start, end, userId); const balanceDollars = money(account.balance / 100); - const pendingDollars = money(pendingRow.pending_total); + const pendingDollars = fromCents(pendingRow.pending_total); const effectiveDollars = money(balanceDollars - pendingDollars); - const unpaidDollars = money(unpaidRow.unpaid_total); + const unpaidDollars = fromCents(unpaidRow.unpaid_total); return { enabled: true, @@ -127,9 +128,9 @@ function getStartingAmounts(db, userId, year, month) { `).get(userId, year, month); return { - first_amount: money(row?.first_amount || 0), - fifteenth_amount: money(row?.fifteenth_amount || 0), - other_amount: money(row?.other_amount || 0), + first_amount: fromCents(row?.first_amount || 0), + fifteenth_amount: fromCents(row?.fifteenth_amount || 0), + other_amount: fromCents(row?.other_amount || 0), }; } @@ -187,10 +188,10 @@ function calculatePaidDeductions(db, userId, year, month) { `).get(userId, start, end); return { - paid_from_first: money(firstPaid.paid), - paid_from_fifteenth: money(fifteenthPaid.paid), - paid_from_other: money(otherPaid.paid), - paid_total: money(totalPaid.paid), + paid_from_first: fromCents(firstPaid.paid), + paid_from_fifteenth: fromCents(fifteenthPaid.paid), + paid_from_other: fromCents(otherPaid.paid), + paid_total: fromCents(totalPaid.paid), }; } @@ -229,7 +230,7 @@ function getIncome(db, userId, year, month) { return { id: row?.id || null, label: row?.label || DEFAULT_INCOME_LABEL, - amount: money(row?.amount), + amount: fromCents(row?.amount ?? 0), }; } @@ -284,7 +285,7 @@ function buildSummary(db, userId, year, month) { for (const row of payments) { paymentMap.set(row.bill_id, { payment_count: row.payment_count || 0, - paid_amount: money(row.paid_amount), + paid_amount: fromCents(row.paid_amount), }); } } @@ -292,14 +293,14 @@ function buildSummary(db, userId, year, month) { const expenses = billRows.map(row => { const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 }; const hasActual = row.actual_amount !== null && row.actual_amount !== undefined; - const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount); + const displayAmount = fromCents(hasActual ? row.actual_amount : row.expected_amount); const paidAmount = money(payment.paid_amount); return { bill_id: row.bill_id, name: row.name, - expected_amount: money(row.expected_amount), - actual_amount: hasActual ? money(row.actual_amount) : null, + expected_amount: fromCents(row.expected_amount), + actual_amount: hasActual ? fromCents(row.actual_amount) : null, display_amount: displayAmount, is_paid: payment.payment_count > 0, paid_amount: paidAmount, @@ -407,7 +408,7 @@ router.put('/income', (req, res) => { label = excluded.label, amount = excluded.amount, updated_at = datetime('now') - `).run(req.user.id, parsed.year, parsed.month, label, amount); + `).run(req.user.id, parsed.year, parsed.month, label, toCents(amount)); res.json({ year: parsed.year, diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.js index 4d2cac0..0de2ea0 100644 --- a/services/amountSuggestionService.js +++ b/services/amountSuggestionService.js @@ -1,7 +1,7 @@ 'use strict'; const { accountingActiveSql } = require('./paymentAccountingService'); -const { roundMoney } = require('../utils/money'); +const { fromCents } = require('../utils/money'); /** * Computes a suggested expected amount for a bill based on the rolling median @@ -48,7 +48,7 @@ function computeAmountSuggestion(db, billId, year, month) { : sorted[mid]; return { - suggestion: roundMoney(median), + suggestion: fromCents(Math.round(median)), months_used: amounts.length, confidence: amounts.length >= 3 ? 'high' : 'low', }; diff --git a/services/analyticsService.js b/services/analyticsService.js index ef5886c..3944e8c 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.js @@ -2,7 +2,7 @@ const { getDb } = require('../db/database'); const { accountingActiveSql } = require('./paymentAccountingService'); -const { sumMoney } = require('../utils/money'); +const { sumMoney, fromCents } = require('../utils/money'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; @@ -183,7 +183,7 @@ function getAnalyticsSummary(userId, query = {}) { const monthly_spending = rangeMonths.map(m => { 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: fromCents(total) }; }).filter(row => row.total > 0); const expected_vs_actual = rangeMonths.map(m => { @@ -204,8 +204,8 @@ function getAnalyticsSummary(userId, query = {}) { return { month: m.key, label: m.label, - expected: Number(expected.toFixed(2)), - actual: Number(actual.toFixed(2)), + expected: fromCents(expected), + actual: fromCents(actual), skipped_count, }; }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0); @@ -225,7 +225,7 @@ function getAnalyticsSummary(userId, query = {}) { categoryMap.set(key, existing); } const category_spend = Array.from(categoryMap.values()) - .map(row => ({ ...row, total: Number(row.total.toFixed(2)) })) + .map(row => ({ ...row, total: fromCents(row.total) })) .filter(row => row.total > 0) .sort((a, b) => b.total - a.total); @@ -242,7 +242,7 @@ function getAnalyticsSummary(userId, query = {}) { month: m.key, label: m.label, status, - amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)), + amount_paid: fromCents(paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), }; }); return { diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index ac06a08..2353631 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -4,6 +4,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { getUserSettings } = require('./userSettings'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); const { localDateString } = require('../utils/dates'); +const { fromCents } = require('../utils/money'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. @@ -165,10 +166,13 @@ function applyMerchantRules(db, userId) { const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); if (!paidDate) continue; - const amount = Math.round(Math.abs(tx.amount)) / 100; + // tx.amount and payments.amount are both integer cents; keep a dollar + // copy only for the lateAttributions display payload below. + const amountCents = Math.round(Math.abs(tx.amount)); + const amount = fromCents(amountCents); const bill = getBill.get(rule.bill_id); - const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id); + const result = insertPayment.run(rule.bill_id, amountCents, paidDate, tx.id); if (result.changes > 0) { const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id); updateTx.run(rule.bill_id, tx.id, userId); @@ -294,10 +298,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { if (!matches) continue; const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); if (!paidDate) continue; - const amount = Math.round(Math.abs(tx.amount)) / 100; + // tx.amount and payments.amount are both integer cents; keep a dollar + // copy only for the lateAttributions display payload below. + const amountCents = Math.round(Math.abs(tx.amount)); + const amount = fromCents(amountCents); const bill = getBill.get(billId); - const result = insertPayment.run(billId, amount, paidDate, tx.id); + const result = insertPayment.run(billId, amountCents, paidDate, tx.id); if (result.changes > 0) { const inserted = getPaymentId.get(tx.id, billId); updateTx.run(billId, tx.id, userId); diff --git a/services/billsService.js b/services/billsService.js index 41c967d..a0363e1 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -1,5 +1,5 @@ const { monthKey } = require('../utils/dates'); -const { roundMoney, mulMoney } = require('../utils/money'); +const { toCents, fromCents } = require('../utils/money'); const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const TEMPLATE_FIELDS = [ @@ -52,6 +52,19 @@ function categoryBelongsToUser(db, categoryId, userId) { return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId); } +/** + * Converts a bill row's integer-cents money columns to dollars for API responses. + */ +function serializeBill(bill) { + if (!bill) return bill; + return { + ...bill, + expected_amount: fromCents(bill.expected_amount), + current_balance: fromCents(bill.current_balance), + minimum_payment: fromCents(bill.minimum_payment), + }; +} + function insertBill(db, userId, normalized) { const result = db.prepare(` INSERT INTO bills @@ -278,8 +291,8 @@ function validateBillData(data, existingBill = null) { // override_due_date normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null); - // expected_amount - normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0); + // expected_amount (stored as integer cents) + normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0); // interest_rate if (data.interest_rate !== undefined) { @@ -361,13 +374,13 @@ function validateBillData(data, existingBill = null) { // Calculate bucket based on due_day normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th'; - // current_balance — outstanding debt balance (nullable) + // current_balance — outstanding debt balance, stored as integer cents (nullable) if (data.current_balance !== undefined) { if (data.current_balance === null || data.current_balance === '') { normalized.current_balance = null; } else { - const cb = parseFloat(data.current_balance); - if (!Number.isFinite(cb) || cb < 0) { + const cb = toCents(data.current_balance); + if (!Number.isInteger(cb) || cb < 0) { errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' }); } else { normalized.current_balance = cb; @@ -377,13 +390,13 @@ function validateBillData(data, existingBill = null) { normalized.current_balance = existingBill?.current_balance ?? null; } - // minimum_payment — required minimum payment for debt (nullable) + // minimum_payment — required minimum payment for debt, stored as integer cents (nullable) if (data.minimum_payment !== undefined) { if (data.minimum_payment === null || data.minimum_payment === '') { normalized.minimum_payment = null; } else { - const mp = parseFloat(data.minimum_payment); - if (!Number.isFinite(mp) || mp < 0) { + const mp = toCents(data.minimum_payment); + if (!Number.isInteger(mp) || mp < 0) { errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' }); } else { normalized.minimum_payment = mp; @@ -473,20 +486,20 @@ function validateCycleDayOnly(cycleType, cycleDay) { // where interest_delta and interest_accrued_month are null when no interest // was charged this call (so callers can use COALESCE to leave the DB column alone). function computeBalanceDelta(bill, paymentAmount) { - const bal = Number(bill.current_balance); - const rate = Number(bill.interest_rate) || 0; - const amt = Number(paymentAmount); + const bal = Number(bill.current_balance); // cents + const rate = Number(bill.interest_rate) || 0; // percent + const amt = Number(paymentAmount); // cents if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null; const currentMonth = monthKey(); // "YYYY-MM" (local time) const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; - const interestDelta = applyInterest ? mulMoney(bal, rate / 100 / 12) : 0; + const interestDelta = applyInterest ? Math.round(bal * rate / 100 / 12) : 0; // cents - const raw = bal + interestDelta - amt; - const newBalance = roundMoney(Math.max(0, raw)); - const delta = roundMoney(newBalance - bal); + const raw = bal + interestDelta - amt; // cents, exact integer arithmetic + const newBalance = Math.max(0, raw); + const delta = newBalance - bal; return { new_balance: newBalance, @@ -519,6 +532,7 @@ module.exports = { getValidCycleTypes, getDefaultCycleDay, insertBill, + serializeBill, parseTemplateData, validateCycleDay, parseDueDay, diff --git a/services/calendarFeedService.js b/services/calendarFeedService.js index d51c3ff..9c0de87 100644 --- a/services/calendarFeedService.js +++ b/services/calendarFeedService.js @@ -3,6 +3,7 @@ const crypto = require('crypto'); const { getDb } = require('../db/database'); const { normalizeCycleType, resolveDueDate } = require('./statusService'); +const { fromCents } = require('../utils/money'); const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const FEED_PAST_MONTHS = 12; @@ -231,14 +232,14 @@ function eventUid(bill, dueDate) { function eventSummary(bill, detailLevel = 'standard') { if (detailLevel === 'private') return 'Bill due'; - if (detailLevel === 'full') return `${bill.name} due - $${Number(bill.expected_amount || 0).toFixed(2)}`; + if (detailLevel === 'full') return `${bill.name} due - $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`; return `${bill.name} due`; } function eventDescription(bill, dueDate, detailLevel = 'standard') { const lines = ['Bill Tracker reminder']; if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`); - if (detailLevel === 'full') lines.push(`Expected amount: $${Number(bill.expected_amount || 0).toFixed(2)}`); + if (detailLevel === 'full') lines.push(`Expected amount: $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`); lines.push(`Due date: ${dueDate}`); if (bill.category_name) lines.push(`Category: ${bill.category_name}`); if (bill.autopay_enabled) lines.push('Autopay: enabled'); @@ -333,7 +334,7 @@ function previewFeed(userId, options = {}, db = getDb()) { bill_id: event.bill.id, name: event.bill.name, due_date: event.dueDate, - amount: Number(event.bill.expected_amount || 0), + amount: fromCents(event.bill.expected_amount) || 0, cycle_type: normalizeCycleType(event.bill), category_name: event.bill.category_name || null, })); diff --git a/services/driftService.js b/services/driftService.js index 77a92c0..993895d 100644 --- a/services/driftService.js +++ b/services/driftService.js @@ -5,7 +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 { roundMoney, fromCents } = require('../utils/money'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; @@ -53,7 +53,8 @@ function getDriftReport(userId, now = new Date()) { `); for (const bill of bills) { - if (!bill.expected_amount || bill.expected_amount <= 0) continue; + const expectedAmount = fromCents(bill.expected_amount); + if (!expectedAmount || expectedAmount <= 0) continue; if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue; const monthTotals = []; @@ -74,15 +75,15 @@ function getDriftReport(userId, now = new Date()) { if (!range) continue; const { total } = payStmt.get(bill.id, range.start, range.end); - if (total > 0) monthTotals.push(total); + if (total > 0) monthTotals.push(fromCents(total)); } if (monthTotals.length < MIN_PAID_MONTHS) continue; const recentAmount = median(monthTotals); - const delta = recentAmount - bill.expected_amount; + const delta = recentAmount - expectedAmount; const absDelta = Math.abs(delta); - const driftPct = (delta / bill.expected_amount) * 100; + const driftPct = (delta / expectedAmount) * 100; if (absDelta < MIN_ABS_DELTA) continue; if (Math.abs(driftPct) < thresholdPct) continue; @@ -91,7 +92,7 @@ function getDriftReport(userId, now = new Date()) { id: bill.id, name: bill.name, category_name: bill.category_name ?? null, - expected_amount: bill.expected_amount, + expected_amount: expectedAmount, recent_amount: roundMoney(recentAmount), drift_pct: Math.round(driftPct * 10) / 10, direction: delta > 0 ? 'up' : 'down', diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index 1b58c7b..c0ee767 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -3,6 +3,7 @@ const { getDb } = require('../db/database'); const { getCycleRange, resolveDueDate } = require('./statusService'); const { decorateTransaction } = require('./transactionService'); +const { fromCents } = require('../utils/money'); function suggestionError(status, message, code, field = null) { const err = new Error(message); @@ -66,7 +67,7 @@ function amountDollars(transaction) { function addAmountScore(score, reasons, transaction, bill) { const txAmount = amountDollars(transaction); - const expected = Number(bill.expected_amount) || 0; + const expected = fromCents(bill.expected_amount) || 0; if (txAmount <= 0 || expected <= 0) return score; const delta = Math.abs(txAmount - expected); @@ -298,7 +299,7 @@ function listMatchSuggestions(userId, options = {}) { bill: { id: bill.id, name: bill.name, - expected_amount: bill.expected_amount, + expected_amount: fromCents(bill.expected_amount), due_day: bill.due_day, category_name: bill.category_name || null, }, diff --git a/services/notificationService.js b/services/notificationService.js index 0497c45..ea0bcb4 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -8,6 +8,7 @@ const { markNotificationTestSuccess, } = require('./statusRuntime'); const { localDateString } = require('../utils/dates'); +const { fromCents } = require('../utils/money'); // ── Push notification channels ──────────────────────────────────────────────── @@ -156,7 +157,7 @@ const URGENCY_COLOR = { function buildEmailHtml(bill, type, dueDate) { const meta = TYPE_META[type]; const color = URGENCY_COLOR[meta.urgency]; - const amount = '$' + Number(bill.expected_amount || 0).toFixed(2); + const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2); const fmt = (d) => { if (!d) return '—'; const [y, m, day] = d.split('-'); @@ -384,7 +385,7 @@ async function runNotifications() { const meta = TYPE_META[type]; const subject = meta.subject(bill); const urgency = meta.urgency; - const amount = '$' + Number(bill.expected_amount || 0).toFixed(2); + const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2); const pushBody = `${subject} · ${amount}`; let sent = false; diff --git a/services/paymentAccountingService.js b/services/paymentAccountingService.js index d14bdfd..4a1b8cc 100644 --- a/services/paymentAccountingService.js +++ b/services/paymentAccountingService.js @@ -2,7 +2,6 @@ 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']); @@ -40,7 +39,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, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta))); + const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic db.prepare(` UPDATE bills SET current_balance = ?, diff --git a/services/paymentValidation.js b/services/paymentValidation.js index a9519dd..2d93e96 100644 --- a/services/paymentValidation.js +++ b/services/paymentValidation.js @@ -1,5 +1,7 @@ 'use strict'; +const { toCents, fromCents } = require('../utils/money'); + function isPositiveIntegerString(value) { return /^\d+$/.test(String(value).trim()); } @@ -31,12 +33,29 @@ function validateIsoDate(value, field = 'paid_date') { return { value: trimmed }; } +/** + * Validates a positive dollar amount and converts it to integer cents + * (the unit `payments.amount` and related money columns are stored in). + */ function validatePositiveAmount(value, field = 'amount') { - const amount = Number(value); - if (!Number.isFinite(amount) || amount <= 0) { + const cents = toCents(value); + if (!Number.isInteger(cents) || cents <= 0) { return { error: `${field} must be a positive number` }; } - return { value: amount }; + return { value: cents }; +} + +/** + * Converts a payment row's cent columns (amount, balance_delta, interest_delta) + * to dollars for API responses. + */ +function serializePayment(payment) { + if (!payment) return payment; + const out = { ...payment }; + if (out.amount != null) out.amount = fromCents(out.amount); + if (out.balance_delta != null) out.balance_delta = fromCents(out.balance_delta); + if (out.interest_delta != null) out.interest_delta = fromCents(out.interest_delta); + return out; } const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync']; @@ -101,6 +120,7 @@ function validatePaymentInput(data, options = {}) { module.exports = { PAYMENT_SOURCES, + serializePayment, validateIsoDate, validatePaymentInput, validatePaymentSource, diff --git a/services/spendingService.js b/services/spendingService.js index 099c07e..45b48d0 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -2,6 +2,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates'); +const { toCents, fromCents } = require('../utils/money'); // Spending = unmatched outflows (amount < 0) that haven't been ignored. // Bill-matched transactions are excluded so there's no double-counting. @@ -56,7 +57,7 @@ function getSpendingSummary(db, userId, year, month) { category_name: r.category_name ?? '(Uncategorized)', amount: r.total_cents / 100, tx_count: r.tx_count, - budget: r.category_id ? (budgetMap.get(r.category_id) ?? null) : null, + budget: r.category_id ? fromCents(budgetMap.get(r.category_id) ?? null) : null, }; }); @@ -218,12 +219,13 @@ function merchantMatches(txMerchant, ruleMerchant) { // ── Budgets ────────────────────────────────────────────────────────────────── function getSpendingBudgets(db, userId, year, month) { - return db.prepare(` + const rows = db.prepare(` SELECT sb.category_id, sb.amount, c.name AS category_name FROM spending_budgets sb JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL WHERE sb.user_id=? AND sb.year=? AND sb.month=? `).all(userId, year, month); + return rows.map(r => ({ ...r, amount: fromCents(r.amount) })); } function setSpendingBudget(db, userId, categoryId, year, month, amount) { @@ -236,7 +238,7 @@ function setSpendingBudget(db, userId, categoryId, year, month, amount) { VALUES (?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET amount=excluded.amount, updated_at=datetime('now') - `).run(userId, categoryId, year, month, Number(amount)); + `).run(userId, categoryId, year, month, toCents(amount)); } } diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index 144bdff..73427a8 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -15,6 +15,7 @@ const xlsx = require('xlsx'); const crypto = require('crypto'); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); +const { toCents, fromCents } = require('../utils/money'); // ─── Constants ──────────────────────────────────────────────────────────────── @@ -556,7 +557,7 @@ function findBillMatches(detectedName, userBills) { bill_name: bill.name, category_id: bill.category_id ?? null, category: bill.category_name || null, - expected_amount: bill.expected_amount, + expected_amount: fromCents(bill.expected_amount), due_day: bill.due_day ?? null, match_confidence: scored.match_confidence, match_reason: scored.match_reason, @@ -1383,6 +1384,7 @@ function amountsEqual(a, b) { } function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) { + amount = toCents(amount); // incoming amount is dollars (decision/spreadsheet); column is cents const existing = db.prepare(` SELECT id, actual_amount, notes, is_skipped FROM monthly_bill_state @@ -1428,6 +1430,7 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverwrite) { if (!paidDate || amount == null || amount <= 0) return null; + amount = toCents(amount); // incoming amount is dollars (decision/spreadsheet); column is cents const dup = db.prepare(` SELECT id, created_at FROM payments @@ -1550,7 +1553,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv const ins = db.prepare(` INSERT INTO bills (user_id, name, category_id, due_day, bucket, expected_amount, billing_cycle, cycle_type, cycle_day, autopay_enabled, active) VALUES (?, ?, ?, ?, ?, ?, 'monthly', 'monthly', ?, ?, 1) - `).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, String(dueDay), autopay); + `).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', toCents(expectedAmount), String(dueDay), autopay); const newBillId = ins.lastInsertRowid; summary.created++; @@ -1660,10 +1663,13 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv return; } + // payAmount is dollars (decision/spreadsheet); payments columns are cents + const payAmountCents = toCents(payAmount); + const dup = db.prepare(` SELECT id, created_at, paid_date, amount FROM payments WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL - `).get(billId, payDate, payAmount); + `).get(billId, payDate, payAmountCents); if (dup && !allowOverwrite) { summary.skipped++; @@ -1675,17 +1681,17 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv note: 'Identical payment already exists', existing_created_at: dup.created_at ?? null, existing_paid_date: dup.paid_date ?? null, - existing_amount: dup.amount ?? null, + existing_amount: fromCents(dup.amount), }); return; } - const balCalcCp = computeBalanceDelta(bill, payAmount); + const balCalcCp = computeBalanceDelta(bill, payAmountCents); db.prepare(` INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import') - `).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null); + `).run(billId, payAmountCents, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null); applyBalanceDelta(db, billId, balCalcCp); diff --git a/services/statusService.js b/services/statusService.js index 5cc2182..95fb7d0 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -21,7 +21,8 @@ function pad(value) { return String(value).padStart(2, '0'); } -const { roundMoney, sumMoney } = require('../utils/money'); +const { fromCents } = require('../utils/money'); +const { serializePayment } = require('./paymentValidation'); function dateString(year, month, day) { return `${year}-${pad(month)}-${pad(day)}`; @@ -194,7 +195,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 = sumMoney(safePayments, p => p.amount); + const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0); if (totalPaid >= expectedAmount) return 'paid'; @@ -223,11 +224,11 @@ 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 = sumMoney(safePayments, p => p.amount); + const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0); const hasPayment = safePayments.length > 0; const isSettled = status === 'paid' || status === 'autodraft'; - const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount)); - const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0)); + const paidTowardDue = Math.min(totalPaid, expectedAmount); + const overpaidAmount = Math.max(totalPaid - expectedAmount, 0); const rawBalance = expectedAmount - totalPaid; const balance = isSettled ? 0 : Math.max(rawBalance, 0); const lastPayment = hasPayment @@ -242,16 +243,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { due_date: dueDate, due_day: bill.due_day, bucket, - expected_amount: expectedAmount, + expected_amount: fromCents(expectedAmount), notes: bill.notes || null, // Bill-level notes (always available) - total_paid: totalPaid, - paid_toward_due: paidTowardDue, - overpaid_amount: overpaidAmount, - balance, + total_paid: fromCents(totalPaid), + paid_toward_due: fromCents(paidTowardDue), + overpaid_amount: fromCents(overpaidAmount), + balance: fromCents(balance), has_payment: hasPayment, is_settled: isSettled, last_paid_date: lastPayment ? lastPayment.paid_date : null, - last_payment_amount: lastPayment ? lastPayment.amount : null, + last_payment_amount: lastPayment ? fromCents(lastPayment.amount) : null, status, autopay_enabled: !!bill.autopay_enabled, autodraft_status: bill.autodraft_status, @@ -259,8 +260,8 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { billing_cycle: bill.billing_cycle, cycle_type: normalizeCycleType(bill), cycle_day: bill.cycle_day, - current_balance: bill.current_balance ?? null, - minimum_payment: bill.minimum_payment ?? null, + current_balance: bill.current_balance != null ? fromCents(bill.current_balance) : null, + minimum_payment: bill.minimum_payment != null ? fromCents(bill.minimum_payment) : null, interest_rate: bill.interest_rate ?? null, is_subscription: !!bill.is_subscription, has_2fa: !!bill.has_2fa, @@ -272,7 +273,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { inactivated_at: bill.inactivated_at ?? null, sparkline: bill.sparkline ?? null, autopay_stats: bill.autopay_stats ?? null, - payments: safePayments, + payments: safePayments.map(serializePayment), }; } @@ -285,5 +286,4 @@ module.exports = { resolveBucket, resolveDueDate, resolveGracePeriodDays, - roundMoney, }; diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 23a77f6..e763d5f 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -2,7 +2,7 @@ const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { localDateString, todayLocal } = require('../utils/dates'); -const { roundMoney, sumMoney, mulMoney } = require('../utils/money'); +const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money'); const SUBSCRIPTION_TYPES = [ 'streaming', 'software', 'cloud', 'music', 'news', @@ -494,9 +494,13 @@ function nextDueDate(bill, now = new Date()) { } function decorateSubscription(bill) { - const monthly = monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle); + const expectedAmount = fromCents(bill.expected_amount); + const monthly = monthlyEquivalent(expectedAmount, bill.cycle_type, bill.billing_cycle); return { ...bill, + expected_amount: expectedAmount, + current_balance: fromCents(bill.current_balance), + minimum_payment: fromCents(bill.minimum_payment), is_subscription: !!bill.is_subscription, active: !!bill.active, monthly_equivalent: monthly, @@ -666,7 +670,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun if (score === 0) continue; - const expected = Number(bill.expected_amount || 0); + const expected = fromCents(bill.expected_amount) || 0; const amountDelta = expected ? Math.abs(expected - averageAmount) : null; if (amountDelta !== null) { const pct = expected ? amountDelta / expected : 1; @@ -1094,7 +1098,7 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) { db.transaction(() => { for (const tx of txRows) { const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); - const amount = Math.round(Math.abs(tx.amount)) / 100; + const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents updateTx.run(created.id, tx.id, userId); if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id); } diff --git a/services/trackerService.js b/services/trackerService.js index a4851df..7f852b7 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -1,14 +1,14 @@ 'use strict'; const { getDb } = require('../db/database'); -const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService'); +const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService'); const { getUserSettings } = require('./userSettings'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates'); -const { sumMoney } = require('../utils/money'); +const { sumMoney, roundMoney, fromCents } = require('../utils/money'); const DEFAULT_PENDING_DAYS = 3; @@ -116,9 +116,9 @@ function buildBankTracking(db, userId, year, month) { `).get(year, month, start, end, userId); const balance = roundMoney(account.balance / 100); - const pending = roundMoney(pendingRow.pending_total); + const pending = fromCents(pendingRow.pending_total); const effective = roundMoney(balance - pending); - const unpaid = roundMoney(unpaidRow.unpaid_total); + const unpaid = fromCents(unpaidRow.unpaid_total); return { enabled: true, @@ -250,7 +250,7 @@ function fetchSparklines(db, billIds) { const out = {}; for (const r of rows) { if (!out[r.bill_id]) out[r.bill_id] = []; - out[r.bill_id].push(r.total); + out[r.bill_id].push(fromCents(r.total)); } return out; } @@ -357,7 +357,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, if (dismissedSuggestions.has(bill.id)) return null; return { bill_id: bill.id, - amount: suggestedAmount, + amount: fromCents(suggestedAmount), paid_date: dueDate, method: 'autopay', }; @@ -389,7 +389,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) { year: date.getFullYear(), month: date.getMonth() + 1, key: monthKey, - payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0), + payment: fromCents(monthlyPaymentsMap.get(monthKey) || 0), }); } @@ -455,13 +455,13 @@ function getTracker(userId, query = {}, now = new Date()) { const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions); if (!row) return null; - row.expected_amount = bill.expected_amount; - row.actual_amount = mbs?.actual_amount ?? null; + row.expected_amount = fromCents(bill.expected_amount); + row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null; row.monthly_notes = mbs?.notes ?? null; row.is_skipped = !!(mbs?.is_skipped); row.snoozed_until = mbs?.snoozed_until ?? null; if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion; - row.previous_month_paid = prevMonthPayments[bill.id] || 0; + row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0); row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month); return row; }).filter(Boolean); @@ -476,6 +476,13 @@ function getTracker(userId, query = {}, now = new Date()) { WHERE user_id = ? AND year = ? AND month = ? `).get(userId, year, month); + if (startingAmounts) { + startingAmounts.first_amount = fromCents(startingAmounts.first_amount); + startingAmounts.fifteenth_amount = fromCents(startingAmounts.fifteenth_amount); + startingAmounts.other_amount = fromCents(startingAmounts.other_amount); + startingAmounts.combined_amount = fromCents(startingAmounts.combined_amount); + } + const dayOfMonth = now.getDate(); const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); @@ -634,7 +641,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) { name: bill.name, category_name: bill.category_name, due_date: dueDate, - expected_amount: bill.expected_amount, + expected_amount: row.expected_amount, status: row.status, days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000), }); diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index c86ffbb..fe6fe97 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -10,7 +10,7 @@ const { decorateTransaction, getTransactionForUser, } = require('./transactionService'); -const { roundMoney } = require('../utils/money'); +const { serializePayment } = require('./paymentValidation'); const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match'; @@ -76,7 +76,7 @@ function paymentAmountForTransaction(transaction) { 'amount', ); } - return Math.round(Math.abs(cents)) / 100; + return Math.round(Math.abs(cents)); // tx.amount and payments.amount are both cents } function getActivePaymentForTransaction(db, userId, transactionId) { @@ -109,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, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta))); + const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic // Clear interest_accrued_month when reversing a payment that charged interest, // so the re-applied payment can accrue interest fresh. db.prepare(` @@ -220,7 +220,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) { SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? `).run(existingPayment.id); - return { ...existingPayment, deleted: true }; + return { ...serializePayment(existingPayment), deleted: true }; } db.prepare(` @@ -228,14 +228,14 @@ function unlinkPaymentForTransaction(db, userId, transactionId) { SET transaction_id = NULL, updated_at = datetime('now') WHERE id = ? `).run(existingPayment.id); - return { ...existingPayment, unlinked: true }; + return { ...serializePayment(existingPayment), unlinked: true }; } function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) { return { success: true, transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)), - payment: getPaymentForResponse(db, userId, paymentId), + payment: serializePayment(getPaymentForResponse(db, userId, paymentId)), ...extra, }; } diff --git a/services/userDbImportService.js b/services/userDbImportService.js index 58b2713..8fc74d1 100644 --- a/services/userDbImportService.js +++ b/services/userDbImportService.js @@ -7,6 +7,7 @@ const path = require('path'); const Database = require('better-sqlite3'); const { getDb } = require('../db/database'); const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService'); +const { toCents } = require('../utils/money'); const MAX_SQLITE_BYTES = 50 * 1024 * 1024; const SESSION_TTL_HOURS = 24; @@ -131,7 +132,28 @@ function sanitizeCategory(row) { }; } -function sanitizeBill(row) { +/** + * Convert a money value from a source export to integer cents. + * Pre-v1.03 exports store dollars (REAL); post-v1.03 exports already store + * integer cents. Applying toCents() to a cents value would multiply it ×100, + * so the caller detects the source's unit via its schema_migrations table. + */ +function importMoney(value, sourceIsCents) { + if (value === null || value === undefined) return null; + return sourceIsCents ? Math.round(Number(value)) : toCents(value); +} + +/** True when the source DB already stores money in integer cents (v1.03+). */ +function sourceUsesCents(src) { + try { + if (!tableNames(src).has('schema_migrations')) return false; + return !!src.prepare("SELECT 1 FROM schema_migrations WHERE version = 'v1.03'").get(); + } catch { + return false; + } +} + +function sanitizeBill(row, sourceIsCents) { const name = cleanText(row.name, 160); const dueDay = toInt(row.due_day); if (!name || dueDay < 1 || dueDay > 31) return null; @@ -149,7 +171,7 @@ function sanitizeBill(row) { due_day: dueDay, override_due_date: cleanText(row.override_due_date, 32), bucket: dueDay <= 14 ? '1st' : '15th', - expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0), + expected_amount: importMoney(Math.max(0, toNumber(row.expected_amount, 0) ?? 0), sourceIsCents), interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate, billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType), cycle_type: normalizedCycleType, @@ -167,7 +189,7 @@ function sanitizeBill(row) { }; } -function sanitizePayment(row, validBillIds) { +function sanitizePayment(row, validBillIds, sourceIsCents) { const billId = toInt(row.bill_id); const amount = toNumber(row.amount); const paidDate = cleanDate(row.paid_date); @@ -176,7 +198,7 @@ function sanitizePayment(row, validBillIds) { return { old_id: toInt(row.id), bill_id: billId, - amount, + amount: importMoney(amount, sourceIsCents), paid_date: paidDate, method: cleanText(row.method, 120), notes: cleanText(row.notes, 2000), @@ -187,7 +209,7 @@ function sanitizePayment(row, validBillIds) { }; } -function sanitizeMonthlyState(row, validBillIds) { +function sanitizeMonthlyState(row, validBillIds, sourceIsCents) { const billId = toInt(row.bill_id); const year = toInt(row.year); const month = toInt(row.month); @@ -198,7 +220,7 @@ function sanitizeMonthlyState(row, validBillIds) { bill_id: billId, year, month, - actual_amount: actual == null || actual < 0 ? null : actual, + actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents), notes: cleanText(row.notes, 2000), is_skipped: toInt(row.is_skipped, 0) ? 1 : 0, created_at: cleanText(row.created_at, 32), @@ -206,7 +228,7 @@ function sanitizeMonthlyState(row, validBillIds) { }; } -function sanitizeMonthlyStartingAmounts(row) { +function sanitizeMonthlyStartingAmounts(row, sourceIsCents) { const year = toInt(row.year); const month = toInt(row.month); if (year < 2000 || year > 2100 || month < 1 || month > 12) return null; @@ -214,9 +236,9 @@ function sanitizeMonthlyStartingAmounts(row) { old_id: toInt(row.id), year, month, - first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0), - fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), - other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0), + first_amount: importMoney(Math.max(0, toNumber(row.first_amount, 0) ?? 0), sourceIsCents), + fifteenth_amount: importMoney(Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), sourceIsCents), + other_amount: importMoney(Math.max(0, toNumber(row.other_amount, 0) ?? 0), sourceIsCents), notes: cleanText(row.notes, 2000), created_at: cleanText(row.created_at, 32), updated_at: cleanText(row.updated_at, 32), @@ -234,23 +256,25 @@ function readExportData(src) { } const metadata = parseMetadata(src); + // Pre-v1.03 exports store money in dollars; v1.03+ exports store integer cents. + const sourceIsCents = sourceUsesCents(src); const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at']) .map(sanitizeCategory).filter(Boolean); const bills = selectKnown(src, 'bills', [ 'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate', 'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa', 'active', 'notes', 'created_at', 'updated_at', - ]).map(sanitizeBill).filter(Boolean); + ]).map(row => sanitizeBill(row, sourceIsCents)).filter(Boolean); const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean)); const payments = selectKnown(src, 'payments', [ 'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at', ]) - .map(row => sanitizePayment(row, validBillIds)).filter(Boolean); + .map(row => sanitizePayment(row, validBillIds, sourceIsCents)).filter(Boolean); const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at']) - .map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean); + .map(row => sanitizeMonthlyState(row, validBillIds, sourceIsCents)).filter(Boolean); const monthlyStartingAmounts = names.has('monthly_starting_amounts') ? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at']) - .map(sanitizeMonthlyStartingAmounts).filter(Boolean) + .map(row => sanitizeMonthlyStartingAmounts(row, sourceIsCents)).filter(Boolean) : []; const notes = names.has('notes') ? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes']) diff --git a/tests/billReorder.test.js b/tests/billReorder.test.js index 6ddf1b8..0b467ae 100644 --- a/tests/billReorder.test.js +++ b/tests/billReorder.test.js @@ -20,7 +20,7 @@ function createUser(db, suffix) { function createBill(db, userId, name, dueDay) { return db.prepare(` INSERT INTO bills (user_id, name, due_day, expected_amount) - VALUES (?, ?, ?, 25) + VALUES (?, ?, ?, 2500) `).run(userId, name, dueDay).lastInsertRowid; } diff --git a/tests/calendarFeedService.test.js b/tests/calendarFeedService.test.js index 82e63a6..171603d 100644 --- a/tests/calendarFeedService.test.js +++ b/tests/calendarFeedService.test.js @@ -36,7 +36,7 @@ function createBill(db, userId, overrides = {}) { userId, overrides.name || 'Water, Power; Internet', overrides.due_day || 15, - overrides.expected_amount || 123.45, + overrides.expected_amount || 12345, overrides.cycle_type || 'monthly', overrides.cycle_day || '1', overrides.billing_cycle || 'monthly', diff --git a/tests/statusService.test.js b/tests/statusService.test.js index b4a0ff9..c22a088 100644 --- a/tests/statusService.test.js +++ b/tests/statusService.test.js @@ -82,8 +82,8 @@ test('tracker rows are skipped when a bill does not occur in the requested month test('tracker rows cap due math when a payment exceeds the amount due', () => { const row = buildTrackerRow( - bill({ expected_amount: 100 }), - [{ amount: 125, paid_date: '2026-05-10' }], + bill({ expected_amount: 10000 }), + [{ amount: 12500, paid_date: '2026-05-10' }], 2026, 5, '2026-05-16', diff --git a/tests/subscriptionService.test.js b/tests/subscriptionService.test.js index 78752c8..7e6f8af 100644 --- a/tests/subscriptionService.test.js +++ b/tests/subscriptionService.test.js @@ -58,7 +58,7 @@ function createBill(db, userId, overrides = {}) { userId, overrides.name || 'Netflix', overrides.due_day || 8, - overrides.expected_amount ?? 15.99, + overrides.expected_amount ?? 1599, overrides.is_subscription ?? 1, overrides.cycle_type || 'monthly', overrides.billing_cycle || 'monthly', @@ -97,7 +97,7 @@ test('existing tracked bills are recommended for linking instead of tracking aga const billId = createBill(db, userId, { name: 'Netflix', due_day: 12, - expected_amount: 15.99, + expected_amount: 1599, is_subscription: 1, }); createTransaction(db, userId, { diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index 48d32a7..56ae10c 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -32,7 +32,7 @@ function createUser(db, suffix) { function createBill(db, userId, name = 'City Water') { return db.prepare(` INSERT INTO bills (user_id, name, due_day, expected_amount) - VALUES (?, ?, 16, 85) + VALUES (?, ?, 16, 8500) `).run(userId, name).lastInsertRowid; } @@ -68,7 +68,7 @@ function createManualPayment(db, billId, overrides = {}) { VALUES (?, ?, ?, ?, ?, ?) `).run( billId, - overrides.amount ?? 85, + overrides.amount ?? 8500, overrides.paid_date || '2026-05-16', overrides.method || 'manual', overrides.payment_source || 'manual', @@ -237,7 +237,7 @@ test('transaction match payments cannot be edited, deleted, or restored through assert.equal(updateRes.status, 409); let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id); - assert.equal(payment.amount, 85); + assert.equal(payment.amount, 8500); assert.equal(payment.paid_date, '2026-05-16'); assert.equal(payment.method, 'transaction_match'); assert.equal(payment.payment_source, 'transaction_match'); @@ -387,7 +387,7 @@ test('manual payment history remains visible and suppresses duplicate suggestion const userId = createUser(db, 'manual-history'); const billId = createBill(db, userId, 'Internet'); const manualPaymentId = createManualPayment(db, billId, { - amount: 65, + amount: 6500, notes: 'Paid from checking', }); const transactionId = createTransaction(db, userId, { @@ -427,7 +427,7 @@ test('bank-backed match overrides same-cycle manual tracker payment but keeps it const userId = createUser(db, 'bank-override'); const billId = createBill(db, userId, 'Internet Override'); const manualPaymentId = createManualPayment(db, billId, { - amount: 85, + amount: 8500, notes: 'Marked paid while waiting for bank clear', }); const transactionId = createTransaction(db, userId, {