Compare commits

..

No commits in common. "4b74a456d9fed935098aab2e07ae9f8bb1f1ee87" and "bf66ab1ee6a74cf6753d1a4da249cf9ba2b3f679" have entirely different histories.

36 changed files with 253 additions and 437 deletions

View File

@ -33,6 +33,29 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
## 🟠 HIGH
### 🟠 Cents Migration Stage 2 — Schema Flip to Integer Cents — HIGH
**Priority:** HIGH
**Added:** 2026-06-10 by Claude (cents migration stage 1)
**Description:**
Stage 1 is shipped: `utils/money.js` exists and all server-side money summation/
rounding is cent-exact. Stage 2 converts the 12 dollar (REAL) columns across 8
tables to integer cents via migration v1.03 and updates all ~288 query sites.
**Scope:**
- Apply v1.03 migration + rollback + schema.sql changes per `docs/cents-migration-plan.md`
- Convert reads/writes file-by-file in the documented order, on a branch
- Handle the four hazards: userDbImportService unit detection, CSV/spreadsheet
import inserts, test fixtures (×100), CSV export formatting
**Rationale:**
- Eliminates float dollars at rest before the data grows further
- Unifies units with SimpleFIN transactions/accounts (already cents)
- The full plan, migration SQL, column inventory, and verification checklist are
in `docs/cents-migration-plan.md` — this item is execution only
## 🟡 MEDIUM
### 🟡 Projected Cash Flow — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap)

View File

@ -1,10 +1,4 @@
# Bill Tracker — Changelog
## v0.38.4
### ✨ Money
- **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`.
## v0.37.0
### ✨ Added

View File

@ -3367,46 +3367,6 @@ 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 ───────────────────────────────────────────
@ -3751,33 +3711,6 @@ 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 INTEGER NOT NULL DEFAULT 0, -- cents
expected_amount REAL NOT NULL DEFAULT 0,
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 INTEGER, -- cents
minimum_payment INTEGER, -- cents
current_balance REAL,
minimum_payment REAL,
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 INTEGER NOT NULL, -- cents
amount REAL NOT NULL,
paid_date TEXT NOT NULL,
method TEXT,
notes TEXT,
balance_delta INTEGER, -- cents
balance_delta REAL,
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 INTEGER NOT NULL DEFAULT 0, -- cents
snowball_extra_payment REAL NOT NULL DEFAULT 0,
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 INTEGER, -- cents; NULL = use bill.expected_amount for this month
actual_amount REAL, -- 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 INTEGER NOT NULL DEFAULT 0, -- cents
extra_payment REAL NOT NULL DEFAULT 0,
plan_snapshot TEXT NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.38.4",
"version": "0.37.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -5,7 +5,6 @@ const {
auditBillsForUser,
categoryBelongsToUser,
insertBill,
serializeBill,
parseTemplateData,
sanitizeTemplateData,
validateBillData,
@ -14,7 +13,7 @@ const {
} = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { validatePaymentInput } = require('../services/paymentValidation');
const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService');
const { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService');
@ -23,7 +22,7 @@ const {
applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService');
const { localDateString, todayLocal } = require('../utils/dates');
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money');
const { roundMoney, sumMoney } = require('../utils/money');
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
@ -46,7 +45,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.map(serializeBill));
res.json(bills);
});
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
@ -93,7 +92,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: bills.map(serializeBill) });
res.json({ success: true, bills });
});
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
@ -147,18 +146,6 @@ 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();
@ -171,7 +158,7 @@ router.get('/templates', (req, res) => {
res.json(rows.map(row => ({
...row,
data: serializeTemplateData(parseTemplateData(row.data)),
data: parseTemplateData(row.data),
})));
});
@ -213,7 +200,7 @@ router.post('/templates', (req, res) => {
res.status(result.changes > 0 ? 201 : 200).json({
...template,
data: serializeTemplateData(parseTemplateData(template.data)),
data: parseTemplateData(template.data),
});
});
@ -241,7 +228,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(serializeBill(source)),
...sanitizeTemplateData(source),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
@ -255,7 +242,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(serializeBill(insertBill(db, req.user.id, normalized)));
res.status(201).json(insertBill(db, req.user.id, normalized));
});
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
@ -280,7 +267,7 @@ router.get('/:id/monthly-state', (req, res) => {
bill_id: billId,
year,
month,
actual_amount: fromCents(mbs?.actual_amount),
actual_amount: mbs?.actual_amount ?? null,
notes: mbs?.notes ?? null,
is_skipped: !!(mbs?.is_skipped),
});
@ -319,7 +306,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 : toCents(actual_amount)) : (existing?.actual_amount ?? null);
const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(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);
@ -343,7 +330,7 @@ router.put('/:id/monthly-state', (req, res) => {
bill_id: saved.bill_id,
year: saved.year,
month: saved.month,
actual_amount: fromCents(saved.actual_amount),
actual_amount: saved.actual_amount,
notes: saved.notes,
is_skipped: !!saved.is_skipped,
snoozed_until: saved.snoozed_until ?? null,
@ -390,7 +377,7 @@ router.get('/:id', (req, res) => {
};
}
res.json({ ...serializeBill(bill), autopay_stats });
res.json({ ...bill, autopay_stats });
});
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
@ -424,7 +411,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(serializeBill(source)),
...sanitizeTemplateData(source),
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
@ -444,7 +431,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(serializeBill(insertBill(db, req.user.id, normalized)));
res.status(201).json(insertBill(db, req.user.id, normalized));
});
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
@ -521,7 +508,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(serializeBill(updated));
res.json(updated);
});
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
@ -539,7 +526,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({ ...serializeBill(updated), archived: !updated.active });
res.json({ ...updated, archived: !updated.active });
});
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
@ -568,7 +555,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(serializeBill(db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(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));
});
// POST /api/bills/:id/sync-simplefin-payments
@ -613,7 +600,7 @@ router.get('/:id/payments', (req, res) => {
page,
limit,
pages: Math.ceil(total / limit),
payments: items.map(serializePayment),
payments: items,
});
});
@ -660,7 +647,7 @@ router.get('/:id/transactions', (req, res) => {
...row,
linked_payment: row.linked_payment_id ? {
id: row.linked_payment_id,
amount: fromCents(row.linked_payment_amount),
amount: row.linked_payment_amount,
paid_date: row.linked_payment_date,
payment_source: row.linked_payment_source,
method: row.linked_payment_method,
@ -715,7 +702,7 @@ router.post('/:id/toggle-paid', (req, res) => {
if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) {
const restored = Math.max(0, freshBill.current_balance - currentPayment.balance_delta);
const restored = Math.max(0, roundMoney(freshBill.current_balance - currentPayment.balance_delta));
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, billId);
}
}
@ -731,7 +718,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 : fromCents(bill.expected_amount);
const amount = req.body.amount !== undefined ? req.body.amount : bill.expected_amount;
// Determine paid_date
let paidDate = req.body.paid_date;
@ -768,7 +755,7 @@ router.post('/:id/toggle-paid', (req, res) => {
success: true,
isPaid: true,
action: 'created_payment',
payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)),
payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid),
});
});
@ -899,9 +886,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 = fromCents(Number(bill.current_balance));
const balance = Number(bill.current_balance);
const apr = Number(bill.interest_rate) || 0;
const minPmt = fromCents(Number(bill.minimum_payment) || 0);
const minPmt = Number(bill.minimum_payment) || 0;
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt;
@ -925,7 +912,7 @@ router.get('/:id/amortization', (req, res) => {
}
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
const apr_snapshot = debtAprSnapshot({ ...bill, current_balance: balance, minimum_payment: minPmt });
const apr_snapshot = debtAprSnapshot(bill);
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
res.json({
@ -981,7 +968,7 @@ router.patch('/:id/balance', (req, res) => {
val = roundMoney(val);
}
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(toCents(val), billId);
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(val, billId);
res.json({ id: billId, current_balance: val });
});
@ -1234,11 +1221,10 @@ 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 amountCents = Math.round(Math.abs(tx.amount));
const amount = fromCents(amountCents);
const amount = Math.round(Math.abs(tx.amount)) / 100;
const billRow = getBill.get(billId);
const result = insertPayment.run(billId, amountCents, paidDate, txId);
const result = insertPayment.run(billId, amount, 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, fromCents } = require('../utils/money');
const { roundMoney, sumMoney } = require('../utils/money');
function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate();
@ -161,17 +161,16 @@ 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,
amount: payment.amount,
paid_date: payment.paid_date,
method: payment.method || null,
notes: payment.notes || null,
});
day.status_summary.total_paid += amount || 0;
day.status_summary.total_paid += payment.amount || 0;
}
}
@ -184,7 +183,7 @@ router.get('/', (req, res) => {
if (!row) return null;
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = fromCents(monthlyState?.actual_amount);
const actualAmount = monthlyState?.actual_amount ?? null;
const isSkipped = !!monthlyState?.is_skipped;
const effectiveAmount = actualAmount ?? row.expected_amount;
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;

View File

@ -7,7 +7,6 @@ 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) => {
@ -59,11 +58,11 @@ router.get('/', (req, res) => {
r.paid_date,
escCsv(r.bill_name),
escCsv(r.category),
fromCents(r.expected_amount).toFixed(2),
fromCents(r.paid_amount).toFixed(2),
r.expected_amount.toFixed(2),
r.paid_amount.toFixed(2),
escCsv(r.method),
escCsv(r.notes),
mbs?.actual_amount != null ? fromCents(mbs.actual_amount).toFixed(2) : '',
mbs?.actual_amount != null ? mbs.actual_amount.toFixed(2) : '',
escCsv(mbs?.notes ?? null),
].join(',');
}).join('\n');
@ -80,9 +79,7 @@ router.get('/', (req, res) => {
const mbs = mbsStmt.get(r.bill_id, paidYear, paidMonth);
return {
...r,
expected_amount: fromCents(r.expected_amount),
paid_amount: fromCents(r.paid_amount),
actual_amount: fromCents(mbs?.actual_amount ?? null),
actual_amount: mbs?.actual_amount ?? null,
monthly_notes: mbs?.notes ?? null,
};
});
@ -99,7 +96,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).map(b => ({ ...b, expected_amount: fromCents(b.expected_amount) }));
`).all(userId);
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,
@ -108,25 +105,20 @@ 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).map(p => ({ ...p, amount: fromCents(p.amount) }));
`).all(userId);
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).map(m => ({ ...m, actual_amount: fromCents(m.actual_amount) }));
`).all(userId);
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).map(r => ({
...r,
first_amount: fromCents(r.first_amount),
fifteenth_amount: fromCents(r.fifteenth_amount),
other_amount: fromCents(r.other_amount),
}));
`).all(userId);
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,7 +7,6 @@ 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') {
@ -58,7 +57,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)); // tx.amount and payments.amount are both cents
const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars
try {
db.exec('BEGIN');
@ -88,7 +87,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: serializePayment(payment) });
res.json({ transaction: updated, payment });
} catch (err) {
try { db.exec('ROLLBACK'); } catch {}
return sendMatchError(res, err, 'Failed to confirm match');

View File

@ -3,7 +3,6 @@ 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();
@ -33,9 +32,9 @@ function getStartingAmounts(db, userId, year, month) {
`).get(userId, year, month);
return {
first_amount: fromCents(row?.first_amount || 0),
fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
other_amount: fromCents(row?.other_amount || 0),
first_amount: money(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0),
};
}
@ -89,10 +88,10 @@ function calculatePaidDeductions(db, userId, year, month) {
`).get(userId, start, end);
return {
paid_from_first: fromCents(firstPaid.paid),
paid_from_fifteenth: fromCents(fifteenthPaid.paid),
paid_from_other: fromCents(otherPaid.paid),
paid_total: fromCents(totalPaid.paid),
paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid),
paid_total: money(totalPaid.paid),
};
}
@ -157,7 +156,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, toCents(firstAmount), toCents(fifteenthAmount), toCents(otherAmount));
`).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
});

View File

@ -3,14 +3,13 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation');
const { validatePaymentInput } = 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
@ -67,7 +66,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 = fromCents(state?.actual_amount ?? bill.expected_amount);
const amount = state?.actual_amount ?? bill.expected_amount;
return { bill, dueDate, amount };
}
@ -108,7 +107,7 @@ router.get('/', (req, res) => {
}
query += ' ORDER BY p.paid_date DESC';
res.json(db.prepare(query).all(...params).map(serializePayment));
res.json(db.prepare(query).all(...params));
});
// GET /api/payments/recent-auto — provider_sync payments with a linked tx, last 7 days
@ -131,7 +130,7 @@ router.get('/recent-auto', (req, res) => {
ORDER BY p.created_at DESC
LIMIT 50
`).all(req.user.id);
res.json(rows.map(serializePayment));
res.json(rows);
});
// GET /api/payments/:id
@ -139,7 +138,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(serializePayment(payment));
res.json(payment);
});
// POST /api/payments/:id/undo-auto — reverse a provider_sync auto-match
@ -165,7 +164,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, Number(bill.current_balance) - Number(payment.balance_delta));
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
db.prepare(`
UPDATE bills
SET current_balance = ?,
@ -213,7 +212,7 @@ router.post('/', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
// POST /api/payments/quick — pay a bill (expected amount, today)
@ -231,7 +230,7 @@ router.post('/quick', (req, res) => {
const paymentValidation = validatePaymentInput(
{
amount: amount != null ? amount : fromCents(bill.expected_amount),
amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || todayLocal(),
payment_source: payment_source ?? 'manual',
},
@ -252,7 +251,7 @@ router.post('/quick', (req, res) => {
applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
// POST /api/payments/autopay-suggestions/:billId/confirm
@ -297,7 +296,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: serializePayment(existing) });
return res.json({ created: false, payment: existing });
}
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
@ -319,7 +318,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: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
@ -408,7 +407,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: fromCents(parsedAmt) });
skipped.push({ bill_id, paid_date, amount: parsedAmt });
continue;
}
@ -422,7 +421,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(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)));
created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid));
}
});
@ -461,7 +460,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, bill.current_balance - paymentPortion);
restoredBalance = Math.max(0, Math.round((bill.current_balance - paymentPortion) * 100) / 100);
}
// interest_accrued_month is still set to this month (if interest was charged) so
@ -500,7 +499,7 @@ router.put('/:id', (req, res) => {
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)));
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));
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
@ -516,7 +515,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, bill.current_balance - payment.balance_delta);
const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100);
db.prepare(`
UPDATE bills
SET current_balance = ?,
@ -544,7 +543,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, bill.current_balance + payment.balance_delta);
const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100);
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
db.prepare(`
UPDATE bills
@ -557,7 +556,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(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)));
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));
});
// PATCH /api/payments/:id/attribute-to-month
@ -618,7 +617,7 @@ router.patch('/:id/attribute-to-month', (req, res) => {
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
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)));
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));
} 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,8 +4,6 @@ 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
@ -86,7 +84,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).map(serializeBill));
res.json(getDebtBills(req.user.id, ramseyMode));
});
// GET /api/snowball/settings — extra monthly payment for this user
@ -94,7 +92,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: fromCents(user?.snowball_extra_payment ?? 0),
extra_payment: 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'),
@ -120,7 +118,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(toCents(val), req.user.id);
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
}
if (ramsey_mode !== undefined) {
@ -139,7 +137,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: fromCents(user?.snowball_extra_payment ?? 0),
extra_payment: 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'),
@ -156,24 +154,16 @@ 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
: fromCents(user?.snowball_extra_payment ?? 0);
: (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 billsForMath) {
for (const b of bills) {
const snap = debtAprSnapshot(b);
if (snap) aprByBill[b.id] = snap;
}
@ -190,9 +180,9 @@ router.get('/projection', (req, res) => {
}
const now = new Date();
const snowball = enrich(calculateSnowball(billsForMath, extra, now));
const avalanche = enrich(calculateAvalanche(billsForMath, extra, now));
const minimum_only = enrich(calculateMinimumOnly(billsForMath, now));
const snowball = enrich(calculateSnowball(bills, extra, now));
const avalanche = enrich(calculateAvalanche(bills, extra, now));
const minimum_only = enrich(calculateMinimumOnly(bills, now));
// Comparison: what does the snowball save vs just paying minimums?
const comparison = buildComparison(snowball, minimum_only);
@ -280,7 +270,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 ? fromCents(bill.current_balance) : null;
const currentBalance = bill && !bill.deleted_at ? (bill.current_balance ?? null) : 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)))
@ -291,7 +281,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, extra_payment: fromCents(plan.extra_payment), plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
return { ...plan, plan_snapshot: snapshot, months_elapsed: monthsElapsed, current_debts: currentDebts };
}
// POST /api/snowball/plans — start a new snowball plan
@ -311,24 +301,15 @@ 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 now = new Date();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debtsForMath, extra, now) : calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const snowball = planMethod === 'avalanche' ? calculateAvalanche(debts, extra, now) : calculateSnowball(debts, extra, now);
const minOnly = calculateMinimumOnly(debts, now);
const interestSaved = Math.max(0, Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) / 100);
const debtSnaps = debtsForMath.map((b, i) => {
const debtSnaps = debts.map((b, i) => {
const proj = snowball.debts?.find(d => d.id === b.id);
return {
bill_id: b.id,
@ -361,7 +342,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, extraCents, planSnapshot, notes || null);
`).run(userId, planName, planMethod, extra, 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,7 +2,6 @@ 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,
@ -106,7 +105,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)); // tx.amount and payments.amount are both cents
const amount = Math.round(Math.abs(tx.amount)) / 100;
matchedCount += updateTx.run(billId, tx.id, req.user.id).changes;
if (paidDate) insertPayment.run(billId, amount, paidDate, tx.id);
}
@ -214,9 +213,9 @@ router.get('/catalog', (req, res) => {
matched_bill: bill ? {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
expected_amount: bill.expected_amount,
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(fromCents(bill.expected_amount), bill.cycle_type, bill.billing_cycle),
monthly_equivalent: monthlyEquivalent(bill.expected_amount, bill.cycle_type, bill.billing_cycle),
} : null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
};

View File

@ -4,7 +4,6 @@ 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;
@ -75,9 +74,9 @@ function buildBankTrackingSummary(db, userId, year, month) {
`).get(year, month, start, end, userId);
const balanceDollars = money(account.balance / 100);
const pendingDollars = fromCents(pendingRow.pending_total);
const pendingDollars = money(pendingRow.pending_total);
const effectiveDollars = money(balanceDollars - pendingDollars);
const unpaidDollars = fromCents(unpaidRow.unpaid_total);
const unpaidDollars = money(unpaidRow.unpaid_total);
return {
enabled: true,
@ -128,9 +127,9 @@ function getStartingAmounts(db, userId, year, month) {
`).get(userId, year, month);
return {
first_amount: fromCents(row?.first_amount || 0),
fifteenth_amount: fromCents(row?.fifteenth_amount || 0),
other_amount: fromCents(row?.other_amount || 0),
first_amount: money(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0),
};
}
@ -188,10 +187,10 @@ function calculatePaidDeductions(db, userId, year, month) {
`).get(userId, start, end);
return {
paid_from_first: fromCents(firstPaid.paid),
paid_from_fifteenth: fromCents(fifteenthPaid.paid),
paid_from_other: fromCents(otherPaid.paid),
paid_total: fromCents(totalPaid.paid),
paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid),
paid_from_other: money(otherPaid.paid),
paid_total: money(totalPaid.paid),
};
}
@ -230,7 +229,7 @@ function getIncome(db, userId, year, month) {
return {
id: row?.id || null,
label: row?.label || DEFAULT_INCOME_LABEL,
amount: fromCents(row?.amount ?? 0),
amount: money(row?.amount),
};
}
@ -285,7 +284,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: fromCents(row.paid_amount),
paid_amount: money(row.paid_amount),
});
}
}
@ -293,14 +292,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 = fromCents(hasActual ? row.actual_amount : row.expected_amount);
const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
const paidAmount = money(payment.paid_amount);
return {
bill_id: row.bill_id,
name: row.name,
expected_amount: fromCents(row.expected_amount),
actual_amount: hasActual ? fromCents(row.actual_amount) : null,
expected_amount: money(row.expected_amount),
actual_amount: hasActual ? money(row.actual_amount) : null,
display_amount: displayAmount,
is_paid: payment.payment_count > 0,
paid_amount: paidAmount,
@ -408,7 +407,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, toCents(amount));
`).run(req.user.id, parsed.year, parsed.month, label, amount);
res.json({
year: parsed.year,

View File

@ -1,7 +1,7 @@
'use strict';
const { accountingActiveSql } = require('./paymentAccountingService');
const { fromCents } = require('../utils/money');
const { roundMoney } = 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: fromCents(Math.round(median)),
suggestion: roundMoney(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, fromCents } = require('../utils/money');
const { sumMoney } = 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: fromCents(total) };
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
}).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: fromCents(expected),
actual: fromCents(actual),
expected: Number(expected.toFixed(2)),
actual: Number(actual.toFixed(2)),
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: fromCents(row.total) }))
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
.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: fromCents(paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0),
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
};
});
return {

View File

@ -4,7 +4,6 @@ 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.
@ -166,13 +165,10 @@ function applyMerchantRules(db, userId) {
const paidDate = tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
// 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 amount = Math.round(Math.abs(tx.amount)) / 100;
const bill = getBill.get(rule.bill_id);
const result = insertPayment.run(rule.bill_id, amountCents, paidDate, tx.id);
const result = insertPayment.run(rule.bill_id, amount, 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);
@ -298,13 +294,10 @@ 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;
// 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 amount = Math.round(Math.abs(tx.amount)) / 100;
const bill = getBill.get(billId);
const result = insertPayment.run(billId, amountCents, paidDate, tx.id);
const result = insertPayment.run(billId, amount, 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 { toCents, fromCents } = require('../utils/money');
const { roundMoney, mulMoney } = require('../utils/money');
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [
@ -52,19 +52,6 @@ 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
@ -291,8 +278,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 (stored as integer cents)
normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
// expected_amount
normalized.expected_amount = data.expected_amount !== undefined ? (parseFloat(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
// interest_rate
if (data.interest_rate !== undefined) {
@ -374,13 +361,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, stored as integer cents (nullable)
// current_balance — outstanding debt balance (nullable)
if (data.current_balance !== undefined) {
if (data.current_balance === null || data.current_balance === '') {
normalized.current_balance = null;
} else {
const cb = toCents(data.current_balance);
if (!Number.isInteger(cb) || cb < 0) {
const cb = parseFloat(data.current_balance);
if (!Number.isFinite(cb) || cb < 0) {
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
} else {
normalized.current_balance = cb;
@ -390,13 +377,13 @@ function validateBillData(data, existingBill = null) {
normalized.current_balance = existingBill?.current_balance ?? null;
}
// minimum_payment — required minimum payment for debt, stored as integer cents (nullable)
// minimum_payment — required minimum payment for debt (nullable)
if (data.minimum_payment !== undefined) {
if (data.minimum_payment === null || data.minimum_payment === '') {
normalized.minimum_payment = null;
} else {
const mp = toCents(data.minimum_payment);
if (!Number.isInteger(mp) || mp < 0) {
const mp = parseFloat(data.minimum_payment);
if (!Number.isFinite(mp) || mp < 0) {
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
} else {
normalized.minimum_payment = mp;
@ -486,20 +473,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); // cents
const rate = Number(bill.interest_rate) || 0; // percent
const amt = Number(paymentAmount); // cents
const bal = Number(bill.current_balance);
const rate = Number(bill.interest_rate) || 0;
const amt = Number(paymentAmount);
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 ? Math.round(bal * rate / 100 / 12) : 0; // cents
const interestDelta = applyInterest ? mulMoney(bal, rate / 100 / 12) : 0;
const raw = bal + interestDelta - amt; // cents, exact integer arithmetic
const newBalance = Math.max(0, raw);
const delta = newBalance - bal;
const raw = bal + interestDelta - amt;
const newBalance = roundMoney(Math.max(0, raw));
const delta = roundMoney(newBalance - bal);
return {
new_balance: newBalance,
@ -532,7 +519,6 @@ module.exports = {
getValidCycleTypes,
getDefaultCycleDay,
insertBill,
serializeBill,
parseTemplateData,
validateCycleDay,
parseDueDay,

View File

@ -3,7 +3,6 @@
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;
@ -232,14 +231,14 @@ function eventUid(bill, dueDate) {
function eventSummary(bill, detailLevel = 'standard') {
if (detailLevel === 'private') return 'Bill due';
if (detailLevel === 'full') return `${bill.name} due - $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`;
if (detailLevel === 'full') return `${bill.name} due - $${Number(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: $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`);
if (detailLevel === 'full') lines.push(`Expected amount: $${Number(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');
@ -334,7 +333,7 @@ function previewFeed(userId, options = {}, db = getDb()) {
bill_id: event.bill.id,
name: event.bill.name,
due_date: event.dueDate,
amount: fromCents(event.bill.expected_amount) || 0,
amount: Number(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, fromCents } = require('../utils/money');
const { roundMoney } = require('../utils/money');
const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2;
@ -53,8 +53,7 @@ function getDriftReport(userId, now = new Date()) {
`);
for (const bill of bills) {
const expectedAmount = fromCents(bill.expected_amount);
if (!expectedAmount || expectedAmount <= 0) continue;
if (!bill.expected_amount || bill.expected_amount <= 0) continue;
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
const monthTotals = [];
@ -75,15 +74,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(fromCents(total));
if (total > 0) monthTotals.push(total);
}
if (monthTotals.length < MIN_PAID_MONTHS) continue;
const recentAmount = median(monthTotals);
const delta = recentAmount - expectedAmount;
const delta = recentAmount - bill.expected_amount;
const absDelta = Math.abs(delta);
const driftPct = (delta / expectedAmount) * 100;
const driftPct = (delta / bill.expected_amount) * 100;
if (absDelta < MIN_ABS_DELTA) continue;
if (Math.abs(driftPct) < thresholdPct) continue;
@ -92,7 +91,7 @@ function getDriftReport(userId, now = new Date()) {
id: bill.id,
name: bill.name,
category_name: bill.category_name ?? null,
expected_amount: expectedAmount,
expected_amount: bill.expected_amount,
recent_amount: roundMoney(recentAmount),
drift_pct: Math.round(driftPct * 10) / 10,
direction: delta > 0 ? 'up' : 'down',

View File

@ -3,7 +3,6 @@
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);
@ -67,7 +66,7 @@ function amountDollars(transaction) {
function addAmountScore(score, reasons, transaction, bill) {
const txAmount = amountDollars(transaction);
const expected = fromCents(bill.expected_amount) || 0;
const expected = Number(bill.expected_amount) || 0;
if (txAmount <= 0 || expected <= 0) return score;
const delta = Math.abs(txAmount - expected);
@ -299,7 +298,7 @@ function listMatchSuggestions(userId, options = {}) {
bill: {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
expected_amount: bill.expected_amount,
due_day: bill.due_day,
category_name: bill.category_name || null,
},

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@
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.
@ -57,7 +56,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 ? fromCents(budgetMap.get(r.category_id) ?? null) : null,
budget: r.category_id ? (budgetMap.get(r.category_id) ?? null) : null,
};
});
@ -219,13 +218,12 @@ function merchantMatches(txMerchant, ruleMerchant) {
// ── Budgets ──────────────────────────────────────────────────────────────────
function getSpendingBudgets(db, userId, year, month) {
const rows = db.prepare(`
return 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) {
@ -238,7 +236,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, toCents(amount));
`).run(userId, categoryId, year, month, Number(amount));
}
}

View File

@ -15,7 +15,6 @@ 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 ────────────────────────────────────────────────────────────────
@ -557,7 +556,7 @@ function findBillMatches(detectedName, userBills) {
bill_name: bill.name,
category_id: bill.category_id ?? null,
category: bill.category_name || null,
expected_amount: fromCents(bill.expected_amount),
expected_amount: bill.expected_amount,
due_day: bill.due_day ?? null,
match_confidence: scored.match_confidence,
match_reason: scored.match_reason,
@ -1384,7 +1383,6 @@ 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
@ -1430,7 +1428,6 @@ 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
@ -1553,7 +1550,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', toCents(expectedAmount), String(dueDay), autopay);
`).run(userId, name, categoryId, dueDay, dueDay <= 14 ? '1st' : '15th', expectedAmount, String(dueDay), autopay);
const newBillId = ins.lastInsertRowid;
summary.created++;
@ -1663,13 +1660,10 @@ 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, payAmountCents);
`).get(billId, payDate, payAmount);
if (dup && !allowOverwrite) {
summary.skipped++;
@ -1681,17 +1675,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: fromCents(dup.amount),
existing_amount: dup.amount ?? null,
});
return;
}
const balCalcCp = computeBalanceDelta(bill, payAmountCents);
const balCalcCp = computeBalanceDelta(bill, payAmount);
db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, 'file_import')
`).run(billId, payAmountCents, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null);
`).run(billId, payAmount, payDate, decision.payment_method ?? null, decision.payment_notes ?? null, balCalcCp?.balance_delta ?? null, balCalcCp?.interest_delta ?? null);
applyBalanceDelta(db, billId, balCalcCp);

View File

@ -21,8 +21,7 @@ function pad(value) {
return String(value).padStart(2, '0');
}
const { fromCents } = require('../utils/money');
const { serializePayment } = require('./paymentValidation');
const { roundMoney, sumMoney } = require('../utils/money');
function dateString(year, month, day) {
return `${year}-${pad(month)}-${pad(day)}`;
@ -195,7 +194,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : [];
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const totalPaid = sumMoney(safePayments, p => p.amount);
if (totalPaid >= expectedAmount) return 'paid';
@ -224,11 +223,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 = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const totalPaid = sumMoney(safePayments, p => p.amount);
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
const paidTowardDue = roundMoney(Math.min(totalPaid, expectedAmount));
const overpaidAmount = roundMoney(Math.max(totalPaid - expectedAmount, 0));
const rawBalance = expectedAmount - totalPaid;
const balance = isSettled ? 0 : Math.max(rawBalance, 0);
const lastPayment = hasPayment
@ -243,16 +242,16 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
due_date: dueDate,
due_day: bill.due_day,
bucket,
expected_amount: fromCents(expectedAmount),
expected_amount: expectedAmount,
notes: bill.notes || null, // Bill-level notes (always available)
total_paid: fromCents(totalPaid),
paid_toward_due: fromCents(paidTowardDue),
overpaid_amount: fromCents(overpaidAmount),
balance: fromCents(balance),
total_paid: totalPaid,
paid_toward_due: paidTowardDue,
overpaid_amount: overpaidAmount,
balance,
has_payment: hasPayment,
is_settled: isSettled,
last_paid_date: lastPayment ? lastPayment.paid_date : null,
last_payment_amount: lastPayment ? fromCents(lastPayment.amount) : null,
last_payment_amount: lastPayment ? lastPayment.amount : null,
status,
autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status,
@ -260,8 +259,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 ? fromCents(bill.current_balance) : null,
minimum_payment: bill.minimum_payment != null ? fromCents(bill.minimum_payment) : null,
current_balance: bill.current_balance ?? null,
minimum_payment: bill.minimum_payment ?? null,
interest_rate: bill.interest_rate ?? null,
is_subscription: !!bill.is_subscription,
has_2fa: !!bill.has_2fa,
@ -273,7 +272,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.map(serializePayment),
payments: safePayments,
};
}
@ -286,4 +285,5 @@ 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, fromCents } = require('../utils/money');
const { roundMoney, sumMoney, mulMoney } = require('../utils/money');
const SUBSCRIPTION_TYPES = [
'streaming', 'software', 'cloud', 'music', 'news',
@ -494,13 +494,9 @@ function nextDueDate(bill, now = new Date()) {
}
function decorateSubscription(bill) {
const expectedAmount = fromCents(bill.expected_amount);
const monthly = monthlyEquivalent(expectedAmount, bill.cycle_type, bill.billing_cycle);
const monthly = monthlyEquivalent(bill.expected_amount, 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,
@ -670,7 +666,7 @@ function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmoun
if (score === 0) continue;
const expected = fromCents(bill.expected_amount) || 0;
const expected = Number(bill.expected_amount || 0);
const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
if (amountDelta !== null) {
const pct = expected ? amountDelta / expected : 1;
@ -1098,7 +1094,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)); // tx.amount and payments.amount are both cents
const amount = Math.round(Math.abs(tx.amount)) / 100;
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 } = require('./statusService');
const { buildTrackerRow, getCycleRange, resolveDueDate, roundMoney } = 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, roundMoney, fromCents } = require('../utils/money');
const { sumMoney } = 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 = fromCents(pendingRow.pending_total);
const pending = roundMoney(pendingRow.pending_total);
const effective = roundMoney(balance - pending);
const unpaid = fromCents(unpaidRow.unpaid_total);
const unpaid = roundMoney(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(fromCents(r.total));
out[r.bill_id].push(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: fromCents(suggestedAmount),
amount: 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: fromCents(monthlyPaymentsMap.get(monthKey) || 0),
payment: parseFloat(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 = fromCents(bill.expected_amount);
row.actual_amount = mbs?.actual_amount != null ? fromCents(mbs.actual_amount) : null;
row.expected_amount = bill.expected_amount;
row.actual_amount = 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 = fromCents(prevMonthPayments[bill.id] || 0);
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
return row;
}).filter(Boolean);
@ -476,13 +476,6 @@ 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);
@ -641,7 +634,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
name: bill.name,
category_name: bill.category_name,
due_date: dueDate,
expected_amount: row.expected_amount,
expected_amount: bill.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 { serializePayment } = require('./paymentValidation');
const { roundMoney } = require('../utils/money');
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)); // tx.amount and payments.amount are both cents
return Math.round(Math.abs(cents)) / 100;
}
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, Number(bill.current_balance) - Number(payment.balance_delta)); // cents, exact integer arithmetic
const restored = Math.max(0, roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)));
// Clear interest_accrued_month when reversing a payment that charged interest,
// so the re-applied payment can accrue interest fresh.
db.prepare(`
@ -220,7 +220,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) {
SET deleted_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?
`).run(existingPayment.id);
return { ...serializePayment(existingPayment), deleted: true };
return { ...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 { ...serializePayment(existingPayment), unlinked: true };
return { ...existingPayment, unlinked: true };
}
function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) {
return {
success: true,
transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)),
payment: serializePayment(getPaymentForResponse(db, userId, paymentId)),
payment: getPaymentForResponse(db, userId, paymentId),
...extra,
};
}

View File

@ -7,7 +7,6 @@ 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;
@ -132,28 +131,7 @@ function sanitizeCategory(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) {
function sanitizeBill(row) {
const name = cleanText(row.name, 160);
const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null;
@ -171,7 +149,7 @@ function sanitizeBill(row, sourceIsCents) {
due_day: dueDay,
override_due_date: cleanText(row.override_due_date, 32),
bucket: dueDay <= 14 ? '1st' : '15th',
expected_amount: importMoney(Math.max(0, toNumber(row.expected_amount, 0) ?? 0), sourceIsCents),
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
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,
@ -189,7 +167,7 @@ function sanitizeBill(row, sourceIsCents) {
};
}
function sanitizePayment(row, validBillIds, sourceIsCents) {
function sanitizePayment(row, validBillIds) {
const billId = toInt(row.bill_id);
const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date);
@ -198,7 +176,7 @@ function sanitizePayment(row, validBillIds, sourceIsCents) {
return {
old_id: toInt(row.id),
bill_id: billId,
amount: importMoney(amount, sourceIsCents),
amount,
paid_date: paidDate,
method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000),
@ -209,7 +187,7 @@ function sanitizePayment(row, validBillIds, sourceIsCents) {
};
}
function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
function sanitizeMonthlyState(row, validBillIds) {
const billId = toInt(row.bill_id);
const year = toInt(row.year);
const month = toInt(row.month);
@ -220,7 +198,7 @@ function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
bill_id: billId,
year,
month,
actual_amount: actual == null || actual < 0 ? null : importMoney(actual, sourceIsCents),
actual_amount: actual == null || actual < 0 ? null : actual,
notes: cleanText(row.notes, 2000),
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
created_at: cleanText(row.created_at, 32),
@ -228,7 +206,7 @@ function sanitizeMonthlyState(row, validBillIds, sourceIsCents) {
};
}
function sanitizeMonthlyStartingAmounts(row, sourceIsCents) {
function sanitizeMonthlyStartingAmounts(row) {
const year = toInt(row.year);
const month = toInt(row.month);
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
@ -236,9 +214,9 @@ function sanitizeMonthlyStartingAmounts(row, sourceIsCents) {
old_id: toInt(row.id),
year,
month,
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),
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),
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
@ -256,25 +234,23 @@ 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(row => sanitizeBill(row, sourceIsCents)).filter(Boolean);
]).map(sanitizeBill).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, sourceIsCents)).filter(Boolean);
.map(row => sanitizePayment(row, validBillIds)).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, sourceIsCents)).filter(Boolean);
.map(row => sanitizeMonthlyState(row, validBillIds)).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(row => sanitizeMonthlyStartingAmounts(row, sourceIsCents)).filter(Boolean)
.map(sanitizeMonthlyStartingAmounts).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 (?, ?, ?, 2500)
VALUES (?, ?, ?, 25)
`).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 || 12345,
overrides.expected_amount || 123.45,
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: 10000 }),
[{ amount: 12500, paid_date: '2026-05-10' }],
bill({ expected_amount: 100 }),
[{ amount: 125, 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 ?? 1599,
overrides.expected_amount ?? 15.99,
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: 1599,
expected_amount: 15.99,
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, 8500)
VALUES (?, ?, 16, 85)
`).run(userId, name).lastInsertRowid;
}
@ -68,7 +68,7 @@ function createManualPayment(db, billId, overrides = {}) {
VALUES (?, ?, ?, ?, ?, ?)
`).run(
billId,
overrides.amount ?? 8500,
overrides.amount ?? 85,
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, 8500);
assert.equal(payment.amount, 85);
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: 6500,
amount: 65,
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: 8500,
amount: 85,
notes: 'Marked paid while waiting for bank clear',
});
const transactionId = createTransaction(db, userId, {