Add special financing feature

Data model:
- Migration 003: financing_plans and financing_payments tables
- Plans track total amount, due date, paycheck assignment (1, 2, or split)
- Payments linked to paycheck records, one per period

Payment calculation:
- Auto-calculated: (total - paid_so_far) / remaining_periods
- Split plans halve the per-period amount across both paychecks
- Recalculates each period as payments are made

Financing page (/financing):
- Create/edit/delete plans with name, amount, due date, paycheck assignment
- Progress bar showing paid vs total
- Overdue badge when past due with remaining balance
- Paid-off plans moved to a separate section

Paycheck view:
- New Financing section per column with payment checkbox
- Overdue badge on individual payments
- Running paid/total shown in bill meta
- Financing payments included in remaining balance calculation
- Auto-closes plan and reloads when fully paid off
- Lazy generation works: first interaction generates paycheck and payment records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 20:28:09 -04:00
parent 45383b80cf
commit 2d152f301d
7 changed files with 705 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ const paychecksRouter = require('./routes/paychecks');
const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing');
const db = require('./db');
const app = express();
@@ -25,6 +26,7 @@ app.use('/api', paychecksRouter);
app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter);
app.use('/api', financingRouter);
// Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist');

View File

@@ -0,0 +1,230 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../db');
// ─── Helpers ──────────────────────────────────────────────────────────────────
// Count how many payment periods remain for a plan starting from (year, month),
// including that month. Each month contributes 1 or 2 periods depending on
// whether the plan is split across both paychecks (assigned_paycheck = null).
function remainingPeriods(plan, year, month) {
const due = new Date(plan.due_date);
const dueYear = due.getFullYear();
const dueMonth = due.getMonth() + 1; // 1-based
const monthsLeft = (dueYear - year) * 12 + (dueMonth - month) + 1;
if (monthsLeft <= 0) return 1; // always at least 1 to avoid division by zero
const perMonth = plan.assigned_paycheck == null ? 2 : 1;
return monthsLeft * perMonth;
}
// Calculate the payment amount for one period.
async function calcPaymentAmount(client, plan, year, month) {
const { rows } = await client.query(
`SELECT COALESCE(SUM(fp.amount), 0) AS paid_total
FROM financing_payments fp
WHERE fp.plan_id = $1 AND fp.paid = TRUE`,
[plan.id]
);
const paidSoFar = parseFloat(rows[0].paid_total) || 0;
const remaining = parseFloat(plan.total_amount) - paidSoFar;
if (remaining <= 0) return 0;
const periods = remainingPeriods(plan, year, month);
return parseFloat((remaining / periods).toFixed(2));
}
// Enrich a plan row with computed progress fields.
async function enrichPlan(pool, plan) {
const { rows } = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN paid THEN amount ELSE 0 END), 0) AS paid_total,
COALESCE(SUM(amount), 0) AS scheduled_total,
COUNT(*) FILTER (WHERE paid)::int AS paid_count,
COUNT(*)::int AS total_count
FROM financing_payments
WHERE plan_id = $1`,
[plan.id]
);
const r = rows[0];
const paidTotal = parseFloat(r.paid_total) || 0;
const totalAmount = parseFloat(plan.total_amount);
const remaining = Math.max(0, totalAmount - paidTotal);
const due = new Date(plan.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const overdue = plan.active && remaining > 0 && due < today;
return {
...plan,
total_amount: totalAmount,
paid_total: parseFloat(paidTotal.toFixed(2)),
remaining: parseFloat(remaining.toFixed(2)),
paid_count: r.paid_count,
total_count: r.total_count,
overdue,
};
}
// ─── Routes ───────────────────────────────────────────────────────────────────
// GET /api/financing
router.get('/financing', async (req, res) => {
try {
const { rows } = await pool.query(
`SELECT * FROM financing_plans ORDER BY active DESC, due_date ASC`
);
const enriched = await Promise.all(rows.map(r => enrichPlan(pool, r)));
res.json(enriched);
} catch (err) {
console.error('GET /api/financing error:', err);
res.status(500).json({ error: 'Failed to fetch financing plans' });
}
});
// POST /api/financing
router.post('/financing', async (req, res) => {
const { name, total_amount, due_date, assigned_paycheck } = req.body;
if (!name || !total_amount || !due_date) {
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
}
if (assigned_paycheck != null && assigned_paycheck !== 1 && assigned_paycheck !== 2) {
return res.status(400).json({ error: 'assigned_paycheck must be 1, 2, or null' });
}
try {
const { rows } = await pool.query(
`INSERT INTO financing_plans (name, total_amount, due_date, assigned_paycheck)
VALUES ($1, $2, $3, $4) RETURNING *`,
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null]
);
res.status(201).json(await enrichPlan(pool, rows[0]));
} catch (err) {
console.error('POST /api/financing error:', err);
res.status(500).json({ error: 'Failed to create financing plan' });
}
});
// GET /api/financing/:id
router.get('/financing/:id', async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM financing_plans WHERE id = $1', [req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const plan = await enrichPlan(pool, rows[0]);
const { rows: payments } = await pool.query(
`SELECT fp.id, fp.amount, fp.paid, fp.paid_at,
p.period_year, p.period_month, p.paycheck_number, p.pay_date
FROM financing_payments fp
JOIN paychecks p ON p.id = fp.paycheck_id
WHERE fp.plan_id = $1
ORDER BY p.period_year, p.period_month, p.paycheck_number`,
[plan.id]
);
res.json({ ...plan, payments });
} catch (err) {
console.error('GET /api/financing/:id error:', err);
res.status(500).json({ error: 'Failed to fetch financing plan' });
}
});
// PUT /api/financing/:id
router.put('/financing/:id', async (req, res) => {
const { name, total_amount, due_date, assigned_paycheck } = req.body;
if (!name || !total_amount || !due_date) {
return res.status(400).json({ error: 'name, total_amount, and due_date are required' });
}
try {
const { rows } = await pool.query(
`UPDATE financing_plans SET name=$1, total_amount=$2, due_date=$3, assigned_paycheck=$4
WHERE id=$5 RETURNING *`,
[name.trim(), parseFloat(total_amount), due_date, assigned_paycheck ?? null, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
res.json(await enrichPlan(pool, rows[0]));
} catch (err) {
console.error('PUT /api/financing/:id error:', err);
res.status(500).json({ error: 'Failed to update financing plan' });
}
});
// DELETE /api/financing/:id
router.delete('/financing/:id', async (req, res) => {
try {
const { rows } = await pool.query(
'DELETE FROM financing_plans WHERE id=$1 RETURNING id', [req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
res.json({ deleted: true });
} catch (err) {
console.error('DELETE /api/financing/:id error:', err);
res.status(500).json({ error: 'Failed to delete financing plan' });
}
});
// PATCH /api/financing-payments/:id/paid
router.patch('/financing-payments/:id/paid', async (req, res) => {
const id = parseInt(req.params.id, 10);
const { paid } = req.body;
if (typeof paid !== 'boolean') {
return res.status(400).json({ error: 'paid must be a boolean' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`UPDATE financing_payments
SET paid = $1, paid_at = CASE WHEN $1 THEN NOW() ELSE NULL END
WHERE id = $2
RETURNING id, plan_id, amount, paid, paid_at`,
[paid, id]
);
if (!rows.length) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Payment not found' });
}
const payment = rows[0];
// Check if plan is now fully paid — if so, auto-close it
const { rows: planRows } = await client.query(
'SELECT * FROM financing_plans WHERE id = $1', [payment.plan_id]
);
const plan = planRows[0];
const { rows: totals } = await client.query(
`SELECT COALESCE(SUM(amount), 0) AS paid_total
FROM financing_payments WHERE plan_id = $1 AND paid = TRUE`,
[plan.id]
);
const paidTotal = parseFloat(totals[0].paid_total) || 0;
const totalAmount = parseFloat(plan.total_amount);
let planClosed = false;
if (paid && paidTotal >= totalAmount) {
await client.query(
'UPDATE financing_plans SET active = FALSE WHERE id = $1', [plan.id]
);
planClosed = true;
}
await client.query('COMMIT');
res.json({ ...payment, plan_closed: planClosed });
} catch (err) {
await client.query('ROLLBACK');
console.error('PATCH /api/financing-payments/:id/paid error:', err);
res.status(500).json({ error: 'Failed to update payment' });
} finally {
client.release();
}
});
module.exports = { router, calcPaymentAmount, remainingPeriods };

View File

@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { pool } = require('../db');
const { calcPaymentAmount } = require('./financing');
const CONFIG_KEYS = [
'paycheck1_day',
@@ -84,9 +85,51 @@ async function buildVirtualPaychecks(year, month) {
paid_at: null,
})),
one_time_expenses: [],
financing: [],
});
}
// Attach virtual financing payment previews
const activePlans = await pool.query(
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
);
const client = await pool.connect();
try {
for (const plan of activePlans.rows) {
const amount = await calcPaymentAmount(client, plan, year, month);
if (amount <= 0) continue;
const due = new Date(plan.due_date);
const dueYear = due.getFullYear();
const dueMonth = due.getMonth() + 1;
const today = new Date(); today.setHours(0, 0, 0, 0);
const overdue = due < today && parseFloat(plan.total_amount) > 0;
const entry = {
financing_payment_id: null,
plan_id: plan.id,
name: plan.name,
amount,
paid: false,
paid_at: null,
due_date: plan.due_date,
overdue,
plan_closed: false,
};
if (plan.assigned_paycheck == null) {
// split across both — give half to each
const half = parseFloat((amount / 2).toFixed(2));
paychecks.find(p => p.paycheck_number === 1)?.financing.push({ ...entry, amount: half });
paychecks.find(p => p.paycheck_number === 2)?.financing.push({ ...entry, amount: half });
} else {
paychecks.find(p => p.paycheck_number === plan.assigned_paycheck)?.financing.push(entry);
}
}
} finally {
client.release();
}
return paychecks;
}
@@ -135,6 +178,31 @@ async function generatePaychecks(year, month) {
}
}
// Generate financing_payment records for active plans
const activePlans = await client.query(
`SELECT * FROM financing_plans WHERE active = TRUE ORDER BY due_date ASC`
);
for (const plan of activePlans.rows) {
// Determine which paycheck(s) this plan applies to
const targets = plan.assigned_paycheck == null ? [1, 2] : [plan.assigned_paycheck];
for (const pcNum of targets) {
const paycheckId = paycheckIds[pcNum - 1];
// Calculate per-period amount (for split plans, this is the full per-period amount; we halve below)
const fullAmount = await calcPaymentAmount(client, plan, year, month);
if (fullAmount <= 0) continue;
const amount = plan.assigned_paycheck == null
? parseFloat((fullAmount / 2).toFixed(2))
: fullAmount;
await client.query(
`INSERT INTO financing_payments (plan_id, paycheck_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (plan_id, paycheck_id) DO NOTHING`,
[plan.id, paycheckId, amount]
);
}
}
await client.query('COMMIT');
return paycheckIds;
} catch (err) {
@@ -185,6 +253,27 @@ async function fetchPaychecksForMonth(year, month) {
[pc.id]
);
const financingResult = await pool.query(
`SELECT fp.id AS financing_payment_id,
fp.plan_id,
fp.amount,
fp.paid,
fp.paid_at,
pl.name,
pl.due_date,
pl.total_amount,
COALESCE(SUM(fp2.amount) FILTER (WHERE fp2.paid), 0) AS paid_total
FROM financing_payments fp
JOIN financing_plans pl ON pl.id = fp.plan_id
LEFT JOIN financing_payments fp2 ON fp2.plan_id = fp.plan_id AND fp2.paid = TRUE
WHERE fp.paycheck_id = $1
GROUP BY fp.id, fp.plan_id, fp.amount, fp.paid, fp.paid_at, pl.name, pl.due_date, pl.total_amount
ORDER BY pl.due_date`,
[pc.id]
);
const today = new Date(); today.setHours(0, 0, 0, 0);
paychecks.push({
id: pc.id,
period_year: pc.period_year,
@@ -207,6 +296,18 @@ async function fetchPaychecksForMonth(year, month) {
paid_at: b.paid_at,
})),
one_time_expenses: oteResult.rows,
financing: financingResult.rows.map(f => ({
financing_payment_id: f.financing_payment_id,
plan_id: f.plan_id,
name: f.name,
amount: f.amount,
paid: f.paid,
paid_at: f.paid_at,
due_date: f.due_date,
total_amount: f.total_amount,
paid_total: parseFloat(f.paid_total) || 0,
overdue: new Date(f.due_date) < today && (parseFloat(f.total_amount) - (parseFloat(f.paid_total) || 0)) > 0,
})),
});
}