const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; const parsed = Number(value); return Number.isInteger(parsed) ? parsed : NaN; } function monthKey(year, month) { return `${year}-${String(month).padStart(2, '0')}`; } function monthLabel(year, month) { return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC', }); } function addMonths(year, month, delta) { const date = new Date(Date.UTC(year, month - 1 + delta, 1)); return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }; } function monthEndDate(year, month) { const day = new Date(Date.UTC(year, month, 0)).getUTCDate(); return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`; } function buildMonths(endYear, endMonth, count) { return Array.from({ length: count }, (_, index) => { const value = addMonths(endYear, endMonth, index - count + 1); return { ...value, key: monthKey(value.year, value.month), label: monthLabel(value.year, value.month), start: `${monthKey(value.year, value.month)}-01`, end: monthEndDate(value.year, value.month), }; }); } function validateSummaryQuery(query) { const now = new Date(); const year = parseInteger(query.year, now.getFullYear()); const month = parseInteger(query.month, now.getMonth() + 1); const months = parseInteger(query.months, 12); const categoryId = parseInteger(query.category_id, null); const billId = parseInteger(query.bill_id, null); const includeInactive = query.include_inactive === 'true'; const includeSkipped = query.include_skipped !== 'false'; if (!Number.isInteger(year) || year < 2000 || year > 2100) { return { error: 'year must be a 4-digit integer between 2000 and 2100' }; } if (!Number.isInteger(month) || month < 1 || month > 12) { return { error: 'month must be an integer between 1 and 12' }; } if (!Number.isInteger(months) || months < 1 || months > 36) { return { error: 'months must be an integer between 1 and 36' }; } if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) { return { error: 'category_id must be a positive integer' }; } if (billId !== null && (!Number.isInteger(billId) || billId < 1)) { return { error: 'bill_id must be a positive integer' }; } return { year, month, months, categoryId, billId, includeInactive, includeSkipped }; } function isMonthInPast(year, month) { const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const targetMonthStart = new Date(year, month - 1, 1); return targetMonthStart < currentMonthStart; } function buildBillWhere({ userId, categoryId, billId, includeInactive }) { const clauses = ['b.user_id = ?']; const params = [userId]; if (!includeInactive) clauses.push('b.active = 1'); if (categoryId) { clauses.push('b.category_id = ?'); params.push(categoryId); } if (billId) { clauses.push('b.id = ?'); params.push(billId); } return { where: clauses.join(' AND '), params }; } router.get('/summary', (req, res) => { const parsed = validateSummaryQuery(req.query); if (parsed.error) return res.status(400).json({ error: parsed.error }); const db = getDb(); const userId = req.user.id; const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months); const startDate = rangeMonths[0].start; const endDate = rangeMonths[rangeMonths.length - 1].end; const billWhere = buildBillWhere({ ...parsed, userId }); const categories = db.prepare(` SELECT id, name FROM categories WHERE user_id = ? ORDER BY name COLLATE NOCASE `).all(userId); const bills = db.prepare(` SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at, c.name AS category_name FROM bills b LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id WHERE ${billWhere.where} ORDER BY b.name COLLATE NOCASE `).all(...billWhere.params); if (!bills.length) { return res.json({ range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate }, filters: { category_id: parsed.categoryId, bill_id: parsed.billId, include_inactive: parsed.includeInactive, include_skipped: parsed.includeSkipped, }, categories, bills: [], monthly_spending: [], expected_vs_actual: [], category_spend: [], heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] }, generated_at: new Date().toISOString(), }); } const billIds = bills.map(b => b.id); const placeholders = billIds.map(() => '?').join(','); const paymentRows = db.prepare(` SELECT p.bill_id, substr(p.paid_date, 1, 7) AS month_key, SUM(p.amount) AS total FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND p.bill_id IN (${placeholders}) AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL GROUP BY p.bill_id, substr(p.paid_date, 1, 7) `).all(userId, ...billIds, startDate, endDate); const stateRows = db.prepare(` SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ? AND m.bill_id IN (${placeholders}) AND (m.year * 100 + m.month) BETWEEN ? AND ? `).all( userId, ...billIds, rangeMonths[0].year * 100 + rangeMonths[0].month, rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month, ); const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0])); const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row])); const monthly_spending = rangeMonths.map(m => { const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0); return { month: m.key, label: m.label, total: Number(total.toFixed(2)) }; }).filter(row => row.total > 0); const expected_vs_actual = rangeMonths.map(m => { let expected = 0; let actual = 0; let skipped_count = 0; for (const bill of bills) { const state = stateByBillMonth.get(`${bill.id}:${m.key}`); const skipped = !!state?.is_skipped; if (skipped) skipped_count += 1; if (!skipped || parsed.includeSkipped) { actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0; } if (!skipped) { expected += state?.actual_amount ?? bill.expected_amount ?? 0; } } return { month: m.key, label: m.label, expected: Number(expected.toFixed(2)), actual: Number(actual.toFixed(2)), skipped_count, }; }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0); const categoryMap = new Map(); for (const bill of bills) { const categoryId = bill.category_id || null; const key = categoryId == null ? 'uncategorized' : String(categoryId); const existing = categoryMap.get(key) || { category_id: categoryId, category_name: bill.category_name || 'Uncategorized', total: 0, }; for (const m of rangeMonths) { existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0; } categoryMap.set(key, existing); } const category_spend = Array.from(categoryMap.values()) .map(row => ({ ...row, total: Number(row.total.toFixed(2)) })) .filter(row => row.total > 0) .sort((a, b) => b.total - a.total); const heatmapRows = bills.map(bill => { const cells = rangeMonths.map(m => { const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0; const state = stateByBillMonth.get(`${bill.id}:${m.key}`); const skipped = !!state?.is_skipped; let status = 'no_data'; if (skipped) status = 'skipped'; else if (paid) status = 'paid'; else if (isMonthInPast(m.year, m.month)) status = 'missed'; return { month: m.key, label: m.label, status, amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)), }; }); return { bill_id: bill.id, bill_name: bill.name, category_name: bill.category_name || 'Uncategorized', active: !!bill.active, cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'), }; }); res.json({ range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate }, filters: { category_id: parsed.categoryId, bill_id: parsed.billId, include_inactive: parsed.includeInactive, include_skipped: parsed.includeSkipped, }, categories, bills: bills.map(b => ({ id: b.id, name: b.name, category_id: b.category_id, category_name: b.category_name || 'Uncategorized', active: !!b.active, })), monthly_spending, expected_vs_actual, category_spend, heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: heatmapRows, }, generated_at: new Date().toISOString(), }); }); module.exports = router;