+
= 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,
+ })),
});
}