Add start_date to financing plans and protect paid paychecks from refresh
- Migration 004: adds start_date column to financing_plans (DEFAULT CURRENT_DATE) - generatePaychecks: skips financing plans whose start_date is after the target month - buildVirtualPaychecks: same start_date guard for virtual previews (already applied) - generatePaychecks upsert: preserves gross/net when paycheck has any paid bills - financing.js POST/PUT: accept and store start_date - Financing.jsx: add Start Date field to create/edit form (defaults to today) - CLAUDE.md: document start_date guard and paid-paycheck refresh protection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -61,8 +61,8 @@ The default route `/` renders the paycheck-centric main view (`client/src/pages/
|
|||||||
|
|
||||||
**Variable amount bills:** Bills with `variable_amount = true` require the amount to be entered each month in the paycheck view (stored as `amount_override` on `paycheck_bills`). The bill's `amount` field serves as an optional typical/estimated value.
|
**Variable amount bills:** Bills with `variable_amount = true` require the amount to be entered each month in the paycheck view (stored as `amount_override` on `paycheck_bills`). The bill's `amount` field serves as an optional typical/estimated value.
|
||||||
|
|
||||||
**Lazy paycheck generation:** `GET /api/paychecks` returns virtual (unsaved) data with `id: null` when no DB record exists for the month. Paychecks are only persisted when the first interaction occurs (bill toggle, expense add, etc.). The "↺ Refresh amounts" button on the paycheck view re-runs `POST /api/paychecks/generate` to sync gross/net from current Settings. Individual paycheck gross/net can also be edited inline via the pencil icon.
|
**Lazy paycheck generation:** `GET /api/paychecks` returns virtual (unsaved) data with `id: null` when no DB record exists for the month. Paychecks are only persisted when the first interaction occurs (bill toggle, expense add, etc.). The "↺ Refresh amounts" button on the paycheck view re-runs `POST /api/paychecks/generate` to sync gross/net from current Settings — but gross/net are never overwritten when the paycheck already has any paid bills, preserving historical income values. Individual paycheck gross/net can also be edited inline via the pencil icon.
|
||||||
|
|
||||||
**Financing:** `GET/POST /api/financing`, `PUT/DELETE /api/financing/:id`, `PATCH /api/financing-payments/:id/paid`. Plans track a total amount and payoff due date. Payment per period is auto-calculated as `(remaining balance) / (remaining periods)`. Split plans (`assigned_paycheck = null`) divide each period's payment across both paychecks. Plans auto-close when fully paid. Financing payments are included in the paycheck remaining balance.
|
**Financing:** `GET/POST /api/financing`, `PUT/DELETE /api/financing/:id`, `PATCH /api/financing-payments/:id/paid`. Plans track a total amount, payoff due date, and `start_date`. Payment per period is auto-calculated as `(remaining balance) / (remaining periods)`. Split plans (`assigned_paycheck = null`) divide each period's payment across both paychecks. Plans auto-close when fully paid. Financing payments are included in the paycheck remaining balance. `start_date` prevents a plan from appearing on paycheck months before it was created — both virtual previews and `generate` respect this guard.
|
||||||
|
|
||||||
**Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table.
|
**Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table.
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ function ProgressBar({ paid, total }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TODAY = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const EMPTY_FORM = {
|
const EMPTY_FORM = {
|
||||||
name: '',
|
name: '',
|
||||||
total_amount: '',
|
total_amount: '',
|
||||||
due_date: '',
|
due_date: '',
|
||||||
|
start_date: TODAY,
|
||||||
assigned_paycheck: 'both',
|
assigned_paycheck: 'both',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +114,7 @@ export default function Financing() {
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
total_amount: plan.total_amount,
|
total_amount: plan.total_amount,
|
||||||
due_date: plan.due_date,
|
due_date: plan.due_date,
|
||||||
|
start_date: plan.start_date || TODAY,
|
||||||
assigned_paycheck: plan.assigned_paycheck == null ? 'both' : String(plan.assigned_paycheck),
|
assigned_paycheck: plan.assigned_paycheck == null ? 'both' : String(plan.assigned_paycheck),
|
||||||
});
|
});
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -138,6 +142,7 @@ export default function Financing() {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
total_amount: parseFloat(form.total_amount),
|
total_amount: parseFloat(form.total_amount),
|
||||||
due_date: form.due_date,
|
due_date: form.due_date,
|
||||||
|
start_date: form.start_date || TODAY,
|
||||||
assigned_paycheck: form.assigned_paycheck === 'both' ? null : parseInt(form.assigned_paycheck, 10),
|
assigned_paycheck: form.assigned_paycheck === 'both' ? null : parseInt(form.assigned_paycheck, 10),
|
||||||
};
|
};
|
||||||
const url = editingId ? `/api/financing/${editingId}` : '/api/financing';
|
const url = editingId ? `/api/financing/${editingId}` : '/api/financing';
|
||||||
@@ -204,6 +209,11 @@ export default function Financing() {
|
|||||||
<input name="due_date" type="date" value={form.due_date}
|
<input name="due_date" type="date" value={form.due_date}
|
||||||
onChange={handleChange} required className="form-input" />
|
onChange={handleChange} required className="form-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Start Date</label>
|
||||||
|
<input name="start_date" type="date" value={form.start_date}
|
||||||
|
onChange={handleChange} required className="form-input" />
|
||||||
|
</div>
|
||||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||||
<label className="form-label">Assign to paycheck</label>
|
<label className="form-label">Assign to paycheck</label>
|
||||||
<select name="assigned_paycheck" value={form.assigned_paycheck}
|
<select name="assigned_paycheck" value={form.assigned_paycheck}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ router.get('/financing', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/financing
|
// POST /api/financing
|
||||||
router.post('/financing', async (req, res) => {
|
router.post('/financing', async (req, res) => {
|
||||||
const { name, total_amount, due_date, assigned_paycheck } = req.body;
|
const { name, total_amount, due_date, assigned_paycheck, start_date } = req.body;
|
||||||
if (!name || !total_amount || !due_date) {
|
if (!name || !total_amount || !due_date) {
|
||||||
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
|
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
|
||||||
}
|
}
|
||||||
@@ -96,9 +96,9 @@ router.post('/financing', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO financing_plans (name, total_amount, due_date, assigned_paycheck)
|
`INSERT INTO financing_plans (name, total_amount, due_date, assigned_paycheck, start_date)
|
||||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||||
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null]
|
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null, start_date || new Date().toISOString().slice(0, 10)]
|
||||||
);
|
);
|
||||||
res.status(201).json(await enrichPlan(pool, rows[0]));
|
res.status(201).json(await enrichPlan(pool, rows[0]));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -136,16 +136,16 @@ router.get('/financing/:id', async (req, res) => {
|
|||||||
|
|
||||||
// PUT /api/financing/:id
|
// PUT /api/financing/:id
|
||||||
router.put('/financing/:id', async (req, res) => {
|
router.put('/financing/:id', async (req, res) => {
|
||||||
const { name, total_amount, due_date, assigned_paycheck } = req.body;
|
const { name, total_amount, due_date, assigned_paycheck, start_date } = req.body;
|
||||||
if (!name || !total_amount || !due_date) {
|
if (!name || !total_amount || !due_date) {
|
||||||
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
|
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`UPDATE financing_plans SET name=$1, total_amount=$2, due_date=$3, assigned_paycheck=$4
|
`UPDATE financing_plans SET name=$1, total_amount=$2, due_date=$3, assigned_paycheck=$4, start_date=$5
|
||||||
WHERE id=$5 RETURNING *`,
|
WHERE id=$6 RETURNING *`,
|
||||||
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null, req.params.id]
|
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null, start_date || new Date().toISOString().slice(0, 10), req.params.id]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
res.json(await enrichPlan(pool, rows[0]));
|
res.json(await enrichPlan(pool, rows[0]));
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ async function buildVirtualPaychecks(year, month) {
|
|||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
for (const plan of activePlans.rows) {
|
for (const plan of activePlans.rows) {
|
||||||
|
// Skip plans that haven't started yet for this period
|
||||||
|
const start = new Date(plan.start_date);
|
||||||
|
const planStartYear = start.getFullYear();
|
||||||
|
const planStartMonth = start.getMonth() + 1;
|
||||||
|
if (year * 12 + month < planStartYear * 12 + planStartMonth) continue;
|
||||||
|
|
||||||
const amount = await calcPaymentAmount(client, plan, year, month);
|
const amount = await calcPaymentAmount(client, plan, year, month);
|
||||||
if (amount <= 0) continue;
|
if (amount <= 0) continue;
|
||||||
|
|
||||||
@@ -155,8 +161,20 @@ async function generatePaychecks(year, month) {
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (period_year, period_month, paycheck_number)
|
ON CONFLICT (period_year, period_month, paycheck_number)
|
||||||
DO UPDATE SET pay_date = EXCLUDED.pay_date,
|
DO UPDATE SET pay_date = EXCLUDED.pay_date,
|
||||||
gross = EXCLUDED.gross,
|
gross = CASE
|
||||||
net = EXCLUDED.net
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM paycheck_bills pb
|
||||||
|
WHERE pb.paycheck_id = paychecks.id AND pb.paid = TRUE
|
||||||
|
) THEN paychecks.gross
|
||||||
|
ELSE EXCLUDED.gross
|
||||||
|
END,
|
||||||
|
net = CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM paycheck_bills pb
|
||||||
|
WHERE pb.paycheck_id = paychecks.id AND pb.paid = TRUE
|
||||||
|
) THEN paychecks.net
|
||||||
|
ELSE EXCLUDED.net
|
||||||
|
END
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[year, month, num, payDate, gross || 0, net || 0]
|
[year, month, num, payDate, gross || 0, net || 0]
|
||||||
);
|
);
|
||||||
@@ -183,6 +201,12 @@ async function generatePaychecks(year, month) {
|
|||||||
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
|
||||||
);
|
);
|
||||||
for (const plan of activePlans.rows) {
|
for (const plan of activePlans.rows) {
|
||||||
|
// Skip plans that haven't started yet for this period
|
||||||
|
const start = new Date(plan.start_date);
|
||||||
|
const planStartYear = start.getFullYear();
|
||||||
|
const planStartMonth = start.getMonth() + 1;
|
||||||
|
if (year * 12 + month < planStartYear * 12 + planStartMonth) continue;
|
||||||
|
|
||||||
// Determine which paycheck(s) this plan applies to
|
// Determine which paycheck(s) this plan applies to
|
||||||
const targets = plan.assigned_paycheck == null ? [1, 2] : [plan.assigned_paycheck];
|
const targets = plan.assigned_paycheck == null ? [1, 2] : [plan.assigned_paycheck];
|
||||||
for (const pcNum of targets) {
|
for (const pcNum of targets) {
|
||||||
|
|||||||
Reference in New Issue
Block a user