From 8a8399eb74f6edb8bf1f5a6da5414da475a8538d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 14 Mar 2026 21:43:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20borrower=20affordability=20calculator?= =?UTF-8?q?=20=E2=80=94=20reverse-engineer=20loan=20from=20monthly=20budge?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../BorrowerAffordabilityCalculator.tsx | 284 ++++++++++++++++++ components/mortgage/MortgageSimulator.tsx | 11 +- components/pay/PaymentFlow.tsx | 2 +- lib/mortgage-engine.ts | 135 +++++++++ 4 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 components/mortgage/BorrowerAffordabilityCalculator.tsx diff --git a/components/mortgage/BorrowerAffordabilityCalculator.tsx b/components/mortgage/BorrowerAffordabilityCalculator.tsx new file mode 100644 index 0000000..6bae470 --- /dev/null +++ b/components/mortgage/BorrowerAffordabilityCalculator.tsx @@ -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(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 ( +
+ {/* Header */} +
+

What Can I Afford?

+

+ Enter what you can pay monthly — see how tier mix affects your borrowing power +

+
+ + {/* Inputs */} +
+
+
+ Monthly Budget + {fmt(monthlyBudget)} +
+ setMonthlyBudget(Number(e.target.value))} + className="w-full accent-sky-500" + /> +
+ $500 + $10,000 +
+
+ +
+
+ Down Payment + {downPayment}% +
+ setDownPayment(Number(e.target.value))} + className="w-full accent-emerald-500" + /> +
+ 0% + 50% +
+
+
+ + {/* Scenarios */} +
+ {scenarios.map((s, idx) => { + const isBestLoan = s.maxLoan === bestLoan + const isLeastInterest = s.totalInterest === leastInterest + const isExpanded = expandedIdx === idx + + return ( +
+ {/* Summary row */} + + + {/* Expanded: tier breakdown */} + {isExpanded && ( +
+
{s.description}
+ + {/* Borrowing power bar */} +
+
+ Borrowing power + {fmt(s.maxLoan)} / {fmt(bestLoan)} max +
+
+
+
+
+ + {/* Stacked tier bar */} +
+
Tier allocation of your {fmt(monthlyBudget)}/mo
+
+ {s.breakdown.map((b, i) => { + const frac = b.monthlyPayment / monthlyBudget + const colors = ['#06b6d4', '#10b981', '#3b82f6', '#f59e0b', '#ef4444'] + return ( +
0.05 ? undefined : 0, + }} + title={`${b.tierLabel}: ${fmt(b.monthlyPayment)}/mo → ${fmt(b.principal)}`} + > + {frac > 0.08 ? b.tierLabel : ''} +
+ ) + })} +
+
+ + {/* Tier detail table */} +
+ + + + + + + + + + + + + {s.breakdown.map(b => ( + + + + + + + + + ))} + + + + + + + + +
TierRateMonthlyPrincipalInterestTerm
{b.tierLabel}{(b.rate * 100).toFixed(1)}%{fmt(b.monthlyPayment)}{fmt(b.principal)}{fmt(b.totalInterest)}{b.termYears}yr
Total + {fmt(monthlyBudget)}{fmt(s.maxLoan)}{fmt(s.totalInterest)}{s.payoffYears}yr
+
+ + {/* Interest vs principal visual */} +
+
+ Total cost breakdown +
+
+
+
+ {downPayment > 0 && ( +
+ )} +
+
+ Principal {fmt(s.maxLoan)} + Interest {fmt(s.totalInterest)} + {downPayment > 0 && Down {fmt(s.propertyValue - s.maxLoan)}} +
+
+
+ )} +
+ ) + })} +
+ + {/* Insight */} +
+

+ {(() => { + 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 ( + <> + {best.label} lets you borrow the most ({fmt(best.maxLoan)}) + {diff > 0 && cheapest.label !== best.label && ( + <>, but {cheapest.label} saves you{' '} + {fmt(savedInterest)} 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. + + ) + })()} +

+
+
+ ) +} diff --git a/components/mortgage/MortgageSimulator.tsx b/components/mortgage/MortgageSimulator.tsx index c2915bf..fcb6f1e 100644 --- a/components/mortgage/MortgageSimulator.tsx +++ b/components/mortgage/MortgageSimulator.tsx @@ -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(DEFAULT_CONFIG) @@ -98,7 +99,7 @@ export default function MortgageSimulator() {

- {(['mycelial', 'flow', 'grid', 'lender'] as const).map(mode => ( + {(['mycelial', 'flow', 'grid', 'lender', 'borrower'] as const).map(mode => ( ))}
@@ -168,8 +169,10 @@ export default function MortgageSimulator() { onSelectTranche={handleSelectTranche} selectedTrancheId={selectedTranche?.id ?? null} /> - ) : ( + ) : viewMode === 'lender' ? ( + ) : ( + )} {/* Lender Detail (below viz when selected) */} diff --git a/components/pay/PaymentFlow.tsx b/components/pay/PaymentFlow.tsx index 10e1db9..c644665 100644 --- a/components/pay/PaymentFlow.tsx +++ b/components/pay/PaymentFlow.tsx @@ -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 (
diff --git a/lib/mortgage-engine.ts b/lib/mortgage-engine.ts index 02ea558..7e58815 100644 --- a/lib/mortgage-engine.ts +++ b/lib/mortgage-engine.ts @@ -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 +}