[object Object]

← back to AbramsOS

auto-save: 2026-07-13T01:52:00 (1 files) — lib/bill-audit.js

a0f0e6a1e03f708681a0c20b232d2f3332914de4 · 2026-07-13 01:52:07 -0700 · Steve Abrams

Files touched

Diff

commit a0f0e6a1e03f708681a0c20b232d2f3332914de4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 01:52:07 2026 -0700

    auto-save: 2026-07-13T01:52:00 (1 files) — lib/bill-audit.js
---
 lib/bill-audit.js | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 120 insertions(+)

diff --git a/lib/bill-audit.js b/lib/bill-audit.js
new file mode 100644
index 0000000..1af8b0d
--- /dev/null
+++ b/lib/bill-audit.js
@@ -0,0 +1,120 @@
+// bill-audit.js — flag likely-unused / duplicative / overpriced recurring bills and draft
+// cancel/downgrade/shop-rate suggestions into savings_suggestion (kind='bill-audit').
+//
+// Rails:
+//   • Category-aware: NEVER suggests cancelling essentials (tax, rent, mortgage, loan,
+//     utility). Those are skipped entirely. Insurance/phone/internet get "review the rate"
+//     (rule-based). Only discretionary categories (subscription/other) go to the LLM for a
+//     cancel/downgrade opinion — and it's told they're discretionary, nothing essential.
+//   • Rule-based duplicate detector (same payee/name twice) — high signal, no LLM needed.
+//   • est_savings only when the bill amount is known (annualized by cadence); never faked.
+//   • Local Ollama only ($0).
+
+const db = require('./db');
+const ollama = require('./ollama');
+const { id } = require('./ids');
+
+const ESSENTIAL = new Set(['tax', 'government', 'rent', 'mortgage', 'loan', 'utility']);
+const RATE_SHOP = new Set(['insurance', 'phone', 'internet']);          // essential-ish, but shoppable
+const DISCRETIONARY = new Set(['subscription', 'card', 'other']);
+
+const PER_YEAR = { weekly: 52, biweekly: 26, monthly: 12, quarterly: 4, semiannual: 2, annual: 1, once: 0 };
+
+function annual(amount, cadence) {
+  const a = Number(amount); const n = PER_YEAR[cadence];
+  return (Number.isFinite(a) && n) ? Math.round(a * n * 100) / 100 : null;
+}
+
+async function alreadyOpen(userId, billId) {
+  const r = await db.query(
+    `SELECT 1 FROM savings_suggestion WHERE user_id=$1 AND kind='bill-audit'
+       AND metadata_jsonb->>'bill_id'=$2 AND status IN ('new','saved')`, [userId, billId]);
+  return r.rows.length > 0;
+}
+
+async function insertSuggestion(userId, bill, s) {
+  if (await alreadyOpen(userId, bill.id)) return false;
+  await db.query(
+    `INSERT INTO savings_suggestion
+       (id,user_id,kind,title,current_item,current_price,suggested_item,est_savings,
+        savings_basis,quality_note,merchant,rationale,confidence,metadata_jsonb)
+     VALUES ($1,$2,'bill-audit',$3,$4,$5,$6,$7,'per year',$8,$9,$10,$11,$12)`,
+    [
+      id('saving'), userId, `Review bill: ${bill.name}`.slice(0, 200),
+      bill.name, bill.amount ?? null, (s.action || 'Review').slice(0, 120),
+      s.estSavings ?? null, (s.note || '').slice(0, 500), bill.payee || null,
+      (s.rationale || '').slice(0, 1000), s.confidence ?? 0.5,
+      JSON.stringify({ bill_id: bill.id, category: bill.category, audit: s.tag }),
+    ]);
+  return true;
+}
+
+async function llmAssess(bill, opts) {
+  const prompt = `A person pays this recurring bill:
+- name: ${bill.name}
+- category: ${bill.category}
+- amount: ${bill.amount != null ? '$' + bill.amount : 'unknown'} ${bill.cadence}
+This is a DISCRETIONARY bill (not rent/utilities/tax). Assess whether it's likely
+cancelable, downgradable, or overpriced. Return JSON:
+{"tag":"cancel"|"downgrade"|"overpriced"|"keep","action":"short action label <=6 words",
+"rationale":"one sentence","confidence":number}`;
+  try {
+    const j = await ollama.generateJson(prompt, {
+      system: 'Frugal bill auditor. Only judge the discretionary bill given. Never suggest cancelling essentials. JSON only.',
+      timeoutMs: opts.timeoutMs || 45_000, model: opts.model,
+    });
+    return j && j.tag ? j : null;
+  } catch (_) { return null; }
+}
+
+async function generateBillAudit(userId, opts = {}) {
+  const bills = (await db.query(
+    `SELECT id, name, payee, category, amount, cadence, last_paid_at, status
+       FROM bill WHERE user_id=$1 AND status='active'`, [userId])).rows;
+
+  let created = 0;
+
+  // 1) Duplicate detector (rule-based) — same normalized name/payee more than once.
+  const seen = new Map();
+  for (const b of bills) {
+    const key = (b.payee || b.name || '').toLowerCase().replace(/\s+/g, ' ').trim();
+    if (!key) continue;
+    if (seen.has(key)) {
+      const est = annual(b.amount, b.cadence);
+      if (await insertSuggestion(userId, b, {
+        tag: 'duplicate', action: 'Possible duplicate bill',
+        estSavings: est, confidence: 0.6,
+        note: `Also billed as "${seen.get(key).name}".`,
+        rationale: `Two active bills look like the same payee — check for a double charge.`,
+      })) created++;
+    } else seen.set(key, b);
+  }
+
+  // 2) Per-bill assessment.
+  for (const b of bills) {
+    if (ESSENTIAL.has(b.category)) continue;                 // never touch essentials
+    if (RATE_SHOP.has(b.category)) {
+      const est = annual(b.amount, b.cadence);
+      if (await insertSuggestion(userId, b, {
+        tag: 'shop-rate', action: `Shop your ${b.category} rate`,
+        estSavings: est != null ? Math.round(est * 0.15 * 100) / 100 : null, // ~15% typical, labeled estimate
+        confidence: 0.5, note: 'Rates drift up; a quick re-quote often saves ~10-20%.',
+        rationale: `${b.category} bills are often overpriced after a year — re-quote or renegotiate.`,
+      })) created++;
+      continue;
+    }
+    if (!DISCRETIONARY.has(b.category)) continue;
+    const s = await llmAssess(b, opts);
+    if (!s || s.tag === 'keep') continue;
+    const est = annual(b.amount, b.cadence);
+    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,
+    })) created++;
+  }
+
+  return { scanned: bills.length, created };
+}
+
+module.exports = { generateBillAudit, annual };

← 4c949de docs: roadmap — health track done; Assets/Neighborhood/Biome  ·  back to AbramsOS  ·  savings: subscription/bill audit + reorder-timing nudges 5387c7d →