Skip to content
SlashClone
Made by@ledgerly

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.

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