← back to Commercialrealestate
public/finance.js
72 lines
// finance.js — shared leverage math for the SFV CRE ranker.
// Works in Node (require) and the browser (global CREFin). Single source of truth so the
// viewer can recompute any down-payment scenario live and match the Node ranking exactly.
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.CREFin = factory();
})(typeof self !== 'undefined' ? self : this, function () {
function debtService(loan, ratePct, amortYears) {
if (loan <= 0) return 0;
const r = ratePct / 100 / 12, n = amortYears * 12;
if (r === 0) return loan / amortYears;
const m = loan * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
return m * 12;
}
// A = { budget, downPct, ratePct, amortYears, closingPct }
function finance(L, A) {
const down = L.price * A.downPct / 100;
const closing = L.price * A.closingPct / 100;
const cashNeeded = Math.round(down + closing);
const loan = Math.max(0, L.price - down);
const ads = (A.downPct >= 100 || loan <= 0) ? 0 : debtService(loan, A.ratePct, A.amortYears);
const noi = (L.cap_rate != null) ? Math.round(L.price * L.cap_rate / 100) : null;
let cashFlow = null, coc = null, dscr = null;
if (noi != null) {
cashFlow = Math.round(noi - ads);
coc = +(cashFlow / cashNeeded * 100).toFixed(2);
dscr = ads > 0 ? +(noi / ads).toFixed(2) : null; // null = all-cash (no debt)
}
const affordable = cashNeeded <= A.budget;
// Gross rent yield ("rent close to price") = annual gross rent / price, from ACTUAL rent data only.
let grossAnnual = null;
if (L.gross_actual) grossAnnual = L.gross_actual;
else if (L.gross_proforma) grossAnnual = L.gross_proforma;
else if (Array.isArray(L.rent_roll) && L.rent_roll.length) grossAnnual = L.rent_roll.reduce((s, u) => s + (u.rent || 0), 0) * 12;
const grossYield = grossAnnual ? +(grossAnnual / L.price * 100).toFixed(2) : null;
return { down: Math.round(down), closing: Math.round(closing), cashNeeded, loan: Math.round(loan),
annualDebtService: Math.round(ads), noi, cashFlow, coc, dscr, affordable, grossAnnual, grossYield };
}
// Deterministic finance score 0-100 (return-driven; honest about negative leverage)
function financeScore(L, f) {
let s = 50, conf = 'med';
if (f.coc != null) {
s = 30 + Math.max(-30, Math.min(45, f.coc * 5));
if (f.dscr != null) s += f.dscr >= 1.25 ? 8 : f.dscr >= 1.0 ? 0 : -15;
if (L.cap_rate != null) s += (L.cap_rate - 5) * 3;
conf = 'high';
} else { s = 38; conf = 'low'; } // NOI undisclosed -> can't score return; lowered base so unknown-NOI deals don't free-ride a neutral score above verified ones
if (!f.affordable) s -= 18;
if (/Under Contract|Pending/i.test(L.status || '')) s -= 25;
if (/OUT OF BUDGET/i.test(L.status || '')) s -= 30;
return { score: Math.max(0, Math.min(100, Math.round(s))), conf };
}
function composite(L, f, fScore, qwenScore) {
let c;
if (f.coc != null && qwenScore != null) c = Math.round(0.55 * fScore + 0.45 * qwenScore);
else if (qwenScore != null) c = Math.round(0.4 * fScore + 0.6 * qwenScore);
else c = fScore;
if (/Under Contract|Pending/i.test(L.status || '')) c = Math.round(c * 0.65);
if (!f.affordable) c = Math.round(c * 0.85);
// Unverified-NOI confidence penalty: a deal whose in-place return we can't confirm
// (cap undisclosed, or explicitly not verified) must not outrank verified economics.
if (L.cap_rate == null || L.verified === false) c = Math.round(c * 0.88);
return c;
}
return { debtService, finance, financeScore, composite };
});