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

@@ -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>
)}