The actual code that produces every number.
This page reads the live TypeScript source files at render time. What you see below is what the engine runs. No transpilation, no black box, no “we'll send you the model” meeting. If the math doesn't hold up, the bug is in the code on this page.
Five files, ~430 lines of pure arithmetic. Hosted from github.com/aliazimx-dotcom/underwrite-app; this page is just a viewer over the same files.
PMT and IRR
The two core financial primitives. pmt() is the standard Excel/financial-calc PMT formula (annuity payment given rate, periods, and present value). irr() runs Newton-Raphson on a cash-flow series with clamping so a single iteration can't overshoot the rate > -1 boundary. These two functions feed every levered cash flow and equity IRR the engine emits.
lib/engine/finance.tslive read from disk at render timeexport function pmt(rate: number, nper: number, pv: number, fv: number = 0, type: 0 | 1 = 0): number {
if (rate === 0) return -(pv + fv) / nper;
const pvif = Math.pow(1 + rate, nper);
return (-rate * (pv * pvif + fv)) / ((1 + rate * type) * (pvif - 1));
}
export function ipmt(rate: number, per: number, nper: number, pv: number, fv: number = 0, type: 0 | 1 = 0): number {
if (rate === 0) return 0;
if (per < 1 || per > nper) return 0;
const payment = pmt(rate, nper, pv, fv, type);
let interest: number;
if (per === 1) {
interest = type === 1 ? 0 : -pv * rate;
} else {
const fvBefore = fvAfter(rate, per - 1, payment, pv, type);
interest = type === 1 ? fvBefore * rate / (1 + rate) : fvBefore * rate;
}
return interest;
}
function fvAfter(rate: number, periods: number, payment: number, pv: number, type: 0 | 1): number {
const pvif = Math.pow(1 + rate, periods);
return -(pv * pvif + payment * (1 + rate * type) * (pvif - 1) / rate);
}
export function cumipmt(rate: number, nper: number, pv: number, startPeriod: number, endPeriod: number, type: 0 | 1 = 0): number {
let total = 0;
for (let i = startPeriod; i <= endPeriod; i++) {
total += ipmt(rate, i, nper, pv, 0, type);
}
return total;
}
export function irr(cashflows: number[], guess: number = 0.1): number {
if (cashflows.length < 2) return Number.NaN;
const hasNeg = cashflows.some((c) => c < 0);
const hasPos = cashflows.some((c) => c > 0);
if (!hasNeg || !hasPos) return Number.NaN;
let rate = guess;
for (let iter = 0; iter < 200; iter++) {
let npv = 0;
let dnpv = 0;
for (let t = 0; t < cashflows.length; t++) {
const factor = Math.pow(1 + rate, t);
npv += cashflows[t] / factor;
if (t > 0) dnpv -= (t * cashflows[t]) / (factor * (1 + rate));
}
if (!Number.isFinite(npv) || !Number.isFinite(dnpv) || Math.abs(dnpv) < 1e-15) {
return Number.NaN;
}
let newRate = rate - npv / dnpv;
if (!Number.isFinite(newRate)) return Number.NaN;
// Clamp Newton steps so a single iteration can't overshoot past the
// "rate must be > -1" boundary (where (1+rate)^t would explode) or
// jump beyond the plausible IRR range. Without this clamp, even a
// simple "you lost half your money" case (irr([-100, 50])) overshoots
// to rate=-1.22 in the first iteration and bails out as divergent.
if (newRate <= -0.99) newRate = (rate + -0.99) / 2;
if (newRate >= 50) newRate = (rate + 50) / 2;
if (Math.abs(newRate - rate) < 1e-12) {
if (Math.abs(newRate) > 5) return Number.NaN;
return newRate;
}
rate = newRate;
}
if (Math.abs(rate) > 5) return Number.NaN;
return rate;
}
// Modified IRR (MIRR). Where standard IRR implicitly assumes that interim
// positive cash flows are reinvested at the IRR itself, MIRR explicitly
// separates the reinvestment rate (typically a conservative safe-rate or
// hurdle-rate proxy) from the financing rate applied to the initial and
// any subsequent negative cash flows. Closer to how real LPs think about
// returns: distributions aren't magically compounding at 18%.
//
// Formula:
// n = number of periods (cashflows.length - 1)
// FV(positives) = sum of positives compounded forward to year n at reinvestRate
// PV(negatives) = sum of negatives discounted to year 0 at financeRate
// MIRR = (FV(positives) / -PV(negatives))^(1/n) - 1
//
// Matches Excel's MIRR() function. When reinvestRate equals the implied
// IRR, MIRR exactly equals IRR; for any reinvestRate < IRR, MIRR < IRR
// (the "IRR overstates returns when distributions can't compound at the
// IRR rate" effect that institutional readers want to see).
export function mirr(
cashflows: number[],
financeRate: number,
reinvestRate: number,
): number {
if (cashflows.length < 2) return Number.NaN;
const n = cashflows.length - 1;
let fvPositives = 0;
let pvNegatives = 0;
for (let t = 0; t < cashflows.length; t++) {
const cf = cashflows[t];
if (cf > 0) {
fvPositives += cf * Math.pow(1 + reinvestRate, n - t);
} else if (cf < 0) {
pvNegatives += cf / Math.pow(1 + financeRate, t);
}
}
if (pvNegatives === 0 || fvPositives === 0) return Number.NaN;
// pvNegatives is negative (sum of negatives); ratio must be positive.
const ratio = fvPositives / -pvNegatives;
if (ratio <= 0) return Number.NaN;
return Math.pow(ratio, 1 / n) - 1;
}
What to look at: Read pmt() first: it's the rate x pv x (1+r)^n compounding formula, four lines of arithmetic. irr() is harder because Newton-Raphson on cash flows can be unstable near -1; the inline comment explains the clamping.
15-year revenue and NOI build
Year-by-year build of EGI and NOI. Revenue is residential GPR + retail GPR + other income, less vacancy. Expenses are taxes / insurance / repairs / utilities / marketing scaled by expense growth, plus property management and reserves scaled as % of EGI. NOI is the sum. Horizon is 15 years so the waterfall can look up forward-NOI when hold equals the last modeled year.
lib/engine/proforma.tslive read from disk at render timeimport type { DealInputs } from "./inputs";
export type ProFormaYear = {
year: number;
residentialGPR: number;
residentialVacancy: number;
retailGPR: number;
retailVacancy: number;
// Free-rent abatement against retail GPR. Negative number (a reduction
// in collected rent) when tenants have months of free rent at lease
// start, spread proportionally. Zero when no tenant rent roll, or when
// no tenant has free rent in this year.
freeRentAbatement: number;
otherIncome: number;
egi: number;
realEstateTaxes: number;
insurance: number;
propertyMgmt: number;
repairsMaintenance: number;
utilities: number;
marketingGA: number;
reserves: number;
// Hotel-specific OpEx overlay (negative dollar amounts). All three are
// 0 unless the deal is a hotel AND inputs.hotelOps is set. When active,
// they're deducted from NOI alongside the generic OpEx lines.
hotelBrandFee: number;
hotelMgmtFee: number;
hotelFFEReserve: number;
// Leasing capex (TI/LC) on lease commencement years. Negative number
// deducted from NOI in the year incurred. Zero when no tenant rent
// roll or when no tenant starts a new lease in this year. Phase 1
// simplification: real institutional treatment amortizes TIs over the
// lease term; the engine takes the one-time hit. /beyond-phase-1.
leasingCapex: number;
totalOpEx: number;
noi: number;
opExPctOfEGI: number;
};
// 15-year horizon supports any realistic CRE hold (typical range 3-10 years)
// and gives the waterfall a forward-year NOI lookup when hold equals the last
// modeled year. Was 10; raising it does not affect existing canonical math
// because consumers slice to the actual hold period.
const YEARS = 15;
export type ProForma = ProFormaYear[];
export function buildProForma(inputs: DealInputs): ProForma {
const { revenue, growth, operatingExpenses: oe } = inputs;
const grow = (base: number, rate: number, year: number) =>
base * Math.pow(1 + rate, year - 1);
// Tenant-level retail GPR builder. When inputs.revenue.tenants is present
// and non-empty, this REPLACES the aggregate retailSF × retailRentPerSFYear
// math with a per-tenant build:
//
// For each tenant active in year y (leaseStartYear <= y <= leaseEnd):
// in-term rent = sf × baseRentPerSFYear × (1 + escalator)^(y - start)
// For each tenant whose lease has expired (re-lease at market rate):
// market rate at year y = baseRentPerSFYear × (1 + rentGrowth)^(y - 1)
// re-leased rent = sf × marketRate
//
// No downtime, TI/LC, free rent, or co-tenancy modeling in this Phase 1
// version (see /methodology/beyond-phase-1). When tenants is absent or
// empty, returns null and the aggregate calc runs unchanged.
const tenants = revenue.tenants;
const hasTenants = Array.isArray(tenants) && tenants.length > 0;
// Per-tenant lease segment iterator. Given a tenant, yields a sequence
// of (commencementYear, leaseEnd, isReLease) tuples that walks from the
// original lease through any number of mid-hold rollovers, each shifted
// by the tenant's downtimeMonths converted to whole years (ceil).
// Returns all segments whose start year falls within [1, YEARS]; callers
// that need to know "is year y covered by a lease, and which one?"
// iterate the segments to find a match.
const tenantSegments = (
t: NonNullable<typeof tenants>[number],
): Array<{ start: number; end: number; isReLease: boolean }> => {
const segs: Array<{ start: number; end: number; isReLease: boolean }> = [];
const startInit = Number.isFinite(t.leaseStartYear) ? t.leaseStartYear : 1;
const term = Number.isFinite(t.leaseTermYears) ? t.leaseTermYears : 5;
const downMonths = Number.isFinite(t.downtimeMonths) ? Math.max(0, t.downtimeMonths ?? 0) : 0;
// Convert downtime months → whole-year offset on re-lease (ceil so
// 6 months still pushes the next lease by a full year, matching how
// institutional underwriting tends to round downtime conservatively).
const downYears = Math.ceil(downMonths / 12);
let cursor = startInit;
let isFirst = true;
while (cursor <= YEARS) {
const end = Math.min(cursor + term - 1, YEARS);
segs.push({ start: cursor, end, isReLease: !isFirst });
isFirst = false;
cursor = end + 1 + downYears; // jump past expiration + downtime
}
return segs;
};
const retailGPRForYear = (y: number): number => {
if (!hasTenants) {
return (
revenue.retailSF * revenue.retailRentPerSFYear *
Math.pow(1 + growth.rentGrowthAnnual, y - 1)
);
}
let sum = 0;
for (const t of tenants) {
if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
const segs = tenantSegments(t);
// Find the segment that covers year y, if any. Years between
// segments (downtime gap) contribute zero — the space is empty.
const seg = segs.find((s) => y >= s.start && y <= s.end);
if (!seg) continue;
if (!seg.isReLease) {
// In-term original lease: contracted rent with escalator
const yearsInLease = y - seg.start;
sum += t.sf * t.baseRentPerSFYear * Math.pow(1 + t.escalatorAnnual, yearsInLease);
} else {
// Re-lease at market rate (rent growth applied from year 1 of the
// proforma — represents what space rents for at re-lease time).
const marketRate = t.baseRentPerSFYear * Math.pow(1 + growth.rentGrowthAnnual, y - 1);
sum += t.sf * marketRate;
}
}
return sum;
};
// Free-rent abatement: negative dollar amount applied against retail GPR
// when tenants have months of free rent at lease start (or at re-lease
// commencement on a roll). Spread proportionally across the affected
// years: 18 free-rent months starting in year 1 means year 1 is fully
// abated (12 months) and year 2 is half-abated (6 months).
//
// Helper takes a list of (commencementYear, months) pairs for each
// tenant (initial start + any mid-hold re-lease) and returns the dollar
// abatement against year y. When a tenant has 0 free rent (or doesn't
// start in/before y), contributes 0.
const freeRentForYear = (y: number): number => {
if (!hasTenants) return 0;
let abate = 0;
for (const t of tenants) {
const fr = Number.isFinite(t.freeRentMonths) ? (t.freeRentMonths ?? 0) : 0;
if (fr <= 0) continue;
if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
// Use the same segment iterator as retail GPR so downtime-shifted
// commencement years stay consistent across rent, free rent, and TIs.
const segs = tenantSegments(t);
for (const seg of segs) {
const cYear = seg.start;
// Months of free rent that land in year y, given that free rent
// starts at the beginning of cYear and continues for `fr` months.
const offset = y - cYear;
if (offset < 0) continue; // before commencement
if (offset >= Math.ceil(fr / 12)) continue; // past the free-rent window
const monthsStart = 12 * offset;
const monthsEnd = Math.min(fr, 12 * offset + 12);
const monthsInYear = Math.max(0, monthsEnd - monthsStart);
if (monthsInYear <= 0) continue;
// Rent for this tenant in year y. For the initial commencement
// we use the contracted base × escalator; for a re-lease we use
// the deal's market rate (rent growth).
const yearsIn = y - cYear;
const rentPerSF = seg.isReLease
? t.baseRentPerSFYear * Math.pow(1 + growth.rentGrowthAnnual, y - 1)
: t.baseRentPerSFYear * Math.pow(1 + t.escalatorAnnual, yearsIn);
abate -= t.sf * rentPerSF * (monthsInYear / 12);
}
}
return abate;
};
// Leasing capex (TI/LC) for year y. Negative dollar amount fired in
// each lease-commencement year (initial start + each re-lease). When
// tenants is absent or tenant has no tiLcPerSF, contributes 0.
const leasingCapexForYear = (y: number): number => {
if (!hasTenants) return 0;
let capex = 0;
for (const t of tenants) {
const ti = Number.isFinite(t.tiLcPerSF) ? (t.tiLcPerSF ?? 0) : 0;
if (ti <= 0) continue;
if (!Number.isFinite(t.sf) || t.sf <= 0) continue;
// Use the same segment iterator: TI/LC fires in each commencement
// year (initial + each downtime-shifted re-lease).
const segs = tenantSegments(t);
for (const seg of segs) {
if (y === seg.start) capex -= t.sf * ti;
}
}
return capex;
};
// Lease-up curve. When inputs.acquisition.leaseUpYears > 0, the engine
// linearly interpolates vacancy from a higher "lease-up start" rate to
// the stabilized rate across the lease-up years. After lease-up, vacancy
// is the stabilized rate. When leaseUpYears is 0 / undefined, every year
// uses the stabilized rate — matches the pre-lease-up behavior exactly.
const leaseUpYears =
Number.isFinite(inputs.acquisition.leaseUpYears)
? Math.max(0, Math.floor(inputs.acquisition.leaseUpYears ?? 0))
: 0;
const stabResVac = revenue.residentialVacancyPct;
const stabComVac = revenue.retailVacancyPct;
const startResVac =
Number.isFinite(inputs.acquisition.leaseUpStartResidentialVacancyPct)
? inputs.acquisition.leaseUpStartResidentialVacancyPct ?? 0.4
: 0.4;
const startComVac =
Number.isFinite(inputs.acquisition.leaseUpStartCommercialVacancyPct)
? inputs.acquisition.leaseUpStartCommercialVacancyPct ?? 0.5
: 0.5;
// Linear interp from start to stabilized vacancy over leaseUpYears years.
// Year 1 = startVac, Year leaseUpYears+1 = stabVac.
const vacancyForYear = (y: number, startVac: number, stabVac: number): number => {
if (leaseUpYears <= 0 || y > leaseUpYears) return stabVac;
// y is 1-indexed; at y=1 we want startVac, at y=leaseUpYears+1 we want stabVac.
const t = (y - 1) / leaseUpYears;
return startVac + (stabVac - startVac) * t;
};
const years: ProFormaYear[] = [];
for (let y = 1; y <= YEARS; y++) {
const residentialGPR =
revenue.residentialUnits * revenue.avgResidentialRentMonthly * 12 *
Math.pow(1 + growth.rentGrowthAnnual, y - 1);
const effectiveResVac = vacancyForYear(y, startResVac, stabResVac);
const residentialVacancy = -residentialGPR * effectiveResVac;
const retailGPR = retailGPRForYear(y);
const effectiveComVac = vacancyForYear(y, startComVac, stabComVac);
const retailVacancy = -retailGPR * effectiveComVac;
const freeRentAbatement = freeRentForYear(y);
const otherIncome = grow(revenue.otherIncomeAnnual, growth.otherIncomeGrowthAnnual, y);
const egi =
residentialGPR + residentialVacancy + retailGPR + retailVacancy + freeRentAbatement + otherIncome;
const realEstateTaxes = -grow(oe.realEstateTaxes, growth.expenseGrowthAnnual, y);
const insurance = -grow(oe.insurance, growth.expenseGrowthAnnual, y);
const propertyMgmt = -egi * oe.propertyMgmtPctEGI;
const repairsMaintenance = -grow(oe.repairsMaintenance, growth.expenseGrowthAnnual, y);
const utilities = -grow(oe.utilities, growth.expenseGrowthAnnual, y);
const marketingGA = -grow(oe.marketingGA, growth.expenseGrowthAnnual, y);
const reserves = -egi * oe.reservesPctEGI;
const leasingCapex = leasingCapexForYear(y);
// Hotel-specific OpEx overlay. Only fires when propertyType === "Hotel"
// AND inputs.hotelOps is configured. Each fee is a % of EGI; deducted
// from NOI alongside generic OpEx. Backwards-compatible: when absent,
// all three lines are 0 and Cherry Street / non-hotel deals stay
// byte-identical.
const isHotel = inputs.meta.propertyType === "Hotel";
const hotelOps = isHotel ? inputs.hotelOps : undefined;
const hotelBrandFee = hotelOps && Number.isFinite(hotelOps.brandFeePctOfRevenue)
? -egi * Math.max(0, hotelOps.brandFeePctOfRevenue) : 0;
const hotelMgmtFee = hotelOps && Number.isFinite(hotelOps.mgmtFeePctOfRevenue)
? -egi * Math.max(0, hotelOps.mgmtFeePctOfRevenue) : 0;
const hotelFFEReserve = hotelOps && Number.isFinite(hotelOps.ffeReservePctOfRevenue)
? -egi * Math.max(0, hotelOps.ffeReservePctOfRevenue) : 0;
const totalOpEx =
realEstateTaxes + insurance + propertyMgmt + repairsMaintenance +
utilities + marketingGA + reserves +
hotelBrandFee + hotelMgmtFee + hotelFFEReserve;
// NOI is the institutional-standard "Net Operating Income" line:
// EGI minus ongoing operating expenses. Leasing capex (TI/LC) sits
// BELOW NOI as a separate capex item — this matches Argus and
// institutional credit-committee treatment, where DSCR is tested on
// NOI proper. The waterfall and after-tax engines pull operating
// cash flow from NOI - leasingCapex (the "Cash Flow After Capex"
// line); only DSCR uses raw NOI.
//
// Earlier engine versions folded leasing capex into NOI, which
// understated DSCR on rent-roll deals with TI/LC. Cherry Street
// (no rent roll → leasingCapex always 0) is byte-identical either
// way; deals with TIs see correct, higher DSCR with this fix.
const noi = egi + totalOpEx;
// Guard against zero/negative EGI (degenerate deal with no revenue inputs).
const opExPctOfEGI = egi === 0 ? 0 : -totalOpEx / egi;
years.push({
year: y,
residentialGPR,
residentialVacancy,
retailGPR,
retailVacancy,
freeRentAbatement,
otherIncome,
egi,
realEstateTaxes,
insurance,
propertyMgmt,
repairsMaintenance,
utilities,
marketingGA,
reserves,
hotelBrandFee,
hotelMgmtFee,
hotelFFEReserve,
leasingCapex,
totalOpEx,
noi,
opExPctOfEGI,
});
}
return years;
}
What to look at: The grow() helper compounds a base value by an annual rate. Vacancy is stored as a negative number (vacancy is a loss against GPR). propertyMgmt and reserves are computed AFTER EGI so they scale correctly when other income grows at a different rate from rent.
fixed-rate amortizing mortgage with balloon
Fixed-rate fully-amortizing mortgage schedule. PMT comes from finance.ts. Each year decomposes annual debt service into principal + interest, tracks beginning and ending balance, and reports DSCR = NOI / annualDebtService. At the loan term the remaining balance is the balloon, paid off at sale. What this does NOT model: prepayment, defeasance, IO periods, and floating rate. Those are listed on /methodology/beyond-phase-1.
lib/engine/debt.tslive read from disk at render timeimport type { DealInputs } from "./inputs";
import { pmt, cumipmt } from "./finance";
import type { ProForma } from "./proforma";
export type DebtYear = {
year: number;
begBalance: number;
annualPI: number;
interest: number;
principal: number;
endBalance: number;
dscr: number;
};
export type DebtSchedule = {
loanAmount: number;
interestRate: number;
amortizationMonths: number;
termMonths: number;
monthlyPayment: number;
years: DebtYear[];
// Refinance event metadata. Present only when inputs.refinance.enabled is
// true AND hold > term, so the engine actually splices in a refi schedule.
// Surfaces the cash event for the waterfall and lets the IC memo render
// the refinance line item explicitly. Null otherwise.
refinance?: {
eventYear: number; // year at which the original loan matures
terminalAssetValue: number; // forward NOI(eventYear+1) / entryCapRate
payoffBalance: number; // remaining principal on the original loan
newLoanAmount: number; // refinance.ltv × terminalAssetValue
cashOut: number; // newLoanAmount - payoffBalance
newInterestRate: number; // refi rate
newAmortizationMonths: number;
} | null;
};
export function buildDebtSchedule(inputs: DealInputs, proforma: ProForma): DebtSchedule {
const loanAmount = inputs.acquisition.purchasePrice * inputs.debt.ltv;
const interestRate = inputs.debt.interestRate;
const amortizationMonths = inputs.debt.amortizationYears * 12;
const termMonths = inputs.debt.termYears * 12;
const monthlyPayment = pmt(interestRate / 12, amortizationMonths, -loanAmount);
const years: DebtYear[] = [];
let begBalance = loanAmount;
// Match proforma horizon. The waterfall uses debt.years[hold-1] for end-of-
// hold loan balance, so debt must cover the same year range as proforma.
// If the loan amortizes within the schedule horizon (amort < 15 years), the
// begBalance hits zero in some year and subsequent years should report zero
// debt service rather than continuing to "pay" against a paid-off loan and
// pushing the balance negative.
//
// Refinance event: when enabled AND hold > term, at year=termYears the loan
// matures. We pay off the remaining balance and originate a new loan sized
// against terminal asset value × refinance LTV. The new loan amortizes
// through the rest of the proforma horizon.
const termYears = inputs.debt.termYears;
const holdYears = inputs.acquisition.holdPeriodYears;
const refiEnabled =
inputs.refinance?.enabled === true &&
Number.isFinite(holdYears) &&
Number.isFinite(termYears) &&
holdYears > termYears &&
Number.isFinite(inputs.refinance.ltv) &&
inputs.refinance.ltv > 0;
let refiMeta: NonNullable<DebtSchedule["refinance"]> | null = null;
for (let y = 1; y <= 15; y++) {
const noi = proforma[y - 1].noi;
// Trigger the refinance at the start of year termYears + 1 — i.e. the
// original loan amortized THROUGH year termYears, hit the balloon at
// year-end, and the new loan covers year termYears + 1 onward.
if (refiEnabled && y === termYears + 1 && refiMeta === null) {
const refi = inputs.refinance!;
const entryCapRate = proforma[0].noi / inputs.acquisition.purchasePrice;
// Terminal asset value at the refi event = forward NOI / entry cap rate.
// Use entry cap not exit cap: cap compression / expansion is an exit-
// year thesis, not a mid-hold mark.
const forwardNOI =
proforma[y - 1]?.noi ?? proforma[proforma.length - 1].noi;
const terminalAssetValue = forwardNOI / entryCapRate;
const newLoanAmount = terminalAssetValue * refi.ltv;
const payoffBalance = begBalance;
const cashOut = newLoanAmount - payoffBalance;
const newInterestRate = refi.interestRate ?? inputs.debt.interestRate;
const newAmortYears = refi.amortizationYears ?? inputs.debt.amortizationYears;
const newAmortMonths = newAmortYears * 12;
refiMeta = {
eventYear: termYears,
terminalAssetValue,
payoffBalance,
newLoanAmount,
cashOut,
newInterestRate,
newAmortizationMonths: newAmortMonths,
};
// Switch the working loan to the refi loan. begBalance becomes
// newLoanAmount; subsequent years amortize on (newInterestRate,
// newAmortMonths). To keep cumipmt's year-anchored math correct we
// need to track a separate "monthsIntoRefi" counter rather than the
// global y.
// Use a local index that resets at refi event: year y corresponds to
// the first year of the refi loan (so periods 1..12 of the refi).
begBalance = newLoanAmount;
}
if (begBalance <= 0 || !Number.isFinite(begBalance)) {
years.push({
year: y,
begBalance: 0,
annualPI: 0,
interest: 0,
principal: 0,
endBalance: 0,
dscr: noi === 0 ? Number.NaN : noi > 0 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY,
});
continue;
}
// Choose which amortization context to compute against. Pre-refi years
// use the original (rate, amort, loan amount). Post-refi years use the
// refi loan with a yearsIntoRefi index for cumipmt.
let activeRate: number;
let activeAmortMonths: number;
let activePI: number;
let startPeriod: number;
let endPeriod: number;
let activeLoanAmount: number;
if (refiMeta && y > termYears) {
activeRate = refiMeta.newInterestRate;
activeAmortMonths = refiMeta.newAmortizationMonths;
const yearsIntoRefi = y - termYears;
startPeriod = (yearsIntoRefi - 1) * 12 + 1;
endPeriod = yearsIntoRefi * 12;
activeLoanAmount = refiMeta.newLoanAmount;
activePI = pmt(activeRate / 12, activeAmortMonths, -activeLoanAmount) * 12;
} else {
activeRate = interestRate;
activeAmortMonths = amortizationMonths;
startPeriod = (y - 1) * 12 + 1;
endPeriod = y * 12;
activeLoanAmount = loanAmount;
activePI = monthlyPayment * 12;
}
const rawInterest = -cumipmt(activeRate / 12, activeAmortMonths, activeLoanAmount, startPeriod, endPeriod);
const rawPrincipal = activePI - rawInterest;
const principal = Math.min(rawPrincipal, begBalance);
const interest = rawInterest;
const annualPI = principal + interest;
const endBalance = begBalance - principal;
const dscr = annualPI === 0
? noi > 0 ? Number.POSITIVE_INFINITY : noi === 0 ? Number.NaN : Number.NEGATIVE_INFINITY
: noi / annualPI;
years.push({
year: y,
begBalance,
annualPI,
interest,
principal,
endBalance,
dscr,
});
begBalance = endBalance;
}
return {
loanAmount,
interestRate,
amortizationMonths,
termMonths,
monthlyPayment,
years,
refinance: refiMeta,
};
}
// Build the mezzanine debt schedule when inputs.mezzDebt is present. Returns
// null otherwise — call sites use the null to skip every mezz-aware code
// path so the no-mezz case stays byte-identical to the pre-mezz engine.
//
// Mezz is sized as additional LTV above senior. If senior is 65% LTV and
// mezz LTV is 10pp, mezz loan amount = purchasePrice × 0.10. The combined
// debt yield is what the mezz lender prices off, but for sizing we just
// use the explicit pp input.
//
// Mezz schedule mirrors senior structure (PMT amortization, balloon at
// term). Mezz years can be marked interest-only by passing a long amort
// (e.g. amortizationYears = 100); the math still works (principal pays
// down minimally each year).
//
// DSCR field on each year is MEZZ ONLY (NOI / mezz P&I) — not very useful
// for credit-committee purposes, but matches the senior schedule's shape
// for consistency. Most consumers will compute blended DSCR by summing
// senior + mezz P&I.
export function buildMezzSchedule(inputs: DealInputs, proforma: ProForma): DebtSchedule | null {
const mezz = inputs.mezzDebt;
if (!mezz || !Number.isFinite(mezz.ltv) || mezz.ltv <= 0) return null;
const loanAmount = inputs.acquisition.purchasePrice * mezz.ltv;
if (!Number.isFinite(loanAmount) || loanAmount <= 0) return null;
const interestRate = mezz.interestRate;
const amortizationMonths = mezz.amortizationYears * 12;
const termMonths = mezz.termYears * 12;
const monthlyPayment = pmt(interestRate / 12, amortizationMonths, -loanAmount);
const years: DebtYear[] = [];
let begBalance = loanAmount;
for (let y = 1; y <= 15; y++) {
const noi = proforma[y - 1].noi;
if (begBalance <= 0 || !Number.isFinite(begBalance)) {
years.push({
year: y,
begBalance: 0,
annualPI: 0,
interest: 0,
principal: 0,
endBalance: 0,
dscr: noi === 0 ? Number.NaN : noi > 0 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY,
});
continue;
}
const rawAnnualPI = monthlyPayment * 12;
const startPeriod = (y - 1) * 12 + 1;
const endPeriod = y * 12;
const rawInterest = -cumipmt(interestRate / 12, amortizationMonths, loanAmount, startPeriod, endPeriod);
const rawPrincipal = rawAnnualPI - rawInterest;
const principal = Math.min(rawPrincipal, begBalance);
const interest = rawInterest;
const annualPI = principal + interest;
const endBalance = begBalance - principal;
const dscr = annualPI === 0
? noi > 0 ? Number.POSITIVE_INFINITY : noi === 0 ? Number.NaN : Number.NEGATIVE_INFINITY
: noi / annualPI;
years.push({ year: y, begBalance, annualPI, interest, principal, endBalance, dscr });
begBalance = endBalance;
}
return {
loanAmount,
interestRate,
amortizationMonths,
termMonths,
monthlyPayment,
years,
};
}
What to look at: The monthly rate is annual / 12 because PMT compounds monthly. annualPI is monthly payment x 12. The dscr calc uses the proforma's NOI for that year. For all-equity deals (ltv = 0) the function short-circuits with an empty schedule.
capital stack at close
Sources and uses at closing. Uses = purchase price + closing costs + loan origination fee. Sources = senior debt + equity. LTV sizes off purchase price (not total uses), which is the conventional CRE convention; the borrower funds closing + origination out of equity. balanceCheck is sources minus uses and should always be exactly 0. Drift here means the engine has a math bug.
lib/engine/sourcesUses.tslive read from disk at render timeimport type { DealInputs } from "./inputs";
import type { DebtSchedule } from "./debt";
export type SourcesUses = {
uses: {
purchasePrice: number;
closingCosts: number;
loanOriginationFee: number;
// Total origination fees across all debt tranches (senior + mezz). When
// there's no mezz, this equals the senior-only fee unchanged. Pre-mezz
// callers that read `loanOriginationFee` continue to see senior-only;
// the new field is additive.
mezzOriginationFee: number;
totalUses: number;
};
sources: {
seniorDebt: number;
// Mezz tranche loan amount. 0 when no mezz; preserves byte-identical
// numbers for Cherry Street and every pre-mezz deal.
mezzDebt: number;
equity: number;
totalSources: number;
};
balanceCheck: number;
};
export function buildSourcesUses(
inputs: DealInputs,
debt: DebtSchedule,
mezz: DebtSchedule | null = null,
): SourcesUses {
const purchasePrice = inputs.acquisition.purchasePrice;
const closingCosts = purchasePrice * inputs.acquisition.closingCostPct;
const loanOriginationFee = debt.loanAmount * inputs.debt.originationFeePct;
const mezzOriginationFee =
mezz && inputs.mezzDebt
? mezz.loanAmount * inputs.mezzDebt.originationFeePct
: 0;
const totalUses = purchasePrice + closingCosts + loanOriginationFee + mezzOriginationFee;
const seniorDebt = debt.loanAmount;
const mezzDebt = mezz?.loanAmount ?? 0;
const equity = totalUses - seniorDebt - mezzDebt;
const totalSources = seniorDebt + mezzDebt + equity;
return {
uses: { purchasePrice, closingCosts, loanOriginationFee, mezzOriginationFee, totalUses },
sources: { seniorDebt, mezzDebt, equity, totalSources },
balanceCheck: totalSources - totalUses,
};
}
What to look at: Simplicity is the feature. Capital stack has no mezz, no pref equity, no construction draws. Listed on /methodology/beyond-phase-1.
4-tier equity promote
The four-tier waterfall that splits sale proceeds + accumulated operating cash flow between LP and GP. Tier 1 returns capital to LP. Tier 2 distributes the LP's preferred return. Tiers 3 and 4 escalate to higher IRR hurdles with progressively richer GP promote splits (90/10 to 80/20 to 70/30 to 60/40 residual). The critical invariant: LP + GP = max(0, totalProfit) for EVERY deal, including losing ones. No phantom pref on a deal that lost money.
lib/engine/waterfall.tslive read from disk at render timeimport type { DealInputs } from "./inputs";
import type { ProForma } from "./proforma";
import type { DebtSchedule } from "./debt";
import type { SourcesUses } from "./sourcesUses";
import { irr, mirr } from "./finance";
export type WaterfallTier = {
label: string;
hurdleAmount: number;
lpShare: number;
gpShare: number;
lpSplitPct: number;
gpSplitPct: number;
};
export type Waterfall = {
equityCashFlows: number[]; // Year 0 through Year hold
projectIRR: number;
// Modified IRR. Standard IRR implicitly assumes interim positive cash
// flows compound at the IRR rate; MIRR uses a separate reinvestment
// rate (set to the preferred return — a conservative LP-side proxy)
// and a finance rate (also preferred return). When projectMIRR is
// materially below projectIRR, the IRR is overstating the return that
// could realistically be earned on reinvested distributions.
projectMIRR: number;
projectMoIC: number;
totalDistribution: number;
totalContribution: number;
totalProfit: number;
tiers: WaterfallTier[];
totalLP: number;
totalGP: number;
// GP co-invest economics. Present only when inputs.waterfall.gpCoinvestPct
// is set and > 0; null otherwise. When present, breaks out LP and GP
// capital contributions, per-year distributions, and standalone IRRs.
// GP IRR is typically materially higher than LP IRR thanks to promote
// amplifying the GP's coinvest.
gpCoinvest: {
gpCoinvestPct: number;
lpContribution: number;
gpContribution: number;
lpDistribution: number;
gpDistribution: number;
lpMoIC: number;
gpMoIC: number;
lpCashFlows: number[]; // Year 0 through Year hold (LP-only)
gpCashFlows: number[]; // Year 0 through Year hold (GP-only)
lpIRR: number;
gpIRR: number;
} | null;
};
const TIER1_SPLIT: [number, number] = [0.9, 0.1];
const TIER2_SPLIT: [number, number] = [0.8, 0.2];
const TIER3_SPLIT: [number, number] = [0.7, 0.3];
const TIER4_SPLIT: [number, number] = [0.6, 0.4];
export function buildWaterfall(
inputs: DealInputs,
proforma: ProForma,
debt: DebtSchedule,
sourcesUses: SourcesUses,
mezz: DebtSchedule | null = null,
): Waterfall {
// Clamp the hold period to the available proforma/debt horizon so we can
// never run off the end of those arrays. Validation surfaces a danger
// warning if the user-entered hold is out of the [1, proforma.length]
// range, but the engine itself stays robust.
const requestedHold = inputs.acquisition.holdPeriodYears;
const hold = Math.max(
1,
Math.min(Number.isFinite(requestedHold) ? Math.floor(requestedHold) : 1, proforma.length),
);
const exitCap = inputs.growth.exitCapRate;
const sellingCost = inputs.growth.sellingCostPct;
const equity = sourcesUses.sources.equity;
const cashFlows: number[] = [];
cashFlows.push(-equity); // Year 0
// Refinance event: when the senior schedule includes a refi, the cash-out
// (or cash-in) happens at year=eventYear. Add it to that year's CF before
// we look at exit-year mechanics. Net-zero in pre-refi engine because
// debt.refinance is null when refi isn't triggered.
const refiYear = debt.refinance?.eventYear ?? null;
const refiCashOut = debt.refinance?.cashOut ?? 0;
for (let y = 1; y <= hold; y++) {
const noi = proforma[y - 1].noi;
// Leasing capex (TI/LC) is a below-NOI capex line on rent-roll deals.
// NOI itself is unburdened (matches Argus / lender DSCR convention);
// operating cash flow to equity subtracts it here. Zero on deals
// without a tenant rent roll, so backwards-compatible.
const leasingCapex = proforma[y - 1].leasingCapex ?? 0;
const seniorPI = debt.years[y - 1].annualPI;
// Mezz P&I subtracts from operating CF the same way senior does — equity
// sees what's left after BOTH debt tranches are served. When mezz is null,
// this is 0 and the math is byte-identical to the pre-mezz engine.
const mezzPI = mezz?.years[y - 1]?.annualPI ?? 0;
let cf = noi + leasingCapex - seniorPI - mezzPI;
// Refinance cash-out (or cash-in) at term-year maturity. Positive when
// terminal asset value × refi LTV exceeds the maturing balance (sponsor
// pulls cash out); negative when the gap goes the other way. When refi
// is not triggered, refiYear is null and this branch is skipped.
if (refiYear !== null && y === refiYear) {
cf += refiCashOut;
}
if (y === hold) {
// Forward NOI = year-hold+1 NOI. If the proforma horizon does not extend
// that far (would happen only for unusually long holds), fall back to a
// rent-growth-adjusted estimate so we never index past the array.
const forward = proforma[y];
const currentNOI = proforma[y - 1].noi;
const forwardNOI = forward
? forward.noi
: currentNOI * (1 + inputs.growth.rentGrowthAnnual);
const grossSale = forwardNOI / exitCap;
const netSale = grossSale * (1 - sellingCost);
// Same safety on the debt schedule.
const seniorBalance =
debt.years[y - 1]?.endBalance ?? debt.years[debt.years.length - 1].endBalance;
// Mezz balloon at exit — paid off out of sale proceeds after senior,
// before equity sees a dime. Zero when no mezz.
const mezzBalance =
mezz?.years[y - 1]?.endBalance ?? mezz?.years[mezz.years.length - 1]?.endBalance ?? 0;
cf += netSale - seniorBalance - mezzBalance;
}
cashFlows.push(cf);
}
const projectIRR = irr(cashFlows, 0.15);
// MIRR uses the preferred return as both finance and reinvestment rate:
// an LP can realistically reinvest distributions at their pref-rate
// ledger, not at the deal's headline IRR. When the deal's IRR is well
// above pref, MIRR will be materially below IRR — surfacing the
// "IRR overstatement" effect for the IC committee.
const projectMIRR = mirr(cashFlows, inputs.waterfall.preferredReturn, inputs.waterfall.preferredReturn);
const totalDistribution = cashFlows.slice(1).filter(v => v > 0).reduce((a, b) => a + b, 0);
const totalContribution = -cashFlows[0];
const totalProfit = totalDistribution - totalContribution;
const projectMoIC = totalDistribution / totalContribution;
// 4-tier waterfall, simplified single-distribution at exit (matching the
// canonical workbook). We compute each tier's THEORETICAL hurdle amount
// (the dollar amount that would carry the LP from the previous tier's IRR
// to this tier's IRR over the hold), then distribute available profit in
// priority order. Each tier is capped by remaining profit so the four
// tiers sum to max(0, totalProfit) — a losing deal distributes zero
// profit, instead of awarding the LP a phantom pref on a deal that
// actually lost money.
const tier1Theoretical = totalContribution * (Math.pow(1 + inputs.waterfall.preferredReturn, hold) - 1);
const tier2Theoretical =
totalContribution * (Math.pow(1 + inputs.waterfall.tier2Hurdle, hold) - 1) - tier1Theoretical;
const tier3Theoretical =
totalContribution * (Math.pow(1 + inputs.waterfall.tier3Hurdle, hold) - 1) -
tier1Theoretical -
tier2Theoretical;
let remaining = Math.max(0, totalProfit);
const tier1Amount = Math.min(remaining, Math.max(0, tier1Theoretical));
remaining -= tier1Amount;
const tier2Amount = Math.min(remaining, Math.max(0, tier2Theoretical));
remaining -= tier2Amount;
const tier3Amount = Math.min(remaining, Math.max(0, tier3Theoretical));
remaining -= tier3Amount;
const tier4Amount = Math.max(0, remaining);
const tier1 = makeTier(`Tier 1: ${pct(inputs.waterfall.preferredReturn)} Preferred Return`, tier1Amount, TIER1_SPLIT);
const tier2 = makeTier(`Tier 2: Pref to ${pct(inputs.waterfall.tier2Hurdle)} IRR`, tier2Amount, TIER2_SPLIT);
const tier3 = makeTier(`Tier 3: ${pct(inputs.waterfall.tier2Hurdle)} to ${pct(inputs.waterfall.tier3Hurdle)} IRR`, tier3Amount, TIER3_SPLIT);
const tier4 = makeTier(`Tier 4: > ${pct(inputs.waterfall.tier3Hurdle)} IRR (residual)`, tier4Amount, TIER4_SPLIT);
const tiers = [tier1, tier2, tier3, tier4];
const totalLP = tiers.reduce((acc, t) => acc + t.lpShare, 0);
const totalGP = tiers.reduce((acc, t) => acc + t.gpShare, 0);
// GP co-invest split. When the user sets a non-zero gpCoinvestPct, both
// LP and GP contribute to the equity check in proportion to that pct,
// and each receives their pari-passu share of distributions PLUS the
// GP receives the carry (the "GP split" portion of each tier).
//
// For per-year cash flows, we apply the same split to operating CF
// (treat operating CF as a pro-rata distribution between LP and GP
// weighted by their capital contribution — no promote on operating
// CF in the simplified model). The tier-based promote applies only
// to the exit distribution; we allocate it to the LP/GP based on the
// tier mechanics above. Sum-check: lpCF + gpCF == equityCashFlows.
const coinvestPctRaw = inputs.waterfall.gpCoinvestPct;
const coinvestPct =
Number.isFinite(coinvestPctRaw) && (coinvestPctRaw ?? 0) > 0
? Math.min(0.5, Math.max(0, coinvestPctRaw ?? 0))
: 0;
let gpCoinvest: Waterfall["gpCoinvest"] = null;
if (coinvestPct > 0) {
const lpContribution = equity * (1 - coinvestPct);
const gpContribution = equity * coinvestPct;
// Pro-rata operating split: each year's CF (years 1..hold-1) flows
// pari-passu between LP and GP based on their capital share. At
// exit (year=hold), the residual equity CF is allocated via the
// tier mechanics: LP receives the LP-split portion (× 1 - coinvest),
// GP receives the GP-split portion + coinvest × LP-split portion.
const lpFlows: number[] = [-lpContribution];
const gpFlows: number[] = [-gpContribution];
for (let y = 1; y < hold; y++) {
const yCF = cashFlows[y] ?? 0;
lpFlows.push(yCF * (1 - coinvestPct));
gpFlows.push(yCF * coinvestPct);
}
// Exit year: split operating CF pro-rata; split the tier-distributed
// exit profit per the tier mechanics. The exit-year CF = operating
// CF + (netSale - debt balances). We can't easily disentangle
// operating-vs-exit here; for institutional realism we treat the
// full year-hold CF as the "promote distribution" — the LP/GP carry
// mechanics apply.
if (hold > 0) {
// Total LP/GP shares from the 4-tier waterfall. The tier mechanics
// already split tier amounts × split[0]/split[1]. We need to
// additionally allocate the LP portion to the GP based on coinvest.
// LP receives: (1 - coinvestPct) × tier-LP-share
// GP receives: tier-GP-share + coinvestPct × tier-LP-share
// But these sum to the same totalProfit; the EXIT-YEAR CF is what
// delivers all of this. We treat the exit-year CF as the promote
// distribution event.
const exitYearCF = cashFlows[hold] ?? 0;
// Of that, we need to split it. The total LP+GP profit = totalProfit
// = sum of LP + GP shares from tiers. The exit CF, however, also
// includes the return of equity contribution itself (capital back).
// For the per-year LP/GP split, the simplest equitable approach:
// - Return of LP capital (lpContribution) → LP gets back their
// contribution out of the exit CF, pro-rata at first.
// - Return of GP capital (gpContribution) → GP gets back theirs.
// - Remaining exit CF = the promote distribution. Split using
// the tier mechanics: lpShareOfRemaining / gpShareOfRemaining
// = totalLP / totalGP (from the tier table).
const remaining = exitYearCF - equity; // total profit at exit
const promoteLP = totalLP;
const promoteGP = totalGP;
const promoteSum = promoteLP + promoteGP;
let exitLP: number;
let exitGP: number;
if (promoteSum > 0 && remaining > 0) {
// LP gets: lpContribution back + (1 - coinvestPct) × promoteLP
// GP gets: gpContribution back + (coinvestPct × promoteLP) + promoteGP
exitLP = lpContribution + (1 - coinvestPct) * promoteLP;
exitGP = gpContribution + coinvestPct * promoteLP + promoteGP;
// Rebalance so exitLP + exitGP exactly equals exitYearCF
// (small float drift can creep in from the tier-amount rounding).
const totalAllocated = exitLP + exitGP;
if (Math.abs(totalAllocated - exitYearCF) > 1e-6) {
const scale = exitYearCF / totalAllocated;
exitLP *= scale;
exitGP *= scale;
}
} else {
// Losing or break-even deal: just split pro-rata, no promote.
exitLP = exitYearCF * (1 - coinvestPct);
exitGP = exitYearCF * coinvestPct;
}
// Subtract operating-pro-rata portion that's already been distributed
// — actually no, we haven't pushed the exit-year CF yet. Push it now.
// Wait, the loop above only ran to hold-1. We need exit at index hold.
lpFlows.push(exitLP);
gpFlows.push(exitGP);
}
const lpIRR = irr(lpFlows, 0.15);
const gpIRR = irr(gpFlows, 0.20);
const lpDistribution = lpFlows.slice(1).filter((v) => v > 0).reduce((a, b) => a + b, 0);
const gpDistribution = gpFlows.slice(1).filter((v) => v > 0).reduce((a, b) => a + b, 0);
const lpMoIC = lpContribution > 0 ? lpDistribution / lpContribution : Number.NaN;
const gpMoIC = gpContribution > 0 ? gpDistribution / gpContribution : Number.NaN;
gpCoinvest = {
gpCoinvestPct: coinvestPct,
lpContribution,
gpContribution,
lpDistribution,
gpDistribution,
lpMoIC,
gpMoIC,
lpCashFlows: lpFlows,
gpCashFlows: gpFlows,
lpIRR,
gpIRR,
};
}
return {
equityCashFlows: cashFlows,
projectIRR,
projectMIRR,
projectMoIC,
totalDistribution,
totalContribution,
totalProfit,
tiers,
totalLP,
totalGP,
gpCoinvest,
};
}
function makeTier(label: string, amount: number, split: [number, number]): WaterfallTier {
return {
label,
hurdleAmount: amount,
lpShare: amount * split[0],
gpShare: amount * split[1],
lpSplitPct: split[0],
gpSplitPct: split[1],
};
}
function pct(v: number): string {
// 1 decimal place, trailing '.0' trimmed so integer hurdles render as '8%'
// and fractional hurdles render as '8.5%' rather than losing precision to
// a flat .toFixed(0). Avoids labels like 'Pref to 13% IRR' when the actual
// hurdle is 12.5%.
const formatted = (v * 100).toFixed(1);
return `${formatted.replace(/\.0$/, "")}%`;
}
What to look at: The hurdle amounts compound over the FULL hold (not year-by-year catchup). Single distribution at exit. Tiers cap at total profit so the math can't overpay across the four tiers combined. Annual interim distributions are aggregated into the IRR cash flows but not used to step LPs through hurdles year by year. That simplification is flagged on /methodology/beyond-phase-1.