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:
2026-03-19 20:28:09 -04:00
parent 45383b80cf
commit 2d152f301d
7 changed files with 705 additions and 2 deletions

View 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' })}
&nbsp;·&nbsp;
{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>
);
}

View File

@@ -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 (
<div className="paycheck-card">
@@ -395,6 +396,45 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
</form>
</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 */}
<div className="remaining-row">
<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) {
let realPaycheckBillId = paycheckBillId;
@@ -673,6 +756,7 @@ function PaycheckView() {
onGenerate={generateMonth}
onAmountSave={handleAmountSave}
onBillAmountSave={handleBillAmountSave}
onFinancingPaidToggle={handleFinancingPaidToggle}
/>
<PaycheckColumn
paycheck={pc2}
@@ -684,6 +768,7 @@ function PaycheckView() {
onGenerate={generateMonth}
onAmountSave={handleAmountSave}
onBillAmountSave={handleBillAmountSave}
onFinancingPaidToggle={handleFinancingPaidToggle}
/>
</div>
)}