133 lines
4.7 KiB
JavaScript
133 lines
4.7 KiB
JavaScript
|
|
const express = require('express');
|
||
|
|
const router = express.Router();
|
||
|
|
const { getDb } = require('../db/database');
|
||
|
|
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
|
||
|
|
|
||
|
|
// GET /api/tracker?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 todayStr = now.toISOString().slice(0, 10);
|
||
|
|
|
||
|
|
const { start, end } = getCycleRange(year, month);
|
||
|
|
|
||
|
|
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 mbsStmt = db.prepare(
|
||
|
|
'SELECT actual_amount, notes, is_skipped FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
||
|
|
);
|
||
|
|
|
||
|
|
const rows = bills.map(bill => {
|
||
|
|
// Only count non-deleted payments for status/totals
|
||
|
|
const payments = db.prepare(`
|
||
|
|
SELECT * FROM payments
|
||
|
|
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
||
|
|
AND deleted_at IS NULL
|
||
|
|
ORDER BY paid_date DESC
|
||
|
|
`).all(bill.id, start, end);
|
||
|
|
|
||
|
|
const row = buildTrackerRow(bill, payments, year, month, todayStr);
|
||
|
|
|
||
|
|
// Overlay monthly state overrides
|
||
|
|
const mbs = mbsStmt.get(bill.id, year, month);
|
||
|
|
row.actual_amount = mbs?.actual_amount ?? null;
|
||
|
|
row.monthly_notes = mbs?.notes ?? null;
|
||
|
|
row.is_skipped = !!(mbs?.is_skipped);
|
||
|
|
|
||
|
|
return row;
|
||
|
|
});
|
||
|
|
|
||
|
|
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
|
||
|
|
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
|
||
|
|
const totalOverdue = rows
|
||
|
|
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
|
||
|
|
.reduce((s, r) => s + r.balance, 0);
|
||
|
|
|
||
|
|
const activeRows = rows.filter(r => !r.is_skipped);
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
year, month, today: todayStr,
|
||
|
|
summary: {
|
||
|
|
total_expected: activeRows.reduce((s, r) => s + r.expected_amount, 0),
|
||
|
|
total_paid: activeRows.reduce((s, r) => s + r.total_paid, 0),
|
||
|
|
remaining: Math.max(0, activeRows.reduce((s, r) => s + r.expected_amount, 0) - activeRows.reduce((s, r) => s + r.total_paid, 0)),
|
||
|
|
overdue: totalOverdue,
|
||
|
|
count_paid: activeRows.filter(r => r.status === 'paid').length,
|
||
|
|
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
|
||
|
|
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
|
||
|
|
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
|
||
|
|
},
|
||
|
|
rows,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// GET /api/tracker/upcoming?days=30
|
||
|
|
// Returns active bills with a due date in the next N days, sorted by due_date asc.
|
||
|
|
router.get('/upcoming', (req, res) => {
|
||
|
|
const db = getDb();
|
||
|
|
const days = Math.min(parseInt(req.query.days || '30', 10), 365);
|
||
|
|
const now = new Date();
|
||
|
|
const todayStr = now.toISOString().slice(0, 10);
|
||
|
|
|
||
|
|
const year = now.getFullYear();
|
||
|
|
const month = now.getMonth() + 1;
|
||
|
|
const { start, end } = getCycleRange(year, month);
|
||
|
|
|
||
|
|
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 = ?
|
||
|
|
`).all(req.user.id);
|
||
|
|
|
||
|
|
const cutoff = new Date(now);
|
||
|
|
cutoff.setDate(cutoff.getDate() + days);
|
||
|
|
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||
|
|
|
||
|
|
const upcoming = [];
|
||
|
|
|
||
|
|
for (const bill of bills) {
|
||
|
|
const dueDate = resolveDueDate(bill, year, month);
|
||
|
|
if (dueDate < todayStr || dueDate > cutoffStr) continue;
|
||
|
|
|
||
|
|
const payments = db.prepare(`
|
||
|
|
SELECT * FROM payments
|
||
|
|
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
|
||
|
|
AND deleted_at IS NULL
|
||
|
|
ORDER BY paid_date DESC
|
||
|
|
`).all(bill.id, start, end);
|
||
|
|
|
||
|
|
const row = buildTrackerRow(bill, payments, year, month, todayStr);
|
||
|
|
if (row.status === 'paid') continue; // skip already paid
|
||
|
|
|
||
|
|
upcoming.push({
|
||
|
|
id: bill.id,
|
||
|
|
name: bill.name,
|
||
|
|
category_name: bill.category_name,
|
||
|
|
due_date: dueDate,
|
||
|
|
expected_amount: bill.expected_amount,
|
||
|
|
status: row.status,
|
||
|
|
days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
|
||
|
|
res.json({ days, today: todayStr, upcoming });
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = router;
|