← 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
M lib/bill-audit.jsA lib/reorder-timing.jsM routes/savings.jsM scripts/run-savings-advisor.js
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 →