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 invalidTransactioncan't exist, so nothing downstream has to check. to_dict/from_dictround-trip exactly. That equality check is a test in disguise — make it a real one.- Dates are stored as ISO strings, held as
dateobjects. 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.replacemakes 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_idis 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
Transactionvalidation,spending_by_category, and the save/load round-trip (lesson 9.9) - [ ]
python -m unittestpasses and the code is formatted
Stretch goals
- Budgets.
set_budget("food", 20000), and flag categories over budget in the report. - Month-over-month. Show change from last month per category.
argparse.finance add -12.50 food --note lunchinstead of a menu.- Recurring transactions. Rent every month — generate them with
datetime(lesson 9.6). - A chart. Bars from
#characters — no library needed:print(category, "#" * int(share)).
Questions about this lesson
Log in to ask a question.