<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P064-savings-ledger/ | updated: 2026-10-14 -->

# P064 Savings ledger

A savings account in whole cents: deposits, withdrawals that are refused rather than allowed to take it below zero, monthly compound interest rounded to the cent, a statement, a check of the books worked out from the log alone, and how many months a savings goal would take.

EML-P project `projects/savings-ledger` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/savings-ledger` runs it and `eml project verify projects/savings-ledger` 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: bank-account-simulator (https://efficientnewlanguage.org/cases/013-bank-account-simulator/), bank-account-invariant (https://efficientnewlanguage.org/cases/126-bank-account-invariant/).

## Sessions

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

Input:

```text
0
x
2
50
1
abc
-5
12.345
1000000.01
0
1
1,500
2
2000
7
25
x

6
100
6
5000

7
0
6
1,000,000
1
5
4
8
```

Screen:

```text
== Savings ledger ==
Whole cents, monthly interest, and a balance that never goes below zero.
The account opens in 2026-10 at 2.50% a year, paid monthly.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 0
Pick a number from 1 to 8.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> x
Pick a number from 1 to 8.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 2
withdraw> 50
Refused: only 0.00 is in the account; the balance stays 0.00.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 1
deposit> abc
Type an amount like 250 or 12.50, up to 1,000,000.00.
deposit> -5
Type an amount like 250 or 12.50, up to 1,000,000.00.
deposit> 12.345
Type an amount like 250 or 12.50, up to 1,000,000.00.
deposit> 1000000.01
Type an amount like 250 or 12.50, up to 1,000,000.00.
deposit> 0
Not done: a deposit must be more than 0.00.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 1
deposit> 1,500
Deposited 1,500.00 in 2026-10. Balance 1,500.00.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 2
withdraw> 2000
Refused: only 1,500.00 is in the account; the balance stays 1,500.00.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 7
yearly rate in % (0 to 20)> 25
Type a rate like 2.5 or 3.25, from 0 to 20.
yearly rate in % (0 to 20)> x
Type a rate like 2.5 or 3.25, from 0 to 20.
yearly rate in % (0 to 20)> 
Cancelled.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 6
goal> 100
The balance, 1,500.00, is there already.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 6
goal> 5000
put in each month> 
Cancelled.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 7
yearly rate in % (0 to 20)> 0
The rate is now 0.00% a year, paid monthly.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 6
goal> 1,000,000
put in each month> 1
Putting in 1.00 a month, it would take more than 100 years.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 5
From the log: deposits 1,500.00, withdrawals 0.00, interest 0.00.
They add up to 1,500.00; the account says 1,500.00.
The books agree, every balance on the way was at least 0.00, and 2 refused withdrawal(s) changed nothing.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 4
month     entry           amount       balance
2026-10   refused        (50.00)          0.00
2026-10   deposit       1,500.00      1,500.00
2026-10   refused     (2,000.00)      1,500.00

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 8
Bye.
```

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

Input:

```text
1
1000
1
250.50
3
2
300
3
3
4
5
6
10,000
500
7
4
6
10,000
500
8
```

Screen:

```text
== Savings ledger ==
Whole cents, monthly interest, and a balance that never goes below zero.
The account opens in 2026-10 at 2.50% a year, paid monthly.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 1
deposit> 1000
Deposited 1,000.00 in 2026-10. Balance 1,000.00.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 1
deposit> 250.50
Deposited 250.50 in 2026-10. Balance 1,250.50.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 3
2026-10 closes: interest 2.61 at 2.50% a year. Balance 1,253.11; now 2026-11.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 2
withdraw> 300
Withdrew 300.00. Balance 953.11.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 3
2026-11 closes: interest 1.99 at 2.50% a year. Balance 955.10; now 2026-12.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 3
2026-12 closes: interest 1.99 at 2.50% a year. Balance 957.09; now 2027-01.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 4
month     entry           amount       balance
2026-10   deposit       1,000.00      1,000.00
2026-10   deposit         250.50      1,250.50
2026-10   interest          2.61      1,253.11
2026-11   withdraw       -300.00        953.11
2026-11   interest          1.99        955.10
2026-12   interest          1.99        957.09

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 5
From the log: deposits 1,250.50, withdrawals 300.00, interest 6.59.
They add up to 957.09; the account says 957.09.
The books agree, every balance on the way was at least 0.00, and 0 refused withdrawal(s) changed nothing.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 6
goal> 10,000
put in each month> 500
Putting in 500.00 a month at 2.50%: 18 months (1 year 6 months), reaching 10,173.88 with 216.79 of it interest.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 7
yearly rate in % (0 to 20)> 4
The rate is now 4.00% a year, paid monthly.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 6
goal> 10,000
put in each month> 500
Putting in 500.00 a month at 4.00%: 18 months (1 year 6 months), reaching 10,306.65 with 349.56 of it interest.

1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit
choice> 8
Bye.
```

## Modules

### main.eml

```eml
# P064 savings ledger: a savings account in whole cents - deposits,
# withdrawals that can never take it below zero, monthly compound interest
# - with a statement, a check of the books from the log alone, and how long
# a goal would take.
import ledger

def trim(s):
    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 cents_of(s, places):
    # "120", "120.5", "1,200.50" as a whole number in 1/10^places, or -1.
    # Commas between the thousands are skipped.
    "" => t
    for ch in s:
        if ch != ",":
            t + ch => t
    0 => k
    while k < len(t) and t[k] != ".":
        k + 1 => k
    t[0:k] => whole
    "" => part
    if k < len(t):
        t[k + 1:len(t)] => part
    if whole == "" or len(whole) > 7 or len(part) > places:
        return -1
    for ch in whole + part:
        if not (ch >= "0" and ch <= "9"):
            return -1
    while len(part) < places:
        part + "0" => part
    0 => n
    for ch in whole + part:
        n * 10 + int(ch) => n
    return n

def month_label(m):
    # Month 0 is 2026-10.
    9 + m => k
    2026 + ledger.quotient(k, 12) => y
    str(k % 12 + 1) => mm
    if len(mm) == 1:
        "0" + mm => mm
    return str(y) + "-" + mm

def rate_text(rate):
    str(rate % 100) => part
    if len(part) == 1:
        "0" + part => part
    return str(ledger.quotient(rate, 100)) + "." + part + "%"

def ask_amount(prompt):
    while True:
        trim(input(prompt)) => answer
        if answer == "":
            return -1
        cents_of(answer, 2) => c
        if c >= 0 and c <= 100000000:
            return c
        "Type an amount like 250 or 12.50, up to 1,000,000.00." ^0

def deposit(acct):
    ask_amount("deposit> ") => c
    if c == -1:
        "Cancelled." ^0
        return
    try:
        acct.deposit(c)
        ("Deposited " + ledger.money(c) + " in " + month_label(acct.month) + ". Balance " + ledger.money(acct.balance) + ".") ^0
    except ValueError as e:
        ("Not done: " + str(e) + ".") ^0

def withdraw(acct):
    ask_amount("withdraw> ") => c
    if c == -1:
        "Cancelled." ^0
        return
    try:
        acct.withdraw(c)
        ("Withdrew " + ledger.money(c) + ". Balance " + ledger.money(acct.balance) + ".") ^0
    except ValueError as e:
        ("Refused: " + str(e) + "; the balance stays " + ledger.money(acct.balance) + ".") ^0

def next_month(acct):
    month_label(acct.month) => ending
    acct.next_month() => earned
    (ending + " closes: interest " + ledger.money(earned) + " at " + rate_text(acct.rate) + " a year. Balance " + ledger.money(acct.balance) + "; now " + month_label(acct.month) + ".") ^0

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

def padded(s, width):
    while len(s) < width:
        " " + s => s
    return s

def statement(acct):
    if len(acct.log) == 0:
        "Nothing has happened in the account yet." ^0
        return
    "month     entry           amount       balance" ^0
    for e in acct.log:
        e[1] => kind
        while len(kind) < 9:
            kind + " " => kind
        ledger.money(e[2]) => amount
        if e[1] == "withdraw":
            "-" + amount => amount
        elif e[1] == "refused":
            "(" + amount + ")" => amount
        (month_label(e[0]) + "   " + kind + padded(amount, 13) + padded(ledger.money(e[3]), 14)) ^0

def check_books(acct):
    ledger.books(acct.log) => b
    ("From the log: deposits " + ledger.money(b[0]) + ", withdrawals " + ledger.money(b[1]) + ", interest " + ledger.money(b[2]) + ".") ^0
    ("They add up to " + ledger.money(b[3]) + "; the account says " + ledger.money(acct.balance) + ".") ^0
    0 => refused
    for e in acct.log:
        if e[1] == "refused":
            refused + 1 => refused
    if b[3] == acct.balance and b[4] and b[5]:
        ("The books agree, every balance on the way was at least 0.00, and " + str(refused) + " refused withdrawal(s) changed nothing.") ^0
    else:
        "The books do NOT agree." ^0

def goal(acct):
    ask_amount("goal> ") => g
    if g == -1:
        "Cancelled." ^0
        return
    if g <= acct.balance:
        ("The balance, " + ledger.money(acct.balance) + ", is there already.") ^0
        return
    ask_amount("put in each month> ") => m
    if m == -1:
        "Cancelled." ^0
        return
    ledger.months_to_goal(acct.balance, acct.rate, m, g) => r
    if r[0] == -1:
        ("Putting in " + ledger.money(m) + " a month, it would take more than 100 years.") ^0
        return
    ledger.quotient(r[0], 12) => years
    ("Putting in " + ledger.money(m) + " a month at " + rate_text(acct.rate) + ": " + counted(r[0], "month", "months") + " (" + counted(years, "year", "years") + " " + counted(r[0] % 12, "month", "months") + "), reaching " + ledger.money(r[1]) + " with " + ledger.money(r[2]) + " of it interest.") ^0

def set_rate(acct):
    while True:
        trim(input("yearly rate in % (0 to 20)> ")) => answer
        if answer == "":
            "Cancelled." ^0
            return
        cents_of(answer, 2) => r
        if r >= 0 and r <= 2000:
            r => acct.rate
            ("The rate is now " + rate_text(r) + " a year, paid monthly.") ^0
            return
        "Type a rate like 2.5 or 3.25, from 0 to 20." ^0

"== Savings ledger ==" ^0
"Whole cents, monthly interest, and a balance that never goes below zero." ^0
ledger.Account(250) => acct
("The account opens in " + month_label(0) + " at " + rate_text(250) + " a year, paid monthly.") ^0
True => running
while running:
    "" ^0
    "1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit" ^0
    trim(input("choice> ")) => choice
    if choice == "1":
        deposit(acct)
    elif choice == "2":
        withdraw(acct)
    elif choice == "3":
        next_month(acct)
    elif choice == "4":
        statement(acct)
    elif choice == "5":
        check_books(acct)
    elif choice == "6":
        goal(acct)
    elif choice == "7":
        set_rate(acct)
    elif choice == "8":
        False => running
    else:
        "Pick a number from 1 to 8." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import ledger

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 cents_of(s, places):
    t = ""
    for ch in s:
        if ch != ",":
            t = t + ch
    k = 0
    while k < len(t) and t[k] != ".":
        k = k + 1
    whole = t[0:k]
    part = ""
    if k < len(t):
        part = t[k + 1:len(t)]
    if whole == "" or len(whole) > 7 or len(part) > places:
        return -1
    for ch in whole + part:
        if not (ch >= "0" and ch <= "9"):
            return -1
    while len(part) < places:
        part = part + "0"
    n = 0
    for ch in whole + part:
        n = n * 10 + int(ch)
    return n

def month_label(m):
    k = 9 + m
    y = 2026 + ledger.quotient(k, 12)
    mm = str(k % 12 + 1)
    if len(mm) == 1:
        mm = "0" + mm
    return str(y) + "-" + mm

def rate_text(rate):
    part = str(rate % 100)
    if len(part) == 1:
        part = "0" + part
    return str(ledger.quotient(rate, 100)) + "." + part + "%"

def ask_amount(prompt):
    while True:
        answer = trim(input(prompt))
        if answer == "":
            return -1
        c = cents_of(answer, 2)
        if c >= 0 and c <= 100000000:
            return c
        print("Type an amount like 250 or 12.50, up to 1,000,000.00.")

def deposit(acct):
    c = ask_amount("deposit> ")
    if c == -1:
        print("Cancelled.")
        return
    try:
        acct.deposit(c)
        print("Deposited " + ledger.money(c) + " in " + month_label(acct.month) + ". Balance " + ledger.money(acct.balance) + ".")
    except ValueError as e:
        print("Not done: " + str(e) + ".")

def withdraw(acct):
    c = ask_amount("withdraw> ")
    if c == -1:
        print("Cancelled.")
        return
    try:
        acct.withdraw(c)
        print("Withdrew " + ledger.money(c) + ". Balance " + ledger.money(acct.balance) + ".")
    except ValueError as e:
        print("Refused: " + str(e) + "; the balance stays " + ledger.money(acct.balance) + ".")

def next_month(acct):
    ending = month_label(acct.month)
    earned = acct.next_month()
    print(ending + " closes: interest " + ledger.money(earned) + " at " + rate_text(acct.rate) + " a year. Balance " + ledger.money(acct.balance) + "; now " + month_label(acct.month) + ".")

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

def padded(s, width):
    while len(s) < width:
        s = " " + s
    return s

def statement(acct):
    if len(acct.log) == 0:
        print("Nothing has happened in the account yet.")
        return
    print("month     entry           amount       balance")
    for e in acct.log:
        kind = e[1]
        while len(kind) < 9:
            kind = kind + " "
        amount = ledger.money(e[2])
        if e[1] == "withdraw":
            amount = "-" + amount
        elif e[1] == "refused":
            amount = "(" + amount + ")"
        print(month_label(e[0]) + "   " + kind + padded(amount, 13) + padded(ledger.money(e[3]), 14))

def check_books(acct):
    b = ledger.books(acct.log)
    print("From the log: deposits " + ledger.money(b[0]) + ", withdrawals " + ledger.money(b[1]) + ", interest " + ledger.money(b[2]) + ".")
    print("They add up to " + ledger.money(b[3]) + "; the account says " + ledger.money(acct.balance) + ".")
    refused = 0
    for e in acct.log:
        if e[1] == "refused":
            refused = refused + 1
    if b[3] == acct.balance and b[4] and b[5]:
        print("The books agree, every balance on the way was at least 0.00, and " + str(refused) + " refused withdrawal(s) changed nothing.")
    else:
        print("The books do NOT agree.")

def goal(acct):
    g = ask_amount("goal> ")
    if g == -1:
        print("Cancelled.")
        return
    if g <= acct.balance:
        print("The balance, " + ledger.money(acct.balance) + ", is there already.")
        return
    m = ask_amount("put in each month> ")
    if m == -1:
        print("Cancelled.")
        return
    r = ledger.months_to_goal(acct.balance, acct.rate, m, g)
    if r[0] == -1:
        print("Putting in " + ledger.money(m) + " a month, it would take more than 100 years.")
        return
    years = ledger.quotient(r[0], 12)
    print("Putting in " + ledger.money(m) + " a month at " + rate_text(acct.rate) + ": " + counted(r[0], "month", "months") + " (" + counted(years, "year", "years") + " " + counted(r[0] % 12, "month", "months") + "), reaching " + ledger.money(r[1]) + " with " + ledger.money(r[2]) + " of it interest.")

def set_rate(acct):
    while True:
        answer = trim(input("yearly rate in % (0 to 20)> "))
        if answer == "":
            print("Cancelled.")
            return
        r = cents_of(answer, 2)
        if r >= 0 and r <= 2000:
            acct.rate = r
            print("The rate is now " + rate_text(r) + " a year, paid monthly.")
            return
        print("Type a rate like 2.5 or 3.25, from 0 to 20.")

print("== Savings ledger ==")
print("Whole cents, monthly interest, and a balance that never goes below zero.")
acct = ledger.Account(250)
print("The account opens in " + month_label(0) + " at " + rate_text(250) + " a year, paid monthly.")
running = True
while running:
    print("")
    print("1) deposit  2) withdraw  3) next month  4) statement  5) check the books  6) goal  7) rate  8) quit")
    choice = trim(input("choice> "))
    if choice == "1":
        deposit(acct)
    elif choice == "2":
        withdraw(acct)
    elif choice == "3":
        next_month(acct)
    elif choice == "4":
        statement(acct)
    elif choice == "5":
        check_books(acct)
    elif choice == "6":
        goal(acct)
    elif choice == "7":
        set_rate(acct)
    elif choice == "8":
        running = False
    else:
        print("Pick a number from 1 to 8.")
print("Bye.")
```

### ledger.eml

```eml
# P064 savings ledger - the account, its log and the checks on it. Money is
# whole cents; the yearly rate is in hundredths of a percent (250 = 2.50%).

def quotient(a, b):
    # a // b for 0 <= a and b > 0. The numbers here stay far below 2^53, so a
    # / b as a float is exact when the answer is whole, and otherwise at least
    # 1/b away from a whole number - more than a float's error - so int()
    # rounds down correctly.
    return int(a / b)

def month_interest(balance, rate):
    # One month at the yearly rate: balance x rate / 10000 / 12, to the
    # nearest cent, halves up.
    return quotient(2 * balance * rate + 120000, 240000)

class Account:
    def __init__(self, rate):
        0 => self.balance
        rate => self.rate
        0 => self.month
        # each entry: [month, kind, amount, balance after]; kinds deposit,
        # withdraw, interest, refused
        [] => self.log

    def deposit(self, cents):
        if cents <= 0:
            raise ValueError("a deposit must be more than 0.00")
        self.balance + cents => self.balance
        self.log + [[self.month, "deposit", cents, self.balance]] => self.log

    def withdraw(self, cents):
        # The balance is never allowed below zero: a withdrawal that would do
        # that is refused by raising, which the caller cannot overlook - the
        # corpus case bank-account-invariant's point - and the refusal is
        # logged without touching the balance.
        if cents <= 0:
            raise ValueError("a withdrawal must be more than 0.00")
        if cents > self.balance:
            self.log + [[self.month, "refused", cents, self.balance]] => self.log
            raise ValueError("only " + money(self.balance) + " is in the account")
        self.balance - cents => self.balance
        self.log + [[self.month, "withdraw", cents, self.balance]] => self.log

    def next_month(self):
        # Interest for the month that ends, on the balance at its end.
        month_interest(self.balance, self.rate) => earned
        self.balance + earned => self.balance
        self.log + [[self.month, "interest", earned, self.balance]] => self.log
        self.month + 1 => self.month
        return earned

def money(cents):
    # 123456 -> "1,234.56"; negative with a minus in front.
    "" => sign
    cents => c
    if c < 0:
        "-" => sign
        0 - c => c
    str(c % 100) => part
    if len(part) == 1:
        "0" + part => part
    str(quotient(c, 100)) => whole
    "" => grouped
    while len(whole) > 3:
        "," + whole[len(whole) - 3:len(whole)] + grouped => grouped
        whole[0:len(whole) - 3] => whole
    return sign + whole + grouped + "." + part

def books(log):
    # The checks of the corpus case bank-account-invariant, worked out from
    # the log alone, not from the account: [deposits, withdrawals, interest,
    # the balance they add up to, every running balance >= 0, every entry's
    # balance equal to the one before plus its own change].
    0 => dep
    0 => wd
    0 => intr
    0 => running
    True => never_negative
    True => chain
    for e in log:
        if e[1] == "deposit":
            dep + e[2] => dep
            running + e[2] => running
        elif e[1] == "withdraw":
            wd + e[2] => wd
            running - e[2] => running
        elif e[1] == "interest":
            intr + e[2] => intr
            running + e[2] => running
        if running < 0:
            False => never_negative
        if e[3] != running:
            False => chain
    return [dep, wd, intr, running, never_negative, chain]

def months_to_goal(balance, rate, monthly, goal):
    # Months until the balance reaches goal, putting in `monthly` at the
    # start of each month and earning that month's interest at its end; -1
    # when it would take more than 100 years. Returns [months, balance then,
    # interest earned on the way].
    balance => b
    0 => earned
    0 => m
    while b < goal:
        if m == 1200:
            return [-1, b, earned]
        b + monthly => b
        month_interest(b, rate) => i
        b + i => b
        earned + i => earned
        m + 1 => m
    return [m, b, earned]
```

Python projection of ledger.eml:

```python
def quotient(a, b):
    return int(a / b)

def month_interest(balance, rate):
    return quotient(2 * balance * rate + 120000, 240000)

class Account:
    def __init__(self, rate):
        self.balance = 0
        self.rate = rate
        self.month = 0
        self.log = []
    def deposit(self, cents):
        if cents <= 0:
            raise ValueError("a deposit must be more than 0.00")
        self.balance = self.balance + cents
        self.log = self.log + [[self.month, "deposit", cents, self.balance]]
    def withdraw(self, cents):
        if cents <= 0:
            raise ValueError("a withdrawal must be more than 0.00")
        if cents > self.balance:
            self.log = self.log + [[self.month, "refused", cents, self.balance]]
            raise ValueError("only " + money(self.balance) + " is in the account")
        self.balance = self.balance - cents
        self.log = self.log + [[self.month, "withdraw", cents, self.balance]]
    def next_month(self):
        earned = month_interest(self.balance, self.rate)
        self.balance = self.balance + earned
        self.log = self.log + [[self.month, "interest", earned, self.balance]]
        self.month = self.month + 1
        return earned

def money(cents):
    sign = ""
    c = cents
    if c < 0:
        sign = "-"
        c = 0 - c
    part = str(c % 100)
    if len(part) == 1:
        part = "0" + part
    whole = str(quotient(c, 100))
    grouped = ""
    while len(whole) > 3:
        grouped = "," + whole[len(whole) - 3:len(whole)] + grouped
        whole = whole[0:len(whole) - 3]
    return sign + whole + grouped + "." + part

def books(log):
    dep = 0
    wd = 0
    intr = 0
    running = 0
    never_negative = True
    chain = True
    for e in log:
        if e[1] == "deposit":
            dep = dep + e[2]
            running = running + e[2]
        elif e[1] == "withdraw":
            wd = wd + e[2]
            running = running - e[2]
        elif e[1] == "interest":
            intr = intr + e[2]
            running = running + e[2]
        if running < 0:
            never_negative = False
        if e[3] != running:
            chain = False
    return [dep, wd, intr, running, never_negative, chain]

def months_to_goal(balance, rate, monthly, goal):
    b = balance
    earned = 0
    m = 0
    while b < goal:
        if m == 1200:
            return [-1, b, earned]
        b = b + monthly
        i = month_interest(b, rate)
        b = b + i
        earned = earned + i
        m = m + 1
    return [m, b, earned]
```

## README

# P064 - Savings ledger

A savings account kept in whole cents. You can deposit, and withdraw - a
withdrawal that would take the balance below zero is refused. Each month
ends with compound interest, rounded to the cent. The statement shows
every entry with its running balance. A check of the books works the
balance out again from the log alone, and a goal says how many months a
savings target would take.

- `main.eml` - the menu, reading amounts and the rate, and the statement,
  books and goal messages
- `ledger.eml` - the account, interest, money formatting, the books check
  and the goal

How each part works:

- Money is whole cents, and the yearly rate is in hundredths of a percent:
  2.50% is 250. A month's interest is balance x rate / 10000 / 12, rounded
  to the nearest cent, halves up, and is added to the balance, so the next
  month earns interest on it too.
- The account is a class. A deposit or withdrawal that breaks the rules
  raises an error instead of returning a status that could be ignored, as
  in the corpus cases `bank-account-simulator` and
  `bank-account-invariant`. The menu catches it and says why. A refused
  withdrawal is written in the log but changes nothing.
- Checking the books follows the second corpus case. It adds the deposits,
  withdrawals and interest from the log, without asking the account, and
  checks three things: the total equals the balance, every running balance
  on the way was at least 0.00, and every entry's balance equals the one
  before it plus its own change.
- A goal puts the monthly amount in at the start of each month and earns
  that month's interest at its end, until the target is reached. It gives
  up after 100 years.

What is checked: menu choices 1 to 8; amounts as whole numbers or with up
to two decimals, commas between thousands allowed, up to 1,000,000.00;
deposits and withdrawals above 0.00; rates from 0 to 20%. An empty answer
cancels.

Sessions: `sessions/basic.in` gives:
- deposits of 1,000 and 250.50;
- a month at 2.50%, which earns 2.61 (1,250.50 x 2.5% / 12 = 2.6052);
- a withdrawal of 300, then two more months;
- the statement, and the books agreeing at 957.09;
- the months to 10,000 at 500 a month: 18 at 2.50%, and again 18 at
  4.00%, with 349.56 of interest instead of 216.79.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- a withdrawal from the empty account;
- amounts abc, -5, 12.345 and 1000000.01, and a deposit of 0;
- 1,500 with a comma, then a withdrawal of 2,000, which is refused;
- rates 25 and x;
- a goal below the balance;
- a cancelled goal;
- at 0%, 1,000,000 at 1.00 a month, which is more than 100 years;
- the books with two refused withdrawals, and the statement.

Built on the verified corpus cases `bank-account-simulator` (an account
class that raises on a bad withdrawal) and `bank-account-invariant` (the
balance never negative, checked again from the log of allowed
operations).
