Skip to content

ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ

Project: Birr Ledger v1

9 min readፕሮጀክት

Birr Ledger is a personal expense tracker, the project that grows with you through the rest of the course. Version 1 remembers: you'll add expenses, list them, and see what each category cost, all saved to ledger.json. Each milestone runs in the browser; the last section moves it to your computer, where the file really stays.

The plan

These names are fixed for every later version:

  • LEDGER_FILE, the path of ledger.json, next to the script.
  • load_expenses(path=LEDGER_FILE) returns the list of expenses, or [] if there's no file yet.
  • save_expenses(expenses, path=LEDGER_FILE) writes the list as JSON.
  • add_expense(expenses, amount, category, note, date=None) adds one expense; with no date, it's dated today.
  • total_by_category(expenses) returns each category's total.
  • main() runs the menu, and it's the only function that prints or asks.

Each expense is a dict with four keys. Amounts are whole birr for now:

{"date": "2026-10-01", "amount": 250, "category": "food", "note": "ቡና"}

It's the contact book's menu from lesson 4.9, with lessons 8.1–8.4 around it.

Milestone 1: an expense in memory

Each expense gets a date as text, like "2026-10-01". Today's date changes every day, so a test that uses it can't say what it will print. That's why add_expense takes the date as a parameter with a default (lesson 5.2): give a fixed date to test it, and leave it out to mean today. None stands for "not given", and only then does the function ask for today's date.

The parameter is called date, so inside the function it would hide date from from datetime import date (a clash, as in lesson 8.5). Import the module instead and write datetime.date.today().isoformat() (lesson 8.6), which gives today's date as text.

መልመጃ

add_expense

Write add_expense so it appends a new expense dict to expenses, dated date, or today when date is None. The first test expense has a fixed date; the second is dated today, so the test checks it against today instead of printing it.

Output
2
{'date': '2026-10-01', 'amount': 250, 'category': 'food', 'note': 'ቡና'}
True

Your code

import datetime


def add_expense(expenses, amount, category, note, date=None):
    # If date is None, use today's date as text; then append the expense dict
    pass


expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
add_expense(expenses, 120, "transport", "ታክሲ")
print(len(expenses))
print(expenses[0])
print(expenses[1]["date"] == datetime.date.today().isoformat())

In the editor, press Escape then Tab to move on.

መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import datetime


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
add_expense(expenses, 120, "transport", "ታክሲ")
print(len(expenses))
print(expenses[0])
print(expenses[1]["date"] == datetime.date.today().isoformat())

add_expense changes the list it's given and returns nothing, like list.append; the caller's expenses is the same list (lesson 5.3). The test lines at the bottom stand in for main() until Milestone 3.

Milestone 2: save and load

LEDGER_FILE = Path(__file__).parent / "ledger.json" finds the file next to the script (lesson 8.2). A missing file just means the first run: load_expenses returns [].

Both functions take the file's path as a parameter, with LEDGER_FILE as its default, for the same reason add_expense takes date: the program leaves it out and gets ledger.json, and a test can pass a file of its own (Module 9) without touching your real expenses.

መልመጃ

save_expenses and load_expenses

Write both, opening path: save with ensure_ascii=False, indent=2 (lesson 8.4), and catch FileNotFoundError when loading (lesson 8.1). There's no ledger.json yet, so the first line is [].

Output
[]
1
ቡና

Your code

import datetime
import json
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    # Return the list in the file at path, or [] if there's no file
    pass


def save_expenses(expenses, path=LEDGER_FILE):
    # Write expenses to the file at path as JSON
    pass


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


print(load_expenses())
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
save_expenses(expenses)
print(len(load_expenses()))
print(load_expenses()[0]["note"])

In the editor, press Escape then Tab to move on.

መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import datetime
import json
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


print(load_expenses())
expenses = []
add_expense(expenses, 250, "food", "ቡና", "2026-10-01")
save_expenses(expenses)
print(len(load_expenses()))
print(load_expenses()[0]["note"])

ledger.json in Files holds your expense, dated 2026-10-01, written exactly as indent=2 lays it out. Each run here starts from an empty folder, so the first line is always []; on your computer, the second run would start with your first expense.

ጥያቄ

Why is LEDGER_FILE built from __file__ instead of just "ledger.json"?

Why save the whole list with "w", not just the new expense with "a"? A JSON file is one value, and two values in a row aren't JSON:

Milestone 3: the menu

Now main(): load once, then ask for commands in a loop with match (lesson 4.9). add saves after every change, so if the program stops early (you close the window, or press Ctrl+C), the expenses already added are in the file. A bad amount is asked again (lesson 7.2). ledger.json starts with three expenses this time.

መልመጃ

add, list, and quit

Write main(). list prints each expense as date amount birr category note; add asks for a whole-number amount, then the category and note, saves, and confirms.

Output
Command: list
2026-09-28 120 birr transport ታክሲ
2026-09-29 250 birr food ምሳ
2026-09-30 60 birr food ቡና
Command: add
Amount: lots
Amount must be a whole number of birr.
Amount: 40
Category: transport
Note: ባጃጅ
Added 40 birr for transport.
Command: quit
Goodbye!

Your code

import datetime
import json
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def main():
    expenses = load_expenses()
    # Loop: ask for a command, then match add, list, and quit


if __name__ == "__main__":
    main()

In the editor, press Escape then Tab to move on.

One answer per line, given to input() in order.

ፋይሎች1 starting file

ledger.json

[
  {
    "date": "2026-09-28",
    "amount": 120,
    "category": "transport",
    "note": "ታክሲ"
  },
  {
    "date": "2026-09-29",
    "amount": 250,
    "category": "food",
    "note": "ምሳ"
  },
  {
    "date": "2026-09-30",
    "amount": 60,
    "category": "food",
    "note": "ቡና"
  }
]
መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import datetime
import json
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def main():
    expenses = load_expenses()
    while True:
        command = input("Command: ")
        match command:
            case "add":
                amount = None
                while amount is None:
                    try:
                        amount = int(input("Amount: "))
                    except ValueError:
                        print("Amount must be a whole number of birr.")
                category = input("Category: ")
                note = input("Note: ")
                add_expense(expenses, amount, category, note)
                save_expenses(expenses)
                print(f"Added {amount} birr for {category}.")
            case "list":
                if not expenses:
                    print("No expenses yet.")
                for e in expenses:
                    print(e["date"], e["amount"], "birr", e["category"], e["note"])
            case "quit":
                print("Goodbye!")
                break
            case _:
                print("Unknown command. Try add, list, summary, or quit.")


if __name__ == "__main__":
    main()

Files shows ledger.json changed: four expenses, the new one dated today. The while amount is None loop keeps asking because a failed int() never assigns, so amount stays None (lesson 7.2).

Milestone 4: summary

total_by_category is the tally plan from lesson 4.4, adding amounts instead of 1, and a Counter (lesson 8.6) can hold the totals. With a plain dict, looking up a key that isn't there is a KeyError. Predict what a Counter does:

ውጤቱን ገምቱ

Decide before you look. Guessing wrong is how this sticks.

from collections import Counter

totals = Counter()
print(totals["food"])
totals["food"] = totals["food"] + 250
print(totals)
Pick the output

Python prints

0
Counter({'food': 250})

A Counter treats a missing key as 0, so you can add to a category's total without checking whether it's there yet.

If you picked KeyError, you expected a plain dict. A Counter counts a missing key as 0, so totals[category] = totals[category] + amount works from the first expense on, with no .get(). And most_common() lists the biggest total first.

መልመጃ

total_by_category and summary

This is the Milestone 3 program. Write total_by_category(expenses) with a Counter, and add a summary case that prints each category's total, biggest first.

Output
Command: add
Amount: 40
Category: transport
Note: ባጃጅ
Added 40 birr for transport.
Command: summary
food: 310 birr
transport: 160 birr
Command: quit
Goodbye!

Your code

import datetime
import json
from collections import Counter
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def total_by_category(expenses):
    # Add each expense's amount to its category's total
    pass


def main():
    expenses = load_expenses()
    while True:
        command = input("Command: ")
        match command:
            case "add":
                amount = None
                while amount is None:
                    try:
                        amount = int(input("Amount: "))
                    except ValueError:
                        print("Amount must be a whole number of birr.")
                category = input("Category: ")
                note = input("Note: ")
                add_expense(expenses, amount, category, note)
                save_expenses(expenses)
                print(f"Added {amount} birr for {category}.")
            case "list":
                if not expenses:
                    print("No expenses yet.")
                for e in expenses:
                    print(e["date"], e["amount"], "birr", e["category"], e["note"])
            # Add a "summary" case here
            case "quit":
                print("Goodbye!")
                break
            case _:
                print("Unknown command. Try add, list, summary, or quit.")


if __name__ == "__main__":
    main()

In the editor, press Escape then Tab to move on.

One answer per line, given to input() in order.

ፋይሎች1 starting file

ledger.json

[
  {
    "date": "2026-09-28",
    "amount": 120,
    "category": "transport",
    "note": "ታክሲ"
  },
  {
    "date": "2026-09-29",
    "amount": 250,
    "category": "food",
    "note": "ምሳ"
  },
  {
    "date": "2026-09-30",
    "amount": 60,
    "category": "food",
    "note": "ቡና"
  }
]
መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import datetime
import json
from collections import Counter
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def total_by_category(expenses):
    totals = Counter()
    for e in expenses:
        totals[e["category"]] = totals[e["category"]] + e["amount"]
    return totals


def main():
    expenses = load_expenses()
    while True:
        command = input("Command: ")
        match command:
            case "add":
                amount = None
                while amount is None:
                    try:
                        amount = int(input("Amount: "))
                    except ValueError:
                        print("Amount must be a whole number of birr.")
                category = input("Category: ")
                note = input("Note: ")
                add_expense(expenses, amount, category, note)
                save_expenses(expenses)
                print(f"Added {amount} birr for {category}.")
            case "list":
                if not expenses:
                    print("No expenses yet.")
                for e in expenses:
                    print(e["date"], e["amount"], "birr", e["category"], e["note"])
            case "summary":
                for category, total in total_by_category(expenses).most_common():
                    print(f"{category}: {total} birr")
            case "quit":
                print("Goodbye!")
                break
            case _:
                print("Unknown command. Try add, list, summary, or quit.")


if __name__ == "__main__":
    main()

Birr Ledger v1 is done. Only main() talks to the user, so Module 9 can test every other function on its own.

Stretch: commands from the terminal

python ledger.py add 250 food ቡና is quicker than a menu (lesson 8.7). nargs="?" makes an argument optional (None when left out), and parser.error() prints the usage and a message and ends with exit code 2.

መልመጃ

argparse commands

This is the Milestone 4 program. Replace main() so the command comes from the command line: add AMOUNT CATEGORY [NOTE] [--date DATE], list, or summary. The command here is ledger.py summary; try ledger.py add 40 transport ባጃጅ, then the same with --date 2026-10-01, then ledger.py add 40 without a category.

Output
food: 310 birr
transport: 120 birr

Your code

import argparse
import datetime
import json
from collections import Counter
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def total_by_category(expenses):
    totals = Counter()
    for e in expenses:
        totals[e["category"]] = totals[e["category"]] + e["amount"]
    return totals


def main():
    # Replace this menu with argparse: command, amount, category, note
    expenses = load_expenses()
    while True:
        command = input("Command: ")
        match command:
            case "add":
                amount = None
                while amount is None:
                    try:
                        amount = int(input("Amount: "))
                    except ValueError:
                        print("Amount must be a whole number of birr.")
                category = input("Category: ")
                note = input("Note: ")
                add_expense(expenses, amount, category, note)
                save_expenses(expenses)
                print(f"Added {amount} birr for {category}.")
            case "list":
                if not expenses:
                    print("No expenses yet.")
                for e in expenses:
                    print(e["date"], e["amount"], "birr", e["category"], e["note"])
            case "summary":
                for category, total in total_by_category(expenses).most_common():
                    print(f"{category}: {total} birr")
            case "quit":
                print("Goodbye!")
                break
            case _:
                print("Unknown command. Try add, list, summary, or quit.")


if __name__ == "__main__":
    main()

In the editor, press Escape then Tab to move on.

ፋይሎች1 starting file

ledger.json

[
  {
    "date": "2026-09-28",
    "amount": 120,
    "category": "transport",
    "note": "ታክሲ"
  },
  {
    "date": "2026-09-29",
    "amount": 250,
    "category": "food",
    "note": "ምሳ"
  },
  {
    "date": "2026-09-30",
    "amount": 60,
    "category": "food",
    "note": "ቡና"
  }
]
መፍትሄውን አሳይ

This is one way to solve it, not the only one. If yours prints the same thing, it works.

import argparse
import datetime
import json
from collections import Counter
from pathlib import Path

LEDGER_FILE = Path(__file__).parent / "ledger.json"


def load_expenses(path=LEDGER_FILE):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []


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


def add_expense(expenses, amount, category, note, date=None):
    if date is None:
        date = datetime.date.today().isoformat()
    expenses.append({
        "date": date,
        "amount": amount,
        "category": category,
        "note": note,
    })


def total_by_category(expenses):
    totals = Counter()
    for e in expenses:
        totals[e["category"]] = totals[e["category"]] + e["amount"]
    return totals


def main():
    parser = argparse.ArgumentParser(description="Birr Ledger: track your spending.")
    parser.add_argument("command", choices=["add", "list", "summary"])
    parser.add_argument("amount", type=int, nargs="?")
    parser.add_argument("category", nargs="?")
    parser.add_argument("note", nargs="?", default="")
    parser.add_argument("--date", help="YYYY-MM-DD (default: today)")
    args = parser.parse_args()

    expenses = load_expenses()
    match args.command:
        case "add":
            if args.amount is None or args.category is None:
                parser.error("add needs an amount and a category")
            add_expense(expenses, args.amount, args.category, args.note, args.date)
            save_expenses(expenses)
            print(f"Added {args.amount} birr for {args.category}.")
        case "list":
            for e in expenses:
                print(e["date"], e["amount"], "birr", e["category"], e["note"])
        case "summary":
            for category, total in total_by_category(expenses).most_common():
                print(f"{category}: {total} birr")


if __name__ == "__main__":
    main()

With ledger.py add 40 transport ባጃጅ it prints Added 40 birr for transport. and Files shows the new expense, dated today. --date is an option: a name starting with -- that can be left out, and its value is None when it is. Add --date 2026-10-01 and the new expense is dated 2026-10-01. With ledger.py add 40, parser.error() prints the usage line and ledger.py: error: add needs an amount and a category, and the program ends with exit code 2.

Build it for real

On a computer, ledger.json stays, and Birr Ledger becomes a tool you can use every day. The course's exercises repo has a starter with these exact names in 08-birr-ledger. Open it in VS Code, or in a free GitHub Codespace (lesson 8.8). Build it milestone by milestone, run python ledger.py in that folder (python3 on macOS and Linux, lesson 0.4), add a few real expenses, and run it again tomorrow: they'll still be there.

ዋና ዋና ነጥቦች

  • The names are fixed: LEDGER_FILE, load_expenses, save_expenses, add_expense, total_by_category, main.
  • Load once, save the whole list with "w" after every change; a missing file means an empty ledger.
  • Only main() prints and asks; the other functions take values and return them.
  • LEDGER_FILE is built from __file__, so the ledger is found from any folder.