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 };