← back to AbramsOS

lib/bill-audit.js

129 lines

// 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;
    // 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,
      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++;
  }

  return { scanned: bills.length, created };
}

module.exports = { generateBillAudit, annual };