@@ -222,12 +236,17 @@ function Bills() {
{bills.map((bill) => (
| {bill.name} |
- {formatCurrency(bill.amount)} |
+
+ {bill.variable_amount
+ ? varies{bill.amount > 0 ? ` (~${formatCurrency(bill.amount)})` : ''}
+ : formatCurrency(bill.amount)}
+ |
{ordinal(bill.due_day)} |
#{bill.assigned_paycheck} |
{bill.category && {bill.category}}
|
+ {bill.variable_amount ? '〜' : ''} |
{bill.name}
- {formatCurrency(bill.effective_amount)}
+ {bill.variable_amount && editingBillAmount === bill.bill_id ? (
+
+ setBillAmountDraft(e.target.value)}
+ className="form-input"
+ style={{ width: '90px', padding: '0.2rem 0.35rem', fontSize: '0.8rem' }}
+ autoFocus
+ onKeyDown={e => {
+ if (e.key === 'Enter') saveBillAmount(bill);
+ if (e.key === 'Escape') setEditingBillAmount(null);
+ }}
+ />
+
+
+
+ ) : (
+
+
+ {bill.variable_amount && !bill.amount_override
+ ? enter amount
+ : formatCurrency(bill.effective_amount)}
+
+ {bill.variable_amount && !bill.paid && (
+
+ )}
+
+ )}
due {ordinal(bill.due_day)}
+ {bill.variable_amount && variable}
{bill.category && {bill.category}}
@@ -445,6 +499,35 @@ function PaycheckView() {
));
}
+ async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) {
+ let realPaycheckBillId = paycheckBillId;
+
+ if (!realPaycheckBillId) {
+ const generated = await generateMonth();
+ const pc = generated.find(p => p.paycheck_number === paycheckNumber);
+ realPaycheckBillId = pc.bills.find(b => b.bill_id === billId).paycheck_bill_id;
+ }
+
+ const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/amount`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ amount }),
+ });
+ if (!res.ok) {
+ const body = await res.json();
+ throw new Error(body.error || `Server error: ${res.status}`);
+ }
+ // Reflect the new override in local state
+ setPaychecks(prev => prev.map(pc => ({
+ ...pc,
+ bills: pc.bills.map(b =>
+ b.paycheck_bill_id === realPaycheckBillId
+ ? { ...b, amount_override: amount, effective_amount: amount }
+ : b
+ ),
+ })));
+ }
+
async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) {
let realPaycheckBillId = paycheckBillId;
@@ -589,6 +672,7 @@ function PaycheckView() {
categories={categories}
onGenerate={generateMonth}
onAmountSave={handleAmountSave}
+ onBillAmountSave={handleBillAmountSave}
/>
)}
diff --git a/db/migrations/002_variable_amount_bills.sql b/db/migrations/002_variable_amount_bills.sql
new file mode 100644
index 0000000..6b84407
--- /dev/null
+++ b/db/migrations/002_variable_amount_bills.sql
@@ -0,0 +1,4 @@
+-- Add variable_amount flag to bills.
+-- When true, the amount is expected to change each month and must be
+-- entered per-paycheck via amount_override on paycheck_bills.
+ALTER TABLE bills ADD COLUMN IF NOT EXISTS variable_amount BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js
index ada2dd5..8bea9b9 100644
--- a/server/src/routes/bills.js
+++ b/server/src/routes/bills.js
@@ -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,
]
);
diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js
index fcaf548..3782c72 100644
--- a/server/src/routes/paychecks.js
+++ b/server/src/routes/paychecks.js
@@ -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);
|