BillTracker/routes/summary.js

174 lines
5.5 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService');
const DEFAULT_INCOME_LABEL = 'Salary';
function parseYearMonth(source) {
const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
}
if (Number.isNaN(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' };
}
return { year, month };
}
function money(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function getIncome(db, userId, year, month) {
const row = db.prepare(`
SELECT id, label, amount
FROM monthly_income
WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, month);
return {
id: row?.id || null,
label: row?.label || DEFAULT_INCOME_LABEL,
amount: money(row?.amount),
};
}
function buildSummary(db, userId, year, month) {
const income = getIncome(db, userId, year, month);
const { start, end } = getCycleRange(year, month);
const billRows = db.prepare(`
SELECT
b.id AS bill_id,
b.name,
b.expected_amount,
b.due_day,
c.name AS category_name,
m.actual_amount,
m.is_skipped
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
WHERE b.user_id = ? AND b.active = 1
ORDER BY b.due_day ASC, b.name ASC
`).all(year, month, userId);
const billIds = billRows.map(row => row.bill_id);
const paymentMap = new Map();
if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(', ');
const payments = db.prepare(`
SELECT p.bill_id, COUNT(p.id) AS payment_count, SUM(p.amount) AS paid_amount
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY p.bill_id
`).all(userId, ...billIds, start, end);
for (const row of payments) {
paymentMap.set(row.bill_id, {
payment_count: row.payment_count || 0,
paid_amount: money(row.paid_amount),
});
}
}
const expenses = billRows.map(row => {
const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
const paidAmount = money(payment.paid_amount);
return {
bill_id: row.bill_id,
name: row.name,
expected_amount: money(row.expected_amount),
actual_amount: hasActual ? money(row.actual_amount) : null,
display_amount: displayAmount,
is_paid: payment.payment_count > 0,
paid_amount: paidAmount,
payment_count: payment.payment_count,
is_skipped: !!row.is_skipped,
due_day: row.due_day,
category_name: row.category_name || null,
};
});
const countedExpenses = expenses.filter(expense => !expense.is_skipped);
const incomeTotal = money(income.amount);
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
const result = incomeTotal - expenseTotal;
return {
year,
month,
income,
expenses,
summary: {
income_total: incomeTotal,
expense_total: expenseTotal,
paid_expense_count: paidExpenseCount,
expense_count: countedExpenses.length,
paid_total: paidTotal,
remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
result,
},
chart: [
{ type: 'Income', amount: incomeTotal },
{ type: 'Expenses', amount: expenseTotal },
{ type: 'Savings', amount: result },
],
generated_at: new Date().toISOString(),
};
}
router.get('/', (req, res) => {
const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb();
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
});
router.put('/income', (req, res) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
}
const label = String(req.body?.label || DEFAULT_INCOME_LABEL).trim().slice(0, 80) || DEFAULT_INCOME_LABEL;
const db = getDb();
db.prepare(`
INSERT INTO monthly_income (user_id, year, month, label, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, year, month) DO UPDATE SET
label = excluded.label,
amount = excluded.amount,
updated_at = datetime('now')
`).run(req.user.id, parsed.year, parsed.month, label, amount);
res.json({
year: parsed.year,
month: parsed.month,
income: getIncome(db, req.user.id, parsed.year, parsed.month),
});
});
module.exports = router;