// Rule-precedence and alert-noise checks against a COPY of a real db. Every // write happens inside one transaction that is rolled back, so the copy is // unchanged afterwards. Needs the config env vars (dummy values are fine; no // network is called): // FINANCE_DB_PATH=/path/to/snapshot-copy.db PLAID_CLIENT_ID=x PLAID_SECRET=x \ // APP_ENC_KEY=$(printf '0%.0s' {1..64}) npx tsx scripts/verify-tidy.mts const dbPath = process.env.FINANCE_DB_PATH ?? ""; if (!dbPath || /data\/finance\.db$/.test(dbPath)) throw new Error("set FINANCE_DB_PATH to a copy, never the live db"); const { db } = await import("../lib/db.ts"); const { applyRules } = await import("../lib/categorize.ts"); const { POST: createRule } = await import("../app/api/rules/route.ts"); const { NextRequest } = await import("next/server"); const ok = (l: string, c: boolean, extra = "") => { console.log(`${c ? "PASS" : "FAIL"} ${l}${extra ? " — " + extra : ""}`); if (!c) process.exitCode = 1; }; // ---- Local dates (TZ_LOCAL defaults to America/New_York). const { todayLocal, monthLocal, addMonths } = await import("../lib/dates.ts"); const evening = new Date("2026-10-31T01:30:00Z"); // 9:30pm EDT on Oct 30 ok("9:30pm ET is still today, not tomorrow", todayLocal(evening) === "2026-10-30", todayLocal(evening)); const lastEve = new Date("2026-11-01T02:00:00Z"); // 10pm EDT on Oct 31 ok("last evening of a month is still that month", monthLocal(lastEve) === "2026-10", monthLocal(lastEve)); ok("winter offset: 7:30pm EST on Dec 31 is still 2026", todayLocal(new Date("2027-01-01T00:30:00Z")) === "2026-12-31"); ok("addMonths wraps the year", addMonths("2026-01", -1) === "2025-12" && addMonths("2026-11", 2) === "2027-01"); const d = db(); const rule = d.prepare(`INSERT INTO rules (match_type, pattern, field, category, entity, rename, priority, source) VALUES (?,?,?,?,?,?,?,?)`); const insTxn = d.prepare( `INSERT INTO transactions (id, account_id, date, name, merchant, amount, category, category_source, confidence) VALUES (?, (SELECT id FROM accounts LIMIT 1), '2026-01-01', ?, ?, 1, 'Shopping', 'plaid', 0.5)`, ); const cat = (id: string) => (d.prepare(`SELECT category FROM transactions WHERE id=?`).get(id) as { category: string }).category; d.exec("BEGIN"); try { // ---- Task 2: rule precedence + per-field merge. rule.run("contains", "zzverify llm", "merchant", "Dining", null, null, 50, "llm"); rule.run("contains", "zzverify llm", "merchant", "Groceries", null, null, 100, "manual"); ok("user rule (priority 100) beats LLM rule (priority 50)", applyRules("zzverify llm", "x").category === "Groceries", `got ${applyRules("zzverify llm", "x").category}`); // Real rules on the snapshot: 145 (manual, prio 10, category only) and 141 // (manual, prio 100, rename). Both should apply. const jl = applyRules("Jiffy Lube", "JIFFY LUBE #1234"); ok("Jiffy Lube: category from rule 145 and rename from rule 141 both apply", jl.category === "Transportation" && jl.rename === "Jiffy Lube", JSON.stringify(jl)); // LLM rule 6 (merchant "Pennymac") used to shadow user rule 61's rename. const pm = applyRules("Pennymac", "PENNYMAC CASH"); ok("PennyMac: user rule 61's rename survives LLM rule 6", pm.rename === "PennyMac Mortgage", JSON.stringify(pm)); // ---- Task 2: "apply now" honours match type and treats % / _ literally. insTxn.run("zzv-1", "ZZEXACT", "zzexact"); insTxn.run("zzv-2", "ZZEXACT PLUS", "zzexact plus"); insTxn.run("zzv-3", "ZZAXB", "zzaxb"); insTxn.run("zzv-4", "ZZA_B", "zza_b"); const post = (b: object) => createRule(new NextRequest("http://local/api/rules", { method: "POST", body: JSON.stringify(b) })); await post({ match_type: "exact", pattern: "zzexact", category: "Travel", applyNow: true }); ok("apply-now exact rule changes only the exact match", cat("zzv-1") === "Travel" && cat("zzv-2") === "Shopping", `zzexact=${cat("zzv-1")} zzexact plus=${cat("zzv-2")}`); await post({ match_type: "contains", pattern: "zza_b", category: "Travel", applyNow: true }); ok("apply-now treats _ literally", cat("zzv-4") === "Travel" && cat("zzv-3") === "Shopping", `zza_b=${cat("zzv-4")} zzaxb=${cat("zzv-3")}`); // ---- Task 4: alert noise. Real snapshot rows plus fixtures dated this week. const day = (n: number) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); const fx = d.prepare( `INSERT INTO transactions (id, account_id, date, name, merchant, amount, category, category_source, confidence) VALUES (?, (SELECT id FROM accounts LIMIT 1), ?, ?, ?, ?, 'Shopping', 'rule', 1.0)`, ); // A real double-bill: monthly $49.99, then charged twice two days apart. for (const n of [92, 62, 32]) fx.run(`zzn-${n}`, day(n), "ZZSTREAM", "ZZSTREAM", 49.99); fx.run("zzn-a", day(4), "ZZSTREAM", "ZZSTREAM", 49.99); fx.run("zzn-b", day(3), "ZZSTREAM", "ZZSTREAM", 49.99); // One cluster: three identical $80 charges within 3 days of each other. for (const n of [5, 4, 3]) fx.run(`zzc-${n}`, day(n), "ZZCLUSTER", "ZZCLUSTER", 80); const { detectAnomalies } = await import("../lib/anomalies.ts"); const an = detectAnomalies({ windowDays: 30 }); const dupes = an.filter((a) => a.kind === "duplicate"); const has = (re: RegExp) => dupes.some((a) => re.test(a.detail)); ok("no duplicate flag for $3 MTA fares", !has(/ at MTA /), dupes.map((a) => a.detail).join(" | ")); ok("no duplicate flag for $10 Apple Cash / Opencode", !has(/APPLE CASH|Opencode/i)); ok("no duplicate flag for the habitual Venmo $1,289 transfer", !has(/Venmo/)); ok("a real double-bill still fires (positive control)", has(/ZZSTREAM/)); const cl = dupes.filter((a) => /ZZCLUSTER/.test(a.detail)); ok("a 3-charge cluster is one flag", cl.length === 1 && /3×/.test(cl[0]?.detail ?? ""), cl.map((a) => a.detail).join(" | ")); const spikes = an.filter((a) => a.kind === "spike"); ok("ordinary Amazon orders are not spikes", !spikes.some((a) => a.merchant === "Amazon"), spikes.map((a) => a.detail).join(" | ")); ok("Washington Gas 9× its usual still spikes (positive control)", spikes.some((a) => a.merchant === "Washington Gas")); // Insights: page through the whole feed by dismissing each page in turn. const { gatherInsights } = await import("../lib/insights.ts"); const { listSubscriptions } = await import("../lib/subscriptions.ts"); const dismiss = d.prepare(`INSERT OR IGNORE INTO dismissed_insights (insight_id) VALUES (?)`); const keys: string[] = []; for (let guard = 0; guard < 50; guard++) { const page = gatherInsights(); if (!page.length) break; for (const i of page) { keys.push(i.key); dismiss.run(i.key); } } const obligations = listSubscriptions().subs.filter((s) => s.type === "obligation" && s.state === "active" && !s.isActive && s.monthly >= 1); const leaked = obligations.filter((s) => keys.some((k) => k.startsWith(`sub-unused:${s.id}:`))); ok(`no "looks unused" insight for an obligation (${obligations.length} candidates)`, obligations.length > 0 && leaked.length === 0, leaked.map((s) => s.merchant).join(", ")); } finally { d.exec("ROLLBACK"); }