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:
@@ -2,6 +2,7 @@ import { Routes, Route, NavLink } from 'react-router-dom';
|
|||||||
import { useTheme } from './ThemeContext.jsx';
|
import { useTheme } from './ThemeContext.jsx';
|
||||||
import PaycheckView from './pages/PaycheckView.jsx';
|
import PaycheckView from './pages/PaycheckView.jsx';
|
||||||
import Bills from './pages/Bills.jsx';
|
import Bills from './pages/Bills.jsx';
|
||||||
|
import Financing from './pages/Financing.jsx';
|
||||||
import Settings from './pages/Settings.jsx';
|
import Settings from './pages/Settings.jsx';
|
||||||
import MonthlySummary from './pages/MonthlySummary.jsx';
|
import MonthlySummary from './pages/MonthlySummary.jsx';
|
||||||
import AnnualOverview from './pages/AnnualOverview.jsx';
|
import AnnualOverview from './pages/AnnualOverview.jsx';
|
||||||
@@ -16,6 +17,7 @@ function App() {
|
|||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/" end>Paychecks</NavLink>
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/" end>Paychecks</NavLink>
|
||||||
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/bills">Bills</NavLink>
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/bills">Bills</NavLink>
|
||||||
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/financing">Financing</NavLink>
|
||||||
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/monthly">Monthly</NavLink>
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/monthly">Monthly</NavLink>
|
||||||
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/annual">Annual</NavLink>
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/summary/annual">Annual</NavLink>
|
||||||
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/settings">Settings</NavLink>
|
<NavLink className={({ isActive }) => 'nav-link' + (isActive ? ' active' : '')} to="/settings">Settings</NavLink>
|
||||||
@@ -34,6 +36,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PaycheckView />} />
|
<Route path="/" element={<PaycheckView />} />
|
||||||
<Route path="/bills" element={<Bills />} />
|
<Route path="/bills" element={<Bills />} />
|
||||||
|
<Route path="/financing" element={<Financing />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/summary/monthly" element={<MonthlySummary />} />
|
<Route path="/summary/monthly" element={<MonthlySummary />} />
|
||||||
<Route path="/summary/annual" element={<AnnualOverview />} />
|
<Route path="/summary/annual" element={<AnnualOverview />} />
|
||||||
|
|||||||
261
client/src/pages/Financing.jsx
Normal file
261
client/src/pages/Financing.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ background: 'var(--surface-raised)', borderRadius: '999px', height: '8px', overflow: 'hidden', margin: '0.4rem 0' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: pct >= 100 ? 'var(--success)' : 'var(--accent)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="card card-body" style={{ opacity: plan.active ? 1 : 0.6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '1rem' }}>{plan.name}</span>
|
||||||
|
{plan.overdue && (
|
||||||
|
<span className="badge" style={{ background: 'var(--danger-bg)', color: 'var(--danger)', border: '1px solid var(--danger)' }}>
|
||||||
|
overdue
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!plan.active && (
|
||||||
|
<span className="badge badge-category">paid off</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||||
|
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`}
|
||||||
|
</div>
|
||||||
|
<ProgressBar paid={plan.paid_total} total={plan.total_amount} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
<span>{fmt(plan.paid_total)} paid</span>
|
||||||
|
<span style={{ fontWeight: 600, color: plan.remaining > 0 ? 'var(--text)' : 'var(--success)' }}>
|
||||||
|
{plan.remaining > 0 ? `${fmt(plan.remaining)} remaining` : 'Paid off'} / {fmt(plan.total_amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{plan.active && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', flexShrink: 0 }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => onEdit(plan)}>Edit</button>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => onDelete(plan)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!plan.active && (
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => onDelete(plan)}>Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ maxWidth: '680px' }}>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Financing</h1>
|
||||||
|
{!showForm && (
|
||||||
|
<button className="btn btn-primary" onClick={openAdd}>+ Add Plan</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card card-body mb-2">
|
||||||
|
<h2 style={{ margin: '0 0 1rem', fontSize: '1rem', fontWeight: 700 }}>
|
||||||
|
{editingId ? 'Edit Financing Plan' : 'New Financing Plan'}
|
||||||
|
</h2>
|
||||||
|
{formError && <div className="alert alert-error">{formError}</div>}
|
||||||
|
<form onSubmit={handleSave} autoComplete="off">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||||
|
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input name="name" type="text" value={form.name} onChange={handleChange}
|
||||||
|
required placeholder="e.g. Couch financing" className="form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Total Amount ($)</label>
|
||||||
|
<input name="total_amount" type="number" min="0" step="0.01" value={form.total_amount}
|
||||||
|
onChange={handleChange} required placeholder="0.00" className="form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Payoff Due Date</label>
|
||||||
|
<input name="due_date" type="date" value={form.due_date}
|
||||||
|
onChange={handleChange} required className="form-input" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label className="form-label">Assign to paycheck</label>
|
||||||
|
<select name="assigned_paycheck" value={form.assigned_paycheck}
|
||||||
|
onChange={handleChange} className="form-select">
|
||||||
|
<option value="both">Split across both paychecks</option>
|
||||||
|
<option value="1">Paycheck 1 only</option>
|
||||||
|
<option value="2">Paycheck 2 only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button className="btn" type="button" onClick={cancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p className="text-muted">Loading…</p>}
|
||||||
|
{error && <div className="alert alert-error">Error: {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{active.length === 0 && !showForm && (
|
||||||
|
<div className="card card-body" style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
No active financing plans. Click "Add Plan" to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{active.map(plan => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} onEdit={openEdit} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inactive.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em', color: 'var(--text-faint)', margin: '1.5rem 0 0.75rem' }}>
|
||||||
|
Paid Off
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{inactive.map(plan => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} onEdit={openEdit} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ function todayISO() {
|
|||||||
|
|
||||||
// ─── PaycheckColumn ───────────────────────────────────────────────────────────
|
// ─── 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 [newOteName, setNewOteName] = useState('');
|
||||||
const [newOteAmount, setNewOteAmount] = useState('');
|
const [newOteAmount, setNewOteAmount] = useState('');
|
||||||
const [actuals, setActuals] = 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 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 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 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 (
|
return (
|
||||||
<div className="paycheck-card">
|
<div className="paycheck-card">
|
||||||
@@ -395,6 +396,45 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Financing */}
|
||||||
|
{paycheck.financing && paycheck.financing.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="section-title">Financing</div>
|
||||||
|
{paycheck.financing.map((fp) => (
|
||||||
|
<div key={fp.plan_id} className="bill-row" style={{ opacity: fp.paid ? 0.6 : 1 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!fp.paid}
|
||||||
|
onChange={() => onFinancingPaidToggle(fp.financing_payment_id, !fp.paid, fp.plan_id, paycheck.paycheck_number)}
|
||||||
|
className="bill-row__check"
|
||||||
|
disabled={!fp.financing_payment_id}
|
||||||
|
/>
|
||||||
|
<div className="bill-row__info">
|
||||||
|
<div className={`bill-row__name${fp.paid ? ' paid' : ''}`}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||||
|
{fp.name}
|
||||||
|
{fp.overdue && (
|
||||||
|
<span className="badge" style={{ fontSize: '0.65rem', background: 'var(--danger-bg)', color: 'var(--danger)', border: '1px solid var(--danger)' }}>
|
||||||
|
overdue
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="bill-row__amount">{formatCurrency(fp.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bill-row__meta">
|
||||||
|
{fp.total_amount != null && (
|
||||||
|
<span>
|
||||||
|
{formatCurrency(fp.paid_total ?? 0)} / {formatCurrency(fp.total_amount)} paid
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>due {new Date(fp.due_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Remaining */}
|
{/* Remaining */}
|
||||||
<div className="remaining-row">
|
<div className="remaining-row">
|
||||||
<span className="remaining-row__label">Remaining</span>
|
<span className="remaining-row__label">Remaining</span>
|
||||||
@@ -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) {
|
async function handleBillAmountSave(paycheckBillId, billId, paycheckNumber, amount) {
|
||||||
let realPaycheckBillId = paycheckBillId;
|
let realPaycheckBillId = paycheckBillId;
|
||||||
|
|
||||||
@@ -673,6 +756,7 @@ function PaycheckView() {
|
|||||||
onGenerate={generateMonth}
|
onGenerate={generateMonth}
|
||||||
onAmountSave={handleAmountSave}
|
onAmountSave={handleAmountSave}
|
||||||
onBillAmountSave={handleBillAmountSave}
|
onBillAmountSave={handleBillAmountSave}
|
||||||
|
onFinancingPaidToggle={handleFinancingPaidToggle}
|
||||||
/>
|
/>
|
||||||
<PaycheckColumn
|
<PaycheckColumn
|
||||||
paycheck={pc2}
|
paycheck={pc2}
|
||||||
@@ -684,6 +768,7 @@ function PaycheckView() {
|
|||||||
onGenerate={generateMonth}
|
onGenerate={generateMonth}
|
||||||
onAmountSave={handleAmountSave}
|
onAmountSave={handleAmountSave}
|
||||||
onBillAmountSave={handleBillAmountSave}
|
onBillAmountSave={handleBillAmountSave}
|
||||||
|
onFinancingPaidToggle={handleFinancingPaidToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
21
db/migrations/003_financing.sql
Normal file
21
db/migrations/003_financing.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
@@ -9,6 +9,7 @@ const paychecksRouter = require('./routes/paychecks');
|
|||||||
const actualsRouter = require('./routes/actuals');
|
const actualsRouter = require('./routes/actuals');
|
||||||
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
const oneTimeExpensesRouter = require('./routes/one-time-expenses');
|
||||||
const summaryRouter = require('./routes/summary');
|
const summaryRouter = require('./routes/summary');
|
||||||
|
const { router: financingRouter } = require('./routes/financing');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -25,6 +26,7 @@ app.use('/api', paychecksRouter);
|
|||||||
app.use('/api', actualsRouter);
|
app.use('/api', actualsRouter);
|
||||||
app.use('/api', oneTimeExpensesRouter);
|
app.use('/api', oneTimeExpensesRouter);
|
||||||
app.use('/api', summaryRouter);
|
app.use('/api', summaryRouter);
|
||||||
|
app.use('/api', financingRouter);
|
||||||
|
|
||||||
// Serve static client files in production
|
// Serve static client files in production
|
||||||
const clientDist = path.join(__dirname, '../../client/dist');
|
const clientDist = path.join(__dirname, '../../client/dist');
|
||||||
|
|||||||
230
server/src/routes/financing.js
Normal file
230
server/src/routes/financing.js
Normal 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 };
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
|
const { calcPaymentAmount } = require('./financing');
|
||||||
|
|
||||||
const CONFIG_KEYS = [
|
const CONFIG_KEYS = [
|
||||||
'paycheck1_day',
|
'paycheck1_day',
|
||||||
@@ -84,9 +85,51 @@ async function buildVirtualPaychecks(year, month) {
|
|||||||
paid_at: null,
|
paid_at: null,
|
||||||
})),
|
})),
|
||||||
one_time_expenses: [],
|
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;
|
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');
|
await client.query('COMMIT');
|
||||||
return paycheckIds;
|
return paycheckIds;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -185,6 +253,27 @@ async function fetchPaychecksForMonth(year, month) {
|
|||||||
[pc.id]
|
[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({
|
paychecks.push({
|
||||||
id: pc.id,
|
id: pc.id,
|
||||||
period_year: pc.period_year,
|
period_year: pc.period_year,
|
||||||
@@ -207,6 +296,18 @@ async function fetchPaychecksForMonth(year, month) {
|
|||||||
paid_at: b.paid_at,
|
paid_at: b.paid_at,
|
||||||
})),
|
})),
|
||||||
one_time_expenses: oteResult.rows,
|
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,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user