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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user