Ctrl K
ComunidadGuías…

scripts/verify-recategorize.mts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Recategorization checks against a FRESH temp db; no network (LLM off, no Gmail).
//   FINANCE_DB_PATH=$TMPDIR/vr.db npx tsx scripts/verify-recategorize.mts
import fs from "node:fs";
const dbPath = process.env.FINANCE_DB_PATH ?? "";
if (!dbPath || /finance\.db$/.test(dbPath)) throw new Error("set FINANCE_DB_PATH to a throwaway file");
for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) fs.rmSync(f, { force: true });
delete process.env.ORDERS_GMAIL_ACCOUNT;
// config.ts requires these at import; nothing here calls Plaid or decrypts.
process.env.PLAID_CLIENT_ID ??= "x";
process.env.PLAID_SECRET ??= "x";
process.env.APP_ENC_KEY ??= "0".repeat(64);

const { db } = await import("../lib/db.ts");
const R = await import("../lib/recategorize.ts");
const O = await import("../lib/orders.ts");
const { rememberMerchantCategory } = await import("../lib/categorize.ts");

const ok = (l: string, c: boolean, extra = "") => { console.log(`${c ? "PASS" : "FAIL"}  ${l}${extra ? " — " + extra : ""}`); if (!c) process.exitCode = 1; };
const d = db();
O.ensureOrdersSchema();
d.exec(`INSERT INTO items (id, institution, access_token) VALUES ('I1', 'Bank', 'x');
        INSERT INTO accounts (id, item_id, name, type, subtype) VALUES ('C1', 'I1', 'Card', 'credit', 'credit card');`);
const ins = d.prepare(`INSERT INTO transactions (id, account_id, date, name, merchant, amount, plaid_category, plaid_detailed, category, category_source, confidence)
  VALUES (?, 'C1', ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const MKT = "GENERAL_MERCHANDISE_ONLINE_MARKETPLACES";
ins.run("fresh", "2026-10-01", "AMAZON FRESH*AB12", "Amazon", 54.2, "GENERAL_MERCHANDISE", MKT, "Shopping", "plaid", 0.5);
ins.run("kindle", "2026-10-02", "Kindle Svcs*597DF0EO2", "Amazon Kindle", 8.49, "ENTERTAINMENT", "ENTERTAINMENT_OTHER_ENTERTAINMENT", "Groceries", "rule", 1);
ins.run("elec", "2026-10-16", "AMAZON MKTPL*5E71V19D1", "Amazon", 170.66, "GENERAL_MERCHANDISE", MKT, "Groceries", "rule", 1);
ins.run("noreceipt", "2026-10-16", "AMAZON MKTPL*ZZZ", "Amazon", 33.33, "GENERAL_MERCHANDISE", MKT, "Groceries", "rule", 1);
ins.run("manual", "2026-10-03", "AMAZON MKTPL*MAN", "Amazon", 25, "GENERAL_MERCHANDISE", MKT, "Groceries", "manual", 1);
ins.run("royalty", "2026-10-04", "AMAZON MEDIA EU S.A R.L. PAYMENT NTE*ADD", "Amazon", -75.17, "INCOME", "INCOME_CONTRACTOR", "Groceries", "rule", 1);
ins.run("cleaner", "2026-10-06", "Venmo", null, 480, "TRANSFER_OUT", "TRANSFER_OUT_TRANSFER_OUT_FROM_APPS", "Transfer:P2P", "plaid", 0.5);
ins.run("venmo", "2026-10-07", "Venmo", null, 1289, "TRANSFER_OUT", "TRANSFER_OUT_TRANSFER_OUT_FROM_APPS", "Transfer:P2P", "plaid", 0.5);
ins.run("jpm", "2026-09-22", "JPMorgan Chase Ext Trnsfr", null, 688.98, "OTHER", "OTHER_OTHER", "Other", "plaid", 0.5);
ins.run("prosper", "2026-09-03", "PROSPER MARKETPL EDI PYMNTS", "Prosper Marketplace - Micro-lending", 100, "LOAN_PAYMENTS", "LOAN_PAYMENTS_OTHER_PAYMENT", "Other", "plaid", 0.5);
// An electronics shipment email for the 170.66 charge.
d.prepare(`INSERT INTO order_receipts (id, source, kind, order_id, date, total, label, items, subject)
  VALUES ('m1', 'gmail', 'shipped', '114-7163212-6027432', '2026-10-15', 170.66, 'Electronics', '[]', 'Shipped 1 item: Electronics')`).run();
d.exec(`INSERT INTO rules (match_type, pattern, field, category, source, priority) VALUES ('contains', 'Amazon', 'merchant', 'Groceries', 'llm', 50)`);

// Parsers
ok("subject label, new format", O.subjectLabel("Ordered 3 items: Breakfast Cereal, Pet Supplies, and more") === "Breakfast Cereal, Pet Supplies");
ok("subject label, count format", O.subjectLabel("Ordered: ⁦9⁩ Grocery and Snack Foods items") === "Grocery and Snack Foods", String(O.subjectLabel("Ordered: ⁦9⁩ Grocery and Snack Foods items")));
ok("total from shipment text", O.parseTotal("Order #\n114-1\nTotal\n$7.20\nView") === 7.2);
ok("grand total wins", O.parseTotal("Grand Total:\n$170.66\nTotal\n$1.00") === 170.66);
ok("items parse", JSON.stringify(O.parseItems("x\nZojirushi Coffee...\nQuantity: 1\n$\n71\n99")) === JSON.stringify([{ name: "Zojirushi Coffee", qty: 1, price: 71.99 }]));
const csv = O.receiptsFromCsv(`"Order ID","Order Date","Ship Date","Product Name","Quantity","Total Owed"\n"111-1","2026-10-01T10:00:00Z","2026-10-02T01:00:00Z","USB-C Cable","1","12.50"\n"111-1","2026-10-01T10:00:00Z","2026-10-02T01:00:00Z","Dog Treats","1","7.50"`);
ok("csv: one receipt per order+ship date, summed", csv.length === 1 && csv[0].total === 20 && csv[0].items.length === 2);

const m = O.matchReceipts();
ok("receipt matched to the charge by total and date", m.matched === 1 && O.receiptForTxn("elec")?.order_id === "114-7163212-6027432");

// Full pass without the model
const run = async () => {
  const rules = R.loadRules().filter((r) => r.source !== "llm");
  const plans = await R.planRows(R.loadTxns(), { llm: false, rules });
  return { plans, changes: R.diffPlans(plans) };
};
const first = await run();
R.writeChanges(first.changes);
const get = (id: string) => d.prepare(`SELECT category, category_source AS src, merchant, confidence FROM transactions WHERE id=?`).get(id) as
  { category: string; src: string; merchant: string | null; confidence: number };

ok("Amazon Fresh -> Groceries", get("fresh").category === "Groceries", JSON.stringify(get("fresh")));
ok("Amazon Fresh keeps an honest display name", get("fresh").merchant === "Amazon Fresh");
ok("Kindle -> Subscriptions", get("kindle").category === "Subscriptions");
ok("electronics marketplace order -> Electronics (receipt)", get("elec").category === "Electronics" && get("elec").merchant === "Amazon", JSON.stringify(get("elec")));
ok("marketplace charge with no receipt -> Shopping, low confidence (To-Review)", get("noreceipt").category === "Shopping" && get("noreceipt").confidence < R.REVIEW_BELOW);
ok("manual row untouched", get("manual").category === "Groceries" && get("manual").src === "manual");
ok("KDP royalty credit -> Income", get("royalty").category === "Income");
ok("cleaner on Venmo -> Home", get("cleaner").category === "Home");
ok("other Venmo stays P2P", get("venmo").category === "Transfer:P2P");
ok("JPMorgan ext transfer counts as spending, not Transfer", !get("jpm").category.startsWith("Transfer") && get("jpm").category !== "Other", get("jpm").category);
ok("Prosper hold row untouched", get("prosper").category === "Other" && get("prosper").src === "plaid");

const second = await run();
ok("idempotent: second pass changes nothing", second.changes.length === 0, `${second.changes.length} changes`);

const audit = R.auditRules(R.loadRules(), second.plans);
ok("audit deletes the blanket Amazon rule", audit.deletes.some((x) => x.rule.pattern === "Amazon"));

// A hand pick on Amazon must not spread to every Amazon row.
ok("manual pick on a multi-category merchant writes no rule", rememberMerchantCategory("noreceipt", "Groceries") === null);

// After-sync entry point: never throws, never touches manual rows.
d.prepare(`INSERT INTO transactions (id, account_id, date, name, merchant, amount, plaid_category, plaid_detailed, category, category_source, confidence)
  VALUES ('new1', 'C1', '2026-10-17', 'Kindle Unltd*0Z5SV9O63', 'Amazon Kindle', 11.99, 'ENTERTAINMENT', 'ENTERTAINMENT_OTHER_ENTERTAINMENT', 'Entertainment', 'plaid', 0.5)`).run();
const after = await R.classifyNewTransactions({ llm: false, fetchReceipts: false });
ok("classifyNewTransactions labels a new Kindle Unlimited row", !after.error && get("new1").category === "Subscriptions" && get("new1").merchant === "Kindle Unlimited", JSON.stringify(after));
ok("classifyNewTransactions left the manual row alone", get("manual").category === "Groceries" && get("manual").src === "manual");