180 lines
5.8 KiB
JavaScript
180 lines
5.8 KiB
JavaScript
|
|
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;
|