Expense categorizer
@ledgerly/expense-categorizer
Categorize a bank or card CSV export with a rules file you can edit. Writes a categorized CSV, spend by category and by month, and a review list of unknown merchants with suggested rules. Offline and repeatable.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Categorize bank or card transactions with an editable rules file.
- 3// Usage: node scripts/categorize.mjs <transactions.csv> --rules <rules.csv> [--out out/expenses] [--spend-sign auto|negative|positive]
- 4// Rules CSV columns: pattern, category, match (contains | starts_with | exact | regex; default contains),
- 5// optional min_amount, max_amount. First matching rule wins, so put specific rules above general ones.
- 6import { fail, mdTable, money, monthKey, num, parseArgs, parseDate, readCsv, round, toCsv, writeOut } from "./lib.mjs";
- 8const args = parseArgs(process.argv.slice(2));
- 9const input = args._[0];
- 10const rulesPath = args.rules;
- 11const outPrefix = args.out || "out/expenses";
- 12if (!input || !rulesPath) fail("usage: node scripts/categorize.mjs <transactions.csv> --rules <rules.csv> [--out out/expenses]");
- 14const tx = readCsv(input, "transactions CSV");
- 15if (!tx.length) fail(`${input} has no rows.`);
- 16const cols = Object.keys(tx[0]);
- 17const pick = (names) => names.find((n) => cols.includes(n));
- 18const dateCol = pick(["date", "transaction date", "posted date", "posting date", "trans. date", "booking date"]);
- 19const descCol = pick(["description", "merchant", "payee", "name", "details", "memo", "narrative"]);
- 20const amountCol = pick(["amount", "amount (usd)", "transaction amount"]);
- 21const debitCol = pick(["debit", "withdrawal", "withdrawals", "money out"]);
- 22const creditCol = pick(["credit", "deposit", "deposits", "money in"]);
- 23if (!dateCol || !descCol || (!amountCol && !debitCol))
- 24 fail(`could not find date, description, and amount columns in ${input}. Found: ${cols.join(", ")}. Rename them to date, description, amount (or debit and credit).`);
- 26const ruleRows = readCsv(rulesPath, "rules CSV", ["pattern", "category"]);
- 27const rules = ruleRows
- 28 .filter((r) => r.pattern && r.category && !r.pattern.startsWith("#"))
- 29 .map((r, i) => {
- 30 const match = (r.match || "contains").toLowerCase();
- 31 let re = null;
- 32 if (match === "regex") {
- 33 try {
- 34 re = new RegExp(r.pattern, "i");
- 35 } catch (e) {
- 36 fail(`rule ${i + 1} ("${r.pattern}") is not a valid regex: ${e.message}`);
- 37 }
- 38 }
- 39 return { line: i + 2, pattern: r.pattern, p: r.pattern.toLowerCase(), category: r.category, match, re, min: num(r.min_amount), max: num(r.max_amount) };
- 40 });
- 42// Decide which sign means money out.
- 43let spendSign = args["spend-sign"] || "auto";
- 44if (spendSign === "auto" && amountCol) {
- 45 const vals = tx.map((r) => num(r[amountCol])).filter(Number.isFinite);
- 46 const neg = vals.filter((v) => v < 0).length;
- 47 spendSign = neg >= vals.length / 2 ? "negative" : "positive";
- 48}
- 50function normalize(desc) {
- 51 return desc
- 52 .toUpperCase()
- 53 .replace(/^(POS|DEBIT CARD PURCHASE|CHECKCARD|PURCHASE|ACH DEBIT|ACH)\s+/, "")
- 54 .replace(/^(SQ|TST|SP|PP|PAYPAL)\s?\*\s?/, "")
- 55 .replace(/[#*]\S*/g, " ")
- 56 .replace(/\b\d[\d/.-]*\b/g, " ")
- 57 .replace(/\b(INC|LLC|LTD|CO|COM|WWW|HTTPS?)\b/g, " ")
- 58 .replace(/[^A-Z&' ]/g, " ")
- 59 .replace(/\s+/g, " ")
- 60 .trim()
- 61 .split(" ")
- 62 .slice(0, 3)
- 63 .join(" ");
- 64}
- 66function matchRule(desc, amount) {
- 67 const d = desc.toLowerCase();
- 68 for (const r of rules) {
- 69 if (Number.isFinite(r.min) && amount < r.min) continue;
- 70 if (Number.isFinite(r.max) && amount > r.max) continue;
- 71 if (r.match === "regex" ? r.re.test(desc) : r.match === "exact" ? d === r.p : r.match === "starts_with" ? d.startsWith(r.p) : d.includes(r.p)) return r;
- 72 }
- 73 return null;
- 74}
- 76const out = [];
- 77const skipped = [];
- 78tx.forEach((r, i) => {
- 79 const d = parseDate(r[dateCol]);
- 80 let signed;
- 81 if (amountCol) signed = num(r[amountCol]);
- 82 else signed = (Number.isFinite(num(r[creditCol])) ? num(r[creditCol]) : 0) - (Number.isFinite(num(r[debitCol])) ? num(r[debitCol]) : 0);
- 83 if (!d || !Number.isFinite(signed)) {
- 84 skipped.push(i + 2);
- 85 return;
- 86 }
- 87 const outflow = amountCol ? (spendSign === "negative" ? signed < 0 : signed > 0) : signed < 0;
- 88 const amount = Math.abs(signed);
- 89 const desc = r[descCol] || "";
- 90 const rule = matchRule(desc, amount);
- 91 const category = rule ? rule.category : outflow ? "Uncategorized" : "Uncategorized income";
- 92 out.push({ date: d.toISOString().slice(0, 10), month: monthKey(d), description: desc, amount: round(amount), direction: outflow ? "out" : "in", category, rule: rule ? `${rule.match}:${rule.pattern}` : "", merchant_key: normalize(desc) });
- 93});
- 95const outflows = out.filter((r) => r.direction === "out");
- 96const inflows = out.filter((r) => r.direction === "in");
- 97const byCat = new Map();
- 98for (const r of outflows) byCat.set(r.category, (byCat.get(r.category) || 0) + r.amount);
- 99const totalOut = outflows.reduce((a, r) => a + r.amount, 0);
- 100const months = [...new Set(out.map((r) => r.month))].sort();
- 102const unknown = new Map();
- 103for (const r of out.filter((x) => x.category.startsWith("Uncategorized"))) {
- 104 const k = r.merchant_key || r.description;
- 105 const u = unknown.get(k) || { merchant_key: k, count: 0, total: 0, direction: r.direction, example: r.description };
- 106 u.count++;
- 107 u.total += r.amount;
- 108 unknown.set(k, u);
- 109}
- 110const unknownList = [...unknown.values()].sort((a, b) => b.total - a.total).map((u) => ({ ...u, total: round(u.total), suggested_rule: `${u.merchant_key.toLowerCase()},<category>,contains` }));
- 112writeOut(`${outPrefix}-categorized.csv`, toCsv(out, ["date", "description", "amount", "direction", "category", "rule", "merchant_key"]));
- 113writeOut(`${outPrefix}-unknowns.csv`, toCsv(unknownList, ["merchant_key", "direction", "count", "total", "example", "suggested_rule"]));
- 115const categorizedShare = outflows.length ? outflows.filter((r) => r.category !== "Uncategorized").reduce((a, r) => a + r.amount, 0) / totalOut : 1;
- 116const summary = {
- 117 source: input,
- 118 rules: rulesPath,
- 119 spend_sign: amountCol ? spendSign : "debit/credit columns",
- 120 transactions: out.length,
- 121 skipped_rows: skipped.length,
- 122 outflow_total: round(totalOut),
- 123 inflow_total: round(inflows.reduce((a, r) => a + r.amount, 0)),
- 124 categorized_share_of_spend: round(categorizedShare, 4),
- 125 by_category: Object.fromEntries([...byCat.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => [k, round(v)])),
- 126 uncategorized_count: out.filter((r) => r.category.startsWith("Uncategorized")).length,
- 127 unknown_merchants: unknownList.length,
- 128};
- 129writeOut(`${outPrefix}-summary.json`, JSON.stringify(summary, null, 2) + "\n");
- 131const cats = [...byCat.keys()].sort((a, b) => byCat.get(b) - byCat.get(a));
- 132const monthTable = cats.map((c) => [c, ...months.map((m) => money(outflows.filter((r) => r.category === c && r.month === m).reduce((a, r) => a + r.amount, 0))), money(byCat.get(c))]);
- 133const md = [
- 134 `# Spend by category: ${months[0]} to ${months[months.length - 1]}`,
- 135 "",
- 136 `${out.length} transactions. Money out ${money(totalOut)}, money in ${money(summary.inflow_total)}. ${(categorizedShare * 100).toFixed(1)}% of spend matched a rule.${skipped.length ? ` Skipped ${skipped.length} row(s) with no date or amount.` : ""}`,
- 137 "",
- 138 "## Money out by category",
- 139 "",
- 140 mdTable(["Category", "Total", "Share"], cats.map((c) => [c, money(byCat.get(c)), `${((byCat.get(c) / totalOut) * 100).toFixed(1)}%`])),
- 141 "",
- 142 "## By month",
- 143 "",
- 144 mdTable(["Category", ...months, "Total"], monthTable),
- 145 "",
- 146 "## Needs review",
- 147 "",
- 148 unknownList.length
- 149 ? mdTable(["Merchant", "In/out", "Count", "Total", "Example", "Suggested rule"], unknownList.slice(0, 25).map((u) => [u.merchant_key, u.direction, u.count, money(u.total, 2), u.example, `\`${u.suggested_rule}\``]))
- 150 : "Nothing left uncategorized.",
- 151 "",
- 152].join("\n");
- 153writeOut(`${outPrefix}-summary.md`, md);
- 155process.stdout.write(`Wrote ${outPrefix}-categorized.csv, ${outPrefix}-summary.md, ${outPrefix}-summary.json, ${outPrefix}-unknowns.csv\n`);
- 156process.stdout.write(`${out.length} transactions, ${(categorizedShare * 100).toFixed(1)}% of spend categorized, ${unknownList.length} unknown merchant(s) to review.\n`);
