Python Programming — Alpha School

Lesson 20: Final Project — Build a Personal Finance Tracker

🎧 Listen to this lesson

The final project assembles everything — classes, files, JSON, validation, summaries, tests — into a personal finance tracker: record expenses with categories and notes, list them, summarise spending by category, check budgets, and export to CSV for a spreadsheet. It is deliberately the same architecture as Lesson 11's planner with richer data, because recognising that shape in a new problem is the real graduation skill of this course.

The staged build

Professionals build in working stages, never all at once: Stage 1, the data model and totals in memory (test immediately). Stage 2, persistence with JSON. Stage 3, the menu interface. Stage 4, reports and budgets. Stage 5, tests and polish. Each stage ends with something that runs — so bugs are born in small rooms, not mansions. Stage 1 uses the class design from Lesson 10:

class Expense:
    """One expense: an amount, a category, and an optional note."""

    def __init__(self, amount, category, note=""):
        self.amount = amount
        self.category = category
        self.note = note

    def __str__(self):
        return f"${self.amount:.2f} - {self.category}"

expenses = [Expense(12.50, "Food"), Expense(8.00, "Food"), Expense(5.25, "Transport")]

def total_by_category(expenses):
    totals = {}
    for e in expenses:
        totals[e.category] = totals.get(e.category, 0) + e.amount
    return totals

print(total_by_category(expenses))
print("Total spent:", sum(e.amount for e in expenses))

Output:

{'Food': 20.5, 'Transport': 5.25}
Total spent: 25.75

The class gives records names instead of positional indices (e.category cannot be confused with e.amount the way tuple positions can), __str__ (Lesson 16) makes any expense print readably, and the counting-pattern accumulation produces the core summary. Stages 2–3 move to dictionaries for storage — JSON cannot store class instances directly, so the application layer keeps dicts as its schema (Lesson 11's lesson) and the program below is the result.

The complete tracker

Stages 2 and 3: load-or-fresh persistence, save after every change, validated input, one function per menu action:

import json

SAVE_FILE = "expenses.json"

def load_expenses():
    """Load expenses from disk; start empty on first run or a bad file."""
    try:
        with open(SAVE_FILE, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []

def save_expenses(expenses):
    with open(SAVE_FILE, "w", encoding="utf-8") as f:
        json.dump(expenses, f, indent=2)

def add_expense(expenses, amount, category, note=""):
    if amount <= 0:
        print("Amount must be positive.")
        return
    expenses.append({"amount": amount, "category": category, "note": note})
    save_expenses(expenses)
    print(f"Added ${amount:.2f} to {category}.")

def list_expenses(expenses):
    if not expenses:
        print("No expenses yet.")
        return
    for i, e in enumerate(expenses, 1):
        print(f"{i}. ${e['amount']:.2f} - {e['category']} {e['note']}".rstrip())

def show_summary(expenses):
    totals = {}
    for e in expenses:
        totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
    for category, total in totals.items():
        print(f"{category}: ${total:.2f}")
    print(f"TOTAL: ${sum(totals.values()):.2f}")

def main():
    expenses = load_expenses()
    print(f"Loaded {len(expenses)} expense(s).")
    while True:
        print("\n1) Add  2) List  3) Summary  4) Quit")
        choice = input("> ").strip()
        if choice == "1":
            try:
                amount = float(input("Amount: "))
            except ValueError:
                print("Please enter a number.")
                continue
            add_expense(expenses, amount, input("Category: "), input("Note (optional): "))
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_summary(expenses)
        elif choice == "4":
            print("Saved. Spend wisely!")
            break
        else:
            print("Pick 1-4.")

if __name__ == "__main__":
    main()

Sample run:

Loaded 0 expense(s).

1) Add  2) List  3) Summary  4) Quit
> 2
No expenses yet.

1) Add  2) List  3) Summary  4) Quit
> 1
Amount: 12.50
Category: Food
Note (optional): Groceries
Added $12.50 to Food.

1) Add  2) List  3) Summary  4) Quit
> 1
Amount: 5.25
Category: Transport
Note (optional):
Added $5.25 to Transport.

1) Add  2) List  3) Summary  4) Quit
> 3
Food: $12.50
Transport: $5.25
TOTAL: $17.75

1) Add  2) List  3) Summary  4) Quit
> 2
1. $12.50 - Food Groceries
2. $5.25 - Transport

1) Add  2) List  3) Summary  4) Quit
> 4
Saved. Spend wisely!

Study the damage-proofing layered into ordinary-looking code: a non-numeric amount is rejected inside the menu *before* it can reach add_expense (with continue restarting the loop), and add_expense validates again anyway — defence in depth, because other callers (Lesson 14's tracker exceptions were the stricter cousin of this idea) cannot be trusted to pre-check. The summary's money formatting (:.2f) happens only at display, so stored values never accumulate rounding error. And the empty-note listing uses .rstrip() to trim the dangling space — polish is a habit, not a phase.

Stage 4 — budgets and export

Reports turn records into decisions. A budget check compares category totals against limits, and a CSV export (Lesson 13) hands the data to spreadsheets:

import csv

BUDGETS = {"Food": 50.00, "Transport": 30.00}

def check_budget(expenses, budgets=BUDGETS):
    totals = {}
    for e in expenses:
        totals[e["category"]] = totals.get(e["category"], 0) + e["amount"]
    for category, limit in budgets.items():
        spent = totals.get(category, 0)
        status = "OK" if spent <= limit else "WARNING - over budget!"
        print(f"{category}: spent ${spent:.2f} of ${limit:.2f} - {status}")

def export_csv(expenses, path="expenses.csv"):
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["amount", "category", "note"])
        for e in expenses:
            writer.writerow([e["amount"], e["category"], e["note"]])
    print(f"Exported {len(expenses)} expense(s) to {path}")

demo = [
    {"amount": 42.50, "category": "Food", "note": "Weekly shop"},
    {"amount": 15.00, "category": "Food", "note": "Snacks"},
    {"amount": 5.25, "category": "Transport", "note": "Bus pass"},
]
check_budget(demo)
export_csv(demo)

Output:

Food: spent $57.50 of $50.00 - WARNING - over budget!
Transport: spent $5.25 of $30.00 - OK
Exported 3 expense(s) to expenses.csv

Food's two entries sum to $57.50 against a $50 budget — the warning fires on the *accumulated* total, which is the whole point of tracking. check_budget iterates the *budgets*, not the spending, so a category with no spending still reports ($0 against its limit, via the .get(category, 0) default). The export writes a header row then one row per expense in the schema's field order; open the resulting file in a spreadsheet and Stage 4's promise — your data, portable — is visibly kept.

Stage 5 — tests, then done

Close the project the Lesson 17 way, with tests pinning the core calculation before you call it finished:

sample = [Expense(10.0, "Food"), Expense(2.5, "Food"), Expense(5.0, "Transport")]
assert total_by_category(sample) == {"Food": 12.5, "Transport": 5.0}
assert total_by_category([]) == {}
print("All finance tracker tests passed.")

Output: All finance tracker tests passed. — the float sums here are exactly representable (10.0 + 2.5 = 12.5), which is why direct equality is safe *in this test*; production money code often stores integer cents precisely to keep that property everywhere, a design note worth carrying into real projects.

Key idea. Key idea: Build in stages that each run; keep one schema; validate at every door; persist on every change; finish with tests. This sequence — not any single feature — is what "being able to program" means.

Common mistakes

  • Building the whole app before running anything. Stage discipline exists because a hundred untested lines hide bugs in layers; run after every stage and the bug is always in the newest few lines.
  • Storing money as loose floats in serious code. 0.1 + 0.2 is not exactly 0.3 in binary floating point; over thousands of entries the drift becomes visible. Integer cents (or the decimal module) keep money exact. This project formats floats at display only, and its tests chose exactly-representable values — know *why* both choices were made.
  • Two sources of truth. Editing expenses in the menu but summarising from a stale copy read at startup would desynchronise the app. One list, mutated through functions that save immediately, keeps every view honest.
  • Calling it done without the edge cases. Empty list, first run without a file, zero and negative amounts, a category missing from budgets — this project handles each deliberately. Enumerate yours before your users enumerate them for you.
Try it. Try it: Add a monthly report: group expenses by the month in their (new) "date" field — schema changes ripple through add, save, summary and tests; work stage by stage and notice exactly where each change lands. That ripple map is the true architecture diagram of your app.

Real-World Challenge

The task. Reuse the architecture for a different goal: a savings-goal tracker. One goal (a target amount), deposits that must be positive, JSON persistence after every deposit, and a progress report as a percentage.

Worked solution.

import json

SAVE_FILE = "goal.json"

def load_goal():
    """Load the goal; return a fresh one on first run or a bad file."""
    try:
        with open(SAVE_FILE, encoding="utf-8") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {"target": 0, "saved": 0}

def deposit(goal, amount):
    if amount <= 0:
        print("Deposit must be positive.")
        return
    goal["saved"] += amount
    with open(SAVE_FILE, "w", encoding="utf-8") as f:
        json.dump(goal, f, indent=2)
    print(f"Saved ${amount:.2f}. Total: ${goal['saved']:.2f}")

def progress(goal):
    if goal["target"] == 0:
        return "No goal set."
    pct = goal["saved"] / goal["target"] * 100
    return f"{pct:.0f}% of ${goal['target']:.2f} goal"

goal = load_goal()
goal["target"] = 120
deposit(goal, 30)
deposit(goal, 45)
print(progress(goal))

Output:

Saved $30.00. Total: $30.00
Saved $45.00. Total: $75.00
62% of $120.00 goal

How it works. Nothing here is new, and that is the achievement this final challenge is certifying: the load-or-fresh pattern from Lessons 6/11/13, save-on-every-change, validation at the door (a zero or negative deposit is refused without touching the file), display-only formatting, and a percentage computed from stored exact values — 75 of 120 is 62.5%, shown as 62% by the :.0f format (standard rounding of.5 follows the round-half-even rule you met in Lesson 3). The demo at the bottom stands in for a menu exactly as Lesson 11's habit tracker did; wrap the functions in a loop and not one of them changes. One course ago this program was unimaginable to write; now its design decisions are ones you can name, defend and vary. That is the foundation everything else — Advanced Python, data science, web development — builds on.

Extension. Generalise to named goals (a dictionary of goals in one file: "Laptop", "Trip", "Emergency fund"), each with its own target and saved total, and a report printing every goal's percentage with a Lesson 19 text bar scaled to 100%. Then protect it with Lesson 17 tests: deposit refuses negatives, progress at exactly the target reads 100%, and loading a missing file starts fresh.

Going further — grouping by month, and where to go next

One realistic report finishes the architecture tour: monthly totals, produced by filtering records whose ISO date string starts with the target month — the chronological-string property doing its final shift of the course:

def monthly_total(expenses, month):
    """Sum expenses whose ISO date starts with YYYY-MM."""
    return sum(e["amount"] for e in expenses if e["date"].startswith(month))

dated = [
    {"amount": 10.0, "date": "2026-10-03"},
    {"amount": 5.0, "date": "2026-10-20"},
    {"amount": 7.5, "date": "2026-11-01"},
]
print(monthly_total(dated, "2026-10"))

Output: 15.0 — October's two records, November's correctly excluded by a prefix test that only works because the schema stores ISO dates. Adding the date field to the live tracker is this lesson's Try it exercise precisely because schema changes are the real test of an architecture: add the field at the single creation point, and every downstream function keeps working. From here the paths branch exactly as the portal's other courses do — Advanced Python for the language's deeper machinery, Data Science for statistics beyond summaries, Web Development for trackers other people can open in a browser. Each starts from the habits certified in this project: stage the build, guard the doors, save on every change, and finish with tests.

🎬 Watch & Learn

Prefer video? These searches surface top tutorials on this lesson's topic:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. CSV export — Implement export_csv from the Applied Track and open the result in a spreadsheet app.
    HintUse csv.DictWriter with fieldnames=["date", "category", "description", "amount"].
  2. Monthly report — Add a menu option showing only this month's expenses and the month's total.
    HintFilter with e["date"].startswith("2026-10") (adjust to the current month).
  3. Budget alerts — Let the user set a monthly budget per category and warn when spending passes 90% of it.
    HintReuse the check_budget pattern: sum amounts per category, compare to the limit.

🌍 Real World

Budgeting apps like Mint and YNAB are your finance tracker at scale: the same expense objects, category totals, file/database persistence, and charts — plus bank connections and a polished interface. This project is portfolio-ready: it demonstrates classes, files, testing, and UI in one program, which is exactly what employers and colleges look for.

Key vocabulary

Program
A sequence of instructions a computer executes.
String
Text data in quotes, e.g. "hello"; type str.
Variable
A named reference to a value, e.g. score = 0.
Function
A named, reusable block of code defined with def; takes parameters, returns values.
Parameter / Argument
A parameter is the placeholder in a definition; an argument is the value passed in a call.
Return value
The output a function hands back via return (vs. merely printing).
Scope
The region of code where a variable name is visible; locals live inside their function.
Conditional
if/elif/else branching that runs code based on True/False tests.
Loop
for/while repetition; break exits, continue skips ahead.
List
An ordered, mutable collection: [1, 2, 3]; indexed from 0.
Dictionary
Key-to-value mapping: {"a": 1}; fast lookup by key.
Tuple
An immutable ordered collection, e.g. (3, 4).
Module
A Python file of reusable code; imported with import, e.g. math, random.
Bug / Debugging
An error in code; the systematic process of finding and fixing it.

Audio lesson

A narrated walkthrough of this course's key ideas — great for review on the go.

Quizzes & Exams

Check your understanding

Adaptive test

15 questions that adapt to your level.

Homework

Interactive Lab

Hands-on tools for this course. Everything runs right here in your browser — drag, slide, click, and experiment.

START Input a number Double the number Is it > 100? Yes → Print "Big!" No → Print the result END
Flowchart of a simple program: input, process, decide, output.

Further Study on YouTube

These free videos from trusted educators go deeper on this course's topics.

Interactive — hover or explore the chart above.

Keep reading to complete this lesson