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 */}
+
+
+
+
+ | Tier |
+ Rate |
+ Monthly |
+ Principal |
+ Interest |
+ Term |
+
+
+
+ {s.breakdown.map(b => (
+
+ | {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
+}