Lazy paycheck generation, regenerate button, inline gross/net edit

- GET /api/paychecks returns virtual data (id: null) without writing to
  DB when no records exist for the month
- First interaction (bill toggle, OTE add, actual add) lazily calls
  POST /api/paychecks/generate to persist the paycheck
- New PATCH /api/paychecks/:id to update gross and net
- Regenerate/refresh button syncs gross/net from current Settings
- Inline pencil edit for gross/net on each paycheck column header
- 'preview' badge and info banner shown for unsaved months

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:06:41 -04:00
parent c132e1a5fe
commit 3bac852a40
2 changed files with 344 additions and 129 deletions

View File

@@ -25,7 +25,9 @@ function todayISO() {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd }) { // ─── PaycheckColumn ───────────────────────────────────────────────────────────
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave }) {
const [newOteName, setNewOteName] = useState(''); const [newOteName, setNewOteName] = useState('');
const [newOteAmount, setNewOteAmount] = useState(''); const [newOteAmount, setNewOteAmount] = useState('');
const [actuals, setActuals] = useState([]); const [actuals, setActuals] = useState([]);
@@ -39,8 +41,15 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
const [formSubmitting, setFormSubmitting] = useState(false); const [formSubmitting, setFormSubmitting] = useState(false);
const [formError, setFormError] = useState(null); const [formError, setFormError] = useState(null);
// Inline gross/net editing
const [editingAmounts, setEditingAmounts] = useState(false);
const [editGross, setEditGross] = useState('');
const [editNet, setEditNet] = useState('');
const [amountSaving, setAmountSaving] = useState(false);
const [amountError, setAmountError] = useState(null);
useEffect(() => { useEffect(() => {
if (!paycheck) return; if (!paycheck?.id) { setActuals([]); return; }
loadActuals(paycheck.id); loadActuals(paycheck.id);
}, [paycheck?.id]); }, [paycheck?.id]);
@@ -50,8 +59,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
try { try {
const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`); const res = await fetch(`/api/actuals?paycheck_id=${paycheckId}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`); if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json(); setActuals(await res.json());
setActuals(data);
} catch (err) { } catch (err) {
setActualsError(err.message); setActualsError(err.message);
} finally { } finally {
@@ -65,11 +73,18 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
setFormSubmitting(true); setFormSubmitting(true);
setFormError(null); setFormError(null);
try { try {
// Lazy generate if this is a virtual paycheck
let paycheckId = paycheck.id;
if (!paycheckId) {
const generated = await onGenerate();
paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id;
}
const res = await fetch('/api/actuals', { const res = await fetch('/api/actuals', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
paycheck_id: paycheck.id, paycheck_id: paycheckId,
category_id: formCategoryId || null, category_id: formCategoryId || null,
amount: parseFloat(formAmount), amount: parseFloat(formAmount),
note: formNote || null, note: formNote || null,
@@ -80,7 +95,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
const body = await res.json(); const body = await res.json();
throw new Error(body.error || `Server error: ${res.status}`); throw new Error(body.error || `Server error: ${res.status}`);
} }
await loadActuals(paycheck.id); await loadActuals(paycheckId);
setFormCategoryId(''); setFormCategoryId('');
setFormAmount(''); setFormAmount('');
setFormNote(''); setFormNote('');
@@ -105,6 +120,27 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
} }
} }
function openAmountEdit() {
setEditGross(parseFloat(paycheck.gross) || '');
setEditNet(parseFloat(paycheck.net) || '');
setAmountError(null);
setEditingAmounts(true);
}
async function saveAmounts(e) {
e.preventDefault();
setAmountSaving(true);
setAmountError(null);
try {
await onAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0);
setEditingAmounts(false);
} catch (err) {
setAmountError(err.message);
} finally {
setAmountSaving(false);
}
}
if (!paycheck) { if (!paycheck) {
return ( return (
<div className="paycheck-card"> <div className="paycheck-card">
@@ -115,6 +151,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
); );
} }
const isVirtual = paycheck.id === null;
const net = parseFloat(paycheck.net) || 0; const net = parseFloat(paycheck.net) || 0;
const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0); const billsTotal = paycheck.bills.reduce((sum, b) => sum + (parseFloat(b.effective_amount) || 0), 0);
const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0); const otesTotal = paycheck.one_time_expenses.reduce((sum, e) => sum + (parseFloat(e.amount) || 0), 0);
@@ -124,12 +161,67 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
return ( return (
<div className="paycheck-card"> <div className="paycheck-card">
<div className="paycheck-card__header"> <div className="paycheck-card__header">
<div className="paycheck-card__number">Paycheck {paycheck.paycheck_number}</div> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div> <div>
<div className="paycheck-card__amounts"> <div className="paycheck-card__number">
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span> Paycheck {paycheck.paycheck_number}
<span>Net: <strong>{formatCurrency(paycheck.net)}</strong></span> {isVirtual && (
<span style={{
marginLeft: '0.5rem',
fontSize: '0.65rem',
background: 'var(--accent-subtle)',
color: 'var(--accent)',
padding: '0.1rem 0.4rem',
borderRadius: '999px',
border: '1px solid var(--accent)',
verticalAlign: 'middle',
}}>preview</span>
)}
</div>
<div className="paycheck-card__date">{formatPayDate(paycheck.pay_date)}</div>
</div>
</div> </div>
{editingAmounts ? (
<form onSubmit={saveAmounts} style={{ marginTop: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="form-group" style={{ flex: '1 1 120px' }}>
<label className="form-label">Gross</label>
<input type="number" min="0" step="0.01" value={editGross}
onChange={e => setEditGross(e.target.value)} className="form-input" />
</div>
<div className="form-group" style={{ flex: '1 1 120px' }}>
<label className="form-label">Net</label>
<input type="number" min="0" step="0.01" value={editNet}
onChange={e => setEditNet(e.target.value)} className="form-input" />
</div>
</div>
{amountError && <div className="form-error">{amountError}</div>}
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.5rem' }}>
<button type="submit" className="btn btn-sm btn-primary" disabled={amountSaving}>
{amountSaving ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn btn-sm" onClick={() => setEditingAmounts(false)}>
Cancel
</button>
</div>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.35rem' }}>
<div className="paycheck-card__amounts">
<span>Gross: <strong>{formatCurrency(paycheck.gross)}</strong></span>
<span>Net: <strong>{formatCurrency(paycheck.net)}</strong></span>
</div>
<button
className="btn-icon"
onClick={openAmountEdit}
title="Edit gross / net"
style={{ fontSize: '0.85rem', color: 'var(--text-faint)', flexShrink: 0 }}
>
</button>
</div>
)}
</div> </div>
<div className="paycheck-card__body"> <div className="paycheck-card__body">
@@ -140,11 +232,11 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
<p className="empty-state">(none)</p> <p className="empty-state">(none)</p>
) : ( ) : (
paycheck.bills.map((bill) => ( paycheck.bills.map((bill) => (
<div key={bill.paycheck_bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}> <div key={bill.bill_id} className="bill-row" style={{ opacity: bill.paid ? 0.6 : 1 }}>
<input <input
type="checkbox" type="checkbox"
checked={!!bill.paid} checked={!!bill.paid}
onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid)} onChange={() => onBillPaidToggle(bill.paycheck_bill_id, !bill.paid, bill.bill_id, paycheck.paycheck_number)}
className="bill-row__check" className="bill-row__check"
/> />
<div className="bill-row__info"> <div className="bill-row__info">
@@ -183,32 +275,17 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
)) ))
)} )}
<div className="inline-add-form"> <div className="inline-add-form">
<input <input type="text" placeholder="Name" value={newOteName}
type="text" onChange={(e) => setNewOteName(e.target.value)} className="form-input" />
placeholder="Name" <input type="number" placeholder="Amount" value={newOteAmount}
value={newOteName} onChange={(e) => setNewOteAmount(e.target.value)} min="0" step="0.01"
onChange={(e) => setNewOteName(e.target.value)} className="form-input" style={{ maxWidth: '100px' }} />
className="form-input" <button className="btn btn-sm btn-primary" onClick={() => {
/> if (!newOteName.trim() || !newOteAmount) return;
<input onOteAdd(paycheck.paycheck_number, newOteName.trim(), parseFloat(newOteAmount));
type="number" setNewOteName('');
placeholder="Amount" setNewOteAmount('');
value={newOteAmount} }}>
onChange={(e) => setNewOteAmount(e.target.value)}
min="0"
step="0.01"
className="form-input"
style={{ maxWidth: '100px' }}
/>
<button
className="btn btn-sm btn-primary"
onClick={() => {
if (!newOteName.trim() || !newOteAmount) return;
onOteAdd(paycheck.id, newOteName.trim(), parseFloat(newOteAmount));
setNewOteName('');
setNewOteAmount('');
}}
>
Add Add
</button> </button>
</div> </div>
@@ -217,7 +294,6 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
{/* Variable spending */} {/* Variable spending */}
<div className="mb-2"> <div className="mb-2">
<div className="section-title">Variable Spending</div> <div className="section-title">Variable Spending</div>
{actualsLoading && <p className="empty-state">Loading</p>} {actualsLoading && <p className="empty-state">Loading</p>}
{actualsError && <div className="alert alert-error">Error: {actualsError}</div>} {actualsError && <div className="alert alert-error">Error: {actualsError}</div>}
{!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>} {!actualsLoading && actuals.length === 0 && <p className="empty-state">(none)</p>}
@@ -242,43 +318,21 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
<form onSubmit={handleAddActual} className="form-rows"> <form onSubmit={handleAddActual} className="form-rows">
<div className="form-row"> <div className="form-row">
<select <select value={formCategoryId} onChange={e => setFormCategoryId(e.target.value)} className="form-select">
value={formCategoryId}
onChange={e => setFormCategoryId(e.target.value)}
className="form-select"
>
<option value=""> Category </option> <option value=""> Category </option>
{categories.map(cat => ( {categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option> <option key={cat.id} value={cat.id}>{cat.name}</option>
))} ))}
</select> </select>
<input <input type="number" placeholder="Amount" value={formAmount}
type="number" onChange={e => setFormAmount(e.target.value)} step="0.01" min="0"
placeholder="Amount" className="form-input" style={{ maxWidth: '110px' }} required />
value={formAmount}
onChange={e => setFormAmount(e.target.value)}
step="0.01"
min="0"
className="form-input"
style={{ maxWidth: '110px' }}
required
/>
</div> </div>
<div className="form-row"> <div className="form-row">
<input <input type="text" placeholder="Note (optional)" value={formNote}
type="text" onChange={e => setFormNote(e.target.value)} className="form-input" />
placeholder="Note (optional)" <input type="date" value={formDate} onChange={e => setFormDate(e.target.value)}
value={formNote} className="form-input" style={{ maxWidth: '140px' }} />
onChange={e => setFormNote(e.target.value)}
className="form-input"
/>
<input
type="date"
value={formDate}
onChange={e => setFormDate(e.target.value)}
className="form-input"
style={{ maxWidth: '140px' }}
/>
<button type="submit" disabled={formSubmitting} className="btn btn-sm btn-primary" style={{ flexShrink: 0 }}> <button type="submit" disabled={formSubmitting} className="btn btn-sm btn-primary" style={{ flexShrink: 0 }}>
Add Add
</button> </button>
@@ -299,6 +353,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
); );
} }
// ─── PaycheckView ─────────────────────────────────────────────────────────────
function PaycheckView() { function PaycheckView() {
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
@@ -307,6 +363,7 @@ function PaycheckView() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [regenerating, setRegenerating] = useState(false);
useEffect(() => { loadPaychecks(year, month); }, [year, month]); useEffect(() => { loadPaychecks(year, month); }, [year, month]);
useEffect(() => { loadCategories(); }, []); useEffect(() => { loadCategories(); }, []);
@@ -333,6 +390,27 @@ function PaycheckView() {
} catch { /* silent */ } } catch { /* silent */ }
} }
// Generates (or regenerates) paychecks for the current month, updates state,
// and returns the new paychecks array.
async function generateMonth() {
const res = await fetch(`/api/paychecks/generate?year=${year}&month=${month}`, { method: 'POST' });
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json();
setPaychecks(data);
return data;
}
async function handleRegenerate() {
setRegenerating(true);
try {
await generateMonth();
} catch (err) {
alert(`Failed to regenerate: ${err.message}`);
} finally {
setRegenerating(false);
}
}
function prevMonth() { function prevMonth() {
if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); } if (month === 1) { setYear(y => y - 1); setMonth(12); } else { setMonth(m => m - 1); }
} }
@@ -341,6 +419,81 @@ function PaycheckView() {
if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); } if (month === 12) { setYear(y => y + 1); setMonth(1); } else { setMonth(m => m + 1); }
} }
// Saves gross/net for a paycheck. If virtual, generates first then patches.
async function handleAmountSave(paycheckNumber, gross, net) {
let pc = paychecks.find(p => p.paycheck_number === paycheckNumber);
if (!pc.id) {
const generated = await generateMonth();
pc = generated.find(p => p.paycheck_number === paycheckNumber);
}
const res = await fetch(`/api/paychecks/${pc.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gross, net }),
});
if (!res.ok) {
const body = await res.json();
throw new Error(body.error || `Server error: ${res.status}`);
}
const updated = await res.json();
setPaychecks(prev => prev.map(p =>
p.paycheck_number === paycheckNumber
? { ...p, id: updated.id, gross: updated.gross, net: updated.net }
: p
));
}
async function handleBillPaidToggle(paycheckBillId, paid, billId, paycheckNumber) {
let realPaycheckBillId = paycheckBillId;
if (!realPaycheckBillId) {
// Virtual paycheck — generate first, then find the real paycheck_bill_id
const generated = await generateMonth();
const pc = generated.find(p => p.paycheck_number === paycheckNumber);
const bill = pc.bills.find(b => b.bill_id === billId);
realPaycheckBillId = bill.paycheck_bill_id;
}
// Optimistic update
setPaychecks(prev => prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === realPaycheckBillId
? { ...b, paid, paid_at: paid ? new Date().toISOString() : null }
: b
),
})));
try {
const res = await fetch(`/api/paycheck-bills/${realPaycheckBillId}/paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const updated = await res.json();
setPaychecks(prev => prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === realPaycheckBillId
? { ...b, paid: updated.paid, paid_at: updated.paid_at }
: b
),
})));
} catch (err) {
// Revert on failure
setPaychecks(prev => prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === realPaycheckBillId ? { ...b, paid: !paid } : b
),
})));
alert(`Failed to update bill: ${err.message}`);
}
}
async function handleOtePaidToggle(oteId, paid) { async function handleOtePaidToggle(oteId, paid) {
try { try {
const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, { const res = await fetch(`/api/one-time-expenses/${oteId}/paid`, {
@@ -366,7 +519,15 @@ function PaycheckView() {
} }
} }
async function handleOteAdd(paycheckId, name, amount) { async function handleOteAdd(paycheckNumber, name, amount) {
let pc = paychecks.find(p => p.paycheck_number === paycheckNumber);
let paycheckId = pc?.id;
if (!paycheckId) {
const generated = await generateMonth();
paycheckId = generated.find(p => p.paycheck_number === paycheckNumber).id;
}
try { try {
const res = await fetch('/api/one-time-expenses', { const res = await fetch('/api/one-time-expenses', {
method: 'POST', method: 'POST',
@@ -380,51 +541,9 @@ function PaycheckView() {
} }
} }
async function handleBillPaidToggle(paycheckBillId, paid) {
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId
? { ...b, paid, paid_at: paid ? new Date().toISOString() : null }
: b
),
}))
);
try {
const res = await fetch(`/api/paycheck-bills/${paycheckBillId}/paid`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paid }),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const updated = await res.json();
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId
? { ...b, paid: updated.paid, paid_at: updated.paid_at }
: b
),
}))
);
} catch (err) {
setPaychecks(prev =>
prev.map(pc => ({
...pc,
bills: pc.bills.map(b =>
b.paycheck_bill_id === paycheckBillId ? { ...b, paid: !paid } : b
),
}))
);
alert(`Failed to update bill: ${err.message}`);
}
}
const pc1 = paychecks.find(p => p.paycheck_number === 1) || null; const pc1 = paychecks.find(p => p.paycheck_number === 1) || null;
const pc2 = paychecks.find(p => p.paycheck_number === 2) || null; const pc2 = paychecks.find(p => p.paycheck_number === 2) || null;
const isVirtual = paychecks.length > 0 && paychecks.every(p => p.id === null);
return ( return (
<div> <div>
@@ -432,8 +551,29 @@ function PaycheckView() {
<button className="btn-nav" onClick={prevMonth}>&#8592;</button> <button className="btn-nav" onClick={prevMonth}>&#8592;</button>
<span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span> <span className="period-nav__label">{MONTH_NAMES[month - 1]} {year}</span>
<button className="btn-nav" onClick={nextMonth}>&#8594;</button> <button className="btn-nav" onClick={nextMonth}>&#8594;</button>
<button
className="btn btn-sm"
onClick={handleRegenerate}
disabled={regenerating}
title="Refresh gross/net from current Settings for this month"
style={{ marginLeft: '0.5rem' }}
>
{regenerating ? 'Refreshing…' : '↺ Refresh amounts'}
</button>
</div> </div>
{isVirtual && (
<div className="alert" style={{
background: 'var(--accent-subtle)',
border: '1px solid var(--accent)',
color: 'var(--accent)',
marginBottom: '1rem',
fontSize: '0.875rem',
}}>
Previewing from current settings no data saved yet for this month.
</div>
)}
{error && <div className="alert alert-error">Error: {error}</div>} {error && <div className="alert alert-error">Error: {error}</div>}
{loading ? ( {loading ? (
@@ -447,6 +587,8 @@ function PaycheckView() {
onOteDelete={handleOteDelete} onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd} onOteAdd={handleOteAdd}
categories={categories} categories={categories}
onGenerate={generateMonth}
onAmountSave={handleAmountSave}
/> />
<PaycheckColumn <PaycheckColumn
paycheck={pc2} paycheck={pc2}
@@ -455,6 +597,8 @@ function PaycheckView() {
onOteDelete={handleOteDelete} onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd} onOteAdd={handleOteAdd}
categories={categories} categories={categories}
onGenerate={generateMonth}
onAmountSave={handleAmountSave}
/> />
</div> </div>
)} )}

View File

@@ -38,13 +38,59 @@ async function getConfig() {
return config; return config;
} }
// Pad a number to two digits
function pad2(n) { function pad2(n) {
return String(n).padStart(2, '0'); 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
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,
paid: false,
paid_at: null,
})),
one_time_expenses: [],
});
}
return paychecks;
}
// Generate (upsert) paycheck records for the given year/month. // Generate (upsert) paycheck records for the given year/month.
// Returns the two paycheck rows with their assigned bills. // Returns the two paycheck IDs.
async function generatePaychecks(year, month) { async function generatePaychecks(year, month) {
const config = await getConfig(); const config = await getConfig();
@@ -55,12 +101,11 @@ async function generatePaychecks(year, month) {
const paycheckIds = []; const paycheckIds = [];
for (const num of [1, 2]) { for (const num of [1, 2]) {
const day = num === 1 ? config.paycheck1_day : config.paycheck2_day; const day = num === 1 ? config.paycheck1_day : config.paycheck2_day;
const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross; const gross = num === 1 ? config.paycheck1_gross : config.paycheck2_gross;
const net = num === 1 ? config.paycheck1_net : config.paycheck2_net; const net = num === 1 ? config.paycheck1_net : config.paycheck2_net;
const payDate = `${year}-${pad2(month)}-${pad2(day)}`; const payDate = `${year}-${pad2(month)}-${pad2(day)}`;
// Upsert paycheck record
const pcResult = await client.query( const pcResult = await client.query(
`INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net) `INSERT INTO paychecks (period_year, period_month, paycheck_number, pay_date, gross, net)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
@@ -74,13 +119,11 @@ async function generatePaychecks(year, month) {
const paycheckId = pcResult.rows[0].id; const paycheckId = pcResult.rows[0].id;
paycheckIds.push(paycheckId); paycheckIds.push(paycheckId);
// Fetch all active bills assigned to this paycheck number
const billsResult = await client.query( const billsResult = await client.query(
'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1', 'SELECT id FROM bills WHERE active = TRUE AND assigned_paycheck = $1',
[num] [num]
); );
// Idempotently insert each bill into paycheck_bills
for (const bill of billsResult.rows) { for (const bill of billsResult.rows) {
await client.query( await client.query(
`INSERT INTO paycheck_bills (paycheck_id, bill_id) `INSERT INTO paycheck_bills (paycheck_id, bill_id)
@@ -103,7 +146,6 @@ async function generatePaychecks(year, month) {
// Fetch both paycheck records for a month with full bill and one_time_expense data. // Fetch both paycheck records for a month with full bill and one_time_expense data.
async function fetchPaychecksForMonth(year, month) { async function fetchPaychecksForMonth(year, month) {
// Fetch paycheck rows
const pcResult = await pool.query( const pcResult = await pool.query(
`SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net `SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net
FROM paychecks FROM paychecks
@@ -115,7 +157,6 @@ async function fetchPaychecksForMonth(year, month) {
const paychecks = []; const paychecks = [];
for (const pc of pcResult.rows) { for (const pc of pcResult.rows) {
// Fetch associated bills joined with bill definitions
const billsResult = await pool.query( const billsResult = await pool.query(
`SELECT pb.id AS paycheck_bill_id, `SELECT pb.id AS paycheck_bill_id,
pb.bill_id, pb.bill_id,
@@ -134,7 +175,6 @@ async function fetchPaychecksForMonth(year, month) {
[pc.id] [pc.id]
); );
// Fetch one-time expenses
const oteResult = await pool.query( const oteResult = await pool.query(
`SELECT id, name, amount, paid, paid_at `SELECT id, name, amount, paid, paid_at
FROM one_time_expenses FROM one_time_expenses
@@ -172,7 +212,7 @@ async function fetchPaychecksForMonth(year, month) {
// POST /api/paychecks/generate?year=&month= // POST /api/paychecks/generate?year=&month=
router.post('/paychecks/generate', async (req, res) => { router.post('/paychecks/generate', async (req, res) => {
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10); const month = parseInt(req.query.month, 10);
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
@@ -190,8 +230,9 @@ router.post('/paychecks/generate', async (req, res) => {
}); });
// GET /api/paychecks?year=&month= // GET /api/paychecks?year=&month=
// Returns virtual (unsaved) data when no DB records exist for the month.
router.get('/paychecks', async (req, res) => { router.get('/paychecks', async (req, res) => {
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10); const month = parseInt(req.query.month, 10);
if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { if (isNaN(year) || isNaN(month) || month < 1 || month > 12) {
@@ -199,14 +240,14 @@ router.get('/paychecks', async (req, res) => {
} }
try { try {
// Check if paychecks exist for this month; if not, auto-generate
const existing = await pool.query( const existing = await pool.query(
'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1', 'SELECT id FROM paychecks WHERE period_year = $1 AND period_month = $2 LIMIT 1',
[year, month] [year, month]
); );
if (existing.rows.length === 0) { if (existing.rows.length === 0) {
await generatePaychecks(year, month); const virtual = await buildVirtualPaychecks(year, month);
return res.json(virtual);
} }
const paychecks = await fetchPaychecksForMonth(year, month); const paychecks = await fetchPaychecksForMonth(year, month);
@@ -232,6 +273,36 @@ router.get('/paychecks/months', async (req, res) => {
} }
}); });
// 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/paid // PATCH /api/paycheck-bills/:id/paid
router.patch('/paycheck-bills/:id/paid', async (req, res) => { router.patch('/paycheck-bills/:id/paid', async (req, res) => {
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);