1. The Problem
Build a budget tracker: add income and expense transactions with category and date. Show a running balance, totals by category, and a monthly summary. Persist to JSON.
Why this project? Reduce for financial aggregation, groupBy patterns using an object accumulator, Intl for currency display, date filtering with ISO string comparison — the building blocks of every fintech application.
2. How to Think About This
Each transaction is { id, type ("income"|"expense"), amount, category, date, note }. Balance = sum of incomes − sum of expenses. Category breakdown via reduce into an object. Monthly grouping by slicing the ISO date to YYYY-MM.
3. The Build
import { readFileSync, writeFileSync, existsSync } from "fs";
const DATA = "budget.json";
const fmt = amount =>
new Intl.NumberFormat("en-IN", { style:"currency", currency:"INR" }).format(amount);
export class BudgetTracker {
#txns;
constructor(txns = []) { this.#txns = txns; }
add({ type, amount, category, note = "", date = new Date().toISOString().slice(0,10) }) {
if (!["income","expense"].includes(type)) throw new Error("type must be income or expense");
const amt = parseFloat(amount);
if (isNaN(amt) || amt <= 0) throw new Error("Amount must be a positive number");
const txn = { id: Date.now(), type, amount: +amt.toFixed(2), category, note, date };
this.#txns.push(txn);
return txn;
}
balance() {
return +this.#txns.reduce((sum, t) =>
t.type === "income" ? sum + t.amount : sum - t.amount, 0).toFixed(2);
}
byCategory() {
return this.#txns.reduce((acc, t) => {
const key = `${t.type}:${t.category}`;
acc[key] = +(( acc[key] || 0) + t.amount).toFixed(2);
return acc;
}, {});
}
byMonth(month) { // "YYYY-MM"
return this.#txns.filter(t => t.date.startsWith(month));
}
summary() {
const income = +this.#txns.filter(t=>t.type==="income").reduce((s,t)=>s+t.amount,0).toFixed(2);
const expense = +this.#txns.filter(t=>t.type==="expense").reduce((s,t)=>s+t.amount,0).toFixed(2);
return { income, expense, balance: +(income - expense).toFixed(2) };
}
all() { return [...this.#txns]; }
toJSON() { return JSON.stringify(this.#txns, null, 2); }
}
function load() {
if (!existsSync(DATA)) return new BudgetTracker();
try { return new BudgetTracker(JSON.parse(readFileSync(DATA,"utf-8"))); } catch { return new BudgetTracker(); }
}
function save(b) { writeFileSync(DATA, b.toJSON()); }
const [,, cmd, ...args] = process.argv;
const budget = load();
switch (cmd) {
case "income":
case "expense": {
const [amount, category="General", ...noteParts] = args;
if (!amount) { console.error(`Usage: node budget.mjs ${cmd} <amount> [category] [note]`); break; }
try {
const t = budget.add({ type: cmd, amount, category, note: noteParts.join(" ") });
save(budget);
console.log(`${cmd === "income" ? "+" : "-"}${fmt(t.amount)} [${t.category}]`);
console.log(`Balance: ${fmt(budget.balance())}`);
} catch(e) { console.error(e.message); }
break;
}
case "summary": {
const { income, expense, balance } = budget.summary();
console.log(`
Income: ${fmt(income)}`);
console.log(`Expenses: ${fmt(expense)}`);
console.log(`Balance: ${fmt(balance)}`);
console.log("
By category:");
Object.entries(budget.byCategory())
.sort(([,a],[,b]) => b - a)
.forEach(([key, val]) => console.log(` ${key.padEnd(25)} ${fmt(val)}`));
break;
}
case "list":
if (!budget.all().length) { console.log("No transactions."); break; }
budget.all().slice(-20).forEach(t => {
const sign = t.type === "income" ? "+" : "-";
console.log(` ${t.date} ${sign}${fmt(t.amount).padStart(14)} [${t.category}] ${t.note}`);
});
break;
default:
console.log("Commands: income <amount> [category] [note] | expense <amount> [category] [note] | list | summary");
}
4. Test & Prove
import { BudgetTracker } from "./budget.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
const b = new BudgetTracker();
b.add({ type:"income", amount:5000, category:"Salary" });
b.add({ type:"expense", amount:1200, category:"Rent" });
b.add({ type:"expense", amount:300, category:"Food" });
test("balance is correct", () => { if(b.balance()!==3500) throw new Error(b.balance()); });
test("summary totals", () => { const s=b.summary(); if(s.income!==5000||s.expense!==1500) throw new Error(JSON.stringify(s)); });
test("byCategory keys", () => { const c=b.byCategory(); if(!("income:Salary" in c)) throw new Error(); });
test("income:Salary = 5000", () => { if(b.byCategory()["income:Salary"]!==5000) throw new Error(); });
test("negative amount throws",() => { try{b.add({type:"expense",amount:-100,category:"x"});throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
test("bad type throws", () => { try{b.add({type:"gift",amount:100,category:"x"});throw new Error("no");}catch(e){if(e.message==="no")throw e;} });
console.log(`
${p} passed, ${f} failed`);5. The Interface
node budget.mjs income 50000 Salary node budget.mjs expense 8000 Rent "Monthly rent" node budget.mjs expense 2500 Food "Groceries" node budget.mjs summary Income: ₹50,000.00 Expenses: ₹10,500.00 Balance: ₹39,500.00
6. Run It
node budget.mjs income 50000 Salary
node budget.mjs expense 8000 Rent
node budget.mjs summary
node budget.test.mjs🎯 Try this next
- Add monthly filtering:
node budget.mjs summary --month 2026-06 - Add budget limits per category — warn when spending exceeds 80% of limit
- Export to CSV:
node budget.mjs export > june.csv - Add a chart command: ASCII bar chart of spending by category
What you practised
Reduce for financial aggregation, groupBy accumulator pattern, Intl.NumberFormat for currency, ISO date string slicing for month grouping, and private class fields to protect transaction data.
Reference: Arrays · Date & Intl · OOP & Classes