← 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 };