Add bills CRUD API and management UI

Full REST API for bill definitions with validation.
Bills page supports add, edit, toggle active, and delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 19:06:43 -04:00
parent 5f5f1111c5
commit 0835b86c1a
3 changed files with 611 additions and 1 deletions

174
server/src/routes/bills.js Normal file
View File

@@ -0,0 +1,174 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../db');
function validateBillFields(body) {
const { name, amount, due_day, assigned_paycheck } = body;
if (!name || name.toString().trim() === '') {
return 'name is required';
}
if (amount === undefined || amount === null || amount === '') {
return 'amount is required';
}
if (isNaN(Number(amount))) {
return 'amount must be a number';
}
if (due_day === undefined || due_day === null || due_day === '') {
return 'due_day is required';
}
const dueDayInt = parseInt(due_day, 10);
if (isNaN(dueDayInt) || dueDayInt < 1 || dueDayInt > 31) {
return 'due_day must be an integer between 1 and 31';
}
if (assigned_paycheck === undefined || assigned_paycheck === null || assigned_paycheck === '') {
return 'assigned_paycheck is required';
}
const pc = parseInt(assigned_paycheck, 10);
if (pc !== 1 && pc !== 2) {
return 'assigned_paycheck must be 1 or 2';
}
return null;
}
// GET /api/bills — list all bills
router.get('/bills', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM bills ORDER BY assigned_paycheck, name'
);
res.json(result.rows);
} catch (err) {
console.error('GET /api/bills error:', err);
res.status(500).json({ error: 'Failed to fetch bills' });
}
});
// POST /api/bills — create a bill
router.post('/bills', async (req, res) => {
const validationError = validateBillFields(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const {
name,
amount,
due_day,
assigned_paycheck,
category = 'General',
active = true,
} = req.body;
try {
const result = await pool.query(
`INSERT INTO bills (name, amount, due_day, assigned_paycheck, category, active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
name.toString().trim(),
Number(amount),
parseInt(due_day, 10),
parseInt(assigned_paycheck, 10),
category || 'General',
active !== undefined ? active : true,
]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error('POST /api/bills error:', err);
res.status(500).json({ error: 'Failed to create bill' });
}
});
// GET /api/bills/:id — get single bill
router.get('/bills/:id', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM bills WHERE id = $1', [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Bill not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('GET /api/bills/:id error:', err);
res.status(500).json({ error: 'Failed to fetch bill' });
}
});
// PUT /api/bills/:id — update bill
router.put('/bills/:id', async (req, res) => {
const validationError = validateBillFields(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const {
name,
amount,
due_day,
assigned_paycheck,
category = 'General',
active = true,
} = req.body;
try {
const result = await pool.query(
`UPDATE bills
SET name = $1, amount = $2, due_day = $3, assigned_paycheck = $4,
category = $5, active = $6
WHERE id = $7
RETURNING *`,
[
name.toString().trim(),
Number(amount),
parseInt(due_day, 10),
parseInt(assigned_paycheck, 10),
category || 'General',
active !== undefined ? active : true,
req.params.id,
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Bill not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('PUT /api/bills/:id error:', err);
res.status(500).json({ error: 'Failed to update bill' });
}
});
// DELETE /api/bills/:id — hard delete
router.delete('/bills/:id', async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM bills WHERE id = $1 RETURNING id',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Bill not found' });
}
res.json({ deleted: true, id: result.rows[0].id });
} catch (err) {
console.error('DELETE /api/bills/:id error:', err);
res.status(500).json({ error: 'Failed to delete bill' });
}
});
// PATCH /api/bills/:id/toggle — toggle active field
router.patch('/bills/:id/toggle', async (req, res) => {
try {
const result = await pool.query(
'UPDATE bills SET active = NOT active WHERE id = $1 RETURNING *',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Bill not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('PATCH /api/bills/:id/toggle error:', err);
res.status(500).json({ error: 'Failed to toggle bill' });
}
});
module.exports = router;