// Subscriptions surface checks against a COPY of a real snapshot. The db is
// mutated (reconcile, merges, edits), so point FINANCE_DB_PATH at a scratch copy.
// No network: fetch is replaced before anything loads; the LLM pass gets a stub.
// cp data/review/snapshot-YYYYMMDD.db /tmp/vs.db
// FINANCE_DB_PATH=/tmp/vs.db PLAID_CLIENT_ID=x PLAID_SECRET=x \
// APP_ENC_KEY=$(printf '0%.0s' {1..64}) npx tsx scripts/verify-subscriptions.mts
// SUBS_LIB=
runs the same checks against another copy of lib/ (the
// pre-change code), to show which checks the old behaviour fails.
const dbPath = process.env.FINANCE_DB_PATH ?? "";
if (!dbPath || /(^|\/)finance\.db$/.test(dbPath)) throw new Error("set FINANCE_DB_PATH to a scratch copy, never finance.db");
let fetchCalls = 0;
let llmReply: ((body: { messages: { content: string }[] }) => string) | null = null;
globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => {
fetchCalls++;
if (!llmReply) throw new Error("network blocked in verify-subscriptions");
const body = JSON.parse(init?.body ?? "{}");
return new Response(JSON.stringify({ choices: [{ message: { content: llmReply(body) }, finish_reason: "stop" }] }), { status: 200 });
}) as typeof fetch;
const LIB = process.env.SUBS_LIB ?? new URL("../lib", import.meta.url).pathname;
const subsMod = await import(`${LIB}/subscriptions.ts`);
const enrichMod = await import(`${LIB}/enrich.ts`).catch(() => null);
const { db } = await import(`${LIB}/db.ts`);
const { reconcileSubscriptions, listSubscriptions, updateSubscription } = subsMod;
let fails = 0;
const ok = (label: string, cond: boolean, extra = "") => {
console.log(`${cond ? "PASS" : "FAIL"} ${label}${extra ? " — " + extra : ""}`);
if (!cond) fails++;
};
const check = async (label: string, fn: () => unknown | Promise) => {
try { await fn(); } catch (e) { ok(label, false, `threw: ${e instanceof Error ? e.message : String(e)}`); }
};
const tidy = async () => {
if (enrichMod) return enrichMod.postSyncEnrich({ llm: false });
await reconcileSubscriptions({ plaid: false });
return null;
};
type Row = { id: string; merchant: string; entity: string; type: string | null; category: string | null; cadence: string;
monthly: number; isActive: boolean; state: string; group?: string; aliases?: { merchant: string }[]; lastDate: string | null };
const view = () => listSubscriptions(null) as { subs: Row[]; monthlyTotal: number; billsMonthly?: number; obligationsMonthly?: number; transfersMonthly?: number };
const grp = (s: Row) => s.group ?? (!s.isActive ? "ended" : s.type === "obligation" ? "bills" : "subs");
const live = (s: Row) => s.state === "active" || s.state === "trial";
const first = await tidy();
const v = view();
const personal = v.subs.filter((s) => s.entity === "personal");
await check("merge", () => {
const services: [string, RegExp][] = [
["Anthropic", /anthropic/i], ["Microsoft", /microsoft/i], ["Google Workspace", /google work|workspace/i],
["OpenAI", /openai/i], ["Vercel", /vercel/i], ["exe.dev", /exe\.?dev|^e dev$/i], ["Zoom", /zoom/i],
["Verizon", /verizon/i], ["PennyMac", /pennymac/i], ["Prudent Pet", /prudent pet/i], ["Prosper", /prosper/i],
["Kindle", /kindle/i], ["X", /^x$|x corp/i],
];
for (const [name, re] of services) {
const rows = personal.filter((s) => re.test(s.merchant));
ok(`merged: ${name} is one row`, rows.length === 1, rows.map((r) => r.merchant).join(" + "));
}
const uber = db().prepare(`SELECT entity, merged_into FROM subscriptions WHERE merchant = 'Uber'`).all() as { entity: string; merged_into: string | null }[];
ok("not merged across entities: Uber personal and business stay two rows", uber.length === 2 && uber.every((u) => !u.merged_into), uber.map((u) => u.entity).join(","));
});
await check("types", () => {
const TYPES = new Set(["subscription", "bill", "membership", "transfer"]);
const open = v.subs.filter((s) => grp(s) !== "ended");
const untyped = open.filter((s) => !s.type || !TYPES.has(s.type));
ok("rules type every live row but a short tail", untyped.length <= 3, `${untyped.length}: ${untyped.map((s) => s.merchant).join(", ")}`);
const raw = v.subs.filter((s) => s.category && /^[A-Z_]+$/.test(s.category) && s.category.includes("_") || s.category === "OTHER");
ok("no raw Plaid category codes on any row", raw.length === 0, raw.map((s) => `${s.merchant}=${s.category}`).join(", "));
const vercel = personal.find((s) => /vercel/i.test(s.merchant));
const x = personal.find((s) => /^x$|x corp/i.test(s.merchant));
ok("Vercel and X are not Dining", vercel?.category !== "Dining" && x?.category !== "Dining", `${vercel?.category} / ${x?.category}`);
});
await check("transfers", () => {
// The house cleaner (paid by Zelle) and the JPMorgan payment are bills by ruling.
const re = /invest529|529|fundrise|zelle|venmo|greenlight|prosper/i;
const rows = v.subs.filter((s) => re.test(s.merchant) && !/espericueta/i.test(s.merchant));
const wrong = rows.filter((s) => !["transfers", "ended"].includes(grp(s)));
ok("529, Fundrise, Zelle, Venmo, Greenlight, Prosper are transfers", rows.length >= 6 && wrong.length === 0,
wrong.map((s) => `${s.merchant}→${grp(s)}`).join(", ") || `${rows.length} rows`);
const heroSum = Math.round(v.subs.filter((s) => grp(s) === "subs" && live(s)).reduce((a, s) => a + s.monthly, 0) * 100) / 100;
ok("hero = sum of live subscription rows only", Math.abs(heroSum - v.monthlyTotal) < 0.02, `${heroSum} vs ${v.monthlyTotal}`);
const billSum = v.subs.filter((s) => grp(s) === "bills" && live(s)).reduce((a, s) => a + s.monthly, 0);
const bills = v.billsMonthly ?? v.obligationsMonthly ?? 0;
ok("bills total holds no transfer", Math.abs(billSum - bills) < 0.02 && rows.every((s) => grp(s) !== "bills"), `${bills}`);
ok("transfers carry their own subtotal", typeof v.transfersMonthly === "number" && v.transfersMonthly > 0, String(v.transfersMonthly));
});
await check("annual fee", () => {
const amex = v.subs.filter((s) => /amex membership renewal|renewal membership/i.test(s.merchant)
|| (s.aliases ?? []).some((a) => /amex membership renewal/i.test(a.merchant)));
const a = amex[0];
ok("AMEX renewal fee is yearly, monthly = annual/12", !!a && a.cadence === "yearly" && a.monthly < 100, a ? `${a.cadence} ${a.monthly}/mo` : "missing");
});
await check("ended", () => {
const ended = v.subs.filter((s) => grp(s) === "ended");
ok("lapsed rows sit in Ended", ["Hulu", "Quantconnect", "Buffer Plan"].every((m) => ended.some((s) => s.merchant === m)), ended.length + " ended");
const stale = v.subs.filter((s) => grp(s) !== "ended" && s.lastDate && (Date.now() - Date.parse(s.lastDate)) / 864e5 > 400);
ok("no row older than a year outside Ended", stale.length === 0, stale.map((s) => s.merchant).join(", "));
const r = db().prepare(`SELECT is_active FROM subscriptions WHERE id = 'personal:expense:chipotle mexican grill'`).get() as { is_active: number } | undefined;
ok("a row reconcile stops finding is ended in the table", r?.is_active === 0, JSON.stringify(r));
ok("ended rows add nothing to the hero", !ended.some((s) => grp(s) === "subs"));
});
await check("live fixes 2026-10-18", async () => {
type R = Row & { displayName?: string; oneOffs?: { count: number; total: number } | null; streams?: { live: boolean }[] };
const subs = v.subs as R[];
const find = (re: RegExp) => subs.find((s) => re.test(s.merchant) || re.test(s.displayName ?? ""));
const rec = await import(`${LIB}/recurring.ts`);
if (rec.isCardPayoff) {
const pay = ["CHASE CREDIT CRD AUTOPAY", "AMEX EPAYMENT ACH PMT", "CITI AUTOPAY PAYMENT", "CAPITAL ONE CRCARDPMT", "BARCLAYCARD US CREDITCARD"];
ok("card payoffs are recognised by descriptor", pay.every((d) => rec.isCardPayoff(d)), pay.filter((d) => !rec.isCardPayoff(d)).join(", "));
ok("an insurance autopay filed as a card payment is not a payoff", !rec.isCardPayoff("USAA P&C AUTOPAY"));
} else ok("isCardPayoff exists", false);
const usaa = find(/usaa p.?c/i);
ok("USAA P&C autopay is a live bill", usaa?.group === "bills" && (usaa?.monthly ?? 0) > 200, `${usaa?.group} ${usaa?.monthly}`);
ok("card payoffs stay off the surface", !subs.some((s) => /credit crd autopay|amex epayment/i.test(s.merchant)));
const exe = find(/exe\.?dev/i);
ok("a handed-off descriptor adds nothing (exe.dev = one $20 plan)", !!exe && exe.monthly < 25, String(exe?.monthly));
const pm = find(/pennymac/i);
ok("PennyMac merged rows count one mortgage", !!pm && Math.abs(pm.monthly - 6843.12) < 1, String(pm?.monthly));
const jpm = find(/jpmorgan chase ext/i);
ok("JPMorgan Chase Ext Trnsfr is a bill", jpm?.group === "bills", String(jpm?.group));
ok("no near-zero rows in any group", !subs.some((s) => s.monthly < 1), subs.filter((s) => s.monthly < 1).map((s) => s.merchant).join(", "));
const ro = find(/ro health/i);
ok("Ro Health monthly leaves out the $888 one-off", !!ro && ro.monthly < 600 && (ro.oneOffs?.total ?? 0) >= 888, `${ro?.monthly} ${JSON.stringify(ro?.oneOffs)}`);
const kin = find(/kindle/i);
ok("Kindle monthly leaves out book purchases", !!kin && kin.monthly < 30, `${kin?.monthly}`);
const apple = find(/^apple/i);
ok("Apple monthly leaves out app purchases and stopped plans", !!apple && apple.monthly < 40, String(apple?.monthly));
const uberSubs = subs.filter((s) => /uber/i.test(s.merchant) && s.group === "subs");
ok("Uber rides are not a subscription", uberSubs.length === 0, uberSubs.map((s) => `${s.entity} ${s.monthly}`).join(", "));
const maria = find(/espericueta|house cleaning/i);
ok("the house cleaner is a Home bill", maria?.group === "bills" && maria?.category === "Home" && maria?.displayName === "House cleaning (Maria)",
`${maria?.group} ${maria?.category} ${maria?.displayName}`);
ok("no Venmo descriptor merged into the cleaner", !(maria?.aliases ?? []).some((a) => /venmo/i.test(a.merchant)));
});
await check("user edits survive", async () => {
const edited = v.subs.find((s) => /hetzner/i.test(s.merchant))!; // any live row will do
const peacock = v.subs.find((s) => /peacock/i.test(s.merchant))!;
updateSubscription(edited.id, { type: "bill", category: "Travel" });
updateSubscription(peacock.id, { merchant: "Peacock Premium", type: "membership" });
const xdev = db().prepare(`SELECT id FROM subscriptions WHERE merchant = 'X Developer Platform'`).get() as { id: string };
const xroot = personal.find((s) => /^x$|x corp/i.test(s.merchant))!;
const vercelAlias = db().prepare(`SELECT id FROM subscriptions WHERE merchant = 'Vercel' AND merged_into IS NOT NULL`).get() as { id: string } | undefined;
ok("user merge accepted", !!subsMod.mergeSubscription?.(xdev.id, xroot.id));
ok("unmerge accepted", !!vercelAlias && !!subsMod.unmergeSubscription?.(vercelAlias.id));
await tidy(); await reconcileSubscriptions({ plaid: false }); await tidy();
const after = view().subs;
const u = after.find((s) => s.id === edited.id);
ok("user type + category survive reconcile and enrich", u?.type === "bill" && u?.category === "Travel", `${u?.type}/${u?.category}`);
const p = after.find((s) => s.id === peacock.id);
ok("user rename + type survive", p?.merchant === "Peacock Premium" && p?.type === "membership", `${p?.merchant}/${p?.type}`);
const xd = db().prepare(`SELECT merged_into FROM subscriptions WHERE id = ?`).get(xdev.id) as { merged_into: string | null };
ok("user merge survives", xd.merged_into === xroot.id);
const va = db().prepare(`SELECT merged_into, merge_lock FROM subscriptions WHERE id = ?`).get(vercelAlias?.id ?? "") as { merged_into: string | null; merge_lock: number } | undefined;
ok("unmerge survives (auto pass does not re-merge)", va?.merged_into === null && va?.merge_lock === 1, JSON.stringify(va));
const dism = db().prepare(`SELECT COUNT(*) AS n FROM subscriptions WHERE state = 'dismissed'`).get() as { n: number };
ok("dismissed rows stay dismissed", dism.n >= 4 && !after.some((s) => /tatte|airbnb/i.test(s.merchant)), String(dism.n));
});
await check("idempotent", async () => {
if (!enrichMod) { ok("postSyncEnrich exists", false); return; }
const r = await enrichMod.postSyncEnrich({ llm: false });
ok("second enrich pass changes nothing", r.merged === 0 && r.typed === 0 && r.categorized === 0 && r.errors.length === 0, JSON.stringify(r));
ok("enrich without LLM is fast", r.ms < 2000, `${r.ms}ms`);
const merged = (db().prepare(`SELECT COUNT(*) AS n FROM subscriptions WHERE merged_into IS NOT NULL`).get() as { n: number }).n;
ok("descriptors are merged (this pass or an earlier one)", merged > 0, `${merged} aliases; first pass ${JSON.stringify(first)}`);
});
await check("llm batch", async () => {
if (!enrichMod) { ok("postSyncEnrich exists", false); return; }
db().prepare(`UPDATE subscriptions SET type = NULL, type_source = NULL WHERE merchant IN ('Nanonoble Pte. Ltd.', 'Amazon')`).run();
const userBefore = db().prepare(`SELECT id, type FROM subscriptions WHERE type_source = 'user'`).all() as { id: string; type: string }[];
let seen = 0;
fetchCalls = 0;
llmReply = (body) => {
const items = JSON.parse(body.messages[body.messages.length - 1].content) as { id: string }[];
seen = items.length;
return JSON.stringify(items.map((i) => ({ id: i.id, type: "subscription", category: "Shopping" })));
};
const r = await enrichMod.postSyncEnrich({ llm: true });
ok("LLM batch: one sequential call, capped at 20 rows", fetchCalls === 1 && seen > 0 && seen <= 20, `calls=${fetchCalls} rows=${seen}`);
ok("LLM batch filled the untyped rows", (r.llm?.updated ?? 0) >= 2, JSON.stringify(r.llm));
const userAfter = db().prepare(`SELECT id, type FROM subscriptions WHERE type_source = 'user'`).all() as { id: string; type: string }[];
ok("LLM never overwrites a user edit", JSON.stringify(userBefore) === JSON.stringify(userAfter));
llmReply = () => { throw new Error("gateway down"); };
// Amazon is a registry brand now, so the rules label it; Nanonoble still goes to the LLM.
db().prepare(`UPDATE subscriptions SET type = NULL, type_source = NULL WHERE merchant = 'Nanonoble Pte. Ltd.'`).run();
let threw = false;
const r2 = await enrichMod.postSyncEnrich({ llm: true }).catch(() => { threw = true; return null; });
ok("gateway failure: enrich resolves, reports the error", !threw && !!r2 && r2.errors.length > 0, JSON.stringify(r2?.errors));
llmReply = null;
});
await check("category-seeded noise 2026-10-18", async () => {
type R = Row & { displayName?: string; count?: number };
const subs = view().subs as R[];
const name = (s: R) => s.displayName ?? s.merchant;
const prime = subs.find((s) => /amazon prime/i.test(name(s)));
ok("Amazon Prime is the yearly fee, not $139/mo", !!prime && prime.cadence === "yearly" && prime.monthly < 20, `${prime?.cadence} ${prime?.monthly}`);
const single = subs.filter((s) => s.isActive && s.count === 1 && s.cadence !== "yearly");
ok("no single-charge row is live unless yearly", !single.length, single.map(name).join(", "));
const shop = subs.filter((s) => grp(s) === "subs" && (/^amazon$/i.test(name(s)) || /^kindle$/i.test(name(s))));
ok("Amazon marketplace and Kindle book purchases are not subscriptions", !shop.length, shop.map((s) => `${name(s)} ${s.monthly}`).join(", "));
const ku = subs.find((s) => /kindle unlimited/i.test(name(s)));
ok("Kindle Unlimited stays a live subscription", !!ku && grp(ku) === "subs" && ku.monthly > 5, `${ku?.monthly}`);
const ro = subs.find((s) => /ro health/i.test(name(s)));
ok("Ro Health is a Healthcare bill, out of the hero", ro?.type === "bill" && ro?.category === "Healthcare" && grp(ro) !== "subs" && ro.monthly < 600, `${ro?.type} ${ro?.category} ${ro?.monthly}`);
db().prepare(`UPDATE subscriptions SET state = 'active' WHERE merchant LIKE '%wawa%' AND state = 'dismissed'`).run();
const rec = await import(`${LIB}/recurring.ts`);
const wawa = (rec.detectRecurring() as { merchant: string }[]).filter((r) => /wawa/i.test(r.merchant));
ok("Wawa store visits are not detected as recurring, even undismissed", !wawa.length, wawa.map((r) => r.merchant).join(", "));
});
await check("type override moves a row cleanly", async () => {
const m = db().prepare(`SELECT id FROM subscriptions WHERE merchant LIKE '%ESPERICUETA%' AND merged_into IS NULL`).get() as { id: string } | undefined;
if (!m) { ok("cleaner row present", false); return; }
const at = () => (view().subs as Row[]).find((s) => s.id === m.id);
updateSubscription(m.id, { type: "transfer" });
await tidy();
const a = at();
ok("override to transfer lands in Transfers with a Transfer category", a?.group === "transfers" && !!a?.category?.startsWith("Transfer"), `${a?.group} ${a?.category}`);
updateSubscription(m.id, { type: "bill" });
await tidy();
const b = at();
ok("override back to bill lands in Bills with a non-transfer category", b?.group === "bills" && !b?.category?.startsWith("Transfer"), `${b?.group} ${b?.category}`);
});
const fv = view();
const by = (g: string) => fv.subs.filter((s) => grp(s) === g);
console.log(`\nsnapshot after: hero ${fv.monthlyTotal}/mo · bills ${fv.billsMonthly ?? fv.obligationsMonthly} · transfers ${fv.transfersMonthly ?? "n/a"} · rows subs ${by("subs").length} bills ${by("bills").length} transfers ${by("transfers").length} ended ${by("ended").length}`);
console.log(fails ? `\n${fails} FAILED` : "\nALL PASS");
process.exitCode = fails ? 1 : 0;