BillTracker/routes/calendar.cts

268 lines
8.6 KiB
TypeScript
Raw Normal View History

import type { Req, Res, Next } from '../types/http';
2026-05-04 13:14:32 -05:00
const express = require('express');
const { ValidationError } = require('../utils/apiError.cts');
2026-05-04 13:14:32 -05:00
const router = express.Router();
const { getDb } = require('../db/database.cts');
const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
const { getUserSettings } = require('../services/userSettings.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const {
feedUrlForToken,
getActiveToken,
getOrCreateToken,
previewFeed,
regenerateToken,
revokeToken,
} = require('../services/calendarFeedService.cts');
const { localDateString } = require('../utils/dates.mts');
const { roundMoney, sumMoney, fromCents } = require('../utils/money.mts');
2026-05-04 13:14:32 -05:00
function toDateString(year: number, month: number, day: number) {
2026-05-04 13:14:32 -05:00
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function emptyDay(year: number, month: number, day: number) {
2026-05-04 13:14:32 -05:00
return {
date: toDateString(year, month, day),
day,
bills_due: [] as any[],
payments: [] as any[],
2026-05-04 13:14:32 -05:00
status_summary: {
due_count: 0,
paid_count: 0,
skipped_count: 0,
missed_count: 0,
total_due: 0,
total_paid: 0,
},
};
}
function tokenPayload(req: Req, tokenRow: any) {
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: Req, res: 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: Req, res: 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: Req, res: 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: Req, res: 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: Req, res: Res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit || '10', 10) || 10, 1), 50);
res.json({
events: previewFeed(req.user.id, { limit }),
});
});
2026-05-04 13:14:32 -05:00
// GET /api/calendar?year=2026&month=5
router.get('/', (req: Req, res: Res) => {
2026-05-04 13:14:32 -05:00
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) {
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
2026-05-04 13:14:32 -05:00
}
if (isNaN(month) || month < 1 || month > 12) {
throw ValidationError('month must be an integer between 1 and 12', 'month');
2026-05-04 13:14:32 -05:00
}
const today = localDateString(now);
2026-05-15 22:45:38 -05:00
const userSettings = getUserSettings(req.user.id);
const rowOptions = {
gracePeriodDays: userSettings.grace_period_days,
dueSoonDays: userSettings.due_soon_days,
};
2026-05-04 13:14:32 -05:00
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]));
2026-05-04 13:14:32 -05:00
const bills = db
.prepare(
`
2026-05-04 13:14:32 -05:00
SELECT b.*, c.name AS category_name
FROM bills b
2026-05-16 10:34:32 -05:00
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
2026-05-04 13:14:32 -05:00
ORDER BY b.due_day ASC, b.name ASC
`,
)
.all(req.user.id);
2026-05-04 13:14:32 -05:00
const paymentsByBillStmt = db.prepare(`
SELECT *
FROM payments
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
2026-05-04 13:14:32 -05:00
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(
`
2026-05-04 13:14:32 -05:00
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 = ?
2026-05-16 10:34:32 -05:00
AND b.deleted_at IS NULL
2026-05-04 13:14:32 -05:00
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND ${accountingActiveSql('p')}
2026-05-04 13:14:32 -05:00
ORDER BY p.paid_date ASC, b.name ASC
`,
)
.all(req.user.id, start, end);
2026-05-04 13:14:32 -05:00
for (const payment of payments) {
const day = dayByDate.get(payment.paid_date);
if (day) {
const amount = fromCents(payment.amount);
2026-05-04 13:14:32 -05:00
day.payments.push({
payment_id: payment.payment_id,
bill_id: payment.bill_id,
bill_name: payment.bill_name,
amount,
2026-05-04 13:14:32 -05:00
paid_date: payment.paid_date,
method: payment.method || null,
notes: payment.notes || null,
});
day.status_summary.total_paid += amount || 0;
2026-05-04 13:14:32 -05:00
}
}
const calendarBills = bills
.map((bill: any) => {
const billRange = getCycleRange(year, month, bill);
if (!billRange) return null;
2026-05-16 20:26:09 -05:00
const billPayments = paymentsByBillStmt.all(bill.id, billRange.start, billRange.end);
const row = buildTrackerRow(bill, billPayments, year, month, today, rowOptions);
if (!row) return null;
2026-05-16 20:26:09 -05:00
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;
2026-05-04 13:14:32 -05:00
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);
2026-05-04 13:14:32 -05:00
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: any) => !bill.is_skipped);
const expectedTotal = sumMoney(activeBills, (bill: any) => bill.effective_amount || 0);
const paidTotal = sumMoney(activeBills, (bill: any) => 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;
2026-05-04 13:14:32 -05:00
// 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);
}
2026-05-04 13:14:32 -05:00
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: any) => bill.is_paid).length,
skipped_count: calendarBills.filter((bill: any) => bill.is_skipped).length,
missed_count: activeBills.filter(
(bill: any) => bill.status === 'late' || bill.status === 'missed',
).length,
2026-05-04 13:14:32 -05:00
},
});
});
module.exports = router;