const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); return Math.min(Math.max(parseInt(day || 1, 10), 1), daysInMonth); } function toDateString(year, month, day) { return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } function emptyDay(year, month, day) { return { date: toDateString(year, month, day), day, bills_due: [], payments: [], status_summary: { due_count: 0, paid_count: 0, skipped_count: 0, missed_count: 0, total_due: 0, total_paid: 0, }, }; } // GET /api/calendar?year=2026&month=5 router.get('/', (req, res) => { const db = getDb(); const now = new Date(); const year = parseInt(req.query.year || now.getFullYear(), 10); const month = parseInt(req.query.month || now.getMonth() + 1, 10); if (isNaN(year) || year < 2000 || year > 2100) { return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' }); } if (isNaN(month) || month < 1 || month > 12) { return res.status(400).json({ error: 'month must be an integer between 1 and 12' }); } const today = now.toISOString().slice(0, 10); const daysInMonth = new Date(year, month, 0).getDate(); const { start, end } = getCycleRange(year, month); const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1)); const dayByDate = new Map(days.map(day => [day.date, day])); const bills = db.prepare(` SELECT b.*, c.name AS category_name FROM bills b LEFT JOIN categories c ON b.category_id = c.id WHERE b.active = 1 AND b.user_id = ? ORDER BY b.due_day ASC, b.name ASC `).all(req.user.id); const paymentsByBillStmt = db.prepare(` SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL ORDER BY paid_date DESC `); const monthlyStateStmt = db.prepare(` SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ? `); const payments = db.prepare(` SELECT p.id AS payment_id, p.bill_id, b.name AS bill_name, p.amount, p.paid_date, p.method, p.notes FROM payments p JOIN bills b ON p.bill_id = b.id WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL ORDER BY p.paid_date ASC, b.name ASC `).all(req.user.id, start, end); for (const payment of payments) { const day = dayByDate.get(payment.paid_date); if (day) { day.payments.push({ payment_id: payment.payment_id, bill_id: payment.bill_id, bill_name: payment.bill_name, amount: payment.amount, paid_date: payment.paid_date, method: payment.method || null, notes: payment.notes || null, }); day.status_summary.total_paid += payment.amount || 0; } } const calendarBills = bills.map(bill => { const billPayments = paymentsByBillStmt.all(bill.id, start, end); const row = buildTrackerRow(bill, billPayments, year, month, today); const monthlyState = monthlyStateStmt.get(bill.id, year, month); const actualAmount = monthlyState?.actual_amount ?? null; const isSkipped = !!monthlyState?.is_skipped; const effectiveAmount = actualAmount ?? row.expected_amount; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount; const isAutodraft = row.status === 'autodraft'; const status = isSkipped ? 'skipped' : isPaidByThreshold ? 'paid' : row.status; const isPaid = status === 'paid' || isAutodraft; const dueDay = clampDay(year, month, bill.due_day); const dueDate = toDateString(year, month, dueDay); return { bill_id: bill.id, name: bill.name, due_date: dueDate, due_day: dueDay, expected_amount: row.expected_amount, actual_amount: actualAmount, effective_amount: effectiveAmount, category_name: bill.category_name || null, is_paid: isPaid, is_skipped: isSkipped, paid_amount: row.total_paid || 0, status, }; }); for (const bill of calendarBills) { const day = dayByDate.get(bill.due_date); if (!day) continue; day.bills_due.push(bill); day.status_summary.due_count += 1; if (bill.is_paid) day.status_summary.paid_count += 1; if (bill.is_skipped) day.status_summary.skipped_count += 1; if (!bill.is_paid && !bill.is_skipped && (bill.status === 'late' || bill.status === 'missed')) { day.status_summary.missed_count += 1; } if (!bill.is_skipped) day.status_summary.total_due += bill.effective_amount || 0; } const activeBills = calendarBills.filter(bill => !bill.is_skipped); const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0); const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0); const remainingTotal = Math.max(0, expectedTotal - paidTotal); const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0; res.json({ year, month, today, days, summary: { expected_total: expectedTotal, paid_total: paidTotal, remaining_total: remainingTotal, paid_percent: paidPercent, bill_count: activeBills.length, paid_count: activeBills.filter(bill => bill.is_paid).length, skipped_count: calendarBills.filter(bill => bill.is_skipped).length, missed_count: activeBills.filter(bill => bill.status === 'late' || bill.status === 'missed').length, }, }); }); module.exports = router;