diff --git a/server/src/index.js b/server/src/index.js index 2d58a68..7220617 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -5,6 +5,7 @@ const path = require('path'); const healthRouter = require('./routes/health'); const configRouter = require('./routes/config'); const billsRouter = require('./routes/bills'); +const paychecksRouter = require('./routes/paychecks'); const db = require('./db'); const app = express(); @@ -17,6 +18,7 @@ app.use(express.json()); app.use('/api', healthRouter); app.use('/api', configRouter); app.use('/api', billsRouter); +app.use('/api', paychecksRouter); // Serve static client files in production const clientDist = path.join(__dirname, '../../client/dist'); diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js new file mode 100644 index 0000000..76e801e --- /dev/null +++ b/server/src/routes/paychecks.js @@ -0,0 +1,235 @@ +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; +} + +// Pad a number to two digits +function pad2(n) { + return String(n).padStart(2, '0'); +} + +// Generate (upsert) paycheck records for the given year/month. +// Returns the two paycheck rows with their assigned bills. +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)}`; + + // Upsert paycheck record + 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); + + // Fetch all active bills assigned to this paycheck number + const billsResult = await client.query( + 'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1', + [num] + ); + + // Idempotently insert each bill into paycheck_bills + 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) { + // Fetch paycheck rows + 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) { + // Fetch associated bills joined with bill definitions + const billsResult = await pool.query( + `SELECT pb.id AS paycheck_bill_id, + pb.bill_id, + b.name, + b.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] + ); + + // Fetch one-time expenses + 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, + 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= +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 { + // Check if paychecks exist for this month; if not, auto-generate + 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) { + await generatePaychecks(year, month); + } + + 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' }); + } +}); + +module.exports = router;