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;