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');
}
},
{
version: 'v1.03',
description: 'money columns: dollars (REAL) -> integer cents',
run() {
const conv = [
['bills', ['expected_amount', 'current_balance', 'minimum_payment']],
['payments', ['amount', 'balance_delta', 'interest_delta']],
['monthly_bill_state', ['actual_amount']],
['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']],
['monthly_income', ['amount']],
['spending_budgets', ['amount']],
['snowball_plans', ['extra_payment']],
['users', ['snowball_extra_payment']],
];
for (const [table, cols] of conv) {
for (const col of cols) {
db.exec(`UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`);
}
}
console.log('[v1.03] money columns converted to integer cents');
}
},
{
version: 'v1.04',
description: 'bill_templates.data JSON: money fields dollars -> integer cents',
run() {
// v1.03 converted table columns but not money values embedded in the
// bill_templates.data JSON blob. Templates saved before v1.03 hold
// dollars; the template code now reads cents (serializeTemplateData).
for (const field of ['expected_amount', 'current_balance', 'minimum_payment']) {
db.exec(`
UPDATE bill_templates
SET data = json_set(data, '$.${field}',
CAST(ROUND(json_extract(data, '$.${field}') * 100) AS INTEGER))
WHERE json_extract(data, '$.${field}') IS NOT NULL
`);
}
console.log('[v1.04] bill_templates.data money fields converted to integer cents');
}
},
];
// ── users: notification columns ───────────────────────────────────────────
@ -3711,6 +3751,33 @@ function getDbPath() {
// Rollback SQL definitions
const ROLLBACK_SQL_MAP = {
'v1.04': {
description: 'bill_templates.data JSON: money fields dollars -> integer cents',
sql: [
"UPDATE bill_templates SET data = json_set(data, '$.expected_amount', ROUND(json_extract(data, '$.expected_amount') / 100.0, 2)) WHERE json_extract(data, '$.expected_amount') IS NOT NULL",
"UPDATE bill_templates SET data = json_set(data, '$.current_balance', ROUND(json_extract(data, '$.current_balance') / 100.0, 2)) WHERE json_extract(data, '$.current_balance') IS NOT NULL",
"UPDATE bill_templates SET data = json_set(data, '$.minimum_payment', ROUND(json_extract(data, '$.minimum_payment') / 100.0, 2)) WHERE json_extract(data, '$.minimum_payment') IS NOT NULL",
]
},
'v1.03': {
description: 'money columns: dollars (REAL) -> integer cents',
sql: [
'UPDATE bills SET expected_amount = ROUND(expected_amount / 100.0, 2) WHERE expected_amount IS NOT NULL',
'UPDATE bills SET current_balance = ROUND(current_balance / 100.0, 2) WHERE current_balance IS NOT NULL',
'UPDATE bills SET minimum_payment = ROUND(minimum_payment / 100.0, 2) WHERE minimum_payment IS NOT NULL',
'UPDATE payments SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
'UPDATE payments SET balance_delta = ROUND(balance_delta / 100.0, 2) WHERE balance_delta IS NOT NULL',
'UPDATE payments SET interest_delta = ROUND(interest_delta / 100.0, 2) WHERE interest_delta IS NOT NULL',
'UPDATE monthly_bill_state SET actual_amount = ROUND(actual_amount / 100.0, 2) WHERE actual_amount IS NOT NULL',
'UPDATE monthly_starting_amounts SET first_amount = ROUND(first_amount / 100.0, 2) WHERE first_amount IS NOT NULL',
'UPDATE monthly_starting_amounts SET fifteenth_amount = ROUND(fifteenth_amount / 100.0, 2) WHERE fifteenth_amount IS NOT NULL',
'UPDATE monthly_starting_amounts SET other_amount = ROUND(other_amount / 100.0, 2) WHERE other_amount IS NOT NULL',
'UPDATE monthly_income SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
'UPDATE spending_budgets SET amount = ROUND(amount / 100.0, 2) WHERE amount IS NOT NULL',
'UPDATE snowball_plans SET extra_payment = ROUND(extra_payment / 100.0, 2) WHERE extra_payment IS NOT NULL',
'UPDATE users SET snowball_extra_payment = ROUND(snowball_extra_payment / 100.0, 2) WHERE snowball_extra_payment IS NOT NULL',
]
},
'v0.98': {
description: 'payments: bank override metadata for provisional manual payments',
sql: [

View File

@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS bills (
due_day INTEGER NOT NULL CHECK(due_day BETWEEN 1 AND 31),
override_due_date TEXT,
bucket TEXT CHECK(bucket IN ('1st', '15th')),
expected_amount REAL NOT NULL DEFAULT 0,
expected_amount INTEGER NOT NULL DEFAULT 0, -- cents
interest_rate REAL CHECK(interest_rate IS NULL OR (interest_rate >= 0 AND interest_rate <= 100)),
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
cycle_type TEXT NOT NULL DEFAULT 'monthly' CHECK(cycle_type IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')),
@ -32,8 +32,8 @@ CREATE TABLE IF NOT EXISTS bills (
account_info TEXT,
has_2fa INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
current_balance REAL,
minimum_payment REAL,
current_balance INTEGER, -- cents
minimum_payment INTEGER, -- cents
snowball_order INTEGER,
sort_order INTEGER,
snowball_include INTEGER NOT NULL DEFAULT 0,
@ -53,11 +53,11 @@ CREATE TABLE IF NOT EXISTS bills (
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
amount REAL NOT NULL,
amount INTEGER NOT NULL, -- cents
paid_date TEXT NOT NULL,
method TEXT,
notes TEXT,
balance_delta REAL,
balance_delta INTEGER, -- cents
payment_source TEXT NOT NULL DEFAULT 'manual',
transaction_id INTEGER,
accounting_excluded INTEGER NOT NULL DEFAULT 0,
@ -85,7 +85,7 @@ CREATE TABLE IF NOT EXISTS users (
is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1,
snowball_extra_payment REAL NOT NULL DEFAULT 0,
snowball_extra_payment INTEGER NOT NULL DEFAULT 0, -- cents
notify_amount_change INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
@ -228,7 +228,7 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
actual_amount REAL, -- NULL = use bill.expected_amount for this month
actual_amount INTEGER, -- cents; NULL = use bill.expected_amount for this month
notes TEXT, -- month-specific notes, NULL = no notes
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
snoozed_until TEXT, -- ISO date: hide from overdue command center until this date
@ -291,7 +291,7 @@ CREATE TABLE IF NOT EXISTS snowball_plans (
started_at TEXT NOT NULL DEFAULT (datetime('now')),
paused_at TEXT,
completed_at TEXT,
extra_payment REAL NOT NULL DEFAULT 0,
extra_payment INTEGER NOT NULL DEFAULT 0, -- cents
plan_snapshot TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),

View File

@ -5,6 +5,7 @@ const {
auditBillsForUser,
categoryBelongsToUser,
insertBill,
serializeBill,
parseTemplateData,
sanitizeTemplateData,
validateBillData,
@ -13,7 +14,7 @@ const {
} = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
const { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService');
@ -22,7 +23,7 @@ const {
applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService');
const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney } = require('../utils/money');
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money');
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
@ -45,7 +46,7 @@ router.get('/', (req, res) => {
${includeInactive ? '' : 'AND b.active = 1'}
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
`).all(req.user.id);
res.json(bills);
res.json(bills.map(serializeBill));
});
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
@ -92,7 +93,7 @@ router.put('/reorder', (req, res) => {
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
`).all(req.user.id);
res.json({ success: true, bills });
res.json({ success: true, bills: bills.map(serializeBill) });
});
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
@ -146,6 +147,18 @@ router.post('/:id/snooze-drift', (req, res) => {
res.json({ ok: true, drift_snoozed_until: untilStr });
});
// Bill templates store money fields (expected_amount, current_balance, minimum_payment)
// in integer cents, matching validateBillData's normalized output. Convert back to
// dollars for API responses, mirroring serializeBill.
function serializeTemplateData(data) {
if (!data) return data;
const out = { ...data };
if (out.expected_amount != null) out.expected_amount = fromCents(out.expected_amount);
if (out.current_balance != null) out.current_balance = fromCents(out.current_balance);
if (out.minimum_payment != null) out.minimum_payment = fromCents(out.minimum_payment);
return out;
}
// ── GET /api/bills/templates ─────────────────────────────────────────────────
router.get('/templates', (req, res) => {
const db = getDb();
@ -158,7 +171,7 @@ router.get('/templates', (req, res) => {
res.json(rows.map(row => ({
...row,
data: parseTemplateData(row.data),
data: serializeTemplateData(parseTemplateData(row.data)),
})));
});
@ -200,7 +213,7 @@ router.post('/templates', (req, res) => {
res.status(result.changes > 0 ? 201 : 200).json({
...template,
data: parseTemplateData(template.data),
data: serializeTemplateData(parseTemplateData(template.data)),
});
});
@ -228,7 +241,7 @@ router.post('/:id/duplicate', (req, res) => {
if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = {
...sanitizeTemplateData(source),
...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
@ -242,7 +255,7 @@ router.post('/:id/duplicate', (req, res) => {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
res.status(201).json(insertBill(db, req.user.id, normalized));
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
});
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
@ -267,7 +280,7 @@ router.get('/:id/monthly-state', (req, res) => {
bill_id: billId,
year,
month,
actual_amount: mbs?.actual_amount ?? null,
actual_amount: fromCents(mbs?.actual_amount),
notes: mbs?.notes ?? null,
is_skipped: !!(mbs?.is_skipped),
});
@ -306,7 +319,7 @@ router.put('/:id/monthly-state', (req, res) => {
'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
).get(billId, y, m);
const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : (existing?.actual_amount ?? null);
const amt = actual_amount !== undefined ? (actual_amount === null ? null : toCents(actual_amount)) : (existing?.actual_amount ?? null);
const noteVal = notes !== undefined ? (notes || null) : (existing?.notes ?? null);
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : (existing?.is_skipped ?? 0);
const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : (existing?.snoozed_until ?? null);
@ -330,7 +343,7 @@ router.put('/:id/monthly-state', (req, res) => {
bill_id: saved.bill_id,
year: saved.year,
month: saved.month,
actual_amount: saved.actual_amount,
actual_amount: fromCents(saved.actual_amount),
notes: saved.notes,
is_skipped: !!saved.is_skipped,
snoozed_until: saved.snoozed_until ?? null,
@ -377,7 +390,7 @@ router.get('/:id', (req, res) => {
};
}
res.json({ ...bill, autopay_stats });
res.json({ ...serializeBill(bill), autopay_stats });
});
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
@ -411,7 +424,7 @@ router.post('/', (req, res) => {
const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id);
if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = {
...sanitizeTemplateData(source),
...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
@ -431,7 +444,7 @@ router.post('/', (req, res) => {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
res.status(201).json(insertBill(db, req.user.id, normalized));
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
});
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
@ -508,7 +521,7 @@ router.put('/:id', (req, res) => {
);
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
res.json(updated);
res.json(serializeBill(updated));
});
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
@ -526,7 +539,7 @@ router.put('/:id/archived', (req, res) => {
.run(archived ? 0 : 1, id, req.user.id);
const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id);
res.json({ ...updated, archived: !updated.active });
res.json({ ...serializeBill(updated), archived: !updated.active });
});
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
@ -555,7 +568,7 @@ router.post('/:id/restore', (req, res) => {
db.prepare("UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(req.params.id, req.user.id);
res.json(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id));
res.json(serializeBill(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)));
});
// POST /api/bills/:id/sync-simplefin-payments
@ -600,7 +613,7 @@ router.get('/:id/payments', (req, res) => {
page,
limit,
pages: Math.ceil(total / limit),
payments: items,
payments: items.map(serializePayment),
});
});
@ -647,7 +660,7 @@ router.get('/:id/transactions', (req, res) => {
...row,
linked_payment: row.linked_payment_id ? {
id: row.linked_payment_id,
amount: row.linked_payment_amount,
amount: fromCents(row.linked_payment_amount),
paid_date: row.linked_payment_date,
payment_source: row.linked_payment_source,
method: row.linked_payment_method,
@ -702,7 +715,7 @@ router.post('/:id/toggle-paid', (req, res) => {
if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) {
const restored = Math.max(0, roundMoney(freshBill.current_balance - currentPayment.balance_delta));
const restored = Math.max(0, freshBill.current_balance - currentPayment.balance_delta);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
}
}
@ -718,7 +731,7 @@ router.post('/:id/toggle-paid', (req, res) => {
// If unpaid, create payment → Paid
// Use expected_amount if no amount provided
const amount = req.body.amount !== undefined ? req.body.amount : bill.expected_amount;
const amount = req.body.amount !== undefined ? req.body.amount : fromCents(bill.expected_amount);
// Determine paid_date
let paidDate = req.body.paid_date;
@ -755,7 +768,7 @@ router.post('/:id/toggle-paid', (req, res) => {
success: true,
isPaid: true,
action: 'created_payment',
payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid),
payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)),
});
});
@ -886,9 +899,9 @@ router.get('/:id/amortization', (req, res) => {
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balance = Number(bill.current_balance);
const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0;
const minPmt = Number(bill.minimum_payment) || 0;
const minPmt = fromCents(Number(bill.minimum_payment) || 0);
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt;
@ -912,7 +925,7 @@ router.get('/:id/amortization', (req, res) => {
}
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
const apr_snapshot = debtAprSnapshot(bill);
const apr_snapshot = debtAprSnapshot({ ...bill, current_balance: balance, minimum_payment: minPmt });
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
res.json({
@ -968,7 +981,7 @@ router.patch('/:id/balance', (req, res) => {
val = roundMoney(val);
}
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(toCents(val), billId);
res.json({ id: billId, current_balance: val });
});
@ -1221,10 +1234,11 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amount = Math.round(Math.abs(tx.amount)) / 100;
const amountCents = Math.round(Math.abs(tx.amount));
const amount = fromCents(amountCents);
const billRow = getBill.get(billId);
const result = insertPayment.run(billId, amount, paidDate, txId);
const result = insertPayment.run(billId, amountCents, paidDate, txId);
if (result.changes > 0) {
const insertedPayment = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(txId, billId);
updateTx.run(billId, txId);

View File

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

View File

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

View File

@ -7,6 +7,7 @@ const {
rejectMatchSuggestion,
} = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService');
const { serializePayment } = require('../services/paymentValidation');
const { todayLocal } = require('../utils/dates');
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
@ -57,7 +58,7 @@ router.post('/confirm', (req, res) => {
if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars
const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents
try {
db.exec('BEGIN');
@ -87,7 +88,7 @@ router.post('/confirm', (req, res) => {
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL
WHERE t.id = ?
`).get(txId);
res.json({ transaction: updated, payment });
res.json({ transaction: updated, payment: serializePayment(payment) });
} catch (err) {
try { db.exec('ROLLBACK'); } catch {}
return sendMatchError(res, err, 'Failed to confirm match');

View File

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

View File

@ -3,13 +3,14 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService');
const {
markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService');
const { todayLocal } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
@ -66,7 +67,7 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
if (!dueDate) {
return { error: standardizeError('Bill does not occur in the selected month', 'VALIDATION_ERROR', 'month'), status: 400 };
}
const amount = state?.actual_amount ?? bill.expected_amount;
const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
return { bill, dueDate, amount };
}
@ -107,7 +108,7 @@ router.get('/', (req, res) => {
}
query += ' ORDER BY p.paid_date DESC';
res.json(db.prepare(query).all(...params));
res.json(db.prepare(query).all(...params).map(serializePayment));
});
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
@ -130,7 +131,7 @@ router.get('/recent-auto', (req, res) => {
ORDER BY p.created_at DESC
LIMIT 50
`).all(req.user.id);
res.json(rows);
res.json(rows.map(serializePayment));
});
// GET /api/payments/:id
@ -138,7 +139,7 @@ router.get('/:id', (req, res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(payment);
res.json(serializePayment(payment));
});
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
@ -164,7 +165,7 @@ router.post('/:id/undo-auto', (req, res) => {
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
db.prepare(`
UPDATE bills
SET current_balance = ?,
@ -212,7 +213,7 @@ router.post('/', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
});
// POST /api/payments/quick — pay a bill (expected amount, today)
@ -230,7 +231,7 @@ router.post('/quick', (req, res) => {
const paymentValidation = validatePaymentInput(
{
amount: amount != null ? amount : bill.expected_amount,
amount: amount != null ? amount : fromCents(bill.expected_amount),
paid_date: paid_date || todayLocal(),
payment_source: payment_source ?? 'manual',
},
@ -251,7 +252,7 @@ router.post('/quick', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
});
// POST /api/payments/autopay-suggestions/:billId/confirm
@ -296,7 +297,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
if (existing) {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
return res.json({ created: false, payment: existing });
return res.json({ created: false, payment: serializePayment(existing) });
}
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
@ -318,7 +319,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
@ -407,7 +408,7 @@ router.post('/bulk', (req, res) => {
// Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
if (isDuplicate) {
skipped.push({ bill_id, paid_date, amount: parsedAmt });
skipped.push({ bill_id, paid_date, amount: fromCents(parsedAmt) });
continue;
}
@ -421,7 +422,7 @@ router.post('/bulk', (req, res) => {
const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment_source);
applyBalanceDelta(db, bill_id, balCalc);
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
created.push(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)));
}
});
@ -460,7 +461,7 @@ router.put('/:id', (req, res) => {
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
let restoredBalance = bill.current_balance;
if (paymentPortion != null && bill.current_balance != null) {
restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100);
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
}
// interest_accrued_month is still set to this month (if interest was charged) so
@ -499,7 +500,7 @@ router.put('/:id', (req, res) => {
req.user.id,
);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
@ -515,7 +516,7 @@ router.delete('/:id', (req, res) => {
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
const restored = Math.max(0, bill.current_balance - payment.balance_delta);
db.prepare(`
UPDATE bills
SET current_balance = ?,
@ -543,7 +544,7 @@ router.post('/:id/restore', (req, res) => {
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
const reapplied = Math.max(0, bill.current_balance + payment.balance_delta);
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
db.prepare(`
UPDATE bills
@ -556,7 +557,7 @@ router.post('/:id/restore', (req, res) => {
}
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
});
// PATCH /api/payments/:id/attribute-to-month
@ -617,7 +618,7 @@ router.patch('/:id/attribute-to-month', (req, res) => {
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id));
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(paymentId, req.user.id)));
} catch (err) {
console.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));

View File

@ -4,6 +4,8 @@ const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService');
const { serializeBill } = require('../services/billsService');
const { toCents, fromCents } = require('../utils/money');
const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1
@ -84,7 +86,7 @@ function getDebtBills(userId, ramseyMode) {
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
const ramseyMode = isRamseyMode(req.user.id);
res.json(getDebtBills(req.user.id, ramseyMode));
res.json(getDebtBills(req.user.id, ramseyMode).map(serializeBill));
});
// GET /api/snowball/settings — extra monthly payment for this user
@ -92,7 +94,7 @@ router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({
extra_payment: user?.snowball_extra_payment ?? 0,
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
ramsey_mode: isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
@ -118,7 +120,7 @@ router.patch('/settings', (req, res) => {
const db = getDb();
const save = db.transaction(() => {
if (extra_payment !== undefined) {
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(toCents(val), req.user.id);
}
if (ramsey_mode !== undefined) {
@ -137,7 +139,7 @@ router.patch('/settings', (req, res) => {
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
res.json({
extra_payment: user?.snowball_extra_payment ?? 0,
extra_payment: fromCents(user?.snowball_extra_payment ?? 0),
// Use body value when ramsey_mode was just saved; fall back to DB read if not in request
ramsey_mode: ramsey_mode !== undefined ? !!ramsey_mode : isRamseyMode(req.user.id),
ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
@ -154,16 +156,24 @@ router.get('/projection', (req, res) => {
const bills = getDebtBills(req.user.id, ramseyMode);
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
// Money fields on `bills` are stored as integer cents; the snowball/APR math
// and the API response are dollar-denominated, so convert before computing.
const billsForMath = bills.map(b => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
// Allow an optional ?extra=N override so the client can preview an unsaved
// extra payment without a round-trip save. Falls back to the stored value.
const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN;
const extra = Number.isFinite(queryExtra) && queryExtra >= 0
? queryExtra
: (user?.snowball_extra_payment ?? 0);
: fromCents(user?.snowball_extra_payment ?? 0);
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
const aprByBill = {};
for (const b of bills) {
for (const b of billsForMath) {
const snap = debtAprSnapshot(b);
if (snap) aprByBill[b.id] = snap;
}
@ -180,9 +190,9 @@ router.get('/projection', (req, res) => {
}
const now = new Date();
const snowball = enrich(calculateSnowball(bills, extra, now));
const avalanche = enrich(calculateAvalanche(bills, extra, now));
const minimum_only = enrich(calculateMinimumOnly(bills, now));
const snowball = enrich(calculateSnowball(billsForMath, extra, now));
const avalanche = enrich(calculateAvalanche(billsForMath, extra, now));
const minimum_only = enrich(calculateMinimumOnly(billsForMath, now));
// Comparison: what does the snowball save vs just paying minimums?
const comparison = buildComparison(snowball, minimum_only);
@ -270,7 +280,7 @@ function enrichPlanWithProgress(db, plan) {
const currentDebts = (snapshot?.debts ?? []).map(d => {
const bill = db.prepare('SELECT current_balance, name, deleted_at FROM bills WHERE id = ?').get(d.bill_id);
const currentBalance = bill && !bill.deleted_at ? (bill.current_balance ?? null) : null;
const currentBalance = bill && !bill.deleted_at ? fromCents(bill.current_balance) : null;
const startingBalance = d.starting_balance ?? 0;
const progressPct = startingBalance > 0 && currentBalance !== null
? Math.min(100, Math.max(0, Math.round((startingBalance - currentBalance) / startingBalance * 100)))
@ -281,7 +291,7 @@ function enrichPlanWithProgress(db, plan) {
const startedMs = plan.started_at ? new Date(plan.started_at).getTime() : Date.now();
const monthsElapsed = Math.floor((Date.now() - startedMs) / (1000 * 60 * 60 * 24 * 30));
return { ...plan, plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
return { ...plan, extra_payment: fromCents(plan.extra_payment), plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
}
// POST /api/snowball/plans — start a new snowball plan
@ -301,15 +311,24 @@ router.post('/plans', (req, res) => {
return res.status(400).json({ error: 'No debts with a balance found. Add a balance to at least one bill.' });
}
// 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 extra = user?.snowball_extra_payment ?? 0;
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now);
const minOnly = calculateMinimumOnly(debts, now);
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100);
const debtSnaps = debts.map((b, i) => {
const debtSnaps = debtsForMath.map((b, i) => {
const proj = snowball.debts?.find(d => d.id === b.id);
return {
bill_id: b.id,
@ -342,7 +361,7 @@ router.post('/plans', (req, res) => {
const result = db.prepare(`
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
`).run(userId, planName, planMethod, extra, planSnapshot, notes || null);
`).run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));

View File

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

View File

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

View File

@ -1,7 +1,7 @@
'use strict';
const { accountingActiveSql } = require('./paymentAccountingService');
const { roundMoney } = require('../utils/money');
const { fromCents } = require('../utils/money');
/**
* Computes a suggested expected amount for a bill based on the rolling median
@ -48,7 +48,7 @@ function computeAmountSuggestion(db, billId, year, month) {
: sorted[mid];
return {
suggestion: roundMoney(median),
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};

View File

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

View File

@ -4,6 +4,7 @@ const { normalizeMerchant } = require('./subscriptionService');
const { getUserSettings } = require('./userSettings');
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService');
const { localDateString } = require('../utils/dates');
const { fromCents } = require('../utils/money');
// Word-boundary merchant match — requires the rule to appear as complete word(s)
// within the transaction string (or vice versa), not just as a substring.
@ -165,10 +166,13 @@ function applyMerchantRules(db, userId) {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amount = Math.round(Math.abs(tx.amount)) / 100;
// tx.amount and payments.amount are both integer cents; keep a dollar
// copy only for the lateAttributions display payload below.
const amountCents = Math.round(Math.abs(tx.amount));
const amount = fromCents(amountCents);
const bill = getBill.get(rule.bill_id);
const result = insertPayment.run(rule.bill_id, amount, paidDate, tx.id);
const result = insertPayment.run(rule.bill_id, amountCents, paidDate, tx.id);
if (result.changes > 0) {
const inserted = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL').get(tx.id, rule.bill_id);
updateTx.run(rule.bill_id, tx.id, userId);
@ -294,10 +298,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
if (!matches) continue;
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amount = Math.round(Math.abs(tx.amount)) / 100;
// tx.amount and payments.amount are both integer cents; keep a dollar
// copy only for the lateAttributions display payload below.
const amountCents = Math.round(Math.abs(tx.amount));
const amount = fromCents(amountCents);
const bill = getBill.get(billId);
const result = insertPayment.run(billId, amount, paidDate, tx.id);
const result = insertPayment.run(billId, amountCents, paidDate, tx.id);
if (result.changes > 0) {
const inserted = getPaymentId.get(tx.id, billId);
updateTx.run(billId, tx.id, userId);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
'use strict';
const { toCents, fromCents } = require('../utils/money');
function isPositiveIntegerString(value) {
return /^\d+$/.test(String(value).trim());
}
@ -31,12 +33,29 @@ function validateIsoDate(value, field = 'paid_date') {
return { value: trimmed };
}
/**
* Validates a positive dollar amount and converts it to integer cents
* (the unit `payments.amount` and related money columns are stored in).
*/
function validatePositiveAmount(value, field = 'amount') {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
const cents = toCents(value);
if (!Number.isInteger(cents) || cents <= 0) {
return { error: `${field} must be a positive number` };
}
return { value: amount };
return { value: cents };
}
/**
* Converts a payment row's cent columns (amount, balance_delta, interest_delta)
* to dollars for API responses.
*/
function serializePayment(payment) {
if (!payment) return payment;
const out = { ...payment };
if (out.amount != null) out.amount = fromCents(out.amount);
if (out.balance_delta != null) out.balance_delta = fromCents(out.balance_delta);
if (out.interest_delta != null) out.interest_delta = fromCents(out.interest_delta);
return out;
}
const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync'];
@ -101,6 +120,7 @@ function validatePaymentInput(data, options = {}) {
module.exports = {
PAYMENT_SOURCES,
serializePayment,
validateIsoDate,
validatePaymentInput,
validatePaymentSource,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ const path = require('path');
const Database = require('better-sqlite3');
const { getDb } = require('../db/database');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
const { toCents } = require('../utils/money');
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
const SESSION_TTL_HOURS = 24;
@ -131,7 +132,28 @@ function sanitizeCategory(row) {
};
}
function sanitizeBill(row) {
/**
* Convert a money value from a source export to integer cents.
* Pre-v1.03 exports store dollars (REAL); post-v1.03 exports already store
* integer cents. Applying toCents() to a cents value would multiply it ×100,
* so the caller detects the source's unit via its schema_migrations table.
*/
function importMoney(value, sourceIsCents) {
if (value === null || value === undefined) return null;
return sourceIsCents ? Math.round(Number(value)) : toCents(value);
}
/** True when the source DB already stores money in integer cents (v1.03+). */
function sourceUsesCents(src) {
try {
if (!tableNames(src).has('schema_migrations')) return false;
return !!src.prepare("SELECT 1 FROM schema_migrations WHERE version = 'v1.03'").get();
} catch {
return false;
}
}
function sanitizeBill(row, sourceIsCents) {
const name = cleanText(row.name, 160);
const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null;
@ -149,7 +171,7 @@ function sanitizeBill(row) {
due_day: dueDay,
override_due_date: cleanText(row.override_due_date, 32),
bucket: dueDay <= 14 ? '1st' : '15th',
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
expected_amount: importMoney(Math.max(0, toNumber(row.expected_amount, 0) ?? 0), sourceIsCents),
interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : billingCycleForCycleType(normalizedCycleType),
cycle_type: normalizedCycleType,
@ -167,7 +189,7 @@ function sanitizeBill(row) {
};
}
function sanitizePayment(row, validBillIds) {
function sanitizePayment(row, validBillIds, sourceIsCents) {
const billId = toInt(row.bill_id);
const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date);
@ -176,7 +198,7 @@ function sanitizePayment(row, validBillIds) {
return {
old_id: toInt(row.id),
bill_id: billId,
amount,
amount: importMoney(amount, sourceIsCents),
paid_date: paidDate,
method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000),
@ -187,7 +209,7 @@ function sanitizePayment(row, validBillIds) {
};
}
function sanitizeMonthlyState(row, validBillIds) {
function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
const billId = toInt(row.bill_id);
const year = toInt(row.year);
const month = toInt(row.month);
@ -198,7 +220,7 @@ function sanitizeMonthlyState(row, validBillIds) {
bill_id: billId,
year,
month,
actual_amount: actual == null || actual < 0 ? null : actual,
actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents),
notes: cleanText(row.notes, 2000),
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
created_at: cleanText(row.created_at, 32),
@ -206,7 +228,7 @@ function sanitizeMonthlyState(row, validBillIds) {
};
}
function sanitizeMonthlyStartingAmounts(row) {
function sanitizeMonthlyStartingAmounts(row, sourceIsCents) {
const year = toInt(row.year);
const month = toInt(row.month);
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
@ -214,9 +236,9 @@ function sanitizeMonthlyStartingAmounts(row) {
old_id: toInt(row.id),
year,
month,
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
first_amount: importMoney(Math.max(0, toNumber(row.first_amount, 0) ?? 0), sourceIsCents),
fifteenth_amount: importMoney(Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0), sourceIsCents),
other_amount: importMoney(Math.max(0, toNumber(row.other_amount, 0) ?? 0), sourceIsCents),
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
@ -234,23 +256,25 @@ function readExportData(src) {
}
const metadata = parseMetadata(src);
// Pre-v1.03 exports store money in dollars; v1.03+ exports store integer cents.
const sourceIsCents = sourceUsesCents(src);
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
.map(sanitizeCategory).filter(Boolean);
const bills = selectKnown(src, 'bills', [
'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate',
'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa',
'active', 'notes', 'created_at', 'updated_at',
]).map(sanitizeBill).filter(Boolean);
]).map(row => sanitizeBill(row, sourceIsCents)).filter(Boolean);
const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
const payments = selectKnown(src, 'payments', [
'id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'payment_source', 'transaction_id', 'created_at', 'updated_at',
])
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
.map(row => sanitizePayment(row, validBillIds, sourceIsCents)).filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
.map(row => sanitizeMonthlyState(row, validBillIds, sourceIsCents)).filter(Boolean);
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
.map(sanitizeMonthlyStartingAmounts).filter(Boolean)
.map(row => sanitizeMonthlyStartingAmounts(row, sourceIsCents)).filter(Boolean)
: [];
const notes = names.has('notes')
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])

View File

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

View File

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

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', () => {
const row = buildTrackerRow(
bill({ expected_amount: 100 }),
[{ amount: 125, paid_date: '2026-05-10' }],
bill({ expected_amount: 10000 }),
[{ amount: 12500, paid_date: '2026-05-10' }],
2026,
5,
'2026-05-16',

View File

@ -58,7 +58,7 @@ function createBill(db, userId, overrides = {}) {
userId,
overrides.name || 'Netflix',
overrides.due_day || 8,
overrides.expected_amount ?? 15.99,
overrides.expected_amount ?? 1599,
overrides.is_subscription ?? 1,
overrides.cycle_type || 'monthly',
overrides.billing_cycle || 'monthly',
@ -97,7 +97,7 @@ test('existing tracked bills are recommended for linking instead of tracking aga
const billId = createBill(db, userId, {
name: 'Netflix',
due_day: 12,
expected_amount: 15.99,
expected_amount: 1599,
is_subscription: 1,
});
createTransaction(db, userId, {

View File

@ -32,7 +32,7 @@ function createUser(db, suffix) {
function createBill(db, userId, name = 'City Water') {
return db.prepare(`
INSERT INTO bills (user_id, name, due_day, expected_amount)
VALUES (?, ?, 16, 85)
VALUES (?, ?, 16, 8500)
`).run(userId, name).lastInsertRowid;
}
@ -68,7 +68,7 @@ function createManualPayment(db, billId, overrides = {}) {
VALUES (?, ?, ?, ?, ?, ?)
`).run(
billId,
overrides.amount ?? 85,
overrides.amount ?? 8500,
overrides.paid_date || '2026-05-16',
overrides.method || 'manual',
overrides.payment_source || 'manual',
@ -237,7 +237,7 @@ test('transaction match payments cannot be edited, deleted, or restored through
assert.equal(updateRes.status, 409);
let payment = db.prepare('SELECT amount, paid_date, method, payment_source, transaction_id, deleted_at FROM payments WHERE id = ?').get(matched.payment.id);
assert.equal(payment.amount, 85);
assert.equal(payment.amount, 8500);
assert.equal(payment.paid_date, '2026-05-16');
assert.equal(payment.method, 'transaction_match');
assert.equal(payment.payment_source, 'transaction_match');
@ -387,7 +387,7 @@ test('manual payment history remains visible and suppresses duplicate suggestion
const userId = createUser(db, 'manual-history');
const billId = createBill(db, userId, 'Internet');
const manualPaymentId = createManualPayment(db, billId, {
amount: 65,
amount: 6500,
notes: 'Paid from checking',
});
const transactionId = createTransaction(db, userId, {
@ -427,7 +427,7 @@ test('bank-backed match overrides same-cycle manual tracker payment but keeps it
const userId = createUser(db, 'bank-override');
const billId = createBill(db, userId, 'Internet Override');
const manualPaymentId = createManualPayment(db, billId, {
amount: 85,
amount: 8500,
notes: 'Marked paid while waiting for bank clear',
});
const transactionId = createTransaction(db, userId, {