[object Object]

← back to Commercialrealestate

Build rent-roll → DSCR underwriting: /api/rent-rolls endpoint + rent-rolls.html viewer (unit table + underwriting tiles + live DSCR calculator: NOI/debt-service, verdict vs target, max-loan-at-target). Parser fix: money-formatted-rent only (no phantom unit from street#/zip). Strong gate (PASS) + health-check + nav links. Labeled DEMO rent roll included

157d835073f44b8c3dd6a4b252f912390aae75db · 2026-06-29 05:59:20 -0700 · Steve

Files touched

Diff

commit 157d835073f44b8c3dd6a4b252f912390aae75db
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 05:59:20 2026 -0700

    Build rent-roll → DSCR underwriting: /api/rent-rolls endpoint + rent-rolls.html viewer (unit table + underwriting tiles + live DSCR calculator: NOI/debt-service, verdict vs target, max-loan-at-target). Parser fix: money-formatted-rent only (no phantom unit from street#/zip). Strong gate (PASS) + health-check + nav links. Labeled DEMO rent roll included
---
 5x/health-check.sh                      |   2 +-
 5x/rentrolls-assert.js                  |  34 ++++++++
 data/rent-rolls/.gitignore              |   3 +-
 data/rent-rolls/SAMPLE-12unit-demo.json | 111 +++++++++++++++++++++++++
 public/crcp.html                        |   2 +-
 public/deals.html                       |   2 +-
 public/index.html                       |   1 +
 public/map.html                         |   2 +-
 public/rent-rolls.html                  | 138 ++++++++++++++++++++++++++++++++
 scripts/rent-roll-ingest.js             |   4 +-
 scripts/serve.js                        |  17 ++++
 11 files changed, 310 insertions(+), 6 deletions(-)

diff --git a/5x/health-check.sh b/5x/health-check.sh
index 1ccf214..ab3fc9d 100755
--- a/5x/health-check.sh
+++ b/5x/health-check.sh
@@ -5,7 +5,7 @@ cd "$(dirname "$0")/.."
 code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9911/crcp.html 2>/dev/null)
 [ "$code" = "200" ] || { echo "SERVER DOWN ($code) — restart: node scripts/serve.js &"; exit 1; }
 fail=0
-for g in deals map crcp sales brokers index; do
+for g in deals map crcp sales brokers index rentrolls; do
   if node "5x/$g-assert.js" >/dev/null 2>&1; then echo "  $g-assert: PASS"; else echo "  $g-assert: FAIL"; fail=1; fi
 done
 [ $fail = 0 ] && echo "ALL GATES PASS — healthy" || echo "REGRESSION DETECTED"
diff --git a/5x/rentrolls-assert.js b/5x/rentrolls-assert.js
new file mode 100644
index 0000000..d40e097
--- /dev/null
+++ b/5x/rentrolls-assert.js
@@ -0,0 +1,34 @@
+// Strong rent-rolls.html gate: rolls render with unit table + NOI, DSCR calculator computes a real number,
+// changing an input recomputes DSCR, no NaN; empty-data shows the empty state with no error.
+const pw=require('/Users/stevestudio2/.claude/skills/browserbase/node_modules/playwright-core');
+const EXEC='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+async function run(mockEmpty){
+  const errors=[]; const b=await pw.chromium.launch({executablePath:EXEC,headless:true});
+  const pg=await b.newPage({viewport:{width:1200,height:1000}});
+  pg.on('pageerror',e=>errors.push('PAGEERR: '+e.message));
+  pg.on('console',m=>{if(m.type()==='error'&&!/Failed to load resource/.test(m.text()))errors.push('CONSOLE: '+m.text());});
+  if(mockEmpty) await pg.route('**/api/rent-rolls',r=>r.fulfill({status:200,contentType:'application/json',body:'{"rentRolls":[],"count":0}'}));
+  await pg.goto('http://127.0.0.1:9911/rent-rolls.html',{waitUntil:'networkidle'}); await pg.waitForTimeout(1200);
+  const base=await pg.evaluate(()=>({
+    rolls:document.querySelectorAll('.roll').length, unitRows:document.querySelectorAll('.roll tbody tr').length,
+    empty:!!document.querySelector('.empty'),
+    dscr:document.querySelector('.dscrbig')?.textContent||null,
+    anyNaN:/NaN|\$NaN|undefined/.test(document.body.textContent) }));
+  let recalcOk=null;
+  if(!mockEmpty){
+    recalcOk=await pg.evaluate(async()=>{ const before=document.querySelector('.dscrbig')?.textContent;
+      const rate=document.querySelector('[data-f="rate"]'); if(!rate) return null;
+      rate.value=String((parseFloat(rate.value)||6)+2); rate.dispatchEvent(new Event('input'));
+      await new Promise(r=>setTimeout(r,250)); return { before, after:document.querySelector('.dscrbig')?.textContent, changed:before!==document.querySelector('.dscrbig')?.textContent }; });
+  }
+  await b.close();
+  return { mockEmpty, ...base, recalcOk, jsErrors:errors };
+}
+(async()=>{
+  const normal=await run(false); const empty=await run(true);
+  const passNormal = normal.rolls>0 && normal.unitRows>0 && !normal.anyNaN && /\d\.\d/.test(normal.dscr||'')
+    && normal.recalcOk && normal.recalcOk.changed && normal.jsErrors.length===0;
+  const passEmpty = empty.empty && !empty.anyNaN && empty.jsErrors.length===0;   // empty state shown, no NaN/error
+  console.log(JSON.stringify({normal,empty,passNormal,passEmpty,VERDICT:(passNormal&&passEmpty)?'PASS':'FAIL'},null,2));
+  process.exit((passNormal&&passEmpty)?0:1);
+})().catch(e=>{console.error('FAIL',e.message);process.exit(1);});
diff --git a/data/rent-rolls/.gitignore b/data/rent-rolls/.gitignore
index d1d142b..2e36bec 100644
--- a/data/rent-rolls/.gitignore
+++ b/data/rent-rolls/.gitignore
@@ -1,4 +1,5 @@
-# rent-roll PDFs are confidential (CA-gated). Drop them here; they are NOT committed.
+# rent-roll PDFs + parsed JSON are confidential (CA-gated). Drop here; NOT committed.
 *.pdf
 *.json
+!SAMPLE-*.json
 !.gitkeep
diff --git a/data/rent-rolls/SAMPLE-12unit-demo.json b/data/rent-rolls/SAMPLE-12unit-demo.json
new file mode 100644
index 0000000..49cbb4a
--- /dev/null
+++ b/data/rent-rolls/SAMPLE-12unit-demo.json
@@ -0,0 +1,111 @@
+{
+  "source": "SAMPLE-12unit-1234-Demo-Ave.pdf",
+  "isSample": true,
+  "parsedAt": "2026-06-29T00:00:00Z",
+  "property": {
+    "address": "1234 Demo Ave",
+    "city": "Los Angeles",
+    "zip": "90042"
+  },
+  "units": [
+    {
+      "unit": "101",
+      "rent": 2450,
+      "marketRent": 2450,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "102",
+      "rent": 2395,
+      "marketRent": 2395,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "103",
+      "rent": 0,
+      "marketRent": 1950,
+      "status": "vacant",
+      "beds": 1
+    },
+    {
+      "unit": "104",
+      "rent": 1895,
+      "marketRent": 1895,
+      "status": "occupied",
+      "beds": 1
+    },
+    {
+      "unit": "105",
+      "rent": 2795,
+      "marketRent": 2795,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "106",
+      "rent": 2750,
+      "marketRent": 2750,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "201",
+      "rent": 2500,
+      "marketRent": 2500,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "202",
+      "rent": 2450,
+      "marketRent": 2450,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "203",
+      "rent": 0,
+      "marketRent": 1975,
+      "status": "vacant",
+      "beds": 1
+    },
+    {
+      "unit": "204",
+      "rent": 1925,
+      "marketRent": 1925,
+      "status": "occupied",
+      "beds": 1
+    },
+    {
+      "unit": "205",
+      "rent": 2850,
+      "marketRent": 2850,
+      "status": "occupied",
+      "beds": 2
+    },
+    {
+      "unit": "206",
+      "rent": 2800,
+      "marketRent": 2800,
+      "status": "occupied",
+      "beds": 2
+    }
+  ],
+  "underwriting": {
+    "unitCount": 12,
+    "occupied": 10,
+    "vacant": 2,
+    "vacancyRate": 16.7,
+    "grossScheduledRentMonthly": 28735,
+    "grossScheduledRentAnnual": 344820,
+    "inPlaceRentMonthly": 24810,
+    "inPlaceRentAnnual": 297720,
+    "avgRentPerUnit": 2481,
+    "noiProxyAnnual": 178632,
+    "noiBasis": "in-place rent × (1 − 0.4 expense ratio); replace with the OM's actual T-12 when available",
+    "confidence": "parsed from rent-roll PDF — verify unit count + totals against the document before underwriting"
+  },
+  "note": "DEMO rent roll (synthetic) — illustrates parse→underwrite. Replace by dropping a real rent-roll PDF into data/rent-rolls/ + running scripts/rent-roll-ingest.js"
+}
\ No newline at end of file
diff --git a/public/crcp.html b/public/crcp.html
index 55cebed..bed0bb1 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -82,7 +82,7 @@
   <h1><b>CRCP</b> · Commercial Real Estate Control Panel</h1>
   <span class="live"><span class="pulse"></span><span id="livetxt">live</span></span>
   <nav class="nav">
-    <a href="/">Properties</a><a href="/map.html">Map</a><a href="/deals.html">Deals</a><a href="/brokers.html">Mind-map</a><a href="/graphics.html">Graphics</a><a href="/condos.html">Condos</a>
+    <a href="/">Properties</a><a href="/map.html">Map</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/graphics.html">Graphics</a><a href="/condos.html">Condos</a>
     <span class="denslider" title="chip / tile density">⊞ <input type="range" id="dens" min="120" max="300" value="150" aria-label="Tile density"></span>
     <button class="expandall" id="expandall">Expand all ▾</button>
   </nav>
diff --git a/public/deals.html b/public/deals.html
index b73a3e4..52c9306 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -94,7 +94,7 @@
       </span>
     </div>
     <nav>
-      <a href="/">Properties</a><a href="/map.html">Map</a><a href="/crcp.html">CRCP</a>
+      <a href="/">Properties</a><a href="/map.html">Map</a><a href="/crcp.html">CRCP</a><a href="/rent-rolls.html">Rent Rolls</a>
       <a href="/brokers.html">Mind-map</a><a href="/sales.html">Sales</a>
     </nav>
   </header>
diff --git a/public/index.html b/public/index.html
index 5fd7994..6d8c6a6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -124,6 +124,7 @@
   <a href="/crcp.html" style="color:var(--warn);text-decoration:none;font-size:13px;margin-right:12px" title="Commercial Real Estate Control Panel (live)">🛰️ CRCP</a>
   <a href="/map.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="All properties on a map, colored by type, with overhead aerial photos">🗺️ Map</a>
   <a href="/deals.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Liquid-glass deal-intelligence charts">◆ Deals</a>
+  <a href="/rent-rolls.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Rent-roll → DSCR underwriting">📑 Rent Rolls</a>
   <a href="/brokers.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / agent relationship mind-map">🕸️ Broker map</a>
   <a href="/graphics.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / firm / warrantability charts">📊 Graphics</a>
   <a href="/condos.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="LA condos — FHA warrantability">🏢 Condos</a>
diff --git a/public/map.html b/public/map.html
index 98a3d23..6ab15b0 100644
--- a/public/map.html
+++ b/public/map.html
@@ -39,7 +39,7 @@
 <body>
 <div class="topbar">
   <b>🗺️ Property Map</b> · LA County <span id="count">loading…</span>
-  <nav class="nav"><a href="/">Properties</a><a href="/crcp.html">CRCP</a><a href="/deals.html">Deals</a><a href="/brokers.html">Mind-map</a><a href="/sales.html">Sales</a></nav>
+  <nav class="nav"><a href="/">Properties</a><a href="/crcp.html">CRCP</a><a href="/deals.html">Deals</a><a href="/rent-rolls.html">Rent Rolls</a><a href="/brokers.html">Mind-map</a><a href="/sales.html">Sales</a></nav>
 </div>
 <div id="map"></div>
 <div class="legend"><h4>Property type</h4><div id="legend"></div></div>
diff --git a/public/rent-rolls.html b/public/rent-rolls.html
new file mode 100644
index 0000000..b2652c7
--- /dev/null
+++ b/public/rent-rolls.html
@@ -0,0 +1,138 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
+<title>Rent Rolls → DSCR — LA County CRE</title>
+<style>
+  :root{--gold:#e9c46a;--teal:#2dd4bf;--blue:#60a5fa;--violet:#8b7cf6;--pink:#f472b6;--ink:#eef1f7;--muted:#9aa3b4;
+    --glass:rgba(255,255,255,.055);--glassb:rgba(255,255,255,.12);--ok:#34d399;--warn:#fb923c;--bad:#f87171;}
+  *{box-sizing:border-box;margin:0;padding:0;}
+  html,body{min-height:100%;background:#070a10;color:var(--ink);font:15px/1.5 -apple-system,'SF Pro Display',Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased;}
+  body::before{content:'';position:fixed;inset:-25%;z-index:-2;background:
+    radial-gradient(38vw 38vw at 16% 20%,rgba(45,212,191,.34),transparent 60%),
+    radial-gradient(34vw 34vw at 84% 16%,rgba(139,124,246,.32),transparent 60%),
+    radial-gradient(40vw 40vw at 74% 88%,rgba(233,196,106,.28),transparent 62%);filter:saturate(1.2);animation:drift 28s ease-in-out infinite alternate;}
+  body::after{content:'';position:fixed;inset:0;z-index:-1;backdrop-filter:blur(70px);background:rgba(7,10,16,.36);}
+  @keyframes drift{0%{transform:translate3d(0,0,0) scale(1);}100%{transform:translate3d(-2%,2%,0) scale(1.06);}}
+  @media(prefers-reduced-motion:reduce){body::before{animation:none;}.glass{animation:none;opacity:1;transform:none;}}
+  .wrap{max-width:1180px;margin:0 auto;padding:22px 22px 60px;}
+  header{display:flex;align-items:center;gap:16px;margin-bottom:20px;flex-wrap:wrap;}
+  h1{font-size:25px;font-weight:800;letter-spacing:-.5px;background:linear-gradient(92deg,#fff,var(--gold) 60%,var(--teal));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
+  .sub{font-size:12.5px;color:var(--muted);margin-top:2px;}
+  nav{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap;}
+  nav a{font-size:12.5px;color:var(--muted);text-decoration:none;padding:7px 13px;border-radius:13px;background:var(--glass);border:1px solid var(--glassb);backdrop-filter:blur(14px);}
+  nav a:hover{color:#fff;border-color:rgba(233,196,106,.5);}
+  .glass{background:var(--glass);border:1px solid var(--glassb);border-radius:20px;backdrop-filter:blur(22px) saturate(1.5);
+    box-shadow:0 12px 40px rgba(0,0,0,.4),inset 0 1px 0 rgba(255,255,255,.16);overflow:hidden;}
+  .roll{padding:20px 22px;margin-bottom:18px;}
+  .roll h2{font-size:18px;font-weight:700;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
+  .badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:9px;background:rgba(251,146,60,.18);color:var(--warn);border:1px solid rgba(251,146,60,.4);}
+  .addr{font-size:12.5px;color:var(--muted);margin-top:2px;}
+  .uwgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:12px;margin:16px 0;}
+  .uw{background:#0a0d13;border:1px solid var(--glassb);border-radius:12px;padding:12px 13px;}
+  .uw .k{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);}
+  .uw .v{font-size:21px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums;}
+  .uw .v.gold{color:var(--gold);} .uw .v.teal{color:var(--teal);} .uw .v.blue{color:var(--blue);}
+  .cols{display:grid;grid-template-columns:1.1fr 1fr;gap:18px;margin-top:8px;}
+  @media(max-width:820px){.cols{grid-template-columns:1fr;}}
+  table{width:100%;border-collapse:collapse;font-size:13px;}
+  th,td{text-align:left;padding:6px 8px;border-top:1px solid var(--glassb);}
+  th{font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--muted);border-top:0;}
+  td.n{text-align:right;font-variant-numeric:tabular-nums;}
+  .vac{color:var(--warn);} .occ{color:var(--ok);}
+  .sec h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:10px;}
+  .calc label{display:block;font-size:11px;color:var(--muted);margin:9px 0 3px;}
+  .calc input{width:100%;background:#0a0d13;border:1px solid var(--glassb);border-radius:9px;padding:8px 10px;color:var(--ink);font-size:14px;font-variant-numeric:tabular-nums;}
+  .calc .row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
+  .dscrout{margin-top:14px;padding:14px;border-radius:12px;background:#0a0d13;border:1px solid var(--glassb);}
+  .dscrbig{font-size:34px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1;}
+  .dscrbig.pass{color:var(--ok);} .dscrbig.lean{color:var(--warn);} .dscrbig.fail{color:var(--bad);}
+  .dline{display:flex;justify-content:space-between;font-size:13px;margin-top:8px;color:var(--muted);} .dline b{color:var(--ink);font-variant-numeric:tabular-nums;}
+  .verdict{font-size:12px;margin-top:8px;} .note{font-size:11px;color:var(--muted);margin-top:12px;line-height:1.6;}
+  .empty{padding:50px 24px;text-align:center;color:var(--muted);} .empty code{background:#0a0d13;padding:2px 7px;border-radius:6px;color:var(--gold);}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header>
+    <div><h1>📑 Rent Rolls → DSCR</h1><div class="sub" id="sub">document-grade underwriting from parsed rent rolls · local · $0</div></div>
+    <nav><a href="/">Properties</a><a href="/deals.html">Deals</a><a href="/map.html">Map</a><a href="/crcp.html">CRCP</a></nav>
+  </header>
+  <div id="rolls"></div>
+</div>
+<script>
+const M = n => (n==null||!isFinite(n)) ? '—' : '$'+Math.round(n).toLocaleString();
+const PCT = n => (n==null||!isFinite(n)) ? '—' : n.toFixed(1)+'%';
+// amortizing payment + annual debt service constant
+function pmt(L, ratePct, years){ const c=ratePct/100/12, n=years*12; return c===0 ? L/n : L*c/(1-Math.pow(1+c,-n)); }
+function annualConstant(ratePct, years){ return pmt(1, ratePct, years)*12; }
+
+function rollHtml(r, i){
+  const uw = r.underwriting||{};
+  const noi = uw.noiProxyAnnual||0, inplace = uw.inPlaceRentAnnual||0;
+  const units = (r.units||[]);
+  const unitRows = units.map(u=>`<tr><td>${u.unit||'—'}</td><td>${u.beds?u.beds+'bd':'—'}</td>
+    <td class="${u.status==='vacant'?'vac':'occ'}">${u.status||'—'}</td><td class="n">${u.marketRent?M(u.marketRent):'—'}</td></tr>`).join('');
+  return `<div class="glass roll" data-i="${i}">
+    <h2>${(r.property?.address)||r.source||'Rent roll'} ${r.isSample?'<span class="badge">demo</span>':''}</h2>
+    <div class="addr">${[r.property?.city,r.property?.zip].filter(Boolean).join(' ')} · ${r.unitCount||units.length} units · parsed from ${r.source||'PDF'}</div>
+    <div class="uwgrid">
+      <div class="uw"><div class="k">Units</div><div class="v">${uw.unitCount||units.length}</div></div>
+      <div class="uw"><div class="k">Occupied</div><div class="v teal">${uw.occupied??'—'} <span style="font-size:12px;color:var(--muted)">/ ${uw.vacant??0} vac</span></div></div>
+      <div class="uw"><div class="k">Gross sched. rent</div><div class="v">${M(uw.grossScheduledRentAnnual)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
+      <div class="uw"><div class="k">In-place rent</div><div class="v gold">${M(inplace)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
+      <div class="uw"><div class="k">NOI (proxy)</div><div class="v blue">${M(noi)}<span style="font-size:11px;color:var(--muted)">/yr</span></div></div>
+      <div class="uw"><div class="k">Vacancy</div><div class="v">${PCT(uw.vacancyRate)}</div></div>
+    </div>
+    <div class="cols">
+      <div class="sec"><h3>Unit-by-unit</h3>
+        <table><thead><tr><th>Unit</th><th>Beds</th><th>Status</th><th class="n">Market rent</th></tr></thead><tbody>${unitRows}</tbody></table>
+      </div>
+      <div class="sec calc"><h3>DSCR calculator</h3>
+        <label>Net operating income (NOI, annual)</label><input type="number" data-f="noi" value="${noi}">
+        <div class="row"><div><label>Loan amount</label><input type="number" data-f="loan" value="${Math.round(noi/0.085/1000)*1000}"></div>
+          <div><label>Interest rate %</label><input type="number" step="0.05" data-f="rate" value="6.75"></div></div>
+        <div class="row"><div><label>Amortization (yrs)</label><input type="number" data-f="amort" value="30"></div>
+          <div><label>Target DSCR</label><input type="number" step="0.05" data-f="target" value="1.25"></div></div>
+        <div class="dscrout" data-out>—</div>
+      </div>
+    </div>
+    <div class="note">${uw.noiBasis?('NOI basis: '+uw.noiBasis+'. '):''}${r.note||''} DSCR = NOI ÷ annual debt service. Not an appraisal or a lending commitment.</div>
+  </div>`;
+}
+
+function recalc(roll){
+  const g = f => parseFloat(roll.querySelector(`[data-f="${f}"]`).value)||0;
+  const noi=g('noi'), loan=g('loan'), rate=g('rate'), amort=g('amort'), target=g('target')||1.25;
+  const ads = pmt(loan, rate, amort)*12;                       // annual debt service
+  const dscr = ads>0 ? noi/ads : 0;
+  const maxLoan = annualConstant(rate,amort)>0 ? (noi/target)/annualConstant(rate,amort) : 0;
+  const cls = dscr>=target ? 'pass' : dscr>=1.0 ? 'lean' : 'fail';
+  const verdict = dscr>=target ? `✓ Clears the ${target.toFixed(2)} target` : dscr>=1.0 ? `⚠ Covers debt but under the ${target.toFixed(2)} target` : `✗ NOI does not cover debt service`;
+  roll.querySelector('[data-out]').innerHTML =
+    `<div class="dscrbig ${cls}">${dscr?dscr.toFixed(2):'—'}<span style="font-size:13px;color:var(--muted);font-weight:400"> DSCR</span></div>
+     <div class="verdict ${cls==='pass'?'occ':cls==='fail'?'vac':''}" style="color:${cls==='pass'?'var(--ok)':cls==='fail'?'var(--bad)':'var(--warn)'}">${verdict}</div>
+     <div class="dline">Annual debt service <b>${M(ads)}</b></div>
+     <div class="dline">Monthly payment <b>${M(ads/12)}</b></div>
+     <div class="dline">Max loan @ ${target.toFixed(2)} DSCR <b>${M(maxLoan)}</b></div>`;
+}
+
+fetch('/api/rent-rolls').then(r=>r.json()).then(d=>{
+  const rolls=d.rentRolls||[];
+  const host=document.getElementById('rolls');
+  if(!rolls.length){ host.innerHTML=`<div class="glass empty">No rent rolls parsed yet.<br><br>
+    Drop a rent-roll PDF into <code>data/rent-rolls/</code> and run <code>node scripts/rent-roll-ingest.js</code>.<br>
+    It parses the unit/rent table locally ($0) and it shows up here with a live DSCR calculator.<br><br>
+    <span style="font-size:11px">Rent rolls are confidential — they're read locally, never uploaded or served as PDFs.</span></div>`; return; }
+  document.getElementById('sub').textContent=`${rolls.length} rent roll${rolls.length>1?'s':''} · document-grade NOI → DSCR · local · $0`;
+  host.innerHTML=rolls.map(rollHtml).join('');
+  document.querySelectorAll('.roll').forEach(roll=>{
+    recalc(roll);
+    roll.querySelectorAll('input').forEach(inp=>inp.addEventListener('input',()=>recalc(roll)));
+  });
+}).catch(e=>{ document.getElementById('rolls').innerHTML='<div class="glass empty">Failed to load rent rolls: '+e.message+'</div>'; });
+</script>
+</body>
+</html>
diff --git a/scripts/rent-roll-ingest.js b/scripts/rent-roll-ingest.js
index 7290bd9..801dc02 100644
--- a/scripts/rent-roll-ingest.js
+++ b/scripts/rent-roll-ingest.js
@@ -48,7 +48,9 @@ function parseRows(text) {
     const line = raw.replace(/\s+$/, '');
     if (!line.trim()) continue;
     if (/\b(total|subtotal|grand total|gross|scheduled|summary|average|vacancy)\b/i.test(line)) continue; // skip aggregate lines
-    const dollars = (line.match(/\$?\s*[\d,]+(?:\.\d{2})?/g) || [])
+    // Only count MONEY-FORMATTED figures ($-prefixed, thousands-comma, or .dd) as rent — a bare integer like a
+    // street number ("1234 Demo Ave") or zip ("90042") in a title line is NOT a rent. Prevents phantom units.
+    const dollars = (line.match(/\$\s*[\d,]+(?:\.\d{2})?|\b\d{1,3},\d{3}(?:\.\d{2})?\b|\b\d+\.\d{2}\b/g) || [])
       .map(money).filter(n => n && n >= RENT_LO && n <= RENT_HI);
     if (!dollars.length) continue;
     // unit id: a label word + identifier (Unit 101 / Apt A-12 / Suite 200 / #4) if present, else the leading token.
diff --git a/scripts/serve.js b/scripts/serve.js
index 9a359cc..0125ab6 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -556,6 +556,23 @@ app.get('/api/segment', async (req, res) => {
   } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0] }); }
 });
 
+// Rent rolls — list the parsed sidecars (data/rent-rolls/*.json) the ingester produces. Read-only.
+// Document-grade NOI for the DSCR calculator, vs the Census-proxy rent estimate. No PDFs are served (confidential).
+app.get('/api/rent-rolls', (req, res) => {
+  try {
+    const dir = path.join(ROOT, 'data', 'rent-rolls');
+    if (!fs.existsSync(dir)) return res.json({ rentRolls: [] });
+    const rolls = fs.readdirSync(dir).filter(f => /\.json$/i.test(f)).map(f => {
+      try { const j = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
+        return { file: f, source: j.source || f, isSample: !!j.isSample, property: j.property || null,
+          parsedAt: j.parsedAt || null, unitCount: j.units ? j.units.length : (j.underwriting?.unitCount || 0),
+          underwriting: j.underwriting || null, units: j.units || [], note: j.note || null }; }
+      catch (_) { return null; }
+    }).filter(Boolean).sort((a, b) => (b.parsedAt || '').localeCompare(a.parsedAt || ''));
+    res.json({ rentRolls: rolls, count: rolls.length });
+  } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0] }); }
+});
+
 app.use('/data', express.static(path.join(ROOT, 'data')));
 app.use('/', express.static(path.join(ROOT, 'public')));
 

← ec34869 weekly Crexi refresh 2026-06-29  ·  back to Commercialrealestate  ·  Surface assumable-FHA/VA heuristic on the map: bake assumabl 22f626a →