Add variable amount bills
- Migration 002: variable_amount boolean column on bills (default false) - Bills form: 'Variable amount' checkbox; amount field becomes optional 'Typical amount' when checked; table shows 'varies (~$X)' and a 〜 badge - Paycheck view: variable bills show a pencil edit button to enter the month's actual amount, stored as amount_override on paycheck_bills - New PATCH /api/paycheck-bills/:id/amount endpoint - Lazy generation still works: setting an amount on a virtual paycheck generates it first then saves the override Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,15 @@ const router = express.Router();
|
||||
const { pool } = require('../db');
|
||||
|
||||
function validateBillFields(body) {
|
||||
const { name, amount, due_day, assigned_paycheck } = body;
|
||||
const { name, amount, due_day, assigned_paycheck, variable_amount } = body;
|
||||
if (!name || name.toString().trim() === '') {
|
||||
return 'name is required';
|
||||
}
|
||||
if (amount === undefined || amount === null || amount === '') {
|
||||
// Amount is optional for variable bills (defaults to 0)
|
||||
if (!variable_amount && (amount === undefined || amount === null || amount === '')) {
|
||||
return 'amount is required';
|
||||
}
|
||||
if (isNaN(Number(amount))) {
|
||||
if (amount !== undefined && amount !== null && amount !== '' && isNaN(Number(amount))) {
|
||||
return 'amount must be a number';
|
||||
}
|
||||
if (due_day === undefined || due_day === null || due_day === '') {
|
||||
@@ -57,20 +58,22 @@ router.post('/bills', async (req, res) => {
|
||||
assigned_paycheck,
|
||||
category = 'General',
|
||||
active = true,
|
||||
variable_amount = false,
|
||||
} = 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)
|
||||
`INSERT INTO bills (name, amount, due_day, assigned_paycheck, category, active, variable_amount)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
name.toString().trim(),
|
||||
Number(amount),
|
||||
Number(amount) || 0,
|
||||
parseInt(due_day, 10),
|
||||
parseInt(assigned_paycheck, 10),
|
||||
category || 'General',
|
||||
active !== undefined ? active : true,
|
||||
Boolean(variable_amount),
|
||||
]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
@@ -108,22 +111,24 @@ router.put('/bills/:id', async (req, res) => {
|
||||
assigned_paycheck,
|
||||
category = 'General',
|
||||
active = true,
|
||||
variable_amount = false,
|
||||
} = 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
|
||||
category = $5, active = $6, variable_amount = $7
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[
|
||||
name.toString().trim(),
|
||||
Number(amount),
|
||||
Number(amount) || 0,
|
||||
parseInt(due_day, 10),
|
||||
parseInt(assigned_paycheck, 10),
|
||||
category || 'General',
|
||||
active !== undefined ? active : true,
|
||||
Boolean(variable_amount),
|
||||
req.params.id,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function buildVirtualPaychecks(year, month) {
|
||||
const payDate = `${year}-${pad2(month)}-${pad2(day ?? 1)}`;
|
||||
|
||||
const billsResult = await pool.query(
|
||||
`SELECT id, name, amount, due_day, category
|
||||
`SELECT id, name, amount, due_day, category, variable_amount
|
||||
FROM bills WHERE active = TRUE AND assigned_paycheck = $1
|
||||
ORDER BY due_day, name`,
|
||||
[num]
|
||||
@@ -79,6 +79,7 @@ async function buildVirtualPaychecks(year, month) {
|
||||
effective_amount: b.amount,
|
||||
due_day: b.due_day,
|
||||
category: b.category,
|
||||
variable_amount: b.variable_amount,
|
||||
paid: false,
|
||||
paid_at: null,
|
||||
})),
|
||||
@@ -162,6 +163,7 @@ async function fetchPaychecksForMonth(year, month) {
|
||||
pb.bill_id,
|
||||
b.name,
|
||||
b.amount,
|
||||
b.variable_amount,
|
||||
pb.amount_override,
|
||||
CASE WHEN pb.amount_override IS NOT NULL THEN pb.amount_override ELSE b.amount END AS effective_amount,
|
||||
b.due_day,
|
||||
@@ -196,6 +198,7 @@ async function fetchPaychecksForMonth(year, month) {
|
||||
bill_id: b.bill_id,
|
||||
name: b.name,
|
||||
amount: b.amount,
|
||||
variable_amount: b.variable_amount,
|
||||
amount_override: b.amount_override,
|
||||
effective_amount: b.effective_amount,
|
||||
due_day: b.due_day,
|
||||
@@ -303,6 +306,36 @@ router.patch('/paychecks/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/paycheck-bills/:id/amount — set amount_override for a variable bill
|
||||
router.patch('/paycheck-bills/:id/amount', async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: 'Invalid id' });
|
||||
}
|
||||
|
||||
const { amount } = req.body;
|
||||
if (amount == null || isNaN(parseFloat(amount))) {
|
||||
return res.status(400).json({ error: 'amount is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE paycheck_bills SET amount_override = $1 WHERE id = $2
|
||||
RETURNING id, amount_override`,
|
||||
[parseFloat(amount), id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'paycheck_bill not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/paycheck-bills/:id/amount error:', err);
|
||||
res.status(500).json({ error: 'Failed to update amount' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/paycheck-bills/:id/paid
|
||||
router.patch('/paycheck-bills/:id/paid', async (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
|
||||
Reference in New Issue
Block a user