- PATCH paycheck-bills/:id/paid: variable bills now preserve amount_override rather than overwriting it with b.amount (which may be null/0). Fixed bills continue to lock in b.amount on paid and clear on unpaid. - generatePaychecks: revert gross/net protection — refresh always updates gross/net from current settings as originally intended. - CLAUDE.md: remove gross/net protection note; add td approve sub-agent rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
488 lines
15 KiB
JavaScript
488 lines
15 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { pool } = require('../db');
|
|
const { calcPaymentAmount } = require('./financing');
|
|
|
|
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: [],
|
|
financing: [],
|
|
});
|
|
}
|
|
|
|
// Attach virtual financing payment previews
|
|
const activePlans = await pool.query(
|
|
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
|
);
|
|
const client = await pool.connect();
|
|
try {
|
|
for (const plan of activePlans.rows) {
|
|
// Skip plans that haven't started yet for this period
|
|
const [planStartYear, planStartMonth] = plan.start_date.split('-').map(Number);
|
|
if (year * 12 + month < planStartYear * 12 + planStartMonth) continue;
|
|
|
|
const amount = await calcPaymentAmount(client, plan, year, month);
|
|
if (amount <= 0) continue;
|
|
|
|
const due = new Date(plan.due_date);
|
|
const dueYear = due.getFullYear();
|
|
const dueMonth = due.getMonth() + 1;
|
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
const overdue = due < today && parseFloat(plan.total_amount) > 0;
|
|
|
|
const entry = {
|
|
financing_payment_id: null,
|
|
plan_id: plan.id,
|
|
name: plan.name,
|
|
amount,
|
|
paid: false,
|
|
paid_at: null,
|
|
due_date: plan.due_date,
|
|
overdue,
|
|
plan_closed: false,
|
|
};
|
|
|
|
if (plan.assigned_paycheck == null) {
|
|
// split across both — give half to each
|
|
const half = parseFloat((amount / 2).toFixed(2));
|
|
paychecks.find(p => p.paycheck_number === 1)?.financing.push({ ...entry, amount: half });
|
|
paychecks.find(p => p.paycheck_number === 2)?.financing.push({ ...entry, amount: half });
|
|
} else {
|
|
paychecks.find(p => p.paycheck_number === plan.assigned_paycheck)?.financing.push(entry);
|
|
}
|
|
}
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
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]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Generate financing_payment records for active plans
|
|
const activePlans = await client.query(
|
|
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
|
);
|
|
for (const plan of activePlans.rows) {
|
|
// Skip plans that haven't started yet for this period
|
|
const [planStartYear, planStartMonth] = plan.start_date.split('-').map(Number);
|
|
if (year * 12 + month < planStartYear * 12 + planStartMonth) continue;
|
|
|
|
// Determine which paycheck(s) this plan applies to
|
|
const targets = plan.assigned_paycheck == null ? [1, 2] : [plan.assigned_paycheck];
|
|
for (const pcNum of targets) {
|
|
const paycheckId = paycheckIds[pcNum - 1];
|
|
// Calculate per-period amount (for split plans, this is the full per-period amount; we halve below)
|
|
const fullAmount = await calcPaymentAmount(client, plan, year, month);
|
|
if (fullAmount <= 0) continue;
|
|
const amount = plan.assigned_paycheck == null
|
|
? parseFloat((fullAmount / 2).toFixed(2))
|
|
: fullAmount;
|
|
|
|
await client.query(
|
|
`INSERT INTO financing_payments (plan_id, paycheck_id, amount)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (plan_id, paycheck_id) DO NOTHING`,
|
|
[plan.id, paycheckId, amount]
|
|
);
|
|
}
|
|
}
|
|
|
|
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]
|
|
);
|
|
|
|
const financingResult = await pool.query(
|
|
`SELECT fp.id AS financing_payment_id,
|
|
fp.plan_id,
|
|
fp.amount,
|
|
fp.paid,
|
|
fp.paid_at,
|
|
pl.name,
|
|
pl.due_date,
|
|
pl.total_amount,
|
|
COALESCE(SUM(fp2.amount) FILTER (WHERE fp2.paid), 0) AS paid_total
|
|
FROM financing_payments fp
|
|
JOIN financing_plans pl ON pl.id = fp.plan_id
|
|
LEFT JOIN financing_payments fp2 ON fp2.plan_id = fp.plan_id AND fp2.paid = TRUE
|
|
WHERE fp.paycheck_id = $1
|
|
GROUP BY fp.id, fp.plan_id, fp.amount, fp.paid, fp.paid_at, pl.name, pl.due_date, pl.total_amount
|
|
ORDER BY pl.due_date`,
|
|
[pc.id]
|
|
);
|
|
|
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
|
|
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,
|
|
financing: financingResult.rows.map(f => ({
|
|
financing_payment_id: f.financing_payment_id,
|
|
plan_id: f.plan_id,
|
|
name: f.name,
|
|
amount: f.amount,
|
|
paid: f.paid,
|
|
paid_at: f.paid_at,
|
|
due_date: f.due_date,
|
|
total_amount: f.total_amount,
|
|
paid_total: parseFloat(f.paid_total) || 0,
|
|
overdue: new Date(f.due_date) < today && (parseFloat(f.total_amount) - (parseFloat(f.paid_total) || 0)) > 0,
|
|
})),
|
|
});
|
|
}
|
|
|
|
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 b.variable_amount THEN pb.amount_override
|
|
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;
|