const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = require('express').Router(); const { getDb } = require('../db/database'); const { computeBalanceDelta } = require('../services/billsService'); const { validatePaymentInput } = require('../services/paymentValidation'); const { resolveDueDate } = require('../services/statusService'); const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments function parseYearMonth(body) { const year = parseInt(body.year, 10); const month = parseInt(body.month, 10); if (!Number.isInteger(year) || year < 2000 || year > 2100) { return { error: standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year') }; } if (!Number.isInteger(month) || month < 1 || month > 12) { return { error: standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month') }; } return { year, month }; } function getAutopaySuggestionContext(db, userId, billId, year, month) { const bill = db.prepare(` SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL `).get(billId, userId); if (!bill) return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 }; if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') { return { error: standardizeError('Bill is not eligible for autopay suggestions', 'VALIDATION_ERROR', 'bill_id'), status: 400 }; } const state = db.prepare(` SELECT actual_amount, is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ? `).get(bill.id, year, month); if (state?.is_skipped) { return { error: standardizeError('Skipped bills cannot be suggested for payment', 'VALIDATION_ERROR', 'bill_id'), status: 400 }; } const dueDate = resolveDueDate(bill, year, month); const amount = state?.actual_amount ?? bill.expected_amount; return { bill, dueDate, amount }; } // GET /api/payments?bill_id=&year=&month= router.get('/', (req, res) => { const db = getDb(); const { bill_id, year, month } = req.query; // Validate year/month when provided if ((year || month) && !(year && month)) { return res.status(400).json(standardizeError('Both year and month are required when filtering by date', 'VALIDATION_ERROR', 'year')); } let y, m; if (year && month) { y = parseInt(year, 10); m = parseInt(month, 10); if (!Number.isInteger(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 (!Number.isInteger(m) || m < 1 || m > 12) { return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month')); } } let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`; const params = [req.user.id]; if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); } if (y && m) { const yStr = String(y); const mStr = String(m).padStart(2, '0'); const daysInMonth = new Date(y, m, 0).getDate(); const endDay = String(daysInMonth).padStart(2, '0'); query += ' AND p.paid_date BETWEEN ? AND ?'; params.push(`${yStr}-${mStr}-01`, `${yStr}-${mStr}-${endDay}`); } query += ' ORDER BY p.paid_date DESC'; res.json(db.prepare(query).all(...params)); }); // GET /api/payments/:id router.get('/:id', (req, res) => { const db = getDb(); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); res.json(payment); }); // POST /api/payments — create single payment router.post('/', (req, res) => { const db = getDb(); const { bill_id, amount, paid_date, method, notes } = req.body; const validation = validatePaymentInput({ bill_id, amount, paid_date }); if (validation.error) { return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field)); } const payment = validation.normalized; const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const balCalc = computeBalanceDelta(bill, payment.amount); const result = db.prepare( 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' ).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null); if (balCalc) { db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") .run(balCalc.new_balance, bill.id); } res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); }); // POST /api/payments/quick — pay a bill (expected amount, today) router.post('/quick', (req, res) => { const db = getDb(); const { bill_id, amount, paid_date, method, notes } = req.body; const billValidation = validatePaymentInput({ bill_id }, { requireAmount: false, requirePaidDate: false }); if (billValidation.error) { return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field)); } const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billValidation.normalized.bill_id, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); const paymentValidation = validatePaymentInput( { amount: amount != null ? amount : bill.expected_amount, paid_date: paid_date || new Date().toISOString().slice(0, 10), }, { requireBillId: false }, ); if (paymentValidation.error) { return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field)); } const payAmount = paymentValidation.normalized.amount; const payDate = paymentValidation.normalized.paid_date; const balCalc = computeBalanceDelta(bill, payAmount); const result = db.prepare( 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' ).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null); if (balCalc) { db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") .run(balCalc.new_balance, bill.id); } res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)); }); // POST /api/payments/autopay-suggestions/:billId/confirm router.post('/autopay-suggestions/:billId/confirm', (req, res) => { const db = getDb(); const ym = parseYearMonth(req.body); if (ym.error) return res.status(400).json(ym.error); const billId = parseInt(req.params.billId, 10); if (!Number.isInteger(billId)) { return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id')); } const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); if (context.error) return res.status(context.status).json(context.error); const { bill, dueDate, amount } = context; if (dueDate > new Date().toISOString().slice(0, 10)) { return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date')); } const paymentValidation = validatePaymentInput( { amount, paid_date: dueDate }, { requireBillId: false }, ); if (paymentValidation.error) { return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field)); } const suggestedPayment = paymentValidation.normalized; const existing = db.prepare(` SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.bill_id = ? AND b.user_id = ? AND p.deleted_at IS NULL AND strftime('%Y', p.paid_date) = ? AND strftime('%m', p.paid_date) = ? ORDER BY p.paid_date DESC LIMIT 1 `).get(bill.id, req.user.id, String(ym.year), String(ym.month).padStart(2, '0')); if (existing) { db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') .run(req.user.id, bill.id, ym.year, ym.month); return res.json({ created: false, payment: existing }); } const balCalc = computeBalanceDelta(bill, suggestedPayment.amount); const result = db.prepare(` INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?) `).run( bill.id, suggestedPayment.amount, suggestedPayment.paid_date, 'autopay', 'Confirmed autopay suggestion', balCalc?.balance_delta ?? null, ); if (balCalc) { db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?") .run(balCalc.new_balance, bill.id); } db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') .run(req.user.id, bill.id, ym.year, ym.month); res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) }); }); // POST /api/payments/autopay-suggestions/:billId/dismiss router.post('/autopay-suggestions/:billId/dismiss', (req, res) => { const db = getDb(); const ym = parseYearMonth(req.body); if (ym.error) return res.status(400).json(ym.error); const billId = parseInt(req.params.billId, 10); if (!Number.isInteger(billId)) { return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id')); } 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')); } db.prepare(` INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id, bill_id, year, month) DO UPDATE SET dismissed_at = datetime('now') `).run(req.user.id, billId, ym.year, ym.month); res.json({ success: true }); }); // POST /api/payments/bulk — record multiple payments in one request // Bulk payment creation endpoint // Validation rules: // - Request body must contain a `payments` array // - Maximum 50 items per request // - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number) // - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created // - Returns { created: [...], skipped: [...], errors: [...] } router.post('/bulk', (req, res) => { const db = getDb(); const { payments } = req.body; // Validate request body has payments array if (!payments || !Array.isArray(payments)) return res.status(400).json(standardizeError('Request body must contain a `payments` array', 'VALIDATION_ERROR', 'payments')); // Validate max items per request (50) if (payments.length > 50) return res.status(400).json(standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments')); // Validate each payment item for (let i = 0; i < payments.length; i++) { const item = payments[i]; const validation = validatePaymentInput(item, { fieldPrefix: `payments[${i}].` }); if (validation.error) { return res.status(400).json(standardizeError(`Payment at index ${i}: ${validation.error}`, 'VALIDATION_ERROR', validation.field)); } } const insert = db.prepare( 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' ); const getBillForBalance = db.prepare('SELECT current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL'); const applyBalance = db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?"); // Prepare statement for duplicate checking const duplicateCheckStmt = db.prepare( `SELECT 1 FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${LIVE}` ); const created = []; const skipped = []; const errors = []; const runBulk = db.transaction(() => { for (const item of payments) { const payment = validatePaymentInput(item).normalized; const { bill_id, amount: parsedAmt, paid_date } = payment; const { method, notes } = item; // Check for duplicates using composite key (bill_id + paid_date + amount) const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt); if (isDuplicate) { skipped.push({ bill_id, paid_date, amount: parsedAmt }); continue; } const billRow = getBillForBalance.get(bill_id, req.user.id); if (!billRow) { errors.push({ item, error: `Bill ${bill_id} not found` }); continue; } const balCalc = computeBalanceDelta(billRow, parsedAmt); const r = insert.run(bill_id, parsedAmt, paid_date, method || null, notes || null, balCalc?.balance_delta ?? null); if (balCalc) applyBalance.run(balCalc.new_balance, bill_id); created.push(db.prepare('SELECT * FROM payments WHERE id = ?').get(r.lastInsertRowid)); } }); runBulk(); res.status(201).json({ created, skipped, errors }); }); // PUT /api/payments/:id router.put('/:id', (req, res) => { const db = getDb(); const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); const { amount, paid_date, method, notes } = req.body; const validation = validatePaymentInput( { amount, paid_date }, { requireBillId: false, requireAmount: false, requirePaidDate: false }, ); if (validation.error) { return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field)); } const nextAmount = validation.normalized.amount ?? existing.amount; const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date; let nextBalanceDelta = existing.balance_delta; const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id); if (bill) { let restoredBalance = bill.current_balance; if (existing.balance_delta != null && bill.current_balance != null) { restoredBalance = Math.max(0, Math.round((bill.current_balance - existing.balance_delta) * 100) / 100); } const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount); nextBalanceDelta = balCalc?.balance_delta ?? null; if (balCalc) { db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") .run(balCalc.new_balance, existing.bill_id); } else if (existing.balance_delta != null && restoredBalance != null) { db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") .run(restoredBalance, existing.bill_id); } } db.prepare(` UPDATE payments SET amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, updated_at = datetime('now') WHERE id = ? `).run( nextAmount, nextPaidDate, method !== undefined ? (method || null) : existing.method, notes !== undefined ? (notes || null) : existing.notes, nextBalanceDelta, req.params.id, ); res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id)); }); // DELETE /api/payments/:id — soft delete (sets deleted_at) router.delete('/:id', (req, res) => { const db = getDb(); const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); // Reverse any balance delta that was stored when this payment was created if (payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance != null) { const restored = Math.max(0, Math.round((bill.current_balance - payment.balance_delta) * 100) / 100); db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(restored, payment.bill_id); } } db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id); res.json({ success: true }); }); // POST /api/payments/:id/restore — undo soft delete router.post('/:id/restore', (req, res) => { const db = getDb(); const payment = db.prepare('SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL').get(req.params.id, req.user.id); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); // Re-apply the balance delta (undo the reversal done on delete) if (payment.balance_delta != null) { const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance != null) { const reapplied = Math.max(0, Math.round((bill.current_balance + payment.balance_delta) * 100) / 100); db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?").run(reapplied, payment.bill_id); } } db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ?').run(req.params.id); res.json(db.prepare('SELECT * FROM payments WHERE id = ?').get(req.params.id)); }); module.exports = router;