Tier 2 — Intermediate

Expense Tracker

Track spending by category — private class fields, reduce aggregation, JSON persistence, CSV export.

🐍 Python 🟨 JavaScript

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

expenses.mjs
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

expenses.test.mjs
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

  1. Add a monthly budget per category — warn when spending exceeds 80%
  2. Add date filtering: node expenses.mjs list --month 2026-06
  3. Add a chart command that prints an ASCII bar chart of spending by category
  4. 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

Try It Yourself