import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { AlertCircle, Calculator, HelpCircle, Info, Printer, RefreshCw, RotateCcw, Save, 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn, errMessage } from '@/lib/utils'; import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money'; import PayoffChart from '@/components/snowball/PayoffChart'; import type { Bill } from '@/types'; interface ScheduleMonth { month: number; balance: number; interest: number; } // ─── Print isolation ────────────────────────────────────────────────────────── const PRINT_STYLES = ` @media print { * { visibility: hidden !important; } #payoff-print-area, #payoff-print-area * { visibility: visible !important; } #payoff-print-area { position: absolute !important; top: 0 !important; left: 0 !important; right: 0 !important; width: 100% !important; padding: 24px !important; margin: 0 !important; background: #fff !important; color: #111 !important; } #payoff-print-area .no-print { display: none !important; } #payoff-print-area .print-only { display: block !important; } } `; // ─── Helpers ────────────────────────────────────────────────────────────────── function fmt(v: number | null | undefined): string { return formatUSD(asDollars(Number(v) || 0)); } function fmtShort(v: number | null | undefined): string { return formatUSDWhole(asDollars(Number(v) || 0)); } function buildPayoffSchedule( balance: number, annualRatePct: number, monthlyPayment: number, oneTimeExtra = 0, ): ScheduleMonth[] { 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: ScheduleMonth[] = []; 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: ScheduleMonth[], now = new Date()): string | null { 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: ScheduleMonth[]): string | null { 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`; } // ─── Sub-components ─────────────────────────────────────────────────────────── // Focusable inline-help tooltip for a non-obvious figure (keyboard + SR accessible). function Help({ text, label }: { text: string; label: string }) { return ( {text} ); } function StatCard({ label, value, sub, color = 'amber', help, }: { label: ReactNode; value: ReactNode; sub?: ReactNode; color?: string; help?: string; }) { const colors: Record = { 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} {help && typeof label === 'string' && }

{value}

{sub &&

{sub}

}
); } function InputRow({ label, hint, children, }: { label: ReactNode; hint?: ReactNode; children: ReactNode; }) { return (
{hint && {hint}}
{children}
); } function EmptyDebts() { return (

No bills with a balance found

Add a current balance to your bills on the{' '} Snowball page , or use the Custom option in the dropdown above.

); } function NoSelection() { return (

Select a loan or debt to begin

Choose from the dropdown above, or select Custom to simulate any loan.

); } // ─── PayoffPage ─────────────────────────────────────────────────────────────── export default function PayoffPage() { const [bills, setBills] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [selectedId, setSelectedId] = useState(null); // Custom mode inputs const [customName, setCustomName] = useState(''); const [customBalance, setCustomBalance] = useState(''); // Per-simulation inputs (reset when selection changes) const [simPayment, setSimPayment] = useState(''); const [simRate, setSimRate] = useState(''); const [oneTimeExtra, setOneTimeExtra] = useState(''); const [applying, setApplying] = useState(false); const isCustom = selectedId === 'custom'; // isInitial=true drives the full-page loading/error state (first load + retry). // A background refresh (after a save/undo) is "silent": it updates the data // without flashing the skeleton or replacing the page on a transient failure. const loadData = useCallback((isInitial = false) => { if (isInitial) { setLoading(true); setLoadError(null); } // Use api.bills() so ALL active bills with a balance appear (not just debt categories) (api.bills() as Promise) .then((allBills) => { const withBalance = (allBills || []) .filter((b) => (b.current_balance ?? 0) > 0 && !b.is_subscription) .sort((a, b) => a.name.localeCompare(b.name)); setBills(withBalance); // Only auto-pick a bill on the initial load — never yank the user's // current selection on a background refresh (functional update reads the // live value, avoiding the stale-closure snap-back). if (withBalance.length > 0) { setSelectedId((prev) => (prev == null ? withBalance[0]!.id : prev)); } }) .catch((err) => { if (isInitial) setLoadError(errMessage(err, 'Failed to load data')); else toast.error(errMessage(err, "Couldn't refresh the debt list")); }) .finally(() => { if (isInitial) setLoading(false); }); }, []); useEffect(() => { loadData(true); }, [loadData]); const bill = useMemo( () => (isCustom ? null : (bills.find((b) => b.id === selectedId) ?? null)), [bills, selectedId, isCustom], ); // Reset sim inputs whenever selection changes useEffect(() => { if (isCustom) { setSimPayment(''); setSimRate('0'); setOneTimeExtra(''); return; } if (!bill) return; setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))); setSimRate(String(bill.interest_rate ?? 0)); setOneTimeExtra(''); }, [selectedId]); // eslint-disable-line react-hooks/exhaustive-deps // Derived numeric values 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 activeBalance = isCustom ? parseFloat(customBalance) || 0 : (bill?.current_balance ?? 0); const activeName = isCustom ? customName.trim() || 'Custom Loan' : (bill?.name ?? ''); // The "vs minimum" baseline only makes sense when the bill actually has a // minimum payment on record; without one we show absolute payoff figures. const hasMinimum = !isCustom && minPayment > 0; const { minTrack, simTrack } = useMemo(() => { if (!activeBalance) return { minTrack: [] as ScheduleMonth[], simTrack: [] as ScheduleMonth[] }; return { minTrack: hasMinimum ? buildPayoffSchedule(activeBalance, simRateN, minPayment) : [], simTrack: buildPayoffSchedule(activeBalance, simRateN, simPaymentN, oneTimeExtraN), }; }, [activeBalance, hasMinimum, minPayment, simRateN, simPaymentN, oneTimeExtraN]); 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 hasBaseline = minTrack.length > 0; // a real, amortizing minimum to compare against const interestSavings = Math.max(0, minInterest - simInterest); const timeSavings = Math.max(0, minTrack.length - simTrack.length); const simTotalPaid = simInterest + activeBalance; const simPayoffLabel = payoffLabel(simTrack); const minPayoffLabel = payoffLabel(minTrack); const simDuration = numMonths(simTrack); // No APR on record → the schedule is running interest-free (distinct from a // genuine 0%). `interest_rate == null` means the field was never set. const aprMissing = !isCustom && !!bill && bill.interest_rate == null; const simCapped = simTrack.length >= 600; // hit the 600-month cap → won't realistically pay off const paymentBelowMin = !isCustom && simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0; const paymentTooLow = activeBalance > 0 && simPaymentN > 0 && (simTrack.length === 0 || simCapped); const customNeedsBalance = isCustom && !customBalance; const defaultSimPayment = bill ? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)) : ''; const defaultRate = bill ? String(bill.interest_rate ?? 0) : ''; const isDirty = !isCustom && (simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== ''); const handleReset = () => { if (!bill) return; setSimPayment(defaultSimPayment); setSimRate(defaultRate); setOneTimeExtra(''); }; const handlePrint = () => window.print(); // Persist the tuned APR back to the bill (the one durable fact worth keeping — // it makes every future estimate interest-aware). Deliberately does NOT touch // expected_amount / the budget. Rate differs from what's stored ⇒ offer it. const rateChanged = !isCustom && !!bill && simRateN !== (bill.interest_rate ?? 0); const handleSaveApr = async () => { if (!bill || applying) return; const prevRate = bill.interest_rate ?? null; setApplying(true); try { await api.updateBill(bill.id, { interest_rate: simRateN }); toast.success( `Saved ${simRateN}% APR to "${bill.name}" — payoff estimates now include interest`, { action: { label: 'Undo', onClick: async () => { try { await api.updateBill(bill.id, { interest_rate: prevRate }); toast.success('Interest rate reverted'); loadData(); } catch (err) { toast.error(errMessage(err, "Couldn't revert the interest rate")); } }, }, }, ); loadData(); } catch (err) { toast.error(errMessage(err, "Couldn't save the interest rate")); } finally { setApplying(false); } }; const handleSelectChange = (val: string) => { setSelectedId(val === 'custom' ? 'custom' : Number(val)); }; // ── Render ────────────────────────────────────────────────────────────────── if (loading) { return (
{[1, 2, 3, 4].map((i) => (
))}
); } if (loadError) { return (

Failed to load data

{loadError}

); } // Capped (hit the 600-month wall) counts as "won't pay off" — don't present a // bogus payoff date / results for it. const showResults = !simCapped && ((isCustom && activeBalance > 0 && simTrack.length > 0) || (!isCustom && !!bill && simTrack.length > 0)); return (
{/* ── Print-only summary header (hidden on screen) ── */}

Payoff Simulator — {activeName || '—'}

{activeBalance > 0 && (

Balance: {fmt(activeBalance)} {simRateN > 0 && ` · Rate: ${simRateN}%`} {simPaymentN > 0 && ` · Payment: ${fmt(simPaymentN)}/mo`} {oneTimeExtraN > 0 && ` · One-time extra: ${fmt(oneTimeExtraN)}`}

)}
{/* ── Page header ── */}

Payoff Simulator

A what-if calculator for one debt — see how a bigger monthly payment and your APR change the payoff date and total interest. (For a whole-plan strategy across debts, use the Snowball page.)

{isDirty && ( )}
{/* ── Bill/debt selector ── */}
{bills.length === 0 && !isCustom && (

No bills with a current balance found.{' '}

)}
{/* ── Empty / no-selection states ── */} {!isCustom && !bill && bills.length === 0 && } {!isCustom && !bill && bills.length > 0 && } {/* ── Main content ── */} {(isCustom || bill) && (
{/* ── Left panel ── */}
{/* Custom mode: Name + Balance inputs */} {isCustom && ( <> setCustomName(e.target.value)} placeholder="e.g. Car Loan, Mortgage…" className="no-print" />

{customName || 'Custom Loan'}

$ setCustomBalance(e.target.value)} className="font-mono" placeholder="0.00" autoFocus />

{fmt(activeBalance)}

{customNeedsBalance && (

Balance is required to run the simulation

)}
)} {/* Bill mode: Required minimum display */} {!isCustom && (
Required Minimum {minPayment > 0 ? ( fmt(minPayment) ) : ( Not set )}
)} {!isCustom && minPayment <= 0 && (

No minimum payment on this bill — edit the bill's Debt Details to add one and unlock the savings-vs-minimum comparison.

)} {/* Interest rate */} Interest Rate } hint="Override to test scenarios" >
setSimRate(e.target.value)} className="font-mono no-print" placeholder="0.00" /> % {simRateN}%
{aprMissing && simRateN <= 0 && (

No interest rate on this bill — this estimate assumes 0% (interest-free). Enter your APR, then Save it.

)} {rateChanged && ( )}
{/* Monthly payment */}
setSimPayment(e.target.value)} className="font-mono" placeholder="0.00" /> {paymentBelowMin && (

Below minimum payment of {fmt(minPayment)}

)} {paymentTooLow && !paymentBelowMin && (

{simCapped && simRateN <= 0 ? 'This debt won’t pay off in a reasonable time at this payment.' : 'Payment too low to overcome the interest — the balance never shrinks.'}

)}

{fmt(simPaymentN)}/mo

{/* One-time extra */}
setOneTimeExtra(e.target.value)} className="font-mono" placeholder="0.00" />
{oneTimeExtraN > 0 && (

{fmt(oneTimeExtraN)}

)}
{/* Divider */}
{/* Payoff date summary */}
{simPayoffLabel ? (
Payoff
{simPayoffLabel} {simDuration && (

{simDuration}

)}
) : (

{customNeedsBalance ? 'Enter a balance to see payoff date' : 'Enter a payment to see payoff date'}

)} {!isCustom && minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
Minimum only {minPayoffLabel}
)}
{/* ── Right panel ── */}
{/* Chart */} {showResults ? ( <>

Projected balance over time {hasBaseline ? ' — dashed grey is paying only the minimum' : ''}.

) : (
{customNeedsBalance ? 'Enter a balance and payment to see the chart' : simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : simCapped ? 'This payment won’t pay the debt off — raise it to see a payoff curve.' : 'Payment too low to pay off this debt'}
)} {/* Stats row */} {showResults && ( <>
{hasBaseline ? ( <> 0 ? 'teal' : 'slate'} /> 0 ? `${timeSavings} mo` : '—'} sub={timeSavings > 0 ? 'months sooner' : 'same timeline'} color={timeSavings > 0 ? 'amber' : 'slate'} /> ) : ( <> 0 ? 'teal' : 'slate'} /> )}
{/* Breakdown */}
Balance today {fmt(activeBalance)}
Total interest {fmt(simInterest)}
Total paid {fmt(simTotalPaid)}
)}
)}
{/* /payoff-print-area */} ); }