← 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
M 5x/health-check.shA 5x/rentrolls-assert.jsM data/rent-rolls/.gitignoreA data/rent-rolls/SAMPLE-12unit-demo.jsonM public/crcp.htmlM public/deals.htmlM public/index.htmlM public/map.htmlA public/rent-rolls.htmlM scripts/rent-roll-ingest.jsM scripts/serve.js
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 →