Project P003

Expense tracker

Record income and expenses by date and category, in whole cents, and see the balance, the totals by category and a report for any month - from a text menu.

4 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-09-27

Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.

About

Records income and expenses, each with a date, an amount, a category and a note, and shows the balance on every menu, the entries in date order, the totals by category with a bar for each, and a report for any month: income, spending, net and where the money went. A text menu; the entries live while the program runs.

  • main.eml - the menu loop and the questions asked for a new entry
  • ledger.eml - the ledger, a class: add, find and remove entries, totals by kind and month, entries in date order (equal dates keep the order they were added in) and totals by category, largest first
  • fields.eml - amounts, dates and months: reading an amount into whole cents digit by digit, writing cents as 1,234.50, and checking a date against the calendar, leap years included
  • report.eml - what the screen shows: the menu line with the balance, the list, the category totals and the month report

Money is whole cents from the moment it is typed: "12.50" becomes 1250 without passing through a float, and every total is a sum of integers, so none can be off by a fraction of a cent.

What is checked: a date must be written YYYY-MM-DD and exist - 2024-02-29 does, 2025-02-29 and 1900-02-29 do not; an amount must be digits with at most two after the point, more than 0 and at most 1,000,000.00; a category must be one of the numbers offered; a month must be written YYYY-MM. Nothing is recorded until every field of an entry has been accepted.

Sessions: sessions/basic.in records income and expenses over two months, two of them on one day, lists them, shows the totals by category and both month reports, deletes an entry and lists again; sessions/bad-input.in uses every screen while the ledger is empty, then types dates in the wrong form or not on the calendar, amounts with three decimals, letters, a sign, a comma, zero and too much, categories that are not offered, an empty note, a month with no entries and ids that do not exist.

Built on the verified corpus cases money-in-cents (amounts kept in integer cents rather than floating-point currency), bank-account-simulator (an account as a class whose methods keep its balance) and histogram-builder (totals drawn as bars in proportion to the largest).

Recorded sessions

What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.

bad-input

interpreter: byte-equal

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 9
Pick a number from 1 to 7.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 3

-- all entries: 0 --
  (nothing here)

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 4

-- spending by category --
  (nothing here)

-- income by category --
  (nothing here)

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 5
month (YYYY-MM)> 2026-13
A month looks like 2026-09.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-9-3
A date looks like 2026-09-27.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-02-30
There is no 2026-02-30.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2025-02-29
There is no 2025-02-29.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 1900-02-29
There is no 1900-02-29.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-04-31
There is no 2026-04-31.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 12.345
An amount looks like 12 or 12.50.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> abc
An amount looks like 12 or 12.50.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> -5
An amount looks like 12 or 12.50.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 0
An amount must be more than 0.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 1,000
An amount looks like 12 or 12.50.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 2000000
An amount can be at most 1,000,000.00.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 9.99
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 9
Pick a category from 1 to 7.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 9.99
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> x
Pick a category from 1 to 7.

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2024-02-29
amount> 9.99
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 5
note> 
Added #1: expense 9.99 health, 2024-02-29.

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 3

-- all entries: 1 --
  #1   2024-02-29  health            -9.99

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 5
month (YYYY-MM)> 2024-03

-- 2024-03 --
  No entries in 2024-03.

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 6
id> x
No entry with that id.

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 6
id> 99
No entry with that id.

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 6
id> 0
No entry with that id.

== Expense tracker: 1 entry, balance -9.99 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 7
Bye.
What was typed (56 lines)
9
3
4
5
2026-13
1
2026-9-3
1
2026-02-30
1
2025-02-29
1
1900-02-29
1
2026-04-31
1
2024-02-29
12.345
1
2024-02-29
abc
1
2024-02-29
-5
1
2024-02-29
0
1
2024-02-29
1,000
1
2024-02-29
2000000
1
2024-02-29
9.99
9
1
2024-02-29
9.99
x
1
2024-02-29
9.99
5

3
5
2024-03
6
x
6
99
6
0
7

basic

interpreter: byte-equal

== Expense tracker: 0 entries, balance 0.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 2
date (YYYY-MM-DD)> 2026-09-01
amount> 3000
1) salary  2) freelance  3) gift  4) other
category> 1
note> September pay
Added #1: income 3,000.00 salary, 2026-09-01.

== Expense tracker: 1 entry, balance 3,000.00 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-09-03
amount> 12.50
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 1
note> lunch
Added #2: expense 12.50 food, 2026-09-03.

== Expense tracker: 2 entries, balance 2,987.50 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-09-01
amount> 800
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 2
note> rent
Added #3: expense 800.00 housing, 2026-09-01.

== Expense tracker: 3 entries, balance 2,187.50 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-09-05
amount> 1.2
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 3
note> bus
Added #4: expense 1.20 transport, 2026-09-05.

== Expense tracker: 4 entries, balance 2,186.30 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-09-12
amount> 45.20
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 1
note> groceries
Added #5: expense 45.20 food, 2026-09-12.

== Expense tracker: 5 entries, balance 2,141.10 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 1
date (YYYY-MM-DD)> 2026-10-02
amount> 60
1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other
category> 6
note> concert
Added #6: expense 60.00 fun, 2026-10-02.

== Expense tracker: 6 entries, balance 2,081.10 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 2
date (YYYY-MM-DD)> 2026-10-01
amount> 250.75
1) salary  2) freelance  3) gift  4) other
category> 2
note> logo design
Added #7: income 250.75 freelance, 2026-10-01.

== Expense tracker: 7 entries, balance 2,331.85 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 3

-- all entries: 7 --
  #1   2026-09-01  salary        +3,000.00  September pay
  #3   2026-09-01  housing         -800.00  rent
  #2   2026-09-03  food             -12.50  lunch
  #4   2026-09-05  transport         -1.20  bus
  #5   2026-09-12  food             -45.20  groceries
  #7   2026-10-01  freelance       +250.75  logo design
  #6   2026-10-02  fun              -60.00  concert

== Expense tracker: 7 entries, balance 2,331.85 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 4

-- spending by category --
  housing          800.00  ##############################
  fun               60.00  ##
  food              57.70  ##
  transport          1.20  #
  total            918.90

-- income by category --
  salary         3,000.00  ##############################
  freelance        250.75  ##
  total          3,250.75

== Expense tracker: 7 entries, balance 2,331.85 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 5
month (YYYY-MM)> 2026-09

-- 2026-09 --
  income         3,000.00
  spending         858.90
  net           +2,141.10
  entries: 5

-- spending in 2026-09 --
  housing          800.00  ##############################
  food              57.70  ##
  transport          1.20  #
  total            858.90

== Expense tracker: 7 entries, balance 2,331.85 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 5
month (YYYY-MM)> 2026-10

-- 2026-10 --
  income           250.75
  spending          60.00
  net             +190.75
  entries: 2

-- spending in 2026-10 --
  fun               60.00  ##############################
  total             60.00

== Expense tracker: 7 entries, balance 2,331.85 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 6
id> 4
Deleted #4.

== Expense tracker: 6 entries, balance 2,333.05 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 3

-- all entries: 6 --
  #1   2026-09-01  salary        +3,000.00  September pay
  #3   2026-09-01  housing         -800.00  rent
  #2   2026-09-03  food             -12.50  lunch
  #5   2026-09-12  food             -45.20  groceries
  #7   2026-10-01  freelance       +250.75  logo design
  #6   2026-10-02  fun              -60.00  concert

== Expense tracker: 6 entries, balance 2,333.05 ==
1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit
choice> 7
Bye.
What was typed (45 lines)
2
2026-09-01
3000
1
September pay
1
2026-09-03
12.50
1
lunch
1
2026-09-01
800
2
rent
1
2026-09-05
1.2
3
bus
1
2026-09-12
45.20
1
groceries
1
2026-10-02
60
6
concert
2
2026-10-01
250.75
2
logo design
3
4
5
2026-09
5
2026-10
6
4
3
7

Modules

The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.

main.eml(entry)

eml
# P003 expense tracker: income and expenses, each with a date, a category
# and a note; the balance on every menu; totals by category; a report for
# any month. Money is whole cents throughout. The entries live while the
# program runs; the first twenty projects do not save to files.
import ledger
import fields
import report

def ask_date():
    fields.trim(input("date (YYYY-MM-DD)> ")) => d
    fields.date_problem(d) => problem
    if problem != "":
        problem ^0
        return None
    return d

def ask_amount():
    fields.parse_cents(fields.trim(input("amount> "))) => cents
    if cents < 0:
        "An amount looks like 12 or 12.50." ^0
        return None
    if cents == 0:
        "An amount must be more than 0." ^0
        return None
    if cents > 100000000:
        "An amount can be at most 1,000,000.00." ^0
        return None
    return cents

def ask_category(kind):
    ledger.categories[kind] => names
    "" => choices
    0 => i
    while i < len(names):
        if choices != "":
            choices + "  " => choices
        choices + str(i + 1) + ") " + names[i] => choices
        i + 1 => i
    choices ^0
    fields.number(fields.trim(input("category> "))) => n
    if n < 1 or n > len(names):
        ("Pick a category from 1 to " + str(len(names)) + ".") ^0
        return None
    return names[n - 1]

def add(book, kind):
    # One entry of this kind; nothing is recorded if any field is refused.
    ask_date() => d
    if d == None:
        return None
    ask_amount() => cents
    if cents == None:
        return None
    ask_category(kind) => category
    if category == None:
        return None
    fields.trim(input("note> ")) => note
    book.add(d, kind, category, cents, note) => entry_id
    ("Added #" + str(entry_id) + ": " + kind + " " + fields.money(cents) + " " + category + ", " + d + ".") ^0

ledger.Ledger() => book
True => running
while running:
    report.menu(book)
    fields.trim(input("choice> ")) => choice
    if choice == "1":
        add(book, "expense")
    elif choice == "2":
        add(book, "income")
    elif choice == "3":
        report.show_list("all entries", book.by_date())
    elif choice == "4":
        report.show_categories("spending by category", book.by_category("expense", ""))
        report.show_categories("income by category", book.by_category("income", ""))
    elif choice == "5":
        fields.trim(input("month (YYYY-MM)> ")) => month
        if fields.month_ok(month):
            report.month_report(book, month)
        else:
            "A month looks like 2026-09." ^0
    elif choice == "6":
        fields.number(fields.trim(input("id> "))) => n
        if n > 0 and book.remove(n):
            ("Deleted #" + str(n) + ".") ^0
        else:
            "No entry with that id." ^0
    elif choice == "7":
        False => running
    else:
        "Pick a number from 1 to 7." ^0
"Bye." ^0
Python projection (main.py)
import ledger
import fields
import report

def ask_date():
    d = fields.trim(input("date (YYYY-MM-DD)> "))
    problem = fields.date_problem(d)
    if problem != "":
        print(problem)
        return None
    return d

def ask_amount():
    cents = fields.parse_cents(fields.trim(input("amount> ")))
    if cents < 0:
        print("An amount looks like 12 or 12.50.")
        return None
    if cents == 0:
        print("An amount must be more than 0.")
        return None
    if cents > 100000000:
        print("An amount can be at most 1,000,000.00.")
        return None
    return cents

def ask_category(kind):
    names = ledger.categories[kind]
    choices = ""
    i = 0
    while i < len(names):
        if choices != "":
            choices = choices + "  "
        choices = choices + str(i + 1) + ") " + names[i]
        i = i + 1
    print(choices)
    n = fields.number(fields.trim(input("category> ")))
    if n < 1 or n > len(names):
        print("Pick a category from 1 to " + str(len(names)) + ".")
        return None
    return names[n - 1]

def add(book, kind):
    d = ask_date()
    if d == None:
        return None
    cents = ask_amount()
    if cents == None:
        return None
    category = ask_category(kind)
    if category == None:
        return None
    note = fields.trim(input("note> "))
    entry_id = book.add(d, kind, category, cents, note)
    print("Added #" + str(entry_id) + ": " + kind + " " + fields.money(cents) + " " + category + ", " + d + ".")

book = ledger.Ledger()
running = True
while running:
    report.menu(book)
    choice = fields.trim(input("choice> "))
    if choice == "1":
        add(book, "expense")
    elif choice == "2":
        add(book, "income")
    elif choice == "3":
        report.show_list("all entries", book.by_date())
    elif choice == "4":
        report.show_categories("spending by category", book.by_category("expense", ""))
        report.show_categories("income by category", book.by_category("income", ""))
    elif choice == "5":
        month = fields.trim(input("month (YYYY-MM)> "))
        if fields.month_ok(month):
            report.month_report(book, month)
        else:
            print("A month looks like 2026-09.")
    elif choice == "6":
        n = fields.number(fields.trim(input("id> ")))
        if n > 0 and book.remove(n):
            print("Deleted #" + str(n) + ".")
        else:
            print("No entry with that id.")
    elif choice == "7":
        running = False
    else:
        print("Pick a number from 1 to 7.")
print("Bye.")

ledger.eml

eml
# P003 expense tracker - the ledger. An entry is a list
# [id, date, kind, category, cents, note]: the date is YYYY-MM-DD, the kind
# "income" or "expense", and cents is always positive - the kind carries the
# sign. Ids are never reused.

{
    "expense": ["food", "housing", "transport", "bills", "health", "fun", "other"],
    "income": ["salary", "freelance", "gift", "other"],
} => categories

class Ledger:
    def __init__(self):
        [] => self.entries
        1 => self.next_id

    def add(self, date, kind, category, cents, note):
        # Records an entry and returns its id.
        self.entries + [[self.next_id, date, kind, category, cents, note]] => self.entries
        self.next_id + 1 => self.next_id
        return self.next_id - 1

    def find(self, entry_id):
        for e in self.entries:
            if e[0] == entry_id:
                return e
        return None

    def remove(self, entry_id):
        # True if there was an entry with this id to remove.
        if self.find(entry_id) == None:
            return False
        [e for e in self.entries if e[0] != entry_id] => self.entries
        return True

    def total(self, kind, month):
        # The cents of one kind of entry in one month - or in all of them
        # when month is "".
        0 => cents
        for e in self.entries:
            if e[2] == kind and (month == "" or e[1][0:7] == month):
                cents + e[4] => cents
        return cents

    def balance(self):
        return self.total("income", "") - self.total("expense", "")

    def by_date(self):
        # Every entry, the oldest date first. Entries of one date keep the
        # order they were added in: each goes after every entry already
        # placed whose date is the same or earlier.
        [] => ordered
        for e in self.entries:
            0 => i
            while i < len(ordered) and ordered[i][1] <= e[1]:
                i + 1 => i
            ordered[0:i] + [e] + ordered[i:len(ordered)] => ordered
        return ordered

    def in_month(self, month):
        return [e for e in self.by_date() if e[1][0:7] == month]

    def by_category(self, kind, month):
        # [category, cents] for every category of this kind that has any, in
        # one month or ("") all of them: the largest first, and equal totals
        # in the order the categories are listed.
        [] => rows
        for c in categories[kind]:
            0 => cents
            for e in self.entries:
                if e[2] == kind and e[3] == c and (month == "" or e[1][0:7] == month):
                    cents + e[4] => cents
            if cents > 0:
                0 => i
                while i < len(rows) and rows[i][1] >= cents:
                    i + 1 => i
                rows[0:i] + [[c, cents]] + rows[i:len(rows)] => rows
        return rows
Python projection (ledger.py)
categories = {"expense": ["food", "housing", "transport", "bills", "health", "fun", "other"], "income": ["salary", "freelance", "gift", "other"]}

class Ledger:
    def __init__(self):
        self.entries = []
        self.next_id = 1
    def add(self, date, kind, category, cents, note):
        self.entries = self.entries + [[self.next_id, date, kind, category, cents, note]]
        self.next_id = self.next_id + 1
        return self.next_id - 1
    def find(self, entry_id):
        for e in self.entries:
            if e[0] == entry_id:
                return e
        return None
    def remove(self, entry_id):
        if self.find(entry_id) == None:
            return False
        self.entries = [e for e in self.entries if e[0] != entry_id]
        return True
    def total(self, kind, month):
        cents = 0
        for e in self.entries:
            if e[2] == kind and (month == "" or e[1][0:7] == month):
                cents = cents + e[4]
        return cents
    def balance(self):
        return self.total("income", "") - self.total("expense", "")
    def by_date(self):
        ordered = []
        for e in self.entries:
            i = 0
            while i < len(ordered) and ordered[i][1] <= e[1]:
                i = i + 1
            ordered = ordered[0:i] + [e] + ordered[i:len(ordered)]
        return ordered
    def in_month(self, month):
        return [e for e in self.by_date() if e[1][0:7] == month]
    def by_category(self, kind, month):
        rows = []
        for c in categories[kind]:
            cents = 0
            for e in self.entries:
                if e[2] == kind and e[3] == c and (month == "" or e[1][0:7] == month):
                    cents = cents + e[4]
            if cents > 0:
                i = 0
                while i < len(rows) and rows[i][1] >= cents:
                    i = i + 1
                rows = rows[0:i] + [[c, cents]] + rows[i:len(rows)]
        return rows

fields.eml

eml
# P003 expense tracker - reading and writing the fields of an entry: amounts,
# dates and months. Amounts are whole cents from the moment they are typed:
# "12.50" is read digit by digit into 1250 and never passes through a float,
# so no sum can drift by a fraction of a cent. The interpreter that checks
# every session does not run string methods yet, so the text handling is
# written out here too.

def trim(s):
    # s without the spaces at either end.
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def digit(c):
    # 0 to 9 for a digit character, -1 for anything else.
    "0123456789" => ds
    0 => i
    while i < 10:
        if ds[i] == c:
            return i
        i + 1 => i
    return -1

def number(s):
    # The value of s if it is digits only (at least one), otherwise -1.
    if s == "":
        return -1
    0 => n
    for c in s:
        digit(c) => d
        if d < 0:
            return -1
        n * 10 + d => n
    return n

def parse_cents(s):
    # The amount in s in whole cents, or -1 if s is not written like 12,
    # 12.5 or 12.50: digits, then a point and one or two digits if any.
    0 => whole
    0 => whole_digits
    0 => frac
    0 => frac_digits
    False => point
    for c in s:
        if c == ".":
            if point:
                return -1
            True => point
        else:
            digit(c) => d
            if d < 0:
                return -1
            if point:
                frac * 10 + d => frac
                frac_digits + 1 => frac_digits
            else:
                whole * 10 + d => whole
                whole_digits + 1 => whole_digits
    if whole_digits == 0:
        return -1
    if point and (frac_digits == 0 or frac_digits > 2):
        return -1
    if frac_digits == 1:
        frac * 10 => frac
    return whole * 100 + frac

def money(c):
    # Whole cents as 1,234.50, with a minus sign in front when c is negative.
    # Built from the digits of the integer, so no float is involved here either.
    if c < 0:
        return "-" + money(0 - c)
    str(c) => s
    while len(s) < 3:
        "0" + s => s
    s[0:len(s) - 2] => whole
    s[len(s) - 2:len(s)] => cents
    "" => grouped
    len(whole) => i
    while i > 3:
        "," + whole[i - 3:i] + grouped => grouped
        i - 3 => i
    return whole[0:i] + grouped + "." + cents

def leap(year):
    return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0

def days_in(year, month):
    if month == 2:
        if leap(year):
            return 29
        return 28
    if month == 4 or month == 6 or month == 9 or month == 11:
        return 30
    return 31

def date_problem(s):
    # What is wrong with s as a date written YYYY-MM-DD, or "" if nothing is.
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return "A date looks like 2026-09-27."
    number(s[0:4]) => year
    number(s[5:7]) => month
    number(s[8:10]) => day
    if year < 0 or month < 0 or day < 0:
        return "A date looks like 2026-09-27."
    if month < 1 or month > 12 or day < 1 or day > days_in(year, month):
        return "There is no " + s + "."
    return ""

def month_ok(s):
    # True for a month written YYYY-MM.
    if len(s) != 7 or s[4] != "-":
        return False
    number(s[0:4]) => year
    number(s[5:7]) => month
    return year >= 0 and month >= 1 and month <= 12
Python projection (fields.py)
def trim(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def digit(c):
    ds = "0123456789"
    i = 0
    while i < 10:
        if ds[i] == c:
            return i
        i = i + 1
    return -1

def number(s):
    if s == "":
        return -1
    n = 0
    for c in s:
        d = digit(c)
        if d < 0:
            return -1
        n = n * 10 + d
    return n

def parse_cents(s):
    whole = 0
    whole_digits = 0
    frac = 0
    frac_digits = 0
    point = False
    for c in s:
        if c == ".":
            if point:
                return -1
            point = True
        else:
            d = digit(c)
            if d < 0:
                return -1
            if point:
                frac = frac * 10 + d
                frac_digits = frac_digits + 1
            else:
                whole = whole * 10 + d
                whole_digits = whole_digits + 1
    if whole_digits == 0:
        return -1
    if point and (frac_digits == 0 or frac_digits > 2):
        return -1
    if frac_digits == 1:
        frac = frac * 10
    return whole * 100 + frac

def money(c):
    if c < 0:
        return "-" + money(0 - c)
    s = str(c)
    while len(s) < 3:
        s = "0" + s
    whole = s[0:len(s) - 2]
    cents = s[len(s) - 2:len(s)]
    grouped = ""
    i = len(whole)
    while i > 3:
        grouped = "," + whole[i - 3:i] + grouped
        i = i - 3
    return whole[0:i] + grouped + "." + cents

def leap(year):
    return year % 4 == 0 and year % 100 != 0 or year % 400 == 0

def days_in(year, month):
    if month == 2:
        if leap(year):
            return 29
        return 28
    if month == 4 or month == 6 or month == 9 or month == 11:
        return 30
    return 31

def date_problem(s):
    if len(s) != 10 or s[4] != "-" or s[7] != "-":
        return "A date looks like 2026-09-27."
    year = number(s[0:4])
    month = number(s[5:7])
    day = number(s[8:10])
    if year < 0 or month < 0 or day < 0:
        return "A date looks like 2026-09-27."
    if month < 1 or month > 12 or day < 1 or day > days_in(year, month):
        return "There is no " + s + "."
    return ""

def month_ok(s):
    if len(s) != 7 or s[4] != "-":
        return False
    year = number(s[0:4])
    month = number(s[5:7])
    return year >= 0 and month >= 1 and month <= 12

report.eml

eml
# P003 expense tracker - what the screen shows: the menu, the list of
# entries, totals by category with a bar for each, and the month report.
import fields

def count(n, one, many):
    if n == 1:
        return "1 " + one
    return str(n) + " " + many

def menu(book):
    "" ^0
    ("== Expense tracker: " + count(len(book.entries), "entry", "entries") + ", balance " + fields.money(book.balance()) + " ==") ^0
    "1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit" ^0

def signed(cents):
    # A plus sign for money in; money() already writes the minus.
    if cents > 0:
        return "+" + fields.money(cents)
    return fields.money(cents)

def row(label, cents):
    return "  " + ("%-10s" % label) + ("%13s" % fields.money(cents))

def show_list(title, entries):
    "" ^0
    ("-- " + title + ": " + str(len(entries)) + " --") ^0
    if len(entries) == 0:
        "  (nothing here)" ^0
    for e in entries:
        e[4] => cents
        if e[2] == "expense":
            0 - cents => cents
        ("  " + ("%-4s" % ("#" + str(e[0]))) + " " + e[1] + "  " + ("%-10s" % e[3]) + ("%13s" % signed(cents))) => line
        if e[5] != "":
            line + "  " + e[5] => line
        line ^0

def bar(cents, top):
    # Up to 30 marks, in proportion to the largest total; any total at all
    # gets at least one, so a small one does not look like none.
    int(cents * 30 / top) => n
    if n < 1:
        1 => n
    "" => out
    while len(out) < n:
        out + "#" => out
    return out

def show_categories(title, rows):
    "" ^0
    ("-- " + title + " --") ^0
    if len(rows) == 0:
        "  (nothing here)" ^0
        return None
    0 => total
    for r in rows:
        (row(r[0], r[1]) + "  " + bar(r[1], rows[0][1])) ^0
        total + r[1] => total
    row("total", total) ^0

def month_report(book, month):
    book.in_month(month) => entries
    "" ^0
    ("-- " + month + " --") ^0
    if len(entries) == 0:
        ("  No entries in " + month + ".") ^0
        return None
    book.total("income", month) => income
    book.total("expense", month) => spent
    row("income", income) ^0
    row("spending", spent) ^0
    ("  " + ("%-10s" % "net") + ("%13s" % signed(income - spent))) ^0
    ("  entries: " + str(len(entries))) ^0
    show_categories("spending in " + month, book.by_category("expense", month))
Python projection (report.py)
import fields

def count(n, one, many):
    if n == 1:
        return "1 " + one
    return str(n) + " " + many

def menu(book):
    print("")
    print("== Expense tracker: " + count(len(book.entries), "entry", "entries") + ", balance " + fields.money(book.balance()) + " ==")
    print("1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit")

def signed(cents):
    if cents > 0:
        return "+" + fields.money(cents)
    return fields.money(cents)

def row(label, cents):
    return "  " + "%-10s" % label + "%13s" % fields.money(cents)

def show_list(title, entries):
    print("")
    print("-- " + title + ": " + str(len(entries)) + " --")
    if len(entries) == 0:
        print("  (nothing here)")
    for e in entries:
        cents = e[4]
        if e[2] == "expense":
            cents = 0 - cents
        line = "  " + "%-4s" % ("#" + str(e[0])) + " " + e[1] + "  " + "%-10s" % e[3] + "%13s" % signed(cents)
        if e[5] != "":
            line = line + "  " + e[5]
        print(line)

def bar(cents, top):
    n = int(cents * 30 / top)
    if n < 1:
        n = 1
    out = ""
    while len(out) < n:
        out = out + "#"
    return out

def show_categories(title, rows):
    print("")
    print("-- " + title + " --")
    if len(rows) == 0:
        print("  (nothing here)")
        return None
    total = 0
    for r in rows:
        print(row(r[0], r[1]) + "  " + bar(r[1], rows[0][1]))
        total = total + r[1]
    print(row("total", total))

def month_report(book, month):
    entries = book.in_month(month)
    print("")
    print("-- " + month + " --")
    if len(entries) == 0:
        print("  No entries in " + month + ".")
        return None
    income = book.total("income", month)
    spent = book.total("expense", month)
    print(row("income", income))
    print(row("spending", spent))
    print("  " + "%-10s" % "net" + "%13s" % signed(income - spent))
    print("  entries: " + str(len(entries)))
    show_categories("spending in " + month, book.by_category("expense", month))

Built on these corpus cases