BillTracker/routes/calendar.js

260 lines
8.4 KiB
JavaScript

const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router();
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
const { accountingActiveSql } = require('../services/paymentAccountingService');
const {
feedUrlForToken,
getActiveToken,
getOrCreateToken,
previewFeed,
regenerateToken,
revokeToken,
} = require('../services/calendarFeedService');
const { localDateString } = require('../utils/dates');
const { roundMoney, sumMoney, fromCents } = require('../utils/money');
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,
},
};
}
function tokenPayload(req, tokenRow) {
if (!tokenRow) {
return {
active: false,
token: null,
feed_url: null,
created_at: null,
last_used_at: null,
revoked_at: null,
};
}
return {
active: !!tokenRow.active,
token: tokenRow.token,
feed_url: feedUrlForToken(req, tokenRow.token),
created_at: tokenRow.created_at,
last_used_at: tokenRow.last_used_at,
revoked_at: tokenRow.revoked_at,
};
}
// GET /api/calendar/feed — current user's calendar feed token and URL
router.get('/feed', (req, res) => {
res.json(tokenPayload(req, getActiveToken(req.user.id)));
});
// POST /api/calendar/feed — create the feed token if one does not exist
router.post('/feed', (req, res) => {
const token = getOrCreateToken(req.user.id);
res.status(201).json(tokenPayload(req, token));
});
// POST /api/calendar/feed/regenerate — revoke old token and issue a new URL
router.post('/feed/regenerate', (req, res) => {
const token = regenerateToken(req.user.id);
res.json(tokenPayload(req, token));
});
// DELETE /api/calendar/feed — revoke the active feed URL
router.delete('/feed', (req, res) => {
revokeToken(req.user.id);
res.json(tokenPayload(req, null));
});
// GET /api/calendar/feed/preview — next generated events shown before subscribing
router.get('/feed/preview', (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 50);
res.json({
events: previewFeed(req.user.id, { limit }),
});
});
// 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(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
}
if (isNaN(month) || month < 1 || month > 12) {
return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'));
}
const today = localDateString(now);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
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 AND c.deleted_at IS NULL
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
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
AND ${accountingActiveSql()}
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 b.deleted_at IS NULL
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND ${accountingActiveSql('p')}
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) {
const amount = fromCents(payment.amount);
day.payments.push({
payment_id: payment.payment_id,
bill_id: payment.bill_id,
bill_name: payment.bill_name,
amount,
paid_date: payment.paid_date,
method: payment.method || null,
notes: payment.notes || null,
});
day.status_summary.total_paid += amount || 0;
}
}
const calendarBills = bills.map(bill => {
const billRange = getCycleRange(year, month, bill);
if (!billRange) return null;
const billPayments = paymentsByBillStmt.all(bill.id, billRange.start, billRange.end);
const row = buildTrackerRow(bill, billPayments, year, month, today, rowOptions);
if (!row) return null;
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = fromCents(monthlyState?.actual_amount);
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;
return {
bill_id: bill.id,
name: bill.name,
due_date: row.due_date,
due_day: Number(row.due_date.slice(8, 10)),
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,
};
}).filter(Boolean);
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 = sumMoney(activeBills, bill => bill.effective_amount || 0);
const paidTotal = sumMoney(activeBills, bill => bill.paid_amount || 0);
const remainingTotal = Math.max(0, roundMoney(expectedTotal - paidTotal));
const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0;
// Cent-exact: the per-day loops above accumulate floats; settle them here.
for (const day of days) {
day.status_summary.total_paid = roundMoney(day.status_summary.total_paid);
day.status_summary.total_due = roundMoney(day.status_summary.total_due);
}
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;