[object Object]

← back to AbramsOS

feat(warranties): manual Warranties page + Deadlines coverage

61b2dca0311a2b07f811508ce3e7307aec9dff90 · 2026-07-07 07:45:18 -0700 · Steve

- /warranties + /api/warranties CRUD (audit-logged) writing to service_commitment (source='manual')
- reminder-engine scans service_commitment.refund_window_ends_at -> 'coverage_window_closing' deadlines
- nav link, warranty id prefix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 61b2dca0311a2b07f811508ce3e7307aec9dff90
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 07:45:18 2026 -0700

    feat(warranties): manual Warranties page + Deadlines coverage
    
    - /warranties + /api/warranties CRUD (audit-logged) writing to service_commitment (source='manual')
    - reminder-engine scans service_commitment.refund_window_ends_at -> 'coverage_window_closing' deadlines
    - nav link, warranty id prefix
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/ids.js                |   1 +
 lib/reminder-engine.js    |  23 ++++++++++
 routes/warranties.js      | 104 +++++++++++++++++++++++++++++++++++++++++++++
 server.js                 |   2 +
 views/partials/header.ejs |   1 +
 views/warranties.ejs      | 105 ++++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 236 insertions(+)

diff --git a/lib/ids.js b/lib/ids.js
index 9360880..721bae0 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -11,6 +11,7 @@ const PREFIX = {
   bill: 'bill',
   reorder: 'reord',
   reminder: 'rem',
+  warranty: 'wty',
 };
 
 function id(kind) {
diff --git a/lib/reminder-engine.js b/lib/reminder-engine.js
index e39a29e..d9ef012 100644
--- a/lib/reminder-engine.js
+++ b/lib/reminder-engine.js
@@ -117,6 +117,29 @@ async function generateForUser(userId) {
     }
   }
 
+  // 6) Warranty / return / guarantee windows closing — fire within 30 days
+  const commitments = await db.query(
+    `SELECT id, provider_name, commitment_type, refund_window_ends_at
+       FROM service_commitment
+      WHERE user_id = $1 AND refund_window_ends_at IS NOT NULL`,
+    [userId]
+  );
+  for (const c of commitments.rows) {
+    const daysUntil = (new Date(c.refund_window_ends_at).getTime() - Date.now()) / 86400e3;
+    if (daysUntil > 0 && daysUntil <= 30) {
+      const label = c.provider_name || 'coverage';
+      const r = await tryInsert({
+        userId, ownerTable: 'service_commitment', ownerId: c.id,
+        dueAt: new Date(c.refund_window_ends_at),
+        reasonCode: 'coverage_window_closing',
+        title: `${label} ${c.commitment_type || 'warranty'} window closes in ${Math.ceil(daysUntil)}d`,
+        body: `File any ${c.commitment_type || 'warranty'} claim before ${new Date(c.refund_window_ends_at).toDateString()}.`,
+        metadata: { commitment_type: c.commitment_type },
+      });
+      if (r) inserted += 1;
+    }
+  }
+
   return inserted;
 }
 
diff --git a/routes/warranties.js b/routes/warranties.js
new file mode 100644
index 0000000..aa49102
--- /dev/null
+++ b/routes/warranties.js
@@ -0,0 +1,104 @@
+// Warranties — manual entry of warranties / guarantees / return windows.
+// Rows land in service_commitment (source='manual'), same table the receipt
+// extractor populates, so they share the Deadlines engine + claims flow.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { daysUntil } = require('../lib/recurrence');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+const TYPES = ['warranty', 'return', 'guarantee', 'service', 'other'];
+
+function decorate(w) {
+  const d = w.refund_window_ends_at ? daysUntil(w.refund_window_ends_at) : null;
+  return {
+    ...w,
+    days_until: d,
+    is_expired: d != null && d < 0,
+    is_expiring_soon: d != null && d >= 0 && d <= 30,
+  };
+}
+
+async function listWarranties(userId) {
+  const r = await db.query(
+    `SELECT * FROM service_commitment
+       WHERE user_id = $1
+       ORDER BY (refund_window_ends_at IS NULL), refund_window_ends_at ASC, created_at DESC`,
+    [userId]
+  );
+  return r.rows.map(decorate);
+}
+
+router.get('/warranties', async (_req, res) => {
+  const warranties = await listWarranties(DEV_USER_ID);
+  res.render('warranties', { warranties, types: TYPES });
+});
+
+router.get('/api/warranties', async (_req, res) => {
+  res.json(await listWarranties(DEV_USER_ID));
+});
+
+router.post('/api/warranties', async (req, res) => {
+  try {
+    const b = req.body || {};
+    if (!b.provider_name && !b.guarantee_text) return res.status(400).json({ error: 'provider or coverage description required' });
+    const wId = id('warranty');
+    await db.query(
+      `INSERT INTO service_commitment
+         (id, user_id, provider_name, commitment_type, guarantee_text, promised_outcome, window_days, refund_window_ends_at, conditions, source, confidence)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'manual',1.0)`,
+      [
+        wId, DEV_USER_ID, b.provider_name || null,
+        TYPES.includes(b.commitment_type) ? b.commitment_type : 'warranty',
+        b.guarantee_text || b.provider_name || 'Warranty',
+        b.promised_outcome || null,
+        b.window_days ? parseInt(b.window_days, 10) : null,
+        b.refund_window_ends_at || null,
+        b.conditions || null,
+      ]
+    );
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'service_commitment', objectId: wId, eventType: 'warranty_created', metadata: { provider: b.provider_name, type: b.commitment_type } });
+    const r = await db.query(`SELECT * FROM service_commitment WHERE id = $1`, [wId]);
+    res.status(201).json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/warranties/:id', async (req, res) => {
+  try {
+    const b = req.body || {};
+    const fields = ['provider_name', 'commitment_type', 'guarantee_text', 'promised_outcome', 'window_days', 'refund_window_ends_at', 'conditions'];
+    const sets = [];
+    const vals = [];
+    let i = 1;
+    for (const f of fields) {
+      if (b[f] === undefined) continue;
+      sets.push(`${f} = $${i++}`);
+      vals.push(b[f] === '' ? null : b[f]);
+    }
+    if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
+    vals.push(req.params.id, DEV_USER_ID);
+    const r = await db.query(
+      `UPDATE service_commitment SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`,
+      vals
+    );
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'service_commitment', objectId: req.params.id, eventType: 'warranty_updated' });
+    res.json(decorate(r.rows[0]));
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+router.post('/api/warranties/:id/delete', async (req, res) => {
+  await db.query(`DELETE FROM calendar_reminder WHERE user_id = $1 AND owner_table = 'service_commitment' AND owner_id = $2`, [DEV_USER_ID, req.params.id]);
+  await db.query(`DELETE FROM service_commitment WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'service_commitment', objectId: req.params.id, eventType: 'warranty_deleted' });
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 3b41ca6..42277d6 100644
--- a/server.js
+++ b/server.js
@@ -24,6 +24,7 @@ const recallsRouter = require('./routes/recalls');
 const uploadRouter = require('./routes/upload');
 const billsRouter = require('./routes/bills');
 const reordersRouter = require('./routes/reorders');
+const warrantiesRouter = require('./routes/warranties');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -83,6 +84,7 @@ app.use(recallsRouter);             // /recalls, /api/recalls
 app.use(uploadRouter);              // /api/upload/csv
 app.use(billsRouter);               // /bills, /api/bills*
 app.use(reordersRouter);            // /reorders, /api/reorders*
+app.use(warrantiesRouter);          // /warranties, /api/warranties*
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index c344320..214e8a1 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -24,6 +24,7 @@
       <a href="/">Home</a>
       <a href="/deadlines">Deadlines</a>
       <a href="/bills">Bills</a>
+      <a href="/warranties">Warranties</a>
       <a href="/reorders">Reorders</a>
       <a href="/purchases">Purchases</a>
       <a href="/claims">Claims</a>
diff --git a/views/warranties.ejs b/views/warranties.ejs
new file mode 100644
index 0000000..1e1ed19
--- /dev/null
+++ b/views/warranties.ejs
@@ -0,0 +1,105 @@
+<%- include('partials/header', { title: 'Warranties' }) %>
+
+<section class="page-head">
+  <div>
+    <h1>Warranties &amp; Guarantees</h1>
+    <p class="subtle"><%= warranties.length %> tracked · manual entries live alongside anything the receipt reader finds · expiry windows flow into <a href="/deadlines">Deadlines</a>.</p>
+  </div>
+  <div class="grid-controls">
+    <label>Sort
+      <select data-sort-for="warranty">
+        <option value="due-asc">Expires soonest</option>
+        <option value="due-desc">Expires latest</option>
+        <option value="provider-asc">Provider A→Z</option>
+        <option value="type-asc">Type</option>
+        <option value="created-desc">Newest added</option>
+      </select>
+    </label>
+    <label>Density
+      <input data-density-for="warranty" type="range" min="240" max="460" step="10" value="320">
+    </label>
+    <button type="button" class="btn" id="toggleAdd">+ Add warranty</button>
+  </div>
+</section>
+
+<form id="addForm" class="add-form glass" hidden>
+  <div class="row">
+    <label>Provider / item*<input name="provider_name" required placeholder="LG fridge / AppleCare"></label>
+    <label>Type
+      <select name="commitment_type">
+        <% types.forEach(t => { %><option value="<%= t %>" <%= t==='warranty'?'selected':'' %>><%= t %></option><% }) %>
+      </select>
+    </label>
+    <label>Expires<input name="refund_window_ends_at" type="date"></label>
+  </div>
+  <div class="row">
+    <label class="grow">What it covers<input name="guarantee_text" placeholder="1-yr parts &amp; labor; extended to 3-yr via AppleCare"></label>
+    <label>Window (days)<input name="window_days" type="number" placeholder="365"></label>
+  </div>
+  <div class="row">
+    <label class="grow">Promised outcome<input name="promised_outcome" placeholder="free repair or replacement"></label>
+    <label class="grow">Conditions / notes<input name="conditions" placeholder="keep receipt; excludes accidental damage"></label>
+  </div>
+  <div class="row"><button type="submit" class="btn primary">Save warranty</button></div>
+</form>
+
+<% if (!warranties.length) { %>
+  <section class="empty glass"><p>No warranties yet. Click <strong>+ Add warranty</strong> to log one.</p></section>
+<% } %>
+
+<section data-grid="warranty" class="purchase-grid" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));">
+  <% warranties.forEach(w => { %>
+    <article class="purchase glass<%= w.is_expired ? ' overdue' : '' %>"
+             data-provider="<%= (w.provider_name||'').toLowerCase() %>"
+             data-type="<%= w.commitment_type || '' %>"
+             data-due="<%= w.refund_window_ends_at ? new Date(w.refund_window_ends_at).toISOString().slice(0,10) : '' %>"
+             data-created="<%= new Date(w.created_at).toISOString() %>">
+      <header>
+        <h3><%= w.provider_name || 'Warranty' %></h3>
+        <span class="chip cat"><%= w.commitment_type || 'warranty' %></span>
+      </header>
+      <div class="amount" style="font-size:14px;font-weight:500">
+        <% if (w.refund_window_ends_at) { %>
+          expires <%= new Date(w.refund_window_ends_at).toLocaleDateString() %>
+          <% if (w.is_expired) { %><span class="badge red">expired</span>
+          <% } else if (w.is_expiring_soon) { %><span class="badge amber">in <%= w.days_until %>d</span>
+          <% } else { %><span class="badge">in <%= w.days_until %>d</span><% } %>
+        <% } else if (w.window_days) { %><span class="subtle"><%= w.window_days %>-day window</span>
+        <% } else { %><span class="subtle">no expiry set</span><% } %>
+      </div>
+      <dl class="meta">
+        <% if (w.guarantee_text) { %><dt>Covers</dt><dd><%= w.guarantee_text %></dd><% } %>
+        <% if (w.promised_outcome) { %><dt>Outcome</dt><dd><%= w.promised_outcome %></dd><% } %>
+        <% if (w.conditions) { %><dt>Notes</dt><dd><%= w.conditions %></dd><% } %>
+        <% if (w.source && w.source !== 'manual') { %><dt>Source</dt><dd><%= w.source %><% if (w.confidence != null) { %> (<%= Math.round(Number(w.confidence)*100) %>%)<% } %></dd><% } %>
+      </dl>
+      <div class="when" title="<%= new Date(w.created_at).toISOString() %>">🕓 added <%= new Date(w.created_at).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) %></div>
+      <div class="card-actions">
+        <% if (w.commitment_type !== 'return') { %><a class="btn small" href="/claims">File claim →</a><% } %>
+        <button class="btn small danger" data-action="delete" data-id="<%= w.id %>">Delete</button>
+      </div>
+    </article>
+  <% }) %>
+</section>
+
+<script>
+  const $ = (s, r = document) => r.querySelector(s);
+  $('#toggleAdd')?.addEventListener('click', () => { const f = $('#addForm'); f.hidden = !f.hidden; });
+
+  $('#addForm')?.addEventListener('submit', async (e) => {
+    e.preventDefault();
+    const body = Object.fromEntries(new FormData(e.target).entries());
+    const res = await fetch('/api/warranties', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+    if (res.ok) location.reload(); else alert('Save failed: ' + (await res.text()));
+  });
+
+  document.querySelectorAll('[data-action="delete"]').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      if (!confirm('Delete this warranty?')) return;
+      const res = await fetch(`/api/warranties/${btn.dataset.id}/delete`, { method: 'POST' });
+      if (res.ok) location.reload(); else alert('Delete failed');
+    });
+  });
+</script>
+
+<%- include('partials/footer', { gridScript: true }) %>

← 43aea4b feat(ui): Bills, Reorders, and unified Deadlines pages  ·  back to AbramsOS  ·  feat(digest): opt-in daily deadline digest email via George 8cf03df →