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>
231 lines
8.2 KiB
JavaScript
231 lines
8.2 KiB
JavaScript
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 };
|