2026-05-04 13:14:32 -05:00
|
|
|
const express = require('express');
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
const { getDb } = require('../db/database');
|
2026-05-10 15:25:47 -05:00
|
|
|
const { standardizeError } = require('../middleware/errorFormatter');
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
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);
|
2026-05-09 13:03:36 -05:00
|
|
|
if (parsed.error) return res.status(400).json(standardizeError(parsed.error, 'VALIDATION_ERROR', 'month'));
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
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(',');
|
|
|
|
|
|
2026-05-10 03:29:09 -05:00
|
|
|
// Batch fetch all payments for the date range
|
|
|
|
|
let paymentRows = [];
|
|
|
|
|
if (billIds.length > 0) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Batch fetch all monthly bill states for the date range
|
|
|
|
|
let stateRows = [];
|
|
|
|
|
if (billIds.length > 0) {
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
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;
|