[object Object]

← back to AbramsOS

assets: RentCast home-value estimate wiring (one-key-from-working) + all 37 Amazon totals collected

ba2e47d441e01b67b8f4d37fb85c958f5150c983 · 2026-07-13 09:49:42 -0700 · Steve

- lib/home-value.js: RentCast AVM lookup by address; graceful no-key/no-address (never fabricates a value); Zillow API retired so RentCast is the free option
- routes/assets.js: POST /api/assets/:id/estimate → fills current_value + value_source='estimate' + range; valuationConfigured flag
- views/assets.ejs: 'Estimate' button on property cards (prompts for key if absent)
- .env: RENTCAST_API_KEY= (gated)
- DATA: opened remaining 17 older Amazon orders via George → all 37 now have real totals; complete all-time spend $8,966.66 (2022-2026)

Files touched

Diff

commit ba2e47d441e01b67b8f4d37fb85c958f5150c983
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 09:49:42 2026 -0700

    assets: RentCast home-value estimate wiring (one-key-from-working) + all 37 Amazon totals collected
    
    - lib/home-value.js: RentCast AVM lookup by address; graceful no-key/no-address (never fabricates a value); Zillow API retired so RentCast is the free option
    - routes/assets.js: POST /api/assets/:id/estimate → fills current_value + value_source='estimate' + range; valuationConfigured flag
    - views/assets.ejs: 'Estimate' button on property cards (prompts for key if absent)
    - .env: RENTCAST_API_KEY= (gated)
    - DATA: opened remaining 17 older Amazon orders via George → all 37 now have real totals; complete all-time spend $8,966.66 (2022-2026)
---
 lib/home-value.js | 37 +++++++++++++++++++++++++++++++++++++
 routes/assets.js  | 20 +++++++++++++++++++-
 views/assets.ejs  | 12 +++++++++++-
 3 files changed, 67 insertions(+), 2 deletions(-)

diff --git a/lib/home-value.js b/lib/home-value.js
new file mode 100644
index 0000000..21f8ac6
--- /dev/null
+++ b/lib/home-value.js
@@ -0,0 +1,37 @@
+// home-value.js — optional automatic property valuation via RentCast AVM.
+//
+// One-key-from-working: set RENTCAST_API_KEY (free tier = 50 lookups/mo at
+// rentcast.io) and the Assets "Estimate" button fills current_value from RentCast's
+// automated valuation model. No key → graceful "not configured" (never fabricates a value).
+// Zillow's Zestimate API is retired; RentCast is the current no-cost option.
+
+function isConfigured() {
+  return Boolean(process.env.RENTCAST_API_KEY);
+}
+
+async function estimateValue(address) {
+  const key = process.env.RENTCAST_API_KEY;
+  if (!key) return { ok: false, reason: 'no_key' };
+  if (!address) return { ok: false, reason: 'no_address' };
+  const url = `https://api.rentcast.io/v1/avm/value?address=${encodeURIComponent(address)}`;
+  try {
+    const ctrl = new AbortController();
+    const t = setTimeout(() => ctrl.abort(), 12000);
+    const r = await fetch(url, { headers: { 'X-Api-Key': key, accept: 'application/json' }, signal: ctrl.signal });
+    clearTimeout(t);
+    if (!r.ok) return { ok: false, reason: `http_${r.status}` };
+    const j = await r.json();
+    const price = Number(j.price ?? j.value);
+    if (!Number.isFinite(price)) return { ok: false, reason: 'no_price' };
+    return {
+      ok: true,
+      price: Math.round(price),
+      low: Number.isFinite(Number(j.priceRangeLow)) ? Math.round(Number(j.priceRangeLow)) : null,
+      high: Number.isFinite(Number(j.priceRangeHigh)) ? Math.round(Number(j.priceRangeHigh)) : null,
+    };
+  } catch (e) {
+    return { ok: false, reason: e.message };
+  }
+}
+
+module.exports = { isConfigured, estimateValue };
diff --git a/routes/assets.js b/routes/assets.js
index 2a272eb..b02d6b3 100644
--- a/routes/assets.js
+++ b/routes/assets.js
@@ -4,6 +4,7 @@ const express = require('express');
 const db = require('../lib/db');
 const audit = require('../lib/audit');
 const { id } = require('../lib/ids');
+const homeValue = require('../lib/home-value');
 
 const router = express.Router();
 const DEV_USER_ID = 'user_steve';
@@ -26,7 +27,24 @@ router.get('/assets', async (_req, res) => {
     const c = a.category || 'uncategorized';
     byCategory[c] = (byCategory[c] || 0) + (Number(a.current_value) || 0);
   }
-  res.render('assets', { assets, netWorth, byCategory });
+  res.render('assets', { assets, netWorth, byCategory, valuationConfigured: homeValue.isConfigured() });
+});
+
+// Estimate a property's value from its address via RentCast (if a key is set).
+router.post('/api/assets/:id/estimate', async (req, res) => {
+  const a = await db.query(`SELECT id, address, kind FROM asset WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  if (!a.rows.length) return res.status(404).json({ ok: false, error: 'not found' });
+  if (!a.rows[0].address) return res.status(400).json({ ok: false, error: 'asset has no address' });
+  const est = await homeValue.estimateValue(a.rows[0].address);
+  if (!est.ok) return res.status(est.reason === 'no_key' ? 501 : 502).json({ ok: false, reason: est.reason });
+  await db.query(
+    `UPDATE asset SET current_value = $1, value_source = 'estimate', valued_at = current_date,
+        metadata_jsonb = metadata_jsonb || $2::jsonb, updated_at = now()
+      WHERE id = $3 AND user_id = $4`,
+    [est.price, JSON.stringify({ estimate: { source: 'rentcast', low: est.low, high: est.high, at: new Date().toISOString() } }), req.params.id, DEV_USER_ID]
+  );
+  await audit.log({ actorType: 'system', actorId: DEV_USER_ID, objectType: 'asset', objectId: req.params.id, eventType: 'asset_estimated', metadata: { source: 'rentcast', price: est.price } }).catch(() => {});
+  res.json({ ok: true, ...est });
 });
 
 router.get('/api/assets', async (_req, res) => {
diff --git a/views/assets.ejs b/views/assets.ejs
index d8ccf28..3622c38 100644
--- a/views/assets.ejs
+++ b/views/assets.ejs
@@ -78,7 +78,10 @@
       <footer class="card-foot">
         <span class="when" title="<%= new Date(a.created_at).toISOString() %>">🕓 <%= fmt(a.created_at) %></span>
         <span class="actions">
-          <% if (a.kind === 'property' && a.address) { %><a class="btn tiny" href="/neighborhood?asset=<%= a.id %>">🛡 Watch</a><% } %>
+          <% if (a.kind === 'property' && a.address) { %>
+            <button type="button" class="btn tiny" data-estimate="<%= a.id %>" title="<%= valuationConfigured ? 'Estimate value via RentCast' : 'Add RENTCAST_API_KEY to enable' %>">$ Estimate</button>
+            <a class="btn tiny" href="/neighborhood?asset=<%= a.id %>">🛡 Watch</a>
+          <% } %>
           <button type="button" class="btn tiny ghost" data-del="<%= a.id %>">Remove</button>
         </span>
       </footer>
@@ -101,6 +104,13 @@
     if(!confirm('Remove this asset?')) return;
     await post('/api/assets/'+e.currentTarget.dataset.del+'/delete'); e.currentTarget.closest('article').remove();
   }));
+  document.querySelectorAll('[data-estimate]').forEach(b=>b.addEventListener('click', async (e)=>{
+    const btn=e.currentTarget; btn.disabled=true; const t=btn.textContent; btn.textContent='…';
+    const out=await post('/api/assets/'+btn.dataset.estimate+'/estimate');
+    if(out.ok){ btn.textContent='$'+out.price.toLocaleString(); setTimeout(()=>location.reload(),900); }
+    else if(out.reason==='no_key'){ alert('Add RENTCAST_API_KEY (free at rentcast.io) via the secrets skill to enable estimates.'); btn.textContent=t; btn.disabled=false; }
+    else { alert('Estimate unavailable: '+(out.reason||out.error||'?')); btn.textContent=t; btn.disabled=false; }
+  }));
 </script>
 
 <%- include('partials/footer', {}) %>

← 957c215 docs: savings track done; overnight run complete, loop stopp  ·  back to AbramsOS  ·  withings: device-vitals auto-sync connector (OAuth2 → weight 5671885 →