BillTracker/routes/bills.cts

1886 lines
66 KiB
TypeScript
Raw Normal View History

// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http';
2026-05-03 19:51:57 -05:00
const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
2026-05-16 15:38:28 -05:00
const {
auditBillsForUser,
categoryBelongsToUser,
insertBill,
serializeBill,
2026-05-16 15:38:28 -05:00
parseTemplateData,
sanitizeTemplateData,
validateBillData,
computeBalanceDelta,
applyBalanceDelta,
} = require('../services/billsService.cts');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const {
addMerchantRule,
syncBillPaymentsFromSimplefin,
merchantMatches,
} = require('../services/billMerchantRuleService.cts');
const { normalizeMerchant } = require('../services/subscriptionService.cts');
const { decorateTransaction } = require('../services/transactionService.cts');
const {
accountingActiveSql,
applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService.cts');
const { localDateString, todayLocal } = require('../utils/dates.mts');
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts');
2026-05-03 19:51:57 -05:00
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
// LEFT JOIN on pre-grouped subqueries — one query instead of N+1 correlated EXISTS lookups.
const bills = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT b.*, c.name AS category_name,
CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges,
CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr ON hr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
2026-05-03 19:51:57 -05:00
WHERE b.user_id = ?
2026-05-16 10:34:32 -05:00
AND b.deleted_at IS NULL
2026-05-03 19:51:57 -05:00
${includeInactive ? '' : 'AND b.active = 1'}
2026-05-30 16:13:37 -05:00
ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC
`,
)
.all(req.user.id);
res.json(bills.map(serializeBill));
2026-05-16 10:56:56 -05:00
});
// ── GET /api/bills/deleted ────────────────────────────────────────────────────
// Soft-deleted bills still inside the 30-day recovery window (before the
// retention GC purges them), newest deletion first. Powers the "Recently
// deleted" restore view. Must be declared before GET /:id.
const BILL_RETENTION_DAYS = 30; // matches pruneSoftDeletedFinancialRecords()
router.get('/deleted', (req: Req, res: Res) => {
const db = getDb();
const rows = db
.prepare(
`
SELECT b.*, c.name AS category_name,
CAST(julianday(b.deleted_at, '+${BILL_RETENTION_DAYS} days') - julianday('now') AS INTEGER) AS days_left
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ?
AND b.deleted_at IS NOT NULL
AND b.deleted_at >= datetime('now', '-${BILL_RETENTION_DAYS} days')
ORDER BY b.deleted_at DESC
`,
)
.all(req.user.id);
res.json(
rows.map((row) => ({
...serializeBill(row),
category_name: row.category_name,
deleted_at: row.deleted_at,
days_left: Math.max(0, row.days_left),
})),
);
});
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/reorder ───────────────────────────────────────────────────
router.put('/reorder', (req: Req, res: Res) => {
2026-05-30 16:13:37 -05:00
const db = getDb();
const entries = Object.entries(req.body || {}).map(([billId, sortOrder]) => ({
billId: Number(billId),
sortOrder: Number(sortOrder),
}));
if (entries.length === 0) {
return res
.status(400)
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
2026-05-30 16:13:37 -05:00
}
const invalid = entries.find(
({ billId, sortOrder }) =>
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
);
2026-05-30 16:13:37 -05:00
if (invalid) {
return res
.status(400)
.json(
standardizeError(
'Reorder payload must map bill ids to non-negative integer positions',
'VALIDATION_ERROR',
'reorder',
),
);
2026-05-30 16:13:37 -05:00
}
const ids = entries.map((item) => item.billId);
2026-05-30 16:13:37 -05:00
const placeholders = ids.map(() => '?').join(',');
const owned = db
.prepare(
`
2026-05-30 16:13:37 -05:00
SELECT id
FROM bills
WHERE user_id = ? AND deleted_at IS NULL AND id IN (${placeholders})
`,
)
.all(req.user.id, ...ids);
2026-05-30 16:13:37 -05:00
if (owned.length !== ids.length) {
return res
.status(404)
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
2026-05-30 16:13:37 -05:00
}
const update = db.prepare(
"UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
);
2026-05-30 16:13:37 -05:00
const applyOrder = db.transaction((items) => {
for (const item of items) update.run(item.sortOrder, item.billId, req.user.id);
});
applyOrder(entries);
const bills = db
.prepare(
`
2026-05-30 16:13:37 -05:00
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.user_id = ? AND b.deleted_at IS NULL 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);
2026-05-30 16:13:37 -05:00
res.json({ success: true, bills: bills.map(serializeBill) });
2026-05-30 16:13:37 -05:00
});
2026-05-16 10:56:56 -05:00
// ── GET /api/bills/audit?inactive=true ───────────────────────────────────────
router.get('/audit', (req: Req, res: Res) => {
2026-05-16 10:56:56 -05:00
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
2026-05-16 15:38:28 -05:00
res.json(auditBillsForUser(db, req.user.id, includeInactive));
});
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts');
try {
res.json(getDriftReport(req.user.id));
} catch (err) {
res.status(500).json({ error: 'Failed to compute drift report' });
}
});
// GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req: Req, res: Res) => {
try {
const rules = getDb()
.prepare(
`
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
b.id AS bill_id, b.name AS bill_name
FROM bill_merchant_rules bmr
JOIN bills b ON b.id = bmr.bill_id AND b.deleted_at IS NULL
WHERE bmr.user_id = ?
ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC
`,
)
.all(req.user.id);
res.json({ rules });
} catch (err) {
console.error('[bills/merchant-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load merchant rules' });
}
});
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
// Registered early (before /:id) but path has suffix so no conflict
router.post('/:id/snooze-drift', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const bill = db
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
.get(id);
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
const until = new Date();
until.setDate(until.getDate() + 30);
const untilStr = localDateString(until);
db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id);
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;
}
2026-05-16 15:38:28 -05:00
// ── GET /api/bills/templates ─────────────────────────────────────────────────
router.get('/templates', (req: Req, res: Res) => {
2026-05-16 15:38:28 -05:00
const db = getDb();
const rows = db
.prepare(
`
2026-05-16 15:38:28 -05:00
SELECT id, name, data, created_at, updated_at
FROM bill_templates
WHERE user_id = ?
ORDER BY name COLLATE NOCASE ASC
`,
)
.all(req.user.id);
res.json(
rows.map((row) => ({
...row,
data: serializeTemplateData(parseTemplateData(row.data)),
})),
);
2026-05-16 15:38:28 -05:00
});
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
// ── POST /api/bills/templates ────────────────────────────────────────────────
router.post('/templates', (req: Req, res: Res) => {
2026-05-16 15:38:28 -05:00
const db = getDb();
const name = String(req.body.name || '').trim();
if (name.length < 2) {
return res
.status(400)
.json(
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
);
2026-05-16 15:38:28 -05:00
}
2026-05-16 10:56:56 -05:00
2026-05-16 15:38:28 -05:00
const data = sanitizeTemplateData(req.body.data || {});
if (Object.keys(data).length === 0) {
return res
.status(400)
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
2026-05-16 15:38:28 -05:00
}
const validation = validateBillData(data);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
2026-05-16 15:38:28 -05:00
}
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'data.category_id',
),
);
2026-05-16 15:38:28 -05:00
}
const normalizedData = sanitizeTemplateData(validation.normalized);
2026-05-16 10:56:56 -05:00
const result = db
.prepare(
`
2026-05-16 15:38:28 -05:00
INSERT INTO bill_templates (user_id, name, data, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, name) DO UPDATE SET
data = excluded.data,
updated_at = datetime('now')
`,
)
.run(req.user.id, name, JSON.stringify(normalizedData));
2026-05-16 15:38:28 -05:00
const template = db
.prepare(
`
2026-05-16 15:38:28 -05:00
SELECT id, name, data, created_at, updated_at
FROM bill_templates
WHERE user_id = ? AND name = ?
`,
)
.get(req.user.id, name);
2026-05-16 15:38:28 -05:00
res.status(result.changes > 0 ? 201 : 200).json({
...template,
data: serializeTemplateData(parseTemplateData(template.data)),
2026-05-16 10:56:56 -05:00
});
2026-05-03 19:51:57 -05:00
});
2026-05-16 15:38:28 -05:00
// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
router.delete('/templates/:templateId', (req: Req, res: Res) => {
2026-05-16 15:38:28 -05:00
const db = getDb();
const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) {
return res
.status(400)
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
2026-05-16 15:38:28 -05:00
}
const result = db
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
.run(templateId, req.user.id);
if (result.changes === 0)
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
2026-05-16 15:38:28 -05:00
res.json({ success: true });
});
// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
router.post('/:id/duplicate', (req: Req, res: Res) => {
2026-05-16 15:38:28 -05:00
const db = getDb();
const body = req.body || {};
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
2026-05-16 15:38:28 -05:00
}
const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id);
if (!source)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-16 15:38:28 -05:00
const draft = {
...sanitizeTemplateData(serializeBill(source)),
2026-05-16 15:38:28 -05:00
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
const validation = validateBillData(draft);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
2026-05-16 15:38:28 -05:00
}
const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
2026-05-16 15:38:28 -05:00
}
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
2026-05-16 15:38:28 -05:00
});
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
router.get('/:id/monthly-state', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const year = parseInt(req.query.year, 10);
2026-05-03 19:51:57 -05:00
const month = parseInt(req.query.month, 10);
if (isNaN(year) || year < 2000 || year > 2100)
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(month) || month < 1 || month > 12)
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
const mbs = db
.prepare(
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?',
)
.get(billId, year, month);
2026-05-03 19:51:57 -05:00
res.json({
bill_id: billId,
2026-05-03 19:51:57 -05:00
year,
month,
actual_amount: fromCents(mbs?.actual_amount),
notes: mbs?.notes ?? null,
is_skipped: !!mbs?.is_skipped,
2026-05-03 19:51:57 -05:00
});
});
// ── PUT /api/bills/:id/monthly-state ──────────────────────────────────────────
router.put('/:id/monthly-state', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
2026-05-03 19:51:57 -05:00
const y = parseInt(year, 10);
2026-05-03 19:51:57 -05:00
const m = parseInt(month, 10);
if (isNaN(y) || y < 2000 || y > 2100)
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
2026-05-03 19:51:57 -05:00
if (isNaN(m) || m < 1 || m > 12)
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
2026-05-03 19:51:57 -05:00
if (actual_amount !== undefined && actual_amount !== null) {
const amt = parseFloat(actual_amount);
if (isNaN(amt) || amt < 0)
return res
.status(400)
.json(
standardizeError(
'actual_amount must be a non-negative number or null',
'VALIDATION_ERROR',
'actual_amount',
),
);
2026-05-03 19:51:57 -05:00
}
if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
return res
.status(400)
.json(
standardizeError(
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'VALIDATION_ERROR',
'snoozed_until',
),
);
}
// Partial-update semantics: fields omitted from the request keep their
// existing values instead of being wiped by the upsert.
const existing = db
.prepare(
'SELECT actual_amount, notes, is_skipped, snoozed_until FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?',
)
.get(billId, y, m);
const amt =
actual_amount !== undefined
? actual_amount === null
? null
: toCents(actual_amount)
: (existing?.actual_amount ?? null);
const 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);
db.prepare(
`
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, snoozed_until, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
2026-05-03 19:51:57 -05:00
ON CONFLICT(bill_id, year, month) DO UPDATE SET
actual_amount = excluded.actual_amount,
notes = excluded.notes,
is_skipped = excluded.is_skipped,
snoozed_until = excluded.snoozed_until,
2026-05-03 19:51:57 -05:00
updated_at = datetime('now')
`,
).run(billId, y, m, amt, noteVal, skipVal, snoozeVal);
2026-05-03 19:51:57 -05:00
const saved = db
.prepare('SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?')
.get(billId, y, m);
2026-05-03 19:51:57 -05:00
res.json({
bill_id: saved.bill_id,
year: saved.year,
month: saved.month,
actual_amount: fromCents(saved.actual_amount),
notes: saved.notes,
is_skipped: !!saved.is_skipped,
snoozed_until: saved.snoozed_until ?? null,
created_at: saved.created_at,
updated_at: saved.updated_at,
2026-05-03 19:51:57 -05:00
});
});
// ── GET /api/bills/:id ────────────────────────────────────────────────────────
router.get('/:id', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const bill = db
.prepare(
`
2026-05-03 19:51:57 -05:00
SELECT b.*, c.name AS category_name,
CASE WHEN EXISTS(
SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id
) THEN 1 ELSE 0 END AS has_history_ranges,
CASE WHEN EXISTS(
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
) THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN EXISTS(
SELECT 1 FROM transactions WHERE matched_bill_id = b.id AND match_status = 'matched' AND user_id = b.user_id
) THEN 1 ELSE 0 END AS has_linked_transactions
2026-05-03 19:51:57 -05:00
FROM bills b
2026-05-16 10:34:32 -05:00
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL
`,
)
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
let autopay_stats = null;
if (bill.autopay_enabled) {
autopay_stats = db
.prepare(
`
SELECT COUNT(*) AS total,
SUM(autopay_failure) AS failures,
MAX(CASE WHEN autopay_failure = 1 THEN paid_date END) AS last_failure_date,
MAX(CASE WHEN autopay_failure = 1 THEN notes END) AS last_failure_notes
FROM payments
WHERE bill_id = ? AND deleted_at IS NULL AND paid_date >= date('now', '-12 months')
`,
)
.get(bill.id);
autopay_stats = {
total: autopay_stats.total || 0,
failures: autopay_stats.failures || 0,
last_failure_date: autopay_stats.last_failure_date || null,
last_failure_notes: autopay_stats.last_failure_notes || null,
};
}
res.json({ ...serializeBill(bill), autopay_stats });
});
// ── POST /api/bills/:id/verify-autopay ───────────────────────────────────────
router.post('/:id/verify-autopay', (req: Req, res: Res) => {
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
}
const bill = db
.prepare(
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
)
.get(id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill.autopay_enabled) {
return res
.status(400)
.json(
standardizeError(
'Bill does not have autopay enabled',
'VALIDATION_ERROR',
'autopay_enabled',
),
);
}
const now = new Date().toISOString();
db.prepare(
'UPDATE bills SET autopay_verified_at = ?, updated_at = ? WHERE id = ? AND user_id = ?',
).run(now, now, id, req.user.id);
res.json({ ok: true, autopay_verified_at: now });
2026-05-03 19:51:57 -05:00
});
// ── POST /api/bills ───────────────────────────────────────────────────────────
router.post('/', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
2026-05-16 15:38:28 -05:00
const body = req.body || {};
let payload = body;
if (
body.source_bill_id !== undefined &&
body.source_bill_id !== null &&
body.source_bill_id !== ''
) {
2026-05-16 15:38:28 -05:00
const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) {
return res
.status(400)
.json(
standardizeError(
'source_bill_id must be an integer',
'VALIDATION_ERROR',
'source_bill_id',
),
);
2026-05-16 15:38:28 -05:00
}
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'));
2026-05-16 15:38:28 -05:00
payload = {
...sanitizeTemplateData(serializeBill(source)),
2026-05-16 15:38:28 -05:00
...sanitizeTemplateData(body),
name: String(body.name || `${source.name} (Copy)`).trim(),
};
}
2026-05-03 19:51:57 -05:00
// Validate and normalize bill data
2026-05-16 15:38:28 -05:00
const validation = validateBillData(payload);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
2026-05-03 19:51:57 -05:00
}
const { normalized } = validation;
2026-05-03 19:51:57 -05:00
// Validate category_id exists for this user
2026-05-16 15:38:28 -05:00
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
2026-05-03 19:51:57 -05:00
}
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
2026-05-03 19:51:57 -05:00
});
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
router.put('/:id', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!existing)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
// Validate and normalize bill data
const validation = validateBillData(req.body, existing);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
2026-05-03 19:51:57 -05:00
}
const { normalized } = validation;
2026-05-03 19:51:57 -05:00
// Validate category_id exists for this user if changed
2026-05-16 15:38:28 -05:00
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
}
const inactiveReason =
typeof req.body.inactive_reason === 'string' ? req.body.inactive_reason.trim() || null : null;
const wasActive = existing.active === 1;
const nowActive = normalized.active === 1;
const inactivatedAt =
!wasActive || nowActive
? existing.inactivated_at
: inactiveReason
? todayLocal()
: existing.inactivated_at;
db.prepare(
`
2026-05-03 19:51:57 -05:00
UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
2026-05-16 15:38:28 -05:00
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, auto_mark_paid = ?,
2026-05-03 19:51:57 -05:00
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?,
2026-05-14 03:00:01 -05:00
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
is_subscription = ?, subscription_type = ?, reminder_days_before = ?, subscription_source = ?, subscription_detected_at = ?,
inactive_reason = ?, inactivated_at = ?,
2026-05-03 19:51:57 -05:00
updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`,
).run(
normalized.name,
normalized.category_id,
normalized.due_day,
normalized.override_due_date,
normalized.bucket,
normalized.expected_amount,
normalized.interest_rate,
normalized.billing_cycle,
normalized.autopay_enabled,
normalized.autodraft_status,
2026-05-16 15:38:28 -05:00
normalized.auto_mark_paid,
normalized.website,
normalized.username,
normalized.account_info,
normalized.has_2fa,
normalized.notes,
normalized.active,
normalized.history_visibility,
normalized.cycle_type,
normalized.cycle_day,
2026-05-14 02:11:54 -05:00
normalized.current_balance,
normalized.minimum_payment,
normalized.snowball_order,
normalized.snowball_include,
2026-05-14 03:00:01 -05:00
normalized.snowball_exempt,
normalized.is_subscription,
normalized.subscription_type,
normalized.reminder_days_before,
normalized.subscription_source,
normalized.subscription_detected_at,
inactiveReason,
inactivatedAt,
2026-05-03 19:51:57 -05:00
req.params.id,
req.user.id,
);
const updated = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
res.json(serializeBill(updated));
2026-05-03 19:51:57 -05:00
});
2026-05-30 16:13:37 -05:00
// ── PUT /api/bills/:id/archived ──────────────────────────────────────────────
router.put('/:id/archived', (req: Req, res: Res) => {
2026-05-30 16:13:37 -05:00
const db = getDb();
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
}
const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-30 16:13:37 -05:00
const archived = !!req.body?.archived;
db.prepare(
"UPDATE bills SET active = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
).run(archived ? 0 : 1, id, req.user.id);
2026-05-30 16:13:37 -05:00
const updated = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id);
res.json({ ...serializeBill(updated), archived: !updated.active });
2026-05-30 16:13:37 -05:00
});
2026-05-16 10:34:32 -05:00
// ── DELETE /api/bills/:id — soft delete for 30-day recovery ───────────────────
router.delete('/:id', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
db.prepare(
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
).run(req.params.id, req.user.id);
2026-05-03 19:51:57 -05:00
res.json({
success: true,
deleted_bill_id: bill.id,
2026-05-03 19:51:57 -05:00
deleted_bill_name: bill.name,
2026-05-16 10:34:32 -05:00
recoverable_until_days: 30,
2026-05-03 19:51:57 -05:00
});
});
2026-05-16 10:34:32 -05:00
// POST /api/bills/:id/restore — undo bill soft delete
router.post('/:id/restore', (req: Req, res: Res) => {
2026-05-16 10:34:32 -05:00
const db = getDb();
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare(
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
).run(req.params.id, req.user.id);
res.json(
serializeBill(
db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id),
),
);
2026-05-16 10:34:32 -05:00
});
// POST /api/bills/:id/sync-simplefin-payments
// Scan unmatched SimpleFIN transactions for this bill's merchant rules and
// backfill any missing payments.
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId))
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const db = getDb();
const bill = db
.prepare('SELECT id 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'));
try {
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
}
});
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
router.get('/:id/payments', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id);
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
2026-05-03 19:51:57 -05:00
const offset = (page - 1) * limit;
const total = db
.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ? AND deleted_at IS NULL')
.get(req.params.id).n;
2026-05-03 19:51:57 -05:00
const items = db
.prepare(
'SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL ORDER BY paid_date DESC LIMIT ? OFFSET ?',
)
.all(req.params.id, limit, offset);
2026-05-03 19:51:57 -05:00
res.json({
bill_id: parseInt(req.params.id, 10),
bill_name: bill.name,
2026-05-03 19:51:57 -05:00
total,
page,
limit,
pages: Math.ceil(total / limit),
payments: items.map(serializePayment),
2026-05-03 19:51:57 -05:00
});
});
// ── GET /api/bills/:id/transactions ──────────────────────────────────────────
router.get('/:id/transactions', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) {
return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
const bill = db
.prepare('SELECT id, name 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 rows = db
.prepare(
`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name,
p.id AS linked_payment_id,
p.amount AS linked_payment_amount,
p.paid_date AS linked_payment_date,
p.payment_source AS linked_payment_source,
p.method AS linked_payment_method
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
JOIN payments p ON p.transaction_id = t.id AND p.bill_id = ? AND p.deleted_at IS NULL
WHERE t.user_id = ?
AND t.matched_bill_id = ?
AND t.match_status = 'matched'
AND t.ignored = 0
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
`,
)
.all(billId, req.user.id, billId);
const transactions = rows.map((row) =>
decorateTransaction({
...row,
linked_payment: row.linked_payment_id
? {
id: row.linked_payment_id,
amount: fromCents(row.linked_payment_amount),
paid_date: row.linked_payment_date,
payment_source: row.linked_payment_source,
method: row.linked_payment_method,
}
: null,
}),
);
res.json({
bill_id: billId,
bill_name: bill.name,
total: transactions.length,
transactions,
});
});
2026-05-09 13:03:36 -05:00
// ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ──────────────
router.post('/:id/toggle-paid', (req: Req, res: Res) => {
2026-05-09 13:03:36 -05:00
const db = getDb();
const billId = parseInt(req.params.id, 10);
// Get bill - always scope to the requesting user
const bill = db
.prepare(
'SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
)
.get(billId, req.user.id);
2026-05-09 13:03:36 -05:00
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-09 13:03:36 -05:00
// Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
2026-05-15 22:45:38 -05:00
if ((year === null) !== (month === null)) {
return res
.status(400)
.json(
standardizeError(
'year and month must both be provided or both omitted',
'VALIDATION_ERROR',
'year',
),
);
2026-05-15 22:45:38 -05:00
}
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
2026-05-15 22:45:38 -05:00
}
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
2026-05-15 22:45:38 -05:00
}
let currentPayment;
if (year !== null && month !== null) {
currentPayment = db
.prepare(
`SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()} AND strftime('%Y', paid_date) = ? AND strftime('%m', paid_date) = ? ORDER BY paid_date DESC LIMIT 1`,
)
.get(billId, String(year), String(month).padStart(2, '0'));
} else {
currentPayment = db
.prepare(
`SELECT * FROM payments WHERE bill_id = ? AND deleted_at IS NULL AND ${accountingActiveSql()} ORDER BY paid_date DESC LIMIT 1`,
)
.get(billId);
}
2026-05-09 13:03:36 -05:00
// If paid (has payment), remove it → Unpaid
if (currentPayment) {
2026-05-14 02:11:54 -05:00
// Reverse any balance delta that was applied when this payment was created
if (currentPayment.balance_delta != null) {
const freshBill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId);
if (freshBill?.current_balance != null) {
const restored = Math.max(0, freshBill.current_balance - currentPayment.balance_delta);
db.prepare(
"UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?",
).run(restored, billId);
2026-05-14 02:11:54 -05:00
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(
currentPayment.id,
);
2026-05-09 13:03:36 -05:00
res.json({
success: true,
isPaid: false,
action: 'removed_payment',
paymentId: currentPayment.id,
});
return;
}
// If unpaid, create payment → Paid
// Use expected_amount if no amount provided
const amount = req.body.amount !== undefined ? req.body.amount : fromCents(bill.expected_amount);
// Determine paid_date
let paidDate = req.body.paid_date;
if (!paidDate && year !== null && month !== null) {
// Calculate paid_date from bill's due_day clamped to the month's days
const daysInMonth = new Date(year, month, 0).getDate();
const day = Math.min(Math.max(Number(bill.due_day), 1), daysInMonth);
paidDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
} else if (!paidDate) {
paidDate = todayLocal();
}
2026-05-09 13:03:36 -05:00
const method = req.body.method || null;
const notes = req.body.notes || null;
2026-05-15 22:45:38 -05:00
const paymentValidation = validatePaymentInput(
2026-05-16 20:26:09 -05:00
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
2026-05-15 22:45:38 -05:00
{ requireBillId: false },
);
if (paymentValidation.error) {
return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
2026-05-09 13:03:36 -05:00
}
2026-05-15 22:45:38 -05:00
const payment = paymentValidation.normalized;
2026-05-09 13:03:36 -05:00
2026-05-14 02:11:54 -05:00
// Compute balance delta for debt bills before inserting
2026-05-15 22:45:38 -05:00
const balCalc = computeBalanceDelta(bill, payment.amount);
2026-05-14 02:11:54 -05:00
const result = db
.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(
billId,
payment.amount,
payment.paid_date,
method,
notes,
balCalc?.balance_delta ?? null,
balCalc?.interest_delta ?? null,
payment.payment_source,
);
2026-05-14 02:11:54 -05:00
applyBalanceDelta(db, billId, balCalc);
2026-05-09 13:03:36 -05:00
res.status(201).json({
success: true,
isPaid: true,
action: 'created_payment',
payment: serializePayment(
db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid),
),
2026-05-09 13:03:36 -05:00
});
});
2026-05-03 19:51:57 -05:00
// ── GET /api/bills/:id/history-ranges ────────────────────────────────────────
router.get('/:id/history-ranges', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const ranges = db
.prepare(
'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC',
)
.all(req.params.id);
2026-05-03 19:51:57 -05:00
const bill = db
.prepare('SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL')
.get(req.params.id);
2026-05-03 19:51:57 -05:00
res.json({
bill_id: parseInt(req.params.id, 10),
history_visibility: bill.history_visibility,
ranges,
});
2026-05-03 19:51:57 -05:00
});
// ── POST /api/bills/:id/history-ranges ───────────────────────────────────────
router.post('/:id/history-ranges', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = parseInt(start_year, 10);
const sm = parseInt(start_month, 10);
if (isNaN(sy) || sy < 2000 || sy > 2100)
return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
2026-05-03 19:51:57 -05:00
if (isNaN(sm) || sm < 1 || sm > 12)
return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = null,
em = null;
2026-05-03 19:51:57 -05:00
if (end_year != null) {
ey = parseInt(end_year, 10);
if (isNaN(ey) || ey < 2000 || ey > 2100)
return res
.status(400)
.json(
standardizeError(
'end_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'end_year',
),
);
2026-05-03 19:51:57 -05:00
}
if (end_month != null) {
em = parseInt(end_month, 10);
if (isNaN(em) || em < 1 || em > 12)
return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
2026-05-03 19:51:57 -05:00
}
if ((ey == null) !== (em == null)) {
return res
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
2026-05-03 19:51:57 -05:00
}
if (ey != null) {
const startVal = sy * 12 + sm;
const endVal = ey * 12 + em;
2026-05-03 19:51:57 -05:00
if (endVal < startVal)
return res
.status(400)
.json(
standardizeError(
'end date must be on or after start date',
'VALIDATION_ERROR',
'end_year',
),
);
2026-05-03 19:51:57 -05:00
}
const result = db
.prepare(
`
2026-05-03 19:51:57 -05:00
INSERT INTO bill_history_ranges (bill_id, start_year, start_month, end_year, end_month, label)
VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(req.params.id, sy, sm, ey, em, label || null);
2026-05-03 19:51:57 -05:00
const created = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ?')
.get(result.lastInsertRowid);
2026-05-03 19:51:57 -05:00
res.status(201).json(created);
});
// ── PUT /api/bills/:id/history-ranges/:rangeId ───────────────────────────────
router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const range = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
2026-05-03 19:51:57 -05:00
.get(req.params.rangeId, req.params.id);
if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
2026-05-03 19:51:57 -05:00
const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = start_year != null ? parseInt(start_year, 10) : range.start_year;
const sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
if (isNaN(sy) || sy < 2000 || sy > 2100)
return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
2026-05-03 19:51:57 -05:00
if (isNaN(sm) || sm < 1 || sm > 12)
return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
2026-05-03 19:51:57 -05:00
let ey = range.end_year;
let em = range.end_month;
if (end_year !== undefined) ey = end_year != null ? parseInt(end_year, 10) : null;
if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null;
if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
return res
.status(400)
.json(
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
);
2026-05-03 19:51:57 -05:00
if (em != null && (isNaN(em) || em < 1 || em > 12))
return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
2026-05-03 19:51:57 -05:00
if ((ey == null) !== (em == null))
return res
.status(400)
.json(
standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
if (ey != null && ey * 12 + em < sy * 12 + sm)
return res
.status(400)
.json(
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
);
db.prepare(
`
2026-05-03 19:51:57 -05:00
UPDATE bill_history_ranges
SET start_year = ?, start_month = ?, end_year = ?, end_month = ?, label = ?,
updated_at = datetime('now')
WHERE id = ? AND bill_id = ?
`,
).run(
sy,
sm,
ey,
em,
label !== undefined ? label || null : range.label,
req.params.rangeId,
req.params.id,
);
2026-05-03 19:51:57 -05:00
const updated = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ?')
.get(req.params.rangeId);
2026-05-03 19:51:57 -05:00
res.json(updated);
});
// ── DELETE /api/bills/:id/history-ranges/:rangeId ────────────────────────────
router.delete('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
2026-05-03 19:51:57 -05:00
const db = getDb();
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id)
)
2026-05-09 13:03:36 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
2026-05-03 19:51:57 -05:00
const range = db
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
2026-05-03 19:51:57 -05:00
.get(req.params.rangeId, req.params.id);
if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
2026-05-03 19:51:57 -05:00
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
req.params.rangeId,
req.params.id,
);
2026-05-03 19:51:57 -05:00
res.json({ success: true });
});
2026-05-15 00:03:32 -05:00
// ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ──
router.get('/:id/amortization', (req: Req, res: Res) => {
const db = getDb();
2026-05-15 00:03:32 -05:00
const billId = parseInt(req.params.id, 10);
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'));
2026-05-15 00:03:32 -05:00
const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0;
const minPmt = fromCents(Number(bill.minimum_payment) || 0);
2026-05-15 00:03:32 -05:00
// Optional override: ?payment=X lets callers model "what if I pay more?"
let payment = minPmt;
if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) {
return res
.status(400)
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
2026-05-15 00:03:32 -05:00
}
payment = qp;
}
// Optional ?max_months=N (default 360, hard cap 600)
let maxMonths = 360;
if (req.query.max_months !== undefined) {
const qm = parseInt(req.query.max_months, 10);
if (Number.isInteger(qm) && qm > 0) maxMonths = Math.min(qm, 600);
}
if (!Number.isFinite(balance) || balance <= 0) {
return res.json({
bill_id: billId,
schedule: [],
apr_snapshot: null,
error: 'No current balance set',
});
2026-05-15 00:03:32 -05:00
}
const schedule = amortizationSchedule(balance, apr, payment, maxMonths);
const apr_snapshot = debtAprSnapshot({
...bill,
current_balance: balance,
minimum_payment: minPmt,
});
2026-05-15 00:03:32 -05:00
const total_interest = schedule.reduce((s, r) => s + r.interest, 0);
res.json({
bill_id: billId,
2026-05-15 00:03:32 -05:00
balance,
apr,
payment,
schedule,
summary: {
months: schedule.length,
total_interest: roundMoney(total_interest),
total_paid: sumMoney(schedule, (r) => r.payment),
capped: schedule.length >= maxMonths && schedule[schedule.length - 1]?.balance > 0,
2026-05-15 00:03:32 -05:00
},
apr_snapshot,
});
});
2026-05-14 19:33:23 -05:00
// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ──
router.patch('/:id/snowball', (req: Req, res: Res) => {
2026-05-14 19:33:23 -05:00
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id)
) {
2026-05-14 19:33:23 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const include =
req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
const exempt =
req.body.snowball_exempt !== undefined ? (req.body.snowball_exempt ? 1 : 0) : undefined;
2026-05-14 19:33:23 -05:00
const parts = [];
const vals = [];
if (include !== undefined) {
parts.push('snowball_include = ?');
vals.push(include);
}
if (exempt !== undefined) {
parts.push('snowball_exempt = ?');
vals.push(exempt);
}
if (parts.length === 0)
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
2026-05-14 19:33:23 -05:00
parts.push("updated_at = datetime('now')");
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
...vals,
billId,
req.user.id,
);
2026-05-14 19:33:23 -05:00
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
});
2026-05-14 02:11:54 -05:00
// ── PATCH /api/bills/:id/balance — lightweight balance-only update ────────────
router.patch('/:id/balance', (req: Req, res: Res) => {
2026-05-14 02:11:54 -05:00
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (
!db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id)
) {
2026-05-14 02:11:54 -05:00
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const raw = req.body.current_balance;
let val = null;
if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) {
return res
.status(400)
.json(
standardizeError(
'current_balance must be a non-negative number',
'VALIDATION_ERROR',
'current_balance',
),
);
2026-05-14 02:11:54 -05:00
}
val = roundMoney(val);
2026-05-14 02:11:54 -05:00
}
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(
toCents(val),
billId,
);
2026-05-14 02:11:54 -05:00
res.json({ id: billId, current_balance: val });
});
// ── Merchant rule helpers ─────────────────────────────────────────────────────
function requireBill(db, billId, userId) {
return db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, userId);
}
// Count unmatched transactions that would match a normalized merchant string.
function previewMatchCount(db, userId, normalized) {
if (!normalized || normalized.length < 2) return 0;
const txRows = db
.prepare(
`
SELECT t.payee, t.description, t.memo
FROM transactions t
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
WHERE t.user_id = ?
AND t.match_status = 'unmatched'
AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`,
)
.all(userId);
return txRows.filter((tx) => {
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
return txMerchant && merchantMatches(txMerchant, normalized);
}).length;
}
// Find bills (other than this one) that already claim this merchant.
function findConflicts(db, userId, billId, normalized) {
return db
.prepare(
`
SELECT b.id, b.name
FROM bill_merchant_rules bmr
JOIN bills b ON b.id = bmr.bill_id AND b.user_id = bmr.user_id AND b.deleted_at IS NULL
WHERE bmr.user_id = ? AND bmr.merchant = ? AND bmr.bill_id != ?
`,
)
.all(userId, normalized, billId);
}
// ── GET /api/bills/:id/merchant-rules ────────────────────────────────────────
router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db
.prepare(
`
SELECT id, merchant, auto_attribute_late, created_at FROM bill_merchant_rules
WHERE user_id = ? AND bill_id = ?
ORDER BY created_at ASC
`,
)
.all(req.user.id, billId);
// Suggest recent unmatched transactions as quick-pick options
const suggestions = db
.prepare(
`
SELECT t.id, t.payee, t.description, t.memo, t.amount, t.posted_date, t.transacted_at
FROM transactions t
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
WHERE t.user_id = ?
AND t.match_status = 'unmatched'
AND t.ignored = 0
AND t.amount < 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) DESC
LIMIT 30
`,
)
.all(req.user.id)
.map((tx) => {
const raw = tx.payee || tx.description || tx.memo || '';
const normalized = normalizeMerchant(raw);
return {
id: tx.id,
label: raw.trim(),
normalized,
amount: tx.amount,
date: tx.posted_date || String(tx.transacted_at || '').slice(0, 10),
};
})
.filter((s) => s.normalized.length >= 2);
res.json({ rules, suggestions });
});
// ── GET /api/bills/:id/merchant-rules/preview ─────────────────────────────────
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2)
return res.json({ match_count: 0, conflicts: [], normalized: '' });
const match_count = previewMatchCount(db, req.user.id, normalized);
const conflicts = findConflicts(db, req.user.id, billId, normalized);
res.json({ match_count, conflicts, normalized });
});
// ── POST /api/bills/:id/merchant-rules ───────────────────────────────────────
router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2)
return res
.status(400)
.json(
standardizeError(
'merchant must be at least 2 characters after normalisation',
'VALIDATION_ERROR',
'merchant',
),
);
const conflicts = findConflicts(db, req.user.id, billId, normalized);
try {
db.prepare(
`
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`,
).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
// Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
const rule = db
.prepare(
'SELECT id, merchant, created_at FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? AND merchant = ?',
)
.get(req.user.id, billId, normalized);
res.status(201).json({ rule, retroactive_matches: added, conflicts });
});
// ── DELETE /api/bills/:id/merchant-rules/:ruleId ──────────────────────────────
// ── GET /api/bills/:id/merchant-rules/candidates ─────────────────────────────
// All transactions matching this bill's merchant rules — any match_status.
// Each item includes the current status so the user knows what will happen.
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
.all(req.user.id, billId)
.map((r) => r.merchant);
if (rules.length === 0) return res.json({ candidates: [] });
// Fetch all negative transactions for this user — any match status
let txRows;
try {
txRows = db
.prepare(
`
SELECT t.id, t.amount, t.payee, t.description, t.memo,
t.posted_date, t.transacted_at, t.match_status,
t.matched_bill_id,
b.name AS matched_bill_name
FROM transactions t
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
WHERE t.user_id = ? AND t.amount < 0 AND t.ignored = 0
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at,1,10)) DESC
LIMIT 500
`,
)
.all(req.user.id);
} catch {
return res.json({ candidates: [] });
}
// Existing payments for this bill keyed by transaction_id
const existingPayments = new Set(
db
.prepare(
'SELECT transaction_id FROM payments WHERE bill_id = ? AND transaction_id IS NOT NULL AND deleted_at IS NULL',
)
.all(billId)
.map((r) => r.transaction_id),
);
const candidates = [];
for (const tx of txRows) {
const txMerchant = normalizeMerchant(tx.payee || tx.description || tx.memo || '');
if (!txMerchant) continue;
const matches = rules.some((r) => merchantMatches(txMerchant, r));
if (!matches) continue;
const paidDate =
tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
let status;
if (existingPayments.has(tx.id)) {
status = 'payment_exists';
} else if (tx.match_status === 'matched' && tx.matched_bill_id === billId) {
status = 'matched_this_bill';
} else if (tx.match_status === 'matched' && tx.matched_bill_id !== billId) {
status = 'matched_other_bill';
} else {
status = 'unmatched';
}
candidates.push({
id: tx.id,
payee: tx.payee || tx.description || '(no description)',
amount: Math.round(Math.abs(tx.amount)) / 100,
paid_date: paidDate,
status,
matched_bill_name: tx.matched_bill_name || null,
});
}
res.json({ candidates });
});
// ── POST /api/bills/:id/merchant-rules/import-historical ──────────────────────
// Import a specific list of transaction IDs as payments for this bill.
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const ids = req.body?.transaction_ids;
if (!Array.isArray(ids) || ids.length === 0)
return res
.status(400)
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
if (validIds.length === 0)
return res
.status(400)
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
const getTx = db.prepare(
'SELECT * FROM transactions WHERE id = ? AND user_id = ? AND amount < 0',
);
const insertPayment = db.prepare(`
INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
VALUES (?, ?, ?, 'provider_sync', ?)
`);
const updateTx = db.prepare(`
UPDATE transactions SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now') WHERE id = ?
`);
let imported = 0;
const lateAttributions = [];
try {
db.transaction(() => {
for (const txId of validIds) {
const tx = getTx.get(txId, req.user.id);
if (!tx) continue;
const paidDate =
tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
if (!paidDate) continue;
const amountCents = Math.round(Math.abs(tx.amount));
const amount = fromCents(amountCents);
const billRow = getBill.get(billId);
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);
imported++;
// Check for late attribution
const { normalizeMerchant: nm, ..._ } = { normalizeMerchant };
const rules2 = db.prepare('SELECT due_day FROM bills WHERE id = ?').get(billId);
if (rules2?.due_day) {
const { lateAttributionCandidate } = require('../services/billMerchantRuleService.cts');
// inline check
const paid = new Date(paidDate + 'T00:00:00');
const dom = paid.getDate();
if (dom <= 5) {
const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0);
if (rules2.due_day <= prevEnd.getDate()) {
const suggested = localDateString(prevEnd);
if (insertedPayment) {
lateAttributions.push({
payment_id: insertedPayment.id,
bill_name: bill.name,
original_date: paidDate,
suggested_date: suggested,
amount,
});
}
}
}
}
if (billRow && insertedPayment)
applyBankPaymentAsSourceOfTruth(db, billRow, insertedPayment);
}
}
})();
} catch (err) {
console.error('[import-historical] Transaction failed:', err.message);
return res.status(500).json(standardizeError('Import failed', 'DB_ERROR'));
}
res.json({ imported, late_attributions: lateAttributions });
});
// PATCH /api/bills/:id/merchant-rules/:ruleId/auto-attribute
// Toggle the auto_attribute_late flag for a single merchant rule.
router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const enabled = req.body?.enabled ? 1 : 0;
const changes = db
.prepare(
'UPDATE bill_merchant_rules SET auto_attribute_late = ? WHERE id = ? AND user_id = ? AND bill_id = ?',
)
.run(enabled, ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
});
router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const changes = db
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.run(ruleId, req.user.id, billId).changes;
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
res.json({ success: true });
});
2026-05-03 19:51:57 -05:00
module.exports = router;