1. The Problem
Build a command-line expense tracker. Add expenses with a description, amount, and category. Show a summary by category. Export to CSV. Persist data in a JSON file between sessions.
Why this project? File I/O for persistence, private class fields for encapsulation, reduce for aggregation, CSV generation, and the full CRUD loop without a database.
2. How to Think About This
Keep it in three layers: the ExpenseTracker class (pure data logic — no I/O), a persistence layer (load/save from JSON file), and a CLI (reads argv and calls the right method). The class is fully testable without touching the filesystem.
3. The Build
import { readFileSync, writeFileSync, existsSync } from "fs";
const DATA_FILE = "expenses.json";
// ── Data class (pure — no file I/O) ──────────────────────────────────────
export class ExpenseTracker {
#expenses;
constructor(expenses = []) {
this.#expenses = expenses;
}
add(description, amount, category = "General") {
if (!description.trim()) throw new Error("Description required");
const amt = parseFloat(amount);
if (isNaN(amt) || amt <= 0) throw new Error("Amount must be a positive number");
const expense = {
id: this.#expenses.length + 1,
description: description.trim(),
amount: +amt.toFixed(2),
category: category.trim() || "General",
date: new Date().toISOString().slice(0, 10),
};
this.#expenses.push(expense);
return expense;
}
delete(id) {
const before = this.#expenses.length;
this.#expenses = this.#expenses.filter(e => e.id !== Number(id));
return this.#expenses.length < before;
}
total() { return +this.#expenses.reduce((s, e) => s + e.amount, 0).toFixed(2); }
all() { return [...this.#expenses]; }
summary() {
return Object.entries(
this.#expenses.reduce((acc, e) => {
acc[e.category] = (acc[e.category] ?? 0) + e.amount;
return acc;
}, {})
).map(([category, total]) => ({ category, total: +total.toFixed(2) }))
.sort((a, b) => b.total - a.total);
}
toCSV() {
const header = "id,description,amount,category,date";
const rows = this.#expenses.map(e =>
`${e.id},"${e.description.replace(/"/g,'""')}",${e.amount},${e.category},${e.date}`
);
return [header, ...rows].join("
");
}
toJSON() { return JSON.stringify(this.#expenses, null, 2); }
}
// ── Persistence ───────────────────────────────────────────────────────────
function load() {
if (!existsSync(DATA_FILE)) return new ExpenseTracker();
try {
return new ExpenseTracker(JSON.parse(readFileSync(DATA_FILE, "utf-8")));
} catch { return new ExpenseTracker(); }
}
function save(tracker) {
writeFileSync(DATA_FILE, tracker.toJSON(), "utf-8");
}
// ── CLI ───────────────────────────────────────────────────────────────────
const [,, cmd, ...args] = process.argv;
const tracker = load();
switch (cmd) {
case "add": {
const [desc, amount, category] = args;
if (!desc || !amount) { console.error("Usage: node expenses.mjs add <desc> <amount> [category]"); break; }
const e = tracker.add(desc, amount, category);
save(tracker);
console.log(`Added: ${e.description} — $${e.amount} (${e.category})`);
break;
}
case "list":
if (tracker.all().length === 0) { console.log("No expenses yet."); break; }
tracker.all().forEach(e =>
console.log(` #${e.id} ${e.date} ${e.description.padEnd(20)} $${String(e.amount).padStart(8)} [${e.category}]`)
);
console.log(`
Total: $${tracker.total()}`);
break;
case "summary":
console.log("Spending by category:
");
tracker.summary().forEach(({ category, total }) =>
console.log(` ${category.padEnd(20)} $${String(total).padStart(8)}`)
);
console.log(`
Total: $${tracker.total()}`);
break;
case "delete": {
const [id] = args;
if (tracker.delete(id)) { save(tracker); console.log(`Deleted expense #${id}`); }
else console.error(`Expense #${id} not found`);
break;
}
case "export":
process.stdout.write(tracker.toCSV() + "
");
break;
default:
console.log("Commands: add <desc> <amount> [category] | list | summary | delete <id> | export");
}
4. Test & Prove
import { ExpenseTracker } from "./expenses.mjs";
let pass=0,fail=0;
function test(desc,fn){try{fn();console.log(" ✓",desc);pass++;}catch(e){console.log(" ✗",desc,"—",e.message);fail++;}}
const t = new ExpenseTracker();
t.add("Coffee",2.50,"Food");
t.add("Bus",1.20,"Transport");
t.add("Lunch",12,"Food");
test("total is correct", () => { if(t.total()!==15.70) throw new Error(t.total()); });
test("all() length", () => { if(t.all().length!==3) throw new Error(); });
test("summary sorts desc", () => { const s=t.summary(); if(s[0].category!=="Food") throw new Error(s[0].category); });
test("food total=14.5", () => { const s=t.summary(); const f=s.find(x=>x.category==="Food"); if(f.total!==14.50) throw new Error(f.total); });
test("delete works", () => { t.delete(1); if(t.all().length!==2) throw new Error(); });
test("negative amount throws",() =>{ try{t.add("x",-5);throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("empty desc throws", () => { try{t.add(" ",5);throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });
test("CSV has header", () => { if(!t.toCSV().startsWith("id,description")) throw new Error(); });
console.log(\`
\${pass} passed, \${fail} failed\`);
5. The Interface
node expenses.mjs add "Coffee" 2.50 Food node expenses.mjs add "Lunch" 12.00 Food node expenses.mjs list node expenses.mjs summary node expenses.mjs delete 1 node expenses.mjs export > report.csv
6. Run It
node expenses.mjs add "Coffee" 2.50 Food
node expenses.mjs list
node expenses.mjs summary
node expenses.test.mjs🎯 Try this next
- Add a monthly budget per category — warn when spending exceeds 80%
- Add date filtering:
node expenses.mjs list --month 2026-06 - Add a
chartcommand that prints an ASCII bar chart of spending by category - Port to a browser app with a form and localStorage
What you practised
Private class fields (#expenses), reduce for aggregation,
JSON file persistence, CSV generation with proper quoting, command-line subcommands
via switch/case, and separating pure logic from I/O for testability.
Reference: OOP & Classes · Arrays · Error Handling