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:
Jeff Emmett 2026-03-14 21:43:23 +00:00
parent 6a59ac6007
commit 8a8399eb74
4 changed files with 427 additions and 5 deletions

View File

@ -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' : ''}`}>
&#9662;
</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>
)
}

View File

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

View File

@ -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">

View File

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