import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Calculator, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import PayoffChart from '@/components/snowball/PayoffChart';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
}
function fmtShort(v) {
const n = Number(v) || 0;
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
}
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {
if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return [];
const rate = (annualRatePct || 0) / 100 / 12;
if (rate > 0 && monthlyPayment <= balance * rate) return [];
let bal = balance;
const months = [];
for (let i = 0; i < 600; i++) {
const interest = Math.round(bal * rate * 100) / 100;
const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment);
const principal = Math.max(0, pmt - interest);
bal = Math.round(Math.max(0, bal - principal) * 100) / 100;
months.push({ month: i + 1, balance: bal, interest });
if (bal < 0.01) break;
}
return months;
}
function payoffLabel(track, now = new Date()) {
if (!track.length) return null;
const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1);
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function numMonths(track) {
if (!track.length) return null;
const y = Math.floor(track.length / 12);
const m = track.length % 12;
if (y === 0) return `${m} mo`;
if (m === 0) return `${y} yr`;
return `${y} yr ${m} mo`;
}
// ─── Stat card ────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, color = 'amber' }) {
const colors = {
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400',
teal: 'bg-teal-500/8 border-teal-400/20 text-teal-500 dark:text-teal-400',
slate: 'bg-muted/40 border-border/60 text-foreground',
};
return (
{label}
{value}
{sub &&
{sub}
}
);
}
// ─── Input row ────────────────────────────────────────────────────────────────
function InputRow({ label, hint, children }) {
return (
{hint && {hint}}
{children}
);
}
// ─── Empty states ─────────────────────────────────────────────────────────────
function EmptyDebts() {
return (
No debts with a balance found
Add a current balance to your bills on the{' '}
Snowball page.
);
}
function NoSelection() {
return (
Select a loan or debt to begin
Choose from the dropdown above to run your simulation.
);
}
// ─── PayoffPage ───────────────────────────────────────────────────────────────
export default function PayoffPage() {
const [bills, setBills] = useState([]);
const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
// Per-simulation state (reset when bill changes)
const [simPayment, setSimPayment] = useState('');
const [simRate, setSimRate] = useState('');
const [oneTimeExtra, setOneTimeExtra] = useState('');
const [applying, setApplying] = useState(false);
const loadData = useCallback(() => {
setLoading(true);
setLoadError(null);
Promise.all([api.snowball(), api.snowballSettings()])
.then(([billData, settings]) => {
const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0);
setBills(debtBills);
setExtraPayment(Number(settings?.extra_payment) || 0);
if (debtBills.length > 0 && !selectedId) {
setSelectedId(debtBills[0].id);
}
})
.catch(err => setLoadError(err.message || 'Failed to load data'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadData(); }, [loadData]);
const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]);
const isAttack = bills[0]?.id === selectedId;
// Reset sim inputs whenever the selected bill changes
useEffect(() => {
if (!bill) return;
setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)));
setSimRate(String(bill.interest_rate ?? 0));
setOneTimeExtra('');
}, [bill?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Derived simulation tracks
const simPaymentN = Math.max(0, Number(simPayment) || 0);
const simRateN = Math.max(0, Number(simRate) || 0);
const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0);
const minPayment = bill?.minimum_payment ?? 0;
const { minTrack, currentTrack, simTrack } = useMemo(() => {
if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] };
const b = bill.current_balance;
const min = minPayment > 0 ? minPayment : 0.01;
const currentPmt = isAttack ? min + extraPayment : min;
return {
minTrack: buildPayoffSchedule(b, simRateN, min),
currentTrack: buildPayoffSchedule(b, simRateN, currentPmt),
simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN),
};
}, [bill, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]);
const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
const interestSavings = Math.max(0, minInterest - simInterest);
const timeSavings = Math.max(0, minTrack.length - simTrack.length);
const simTotalPaid = simInterest + (bill?.current_balance ?? 0);
const simPayoffLabel = payoffLabel(simTrack);
const minPayoffLabel = payoffLabel(minTrack);
const simDuration = numMonths(simTrack);
const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0;
const defaultSimPayment = bill
? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))
: '';
const defaultRate = bill ? String(bill.interest_rate ?? 0) : '';
const isDirty = simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== '';
const handleReset = () => {
if (!bill) return;
setSimPayment(defaultSimPayment);
setSimRate(defaultRate);
setOneTimeExtra('');
};
const handleApply = async () => {
if (!bill || applying) return;
setApplying(true);
try {
await api.updateBill(bill.id, { expected_amount: simPaymentN });
toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, {
action: {
label: 'Undo',
onClick: async () => {
await api.updateBill(bill.id, { expected_amount: bill.expected_amount });
toast.success('Reverted');
loadData();
},
},
});
loadData();
} catch {
toast.error('Failed to update bill');
} finally {
setApplying(false);
}
};
// ── Render ──────────────────────────────────────────────────────────────────
if (loading) {
return (
{[1, 2, 3, 4].map(i => (
))}
);
}
if (loadError) {
return (
Failed to load data
{loadError}
);
}
return (
{/* Page header */}
Payoff Simulator
Explore how extra payments reduce interest and shorten your payoff timeline.
{isDirty && (
)}
{/* Bill selector */}
{bills.length === 0 ? (
) : (
)}
{/* Main content: left panel + right panel */}
{!bill ? (
bills.length > 0 ?
: null
) : (
{/* ── Left panel ── */}
{/* Required minimum */}
Required Minimum
{minPayment > 0 ? fmt(minPayment) : Not set}
{minPayment <= 0 && (
Set a minimum payment on the Snowball page for best results.
)}
{/* Interest rate */}
setSimRate(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
%
{/* Monthly payment */}
setSimPayment(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
{paymentBelowMin && (
Below minimum payment of {fmt(minPayment)}
)}
{paymentTooLow && !paymentBelowMin && (
Payment too low to overcome interest
)}
{simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
)}
{/* One-time extra */}
{/* Divider */}
{/* Payoff date summary */}
{simPayoffLabel ? (
Payoff
{simPayoffLabel}
{simDuration && (
{simDuration}
)}
) : (
Enter a payment to see payoff date
)}
{minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
Minimum only
{minPayoffLabel}
)}
{/* ── Right panel ── */}
{/* Chart */}
{simTrack.length > 0 ? (
) : (
{simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
)}
{/* Stats row */}
{simTrack.length > 0 && (
<>
0 ? 'teal' : 'slate'}
/>
0 ? `${timeSavings} mo` : '—'}
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
color={timeSavings > 0 ? 'amber' : 'slate'}
/>
{/* Breakdown */}
Balance today
{fmt(bill.current_balance)}
Total interest
{fmt(simInterest)}
Total paid
{fmt(simTotalPaid)}
>
)}
)}
);
}