{
  "id": "P003",
  "slug": "expense-tracker",
  "key": "P003-expense-tracker",
  "title": "Expense tracker",
  "summary": "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.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P003 - Expense tracker\n\nRecords income and expenses, each with a date, an amount, a category and a\nnote, and shows the balance on every menu, the entries in date order, the\ntotals by category with a bar for each, and a report for any month: income,\nspending, net and where the money went. A text menu; the entries live while the\nprogram runs.\n\n- `main.eml` - the menu loop and the questions asked for a new entry\n- `ledger.eml` - the ledger, a class: add, find and remove entries, totals by\n  kind and month, entries in date order (equal dates keep the order they were\n  added in) and totals by category, largest first\n- `fields.eml` - amounts, dates and months: reading an amount into whole cents\n  digit by digit, writing cents as 1,234.50, and checking a date against the\n  calendar, leap years included\n- `report.eml` - what the screen shows: the menu line with the balance, the\n  list, the category totals and the month report\n\nMoney is whole cents from the moment it is typed: \"12.50\" becomes 1250 without\npassing through a float, and every total is a sum of integers, so none can be\noff by a fraction of a cent.\n\nWhat is checked: a date must be written YYYY-MM-DD and exist - 2024-02-29 does,\n2025-02-29 and 1900-02-29 do not; an amount must be digits with at most two\nafter the point, more than 0 and at most 1,000,000.00; a category must be one\nof the numbers offered; a month must be written YYYY-MM. Nothing is recorded\nuntil every field of an entry has been accepted.\n\nSessions: `sessions/basic.in` records income and expenses over two months, two\nof them on one day, lists them, shows the totals by category and both month\nreports, deletes an entry and lists again; `sessions/bad-input.in` uses every\nscreen while the ledger is empty, then types dates in the wrong form or not on\nthe calendar, amounts with three decimals, letters, a sign, a comma, zero and\ntoo much, categories that are not offered, an empty note, a month with no\nentries and ids that do not exist.\n\nBuilt on the verified corpus cases `money-in-cents` (amounts kept in integer\ncents rather than floating-point currency), `bank-account-simulator` (an\naccount as a class whose methods keep its balance) and `histogram-builder`\n(totals drawn as bars in proportion to the largest).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P003 expense tracker: income and expenses, each with a date, a category\n# and a note; the balance on every menu; totals by category; a report for\n# any month. Money is whole cents throughout. The entries live while the\n# program runs; the first twenty projects do not save to files.\nimport ledger\nimport fields\nimport report\n\ndef ask_date():\n    fields.trim(input(\"date (YYYY-MM-DD)> \")) => d\n    fields.date_problem(d) => problem\n    if problem != \"\":\n        problem ^0\n        return None\n    return d\n\ndef ask_amount():\n    fields.parse_cents(fields.trim(input(\"amount> \"))) => cents\n    if cents < 0:\n        \"An amount looks like 12 or 12.50.\" ^0\n        return None\n    if cents == 0:\n        \"An amount must be more than 0.\" ^0\n        return None\n    if cents > 100000000:\n        \"An amount can be at most 1,000,000.00.\" ^0\n        return None\n    return cents\n\ndef ask_category(kind):\n    ledger.categories[kind] => names\n    \"\" => choices\n    0 => i\n    while i < len(names):\n        if choices != \"\":\n            choices + \"  \" => choices\n        choices + str(i + 1) + \") \" + names[i] => choices\n        i + 1 => i\n    choices ^0\n    fields.number(fields.trim(input(\"category> \"))) => n\n    if n < 1 or n > len(names):\n        (\"Pick a category from 1 to \" + str(len(names)) + \".\") ^0\n        return None\n    return names[n - 1]\n\ndef add(book, kind):\n    # One entry of this kind; nothing is recorded if any field is refused.\n    ask_date() => d\n    if d == None:\n        return None\n    ask_amount() => cents\n    if cents == None:\n        return None\n    ask_category(kind) => category\n    if category == None:\n        return None\n    fields.trim(input(\"note> \")) => note\n    book.add(d, kind, category, cents, note) => entry_id\n    (\"Added #\" + str(entry_id) + \": \" + kind + \" \" + fields.money(cents) + \" \" + category + \", \" + d + \".\") ^0\n\nledger.Ledger() => book\nTrue => running\nwhile running:\n    report.menu(book)\n    fields.trim(input(\"choice> \")) => choice\n    if choice == \"1\":\n        add(book, \"expense\")\n    elif choice == \"2\":\n        add(book, \"income\")\n    elif choice == \"3\":\n        report.show_list(\"all entries\", book.by_date())\n    elif choice == \"4\":\n        report.show_categories(\"spending by category\", book.by_category(\"expense\", \"\"))\n        report.show_categories(\"income by category\", book.by_category(\"income\", \"\"))\n    elif choice == \"5\":\n        fields.trim(input(\"month (YYYY-MM)> \")) => month\n        if fields.month_ok(month):\n            report.month_report(book, month)\n        else:\n            \"A month looks like 2026-09.\" ^0\n    elif choice == \"6\":\n        fields.number(fields.trim(input(\"id> \"))) => n\n        if n > 0 and book.remove(n):\n            (\"Deleted #\" + str(n) + \".\") ^0\n        else:\n            \"No entry with that id.\" ^0\n    elif choice == \"7\":\n        False => running\n    else:\n        \"Pick a number from 1 to 7.\" ^0\n\"Bye.\" ^0\n",
      "python": "import ledger\nimport fields\nimport report\n\ndef ask_date():\n    d = fields.trim(input(\"date (YYYY-MM-DD)> \"))\n    problem = fields.date_problem(d)\n    if problem != \"\":\n        print(problem)\n        return None\n    return d\n\ndef ask_amount():\n    cents = fields.parse_cents(fields.trim(input(\"amount> \")))\n    if cents < 0:\n        print(\"An amount looks like 12 or 12.50.\")\n        return None\n    if cents == 0:\n        print(\"An amount must be more than 0.\")\n        return None\n    if cents > 100000000:\n        print(\"An amount can be at most 1,000,000.00.\")\n        return None\n    return cents\n\ndef ask_category(kind):\n    names = ledger.categories[kind]\n    choices = \"\"\n    i = 0\n    while i < len(names):\n        if choices != \"\":\n            choices = choices + \"  \"\n        choices = choices + str(i + 1) + \") \" + names[i]\n        i = i + 1\n    print(choices)\n    n = fields.number(fields.trim(input(\"category> \")))\n    if n < 1 or n > len(names):\n        print(\"Pick a category from 1 to \" + str(len(names)) + \".\")\n        return None\n    return names[n - 1]\n\ndef add(book, kind):\n    d = ask_date()\n    if d == None:\n        return None\n    cents = ask_amount()\n    if cents == None:\n        return None\n    category = ask_category(kind)\n    if category == None:\n        return None\n    note = fields.trim(input(\"note> \"))\n    entry_id = book.add(d, kind, category, cents, note)\n    print(\"Added #\" + str(entry_id) + \": \" + kind + \" \" + fields.money(cents) + \" \" + category + \", \" + d + \".\")\n\nbook = ledger.Ledger()\nrunning = True\nwhile running:\n    report.menu(book)\n    choice = fields.trim(input(\"choice> \"))\n    if choice == \"1\":\n        add(book, \"expense\")\n    elif choice == \"2\":\n        add(book, \"income\")\n    elif choice == \"3\":\n        report.show_list(\"all entries\", book.by_date())\n    elif choice == \"4\":\n        report.show_categories(\"spending by category\", book.by_category(\"expense\", \"\"))\n        report.show_categories(\"income by category\", book.by_category(\"income\", \"\"))\n    elif choice == \"5\":\n        month = fields.trim(input(\"month (YYYY-MM)> \"))\n        if fields.month_ok(month):\n            report.month_report(book, month)\n        else:\n            print(\"A month looks like 2026-09.\")\n    elif choice == \"6\":\n        n = fields.number(fields.trim(input(\"id> \")))\n        if n > 0 and book.remove(n):\n            print(\"Deleted #\" + str(n) + \".\")\n        else:\n            print(\"No entry with that id.\")\n    elif choice == \"7\":\n        running = False\n    else:\n        print(\"Pick a number from 1 to 7.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "ledger.eml",
      "eml": "# P003 expense tracker - the ledger. An entry is a list\n# [id, date, kind, category, cents, note]: the date is YYYY-MM-DD, the kind\n# \"income\" or \"expense\", and cents is always positive - the kind carries the\n# sign. Ids are never reused.\n\n{\n    \"expense\": [\"food\", \"housing\", \"transport\", \"bills\", \"health\", \"fun\", \"other\"],\n    \"income\": [\"salary\", \"freelance\", \"gift\", \"other\"],\n} => categories\n\nclass Ledger:\n    def __init__(self):\n        [] => self.entries\n        1 => self.next_id\n\n    def add(self, date, kind, category, cents, note):\n        # Records an entry and returns its id.\n        self.entries + [[self.next_id, date, kind, category, cents, note]] => self.entries\n        self.next_id + 1 => self.next_id\n        return self.next_id - 1\n\n    def find(self, entry_id):\n        for e in self.entries:\n            if e[0] == entry_id:\n                return e\n        return None\n\n    def remove(self, entry_id):\n        # True if there was an entry with this id to remove.\n        if self.find(entry_id) == None:\n            return False\n        [e for e in self.entries if e[0] != entry_id] => self.entries\n        return True\n\n    def total(self, kind, month):\n        # The cents of one kind of entry in one month - or in all of them\n        # when month is \"\".\n        0 => cents\n        for e in self.entries:\n            if e[2] == kind and (month == \"\" or e[1][0:7] == month):\n                cents + e[4] => cents\n        return cents\n\n    def balance(self):\n        return self.total(\"income\", \"\") - self.total(\"expense\", \"\")\n\n    def by_date(self):\n        # Every entry, the oldest date first. Entries of one date keep the\n        # order they were added in: each goes after every entry already\n        # placed whose date is the same or earlier.\n        [] => ordered\n        for e in self.entries:\n            0 => i\n            while i < len(ordered) and ordered[i][1] <= e[1]:\n                i + 1 => i\n            ordered[0:i] + [e] + ordered[i:len(ordered)] => ordered\n        return ordered\n\n    def in_month(self, month):\n        return [e for e in self.by_date() if e[1][0:7] == month]\n\n    def by_category(self, kind, month):\n        # [category, cents] for every category of this kind that has any, in\n        # one month or (\"\") all of them: the largest first, and equal totals\n        # in the order the categories are listed.\n        [] => rows\n        for c in categories[kind]:\n            0 => cents\n            for e in self.entries:\n                if e[2] == kind and e[3] == c and (month == \"\" or e[1][0:7] == month):\n                    cents + e[4] => cents\n            if cents > 0:\n                0 => i\n                while i < len(rows) and rows[i][1] >= cents:\n                    i + 1 => i\n                rows[0:i] + [[c, cents]] + rows[i:len(rows)] => rows\n        return rows\n",
      "python": "categories = {\"expense\": [\"food\", \"housing\", \"transport\", \"bills\", \"health\", \"fun\", \"other\"], \"income\": [\"salary\", \"freelance\", \"gift\", \"other\"]}\n\nclass Ledger:\n    def __init__(self):\n        self.entries = []\n        self.next_id = 1\n    def add(self, date, kind, category, cents, note):\n        self.entries = self.entries + [[self.next_id, date, kind, category, cents, note]]\n        self.next_id = self.next_id + 1\n        return self.next_id - 1\n    def find(self, entry_id):\n        for e in self.entries:\n            if e[0] == entry_id:\n                return e\n        return None\n    def remove(self, entry_id):\n        if self.find(entry_id) == None:\n            return False\n        self.entries = [e for e in self.entries if e[0] != entry_id]\n        return True\n    def total(self, kind, month):\n        cents = 0\n        for e in self.entries:\n            if e[2] == kind and (month == \"\" or e[1][0:7] == month):\n                cents = cents + e[4]\n        return cents\n    def balance(self):\n        return self.total(\"income\", \"\") - self.total(\"expense\", \"\")\n    def by_date(self):\n        ordered = []\n        for e in self.entries:\n            i = 0\n            while i < len(ordered) and ordered[i][1] <= e[1]:\n                i = i + 1\n            ordered = ordered[0:i] + [e] + ordered[i:len(ordered)]\n        return ordered\n    def in_month(self, month):\n        return [e for e in self.by_date() if e[1][0:7] == month]\n    def by_category(self, kind, month):\n        rows = []\n        for c in categories[kind]:\n            cents = 0\n            for e in self.entries:\n                if e[2] == kind and e[3] == c and (month == \"\" or e[1][0:7] == month):\n                    cents = cents + e[4]\n            if cents > 0:\n                i = 0\n                while i < len(rows) and rows[i][1] >= cents:\n                    i = i + 1\n                rows = rows[0:i] + [[c, cents]] + rows[i:len(rows)]\n        return rows\n"
    },
    {
      "name": "fields.eml",
      "eml": "# P003 expense tracker - reading and writing the fields of an entry: amounts,\n# dates and months. Amounts are whole cents from the moment they are typed:\n# \"12.50\" is read digit by digit into 1250 and never passes through a float,\n# so no sum can drift by a fraction of a cent. The interpreter that checks\n# every session does not run string methods yet, so the text handling is\n# written out here too.\n\ndef trim(s):\n    # s without the spaces at either end.\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef digit(c):\n    # 0 to 9 for a digit character, -1 for anything else.\n    \"0123456789\" => ds\n    0 => i\n    while i < 10:\n        if ds[i] == c:\n            return i\n        i + 1 => i\n    return -1\n\ndef number(s):\n    # The value of s if it is digits only (at least one), otherwise -1.\n    if s == \"\":\n        return -1\n    0 => n\n    for c in s:\n        digit(c) => d\n        if d < 0:\n            return -1\n        n * 10 + d => n\n    return n\n\ndef parse_cents(s):\n    # The amount in s in whole cents, or -1 if s is not written like 12,\n    # 12.5 or 12.50: digits, then a point and one or two digits if any.\n    0 => whole\n    0 => whole_digits\n    0 => frac\n    0 => frac_digits\n    False => point\n    for c in s:\n        if c == \".\":\n            if point:\n                return -1\n            True => point\n        else:\n            digit(c) => d\n            if d < 0:\n                return -1\n            if point:\n                frac * 10 + d => frac\n                frac_digits + 1 => frac_digits\n            else:\n                whole * 10 + d => whole\n                whole_digits + 1 => whole_digits\n    if whole_digits == 0:\n        return -1\n    if point and (frac_digits == 0 or frac_digits > 2):\n        return -1\n    if frac_digits == 1:\n        frac * 10 => frac\n    return whole * 100 + frac\n\ndef money(c):\n    # Whole cents as 1,234.50, with a minus sign in front when c is negative.\n    # Built from the digits of the integer, so no float is involved here either.\n    if c < 0:\n        return \"-\" + money(0 - c)\n    str(c) => s\n    while len(s) < 3:\n        \"0\" + s => s\n    s[0:len(s) - 2] => whole\n    s[len(s) - 2:len(s)] => cents\n    \"\" => grouped\n    len(whole) => i\n    while i > 3:\n        \",\" + whole[i - 3:i] + grouped => grouped\n        i - 3 => i\n    return whole[0:i] + grouped + \".\" + cents\n\ndef leap(year):\n    return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0\n\ndef days_in(year, month):\n    if month == 2:\n        if leap(year):\n            return 29\n        return 28\n    if month == 4 or month == 6 or month == 9 or month == 11:\n        return 30\n    return 31\n\ndef date_problem(s):\n    # What is wrong with s as a date written YYYY-MM-DD, or \"\" if nothing is.\n    if len(s) != 10 or s[4] != \"-\" or s[7] != \"-\":\n        return \"A date looks like 2026-09-27.\"\n    number(s[0:4]) => year\n    number(s[5:7]) => month\n    number(s[8:10]) => day\n    if year < 0 or month < 0 or day < 0:\n        return \"A date looks like 2026-09-27.\"\n    if month < 1 or month > 12 or day < 1 or day > days_in(year, month):\n        return \"There is no \" + s + \".\"\n    return \"\"\n\ndef month_ok(s):\n    # True for a month written YYYY-MM.\n    if len(s) != 7 or s[4] != \"-\":\n        return False\n    number(s[0:4]) => year\n    number(s[5:7]) => month\n    return year >= 0 and month >= 1 and month <= 12\n",
      "python": "def trim(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef digit(c):\n    ds = \"0123456789\"\n    i = 0\n    while i < 10:\n        if ds[i] == c:\n            return i\n        i = i + 1\n    return -1\n\ndef number(s):\n    if s == \"\":\n        return -1\n    n = 0\n    for c in s:\n        d = digit(c)\n        if d < 0:\n            return -1\n        n = n * 10 + d\n    return n\n\ndef parse_cents(s):\n    whole = 0\n    whole_digits = 0\n    frac = 0\n    frac_digits = 0\n    point = False\n    for c in s:\n        if c == \".\":\n            if point:\n                return -1\n            point = True\n        else:\n            d = digit(c)\n            if d < 0:\n                return -1\n            if point:\n                frac = frac * 10 + d\n                frac_digits = frac_digits + 1\n            else:\n                whole = whole * 10 + d\n                whole_digits = whole_digits + 1\n    if whole_digits == 0:\n        return -1\n    if point and (frac_digits == 0 or frac_digits > 2):\n        return -1\n    if frac_digits == 1:\n        frac = frac * 10\n    return whole * 100 + frac\n\ndef money(c):\n    if c < 0:\n        return \"-\" + money(0 - c)\n    s = str(c)\n    while len(s) < 3:\n        s = \"0\" + s\n    whole = s[0:len(s) - 2]\n    cents = s[len(s) - 2:len(s)]\n    grouped = \"\"\n    i = len(whole)\n    while i > 3:\n        grouped = \",\" + whole[i - 3:i] + grouped\n        i = i - 3\n    return whole[0:i] + grouped + \".\" + cents\n\ndef leap(year):\n    return year % 4 == 0 and year % 100 != 0 or year % 400 == 0\n\ndef days_in(year, month):\n    if month == 2:\n        if leap(year):\n            return 29\n        return 28\n    if month == 4 or month == 6 or month == 9 or month == 11:\n        return 30\n    return 31\n\ndef date_problem(s):\n    if len(s) != 10 or s[4] != \"-\" or s[7] != \"-\":\n        return \"A date looks like 2026-09-27.\"\n    year = number(s[0:4])\n    month = number(s[5:7])\n    day = number(s[8:10])\n    if year < 0 or month < 0 or day < 0:\n        return \"A date looks like 2026-09-27.\"\n    if month < 1 or month > 12 or day < 1 or day > days_in(year, month):\n        return \"There is no \" + s + \".\"\n    return \"\"\n\ndef month_ok(s):\n    if len(s) != 7 or s[4] != \"-\":\n        return False\n    year = number(s[0:4])\n    month = number(s[5:7])\n    return year >= 0 and month >= 1 and month <= 12\n"
    },
    {
      "name": "report.eml",
      "eml": "# P003 expense tracker - what the screen shows: the menu, the list of\n# entries, totals by category with a bar for each, and the month report.\nimport fields\n\ndef count(n, one, many):\n    if n == 1:\n        return \"1 \" + one\n    return str(n) + \" \" + many\n\ndef menu(book):\n    \"\" ^0\n    (\"== Expense tracker: \" + count(len(book.entries), \"entry\", \"entries\") + \", balance \" + fields.money(book.balance()) + \" ==\") ^0\n    \"1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\" ^0\n\ndef signed(cents):\n    # A plus sign for money in; money() already writes the minus.\n    if cents > 0:\n        return \"+\" + fields.money(cents)\n    return fields.money(cents)\n\ndef row(label, cents):\n    return \"  \" + (\"%-10s\" % label) + (\"%13s\" % fields.money(cents))\n\ndef show_list(title, entries):\n    \"\" ^0\n    (\"-- \" + title + \": \" + str(len(entries)) + \" --\") ^0\n    if len(entries) == 0:\n        \"  (nothing here)\" ^0\n    for e in entries:\n        e[4] => cents\n        if e[2] == \"expense\":\n            0 - cents => cents\n        (\"  \" + (\"%-4s\" % (\"#\" + str(e[0]))) + \" \" + e[1] + \"  \" + (\"%-10s\" % e[3]) + (\"%13s\" % signed(cents))) => line\n        if e[5] != \"\":\n            line + \"  \" + e[5] => line\n        line ^0\n\ndef bar(cents, top):\n    # Up to 30 marks, in proportion to the largest total; any total at all\n    # gets at least one, so a small one does not look like none.\n    int(cents * 30 / top) => n\n    if n < 1:\n        1 => n\n    \"\" => out\n    while len(out) < n:\n        out + \"#\" => out\n    return out\n\ndef show_categories(title, rows):\n    \"\" ^0\n    (\"-- \" + title + \" --\") ^0\n    if len(rows) == 0:\n        \"  (nothing here)\" ^0\n        return None\n    0 => total\n    for r in rows:\n        (row(r[0], r[1]) + \"  \" + bar(r[1], rows[0][1])) ^0\n        total + r[1] => total\n    row(\"total\", total) ^0\n\ndef month_report(book, month):\n    book.in_month(month) => entries\n    \"\" ^0\n    (\"-- \" + month + \" --\") ^0\n    if len(entries) == 0:\n        (\"  No entries in \" + month + \".\") ^0\n        return None\n    book.total(\"income\", month) => income\n    book.total(\"expense\", month) => spent\n    row(\"income\", income) ^0\n    row(\"spending\", spent) ^0\n    (\"  \" + (\"%-10s\" % \"net\") + (\"%13s\" % signed(income - spent))) ^0\n    (\"  entries: \" + str(len(entries))) ^0\n    show_categories(\"spending in \" + month, book.by_category(\"expense\", month))\n",
      "python": "import fields\n\ndef count(n, one, many):\n    if n == 1:\n        return \"1 \" + one\n    return str(n) + \" \" + many\n\ndef menu(book):\n    print(\"\")\n    print(\"== Expense tracker: \" + count(len(book.entries), \"entry\", \"entries\") + \", balance \" + fields.money(book.balance()) + \" ==\")\n    print(\"1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\")\n\ndef signed(cents):\n    if cents > 0:\n        return \"+\" + fields.money(cents)\n    return fields.money(cents)\n\ndef row(label, cents):\n    return \"  \" + \"%-10s\" % label + \"%13s\" % fields.money(cents)\n\ndef show_list(title, entries):\n    print(\"\")\n    print(\"-- \" + title + \": \" + str(len(entries)) + \" --\")\n    if len(entries) == 0:\n        print(\"  (nothing here)\")\n    for e in entries:\n        cents = e[4]\n        if e[2] == \"expense\":\n            cents = 0 - cents\n        line = \"  \" + \"%-4s\" % (\"#\" + str(e[0])) + \" \" + e[1] + \"  \" + \"%-10s\" % e[3] + \"%13s\" % signed(cents)\n        if e[5] != \"\":\n            line = line + \"  \" + e[5]\n        print(line)\n\ndef bar(cents, top):\n    n = int(cents * 30 / top)\n    if n < 1:\n        n = 1\n    out = \"\"\n    while len(out) < n:\n        out = out + \"#\"\n    return out\n\ndef show_categories(title, rows):\n    print(\"\")\n    print(\"-- \" + title + \" --\")\n    if len(rows) == 0:\n        print(\"  (nothing here)\")\n        return None\n    total = 0\n    for r in rows:\n        print(row(r[0], r[1]) + \"  \" + bar(r[1], rows[0][1]))\n        total = total + r[1]\n    print(row(\"total\", total))\n\ndef month_report(book, month):\n    entries = book.in_month(month)\n    print(\"\")\n    print(\"-- \" + month + \" --\")\n    if len(entries) == 0:\n        print(\"  No entries in \" + month + \".\")\n        return None\n    income = book.total(\"income\", month)\n    spent = book.total(\"expense\", month)\n    print(row(\"income\", income))\n    print(row(\"spending\", spent))\n    print(\"  \" + \"%-10s\" % \"net\" + \"%13s\" % signed(income - spent))\n    print(\"  entries: \" + str(len(entries)))\n    show_categories(\"spending in \" + month, book.by_category(\"expense\", month))\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "9\n3\n4\n5\n2026-13\n1\n2026-9-3\n1\n2026-02-30\n1\n2025-02-29\n1\n1900-02-29\n1\n2026-04-31\n1\n2024-02-29\n12.345\n1\n2024-02-29\nabc\n1\n2024-02-29\n-5\n1\n2024-02-29\n0\n1\n2024-02-29\n1,000\n1\n2024-02-29\n2000000\n1\n2024-02-29\n9.99\n9\n1\n2024-02-29\n9.99\nx\n1\n2024-02-29\n9.99\n5\n\n3\n5\n2024-03\n6\nx\n6\n99\n6\n0\n7\n",
      "screen": "\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 9\nPick a number from 1 to 7.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 3\n\n-- all entries: 0 --\n  (nothing here)\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 4\n\n-- spending by category --\n  (nothing here)\n\n-- income by category --\n  (nothing here)\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 5\nmonth (YYYY-MM)> 2026-13\nA month looks like 2026-09.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-9-3\nA date looks like 2026-09-27.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-02-30\nThere is no 2026-02-30.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2025-02-29\nThere is no 2025-02-29.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 1900-02-29\nThere is no 1900-02-29.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-04-31\nThere is no 2026-04-31.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 12.345\nAn amount looks like 12 or 12.50.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> abc\nAn amount looks like 12 or 12.50.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> -5\nAn amount looks like 12 or 12.50.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 0\nAn amount must be more than 0.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 1,000\nAn amount looks like 12 or 12.50.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 2000000\nAn amount can be at most 1,000,000.00.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 9.99\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 9\nPick a category from 1 to 7.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 9.99\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> x\nPick a category from 1 to 7.\n\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2024-02-29\namount> 9.99\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 5\nnote> \nAdded #1: expense 9.99 health, 2024-02-29.\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 3\n\n-- all entries: 1 --\n  #1   2024-02-29  health            -9.99\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 5\nmonth (YYYY-MM)> 2024-03\n\n-- 2024-03 --\n  No entries in 2024-03.\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 6\nid> x\nNo entry with that id.\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 6\nid> 99\nNo entry with that id.\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 6\nid> 0\nNo entry with that id.\n\n== Expense tracker: 1 entry, balance -9.99 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 7\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "2\n2026-09-01\n3000\n1\nSeptember pay\n1\n2026-09-03\n12.50\n1\nlunch\n1\n2026-09-01\n800\n2\nrent\n1\n2026-09-05\n1.2\n3\nbus\n1\n2026-09-12\n45.20\n1\ngroceries\n1\n2026-10-02\n60\n6\nconcert\n2\n2026-10-01\n250.75\n2\nlogo design\n3\n4\n5\n2026-09\n5\n2026-10\n6\n4\n3\n7\n",
      "screen": "\n== Expense tracker: 0 entries, balance 0.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2026-09-01\namount> 3000\n1) salary  2) freelance  3) gift  4) other\ncategory> 1\nnote> September pay\nAdded #1: income 3,000.00 salary, 2026-09-01.\n\n== Expense tracker: 1 entry, balance 3,000.00 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-09-03\namount> 12.50\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 1\nnote> lunch\nAdded #2: expense 12.50 food, 2026-09-03.\n\n== Expense tracker: 2 entries, balance 2,987.50 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-09-01\namount> 800\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 2\nnote> rent\nAdded #3: expense 800.00 housing, 2026-09-01.\n\n== Expense tracker: 3 entries, balance 2,187.50 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-09-05\namount> 1.2\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 3\nnote> bus\nAdded #4: expense 1.20 transport, 2026-09-05.\n\n== Expense tracker: 4 entries, balance 2,186.30 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-09-12\namount> 45.20\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 1\nnote> groceries\nAdded #5: expense 45.20 food, 2026-09-12.\n\n== Expense tracker: 5 entries, balance 2,141.10 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 1\ndate (YYYY-MM-DD)> 2026-10-02\namount> 60\n1) food  2) housing  3) transport  4) bills  5) health  6) fun  7) other\ncategory> 6\nnote> concert\nAdded #6: expense 60.00 fun, 2026-10-02.\n\n== Expense tracker: 6 entries, balance 2,081.10 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 2\ndate (YYYY-MM-DD)> 2026-10-01\namount> 250.75\n1) salary  2) freelance  3) gift  4) other\ncategory> 2\nnote> logo design\nAdded #7: income 250.75 freelance, 2026-10-01.\n\n== Expense tracker: 7 entries, balance 2,331.85 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 3\n\n-- all entries: 7 --\n  #1   2026-09-01  salary        +3,000.00  September pay\n  #3   2026-09-01  housing         -800.00  rent\n  #2   2026-09-03  food             -12.50  lunch\n  #4   2026-09-05  transport         -1.20  bus\n  #5   2026-09-12  food             -45.20  groceries\n  #7   2026-10-01  freelance       +250.75  logo design\n  #6   2026-10-02  fun              -60.00  concert\n\n== Expense tracker: 7 entries, balance 2,331.85 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 4\n\n-- spending by category --\n  housing          800.00  ##############################\n  fun               60.00  ##\n  food              57.70  ##\n  transport          1.20  #\n  total            918.90\n\n-- income by category --\n  salary         3,000.00  ##############################\n  freelance        250.75  ##\n  total          3,250.75\n\n== Expense tracker: 7 entries, balance 2,331.85 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 5\nmonth (YYYY-MM)> 2026-09\n\n-- 2026-09 --\n  income         3,000.00\n  spending         858.90\n  net           +2,141.10\n  entries: 5\n\n-- spending in 2026-09 --\n  housing          800.00  ##############################\n  food              57.70  ##\n  transport          1.20  #\n  total            858.90\n\n== Expense tracker: 7 entries, balance 2,331.85 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 5\nmonth (YYYY-MM)> 2026-10\n\n-- 2026-10 --\n  income           250.75\n  spending          60.00\n  net             +190.75\n  entries: 2\n\n-- spending in 2026-10 --\n  fun               60.00  ##############################\n  total             60.00\n\n== Expense tracker: 7 entries, balance 2,331.85 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 6\nid> 4\nDeleted #4.\n\n== Expense tracker: 6 entries, balance 2,333.05 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 3\n\n-- all entries: 6 --\n  #1   2026-09-01  salary        +3,000.00  September pay\n  #3   2026-09-01  housing         -800.00  rent\n  #2   2026-09-03  food             -12.50  lunch\n  #5   2026-09-12  food             -45.20  groceries\n  #7   2026-10-01  freelance       +250.75  logo design\n  #6   2026-10-02  fun              -60.00  concert\n\n== Expense tracker: 6 entries, balance 2,333.05 ==\n1) add expense  2) add income  3) list  4) by category  5) month report  6) delete  7) quit\nchoice> 7\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "money-in-cents",
      "caseId": "191-money-in-cents",
      "title": "The same invoice, in floats and in cents"
    },
    {
      "slug": "bank-account-simulator",
      "caseId": "013-bank-account-simulator",
      "title": "Case corpus: a self-authored bank account simulator"
    },
    {
      "slug": "histogram-builder",
      "caseId": "113-histogram-builder",
      "title": "Histogram builder"
    }
  ],
  "updated": "2026-09-27"
}
