[object Object]

← back to AbramsOS

savings: subscription/bill audit + reorder-timing nudges

5387c7dd1c832aadbe42cf4651bff36e2d55f873 · 2026-07-13 01:56:43 -0700 · Steve

- lib/bill-audit.js: category-aware — NEVER flags essentials (tax/rent/utility/loan); insurance/phone/internet get 'shop your rate'; discretionary/subscriptions ALWAYS surface with annual cost ('still using this?'), LLM (gemma3:12b) refines cancel/downgrade/overpriced; rule-based duplicate detector
- lib/reorder-timing.js: deterministic 'reorder soon' nudges from cadence + last_ordered (Paper towels due-now verified)
- wired both into /api/savings/run + nightly runner; kind='bill-audit'/'reorder-timing' surface on /savings
- honest: est_savings only for cancel/downgrade, never for 'review'; verified on synthetic sub then wiped

Files touched

Diff

commit 5387c7dd1c832aadbe42cf4651bff36e2d55f873
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 01:56:43 2026 -0700

    savings: subscription/bill audit + reorder-timing nudges
    
    - lib/bill-audit.js: category-aware — NEVER flags essentials (tax/rent/utility/loan); insurance/phone/internet get 'shop your rate'; discretionary/subscriptions ALWAYS surface with annual cost ('still using this?'), LLM (gemma3:12b) refines cancel/downgrade/overpriced; rule-based duplicate detector
    - lib/reorder-timing.js: deterministic 'reorder soon' nudges from cadence + last_ordered (Paper towels due-now verified)
    - wired both into /api/savings/run + nightly runner; kind='bill-audit'/'reorder-timing' surface on /savings
    - honest: est_savings only for cancel/downgrade, never for 'review'; verified on synthetic sub then wiped
---
 lib/bill-audit.js              | 18 +++++++++++----
 lib/reorder-timing.js          | 52 ++++++++++++++++++++++++++++++++++++++++++
 routes/savings.js              |  9 ++++++--
 scripts/run-savings-advisor.js |  6 ++++-
 4 files changed, 77 insertions(+), 8 deletions(-)

diff --git a/lib/bill-audit.js b/lib/bill-audit.js
index 1af8b0d..adbeb0f 100644
--- a/lib/bill-audit.js
+++ b/lib/bill-audit.js
@@ -104,13 +104,21 @@ async function generateBillAudit(userId, opts = {}) {
       continue;
     }
     if (!DISCRETIONARY.has(b.category)) continue;
-    const s = await llmAssess(b, opts);
-    if (!s || s.tag === 'keep') continue;
+    // Always surface a discretionary/subscription bill with its annual cost — the human
+    // decides if it's still worth it. The LLM refines the angle (cancel/downgrade/overpriced).
     const est = annual(b.amount, b.cadence);
+    const s = await llmAssess(b, opts);
+    const tag = s && s.tag && s.tag !== 'keep' ? s.tag : 'review';
+    const estSavings = tag === 'cancel' ? est
+      : tag === 'downgrade' && est != null ? Math.round(est * 0.4 * 100) / 100
+      : null;                                          // reviewing/overpriced ≠ guaranteed savings
     if (await insertSuggestion(userId, b, {
-      tag: s.tag, action: s.action,
-      estSavings: s.tag === 'downgrade' && est != null ? Math.round(est * 0.4 * 100) / 100 : est,
-      confidence: s.confidence, note: null, rationale: s.rationale,
+      tag,
+      action: (s && s.action) || (b.category === 'subscription' ? 'Still using this?' : 'Review this bill'),
+      estSavings,
+      confidence: (s && s.confidence) || 0.5,
+      note: est != null ? `${'$' + est}/yr as billed.` : null,
+      rationale: (s && s.rationale) || `You pay this ${b.cadence}${est != null ? ` (~$${est}/yr)` : ''} — worth a periodic check that you still use it.`,
     })) created++;
   }
 
diff --git a/lib/reorder-timing.js b/lib/reorder-timing.js
new file mode 100644
index 0000000..c17bf68
--- /dev/null
+++ b/lib/reorder-timing.js
@@ -0,0 +1,52 @@
+// reorder-timing.js — "reorder soon" nudges for repeat-buy items whose cadence says
+// they're about due. Deterministic (no LLM): next_due = last_ordered_at + cadence_days;
+// if that's within the look-ahead window (or already past), draft a savings_suggestion
+// (kind='reorder-timing'). Deduped per reorder item; never fabricates a date.
+
+const db = require('./db');
+const { id } = require('./ids');
+
+const LOOKAHEAD_DAYS = 7;
+
+async function alreadyOpen(userId, reorderId) {
+  const r = await db.query(
+    `SELECT 1 FROM savings_suggestion WHERE user_id=$1 AND kind='reorder-timing'
+       AND reorder_item_id=$2 AND status IN ('new','saved')`, [userId, reorderId]);
+  return r.rows.length > 0;
+}
+
+async function generateReorderNudges(userId, opts = {}) {
+  const items = (await db.query(
+    `SELECT id, name, merchant, reorder_cadence_days, last_ordered_at, next_due_date, best_price, typical_price, currency
+       FROM reorder_item WHERE user_id=$1 AND status='active'
+         AND reorder_cadence_days IS NOT NULL AND last_ordered_at IS NOT NULL`,
+    [userId])).rows;
+
+  const now = Date.now(), DAY = 86400000;
+  let created = 0;
+  for (const it of items) {
+    const due = it.next_due_date
+      ? new Date(it.next_due_date).getTime()
+      : new Date(it.last_ordered_at).getTime() + it.reorder_cadence_days * DAY;
+    const daysUntil = Math.round((due - now) / DAY);
+    if (daysUntil > (opts.lookaheadDays || LOOKAHEAD_DAYS)) continue;   // not due yet
+    if (await alreadyOpen(userId, it.id)) continue;
+
+    const when = daysUntil <= 0 ? 'now' : `in ${daysUntil}d`;
+    await db.query(
+      `INSERT INTO savings_suggestion
+         (id,user_id,kind,title,current_item,suggested_item,savings_basis,merchant,rationale,confidence,reorder_item_id,metadata_jsonb)
+       VALUES ($1,$2,'reorder-timing',$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
+      [
+        id('saving'), userId, `Reorder soon: ${it.name}`.slice(0, 200),
+        it.name, `Reorder ${when}`, 'timing', it.merchant || null,
+        `Based on your ~${it.reorder_cadence_days}-day cycle, you're due to reorder ${when}. Buy at your best-known price${it.best_price ? ` (${it.currency || '$'}${it.best_price})` : ''}.`,
+        0.7, it.id,
+        JSON.stringify({ days_until: daysUntil, next_due: new Date(due).toISOString().slice(0, 10) }),
+      ]);
+    created++;
+  }
+  return { scanned: items.length, created };
+}
+
+module.exports = { generateReorderNudges, LOOKAHEAD_DAYS };
diff --git a/routes/savings.js b/routes/savings.js
index f83ba60..3d1574e 100644
--- a/routes/savings.js
+++ b/routes/savings.js
@@ -3,6 +3,8 @@
 const express = require('express');
 const db = require('../lib/db');
 const advisor = require('../lib/savings-advisor');
+const billAudit = require('../lib/bill-audit');
+const reorderTiming = require('../lib/reorder-timing');
 const audit = require('../lib/audit');
 
 const router = express.Router();
@@ -56,12 +58,15 @@ router.get('/api/coupons', async (_req, res) => {
 router.post('/api/savings/run', async (_req, res) => {
   try {
     const out = await advisor.generateSuggestions(DEV_USER_ID, { limit: 50 });
+    const bills = await billAudit.generateBillAudit(DEV_USER_ID, { model: 'gemma3:12b' });
+    const reorders = await reorderTiming.generateReorderNudges(DEV_USER_ID);
+    const created = out.created + bills.created + reorders.created;
     await audit.log({
       actorType: 'system', actorId: 'savings-advisor', objectType: 'savings_suggestion',
       objectId: null, eventType: 'savings_generated',
-      metadata: { scanned: out.scanned, created: out.created },
+      metadata: { substitutes: out.created, bill_audit: bills.created, reorder_timing: reorders.created },
     }).catch(() => {});
-    res.json({ ok: true, ...out, results: undefined });
+    res.json({ ok: true, created, substitutes: out.created, billAudit: bills.created, reorderTiming: reorders.created });
   } catch (err) {
     res.status(500).json({ ok: false, error: err.message });
   }
diff --git a/scripts/run-savings-advisor.js b/scripts/run-savings-advisor.js
index 9ab544c..fb40677 100644
--- a/scripts/run-savings-advisor.js
+++ b/scripts/run-savings-advisor.js
@@ -10,6 +10,8 @@
 
 require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
 const advisor = require('../lib/savings-advisor');
+const billAudit = require('../lib/bill-audit');
+const reorderTiming = require('../lib/reorder-timing');
 
 const USER_ID = process.env.SAVINGS_USER_ID || 'user_steve';
 
@@ -17,7 +19,9 @@ const USER_ID = process.env.SAVINGS_USER_ID || 'user_steve';
   const started = new Date().toISOString();
   try {
     const out = await advisor.generateSuggestions(USER_ID, { limit: 100, timeoutMs: 90_000 });
-    console.log(`[savings-advisor ${started}] scanned=${out.scanned} created=${out.created} skipped=${out.skipped} errors=${out.errors.length}`);
+    const bills = await billAudit.generateBillAudit(USER_ID, { model: 'gemma3:12b' });
+    const reorders = await reorderTiming.generateReorderNudges(USER_ID);
+    console.log(`[savings-advisor ${started}] substitutes=${out.created} bill-audit=${bills.created} reorder-timing=${reorders.created} skipped=${out.skipped} errors=${out.errors.length}`);
     if (out.errors.length) console.log('  errors:', out.errors.map((e) => `${e.item}: ${e.error}`).join('; '));
     process.exit(0);
   } catch (err) {

← a0f0e6a auto-save: 2026-07-13T01:52:00 (1 files) — lib/bill-audit.js  ·  back to AbramsOS  ·  docs: savings track done; overnight run complete, loop stopp 957c215 →