BillTracker/routes/calendar.js

180 lines
5.8 KiB
JavaScript
Raw Normal View History

2026-05-04 13:14:32 -05:00
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;