Add variable expense actuals logging

API for expense categories and actuals with full CRUD.
Paycheck view shows actuals section and includes them in
remaining balance calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:12:25 -04:00
parent 9ada36deda
commit 17af71a7c7
2 changed files with 428 additions and 4 deletions

View File

@@ -0,0 +1,139 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../db');
// GET /api/expense-categories — list all categories
router.get('/expense-categories', async (req, res) => {
try {
const result = await pool.query(
'SELECT id, name FROM expense_categories ORDER BY name'
);
res.json(result.rows);
} catch (err) {
console.error('GET /api/expense-categories error:', err);
res.status(500).json({ error: 'Failed to fetch expense categories' });
}
});
// POST /api/expense-categories — create a category { name }
router.post('/expense-categories', async (req, res) => {
const { name } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name is required' });
}
try {
const result = await pool.query(
'INSERT INTO expense_categories (name) VALUES ($1) RETURNING id, name',
[name.trim()]
);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') {
return res.status(409).json({ error: 'Category already exists' });
}
console.error('POST /api/expense-categories error:', err);
res.status(500).json({ error: 'Failed to create expense category' });
}
});
// GET /api/actuals?paycheck_id= — list actuals for a paycheck, ordered by date desc
router.get('/actuals', async (req, res) => {
const paycheckId = parseInt(req.query.paycheck_id, 10);
if (isNaN(paycheckId)) {
return res.status(400).json({ error: 'paycheck_id query param is required' });
}
try {
const result = await pool.query(
`SELECT a.id, a.paycheck_id, a.category_id, a.bill_id, a.amount, a.note, a.date, a.created_at,
ec.name AS category_name
FROM actuals a
LEFT JOIN expense_categories ec ON ec.id = a.category_id
WHERE a.paycheck_id = $1
ORDER BY a.date DESC, a.created_at DESC`,
[paycheckId]
);
res.json(result.rows);
} catch (err) {
console.error('GET /api/actuals error:', err);
res.status(500).json({ error: 'Failed to fetch actuals' });
}
});
// POST /api/actuals — log an actual { paycheck_id, category_id, amount, note, date }
router.post('/actuals', async (req, res) => {
const { paycheck_id, category_id, amount, note, date } = req.body;
if (!paycheck_id) {
return res.status(400).json({ error: 'paycheck_id is required' });
}
if (amount === undefined || amount === null || amount === '') {
return res.status(400).json({ error: 'amount is required' });
}
const parsedPaycheckId = parseInt(paycheck_id, 10);
const parsedAmount = parseFloat(amount);
if (isNaN(parsedPaycheckId)) {
return res.status(400).json({ error: 'paycheck_id must be a valid integer' });
}
if (isNaN(parsedAmount)) {
return res.status(400).json({ error: 'amount must be a valid number' });
}
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
const actualDate = date || new Date().toISOString().slice(0, 10);
try {
const result = await pool.query(
`INSERT INTO actuals (paycheck_id, category_id, amount, note, date)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, paycheck_id, category_id, amount, note, date, created_at`,
[parsedPaycheckId, parsedCategoryId, parsedAmount, note || null, actualDate]
);
// Fetch category name to include in response
const actual = result.rows[0];
if (actual.category_id) {
const catResult = await pool.query(
'SELECT name FROM expense_categories WHERE id = $1',
[actual.category_id]
);
actual.category_name = catResult.rows[0]?.name || null;
} else {
actual.category_name = null;
}
res.status(201).json(actual);
} catch (err) {
console.error('POST /api/actuals error:', err);
res.status(500).json({ error: 'Failed to log actual' });
}
});
// DELETE /api/actuals/:id — remove an actual entry
router.delete('/actuals/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid id' });
}
try {
const result = await pool.query(
'DELETE FROM actuals WHERE id = $1 RETURNING id',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Actual not found' });
}
res.json({ deleted: true, id: result.rows[0].id });
} catch (err) {
console.error('DELETE /api/actuals/:id error:', err);
res.status(500).json({ error: 'Failed to delete actual' });
}
});
module.exports = router;