diff --git a/client/src/App.jsx b/client/src/App.jsx index 1c62d9d..afc33c1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -2,6 +2,7 @@ import { Routes, Route, NavLink } from 'react-router-dom'; import { useTheme } from './ThemeContext.jsx'; import PaycheckView from './pages/PaycheckView.jsx'; import Bills from './pages/Bills.jsx'; +import Financing from './pages/Financing.jsx'; import Settings from './pages/Settings.jsx'; import MonthlySummary from './pages/MonthlySummary.jsx'; import AnnualOverview from './pages/AnnualOverview.jsx'; @@ -16,6 +17,7 @@ function App() {
'nav-link' + (isActive ? ' active' : '')} to="/" end>Paychecks 'nav-link' + (isActive ? ' active' : '')} to="/bills">Bills + 'nav-link' + (isActive ? ' active' : '')} to="/financing">Financing 'nav-link' + (isActive ? ' active' : '')} to="/summary/monthly">Monthly 'nav-link' + (isActive ? ' active' : '')} to="/summary/annual">Annual 'nav-link' + (isActive ? ' active' : '')} to="/settings">Settings @@ -34,6 +36,7 @@ function App() { } /> } /> + } /> } /> } /> } /> diff --git a/client/src/pages/Financing.jsx b/client/src/pages/Financing.jsx new file mode 100644 index 0000000..519c288 --- /dev/null +++ b/client/src/pages/Financing.jsx @@ -0,0 +1,261 @@ +import { useState, useEffect } from 'react'; + +function fmt(value) { + const num = parseFloat(value) || 0; + return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function ProgressBar({ paid, total }) { + const pct = total > 0 ? Math.min(100, (paid / total) * 100) : 0; + return ( +
+
= 100 ? 'var(--success)' : 'var(--accent)', + borderRadius: '999px', + transition: 'width 0.3s ease', + }} /> +
+ ); +} + +const EMPTY_FORM = { + name: '', + total_amount: '', + due_date: '', + assigned_paycheck: 'both', +}; + +function PlanCard({ plan, onEdit, onDelete }) { + const pct = plan.total_amount > 0 ? Math.min(100, (plan.paid_total / plan.total_amount) * 100) : 0; + + return ( +
+
+
+
+ {plan.name} + {plan.overdue && ( + + overdue + + )} + {!plan.active && ( + paid off + )} +
+
+ Due {new Date(plan.due_date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' })} +  ·  + {plan.assigned_paycheck == null ? 'Split across both paychecks' : `Paycheck ${plan.assigned_paycheck} only`} +
+ +
+ {fmt(plan.paid_total)} paid + 0 ? 'var(--text)' : 'var(--success)' }}> + {plan.remaining > 0 ? `${fmt(plan.remaining)} remaining` : 'Paid off'} / {fmt(plan.total_amount)} + +
+
+ {plan.active && ( +
+ + +
+ )} + {!plan.active && ( + + )} +
+
+ ); +} + +export default function Financing() { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + async function load() { + try { + setLoading(true); + const res = await fetch('/api/financing'); + if (!res.ok) throw new Error('Failed to load'); + setPlans(await res.json()); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); + + function openAdd() { + setEditingId(null); + setForm(EMPTY_FORM); + setFormError(null); + setShowForm(true); + } + + function openEdit(plan) { + setEditingId(plan.id); + setForm({ + name: plan.name, + total_amount: plan.total_amount, + due_date: plan.due_date, + assigned_paycheck: plan.assigned_paycheck == null ? 'both' : String(plan.assigned_paycheck), + }); + setFormError(null); + setShowForm(true); + } + + function cancel() { + setShowForm(false); + setEditingId(null); + setForm(EMPTY_FORM); + setFormError(null); + } + + function handleChange(e) { + const { name, value } = e.target; + setForm(prev => ({ ...prev, [name]: value })); + } + + async function handleSave(e) { + e.preventDefault(); + setFormError(null); + setSaving(true); + try { + const payload = { + name: form.name, + total_amount: parseFloat(form.total_amount), + due_date: form.due_date, + assigned_paycheck: form.assigned_paycheck === 'both' ? null : parseInt(form.assigned_paycheck, 10), + }; + const url = editingId ? `/api/financing/${editingId}` : '/api/financing'; + const method = editingId ? 'PUT' : 'POST'; + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) { setFormError(data.error || 'Failed to save'); return; } + await load(); + cancel(); + } catch (err) { + setFormError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDelete(plan) { + if (!window.confirm(`Delete "${plan.name}"? This cannot be undone.`)) return; + try { + const res = await fetch(`/api/financing/${plan.id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + await load(); + } catch (err) { + alert(err.message); + } + } + + const active = plans.filter(p => p.active); + const inactive = plans.filter(p => !p.active); + + return ( +
+
+

Financing

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+

+ {editingId ? 'Edit Financing Plan' : 'New Financing Plan'} +

+ {formError &&
{formError}
} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ )} + + {loading &&

Loading…

} + {error &&
Error: {error}
} + + {!loading && !error && ( + <> + {active.length === 0 && !showForm && ( +
+ No active financing plans. Click "Add Plan" to get started. +
+ )} + +
+ {active.map(plan => ( + + ))} +
+ + {inactive.length > 0 && ( + <> +
+ Paid Off +
+
+ {inactive.map(plan => ( + + ))} +
+ + )} + + )} +
+ ); +} diff --git a/client/src/pages/PaycheckView.jsx b/client/src/pages/PaycheckView.jsx index 40f2e76..b6d1e3f 100644 --- a/client/src/pages/PaycheckView.jsx +++ b/client/src/pages/PaycheckView.jsx @@ -27,7 +27,7 @@ function todayISO() { // ─── PaycheckColumn ─────────────────────────────────────────────────────────── -function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave }) { +function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) { const [newOteName, setNewOteName] = useState(''); const [newOteAmount, setNewOteAmount] = useState(''); const [actuals, setActuals] = useState([]); @@ -173,7 +173,8 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl 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 actualsTotal = actuals.reduce((sum, a) => sum + (parseFloat(a.amount) || 0), 0); - const remaining = net - billsTotal - otesTotal - actualsTotal; + const financingTotal = (paycheck.financing || []).reduce((sum, f) => sum + (parseFloat(f.amount) || 0), 0); + const remaining = net - billsTotal - otesTotal - actualsTotal - financingTotal; return (
@@ -395,6 +396,45 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
+ {/* Financing */} + {paycheck.financing && paycheck.financing.length > 0 && ( +
+
Financing
+ {paycheck.financing.map((fp) => ( +
+ onFinancingPaidToggle(fp.financing_payment_id, !fp.paid, fp.plan_id, paycheck.paycheck_number)} + className="bill-row__check" + disabled={!fp.financing_payment_id} + /> +
+
+ + {fp.name} + {fp.overdue && ( + + overdue + + )} + + {formatCurrency(fp.amount)} +
+
+ {fp.total_amount != null && ( + + {formatCurrency(fp.paid_total ?? 0)} / {formatCurrency(fp.total_amount)} paid + + )} + due {new Date(fp.due_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })} +
+
+
+ ))} +
+ )} + {/* Remaining */}
Remaining @@ -499,6 +539,49 @@ function PaycheckView() { )); } + async function handleFinancingPaidToggle(financingPaymentId, paid, planId, paycheckNumber) { + let realId = financingPaymentId; + + // If virtual paycheck, generate first to get real financing_payment_id + if (!realId) { + const generated = await generateMonth(); + const pc = generated.find(p => p.paycheck_number === paycheckNumber); + const fp = pc.financing.find(f => f.plan_id === planId); + realId = fp?.financing_payment_id; + } + + if (!realId) { + alert('Could not find payment record — try refreshing.'); + return; + } + + try { + const res = await fetch(`/api/financing-payments/${realId}/paid`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paid }), + }); + if (!res.ok) throw new Error(`Server error: ${res.status}`); + const result = await res.json(); + if (result.plan_closed) { + // Plan paid off — reload to update all state + await loadPaychecks(year, month); + } else { + // Update local financing state + setPaychecks(prev => prev.map(pc => ({ + ...pc, + financing: (pc.financing || []).map(f => + f.financing_payment_id === realId + ? { ...f, paid: result.paid, paid_at: result.paid_at } + : f + ), + }))); + } + } catch (err) { + alert(`Failed to update payment: ${err.message}`); + } + } + async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) { let realPaycheckBillId = paycheckBillId; @@ -673,6 +756,7 @@ function PaycheckView() { onGenerate={generateMonth} onAmountSave={handleAmountSave} onBillAmountSave={handleBillAmountSave} + onFinancingPaidToggle={handleFinancingPaidToggle} />
)} diff --git a/db/migrations/003_financing.sql b/db/migrations/003_financing.sql new file mode 100644 index 0000000..af2ca73 --- /dev/null +++ b/db/migrations/003_financing.sql @@ -0,0 +1,21 @@ +-- financing_plans: tracks a deferred/no-interest financing arrangement +CREATE TABLE IF NOT EXISTS financing_plans ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + total_amount NUMERIC(12, 2) NOT NULL, + due_date DATE NOT NULL, -- must be paid off by this date + assigned_paycheck INTEGER, -- 1, 2, or NULL (split across both) + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- financing_payments: one row per paycheck period for each active plan +CREATE TABLE IF NOT EXISTS financing_payments ( + id SERIAL PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES financing_plans(id) ON DELETE CASCADE, + paycheck_id INTEGER NOT NULL REFERENCES paychecks(id) ON DELETE CASCADE, + amount NUMERIC(12, 2) NOT NULL, -- calculated at generation time + paid BOOLEAN NOT NULL DEFAULT FALSE, + paid_at TIMESTAMPTZ, + UNIQUE (plan_id, paycheck_id) +); diff --git a/server/src/index.js b/server/src/index.js index 068e93a..7e25475 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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'); diff --git a/server/src/routes/financing.js b/server/src/routes/financing.js new file mode 100644 index 0000000..c169c92 --- /dev/null +++ b/server/src/routes/financing.js @@ -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 }; diff --git a/server/src/routes/paychecks.js b/server/src/routes/paychecks.js index 3782c72..0f3f2b2 100644 --- a/server/src/routes/paychecks.js +++ b/server/src/routes/paychecks.js @@ -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, + })), }); }