import React, { useMemo, useState } from "react";
// ---- Utility helpers ----
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const pct = (n) => `${((isFinite(n) ? n : 0) * 100).toFixed(1)}%`;
const num = (v, d=4) => (isFinite(v) ? Number(v.toFixed(d)) : 0);
function pmt(rateAnnual, years, principal) {
const r = rateAnnual / 12;
const n = years * 12;
if (r === 0) return principal / n;
return (r * principal) / (1 - Math.pow(1 + r, -n));
}
// ---- Component ----
export default function DealOptionsCalculator() {
const [inputs, setInputs] = useState({
rent: 2500,
piti: 1800, // underlying existing PITI for Subto / Wrap
arv: 350000,
price: 280000,
rehab: 15000,
hoaMonthly: 0,
arrearsOwed: 0, // one-time reinstatement/arrears
loanBalance: 0, // optional total mortgage payoff
listingType: 'direct', // 'direct' | 'agent'
isWholesale: false,
assignmentFee: 10000,
});
const [thresholds, setThresholds] = useState({
minCF: 300, // monthly
minCOC: 0.15, // 15%
minCap: 0.08, // 8%
minFlipProfit: 20000,
minFlipMargin: 0.10, // 10%
minAssignmentFee: 5000,
hideFails: false,
});
const [adv, setAdv] = useState({
// Global assumptions
vacancyRate: 0.05,
mgmtRate: 0.08,
maintenanceRate: 0.05,
capexRate: 0.05,
otherOpexRate: 0.00,
fixedMonthlyOpex: 0, // taxes/ins, utilities, etc (ex-debt)
buyClosingPct: 0.03,
acqAgentFeePct: 0.00, // extra buy-side fee if listed w/ agent (set to e.g. 3%)
sellClosingPct: 0.08, // agent + closing + concessions on resale
holdMonths: 4,
holdMonthlyBurn: 500,
// Seller Finance
sfDownPct: 0.10,
sfRate: 0.055,
sfTermYears: 30,
// Subto
subtoUpfront: 10000,
// Hybrid (Subto + 2nd)
underlyingLTV: 0.80, // assumes loan balance ≈ price * LTV if no explicit loanBalance
secondRate: 0.08,
secondTermYears: 30,
// Wrap Exit (to end buyer)
wrapPricePctOfARV: 1.00, // sell at 100% of ARV by default
wrapDownPct: 0.10,
wrapRate: 0.095,
wrapTermYears: 30,
wrapAssignmentFee: 0, // optional fee on top of buyer downpayment
// STR (Short-Term Rental)
strRevenueUplift: 1.8, // vs LTR rent
strExpenseRate: 0.50, // % of STR revenue (incl. cleaning, utilities)
strMgmtRate: 0.20, // professional STR manager
});
const onChange = (k, v) => setInputs((s) => ({ ...s, [k]: typeof v === 'boolean' ? v : Number(v) || 0 }));
const onSet = (k, v) => setInputs((s) => ({ ...s, [k]: v }));
const onThreshold = (k, v, mult=1) => setThresholds((s) => ({ ...s, [k]: typeof v === 'boolean' ? v : Number(v) * mult }));
const onAdv = (k, v, mult=1) => setAdv((s) => ({ ...s, [k]: Number(v) * mult }));
const U = useMemo(() => {
const rent = Math.max(inputs.rent, 0);
const piti = Math.max(inputs.piti, 0);
const arv = Math.max(inputs.arv, 0);
const price = Math.max(inputs.price, 0);
const rehab = Math.max(inputs.rehab, 0);
const hoaMonthly = Math.max(inputs.hoaMonthly, 0);
const arrearsOwed = Math.max(inputs.arrearsOwed, 0);
const loanBalance = Math.max(inputs.loanBalance, 0);
const listingType = inputs.listingType;
const isWholesale = inputs.isWholesale;
const assignmentFeePlanned = Math.max(inputs.assignmentFee, 0);
// buy costs affected by listing type
const buyCosts = price * adv.buyClosingPct + (listingType === 'agent' ? price * adv.acqAgentFeePct : 0);
const basisCash = price + rehab + buyCosts; // cash purchase basis (your total invested at closing for cash buy)
// --- Standardize NOI (monthly, pre-debt) ---
const operRate = adv.mgmtRate + adv.maintenanceRate + adv.capexRate + adv.otherOpexRate;
const egiMonthly = rent * (1 - adv.vacancyRate); // Effective Gross Income (after vacancy)
const varOpexMonthly = egiMonthly * operRate; // % of EGI
const fixedMonthly = adv.fixedMonthlyOpex + hoaMonthly; // taxes, insurance, HOA, utilities etc.
const noiMonthly = Math.max(egiMonthly - varOpexMonthly - fixedMonthly, 0);
const noiAnnual = noiMonthly * 12;
const capRateAnnual = basisCash > 0 ? noiAnnual / basisCash : 0; // same cap rate across pre-debt scenarios
function rentalMetrics(monthlyDebt) {
const cashflowMonthly = noiMonthly - monthlyDebt; // CF after expenses and debt
return { cashflowMonthly, capRate: capRateAnnual };
}
// Gate cash offers if loan balance provided and exceeds offer
const cashOfferCoversLoan = loanBalance === 0 || price >= loanBalance;
// ---- Strategy calcs ----
// 0) WHOLESALE (optional card)
const wholesale = {
fee: assignmentFeePlanned,
pass: isWholesale && assignmentFeePlanned >= thresholds.minAssignmentFee
};
// 1) CASH FLIP
const flipCashIn = basisCash + adv.holdMonths * adv.holdMonthlyBurn; // total cash tied up
const flipSaleCosts = arv * adv.sellClosingPct;
const flipCosts = flipCashIn + flipSaleCosts;
const flipProfit = (cashOfferCoversLoan ? (arv - flipCosts) : Number.NEGATIVE_INFINITY);
const flipMargin = cashOfferCoversLoan && arv > 0 ? (flipProfit / arv) : 0; // profit as % of resale price
const flipROI = cashOfferCoversLoan && flipCashIn > 0 ? (flipProfit / flipCashIn) : 0; // profit vs cash invested
const flipPass = cashOfferCoversLoan && (flipProfit >= thresholds.minFlipProfit) && (flipMargin >= thresholds.minFlipMargin);
// 2) CASH RENTAL (no debt)
const rentCash = rentalMetrics(0);
const cocCash = basisCash > 0 ? (rentCash.cashflowMonthly * 12) / basisCash : 0; // annual CF / cash invested
const rentCashPass = cashOfferCoversLoan && (rentCash.cashflowMonthly >= thresholds.minCF) && (cocCash >= thresholds.minCOC) && (capRateAnnual >= thresholds.minCap);
// 3) SUBTO (use PITI as monthly debt); cash in = subtoUpfront + rehab + closing + arrears
const subtoCashIn = adv.subtoUpfront + rehab + buyCosts + arrearsOwed;
const subtoCF = noiMonthly - piti;
const subtoCOC = subtoCashIn > 0 ? (subtoCF * 12) / subtoCashIn : 0;
const subtoPass = (subtoCF >= thresholds.minCF) && (subtoCOC >= thresholds.minCOC) && (capRateAnnual >= thresholds.minCap);
// 4) SELLER FINANCE
const sfDown = price * adv.sfDownPct;
const sfPrincipal = Math.max(price - sfDown, 0);
const sfPayment = pmt(adv.sfRate, adv.sfTermYears, sfPrincipal);
const sfCF = noiMonthly - sfPayment;
const sfCashIn = sfDown + rehab + buyCosts; // arrears generally not relevant when paying seller-financed equity; adjust if needed
const sfCOC = sfCashIn > 0 ? (sfCF * 12) / sfCashIn : 0;
const sfPass = (sfCF >= thresholds.minCF) && (sfCOC >= thresholds.minCOC) && (capRateAnnual >= thresholds.minCap);
// 5) HYBRID: take over underlying (prefer explicit loan balance) + 2nd for equity
const underlyingAmt = loanBalance > 0 ? loanBalance : price * adv.underlyingLTV;
const estSecondAmt = Math.max(price - underlyingAmt, 0);
const secondPayment = pmt(adv.secondRate, adv.secondTermYears, estSecondAmt);
const hybridMonthlyDebt = piti + secondPayment; // PITI covers underlying
const hybridCF = noiMonthly - hybridMonthlyDebt;
const hybridCashIn = adv.subtoUpfront + rehab + buyCosts + arrearsOwed; // include arrears to reinstate
const hybridCOC = hybridCashIn > 0 ? (hybridCF * 12) / hybridCashIn : 0;
const hybridPass = (hybridCF >= thresholds.minCF) && (hybridCOC >= thresholds.minCOC) && (capRateAnnual >= thresholds.minCap);
// 6) WRAP EXIT: sell via wrap at higher rate/price; spread over underlying (use subto or seller-fin payment)
const wrapPrice = arv * adv.wrapPricePctOfARV;
const wrapDown = wrapPrice * adv.wrapDownPct;
const wrapPrincipal = Math.max(wrapPrice - wrapDown, 0);
const wrapBuyerPay = pmt(adv.wrapRate, adv.wrapTermYears, wrapPrincipal);
// choose underlying payment = smallest of (piti, sfPayment) if both exist; else piti if >0 else sfPayment
const underlyingPay = (piti > 0 && isFinite(sfPayment) && sfPayment > 0) ? Math.min(piti, sfPayment) : (piti > 0 ? piti : (isFinite(sfPayment) ? sfPayment : 0));
const wrapSpread = wrapBuyerPay - underlyingPay; // before servicing; end-buyer typically pays property expenses in a wrap
const wrapMonthlyNet = wrapSpread; // keep simple; optional servicing reserve can be modeled later
const wrapCashIn = adv.subtoUpfront + rehab + buyCosts + arrearsOwed;
const wrapUpfrontNet = (wrapDown + adv.wrapAssignmentFee) - wrapCashIn;
const wrapPass = (wrapMonthlyNet >= thresholds.minCF) && ( (wrapUpfrontNet >= thresholds.minFlipProfit) || (wrapMonthlyNet * 12 / Math.max(wrapCashIn,1) >= thresholds.minCOC) );
// 7) SHORT-TERM RENTAL (cash)
const strRevenue = rent * adv.strRevenueUplift; // LTR baseline × uplift
const strEGI = strRevenue; // vacancy typically embedded in expense rate for STR; adjust if desired
const strVarOpex = strEGI * (adv.strExpenseRate + adv.strMgmtRate);
const strNOIMonthly = Math.max(strEGI - strVarOpex - fixedMonthly, 0);
const strCF = strNOIMonthly; // no debt in this card
const strCOC = basisCash > 0 ? (strCF * 12) / basisCash : 0;
const strCap = basisCash > 0 ? (strNOIMonthly * 12) / basisCash : 0;
const strPass = (strCF >= thresholds.minCF) && (strCOC >= thresholds.minCOC) && (strCap >= thresholds.minCap);
return {
rent, piti, arv, price, rehab, buyCosts, basisCash,
egiMonthly, varOpexMonthly, fixedMonthly, noiMonthly, noiAnnual, capRateAnnual,
cashOfferCoversLoan,
wholesale,
flip: { profit: flipProfit, margin: flipMargin, roi: flipROI, cashIn: flipCashIn, pass: flipPass },
cashRental: { cf: rentCash.cashflowMonthly, coc: cocCash, cap: capRateAnnual, pass: rentCashPass },
subto: { cf: subtoCF, coc: subtoCOC, cap: capRateAnnual, cashIn: subtoCashIn, pass: subtoPass },
sellerFinance: { payment: sfPayment, down: sfDown, cf: sfCF, coc: sfCOC, cap: capRateAnnual, cashIn: sfCashIn, pass: sfPass },
hybrid: { cf: hybridCF, coc: hybridCOC, cap: capRateAnnual, cashIn: hybridCashIn, pass: hybridPass },
wrap: { price: wrapPrice, buyerPay: wrapBuyerPay, spread: wrapSpread, monthlyNet: wrapMonthlyNet, upfrontNet: wrapUpfrontNet, pass: wrapPass },
str: { revenue: strRevenue, cf: strCF, coc: strCOC, cap: strCap, pass: strPass },
};
}, [inputs, adv, thresholds]);
const strategies = [
...(inputs.isWholesale ? [{
key: 'wholesale', label: 'Wholesale (Assignment)',
render: () => (
)
}] : []),
{
key: 'flip', label: 'Cash (Flip)',
render: () => (
)
},
{
key: 'cashRental', label: 'Cash (Rental)',
render: () => (
)
},
{
key: 'subto', label: 'Creative: Subject-To',
render: () => (
)
},
{
key: 'sellerFinance', label: 'Creative: Seller Finance',
render: () => (
)
},
{
key: 'hybrid', label: 'Creative: Hybrid (Subto + 2nd)',
render: () => (
)
},
{
key: 'wrap', label: 'Wrap-Around Exit',
render: () => (
)
},
{
key: 'str', label: 'Short-Term Rental (Cash)',
render: () => (
)
},
];
const visible = thresholds.hideFails ? strategies.filter(s => U[s.key]?.pass) : strategies;
return (
{/* Inputs */}
onChange('rent', v)} prefix="$" />
onChange('piti', v)} prefix="$" />
onChange('hoaMonthly', v)} prefix="$" />
onChange('arv', v)} prefix="$" />
onChange('price', v)} prefix="$" />
onChange('rehab', v)} prefix="$" />
onChange('loanBalance', v)} prefix="$" />
onChange('arrearsOwed', v)} prefix="$" />
onSet('isWholesale', e.target.checked)} />
{inputs.isWholesale && (
onChange('assignmentFee', v)} prefix="$" />
)}
onThreshold('minCF', v)} prefix="$" />
onThreshold('minCOC', v, 0.01)} suffix="%" />
onThreshold('minCap', v, 0.01)} suffix="%" />
onThreshold('minFlipProfit', v)} prefix="$" />
onThreshold('minFlipMargin', v, 0.01)} suffix="%" />
onThreshold('minAssignmentFee', v)} prefix="$" />
onThreshold('hideFails', e.target.checked)} />
onAdv('vacancyRate', v, 0.01)} suffix="%" />
onAdv('mgmtRate', v, 0.01)} suffix="%" />
onAdv('maintenanceRate', v, 0.01)} suffix="%" />
onAdv('capexRate', v, 0.01)} suffix="%" />
onAdv('otherOpexRate', v, 0.01)} suffix="%" />
onAdv('fixedMonthlyOpex', v)} prefix="$" />
onAdv('buyClosingPct', v, 0.01)} suffix="%" />
onAdv('acqAgentFeePct', v, 0.01)} suffix="%" />
onAdv('sellClosingPct', v, 0.01)} suffix="%" />
onAdv('holdMonths', v)} />
onAdv('holdMonthlyBurn', v)} prefix="$" />
onAdv('sfDownPct', v, 0.01)} suffix="%" />
onAdv('sfRate', v, 0.01)} suffix="%" />
onAdv('sfTermYears', v)} />
onAdv('subtoUpfront', v)} prefix="$" />
onAdv('underlyingLTV', v, 0.01)} suffix="%" />
onAdv('secondRate', v, 0.01)} suffix="%" />
onAdv('secondTermYears', v)} />
onAdv('wrapPricePctOfARV', v, 0.01)} suffix="%" />
onAdv('wrapDownPct', v, 0.01)} suffix="%" />
onAdv('wrapRate', v, 0.01)} suffix="%" />
onAdv('wrapTermYears', v)} />
onAdv('wrapAssignmentFee', v)} prefix="$" />
onAdv('strRevenueUplift', v)} suffix="×" />
onAdv('strExpenseRate', v, 0.01)} suffix="%" />
onAdv('strMgmtRate', v, 0.01)} suffix="%" />
{/* Results */}
Results
{visible.map((s) => (
{s.label}
{U[s.key]?.pass ? 'Pass' : 'Below Threshold'}
{s.render()}
))}
{/* Footnote */}
All outputs are estimates based on your inputs & assumptions. Not legal, tax, or investment advice. Always verify numbers for your specific deal.
);
}
function Card({ title, children }) {
return (
);
}
function Field({ label, value, onChange, prefix, suffix }) {
return (
);
}
function Details({ title, children }) {
return (
{title}
{children}
);
}
function MetricGrid({ rows, pass }) {
return (
{rows.map(([k, v]) => (
{k}
{v}
))}
);
}