feat(money): cents migration stage 2 — schema flip to integer cents (batch 0.38.4)

This commit is contained in:
null 2026-06-11 20:12:31 -05:00
parent bf66ab1ee6
commit d6639f1385
33 changed files with 430 additions and 229 deletions

View File

@ -3367,6 +3367,46 @@ function runMigrations() {
console.log('[v1.02] users.geolocation_enabled added'); 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 ─────────────────────────────────────────── // ── users: notification columns ───────────────────────────────────────────
@ -3711,6 +3751,33 @@ function getDbPath() {
// Rollback SQL definitions // Rollback SQL definitions
const ROLLBACK_SQL_MAP = { 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': { 'v0.98': {
description: 'payments: bank override metadata for provisional manual payments', description: 'payments: bank override metadata for provisional manual payments',
sql: [ sql: [

View File

@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS bills (
due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31), due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31),
override_due_date TEXT, override_due_date TEXT,
bucket TEXT CHECK(bucket IN ('1st', '15th')), 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)), 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')), 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')), 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, account_info TEXT,
has_2fa INTEGER NOT NULL DEFAULT 0, has_2fa INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
current_balance REAL, current_balance INTEGER, -- cents
minimum_payment REAL, minimum_payment INTEGER, -- cents
snowball_order INTEGER, snowball_order INTEGER,
sort_order INTEGER, sort_order INTEGER,
snowball_include INTEGER NOT NULL DEFAULT 0, snowball_include INTEGER NOT NULL DEFAULT 0,
@ -53,11 +53,11 @@ CREATE TABLE IF NOT EXISTS bills (
CREATE TABLE IF NOT EXISTS payments ( CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, 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, paid_date TEXT NOT NULL,
method TEXT, method TEXT,
notes TEXT, notes TEXT,
balance_delta REAL, balance_delta INTEGER, -- cents
payment_source TEXT NOT NULL DEFAULT 'manual', payment_source TEXT NOT NULL DEFAULT 'manual',
transaction_id INTEGER, transaction_id INTEGER,
accounting_excluded INTEGER NOT NULL DEFAULT 0, 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, is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1, 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, notify_amount_change INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_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, bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100), year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12), 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 notes TEXT, -- month-specific notes, NULL = no notes
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only 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 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')), started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused_at TEXT, paused_at TEXT,
completed_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, plan_snapshot TEXT NOT NULL,
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),

View File

@ -5,6 +5,7 @@ const {
auditBillsForUser, auditBillsForUser,
categoryBelongsToUser, categoryBelongsToUser,
insertBill, insertBill,
serializeBill,
parseTemplateData, parseTemplateData,
sanitizeTemplateData, sanitizeTemplateData,
validateBillData, validateBillData,
@ -13,7 +14,7 @@ const {
} = require('../services/billsService'); } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
const { normalizeMerchant } = require('../services/subscriptionService'); const { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService'); const { decorateTransaction } = require('../services/transactionService');
@ -22,7 +23,7 @@ const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService');
const { localDateString, todayLocal } = require('../utils/dates'); const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney } = require('../utils/money'); const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -45,7 +46,7 @@ router.get('/', (req, res) => {
${includeInactive ? '' : 'AND b.active = 1'} ${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 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); `).all(req.user.id);
res.json(bills); res.json(bills.map(serializeBill));
}); });
// ── PUT /api/bills/reorder ─────────────────────────────────────────────────── // ── 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 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); `).all(req.user.id);
res.json({ success: true, bills }); res.json({ success: true, bills: bills.map(serializeBill) });
}); });
// ── GET /api/bills/audit?inactive=true ─────────────────────────────────────── // ── 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 }); 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 ───────────────────────────────────────────────── // ── GET /api/bills/templates ─────────────────────────────────────────────────
router.get('/templates', (req, res) => { router.get('/templates', (req, res) => {
const db = getDb(); const db = getDb();
@ -158,7 +171,7 @@ router.get('/templates', (req, res) => {
res.json(rows.map(row => ({ res.json(rows.map(row => ({
...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({ res.status(result.changes > 0 ? 201 : 200).json({
...template, ...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')); if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = { const draft = {
...sanitizeTemplateData(source), ...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body), ...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(), 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')); 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= ───────────────────────────── // ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
@ -267,7 +280,7 @@ router.get('/:id/monthly-state', (req, res) => {
bill_id: billId, bill_id: billId,
year, year,
month, month,
actual_amount: mbs?.actual_amount ?? null, actual_amount: fromCents(mbs?.actual_amount),
notes: mbs?.notes ?? null, notes: mbs?.notes ?? null,
is_skipped: !!(mbs?.is_skipped), 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=?' 'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
).get(billId, y, m); ).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 noteVal = notes !== undefined ? (notes || null) : (existing?.notes ?? null);
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : (existing?.is_skipped ?? 0); 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); 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, bill_id: saved.bill_id,
year: saved.year, year: saved.year,
month: saved.month, month: saved.month,
actual_amount: saved.actual_amount, actual_amount: fromCents(saved.actual_amount),
notes: saved.notes, notes: saved.notes,
is_skipped: !!saved.is_skipped, is_skipped: !!saved.is_skipped,
snoozed_until: saved.snoozed_until ?? null, 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 ─────────────────────────────────────── // ── 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); 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')); if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = { payload = {
...sanitizeTemplateData(source), ...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body), ...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(), 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')); 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 ──────────────────────────────────────────────────────── // ── 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); 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 ────────────────────────────────────────────── // ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
@ -526,7 +539,7 @@ router.put('/:id/archived', (req, res) => {
.run(archived ? 0 : 1, id, req.user.id); .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); 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 ─────────────────── // ── 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 = ?") 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); .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 // POST /api/bills/:id/sync-simplefin-payments
@ -600,7 +613,7 @@ router.get('/:id/payments', (req, res) => {
page, page,
limit, limit,
pages: Math.ceil(total / limit), pages: Math.ceil(total / limit),
payments: items, payments: items.map(serializePayment),
}); });
}); });
@ -647,7 +660,7 @@ router.get('/:id/transactions', (req, res) => {
...row, ...row,
linked_payment: row.linked_payment_id ? { linked_payment: row.linked_payment_id ? {
id: 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, paid_date: row.linked_payment_date,
payment_source: row.linked_payment_source, payment_source: row.linked_payment_source,
method: row.linked_payment_method, method: row.linked_payment_method,
@ -702,7 +715,7 @@ router.post('/:id/toggle-paid', (req, res) => {
if (currentPayment.balance_delta != null) { if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId); const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) { if (freshBill?.current_balance != null) {
const restored = Math.max(0, 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); 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 // If unpaid, create payment → Paid
// Use expected_amount if no amount provided // 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 // Determine paid_date
let paidDate = req.body.paid_date; let paidDate = req.body.paid_date;
@ -755,7 +768,7 @@ router.post('/:id/toggle-paid', (req, res) => {
success: true, success: true,
isPaid: true, isPaid: true,
action: 'created_payment', 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); 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')); 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 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?" // Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt; let payment = minPmt;
@ -912,7 +925,7 @@ router.get('/:id/amortization', (req, res) => {
} }
const schedule = amortizationSchedule(balance, apr, payment, maxMonths); 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); const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
res.json({ res.json({
@ -968,7 +981,7 @@ router.patch('/:id/balance', (req, res) => {
val = roundMoney(val); 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 }); 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); const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue; 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 billRow = getBill.get(billId);
const result = insertPayment.run(billId, amount, paidDate, txId); const result = insertPayment.run(billId, amountCents, paidDate, txId);
if (result.changes > 0) { 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); 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); updateTx.run(billId, txId);

View File

@ -14,7 +14,7 @@ const {
revokeToken, revokeToken,
} = require('../services/calendarFeedService'); } = require('../services/calendarFeedService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { roundMoney, sumMoney } = require('../utils/money'); const { roundMoney, sumMoney, fromCents } = require('../utils/money');
function clampDay(year, month, day) { function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();
@ -161,16 +161,17 @@ router.get('/', (req, res) => {
for (const payment of payments) { for (const payment of payments) {
const day = dayByDate.get(payment.paid_date); const day = dayByDate.get(payment.paid_date);
if (day) { if (day) {
const amount = fromCents(payment.amount);
day.payments.push({ day.payments.push({
payment_id: payment.payment_id, payment_id: payment.payment_id,
bill_id: payment.bill_id, bill_id: payment.bill_id,
bill_name: payment.bill_name, bill_name: payment.bill_name,
amount: payment.amount, amount,
paid_date: payment.paid_date, paid_date: payment.paid_date,
method: payment.method || null, method: payment.method || null,
notes: payment.notes || 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; if (!row) return null;
const monthlyState = monthlyStateStmt.get(bill.id, year, month); 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 isSkipped = !!monthlyState?.is_skipped;
const effectiveAmount = actualAmount ?? row.expected_amount; const effectiveAmount = actualAmount ?? row.expected_amount;
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;

View File

@ -7,6 +7,7 @@ const fs = require('fs');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const xlsx = require('xlsx'); const xlsx = require('xlsx');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { fromCents } = require('../utils/money');
// GET /api/export?year=2026&format=csv // GET /api/export?year=2026&format=csv
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -58,11 +59,11 @@ router.get('/', (req, res) => {
r.paid_date, r.paid_date,
escCsv(r.bill_name), escCsv(r.bill_name),
escCsv(r.category), escCsv(r.category),
r.expected_amount.toFixed(2), fromCents(r.expected_amount).toFixed(2),
r.paid_amount.toFixed(2), fromCents(r.paid_amount).toFixed(2),
escCsv(r.method), escCsv(r.method),
escCsv(r.notes), 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), escCsv(mbs?.notes ?? null),
].join(','); ].join(',');
}).join('\n'); }).join('\n');
@ -79,7 +80,9 @@ router.get('/', (req, res) => {
const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth); const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth);
return { return {
...r, ...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, monthly_notes: mbs?.notes ?? null,
}; };
}); });
@ -96,7 +99,7 @@ function getUserExportData(userId) {
FROM bills FROM bills
WHERE user_id = ? AND deleted_at IS NULL WHERE user_id = ? AND deleted_at IS NULL
ORDER BY active DESC, due_day ASC, name ASC 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(` const payments = db.prepare(`
SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, 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, 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 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 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 ORDER BY p.paid_date ASC, p.id ASC
`).all(userId); `).all(userId).map(p => ({ ...p, amount: fromCents(p.amount) }));
const monthlyState = db.prepare(` 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 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 FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ? AND b.deleted_at IS NULL WHERE b.user_id = ? AND b.deleted_at IS NULL
ORDER BY m.year, m.month, m.bill_id 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(` const monthlyStartingAmounts = db.prepare(`
SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
FROM monthly_starting_amounts FROM monthly_starting_amounts
WHERE user_id = ? WHERE user_id = ?
ORDER BY year, month 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(` const historyRanges = db.prepare(`
SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at
FROM bill_history_ranges FROM bill_history_ranges

View File

@ -7,6 +7,7 @@ const {
rejectMatchSuggestion, rejectMatchSuggestion,
} = require('../services/matchSuggestionService'); } = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
const { serializePayment } = require('../services/paymentValidation');
const { todayLocal } = require('../utils/dates'); const { todayLocal } = require('../utils/dates');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { 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')); 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 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 { try {
db.exec('BEGIN'); 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 LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL
WHERE t.id = ? WHERE t.id = ?
`).get(txId); `).get(txId);
res.json({ transaction: updated, payment }); res.json({ transaction: updated, payment: serializePayment(payment) });
} catch (err) { } catch (err) {
try { db.exec('ROLLBACK'); } catch {} try { db.exec('ROLLBACK'); } catch {}
return sendMatchError(res, err, 'Failed to confirm match'); return sendMatchError(res, err, 'Failed to confirm match');

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService'); const { getCycleRange } = require('../services/statusService');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService');
const { toCents, fromCents } = require('../utils/money');
function parseYearMonth(source) { function parseYearMonth(source) {
const now = new Date(); const now = new Date();
@ -32,9 +33,9 @@ function getStartingAmounts(db, userId, year, month) {
`).get(userId, year, month); `).get(userId, year, month);
return { return {
first_amount: money(row?.first_amount || 0), first_amount: fromCents(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0), fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0), other_amount: fromCents(row?.other_amount || 0),
}; };
} }
@ -88,10 +89,10 @@ function calculatePaidDeductions(db, userId, year, month) {
`).get(userId, start, end); `).get(userId, start, end);
return { return {
paid_from_first: money(firstPaid.paid), paid_from_first: fromCents(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid), paid_from_fifteenth: fromCents(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid), paid_from_other: fromCents(otherPaid.paid),
paid_total: money(totalPaid.paid), paid_total: fromCents(totalPaid.paid),
}; };
} }
@ -156,7 +157,7 @@ router.put('/', (req, res) => {
fifteenth_amount = excluded.fifteenth_amount, fifteenth_amount = excluded.fifteenth_amount,
other_amount = excluded.other_amount, other_amount = excluded.other_amount,
updated_at = datetime('now') 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)); res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
}); });

View File

@ -3,13 +3,14 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getCycleRange, resolveDueDate } = require('../services/statusService');
const { const {
markProvisionalManualPaymentsOverridden, markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService');
const { todayLocal } = require('../utils/dates'); const { todayLocal } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied. // 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 // 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) { if (!dueDate) {
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 }; 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 }; return { bill, dueDate, amount };
} }
@ -107,7 +108,7 @@ router.get('/', (req, res) => {
} }
query += ' ORDER BY p.paid_date DESC'; 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 // 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 ORDER BY p.created_at DESC
LIMIT 50 LIMIT 50
`).all(req.user.id); `).all(req.user.id);
res.json(rows); res.json(rows.map(serializePayment));
}); });
// GET /api/payments/:id // GET /api/payments/:id
@ -138,7 +139,7 @@ router.get('/:id', (req, res) => {
const db = getDb(); 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); 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')); 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 // 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) { if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) { if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100); const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
db.prepare(` db.prepare(`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,
@ -212,7 +213,7 @@ router.post('/', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc); 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) // POST /api/payments/quick — pay a bill (expected amount, today)
@ -230,7 +231,7 @@ router.post('/quick', (req, res) => {
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ {
amount: amount != null ? amount : bill.expected_amount, amount: amount != null ? amount : fromCents(bill.expected_amount),
paid_date: paid_date || todayLocal(), paid_date: paid_date || todayLocal(),
payment_source: payment_source ?? 'manual', payment_source: payment_source ?? 'manual',
}, },
@ -251,7 +252,7 @@ router.post('/quick', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc); 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 // POST /api/payments/autopay-suggestions/:billId/confirm
@ -296,7 +297,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
if (existing) { if (existing) {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') 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); .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); 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 = ?') 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); .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 // 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) // Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt); const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
if (isDuplicate) { if (isDuplicate) {
skipped.push({ bill_id, paid_date, amount: parsedAmt }); skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) });
continue; 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); 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); 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; const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
let restoredBalance = bill.current_balance; let restoredBalance = bill.current_balance;
if (paymentPortion != null && bill.current_balance != null) { 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 // 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, 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) // 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) { 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); 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) { 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(` db.prepare(`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,
@ -543,7 +544,7 @@ router.post('/:id/restore', (req, res) => {
if (!payment.accounting_excluded && payment.balance_delta != null) { if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); 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) { 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; const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
db.prepare(` db.prepare(`
UPDATE bills 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); 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 // 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 }); 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) { } catch (err) {
console.error('[payments] attribute-to-month error:', err.message); console.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR')); res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));

View File

@ -4,6 +4,8 @@ const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
const { serializeBill } = require('../services/billsService');
const { toCents, fromCents } = require('../utils/money');
const DEBT_LIKE_CLAUSES = `( const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1 b.snowball_include = 1
@ -84,7 +86,7 @@ function getDebtBills(userId, ramseyMode) {
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => { router.get('/', (req, res) => {
const ramseyMode = isRamseyMode(req.user.id); 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 // GET /api/snowball/settings — extra monthly payment for this user
@ -92,7 +94,7 @@ router.get('/settings', (req, res) => {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({ res.json({
extra_payment: user?.snowball_extra_payment ?? 0, extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
ramsey_mode: isRamseyMode(req.user.id), ramsey_mode: isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'), ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'), ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
@ -118,7 +120,7 @@ router.patch('/settings', (req, res) => {
const db = getDb(); const db = getDb();
const save = db.transaction(() => { const save = db.transaction(() => {
if (extra_payment !== undefined) { 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) { 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); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({ 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 // 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), ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'), 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 bills = getDebtBills(req.user.id, ramseyMode);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); 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 // 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. // 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 queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN;
const extra = Number.isFinite(queryExtra) && queryExtra >= 0 const extra = Number.isFinite(queryExtra) && queryExtra >= 0
? queryExtra ? 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) // Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {}; const aprByBill = {};
for (const b of bills) { for (const b of billsForMath) {
const snap = debtAprSnapshot(b); const snap = debtAprSnapshot(b);
if (snap) aprByBill[b.id] = snap; if (snap) aprByBill[b.id] = snap;
} }
@ -180,9 +190,9 @@ router.get('/projection', (req, res) => {
} }
const now = new Date(); const now = new Date();
const snowball = enrich(calculateSnowball(bills, extra, now)); const snowball = enrich(calculateSnowball(billsForMath, extra, now));
const avalanche = enrich(calculateAvalanche(bills, extra, now)); const avalanche = enrich(calculateAvalanche(billsForMath, extra, now));
const minimum_only = enrich(calculateMinimumOnly(bills, now)); const minimum_only = enrich(calculateMinimumOnly(billsForMath, now));
// Comparison: what does the snowball save vs just paying minimums? // Comparison: what does the snowball save vs just paying minimums?
const comparison = buildComparison(snowball, minimum_only); const comparison = buildComparison(snowball, minimum_only);
@ -270,7 +280,7 @@ function enrichPlanWithProgress(db, plan) {
const currentDebts = (snapshot?.debts ?? []).map(d => { 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 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 startingBalance = d.starting_balance ?? 0;
const progressPct = startingBalance > 0 && currentBalance !== null const progressPct = startingBalance > 0 && currentBalance !== null
? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100))) ? 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 startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now();
const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30)); 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 // 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.' }); return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' });
} }
// 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 user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extra = user?.snowball_extra_payment ?? 0; const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date(); const now = new Date();
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now); const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debts, 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 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); const proj = snowball.debts?.find(d => d.id === b.id);
return { return {
bill_id: b.id, bill_id: b.id,
@ -342,7 +361,7 @@ router.post('/plans', (req, res) => {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) 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')) 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); const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan)); res.status(201).json(enrichPlanWithProgress(db, plan));

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { fromCents } = require('../utils/money');
const { const {
createSubscriptionFromRecommendation, createSubscriptionFromRecommendation,
declineRecommendation, declineRecommendation,
@ -105,7 +106,7 @@ router.post('/recommendations/match-bill', (req, res) => {
db.transaction(() => { db.transaction(() => {
for (const tx of txRows) { for (const tx of txRows) {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); 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; matchedCount += updateTx.run(billId, tx.id, req.user.id).changes;
if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id); if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id);
} }
@ -213,9 +214,9 @@ router.get('/catalog', (req, res) => {
matched_bill: bill ? { matched_bill: bill ? {
id: bill.id, id: bill.id,
name: bill.name, name: bill.name,
expected_amount: bill.expected_amount, expected_amount: fromCents(bill.expected_amount),
active: !!bill.active, 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, } : null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [], user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
}; };

View File

@ -4,6 +4,7 @@ const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService'); const { getCycleRange } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings'); const { getUserSettings } = require('../services/userSettings');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService');
const { toCents, fromCents } = require('../utils/money');
const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -74,9 +75,9 @@ function buildBankTrackingSummary(db, userId, year, month) {
`).get(year, month, start, end, userId); `).get(year, month, start, end, userId);
const balanceDollars = money(account.balance / 100); const balanceDollars = money(account.balance / 100);
const pendingDollars = money(pendingRow.pending_total); const pendingDollars = fromCents(pendingRow.pending_total);
const effectiveDollars = money(balanceDollars - pendingDollars); const effectiveDollars = money(balanceDollars - pendingDollars);
const unpaidDollars = money(unpaidRow.unpaid_total); const unpaidDollars = fromCents(unpaidRow.unpaid_total);
return { return {
enabled: true, enabled: true,
@ -127,9 +128,9 @@ function getStartingAmounts(db, userId, year, month) {
`).get(userId, year, month); `).get(userId, year, month);
return { return {
first_amount: money(row?.first_amount || 0), first_amount: fromCents(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0), fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0), other_amount: fromCents(row?.other_amount || 0),
}; };
} }
@ -187,10 +188,10 @@ function calculatePaidDeductions(db, userId, year, month) {
`).get(userId, start, end); `).get(userId, start, end);
return { return {
paid_from_first: money(firstPaid.paid), paid_from_first: fromCents(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid), paid_from_fifteenth: fromCents(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid), paid_from_other: fromCents(otherPaid.paid),
paid_total: money(totalPaid.paid), paid_total: fromCents(totalPaid.paid),
}; };
} }
@ -229,7 +230,7 @@ function getIncome(db, userId, year, month) {
return { return {
id: row?.id || null, id: row?.id || null,
label: row?.label || DEFAULT_INCOME_LABEL, 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) { for (const row of payments) {
paymentMap.set(row.bill_id, { paymentMap.set(row.bill_id, {
payment_count: row.payment_count || 0, 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 expenses = billRows.map(row => {
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 }; const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined; 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); const paidAmount = money(payment.paid_amount);
return { return {
bill_id: row.bill_id, bill_id: row.bill_id,
name: row.name, name: row.name,
expected_amount: money(row.expected_amount), expected_amount: fromCents(row.expected_amount),
actual_amount: hasActual ? money(row.actual_amount) : null, actual_amount: hasActual ? fromCents(row.actual_amount) : null,
display_amount: displayAmount, display_amount: displayAmount,
is_paid: payment.payment_count > 0, is_paid: payment.payment_count > 0,
paid_amount: paidAmount, paid_amount: paidAmount,
@ -407,7 +408,7 @@ router.put('/income', (req, res) => {
label = excluded.label, label = excluded.label,
amount = excluded.amount, amount = excluded.amount,
updated_at = datetime('now') 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({ res.json({
year: parsed.year, year: parsed.year,

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
const { accountingActiveSql } = require('./paymentAccountingService'); 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 * 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]; : sorted[mid];
return { return {
suggestion: roundMoney(median), suggestion: fromCents(Math.round(median)),
months_used: amounts.length, months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low', confidence: amounts.length >= 3 ? 'high' : 'low',
}; };

View File

@ -2,7 +2,7 @@
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { sumMoney } = require('../utils/money'); const { sumMoney, fromCents } = require('../utils/money');
function parseInteger(value, fallback) { function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return fallback; if (value === undefined || value === null || value === '') return fallback;
@ -183,7 +183,7 @@ function getAnalyticsSummary(userId, query = {}) {
const monthly_spending = rangeMonths.map(m => { const monthly_spending = rangeMonths.map(m => {
const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0); const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0);
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) }; return { month: m.key, label: m.label, total: fromCents(total) };
}).filter(row => row.total > 0); }).filter(row => row.total > 0);
const expected_vs_actual = rangeMonths.map(m => { const expected_vs_actual = rangeMonths.map(m => {
@ -204,8 +204,8 @@ function getAnalyticsSummary(userId, query = {}) {
return { return {
month: m.key, month: m.key,
label: m.label, label: m.label,
expected: Number(expected.toFixed(2)), expected: fromCents(expected),
actual: Number(actual.toFixed(2)), actual: fromCents(actual),
skipped_count, skipped_count,
}; };
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0); }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
@ -225,7 +225,7 @@ function getAnalyticsSummary(userId, query = {}) {
categoryMap.set(key, existing); categoryMap.set(key, existing);
} }
const category_spend = Array.from(categoryMap.values()) 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) .filter(row => row.total > 0)
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
@ -242,7 +242,7 @@ function getAnalyticsSummary(userId, query = {}) {
month: m.key, month: m.key,
label: m.label, label: m.label,
status, status,
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)), amount_paid: fromCents(paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0),
}; };
}); });
return { return {

View File

@ -4,6 +4,7 @@ const { normalizeMerchant } = require('./subscriptionService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// Word-boundary merchant match — requires the rule to appear as complete word(s) // 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. // 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); const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue; 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 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) { 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); 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); updateTx.run(rule.bill_id, tx.id, userId);
@ -294,10 +298,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (!matches) continue; if (!matches) continue;
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue; 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 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) { if (result.changes > 0) {
const inserted = getPaymentId.get(tx.id, billId); const inserted = getPaymentId.get(tx.id, billId);
updateTx.run(billId, tx.id, userId); updateTx.run(billId, tx.id, userId);

View File

@ -1,5 +1,5 @@
const { monthKey } = require('../utils/dates'); 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 VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [ 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); 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) { function insertBill(db, userId, normalized) {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO bills INSERT INTO bills
@ -278,8 +291,8 @@ function validateBillData(data, existingBill = null) {
// override_due_date // override_due_date
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null); normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
// expected_amount // expected_amount (stored as integer cents)
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0); normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
// interest_rate // interest_rate
if (data.interest_rate !== undefined) { if (data.interest_rate !== undefined) {
@ -361,13 +374,13 @@ function validateBillData(data, existingBill = null) {
// Calculate bucket based on due_day // Calculate bucket based on due_day
normalized.bucket = normalized.due_day <= 14 ? '1st' : '15th'; 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 !== undefined) {
if (data.current_balance === null || data.current_balance === '') { if (data.current_balance === null || data.current_balance === '') {
normalized.current_balance = null; normalized.current_balance = null;
} else { } else {
const cb = parseFloat(data.current_balance); const cb = toCents(data.current_balance);
if (!Number.isFinite(cb) || cb < 0) { if (!Number.isInteger(cb) || cb < 0) {
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' }); errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
} else { } else {
normalized.current_balance = cb; normalized.current_balance = cb;
@ -377,13 +390,13 @@ function validateBillData(data, existingBill = null) {
normalized.current_balance = existingBill?.current_balance ?? 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 !== undefined) {
if (data.minimum_payment === null || data.minimum_payment === '') { if (data.minimum_payment === null || data.minimum_payment === '') {
normalized.minimum_payment = null; normalized.minimum_payment = null;
} else { } else {
const mp = parseFloat(data.minimum_payment); const mp = toCents(data.minimum_payment);
if (!Number.isFinite(mp) || mp < 0) { if (!Number.isInteger(mp) || mp < 0) {
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' }); errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
} else { } else {
normalized.minimum_payment = mp; normalized.minimum_payment = mp;
@ -473,20 +486,20 @@ function validateCycleDayOnly(cycleType, cycleDay) {
// where interest_delta and interest_accrued_month are null when no interest // 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). // was charged this call (so callers can use COALESCE to leave the DB column alone).
function computeBalanceDelta(bill, paymentAmount) { function computeBalanceDelta(bill, paymentAmount) {
const bal = Number(bill.current_balance); const bal = Number(bill.current_balance); // cents
const rate = Number(bill.interest_rate) || 0; const rate = Number(bill.interest_rate) || 0; // percent
const amt = Number(paymentAmount); const amt = Number(paymentAmount); // cents
if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(bal) || bal <= 0) return null;
if (!Number.isFinite(amt) || amt <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null;
const currentMonth = monthKey(); // "YYYY-MM" (local time) const currentMonth = monthKey(); // "YYYY-MM" (local time)
const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth;
const interestDelta = applyInterest ? mulMoney(bal, rate / 100 / 12) : 0; const interestDelta = applyInterest ? Math.round(bal * rate / 100 / 12) : 0; // cents
const raw = bal + interestDelta - amt; const raw = bal + interestDelta - amt; // cents, exact integer arithmetic
const newBalance = roundMoney(Math.max(0, raw)); const newBalance = Math.max(0, raw);
const delta = roundMoney(newBalance - bal); const delta = newBalance - bal;
return { return {
new_balance: newBalance, new_balance: newBalance,
@ -519,6 +532,7 @@ module.exports = {
getValidCycleTypes, getValidCycleTypes,
getDefaultCycleDay, getDefaultCycleDay,
insertBill, insertBill,
serializeBill,
parseTemplateData, parseTemplateData,
validateCycleDay, validateCycleDay,
parseDueDay, parseDueDay,

View File

@ -3,6 +3,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { normalizeCycleType, resolveDueDate } = require('./statusService'); const { normalizeCycleType, resolveDueDate } = require('./statusService');
const { fromCents } = require('../utils/money');
const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const PRODID = '-//Bill Tracker//Calendar Feed//EN';
const FEED_PAST_MONTHS = 12; const FEED_PAST_MONTHS = 12;
@ -231,14 +232,14 @@ function eventUid(bill, dueDate) {
function eventSummary(bill, detailLevel = 'standard') { function eventSummary(bill, detailLevel = 'standard') {
if (detailLevel === 'private') return 'Bill due'; 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`; return `${bill.name} due`;
} }
function eventDescription(bill, dueDate, detailLevel = 'standard') { function eventDescription(bill, dueDate, detailLevel = 'standard') {
const lines = ['Bill Tracker reminder']; const lines = ['Bill Tracker reminder'];
if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`); 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}`); lines.push(`Due date: ${dueDate}`);
if (bill.category_name) lines.push(`Category: ${bill.category_name}`); if (bill.category_name) lines.push(`Category: ${bill.category_name}`);
if (bill.autopay_enabled) lines.push('Autopay: enabled'); if (bill.autopay_enabled) lines.push('Autopay: enabled');
@ -333,7 +334,7 @@ function previewFeed(userId, options = {}, db = getDb()) {
bill_id: event.bill.id, bill_id: event.bill.id,
name: event.bill.name, name: event.bill.name,
due_date: event.dueDate, due_date: event.dueDate,
amount: Number(event.bill.expected_amount || 0), amount: fromCents(event.bill.expected_amount) || 0,
cycle_type: normalizeCycleType(event.bill), cycle_type: normalizeCycleType(event.bill),
category_name: event.bill.category_name || null, category_name: event.bill.category_name || null,
})); }));

View File

@ -5,7 +5,7 @@ const { getCycleRange } = require('./statusService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { roundMoney } = require('../utils/money'); const { roundMoney, fromCents } = require('../utils/money');
const MONTHS_BACK = 3; const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2; const MIN_PAID_MONTHS = 2;
@ -53,7 +53,8 @@ function getDriftReport(userId, now = new Date()) {
`); `);
for (const bill of bills) { 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; if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
const monthTotals = []; const monthTotals = [];
@ -74,15 +75,15 @@ function getDriftReport(userId, now = new Date()) {
if (!range) continue; if (!range) continue;
const { total } = payStmt.get(bill.id, range.start, range.end); 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; if (monthTotals.length < MIN_PAID_MONTHS) continue;
const recentAmount = median(monthTotals); const recentAmount = median(monthTotals);
const delta = recentAmount - bill.expected_amount; const delta = recentAmount - expectedAmount;
const absDelta = Math.abs(delta); const absDelta = Math.abs(delta);
const driftPct = (delta / bill.expected_amount) * 100; const driftPct = (delta / expectedAmount) * 100;
if (absDelta < MIN_ABS_DELTA) continue; if (absDelta < MIN_ABS_DELTA) continue;
if (Math.abs(driftPct) < thresholdPct) continue; if (Math.abs(driftPct) < thresholdPct) continue;
@ -91,7 +92,7 @@ function getDriftReport(userId, now = new Date()) {
id: bill.id, id: bill.id,
name: bill.name, name: bill.name,
category_name: bill.category_name ?? null, category_name: bill.category_name ?? null,
expected_amount: bill.expected_amount, expected_amount: expectedAmount,
recent_amount: roundMoney(recentAmount), recent_amount: roundMoney(recentAmount),
drift_pct: Math.round(driftPct * 10) / 10, drift_pct: Math.round(driftPct * 10) / 10,
direction: delta > 0 ? 'up' : 'down', direction: delta > 0 ? 'up' : 'down',

View File

@ -3,6 +3,7 @@
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange, resolveDueDate } = require('./statusService'); const { getCycleRange, resolveDueDate } = require('./statusService');
const { decorateTransaction } = require('./transactionService'); const { decorateTransaction } = require('./transactionService');
const { fromCents } = require('../utils/money');
function suggestionError(status, message, code, field = null) { function suggestionError(status, message, code, field = null) {
const err = new Error(message); const err = new Error(message);
@ -66,7 +67,7 @@ function amountDollars(transaction) {
function addAmountScore(score, reasons, transaction, bill) { function addAmountScore(score, reasons, transaction, bill) {
const txAmount = amountDollars(transaction); 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; if (txAmount <= 0 || expected <= 0) return score;
const delta = Math.abs(txAmount - expected); const delta = Math.abs(txAmount - expected);
@ -298,7 +299,7 @@ function listMatchSuggestions(userId, options = {}) {
bill: { bill: {
id: bill.id, id: bill.id,
name: bill.name, name: bill.name,
expected_amount: bill.expected_amount, expected_amount: fromCents(bill.expected_amount),
due_day: bill.due_day, due_day: bill.due_day,
category_name: bill.category_name || null, category_name: bill.category_name || null,
}, },

View File

@ -8,6 +8,7 @@ const {
markNotificationTestSuccess, markNotificationTestSuccess,
} = require('./statusRuntime'); } = require('./statusRuntime');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// ── Push notification channels ──────────────────────────────────────────────── // ── Push notification channels ────────────────────────────────────────────────
@ -156,7 +157,7 @@ const URGENCY_COLOR = {
function buildEmailHtml(bill, type, dueDate) { function buildEmailHtml(bill, type, dueDate) {
const meta = TYPE_META[type]; const meta = TYPE_META[type];
const color = URGENCY_COLOR[meta.urgency]; 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) => { const fmt = (d) => {
if (!d) return '—'; if (!d) return '—';
const [y, m, day] = d.split('-'); const [y, m, day] = d.split('-');
@ -384,7 +385,7 @@ async function runNotifications() {
const meta = TYPE_META[type]; const meta = TYPE_META[type];
const subject = meta.subject(bill); const subject = meta.subject(bill);
const urgency = meta.urgency; 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}`; const pushBody = `${subject} · ${amount}`;
let sent = false; let sent = false;

View File

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

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
const { toCents, fromCents } = require('../utils/money');
function isPositiveIntegerString(value) { function isPositiveIntegerString(value) {
return /^\d+$/.test(String(value).trim()); return /^\d+$/.test(String(value).trim());
} }
@ -31,12 +33,29 @@ function validateIsoDate(value, field = 'paid_date') {
return { value: trimmed }; 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') { function validatePositiveAmount(value, field = 'amount') {
const amount = Number(value); const cents = toCents(value);
if (!Number.isFinite(amount) || amount <= 0) { if (!Number.isInteger(cents) || cents <= 0) {
return { error: `${field} must be a positive number` }; 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']; const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'];
@ -101,6 +120,7 @@ function validatePaymentInput(data, options = {}) {
module.exports = { module.exports = {
PAYMENT_SOURCES, PAYMENT_SOURCES,
serializePayment,
validateIsoDate, validateIsoDate,
validatePaymentInput, validatePaymentInput,
validatePaymentSource, validatePaymentSource,

View File

@ -2,6 +2,7 @@
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { toCents, fromCents } = require('../utils/money');
// Spending = unmatched outflows (amount < 0) that haven't been ignored. // Spending = unmatched outflows (amount < 0) that haven't been ignored.
// Bill-matched transactions are excluded so there's no double-counting. // 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)', category_name: r.category_name ?? '(Uncategorized)',
amount: r.total_cents / 100, amount: r.total_cents / 100,
tx_count: r.tx_count, 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 ────────────────────────────────────────────────────────────────── // ── Budgets ──────────────────────────────────────────────────────────────────
function getSpendingBudgets(db, userId, year, month) { function getSpendingBudgets(db, userId, year, month) {
return db.prepare(` const rows = db.prepare(`
SELECT sb.category_id, sb.amount, c.name AS category_name SELECT sb.category_id, sb.amount, c.name AS category_name
FROM spending_budgets sb FROM spending_budgets sb
JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL 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=? WHERE sb.user_id=? AND sb.year=? AND sb.month=?
`).all(userId, year, month); `).all(userId, year, month);
return rows.map(r => ({ ...r, amount: fromCents(r.amount) }));
} }
function setSpendingBudget(db, userId, categoryId, year, month, amount) { function setSpendingBudget(db, userId, categoryId, year, month, amount) {
@ -236,7 +238,7 @@ function setSpendingBudget(db, userId, categoryId, year, month, amount) {
VALUES (?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
amount=excluded.amount, updated_at=datetime('now') amount=excluded.amount, updated_at=datetime('now')
`).run(userId, categoryId, year, month, Number(amount)); `).run(userId, categoryId, year, month, toCents(amount));
} }
} }

View File

@ -15,6 +15,7 @@ const xlsx = require('xlsx');
const crypto = require('crypto'); const crypto = require('crypto');
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { toCents, fromCents } = require('../utils/money');
// ─── Constants ──────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
@ -556,7 +557,7 @@ function findBillMatches(detectedName, userBills) {
bill_name: bill.name, bill_name: bill.name,
category_id: bill.category_id ?? null, category_id: bill.category_id ?? null,
category: bill.category_name || null, category: bill.category_name || null,
expected_amount: bill.expected_amount, expected_amount: fromCents(bill.expected_amount),
due_day: bill.due_day ?? null, due_day: bill.due_day ?? null,
match_confidence: scored.match_confidence, match_confidence: scored.match_confidence,
match_reason: scored.match_reason, match_reason: scored.match_reason,
@ -1383,6 +1384,7 @@ function amountsEqual(a, b) {
} }
function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) { 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(` const existing = db.prepare(`
SELECT id, actual_amount, notes, is_skipped SELECT id, actual_amount, notes, is_skipped
FROM monthly_bill_state 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) { function createPaymentFromImport(db, billId, amount, paidDate, notes, allowOverwrite) {
if (!paidDate || amount == null || amount <= 0) return null; if (!paidDate || amount == null || amount <= 0) return null;
amount = toCents(amount); // incoming amount is dollars (decision/spreadsheet); column is cents
const dup = db.prepare(` const dup = db.prepare(`
SELECT id, created_at FROM payments SELECT id, created_at FROM payments
@ -1550,7 +1553,7 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const ins = db.prepare(` 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) 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) 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; const newBillId = ins.lastInsertRowid;
summary.created++; summary.created++;
@ -1660,10 +1663,13 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
return; return;
} }
// payAmount is dollars (decision/spreadsheet); payments columns are cents
const payAmountCents = toCents(payAmount);
const dup = db.prepare(` const dup = db.prepare(`
SELECT id, created_at, paid_date, amount FROM payments SELECT id, created_at, paid_date, amount FROM payments
WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL WHERE bill_id = ? AND paid_date = ? AND amount = ? AND deleted_at IS NULL
`).get(billId, payDate, payAmount); `).get(billId, payDate, payAmountCents);
if (dup && !allowOverwrite) { if (dup && !allowOverwrite) {
summary.skipped++; summary.skipped++;
@ -1675,17 +1681,17 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
note: 'Identical payment already exists', note: 'Identical payment already exists',
existing_created_at: dup.created_at ?? null, existing_created_at: dup.created_at ?? null,
existing_paid_date: dup.paid_date ?? null, existing_paid_date: dup.paid_date ?? null,
existing_amount: dup.amount ?? null, existing_amount: fromCents(dup.amount),
}); });
return; return;
} }
const balCalcCp = computeBalanceDelta(bill, payAmount); const balCalcCp = computeBalanceDelta(bill, payAmountCents);
db.prepare(` db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import') 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); applyBalanceDelta(db, billId, balCalcCp);

View File

@ -21,7 +21,8 @@ function pad(value) {
return String(value).padStart(2, '0'); 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) { function dateString(year, month, day) {
return `${year}-${pad(month)}-${pad(day)}`; return `${year}-${pad(month)}-${pad(day)}`;
@ -194,7 +195,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const expectedAmount = Number(bill.expected_amount) || 0; const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = sumMoney(safePayments, p => p.amount); const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
if (totalPaid >= expectedAmount) return 'paid'; if (totalPaid >= expectedAmount) return 'paid';
@ -223,11 +224,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options); const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
const expectedAmount = Number(bill.expected_amount) || 0; const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = sumMoney(safePayments, p => p.amount); const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const hasPayment = safePayments.length > 0; const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft'; const isSettled = status === 'paid' || status === 'autodraft';
const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount)); const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0)); const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
const rawBalance = expectedAmount - totalPaid; const rawBalance = expectedAmount - totalPaid;
const balance = isSettled ? 0 : Math.max(rawBalance, 0); const balance = isSettled ? 0 : Math.max(rawBalance, 0);
const lastPayment = hasPayment const lastPayment = hasPayment
@ -242,16 +243,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
due_date: dueDate, due_date: dueDate,
due_day: bill.due_day, due_day: bill.due_day,
bucket, bucket,
expected_amount: expectedAmount, expected_amount: fromCents(expectedAmount),
notes: bill.notes || null, // Bill-level notes (always available) notes: bill.notes || null, // Bill-level notes (always available)
total_paid: totalPaid, total_paid: fromCents(totalPaid),
paid_toward_due: paidTowardDue, paid_toward_due: fromCents(paidTowardDue),
overpaid_amount: overpaidAmount, overpaid_amount: fromCents(overpaidAmount),
balance, balance: fromCents(balance),
has_payment: hasPayment, has_payment: hasPayment,
is_settled: isSettled, is_settled: isSettled,
last_paid_date: lastPayment ? lastPayment.paid_date : null, last_paid_date: lastPayment ? lastPayment.paid_date : null,
last_payment_amount: lastPayment ? lastPayment.amount : null, last_payment_amount: lastPayment ? fromCents(lastPayment.amount) : null,
status, status,
autopay_enabled: !!bill.autopay_enabled, autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status, autodraft_status: bill.autodraft_status,
@ -259,8 +260,8 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
billing_cycle: bill.billing_cycle, billing_cycle: bill.billing_cycle,
cycle_type: normalizeCycleType(bill), cycle_type: normalizeCycleType(bill),
cycle_day: bill.cycle_day, cycle_day: bill.cycle_day,
current_balance: bill.current_balance ?? null, current_balance: bill.current_balance != null ? fromCents(bill.current_balance) : null,
minimum_payment: bill.minimum_payment ?? null, minimum_payment: bill.minimum_payment != null ? fromCents(bill.minimum_payment) : null,
interest_rate: bill.interest_rate ?? null, interest_rate: bill.interest_rate ?? null,
is_subscription: !!bill.is_subscription, is_subscription: !!bill.is_subscription,
has_2fa: !!bill.has_2fa, has_2fa: !!bill.has_2fa,
@ -272,7 +273,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
inactivated_at: bill.inactivated_at ?? null, inactivated_at: bill.inactivated_at ?? null,
sparkline: bill.sparkline ?? null, sparkline: bill.sparkline ?? null,
autopay_stats: bill.autopay_stats ?? null, autopay_stats: bill.autopay_stats ?? null,
payments: safePayments, payments: safePayments.map(serializePayment),
}; };
} }
@ -285,5 +286,4 @@ module.exports = {
resolveBucket, resolveBucket,
resolveDueDate, resolveDueDate,
resolveGracePeriodDays, resolveGracePeriodDays,
roundMoney,
}; };

View File

@ -2,7 +2,7 @@
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
const { localDateString, todayLocal } = require('../utils/dates'); const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney, mulMoney } = require('../utils/money'); const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money');
const SUBSCRIPTION_TYPES = [ const SUBSCRIPTION_TYPES = [
'streaming', 'software', 'cloud', 'music', 'news', 'streaming', 'software', 'cloud', 'music', 'news',
@ -494,9 +494,13 @@ function nextDueDate(bill, now = new Date()) {
} }
function decorateSubscription(bill) { 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 { return {
...bill, ...bill,
expected_amount: expectedAmount,
current_balance: fromCents(bill.current_balance),
minimum_payment: fromCents(bill.minimum_payment),
is_subscription: !!bill.is_subscription, is_subscription: !!bill.is_subscription,
active: !!bill.active, active: !!bill.active,
monthly_equivalent: monthly, monthly_equivalent: monthly,
@ -666,7 +670,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun
if (score === 0) continue; 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; const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
if (amountDelta !== null) { if (amountDelta !== null) {
const pct = expected ? amountDelta / expected : 1; const pct = expected ? amountDelta / expected : 1;
@ -1094,7 +1098,7 @@ function createSubscriptionFromRecommendation(db, userId, payload = {}) {
db.transaction(() => { db.transaction(() => {
for (const tx of txRows) { for (const tx of txRows) {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null); 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); updateTx.run(created.id, tx.id, userId);
if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id); if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id);
} }

View File

@ -1,14 +1,14 @@
'use strict'; 'use strict';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = require('./statusService'); const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService'); const { computeAmountSuggestion } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates'); const { localDateString } = require('../utils/dates');
const { sumMoney } = require('../utils/money'); const { sumMoney, roundMoney, fromCents } = require('../utils/money');
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -116,9 +116,9 @@ function buildBankTracking(db, userId, year, month) {
`).get(year, month, start, end, userId); `).get(year, month, start, end, userId);
const balance = roundMoney(account.balance / 100); const balance = roundMoney(account.balance / 100);
const pending = roundMoney(pendingRow.pending_total); const pending = fromCents(pendingRow.pending_total);
const effective = roundMoney(balance - pending); const effective = roundMoney(balance - pending);
const unpaid = roundMoney(unpaidRow.unpaid_total); const unpaid = fromCents(unpaidRow.unpaid_total);
return { return {
enabled: true, enabled: true,
@ -250,7 +250,7 @@ function fetchSparklines(db, billIds) {
const out = {}; const out = {};
for (const r of rows) { for (const r of rows) {
if (!out[r.bill_id]) out[r.bill_id] = []; 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; return out;
} }
@ -357,7 +357,7 @@ function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr,
if (dismissedSuggestions.has(bill.id)) return null; if (dismissedSuggestions.has(bill.id)) return null;
return { return {
bill_id: bill.id, bill_id: bill.id,
amount: suggestedAmount, amount: fromCents(suggestedAmount),
paid_date: dueDate, paid_date: dueDate,
method: 'autopay', method: 'autopay',
}; };
@ -389,7 +389,7 @@ function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
year: date.getFullYear(), year: date.getFullYear(),
month: date.getMonth() + 1, month: date.getMonth() + 1,
key: monthKey, 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); const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
if (!row) return null; if (!row) return null;
row.expected_amount = bill.expected_amount; row.expected_amount = fromCents(bill.expected_amount);
row.actual_amount = mbs?.actual_amount ?? null; row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
row.monthly_notes = mbs?.notes ?? null; row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped); row.is_skipped = !!(mbs?.is_skipped);
row.snoozed_until = mbs?.snoozed_until ?? null; row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion; 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); row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
return row; return row;
}).filter(Boolean); }).filter(Boolean);
@ -476,6 +476,13 @@ function getTracker(userId, query = {}, now = new Date()) {
WHERE user_id = ? AND year = ? AND month = ? WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, 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 dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th'; const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod); const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
@ -634,7 +641,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
name: bill.name, name: bill.name,
category_name: bill.category_name, category_name: bill.category_name,
due_date: dueDate, due_date: dueDate,
expected_amount: bill.expected_amount, expected_amount: row.expected_amount,
status: row.status, status: row.status,
days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000), days_until_due: Math.floor((new Date(`${dueDate}T00:00:00`) - new Date(`${todayStr}T00:00:00`)) / 86400000),
}); });

View File

@ -10,7 +10,7 @@ const {
decorateTransaction, decorateTransaction,
getTransactionForUser, getTransactionForUser,
} = require('./transactionService'); } = require('./transactionService');
const { roundMoney } = require('../utils/money'); const { serializePayment } = require('./paymentValidation');
const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match';
@ -76,7 +76,7 @@ function paymentAmountForTransaction(transaction) {
'amount', '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) { 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); const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return; if (bill?.current_balance == null) return;
const restored = Math.max(0, 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, // Clear interest_accrued_month when reversing a payment that charged interest,
// so the re-applied payment can accrue interest fresh. // so the re-applied payment can accrue interest fresh.
db.prepare(` db.prepare(`
@ -220,7 +220,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) {
SET deleted_at = datetime('now'), updated_at = datetime('now') SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(existingPayment.id); `).run(existingPayment.id);
return { ...existingPayment, deleted: true }; return { ...serializePayment(existingPayment), deleted: true };
} }
db.prepare(` db.prepare(`
@ -228,14 +228,14 @@ function unlinkPaymentForTransaction(db, userId, transactionId) {
SET transaction_id = NULL, updated_at = datetime('now') SET transaction_id = NULL, updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run(existingPayment.id); `).run(existingPayment.id);
return { ...existingPayment, unlinked: true }; return { ...serializePayment(existingPayment), unlinked: true };
} }
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) { function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
return { return {
success: true, success: true,
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)), transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
payment: getPaymentForResponse(db, userId, paymentId), payment: serializePayment(getPaymentForResponse(db, userId, paymentId)),
...extra, ...extra,
}; };
} }

View File

@ -7,6 +7,7 @@ const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService'); const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
const { toCents } = require('../utils/money');
const MAX_SQLITE_BYTES = 50 * 1024 * 1024; const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
const SESSION_TTL_HOURS = 24; 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 name = cleanText(row.name, 160);
const dueDay = toInt(row.due_day); const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null; if (!name || dueDay < 1 || dueDay > 31) return null;
@ -149,7 +171,7 @@ function sanitizeBill(row) {
due_day: dueDay, due_day: dueDay,
override_due_date: cleanText(row.override_due_date, 32), override_due_date: cleanText(row.override_due_date, 32),
bucket: dueDay <= 14 ? '1st' : '15th', 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, interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType), billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType),
cycle_type: 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 billId = toInt(row.bill_id);
const amount = toNumber(row.amount); const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date); const paidDate = cleanDate(row.paid_date);
@ -176,7 +198,7 @@ function sanitizePayment(row, validBillIds) {
return { return {
old_id: toInt(row.id), old_id: toInt(row.id),
bill_id: billId, bill_id: billId,
amount, amount: importMoney(amount, sourceIsCents),
paid_date: paidDate, paid_date: paidDate,
method: cleanText(row.method, 120), method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000), 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 billId = toInt(row.bill_id);
const year = toInt(row.year); const year = toInt(row.year);
const month = toInt(row.month); const month = toInt(row.month);
@ -198,7 +220,7 @@ function sanitizeMonthlyState(row, validBillIds) {
bill_id: billId, bill_id: billId,
year, year,
month, month,
actual_amount: actual == null || actual < 0 ? null : actual, actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents),
notes: cleanText(row.notes, 2000), notes: cleanText(row.notes, 2000),
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0, is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
created_at: cleanText(row.created_at, 32), 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 year = toInt(row.year);
const month = toInt(row.month); const month = toInt(row.month);
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null; if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
@ -214,9 +236,9 @@ function sanitizeMonthlyStartingAmounts(row) {
old_id: toInt(row.id), old_id: toInt(row.id),
year, year,
month, month,
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0), first_amount: importMoney(Math.max(0, toNumber(row.first_amount, 0) ?? 0), sourceIsCents),
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), fifteenth_amount: importMoney(Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), sourceIsCents),
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0), other_amount: importMoney(Math.max(0, toNumber(row.other_amount, 0) ?? 0), sourceIsCents),
notes: cleanText(row.notes, 2000), notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32), created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32), updated_at: cleanText(row.updated_at, 32),
@ -234,23 +256,25 @@ function readExportData(src) {
} }
const metadata = parseMetadata(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']) const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
.map(sanitizeCategory).filter(Boolean); .map(sanitizeCategory).filter(Boolean);
const bills = selectKnown(src, 'bills', [ const bills = selectKnown(src, 'bills', [
'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate', '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', 'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa',
'active', 'notes', 'created_at', 'updated_at', '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 validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
const payments = selectKnown(src, 'payments', [ const payments = selectKnown(src, 'payments', [
'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at', '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']) 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') 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']) ? 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') const notes = names.has('notes')
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes']) ? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])

View File

@ -20,7 +20,7 @@ function createUser(db, suffix) {
function createBill(db, userId, name, dueDay) { function createBill(db, userId, name, dueDay) {
return db.prepare(` return db.prepare(`
INSERT INTO bills (user_id, name, due_day, expected_amount) INSERT INTO bills (user_id, name, due_day, expected_amount)
VALUES (?, ?, ?, 25) VALUES (?, ?, ?, 2500)
`).run(userId, name, dueDay).lastInsertRowid; `).run(userId, name, dueDay).lastInsertRowid;
} }

View File

@ -36,7 +36,7 @@ function createBill(db, userId, overrides = {}) {
userId, userId,
overrides.name || 'Water, Power; Internet', overrides.name || 'Water, Power; Internet',
overrides.due_day || 15, overrides.due_day || 15,
overrides.expected_amount || 123.45, overrides.expected_amount || 12345,
overrides.cycle_type || 'monthly', overrides.cycle_type || 'monthly',
overrides.cycle_day || '1', overrides.cycle_day || '1',
overrides.billing_cycle || 'monthly', overrides.billing_cycle || 'monthly',

View File

@ -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', () => { test('tracker rows cap due math when a payment exceeds the amount due', () => {
const row = buildTrackerRow( const row = buildTrackerRow(
bill({ expected_amount: 100 }), bill({ expected_amount: 10000 }),
[{ amount: 125, paid_date: '2026-05-10' }], [{ amount: 12500, paid_date: '2026-05-10' }],
2026, 2026,
5, 5,
'2026-05-16', '2026-05-16',

View File

@ -58,7 +58,7 @@ function createBill(db, userId, overrides = {}) {
userId, userId,
overrides.name || 'Netflix', overrides.name || 'Netflix',
overrides.due_day || 8, overrides.due_day || 8,
overrides.expected_amount ?? 15.99, overrides.expected_amount ?? 1599,
overrides.is_subscription ?? 1, overrides.is_subscription ?? 1,
overrides.cycle_type || 'monthly', overrides.cycle_type || 'monthly',
overrides.billing_cycle || '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, { const billId = createBill(db, userId, {
name: 'Netflix', name: 'Netflix',
due_day: 12, due_day: 12,
expected_amount: 15.99, expected_amount: 1599,
is_subscription: 1, is_subscription: 1,
}); });
createTransaction(db, userId, { createTransaction(db, userId, {

View File

@ -32,7 +32,7 @@ function createUser(db, suffix) {
function createBill(db, userId, name = 'City Water') { function createBill(db, userId, name = 'City Water') {
return db.prepare(` return db.prepare(`
INSERT INTO bills (user_id, name, due_day, expected_amount) INSERT INTO bills (user_id, name, due_day, expected_amount)
VALUES (?, ?, 16, 85) VALUES (?, ?, 16, 8500)
`).run(userId, name).lastInsertRowid; `).run(userId, name).lastInsertRowid;
} }
@ -68,7 +68,7 @@ function createManualPayment(db, billId, overrides = {}) {
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`).run( `).run(
billId, billId,
overrides.amount ?? 85, overrides.amount ?? 8500,
overrides.paid_date || '2026-05-16', overrides.paid_date || '2026-05-16',
overrides.method || 'manual', overrides.method || 'manual',
overrides.payment_source || '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); 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); 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.paid_date, '2026-05-16');
assert.equal(payment.method, 'transaction_match'); assert.equal(payment.method, 'transaction_match');
assert.equal(payment.payment_source, '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 userId = createUser(db, 'manual-history');
const billId = createBill(db, userId, 'Internet'); const billId = createBill(db, userId, 'Internet');
const manualPaymentId = createManualPayment(db, billId, { const manualPaymentId = createManualPayment(db, billId, {
amount: 65, amount: 6500,
notes: 'Paid from checking', notes: 'Paid from checking',
}); });
const transactionId = createTransaction(db, userId, { 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 userId = createUser(db, 'bank-override');
const billId = createBill(db, userId, 'Internet Override'); const billId = createBill(db, userId, 'Internet Override');
const manualPaymentId = createManualPayment(db, billId, { const manualPaymentId = createManualPayment(db, billId, {
amount: 85, amount: 8500,
notes: 'Marked paid while waiting for bank clear', notes: 'Marked paid while waiting for bank clear',
}); });
const transactionId = createTransaction(db, userId, { const transactionId = createTransaction(db, userId, {