LEARN PYTHON | Project: An Expense Tracker

Lesson objective

Build a local expense tracker with validated records, integer-cent totals, JSON persistence, CSV export and isolated automated tests.

Learn

05.6 | PROJECT: AN EXPENSE TRACKER

01 | DEFINE A SMALL, COMPLETE PRODUCT

Download the Python project and tests

The tracker lets one local user add, list and total fictional expenses, filter a monthly total and export CSV. It saves each accepted addition immediately so reopening the program restores previous records. It uses CHF only, avoiding a meaningless total across different currencies.

Each record has a real date, a category from food/travel/learning/other, a positive amount from 0.01 to 10000.00 with at most two decimal places, and a trimmed note of one to eighty characters. Duplicate entries are allowed: two similar purchases may be separate expenses.

A missing save starts a new tracker. An existing unreadable or invalid save stops startup. The program should never announce a successful addition when its save failed. These are observable requirements you can test.

02 | KNOW THE FOUR FILES

model.py owns validation and calculations. storage.py owns JSON persistence and CSV export. tracker.py owns prompts and command handling. test_tracker.py contains automated checks. Keep them together and run tracker.py.

The program uses only Python’s standard library. It does not require a package installation, account or network connection. Saved data belongs beside tracker.py, anchored with pathlib rather than whichever folder happened to launch the process.

The small browser model later in this page keeps only session data. It demonstrates the rules but is not a substitute for testing Python’s actual file persistence.

ModuleInputs and responsibilitiesOutputs or effects
model.pyRaw fields and checked recordsValidated dictionaries, cents, totals
storage.pyPaths and record listsLoaded records or written files
tracker.pyUser commandsFeedback and committed saved additions
test_tracker.pyFixed cases and temporary foldersRepeatable evidence

03 | CONVERT DECIMAL TEXT TO INTEGER CENTS

The amount parser uses re.fullmatch to enforce plain ASCII digits with an optional decimal point followed by one or two digits. The non-capturing group (?:...) groups that optional decimal part without storing an extra regex capture. A question mark makes that group optional.

partition(".") splits the text into whole part, separator and fractional part. ljust(2, "0") pads a one-digit fraction: "5" becomes "50". Combining whole × 100 and fractional cents avoids binary float arithmetic. A range check then rejects zero and excessive amounts.

The parser intentionally rejects signs, commas, exponent notation, NaN, infinity and a third decimal. It does not silently round them. Surrounding whitespace is stripped. This grammar is a learning contract, not a universal rule for every financial application.

"""Validation and calculations for a small, single-currency tracker."""
from datetime import date
import re

CURRENCY = "CHF"
CATEGORIES = ("food", "travel", "learning", "other")
MAX_CENTS = 1_000_000


def parse_amount(text):
    """Return integer cents for 0.01 to 10000.00, with at most two decimals."""
    text = text.strip()
    if not re.fullmatch(r"[0-9]+(?:\.[0-9]{1,2})?", text):
        raise ValueError("Use plain digits and at most two decimal places.")
    whole, dot, fraction = text.partition(".")
    cents = int(whole) * 100 + int(fraction.ljust(2, "0") or "0")
    if not 1 <= cents <= MAX_CENTS:
        raise ValueError("Amount must be from 0.01 to 10000.00 CHF.")
    return cents

04 | VALIDATE RECORDS FROM BOTH INPUT AND FILES

build_expense converts the user’s field text into a candidate dictionary. validate_expense also checks saved dictionaries after JSON decoding. This matters because someone can edit the file outside the application.

The record validator requires exactly the four named fields and the expected types. type(amount_cents) is int intentionally rejects True, because bool is otherwise a subclass of int. Dates must pass structure and calendar checks; categories must be permitted; notes must meet presence and length rules.

Successful validation returns a checked copy with canonical date text and a trimmed note. It does not establish that an expense is factually genuine. The project trusts the small local dataset and does not provide authentication or encryption.

def parse_date(text):
    """Validate a real date in the exact YYYY-MM-DD format."""
    text = text.strip()
    if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", text):
        raise ValueError("Use date format YYYY-MM-DD.")
    return date.fromisoformat(text).isoformat()


def parse_month(text):
    text = text.strip()
    if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", text):
        raise ValueError("Use month format YYYY-MM.")
    date(int(text[:4]), int(text[5:]), 1)
    return text


def validate_expense(record):
    """Return a checked copy; reject wrong fields and unsuitable saved values."""
    fields = {"date", "category", "amount_cents", "note"}
    if type(record) is not dict or set(record) != fields:
        raise ValueError("Each expense needs date, category, amount_cents and note.")
    if type(record["date"]) is not str or type(record["category"]) is not str:
        raise ValueError("Date and category must be text.")
    if type(record["amount_cents"]) is not int:
        raise ValueError("Saved amount_cents must be an integer, not a Boolean.")
    if not 1 <= record["amount_cents"] <= MAX_CENTS:
        raise ValueError("Saved amount is outside the allowed range.")
    if record["category"] not in CATEGORIES:
        raise ValueError("Choose food, travel, learning or other.")
    if type(record["note"]) is not str or not 1 <= len(record["note"].strip()) <= 80:
        raise ValueError("Use a note containing 1 to 80 characters after trimming.")
    return {"date": parse_date(record["date"]),
            "category": record["category"],
            "amount_cents": record["amount_cents"],
            "note": record["note"].strip()}


def build_expense(day, category, amount, note):
    return validate_expense({"date": parse_date(day),
                             "category": category.strip().lower(),
                             "amount_cents": parse_amount(amount), "note": note})


def money(cents):
    """Format a non-negative integer count of cents, without float arithmetic."""
    return f"{CURRENCY} {cents // 100}.{cents % 100:02d}"


def total_cents(expenses, month=None):
    if month is not None:
        month = parse_month(month)
    return sum(row["amount_cents"] for row in expenses
               if month is None or row["date"].startswith(month + "-"))

05 | DISTINGUISH MISSING FROM DAMAGED SAVES

load_expenses catches only FileNotFoundError for the new-tracker case. Other access problems, invalid UTF-8, malformed JSON and unsuitable record structures reach the application’s startup handler. It prints a failure and stops before accepting new data.

This protects a damaged existing save from being mistaken for an empty list that the next addition would overwrite. The user can inspect or recover the file before restarting. A successful parse alone is not enough: each record still passes validation.

This is one local process and one data file, not a multi-user database. File-size limits, concurrent writers and stronger recovery guarantees would need additional design.

"""JSON persistence and a CSV export for one local user."""
from pathlib import Path
import csv
import json
from tempfile import NamedTemporaryFile
from model import validate_expense


def load_expenses(path):
    path = Path(path)
    try:
        with path.open("r", encoding="utf-8") as file:
            data = json.load(file)
    except FileNotFoundError:
        return []
    if type(data) is not list:
        raise ValueError("Saved data must be a list of expense records.")
    return [validate_expense(row) for row in data]

06 | SAVE A CHECKED CANDIDATE, THEN COMMIT

save_expenses validates every proposed record before opening an output file. It writes a new temporary file in the destination’s directory, closes it and then replaces the save path. NamedTemporaryFile supplies a fresh temporary name; delete=False lets the closed file remain available for replacement.

The temporary file is in the same directory so replacement stays on the same filesystem in this design. finally removes a leftover temporary file if the normal path did not already move it. This reduces the risk of truncating the old save before the new document is ready.

It is not a backup, a multi-process transaction or a guarantee against power failure. Production durability could need additional techniques such as syncing and retained versions. The project’s application accepts the new in-memory list only after the save operation succeeds.

def save_expenses(path, expenses):
    """Validate first, then replace the destination after closing a new file."""
    checked = [validate_expense(row) for row in expenses]
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary_path = None
    try:
        with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent,
                                prefix=path.name + ".", suffix=".tmp",
                                delete=False) as file:
            temporary_path = Path(file.name)
            json.dump(checked, file, ensure_ascii=False, indent=2)
        temporary_path.replace(path)
    finally:
        if temporary_path is not None:
            temporary_path.unlink(missing_ok=True)


07 | KEEP CSV EXPORT SEPARATE

The export writes a header and one row per record with amount_chf formatted to two decimal places. DictWriter handles commas and quotes in notes. Opening uses newline="" and UTF-8.

The CSV is a readable export rather than the saved source of truth. Editing it does not change expenses.json. The application asks before replacing an existing export, but the export itself is not an atomic save operation.

Use fictional practice notes. Spreadsheet tools can interpret some leading characters in cells as formulas; CSV quoting alone does not stop that. This simple export is intended for controlled learning data, not arbitrary untrusted spreadsheet content.

def export_csv(path, expenses):
    """Write a human-readable export; do not use this CSV as the saved database."""
    checked = [validate_expense(row) for row in expenses]
    with Path(path).open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=["date", "category", "amount_chf", "note"])
        writer.writeheader()
        for row in checked:
            cents = row["amount_cents"]
            writer.writerow({"date": row["date"], "category": row["category"],
                             "amount_chf": f"{cents // 100}.{cents % 100:02d}",
                             "note": row["note"]})

08 | COORDINATE THE COMMAND LOOP

The application accepts add, list, total, month, export and quit. add collects fields, validates a record and creates candidate = expenses + [record]. A failed check returns to the menu without changing expenses. A failed save also leaves that accepted list unchanged.

list creates a date-sorted display without rearranging the stored arrival order. total adds integer cents. month validates a canonical month and selects matching date prefixes. EOFError and KeyboardInterrupt are handled at the main boundary so previously saved additions remain available.

Save this code as tracker.py. The other modules must be beside it. The default path arguments are created when the function definition executes, which is fine here because they are fixed Path objects rather than mutable record lists.

"""Run this file to start the command-line expense tracker."""
from pathlib import Path
from model import build_expense, money, total_cents
from storage import load_expenses, save_expenses, export_csv

BASE_DIR = Path(__file__).resolve().parent
DATA_PATH = BASE_DIR / "expenses.json"
EXPORT_PATH = BASE_DIR / "expenses.csv"


def date_key(record):
    return record["date"]


def run_tracker(data_path=DATA_PATH, export_path=EXPORT_PATH):
    try:
        expenses = load_expenses(data_path)
    except (OSError, ValueError, UnicodeError) as error:
        print("Cannot safely load saved data:", error)
        print("No records changed. Check the file before restarting.")
        return False
    print("Expense tracker | CHF only | use fictional practice data")
    while True:
        command = input("add / list / total / month / export / quit: ").strip().lower()
        if command == "quit":
            print("Finished. Successful additions were saved immediately.")
            return True
        if command == "add":
            day = input("Date YYYY-MM-DD: ")
            category = input("Category food/travel/learning/other: ")
            amount = input("Amount CHF, 0.01 to 10000.00: ")
            note = input("Note, 1 to 80 characters: ")
            try:
                record = build_expense(day, category, amount, note)
            except ValueError as error:
                print("Not added:", error)
                continue
            candidate = expenses + [record]
            try:
                save_expenses(data_path, candidate)
            except OSError as error:
                print("Save failed; expense was not added:", error)
                continue
            expenses = candidate
            print("Added and saved:", money(record["amount_cents"]))
        elif command == "list":
            if not expenses:
                print("No expenses yet.")
            for number, row in enumerate(sorted(expenses, key=date_key), start=1):
                print(f"{number}. {row['date']} | {row['category']} | "
                      f"{money(row['amount_cents'])} | {row['note']}")
        elif command == "total":
            print("Total:", money(total_cents(expenses)))
        elif command == "month":
            month = input("Month YYYY-MM: ")
            try:
                amount = total_cents(expenses, month)
            except ValueError as error:
                print("Invalid month:", error)
            else:
                print("Month total:", money(amount))
        elif command == "export":
            if Path(export_path).exists():
                if input("Replace existing CSV export? Type yes: ").strip().lower() != "yes":
                    print("Export cancelled.")
                    continue
            try:
                export_csv(export_path, expenses)
            except OSError as error:
                print("Export failed:", error)
            else:
                print("CSV export written to:", export_path)
        else:
            print("Unknown command. Choose one of the displayed words.")


def main():
    try:
        run_tracker()
    except (EOFError, KeyboardInterrupt):
        print("\nStopped. Previously saved additions remain on disk.")


if __name__ == "__main__":
    main()

09 | TRY THE SESSION-ONLY MODEL

Add a fictional expense. The model checks dates, categories, note length and decimal-text amounts, then shows a total and month filter. It does not save to your device, and its records disappear when the page is reloaded.

    10 | RUN THE TEST SUITE AND A REAL REOPEN

    Open a terminal in the project folder. Run python tracker.py, or python3 tracker.py if that is your system’s command. Use python -m unittest discover -v to run test_tracker.py. The tests use temporary directories and fixed dates, avoiding your actual save.

    Add an expense of 0.10 and another of 0.20, quit and reopen. The total should be CHF 0.30. Add a next-month expense and check that the month filter excludes it. Export a note containing a comma and inspect it through a CSV reader.

    The bundled suite includes fourteen test methods with multiple subcases. It checks accepted and rejected amounts, calendar dates, records, totals, reloads, malformed data, unchanged saves after validation failure and CSV field preservation. More tests would be needed for new requirements.

    11 | EXTEND WITH A TESTED REQUIREMENT

    A category summary is a useful next step: add a pure helper receiving checked records and returning totals by category. Test an empty list, one category and repeated categories before adding a command.

    Editing or deleting expenses requires an identity rule. Current display numbers are sorted positions, not permanent record IDs. Decide how to identify a record before accepting a delete command.

    Multi-currency totals, shared accounts and financial reporting are outside this project’s scope. Keep improvements small, document their effects on saved data and rerun earlier tests after each change.

    PUT YOUR SKILLS TO WORK

    Use the Section 5 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.

    Section 5 challenges Python course navigation

    MATCH THE PYTHON TERM TO ITS MEANING

    Terminology

    Terminology

    Expense record

    A dictionary describing one accepted expense.

    Integer cents

    The project’s exact whole-number representation of CHF hundredths.

    Category

    A value selected from the project’s permitted expense types.

    Candidate

    A proposed record list not yet committed as accepted state.

    Commit

    Accepting a candidate after its required save succeeds.

    Schema

    Required fields, types and rules for a saved record.

    Canonical date

    A validated YYYY-MM-DD date string.

    Validation boundary

    A place where data must satisfy the application contract.

    Persistence

    Saving data for later processes to reload.

    Missing-file case

    The deliberate first-run condition producing an empty tracker.

    Corrupted save

    Existing data that cannot be decoded or validated as required.

    Temporary file

    A separate file holding a proposed document before replacement.

    Replacement

    Moving a completed candidate file to the chosen destination.

    CSV export

    A separate tabular view of saved records for controlled use.

    Source of truth

    The authoritative saved dataset, here expenses.json.

    Month filter

    Selection of canonical dates matching a validated YYYY-MM prefix.

    Integration test

    A check of connected validation and storage operations.

    Regression check

    Repeating earlier checks after extending the project.

    Questions

    Questions

    CHECK YOUR UNDERSTANDING

    Select all correct choices. Each exact set earns one point.

    1. What is the saved source of truth in the project?
    2. When is the candidate accepted into the current list?
    3. Why validate records after JSON load?
    4. Which stored amount is valid under the project schema?
    5. Why use a temporary file before replacing the JSON save?
    6. Which limits are stated?
    7. Which amount text is accepted?
    8. What should a reopen test check?

    EXPLAIN AND COMPARE

    Write your answers before revealing each sample.

    1. Explain the tracker’s module responsibilities.

    2. Trace a successful addition.

    3. Explain the save-failure route.

    4. Explain missing versus corrupted saves.

    5. Describe the integer-cent rule.

    6. Evaluate temporary replacement.

    7. Design a reopening workflow.

    8. Suggest a safe feature extension.

    CODE IT | YOUR INDEPENDENT CHALLENGE

    Run the tracker, test invalid additions and reopen saved data. Add a pure category summary with tests.

    Work in your Python editor. Keep a working version, explain one design choice and record at least two tests. Use the Learn tab for worked examples before comparing your result.

    Flashcards

    Flashcards

    Click to flip. Select the ideas you need to revisit.

    0 cards selected for revision.

      Selections are kept while this page is open.

      Workbook

      Workbook

      COMING SOON

      The workbook for Project: An Expense Tracker is coming soon. Complete the matching task and written questions in your exercise book.