feat: borrower affordability calculator — reverse-engineer loan from monthly budget
Given a monthly payment budget, shows max borrowable amount across different tier mixes (blended, all-single-tier, short-heavy, long-heavy). Expandable breakdown with per-tier allocation table, stacked bar, and interest comparison. Also fixes pre-existing PaymentFlow.tsx type error from merge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6a59ac6007
commit
8a8399eb74
|
|
@ -0,0 +1,284 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { MortgageSimulatorConfig } from '@/lib/mortgage-types'
|
||||
import { calculateAffordability } from '@/lib/mortgage-engine'
|
||||
|
||||
interface Props {
|
||||
config: MortgageSimulatorConfig
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
|
||||
}
|
||||
|
||||
export default function BorrowerAffordabilityCalculator({ config }: Props) {
|
||||
const [monthlyBudget, setMonthlyBudget] = useState(2000)
|
||||
const [downPayment, setDownPayment] = useState(config.downPaymentPercent)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(0)
|
||||
|
||||
const tiers = useMemo(() => {
|
||||
return config.useVariableTerms
|
||||
? config.lendingTiers
|
||||
: [{ label: `${config.termYears}yr`, termYears: config.termYears, rate: config.interestRate, allocation: 1 }]
|
||||
}, [config])
|
||||
|
||||
const scenarios = useMemo(
|
||||
() => calculateAffordability(monthlyBudget, downPayment, tiers),
|
||||
[monthlyBudget, downPayment, tiers],
|
||||
)
|
||||
|
||||
const bestLoan = Math.max(...scenarios.map(s => s.maxLoan))
|
||||
const leastInterest = Math.min(...scenarios.map(s => s.totalInterest))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-bold text-white">What Can I Afford?</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Enter what you can pay monthly — see how tier mix affects your borrowing power
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inputs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-slate-300">Monthly Budget</span>
|
||||
<span className="text-xl font-mono font-bold text-white">{fmt(monthlyBudget)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={500} max={10000} step={100}
|
||||
value={monthlyBudget}
|
||||
onChange={e => setMonthlyBudget(Number(e.target.value))}
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>$500</span>
|
||||
<span>$10,000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-slate-300">Down Payment</span>
|
||||
<span className="text-xl font-mono font-bold text-white">{downPayment}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={50} step={5}
|
||||
value={downPayment}
|
||||
onChange={e => setDownPayment(Number(e.target.value))}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>0%</span>
|
||||
<span>50%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenarios */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{scenarios.map((s, idx) => {
|
||||
const isBestLoan = s.maxLoan === bestLoan
|
||||
const isLeastInterest = s.totalInterest === leastInterest
|
||||
const isExpanded = expandedIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.label}
|
||||
className={`bg-slate-800/60 rounded-lg border overflow-hidden transition-colors ${
|
||||
isBestLoan ? 'border-emerald-500/50' : isLeastInterest ? 'border-amber-500/40' : 'border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{/* Summary row */}
|
||||
<button
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full flex items-center gap-4 px-4 py-3 hover:bg-slate-700/30 transition-colors text-left"
|
||||
>
|
||||
{/* Badges */}
|
||||
<div className="flex flex-col gap-0.5 w-24 flex-shrink-0">
|
||||
<span className="text-sm font-bold text-white">{s.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{isBestLoan && (
|
||||
<span className="text-[9px] bg-emerald-600 text-white px-1 py-0.5 rounded uppercase tracking-wider">Max $</span>
|
||||
)}
|
||||
{isLeastInterest && (
|
||||
<span className="text-[9px] bg-amber-600 text-white px-1 py-0.5 rounded uppercase tracking-wider">Low Int</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key stats */}
|
||||
<div className="flex-1 grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 uppercase">You Can Borrow</div>
|
||||
<div className="text-sm font-mono text-emerald-400 font-bold">{fmt(s.maxLoan)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 uppercase">Property Value</div>
|
||||
<div className="text-sm font-mono text-white">{fmt(s.propertyValue)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 uppercase">Total Interest</div>
|
||||
<div className="text-sm font-mono text-amber-400">{fmt(s.totalInterest)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-500 uppercase">Payoff</div>
|
||||
<div className="text-sm font-mono text-sky-400">{s.payoffYears}yr</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className={`text-slate-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded: tier breakdown */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-slate-700/50 px-4 py-3">
|
||||
<div className="text-xs text-slate-400 mb-3">{s.description}</div>
|
||||
|
||||
{/* Borrowing power bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-slate-500">Borrowing power</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">{fmt(s.maxLoan)} / {fmt(bestLoan)} max</span>
|
||||
</div>
|
||||
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-sky-500 to-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${(s.maxLoan / bestLoan) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stacked tier bar */}
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Tier allocation of your {fmt(monthlyBudget)}/mo</div>
|
||||
<div className="h-6 bg-slate-700 rounded-full overflow-hidden flex">
|
||||
{s.breakdown.map((b, i) => {
|
||||
const frac = b.monthlyPayment / monthlyBudget
|
||||
const colors = ['#06b6d4', '#10b981', '#3b82f6', '#f59e0b', '#ef4444']
|
||||
return (
|
||||
<div
|
||||
key={b.tierLabel}
|
||||
className="h-full flex items-center justify-center text-[9px] font-bold text-white/80"
|
||||
style={{
|
||||
width: `${frac * 100}%`,
|
||||
backgroundColor: colors[i % colors.length],
|
||||
minWidth: frac > 0.05 ? undefined : 0,
|
||||
}}
|
||||
title={`${b.tierLabel}: ${fmt(b.monthlyPayment)}/mo → ${fmt(b.principal)}`}
|
||||
>
|
||||
{frac > 0.08 ? b.tierLabel : ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier detail table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left py-1 pr-3">Tier</th>
|
||||
<th className="text-right py-1 pr-3">Rate</th>
|
||||
<th className="text-right py-1 pr-3">Monthly</th>
|
||||
<th className="text-right py-1 pr-3">Principal</th>
|
||||
<th className="text-right py-1 pr-3">Interest</th>
|
||||
<th className="text-right py-1">Term</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.breakdown.map(b => (
|
||||
<tr key={b.tierLabel} className="border-t border-slate-700/30">
|
||||
<td className="py-1.5 pr-3 font-medium text-white">{b.tierLabel}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-slate-300 font-mono">{(b.rate * 100).toFixed(1)}%</td>
|
||||
<td className="py-1.5 pr-3 text-right text-sky-400 font-mono">{fmt(b.monthlyPayment)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-emerald-400 font-mono">{fmt(b.principal)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-amber-400 font-mono">{fmt(b.totalInterest)}</td>
|
||||
<td className="py-1.5 text-right text-slate-400">{b.termYears}yr</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t border-slate-600">
|
||||
<td className="py-1.5 pr-3 font-bold text-white">Total</td>
|
||||
<td className="py-1.5 pr-3" />
|
||||
<td className="py-1.5 pr-3 text-right text-white font-mono font-bold">{fmt(monthlyBudget)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-emerald-400 font-mono font-bold">{fmt(s.maxLoan)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-amber-400 font-mono font-bold">{fmt(s.totalInterest)}</td>
|
||||
<td className="py-1.5 text-right text-slate-400">{s.payoffYears}yr</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Interest vs principal visual */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] text-slate-500">Total cost breakdown</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden flex">
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(s.maxLoan / s.totalPaid) * 100}%` }}
|
||||
title="Principal"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(s.totalInterest / s.totalPaid) * 100}%` }}
|
||||
title="Interest"
|
||||
/>
|
||||
{downPayment > 0 && (
|
||||
<div
|
||||
className="h-full bg-sky-500"
|
||||
style={{ width: `${((s.propertyValue - s.maxLoan) / (s.totalPaid + s.propertyValue - s.maxLoan)) * 100}%` }}
|
||||
title="Down payment"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-500">
|
||||
<span>Principal {fmt(s.maxLoan)}</span>
|
||||
<span>Interest {fmt(s.totalInterest)}</span>
|
||||
{downPayment > 0 && <span>Down {fmt(s.propertyValue - s.maxLoan)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Insight */}
|
||||
<div className="bg-gradient-to-br from-sky-900/30 to-emerald-900/30 rounded-lg p-4 border border-sky-800/30">
|
||||
<p className="text-sm text-slate-300 text-center">
|
||||
{(() => {
|
||||
const best = scenarios.find(s => s.maxLoan === bestLoan)
|
||||
const cheapest = scenarios.find(s => s.totalInterest === leastInterest)
|
||||
if (!best || !cheapest) return null
|
||||
const diff = best.maxLoan - cheapest.maxLoan
|
||||
const savedInterest = best.totalInterest - cheapest.totalInterest
|
||||
return (
|
||||
<>
|
||||
<strong className="text-emerald-400">{best.label}</strong> lets you borrow the most ({fmt(best.maxLoan)})
|
||||
{diff > 0 && cheapest.label !== best.label && (
|
||||
<>, but <strong className="text-amber-400">{cheapest.label}</strong> saves you{' '}
|
||||
<strong className="text-white">{fmt(savedInterest)}</strong> in interest
|
||||
{cheapest.payoffYears < best.payoffYears && <> and pays off {best.payoffYears - cheapest.payoffYears} years sooner</>}
|
||||
</>
|
||||
)}
|
||||
. With rMortgage, your interest stays in the community either way.
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,9 +10,10 @@ import LenderGrid from './LenderGrid'
|
|||
import MycelialNetworkViz from './MycelialNetworkViz'
|
||||
import LenderDetail from './LenderDetail'
|
||||
import LenderReturnCalculator from './LenderReturnCalculator'
|
||||
import BorrowerAffordabilityCalculator from './BorrowerAffordabilityCalculator'
|
||||
import ComparisonPanel from './ComparisonPanel'
|
||||
|
||||
type ViewMode = 'mycelial' | 'flow' | 'grid' | 'lender'
|
||||
type ViewMode = 'mycelial' | 'flow' | 'grid' | 'lender' | 'borrower'
|
||||
|
||||
export default function MortgageSimulator() {
|
||||
const [config, setConfig] = useState<MortgageSimulatorConfig>(DEFAULT_CONFIG)
|
||||
|
|
@ -98,7 +99,7 @@ export default function MortgageSimulator() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['mycelial', 'flow', 'grid', 'lender'] as const).map(mode => (
|
||||
{(['mycelial', 'flow', 'grid', 'lender', 'borrower'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
|
|
@ -106,7 +107,7 @@ export default function MortgageSimulator() {
|
|||
viewMode === mode ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{mode === 'mycelial' ? 'Network' : mode === 'flow' ? 'Flow' : mode === 'grid' ? 'Grid' : 'Lender'}
|
||||
{mode === 'mycelial' ? 'Network' : mode === 'flow' ? 'Flow' : mode === 'grid' ? 'Grid' : mode === 'lender' ? 'Lender' : 'Borrower'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -168,8 +169,10 @@ export default function MortgageSimulator() {
|
|||
onSelectTranche={handleSelectTranche}
|
||||
selectedTrancheId={selectedTranche?.id ?? null}
|
||||
/>
|
||||
) : (
|
||||
) : viewMode === 'lender' ? (
|
||||
<LenderReturnCalculator config={config} />
|
||||
) : (
|
||||
<BorrowerAffordabilityCalculator config={config} />
|
||||
)}
|
||||
|
||||
{/* Lender Detail (below viz when selected) */}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ function ArrowIcon() {
|
|||
|
||||
function StepProgress({ current, steps }: { current: Step; steps: Step[] }) {
|
||||
const visibleSteps = steps.filter(s => s !== 'processing' && s !== 'complete')
|
||||
const currentIdx = visibleSteps.indexOf(current)
|
||||
const currentIdx = visibleSteps.indexOf(current as typeof visibleSteps[number])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-8">
|
||||
|
|
|
|||
|
|
@ -611,3 +611,138 @@ export function calculateLenderReturns(
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Borrower Affordability Calculator ───────────────────
|
||||
|
||||
/** Given a monthly payment, how much can you borrow at a given rate and term? */
|
||||
export function maxPrincipal(monthlyBudget: number, annualRate: number, termMonths: number): number {
|
||||
if (annualRate === 0) return monthlyBudget * termMonths
|
||||
const r = annualRate / 12
|
||||
return monthlyBudget * (Math.pow(1 + r, termMonths) - 1) / (r * Math.pow(1 + r, termMonths))
|
||||
}
|
||||
|
||||
export interface AffordabilityScenario {
|
||||
label: string
|
||||
description: string
|
||||
// Tier mix used
|
||||
tiers: { label: string; termYears: number; rate: number; allocation: number }[]
|
||||
// Results
|
||||
maxLoan: number
|
||||
propertyValue: number // with down payment
|
||||
monthlyPayment: number
|
||||
totalInterest: number
|
||||
totalPaid: number
|
||||
payoffYears: number
|
||||
// Per-tier breakdown
|
||||
breakdown: {
|
||||
tierLabel: string
|
||||
principal: number
|
||||
monthlyPayment: number
|
||||
totalInterest: number
|
||||
termYears: number
|
||||
rate: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export function calculateAffordability(
|
||||
monthlyBudget: number,
|
||||
downPaymentPercent: number,
|
||||
tiers: { label: string; termYears: number; rate: number; allocation: number }[],
|
||||
): AffordabilityScenario[] {
|
||||
const scenarios: AffordabilityScenario[] = []
|
||||
|
||||
// Helper: compute a scenario for a given tier mix
|
||||
function compute(
|
||||
label: string,
|
||||
description: string,
|
||||
mix: { label: string; termYears: number; rate: number; allocation: number }[],
|
||||
): AffordabilityScenario {
|
||||
// Each tier gets a fraction of the monthly budget proportional to its allocation
|
||||
const totalAlloc = mix.reduce((s, t) => s + t.allocation, 0)
|
||||
const breakdown = mix.map(tier => {
|
||||
const allocFrac = tier.allocation / totalAlloc
|
||||
const tierBudget = monthlyBudget * allocFrac
|
||||
const termMonths = tier.termYears * 12
|
||||
const principal = maxPrincipal(tierBudget, tier.rate, termMonths)
|
||||
const mp = tierBudget
|
||||
const totalPaid = mp * termMonths
|
||||
const totalInterest = totalPaid - principal
|
||||
return {
|
||||
tierLabel: tier.label,
|
||||
principal,
|
||||
monthlyPayment: mp,
|
||||
totalInterest,
|
||||
termYears: tier.termYears,
|
||||
rate: tier.rate,
|
||||
}
|
||||
})
|
||||
|
||||
const totalLoan = breakdown.reduce((s, b) => s + b.principal, 0)
|
||||
const totalInterest = breakdown.reduce((s, b) => s + b.totalInterest, 0)
|
||||
const totalPaid = breakdown.reduce((s, b) => s + b.monthlyPayment * b.termYears * 12, 0)
|
||||
const longestTerm = Math.max(...mix.map(t => t.termYears))
|
||||
const propertyValue = downPaymentPercent > 0
|
||||
? totalLoan / (1 - downPaymentPercent / 100)
|
||||
: totalLoan
|
||||
|
||||
return {
|
||||
label,
|
||||
description,
|
||||
tiers: mix,
|
||||
maxLoan: totalLoan,
|
||||
propertyValue,
|
||||
monthlyPayment: monthlyBudget,
|
||||
totalInterest,
|
||||
totalPaid,
|
||||
payoffYears: longestTerm,
|
||||
breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 1: Default tier mix
|
||||
if (tiers.length > 1) {
|
||||
scenarios.push(compute('Blended', 'Default tier allocation across all terms', tiers))
|
||||
}
|
||||
|
||||
// Scenario 2: Each single tier
|
||||
for (const tier of tiers) {
|
||||
scenarios.push(compute(
|
||||
`All ${tier.label}`,
|
||||
`Entire loan at ${(tier.rate * 100).toFixed(1)}% for ${tier.termYears} years`,
|
||||
[{ ...tier, allocation: 1 }],
|
||||
))
|
||||
}
|
||||
|
||||
// Scenario 3: Short-heavy mix (if multiple tiers)
|
||||
if (tiers.length >= 3) {
|
||||
const sorted = [...tiers].sort((a, b) => a.termYears - b.termYears)
|
||||
const shortHeavy = sorted.map((t, i) => ({
|
||||
...t,
|
||||
allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)),
|
||||
}))
|
||||
scenarios.push(compute(
|
||||
'Short-Heavy',
|
||||
`50% in shortest term (${sorted[0].label}), rest spread`,
|
||||
shortHeavy,
|
||||
))
|
||||
}
|
||||
|
||||
// Scenario 4: Long-heavy mix (if multiple tiers)
|
||||
if (tiers.length >= 3) {
|
||||
const sorted = [...tiers].sort((a, b) => b.termYears - a.termYears)
|
||||
const longHeavy = sorted.map((t, i) => ({
|
||||
...t,
|
||||
allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)),
|
||||
}))
|
||||
scenarios.push(compute(
|
||||
'Long-Heavy',
|
||||
`50% in longest term (${sorted[0].label}), rest spread`,
|
||||
longHeavy,
|
||||
))
|
||||
}
|
||||
|
||||
// Sort by max loan descending
|
||||
scenarios.sort((a, b) => b.maxLoan - a.maxLoan)
|
||||
|
||||
return scenarios
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue