RizTech Academy logo
RizTech Academy
Capstone ProjectLesson 3 of 560 min

Building it step by step

The longest lesson in the course. Work through it at the keyboard, running things as you go, and do not read ahead — the value is in building it, not in having read how.

Set the project up

Module 8's two-minute sequence:

mkdir expense-tracker && cd expense-tracker
git init
python -m venv .venv
source .venv/bin/activate
printf '.venv/\n__pycache__/\n*.pyc\n.env\n.pytest_cache/\n' > .gitignore
python -m pip install pytest ruff
# pyproject.toml
[project]
name = "expense-tracker"
version = "0.1.0"
requires-python = ">=3.12"

[project.scripts]
expenses = "expense_tracker.cli:main"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
mkdir -p src/expense_tracker tests
touch src/expense_tracker/__init__.py
python -m pip install -e .
git add -A && git commit -m "Set up project structure"

[project.scripts] is what makes expenses a real command. Everything is committed before there is anything to get wrong.

Step 1: the Expense type

# src/expense_tracker/models.py
from dataclasses import dataclass
from datetime import date

VALID_CATEGORIES = ("food", "transport", "rent", "other")
MAX_NOTE_LENGTH = 200


@dataclass(frozen=True)
class Expense:
    """A single recorded expense. Amounts are stored in paise."""

    id: int
    amount_paise: int
    category: str
    date: date
    note: str = ""

    def __post_init__(self) -> None:
        if self.amount_paise <= 0:
            raise ValueError(
                f"amount must be positive, got {self.amount_paise / 100:.2f}"
            )
        if self.category not in VALID_CATEGORIES:
            raise ValueError(
                f"unknown category {self.category!r}. "
                f"Valid: {', '.join(VALID_CATEGORIES)}"
            )
        if len(self.note) > MAX_NOTE_LENGTH:
            raise ValueError(f"note must be {MAX_NOTE_LENGTH} characters or fewer")

    @property
    def rupees(self) -> float:
        """The amount in rupees, for display only."""
        return self.amount_paise / 100

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "amount_paise": self.amount_paise,
            "category": self.category,
            "date": self.date.isoformat(),
            "note": self.note,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Expense":
        return cls(
            id=int(data["id"]),
            amount_paise=int(data["amount_paise"]),
            category=data["category"],
            date=date.fromisoformat(data["date"]),
            note=data.get("note", ""),
        )

Module 9's dataclass with __post_init__ validation, frozen=True so an expense cannot be edited into an invalid state, and to_dict/from_dict for the JSON boundary. The error messages name the offending value and the acceptable ones.

Note .get("note", "") in from_dict — an older file without notes still loads.

Try it in the REPL before moving on. Create a valid one, then a negative one, and read the message.

Step 2: storage

# src/expense_tracker/storage.py
import json
import os
from pathlib import Path

from .models import Expense


def data_file() -> Path:
    override = os.environ.get("EXPENSES_FILE")
    return Path(override) if override else Path.home() / ".expenses.json"


def load(path: Path) -> tuple[list[Expense], int]:
    """Return the stored expenses and the next id.

    A missing file is treated as empty. A corrupt one raises, so we never
    silently discard somebody's data.
    """
    if not path.exists():
        return [], 1

    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as error:
        raise ValueError(f"{path} is not valid JSON: {error}") from error

    expenses = [Expense.from_dict(item) for item in raw.get("expenses", [])]
    return expenses, int(raw.get("next_id", len(expenses) + 1))


def save(path: Path, expenses: list[Expense], next_id: int) -> None:
    """Write expenses to disk, replacing atomically."""
    payload = {
        "next_id": next_id,
        "expenses": [expense.to_dict() for expense in expenses],
    }
    path.parent.mkdir(parents=True, exist_ok=True)

    temporary = path.with_suffix(".tmp")
    temporary.write_text(
        json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
    )
    temporary.replace(path)

Three things worth pausing on.

The corrupt file raises rather than returning empty. Returning [], 1 would mean the next save destroys the file. Module 6's rule: fail where the problem is.

raise ... from error preserves the original for anyone debugging.

Writing to a temporary file and then replacing. Module 7 mentioned this as safer. replace() is atomic on every major platform, so a crash mid-write leaves the original intact rather than half a file. Three extra lines to make data loss impossible.

Step 3: add, end to end

# src/expense_tracker/cli.py
import argparse
import sys
from datetime import date

from .models import Expense
from .storage import data_file, load, save


def cmd_add(args: argparse.Namespace) -> int:
    path = data_file()
    expenses, next_id = load(path)

    expense = Expense(
        id=next_id,
        amount_paise=round(args.amount * 100),
        category=args.category,
        date=date.today(),
        note=args.note or "",
    )

    expenses.append(expense)
    save(path, expenses, next_id + 1)

    print(f"Added #{expense.id}: {expense.category} ₹{expense.rupees:,.2f}"
          + (f" ({expense.note})" if expense.note else ""))
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="expenses", description="Track expenses.")
    sub = parser.add_subparsers(dest="command", required=True)

    add = sub.add_parser("add", help="record an expense")
    add.add_argument("amount", type=float)
    add.add_argument("category")
    add.add_argument("--note", default="")
    add.set_defaults(func=cmd_add)

    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        return args.func(args)
    except ValueError as error:
        print(f"Error: {error}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
$ expenses add 250 food --note "lunch"
Added #1: food ₹250.00 (lunch)

$ expenses add -50 food
Error: amount must be positive, got -0.50

This is the milestone. Data persists between runs. Check the file exists, then commit.

Three details:

round(args.amount * 100) converts rupees to paise at the boundary. Inside the program it is always paise.

One try in main catches every ValueError from anywhere below and turns it into a message. The commands raise; the CLI translates. That is the separation module 9 recommended.

Returning an exit code — 0 for success, 1 for failure — is what makes a command-line tool usable in a script.

Step 4 and 5: list, then filters

# src/expense_tracker/reporting.py
from collections import defaultdict
from datetime import date

from .models import Expense


def filter_expenses(
    expenses: list[Expense],
    category: str | None = None,
    month: str | None = None,
) -> list[Expense]:
    """Filter by category and/or a YYYY-MM month string."""
    result = expenses
    if category:
        result = [e for e in result if e.category == category]
    if month:
        result = [e for e in result if e.date.isoformat().startswith(month)]
    return result


def total_paise(expenses: list[Expense]) -> int:
    return sum(e.amount_paise for e in expenses)


def by_category(expenses: list[Expense]) -> dict[str, int]:
    totals: dict[str, int] = defaultdict(int)
    for expense in expenses:
        totals[expense.category] += expense.amount_paise
    return dict(totals)


def format_table(expenses: list[Expense]) -> str:
    if not expenses:
        return "No expenses found."
    return "\n".join(
        f"  #{e.id:<4} {e.date}  {e.category:<10} ₹{e.rupees:>12,.2f}  {e.note}".rstrip()
        for e in expenses
    )

Every function here is pure — values in, values out, nothing printed and nothing read from disk. That is what makes the next lesson easy.

total_paise on an empty list returns 0, because sum([]) is 0. The edge case handles itself.

Add the list command in cli.py calling filter_expenses and format_table. Run it. Commit.

Step 6 and 7: summary and delete

def cmd_summary(args: argparse.Namespace) -> int:
    expenses, _ = load(data_file())
    if not expenses:
        print("No expenses recorded yet.")
        return 0

    total = total_paise(expenses)
    print(f"Total:        ₹{total / 100:,.2f}\n")

    for category, amount in sorted(
        by_category(expenses).items(), key=lambda pair: pair[1], reverse=True
    ):
        share = amount / total * 100
        print(f"  {category:<10} ₹{amount / 100:>12,.2f}   ({share:.1f}%)")

    months = {e.date.isoformat()[:7] for e in expenses}
    print(f"\nAcross {len(months)} month(s) — average ₹{total / 100 / len(months):,.2f}")
    return 0


def cmd_delete(args: argparse.Namespace) -> int:
    path = data_file()
    expenses, next_id = load(path)

    match = next((e for e in expenses if e.id == args.id), None)
    if match is None:
        raise ValueError(f"no expense with id {args.id}")

    expenses.remove(match)
    save(path, expenses, next_id)
    print(f"Deleted #{match.id}: {match.category} ₹{match.rupees:,.2f}")
    return 0

The empty check comes first, because the average divides by the month count and sorted on an empty dictionary would print nothing useful anyway. Module 6's ZeroDivisionError, prevented by a guard clause.

{...[:7] for e in expenses} is a set comprehension from module 4 — unique months, counted.

next((...), None) finds the first match or None, replacing a loop with a flag.

next_id is not decremented on delete. Deliberate, from the planning lesson: ids are never reused.

Step 8: check the failure cases

Go through your failure table and try every row:

$ expenses add abc food
$ expenses add 0 food
$ expenses add 100 groceries
$ expenses delete 999
$ echo "not json" > ~/.expenses.json && expenses list

Each should print a clear message and exit non-zero — not a traceback. A traceback reaching a user is a bug.

argparse handles the first one for you, which is the reason to use it.

Check your work

The milestone is step 3. Once data survives between runs, the program is real and everything after is addition rather than construction. If you got that far, the hard part is done.

Atomic writes.

temporary = path.with_suffix(".tmp")
temporary.write_text(...)
temporary.replace(path)

replace() is atomic on every major platform, so a crash mid-write leaves the original intact rather than half a file. Three extra lines to make data loss impossible.

One try in main catches every ValueError raised anywhere below and turns it into a message. The commands raise; the CLI translates. That separation is why no traceback reaches a user.

Exit codes. 0 for success, 1 for failure. It is what makes the tool usable in a script — expenses add 250 food && echo ok only works if the failure is signalled.

Validation before counting. Everything that rejects input uses continue and sits above attempts += 1, so a typo does not cost the user a turn. Order is doing real work there.

50.5 is rejected because isdigit() is False for a string containing a dot. Acceptable for this program; try/except from module 6 is the general answer.

The corrupt file. Your program must refuse and leave the file alone. Check it afterwards — if the file is now empty or valid-but-empty, the load path returned empty instead of raising, and the next save destroyed somebody's data.

The edit command. Because Expense is frozen, you cannot modify one — you build a replacement with dataclasses.replace(expense, amount_paise=...) or a fresh constructor call. Notice that the design pushed you towards the safer option, since a replacement goes through validation and a mutation would not.

Practice

  1. Build all eight steps. Run after each. Commit after each.
  2. After step 3, confirm data survives by closing the terminal and reopening.
  3. Break the JSON file by hand and confirm the program refuses without overwriting it.
  4. Add --month 2026-09 to list.
  5. Run ruff format . and ruff check --fix . and fix what remains.
  6. Add an edit command. Since Expense is frozen, you will have to replace rather than modify — notice that the design pushed you towards the safer option.

Next: testing what you have built.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship