Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 2 of 6

Pick this one if you want to practise structuring a real program: classes that model a domain, data that survives restarts, and reports someone would actually read.

The one-sentence version

A command-line tool that records what I earn and spend, and shows me where my money went in any given month.

What it must do

| Capability | Must-have | | --- | --- | | Record income and expenses with date, amount, category, note | yes | | List transactions, filtered by month | yes | | Monthly report: total per category, with percentages | yes | | Save to and load from JSON, surviving a corrupt file | yes | | Export to CSV | nice | | Budgets with warnings | nice | | Edit or delete a transaction | nice |

Money is not a float

Before any of that, the single most important decision in this project.

round(2.675, 2) giving 2.67 isn't a bug — 2.675 isn't exactly representable in binary, so the number you rounded was already slightly below. Money in floats drifts, and the drift shows up as a report that's one cent off and impossible to explain.

This project stores money as integer cents. -1250 means $12.50 out. Divide by 100 only when printing. Decimal("12.50") is the other correct answer — use it if you prefer, just never float.

The record shape

{
    "id": 1,
    "when": "2026-03-15",
    "amount_cents": -1250,
    "category": "food",
    "note": "lunch",
}

One sign convention, applied everywhere: negative is money out, positive is money in. That choice means the balance is just sum(amounts) — no separate income and expense lists to keep in step.

Milestone 1: the Transaction class

A class that validates its own input and can convert itself to and from a dict. Get this right and persistence becomes trivial.

Things worth stealing from this design:

  • Validation lives in __init__. An invalid Transaction can't exist, so nothing downstream has to check.
  • to_dict / from_dict round-trip exactly. That equality check is a test in disguise — make it a real one.
  • Dates are stored as ISO strings, held as date objects. Parse at the boundary, work with real types inside.
  • __repr__ for debugging. Printing a list of transactions shows you something readable instead of <Transaction object at 0x7f...>.

Milestone 2: the Ledger and its report

The Ledger owns the collection and every question you can ask about it. Still no printing to the screen from inside the class — report() returns a string, so it's testable.

Note sorted(totals.items(), key=lambda kv: -kv[1]) — biggest spend first, because that's the line the reader actually cares about. And the empty month returns a sentence, not a crash or a confusing empty table.

Milestone 3: persistence and CSV

Three details doing quiet, important work:

  • os.replace makes the save atomic. Write the temp file first, then swap it in with one operation. Power cut mid-write? You still have yesterday's good file, not half of today's.
  • newline="" on the CSV file. Without it you get blank lines between rows on Windows.
  • next_id is persisted. Reload and the next transaction gets id 4, not id 1.

Milestone 4: the CLI

Now wire it up. Same shape as lesson 9.10: read input, call a method, print, catch LedgerError.

parse_amount is the boundary: the user types 12.50, everything inside the program sees 1250. raise ... from None hides the original ValueError traceback, because the user doesn't need to see Python's internals — they need "not a number: abc".

Definition of done

You've finished when:

  • [ ] Adding, listing, and reporting all work from the menu
  • [ ] Data survives quitting and restarting
  • [ ] A corrupt data file gives a clear message, not a traceback
  • [ ] Every amount is integer cents; nothing is off by a penny
  • [ ] There are tests for Transaction validation, spending_by_category, and the save/load round-trip (lesson 9.9)
  • [ ] python -m unittest passes and the code is formatted

Stretch goals

  1. Budgets. set_budget("food", 20000), and flag categories over budget in the report.
  2. Month-over-month. Show change from last month per category.
  3. argparse. finance add -12.50 food --note lunch instead of a menu.
  4. Recurring transactions. Rent every month — generate them with datetime (lesson 9.6).
  5. A chart. Bars from # characters — no library needed: print(category, "#" * int(share)).

Questions about this lesson

Log in to ask a question.