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.
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 entryledger.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 firstfields.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 includedreport.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))