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:
2026-03-19 20:14:08 -04:00
parent 3bac852a40
commit 45383b80cf
5 changed files with 164 additions and 18 deletions

View File

@@ -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);