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