Python

Bridge To Data And ML

Capstone Project: Expense Tracker

A small but complete Expense Tracker: add expenses, save/load them from a JSON file, and analyze spending with pandas. Deliberately, this project touches almost

JrCodex·7 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: All of Modules 1–5, Chapter 1, Chapter 2 Time to complete: ~45 minutes


Table of Contents

  1. What We're Building
  2. Project Structure
  3. Step 1 — Custom Exceptions
  4. Step 2 — The Expense Dataclass
  5. Step 3 — The ExpenseTracker Class
  6. Step 4 — Persistence with JSON
  7. Step 5 — Analysis with Pandas
  8. Step 6 — Tying It Together
  9. Step 7 — A Quick Test
  10. Where to Go From Here

1. What We're Building

A small but complete Expense Tracker: add expenses, save/load them from a JSON file, and analyze spending with pandas. Deliberately, this project touches almost everything from this entire Python curriculum:

Concepts Used                        From
─────────────────────────────────────────────────
  Classes, @dataclass                  Module 4, Module 5 Ch.4
  Custom exceptions                     Module 3 Ch.5
  File I/O, JSON                          Module 3 Ch.2-3
  Context managers (via `with`)             Module 5 Ch.3
  Decorators (a simple logging one)           Module 5 Ch.2
  Type hints                                    Module 5 Ch.3
  List comprehensions                             Module 2 Ch.5
  Pandas DataFrame & groupby                        This module, Ch.2
─────────────────────────────────────────────────

2. Project Structure

expense_tracker/
├── expenses.py       # Expense dataclass + custom exceptions
├── tracker.py          # ExpenseTracker class
├── main.py               # ties everything together, runs the program
└── expenses.json           # created automatically — the saved data

3. Step 1 — Custom Exceptions

Following Module 3, Chapter 5's pattern — a small hierarchy of domain-specific exceptions:

# expenses.py
 
class ExpenseError(Exception):
    """Base class for all expense-tracker-related errors."""
    pass
 
class InvalidAmountError(ExpenseError):
    """Raised when an expense amount is zero or negative."""
    pass
 
class InvalidCategoryError(ExpenseError):
    """Raised when a category isn't in the allowed list."""
    pass

4. Step 2 — The Expense Dataclass

Using @dataclass (Module 5, Chapter 4) instead of hand-writing __init__/__repr__:

# expenses.py (continued)
 
from dataclasses import dataclass, field
from datetime import date
 
ALLOWED_CATEGORIES = {"food", "transport", "rent", "entertainment", "other"}
 
@dataclass
class Expense:
    amount: float
    category: str
    description: str = ""
    expense_date: date = field(default_factory=date.today)   # safe mutable-ish default (Module 5 Ch.4)
 
    def __post_init__(self):
        """Runs automatically right after __init__ — the natural place for validation."""
        if self.amount <= 0:
            raise InvalidAmountError(f"Amount must be positive, got {self.amount}")
        if self.category not in ALLOWED_CATEGORIES:
            raise InvalidCategoryError(
                f"'{self.category}' is not a valid category. "
                f"Choose from: {', '.join(sorted(ALLOWED_CATEGORIES))}"
            )
 
    def to_dict(self) -> dict:
        """Convert to a JSON-serializable dict (dates aren't natively JSON-friendly)."""
        return {
            "amount": self.amount,
            "category": self.category,
            "description": self.description,
            "expense_date": self.expense_date.isoformat(),
        }
 
    @classmethod
    def from_dict(cls, data: dict) -> "Expense":
        """Alternative constructor (Module 4 Ch.1) — rebuilds an Expense from saved JSON."""
        return cls(
            amount=data["amount"],
            category=data["category"],
            description=data.get("description", ""),
            expense_date=date.fromisoformat(data["expense_date"]),
        )

__post_init__ is a @dataclass-specific hook — it runs immediately after the generated __init__ finishes, making it the natural home for validation logic that a plain dataclass doesn't do automatically.


5. Step 3 — The ExpenseTracker Class

This is where composition (Module 4, Chapter 4) comes in — ExpenseTracker has-a list of Expense objects, plus a small logging decorator (Module 5, Chapter 2) for visibility:

# tracker.py
 
from functools import wraps
from expenses import Expense
 
def log_action(func):
    """A simple decorator (Module 5, Ch.2) logging every tracker action."""
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        result = func(self, *args, **kwargs)
        print(f"[LOG] {func.__name__} called")
        return result
    return wrapper
 
 
class ExpenseTracker:
    def __init__(self):
        self.expenses: list[Expense] = []       # type-hinted instance attribute (Module 5 Ch.3)
 
    @log_action
    def add_expense(self, expense: Expense) -> None:
        self.expenses.append(expense)
 
    def total_spent(self) -> float:
        return sum(e.amount for e in self.expenses)      # generator expression, Module 5 Ch.1
 
    def by_category(self, category: str) -> list[Expense]:
        return [e for e in self.expenses if e.category == category]    # comprehension, Module 2 Ch.5
 
    def __len__(self) -> int:
        """Dunder method (Module 4 Ch.7) — enables len(tracker)."""
        return len(self.expenses)
 
    def __iter__(self):
        """Dunder method — enables `for expense in tracker:` (Module 5 Ch.1)."""
        return iter(self.expenses)

Notice __len__ and __iter__ — straight from Module 4/5's dunder method chapters — make ExpenseTracker behave naturally with Python's built-ins:

tracker = ExpenseTracker()
print(len(tracker))          # 0 — works because of __len__
for expense in tracker:         # works because of __iter__
    print(expense)

6. Step 4 — Persistence with JSON

Straight from Module 3's file handling and JSON chapters, using with for guaranteed cleanup:

# tracker.py (continued)
 
import json
from expenses import Expense
 
class ExpenseTracker:
    # ... (previous methods) ...
 
    def save_to_file(self, filepath: str) -> None:
        data = [e.to_dict() for e in self.expenses]
        with open(filepath, "w") as f:
            json.dump(data, f, indent=2)
 
    def load_from_file(self, filepath: str) -> None:
        try:
            with open(filepath, "r") as f:
                data = json.load(f)
        except FileNotFoundError:
            print(f"No saved data found at {filepath}, starting fresh.")
            return
        self.expenses = [Expense.from_dict(item) for item in data]

7. Step 5 — Analysis with Pandas

This is where Chapter 2's pandas primer plugs directly into the classes we've already built — converting our own objects into a DataFrame for analysis:

# tracker.py (continued)
 
import pandas as pd
 
class ExpenseTracker:
    # ... (previous methods) ...
 
    def to_dataframe(self) -> pd.DataFrame:
        """Bridge from our custom objects into a pandas DataFrame."""
        records = [e.to_dict() for e in self.expenses]
        return pd.DataFrame(records)
 
    def summary_by_category(self) -> pd.DataFrame:
        df = self.to_dataframe()
        if df.empty:
            return df
        return (
            df.groupby("category")["amount"]
            .agg(total="sum", average="mean", count="count")
            .sort_values("total", ascending=False)
        )

8. Step 6 — Tying It Together

# main.py
 
from datetime import date
from expenses import Expense, InvalidAmountError, InvalidCategoryError
from tracker import ExpenseTracker
 
def seed_sample_data(tracker: ExpenseTracker) -> None:
    sample_expenses = [
        (45.50, "food", "Groceries"),
        (12.00, "transport", "Bus pass"),
        (1200.00, "rent", "Monthly rent"),
        (30.00, "entertainment", "Movie night"),
        (60.25, "food", "Restaurant dinner"),
    ]
    for amount, category, description in sample_expenses:
        try:
            expense = Expense(amount=amount, category=category, description=description)
            tracker.add_expense(expense)
        except (InvalidAmountError, InvalidCategoryError) as e:
            print(f"Skipped invalid expense: {e}")
 
 
def main():
    tracker = ExpenseTracker()
    tracker.load_from_file("expenses.json")
 
    if len(tracker) == 0:
        seed_sample_data(tracker)
 
    print(f"\nTotal expenses tracked: {len(tracker)}")
    print(f"Total spent: ${tracker.total_spent():.2f}\n")
 
    print("Spending by category:")
    print(tracker.summary_by_category())
 
    tracker.save_to_file("expenses.json")
    print("\nSaved to expenses.json")
 
 
if __name__ == "__main__":     # Module 3 Ch.1 — script vs import behavior
    main()
$ python main.py
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
[LOG] add_expense called
 
Total expenses tracked: 5
Total spent: $1347.75
 
Spending by category:
                total     average  count
category
rent          1200.00  1200.000000      1
food           105.75    52.875000      2
entertainment   30.00    30.000000      1
transport       12.00    12.000000      1
 
Saved to expenses.json

9. Step 7 — A Quick Test

Following Module 5, Chapter 5's pytest patterns:

# test_expenses.py
 
import pytest
from expenses import Expense, InvalidAmountError, InvalidCategoryError
 
def test_valid_expense():
    e = Expense(amount=50.0, category="food")
    assert e.amount == 50.0
    assert e.category == "food"
 
def test_negative_amount_raises():
    with pytest.raises(InvalidAmountError):
        Expense(amount=-10, category="food")
 
def test_invalid_category_raises():
    with pytest.raises(InvalidCategoryError):
        Expense(amount=50, category="not_a_real_category")
 
@pytest.mark.parametrize("amount,category", [
    (10.0, "food"),
    (500.0, "rent"),
    (1.0, "other"),
])
def test_various_valid_expenses(amount, category):
    e = Expense(amount=amount, category=category)
    assert e.amount == amount
$ pytest test_expenses.py -v
test_expenses.py::test_valid_expense PASSED
test_expenses.py::test_negative_amount_raises PASSED
test_expenses.py::test_invalid_category_raises PASSED
test_expenses.py::test_various_valid_expenses[10.0-food] PASSED
test_expenses.py::test_various_valid_expenses[500.0-rent] PASSED
test_expenses.py::test_various_valid_expenses[1.0-other] PASSED

10. Where to Go From Here

This capstone deliberately stayed small enough to read in one sitting, but every piece is extensible using tools already covered in this curriculum:

Extension IdeaConcepts to Apply
Add a CLI with monthly filteringargparse (standard library), datetime comparisons
Add a budget-limit warning@property with validation (Module 4, Ch.5)
Support multiple currenciesDunder methods on a Money class (Module 4, Ch.7)
Visualize spendingmatplotlib, introduced in the Machine Learning notes
Load expenses from a bank CSV exportcsv.DictReader (Module 3, Ch.3)

You've now completed the entire Python curriculum — from print("Hello") in Module 1 to a working, tested, data-analyzed application here. The natural next step is the Machine Learning Notes, which assume exactly this level of Python, NumPy, and pandas comfort as their starting point.


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Python Index