Files
budget-app/server/src/routes/paychecks.js
Christian Hood 45383b80cf 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>
2026-03-19 20:14:08 -04:00

375 lines
11 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { pool } = require('../db');
const CONFIG_KEYS = [
'paycheck1_day',
'paycheck2_day',
'paycheck1_gross',
'paycheck1_net',
'paycheck2_gross',
'paycheck2_net',
];
const CONFIG_DEFAULTS = {
paycheck1_day: 1,
paycheck2_day: 15,
};
async function getConfig() {
const result = await pool.query(
'SELECT key, value FROM config WHERE key = ANY($1)',
[CONFIG_KEYS]
);
const map = {};
for (const row of result.rows) {
map[row.key] = row.value;
}
const config = {};
for (const key of CONFIG_KEYS) {
const raw =
map[key] !== undefined
? map[key]
: CONFIG_DEFAULTS[key] !== undefined
? String(CONFIG_DEFAULTS[key])
: null;
config[key] = raw !== null ? Number(raw) : null;
}
return config;
}
function pad2(n) {
return String(n).padStart(2, '0');
}
// Build virtual (unsaved) paycheck data from config + active bills.
// Returns the same shape as fetchPaychecksForMonth but with id: null
// and paycheck_bill_id: null — nothing is written to the DB.
async function buildVirtualPaychecks(year, month) {
const config = await getConfig();
const paychecks = [];
for (const num of [1, 2]) {
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
const payDate = `${year}-${pad2(month)}-${pad2(day ?? 1)}`;
const billsResult = await pool.query(
`SELECT id, name, amount, due_day, category, variable_amount
FROM bills WHERE active = TRUE AND assigned_paycheck = $1
ORDER BY due_day, name`,
[num]
);
paychecks.push({
id: null,
period_year: year,
period_month: month,
paycheck_number: num,
pay_date: payDate,
gross: gross || 0,
net: net || 0,
bills: billsResult.rows.map(b => ({
paycheck_bill_id: null,
bill_id: b.id,
name: b.name,
amount: b.amount,
amount_override: null,
effective_amount: b.amount,
due_day: b.due_day,
category: b.category,
variable_amount: b.variable_amount,
paid: false,
paid_at: null,
})),
one_time_expenses: [],
});
}
return paychecks;
}
// Generate (upsert) paycheck records for the given year/month.
// Returns the two paycheck IDs.
async function generatePaychecks(year, month) {
const config = await getConfig();
const client = await pool.connect();
try {
await client.query('BEGIN');
const paycheckIds = [];
for (const num of [1, 2]) {
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
const payDate = `${year}-${pad2(month)}-${pad2(day)}`;
const pcResult = await client.query(
`INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (period_year, period_month, paycheck_number)
DO UPDATE SET pay_date = EXCLUDED.pay_date,
gross = EXCLUDED.gross,
net = EXCLUDED.net
RETURNING id`,
[year, month, num, payDate, gross || 0, net || 0]
);
const paycheckId = pcResult.rows[0].id;
paycheckIds.push(paycheckId);
const billsResult = await client.query(
'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1',
[num]
);
for (const bill of billsResult.rows) {
await client.query(
`INSERT INTO paycheck_bills (paycheck_id, bill_id)
VALUES ($1, $2)
ON CONFLICT (paycheck_id, bill_id) DO NOTHING`,
[paycheckId, bill.id]
);
}
}
await client.query('COMMIT');
return paycheckIds;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Fetch both paycheck records for a month with full bill and one_time_expense data.
async function fetchPaychecksForMonth(year, month) {
const pcResult = await pool.query(
`SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net
FROM paychecks
WHERE period_year = $1 AND period_month = $2
ORDER BY paycheck_number`,
[year, month]
);
const paychecks = [];
for (const pc of pcResult.rows) {
const billsResult = await pool.query(
`SELECT pb.id AS paycheck_bill_id,
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,
b.category,
pb.paid,
pb.paid_at
FROM paycheck_bills pb
JOIN bills b ON b.id = pb.bill_id
WHERE pb.paycheck_id = $1
ORDER BY b.due_day, b.name`,
[pc.id]
);
const oteResult = await pool.query(
`SELECT id, name, amount, paid, paid_at
FROM one_time_expenses
WHERE paycheck_id = $1
ORDER BY id`,
[pc.id]
);
paychecks.push({
id: pc.id,
period_year: pc.period_year,
period_month: pc.period_month,
paycheck_number: pc.paycheck_number,
pay_date: pc.pay_date,
gross: pc.gross,
net: pc.net,
bills: billsResult.rows.map((b) => ({
paycheck_bill_id: b.paycheck_bill_id,
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,
category: b.category,
paid: b.paid,
paid_at: b.paid_at,
})),
one_time_expenses: oteResult.rows,
});
}
return paychecks;
}
// POST /api/paychecks/generate?year=&month=
router.post('/paychecks/generate', async (req, res) => {
const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10);
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
return res.status(400).json({ error: 'year and month (1-12) are required query params' });
}
try {
await generatePaychecks(year, month);
const paychecks = await fetchPaychecksForMonth(year, month);
res.json(paychecks);
} catch (err) {
console.error('POST /api/paychecks/generate error:', err);
res.status(500).json({ error: 'Failed to generate paychecks' });
}
});
// GET /api/paychecks?year=&month=
// Returns virtual (unsaved) data when no DB records exist for the month.
router.get('/paychecks', async (req, res) => {
const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10);
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
return res.status(400).json({ error: 'year and month (1-12) are required query params' });
}
try {
const existing = await pool.query(
'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1',
[year, month]
);
if (existing.rows.length === 0) {
const virtual = await buildVirtualPaychecks(year, month);
return res.json(virtual);
}
const paychecks = await fetchPaychecksForMonth(year, month);
res.json(paychecks);
} catch (err) {
console.error('GET /api/paychecks error:', err);
res.status(500).json({ error: 'Failed to fetch paychecks' });
}
});
// GET /api/paychecks/months — list all generated months, most recent first
router.get('/paychecks/months', async (req, res) => {
try {
const result = await pool.query(
`SELECT DISTINCT period_year AS year, period_month AS month
FROM paychecks
ORDER BY period_year DESC, period_month DESC`
);
res.json(result.rows);
} catch (err) {
console.error('GET /api/paychecks/months error:', err);
res.status(500).json({ error: 'Failed to fetch paycheck months' });
}
});
// PATCH /api/paychecks/:id — update gross and net
router.patch('/paychecks/:id', async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid id' });
}
const { gross, net } = req.body;
if (gross == null || net == null) {
return res.status(400).json({ error: 'gross and net are required' });
}
try {
const result = await pool.query(
`UPDATE paychecks SET gross = $1, net = $2 WHERE id = $3
RETURNING id, gross, net`,
[parseFloat(gross), parseFloat(net), id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Paycheck not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error('PATCH /api/paychecks/:id error:', err);
res.status(500).json({ error: 'Failed to update paycheck' });
}
});
// 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);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid id' });
}
const { paid } = req.body;
if (typeof paid !== 'boolean') {
return res.status(400).json({ error: 'paid must be a boolean' });
}
try {
const result = await pool.query(
`UPDATE paycheck_bills pb
SET paid = $1,
paid_at = CASE WHEN $1 THEN NOW() ELSE NULL END,
amount_override = CASE WHEN $1 THEN b.amount ELSE NULL END
FROM bills b
WHERE pb.bill_id = b.id AND pb.id = $2
RETURNING pb.id, pb.paid, pb.paid_at, pb.amount_override`,
[paid, 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/paid error:', err);
res.status(500).json({ error: 'Failed to update paid status' });
}
});
module.exports = router;