Add paycheck generation and retrieval API
Auto-generates paycheck records from config and active bills. GET /api/paychecks auto-generates if month not yet created. Idempotent generation preserves existing paid status. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
235
server/src/routes/paychecks.js
Normal file
235
server/src/routes/paychecks.js
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user