<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P003-expense-tracker/ | updated: 2026-09-27 -->

# 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.

EML-P project `projects/expense-tracker` in the EML language repo: 4 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/expense-tracker` runs it and `eml project verify projects/expense-tracker` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: money-in-cents (https://efficientnewlanguage.org/cases/191-money-in-cents/), bank-account-simulator (https://efficientnewlanguage.org/cases/013-bank-account-simulator/), histogram-builder (https://efficientnewlanguage.org/cases/113-histogram-builder/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
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
```

Screen:

```text

== 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.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
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
```

Screen:

```text

== 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.
```

## Modules

### main.eml

```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 of main.eml:

```python
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 of ledger.eml:

```python
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 of fields.eml:

```python
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 of report.eml:

```python
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))
```

## README

# P003 - Expense tracker

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).
