const express = require('express'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { auditBillsForUser, categoryBelongsToUser, insertBill, serializeBill, parseTemplateData, sanitizeTemplateData, validateBillData, computeBalanceDelta, applyBalanceDelta, } = require('../services/billsService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { standardizeError } = require('../middleware/errorFormatter'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); const { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); const { accountingActiveSql, applyBankPaymentAsSourceOfTruth, } = require('../services/paymentAccountingService'); const { localDateString, todayLocal } = require('../utils/dates'); const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money'); // ── GET /api/bills ──────────────────────────────────────────────────────────── router.get('/', (req, res) => { 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(` 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 FROM bills b 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 WHERE b.user_id = ? AND b.deleted_at IS NULL ${includeInactive ? '' : 'AND b.active = 1'} ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC `).all(req.user.id); res.json(bills.map(serializeBill)); }); // ── PUT /api/bills/reorder ─────────────────────────────────────────────────── router.put('/reorder', (req, res) => { 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')); } const invalid = entries.find(({ billId, sortOrder }) => ( !Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0 )); if (invalid) { return res.status(400).json(standardizeError('Reorder payload must map bill ids to non-negative integer positions', 'VALIDATION_ERROR', 'reorder')); } const ids = entries.map(item => item.billId); const placeholders = ids.map(() => '?').join(','); const owned = db.prepare(` SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL AND id IN (${placeholders}) `).all(req.user.id, ...ids); if (owned.length !== ids.length) { return res.status(404).json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id')); } const update = db.prepare("UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?"); 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(` 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); res.json({ success: true, bills: bills.map(serializeBill) }); }); // ── GET /api/bills/audit?inactive=true ─────────────────────────────────────── router.get('/audit', (req, res) => { const db = getDb(); ensureUserDefaultCategories(req.user.id); const includeInactive = req.query.inactive === 'true'; res.json(auditBillsForUser(db, req.user.id, includeInactive)); }); // ── GET /api/bills/drift-report ────────────────────────────────────────────── router.get('/drift-report', (req, res) => { const { getDriftReport } = require('../services/driftService'); 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, 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, 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; } // ── GET /api/bills/templates ───────────────────────────────────────────────── router.get('/templates', (req, res) => { const db = getDb(); const rows = db.prepare(` 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)), }))); }); // ── POST /api/bills/templates ──────────────────────────────────────────────── router.post('/templates', (req, res) => { 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')); } 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')); } 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}`)); } 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')); } const normalizedData = sanitizeTemplateData(validation.normalized); const result = db.prepare(` 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)); const template = db.prepare(` SELECT id, name, data, created_at, updated_at FROM bill_templates WHERE user_id = ? AND name = ? `).get(req.user.id, name); res.status(result.changes > 0 ? 201 : 200).json({ ...template, data: serializeTemplateData(parseTemplateData(template.data)), }); }); // ── DELETE /api/bills/templates/:templateId ────────────────────────────────── router.delete('/templates/:templateId', (req, res) => { 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')); } 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')); res.json({ success: true }); }); // ── POST /api/bills/:id/duplicate ──────────────────────────────────────────── router.post('/:id/duplicate', (req, res) => { 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')); } 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')); const draft = { ...sanitizeTemplateData(serializeBill(source)), ...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)); } 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')); } res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); }); // ── GET /api/bills/:id/monthly-state?year=&month= ───────────────────────────── router.get('/:id/monthly-state', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const year = parseInt(req.query.year, 10); 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); res.json({ bill_id: billId, year, month, actual_amount: fromCents(mbs?.actual_amount), notes: mbs?.notes ?? null, is_skipped: !!(mbs?.is_skipped), }); }); // ── PUT /api/bills/:id/monthly-state ────────────────────────────────────────── router.put('/:id/monthly-state', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body; const y = parseInt(year, 10); 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')); 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')); 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')); } 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')) 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, updated_at = datetime('now') `).run(billId, y, m, amt, noteVal, skipVal, snoozeVal); const saved = db.prepare( 'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' ).get(billId, y, m); 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, }); }); // ── GET /api/bills/:id ──────────────────────────────────────────────────────── router.get('/:id', (req, res) => { const db = getDb(); const bill = db.prepare(` 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 FROM bills b 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, 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 }); }); // ── POST /api/bills ─────────────────────────────────────────────────────────── router.post('/', (req, res) => { const db = getDb(); const body = req.body || {}; let payload = body; if (body.source_bill_id !== undefined && body.source_bill_id !== null && body.source_bill_id !== '') { 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')); } const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id); if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id')); payload = { ...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(body), name: String(body.name || `${source.name} (Copy)`).trim(), }; } // Validate and normalize bill data 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)); } const { normalized } = validation; // Validate category_id exists for this user 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')); } res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); }); // ── PUT /api/bills/:id ──────────────────────────────────────────────────────── router.put('/:id', (req, res) => { 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')); // 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)); } const { normalized } = validation; // Validate category_id exists for this user if changed 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(` UPDATE bills SET name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, auto_mark_paid = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?, 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 = ?, 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, 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, normalized.current_balance, normalized.minimum_payment, normalized.snowball_order, normalized.snowball_include, normalized.snowball_exempt, normalized.is_subscription, normalized.subscription_type, normalized.reminder_days_before, normalized.subscription_source, normalized.subscription_detected_at, inactiveReason, inactivatedAt, 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)); }); // ── PUT /api/bills/:id/archived ────────────────────────────────────────────── router.put('/:id/archived', (req, 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 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')); 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); 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 }); }); // ── DELETE /api/bills/:id — soft delete for 30-day recovery ─────────────────── router.delete('/:id', (req, res) => { 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')); 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); res.json({ success: true, deleted_bill_id: bill.id, deleted_bill_name: bill.name, recoverable_until_days: 30, }); }); // POST /api/bills/:id/restore — undo bill soft delete router.post('/:id/restore', (req, res) => { 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))); }); // 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, 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')); } }); // ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── router.get('/:id/payments', (req, res) => { 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')); const limit = Math.min(parseInt(req.query.limit || '20', 10), 100); const page = Math.max(parseInt(req.query.page || '1', 10), 1); 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; 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); res.json({ bill_id: parseInt(req.params.id, 10), bill_name: bill.name, total, page, limit, pages: Math.ceil(total / limit), payments: items.map(serializePayment), }); }); // ── GET /api/bills/:id/transactions ────────────────────────────────────────── router.get('/:id/transactions', (req, 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, }); }); // ── POST /api/bills/:id/toggle-paid — toggle Paid/Unpaid status ────────────── router.post('/:id/toggle-paid', (req, res) => { 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); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); // 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; if ((year === null) !== (month === null)) { return res.status(400).json(standardizeError('year and month must both be provided or both omitted', 'VALIDATION_ERROR', 'year')); } 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')); } 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')); } 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); } // If paid (has payment), remove it → Unpaid if (currentPayment) { // 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); } } db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(currentPayment.id); 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(); } const method = req.body.method || null; const notes = req.body.notes || null; const paymentValidation = validatePaymentInput( { amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' }, { requireBillId: false }, ); if (paymentValidation.error) { return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field)); } const payment = paymentValidation.normalized; // Compute balance delta for debt bills before inserting const balCalc = computeBalanceDelta(bill, payment.amount); 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); applyBalanceDelta(db, billId, balCalc); res.status(201).json({ success: true, isPaid: true, action: 'created_payment', payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)), }); }); // ── GET /api/bills/:id/history-ranges ──────────────────────────────────────── router.get('/:id/history-ranges', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const ranges = db.prepare( 'SELECT * FROM bill_history_ranges WHERE bill_id = ? ORDER BY start_year ASC, start_month ASC' ).all(req.params.id); const bill = db.prepare('SELECT history_visibility FROM bills WHERE id = ? AND deleted_at IS NULL').get(req.params.id); res.json({ bill_id: parseInt(req.params.id, 10), history_visibility: bill.history_visibility, ranges }); }); // ── POST /api/bills/:id/history-ranges ─────────────────────────────────────── router.post('/:id/history-ranges', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); 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')); 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; 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')); } 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')); } 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) { const startVal = sy * 12 + sm; const endVal = ey * 12 + em; if (endVal < startVal) return res.status(400).json(standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year')); } const result = db.prepare(` 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); const created = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ?').get(result.lastInsertRowid); res.status(201).json(created); }); // ── PUT /api/bills/:id/history-ranges/:rangeId ─────────────────────────────── router.put('/:id/history-ranges/:rangeId', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const range = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .get(req.params.rangeId, req.params.id); if (!range) return res.status(404).json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId')); 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')); 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 = 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')); 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')); 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(` 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); const updated = db.prepare('SELECT * FROM bill_history_ranges WHERE id = ?').get(req.params.rangeId); res.json(updated); }); // ── DELETE /api/bills/:id/history-ranges/:rangeId ──────────────────────────── router.delete('/:id/history-ranges/:rangeId', (req, res) => { 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)) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const range = db.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .get(req.params.rangeId, req.params.id); if (!range) return res.status(404).json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId')); db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .run(req.params.rangeId, req.params.id); res.json({ success: true }); }); // ── GET /api/bills/:id/amortization — full month-by-month schedule for a debt bill ── router.get('/:id/amortization', (req, res) => { const db = getDb(); 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')); const balance = fromCents(Number(bill.current_balance)); const apr = Number(bill.interest_rate) || 0; const minPmt = fromCents(Number(bill.minimum_payment) || 0); // 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')); } 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' }); } const schedule = amortizationSchedule(balance, apr, payment, maxMonths); const apr_snapshot = debtAprSnapshot({ ...bill, current_balance: balance, minimum_payment: minPmt }); const total_interest = schedule.reduce((s, r) => s + r.interest, 0); res.json({ bill_id: billId, 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, }, apr_snapshot, }); }); // ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ── router.patch('/:id/snowball', (req, res) => { 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)) { 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; 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')); parts.push("updated_at = datetime('now')"); db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(...vals, billId, req.user.id); res.json({ id: billId, snowball_include: include, snowball_exempt: exempt }); }); // ── PATCH /api/bills/:id/balance — lightweight balance-only update ──────────── router.patch('/:id/balance', (req, res) => { 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)) { 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')); } val = roundMoney(val); } db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(toCents(val), billId); 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, 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, 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, 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, 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, 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'); // 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, 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, 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 }); }); module.exports = router;