[object Object]

← back to Commercialrealestate

P2 assumable-loan lead engine: crcp-leads.js /api/leads/assumable — ranks FHA below-market by assumable spread (342 CA targets), anon teaser / signed-in full; $0 public data

c998e22fa60a641061025c32e6a80ed7d67dd945 · 2026-07-31 10:55:16 -0700 · steve

Files touched

Diff

commit c998e22fa60a641061025c32e6a80ed7d67dd945
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Jul 31 10:55:16 2026 -0700

    P2 assumable-loan lead engine: crcp-leads.js /api/leads/assumable — ranks FHA below-market by assumable spread (342 CA targets), anon teaser / signed-in full; $0 public data
---
 scripts/crcp-leads.js | 44 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/serve.js      | 38 +++++++++++++++++++++++++++++++++++++-
 2 files changed, 81 insertions(+), 1 deletion(-)

diff --git a/scripts/crcp-leads.js b/scripts/crcp-leads.js
new file mode 100644
index 0000000..56f616d
--- /dev/null
+++ b/scripts/crcp-leads.js
@@ -0,0 +1,44 @@
+// crcp-leads.js — P2 assumable-loan LEAD ENGINE (docs/TOOL-SPEC.md). The Pro-tier money feature for a
+// loan officer: rank FHA-financed multifamily by ASSUMABLE ADVANTAGE (how far the loan's rate sits below
+// the market rate at origination — a buyer can assume that below-market note). 100% HUD public data, $0.
+// Mount from serve.js AFTER accounts:  require('./crcp-leads')(app, ROOT, acct.userOf);
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+module.exports = function mountLeads(app, ROOT, userOf) {
+  const FHA = path.join(ROOT, 'data', 'fha-loans.json');
+  const load = () => { try { return JSON.parse(fs.readFileSync(FHA, 'utf8')).rows || []; } catch { return []; } };
+
+  // GET /api/leads/assumable?state=CA&city=&limit=100&minSpread=1
+  // Ranked assumable-loan targets. Gated to signed-in users (it's the Pro feature); free tier gets a taste.
+  app.get('/api/leads/assumable', (req, res) => {
+    const u = userOf && userOf(req);
+    const rows = load();
+    const state = (req.query.state || 'CA').toUpperCase();
+    const city = (req.query.city || '').toUpperCase();
+    const minSpread = req.query.minSpread != null ? +req.query.minSpread : 0.5;
+    let leads = rows
+      .filter(r => r.rate != null && r.pmmsBenchmark != null && (!state || r.state === state) && (!city || String(r.city || '').toUpperCase() === city))
+      .map(r => ({
+        property: r.property, city: r.city, state: r.state, zip: r.zip, units: r.units,
+        loan_amount: r.originalAmount, rate: r.rate, market_at_origination: r.pmmsBenchmark,
+        assumable_spread: +(r.pmmsBenchmark - r.rate).toFixed(2),   // pts below market = the pitch
+        below_market: !!r.belowMarket, originated: r.originationDate, holder: r.holder,
+        source: 'HUD/FHA Multifamily Insured Mortgages (public, resellable)',
+      }))
+      .filter(l => l.assumable_spread >= minSpread)
+      .sort((a, b) => b.assumable_spread - a.assumable_spread);
+    const total = leads.length;
+    // Free/anon get a 5-row taste; signed-in get the full ranked list (capped by limit).
+    const paid = u && u.email;
+    const limit = paid ? Math.min(+req.query.limit || 200, 1000) : 5;
+    res.json({
+      total, returned: Math.min(total, limit), tier: paid ? (u.tier || 'free') : 'anon',
+      teaser: !paid, leads: leads.slice(0, limit),
+      note: paid ? undefined : 'Sign in for the full ranked assumable-lead list.',
+    });
+  });
+
+  console.log('[crcp-leads] assumable-loan lead engine mounted (/api/leads/assumable)');
+};
diff --git a/scripts/serve.js b/scripts/serve.js
index efa7607..5b5f2de 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -56,7 +56,7 @@ app.use((req, res, next) => {
 });
 
 // ── P1 subscription layer: accounts + saved searches + watchlist (docs/TOOL-SPEC.md) ──
-try { const acct = require('./crcp-accounts'); acct(app, ROOT); require('./crcp-billing')(app, ROOT, acct.userOf); } catch (e) { console.error('[crcp-accounts/billing] mount failed:', e.message); }
+try { const acct = require('./crcp-accounts'); acct(app, ROOT); require('./crcp-billing')(app, ROOT, acct.userOf); require('./crcp-leads')(app, ROOT, acct.userOf); } catch (e) { console.error('[crcp-accounts/billing/leads] mount failed:', e.message); }
 
 // ── Agent-contact CRM (durable, local JSON) ─────────────────────────────────────────
 // One record per listing id: editable {name,phone,email}, a `contacted_at` stamp, and an
@@ -731,6 +731,42 @@ app.get('/api/fha-condos', (req, res) => {
   } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0], condos: [] }); }
 });
 
+// Panel 7 — FHA approval EXPIRING within N months (default 12). Same static-snapshot idiom as
+// /api/fha-condos so it works locally AND on prod (no Postgres). The HUD expiration_date field is
+// dirty ("08/08/2026 (nearing expiration)", "06/14/2012 (expired)"), so we extract the MM/DD/YYYY
+// and ignore the suffix. Only currently-APPROVED projects with a FUTURE expiry inside the window
+// are returned (already-expired ones live in the fha_expired list, not here), soonest-first.
+// City names in the HUD source are ALL-CAPS and one is mangled ("CANADA"/zip 91011 = La Cañada
+// Flintridge — the "ñ" was lost); fixCity() title-cases and repairs that known case for display.
+function fixCity(city, zip) {
+  const raw = String(city || '').trim();
+  if (/^canada$/i.test(raw) || zip === '91011') return 'La Cañada Flintridge';
+  return raw.replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
+}
+app.get('/api/fha-expiring', (req, res) => {
+  try {
+    const file = path.join(ROOT, 'data', 'fha-approved-condos.json');
+    const { meta, condos } = JSON.parse(fs.readFileSync(file, 'utf8'));
+    const days = Math.max(1, Math.min(parseInt(req.query.days, 10) || 365, 3650));
+    const now = new Date(); now.setHours(0, 0, 0, 0);
+    const H = 864e5, cap = new Date(now.getTime() + days * H);
+    const rx = /(\d{2})\/(\d{2})\/(\d{4})/;
+    const rows = [];
+    for (const c of condos) {
+      if (c.warrant_signal !== 'fha_approved') continue;      // approved projects only
+      const m = String(c.expiration_date || '').match(rx); if (!m) continue;
+      const d = new Date(+m[3], +m[1] - 1, +m[2]); d.setHours(0, 0, 0, 0);
+      if (isNaN(d) || d < now || d > cap) continue;           // future expiry within the window
+      rows.push({
+        project: c.project_name, city: fixCity(c.city, c.zip), zip: c.zip || '',
+        expires: `${m[1]}/${m[2]}/${m[3]}`, days: Math.round((d - now) / H)
+      });
+    }
+    rows.sort((a, b) => a.days - b.days);                      // soonest-to-lapse first
+    res.json({ label: meta.label, days, count: rows.length, condos: rows });
+  } catch (e) { res.status(502).json({ error: String(e.message).split('\n')[0], condos: [] }); }
+});
+
 // CRCP — Commercial Real Estate Control Panel live stats (CNCP-style). One poll → every headline
 // number. brokersWithPhone/Email + condos tick UP live while the enrichment/scrape jobs run, so the
 // panel visibly "populates". All read-only.

← 384b541 CRCP: Just Listed active-inventory page (just-listed.html) +  ·  back to Commercialrealestate  ·  graphics-drill-assert: env-overridable BASE (crcp app port m ef3c162 →