← 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 };
});