Skip to content

Session 18

Console expense splitter built from the Session 17 study, with sample run and comparison against the original

Updated View as Markdown

This session turns the Session 17 study into a working program. The deliverable is expense_splitter.py, a console program that adds members, records expenses with equal or custom shares, shows balances and prints a minimal settlement plan, followed by a real sample run, a comparison of the original app’s behaviour with this program, and notes on what the exercise taught about reverse engineering. Everything the program does traces back to a rule or requirement written in Session 17; nothing was added that the original did not show.

Objectives

Do not copy. Read for understanding and the viva
  • Implement requirements ES-FR-1 to ES-FR-7 from Session 17 in one readable Python file.
  • Reproduce the observed rounding and settlement behaviour exactly.
  • Compare the rebuilt portion with the original feature by feature and record the differences.
  • Reflect on which inferences from Session 17 held and which had to be revised.

Problem Statement

Write in lab record

Sessions 17 and 18: Have a look at the output of any program that was not written by you. Preferably, look at an application that is not developed by you and write the program for the development of that application or a portion of that application.

Concept

Do not copy. Read for understanding and the viva

From inferred rules to code

Each rule in Session 17 becomes one small function. R2 (equal split with leftover paise to the first members) is equal_split. R4 (net balance = paid minus owed) is balances. R6 (greedy settlement) is settle. When a rule maps to one function, the comparison table at the end can point at the exact place where behaviour matched or differed.

Money as integers

Rupees with two decimals are stored as an integer number of paise. to_paise("33.33") gives 3333 and fmt(3333) gives back “33.33”. Division then uses divmod, which returns the equal share and the leftover paise in one step, and the leftover is handed out one paisa each to the first members. This reproduces the observed 33.34, 33.33, 33.33 with no floating point at all.

Greedy settlement

Sort debtors by how much they owe (largest first) and creditors by how much they are owed (largest first). Pay the largest creditor from the largest debtor with the smaller of the two amounts; one of them reaches zero and drops out; repeat. Every payment retires at least one member, so a group of n members with non-zero balances needs at most n minus 1 payments. This matches what the original app showed for the test group, although the original may pair members in a different order.

What is left out on purpose

The original stores data on a server and syncs between phones; this program keeps everything in memory for one run. The original supports percentages and share weights; this program supports equal and exact amounts only, which is what Session 17 recorded as the portion. Leaving these out is a decision, and the comparison table records it.

Program

Write in lab record
expense_splitter.pypython
"""Expense Splitter: a console re-implementation of the group expense
portion of a bill-splitting app (Splitwise style), written from observed
behaviour only. Python 3, standard library, no persistence.

All money is kept in paise (integers) so equal splits round exactly the
way the original app does: the leftover paise go to the first members.
"""


def to_paise(text):
    """Convert a rupee string such as '1200.50' to an integer of paise."""
    rupees, _, paise = text.strip().partition(".")
    paise = (paise + "00")[:2]
    return int(rupees or 0) * 100 + int(paise)


def fmt(paise):
    """Format paise as a rupee string, e.g. 120050 -> '1200.50'."""
    sign = "-" if paise < 0 else ""
    paise = abs(paise)
    return f"{sign}{paise // 100}.{paise % 100:02d}"


def equal_split(total, names):
    """Split total paise equally; remainder paise go to the first names."""
    base, extra = divmod(total, len(names))
    return {n: base + (1 if i < extra else 0) for i, n in enumerate(names)}


def balances(members, expenses):
    """Net balance per member: positive = is owed, negative = owes."""
    net = {m: 0 for m in members}
    for e in expenses:
        net[e["paid_by"]] += e["amount"]
        for name, share in e["shares"].items():
            net[name] -= share
    return net


def settle(net):
    """Greedy settlement: repeatedly pay the largest creditor from the
    largest debtor. Returns a list of (from, to, paise) transactions."""
    debtors = sorted(((v, k) for k, v in net.items() if v < 0))
    creditors = sorted(((v, k) for k, v in net.items() if v > 0), reverse=True)
    result = []
    i = j = 0
    while i < len(debtors) and j < len(creditors):
        owed, debtor = debtors[i]
        due, creditor = creditors[j]
        pay = min(-owed, due)
        result.append((debtor, creditor, pay))
        debtors[i] = (owed + pay, debtor)
        creditors[j] = (due - pay, creditor)
        if debtors[i][0] == 0:
            i += 1
        if creditors[j][0] == 0:
            j += 1
    return result


def pick_member(members, prompt):
    """Ask for a member name until it matches one in the group."""
    while True:
        name = input(prompt).strip()
        if name in members:
            return name
        print(f"  no such member: {name}. Members: {', '.join(members)}")


def add_member(members):
    name = input("Member name: ").strip()
    if not name or name in members:
        print("  name is empty or already in the group")
        return
    members.append(name)
    print(f"  added {name}")


def add_expense(members, expenses):
    if len(members) < 2:
        print("  add at least two members first")
        return
    desc = input("Description: ").strip() or "expense"
    amount = to_paise(input("Amount (rupees): "))
    if amount <= 0:
        print("  amount must be positive")
        return
    paid_by = pick_member(members, "Paid by: ")
    mode = input("Split equally (e) or custom shares (c)? ").strip().lower()
    if mode == "c":
        shares = {}
        for m in members:
            shares[m] = to_paise(input(f"  share of {m}: ") or "0")
        if sum(shares.values()) != amount:
            print(f"  shares total {fmt(sum(shares.values()))}, "
                  f"expense is {fmt(amount)}. Expense not added.")
            return
        shares = {k: v for k, v in shares.items() if v}
    else:
        shares = equal_split(amount, members)
    expenses.append({"desc": desc, "amount": amount,
                     "paid_by": paid_by, "shares": shares})
    print(f"  added '{desc}' {fmt(amount)} paid by {paid_by}")


def show_expenses(expenses):
    if not expenses:
        print("  no expenses yet")
    for n, e in enumerate(expenses, 1):
        parts = ", ".join(f"{k} {fmt(v)}" for k, v in e["shares"].items())
        print(f"  {n}. {e['desc']:<14} {fmt(e['amount']):>9}  "
              f"paid by {e['paid_by']:<8} shares: {parts}")


def show_balances(members, expenses):
    for name, net in balances(members, expenses).items():
        if net > 0:
            print(f"  {name:<8} gets back {fmt(net)}")
        elif net < 0:
            print(f"  {name:<8} owes      {fmt(-net)}")
        else:
            print(f"  {name:<8} settled up")


def show_settlement(members, expenses):
    txns = settle(balances(members, expenses))
    if not txns:
        print("  everyone is settled up")
    for frm, to, paise in txns:
        print(f"  {frm} pays {to} {fmt(paise)}")
    print(f"  ({len(txns)} transaction(s))")


MENU = """
1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit
"""


def main():
    members, expenses = [], []
    actions = {"1": lambda: add_member(members),
               "2": lambda: add_expense(members, expenses),
               "3": lambda: show_expenses(expenses),
               "4": lambda: show_balances(members, expenses),
               "5": lambda: show_settlement(members, expenses)}
    while True:
        print(MENU)
        try:
            choice = input("Choice: ").strip()
        except EOFError:
            break
        if choice == "0":
            break
        actions.get(choice, lambda: print("  unknown choice"))()
    print("Bye")


if __name__ == "__main__":
    main()

Sample Output

The run below was produced by piping the following choices into the program, so each prompt and the typed value appear on one line. Input given, in order: add members Asha, Bimal, Chetan, Divya; expense Dinner 1000 paid by Asha split equally; expense Cab 350 paid by Bimal with custom shares 100, 100, 150, 0; expense Tickets 800 paid by Chetan split equally; then list expenses, balances, settle up, exit.

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Member name:   added Asha

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Member name:   added Bimal

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Member name:   added Chetan

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Member name:   added Divya

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Description: Amount (rupees): Paid by: Split equally (e) or custom shares (c)?   added 'Dinner' 1000.00 paid by Asha

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Description: Amount (rupees): Paid by: Split equally (e) or custom shares (c)?   share of Asha:   share of Bimal:   share of Chetan:   share of Divya:   added 'Cab' 350.00 paid by Bimal

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Description: Amount (rupees): Paid by: Split equally (e) or custom shares (c)?   added 'Tickets' 800.00 paid by Chetan

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice:   1. Dinner           1000.00  paid by Asha     shares: Asha 250.00, Bimal 250.00, Chetan 250.00, Divya 250.00
  2. Cab               350.00  paid by Bimal    shares: Asha 100.00, Bimal 100.00, Chetan 150.00
  3. Tickets           800.00  paid by Chetan   shares: Asha 200.00, Bimal 200.00, Chetan 200.00, Divya 200.00

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice:   Asha     gets back 450.00
  Bimal    owes      200.00
  Chetan   gets back 200.00
  Divya    owes      450.00

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice:   Divya pays Asha 450.00
  Bimal pays Chetan 200.00
  (2 transaction(s))

1 Add member   2 Add expense   3 List expenses
4 Balances     5 Settle up     0 Exit

Choice: Bye

Hand check of the balances: Asha paid 1000 and owes 250 + 100 + 200 = 550, net +450. Bimal paid 350 and owes 550, net -200. Chetan paid 800 and owes 250 + 150 + 200 = 600, net +200. Divya paid nothing and owes 450. The four nets sum to zero (rule R5). Without simplification the group has four pairwise debts (Divya to Asha, Divya to Chetan, Bimal to Asha, Bimal to Chetan); the plan settles it in two.

The rounding case from Session 17, three members and an expense of 100 split equally, produced this line in a second run:

  1. Tea               100.00  paid by A        shares: A 33.34, B 33.33, C 33.33

Comparison with the Original

Write in lab record
FeatureOriginal application (observed)This programMatch
Add memberBy name or email; duplicates refusedBy name; blank and duplicate refusedPartial: no email
Add expenseDescription, amount, payer, split; date automaticSame fields; no datePartial: no date
Equal split1000 among 4 gives 250.00 each250.00 eachYes
Rounding100 among 3 gives 33.34, 33.33, 33.3333.34, 33.33, 33.33Yes
Custom splitExact amounts, percentages, shares; must total the amountExact amounts only; refused if total differsPartial: one mode
Exclude a memberUntick the memberGive the member a share of 0; share not listedYes, different input
Balances“owes”, “gets back”, “settled up” with coloursSame three words, no coloursYes
Settle upTwo payments for the test group with simplify debts onTwo payments, same pairsYes
Expense listDate order, shows viewer’s shareEntry order, shows all sharesPartial
Edit or delete expenseSupportedNot supportedNo
PersistenceServer, multi-deviceNone; in memory for one runNo, out of scope
InterfaceTouch screen formsNumbered console menuNo, by design

Rules R1 to R5 and R7 were reproduced exactly. R6 produced the same number of payments and the same pairs for the test group, so the inference stands for this case; it has not been shown to hold for every case.

What Reverse Engineering Taught

Write in lab record
  • The observable rounding rule was enough to fix the internal representation: integer paise. One observation settled a design decision.
  • The behaviour of the settlement screen could be matched without knowing the original algorithm. Two different algorithms can be indistinguishable from outside; a black-box study only proves behaviour on the cases tried.
  • The custom-share check (shares must total the amount) was found only because the original’s save button refused to enable. Error behaviour is as much a requirement as normal behaviour, and it is easy to miss when only success paths are tried.
  • Writing the requirements as ES-FR ids before coding made the comparison table mechanical: one row per requirement, match or not.
  • Deciding the portion up front kept the rebuild to 160 lines. Every “No” in the comparison table is a scope decision made in Session 17, not a failure discovered in Session 18.

Viva Questions

Do not copy. Read for understanding and the viva
  • Q: How do you know your program matches the original? A: By the comparison table: the same inputs were given to both and the outputs are listed side by side.
  • Q: Why does equal_split use divmod? A: It returns the base share and the leftover paise together; the leftover is distributed one paisa each to the first members.
  • Q: What happens if custom shares do not total the amount? A: The expense is rejected with a message showing both totals, as the original refuses to save.
  • Q: Why sort debtors and creditors before settling? A: So that the largest debt is retired against the largest credit first, which keeps the payment count small.
  • Q: What is the maximum number of payments the greedy method produces? A: One fewer than the number of members with a non-zero balance.
  • Q: Which inferred rule is least certain and why? A: R6, because different pairing orders give the same payment count and cannot be told apart from the screen.
  • Q: Why is data not saved to a file? A: Persistence was outside the portion chosen in Session 17; adding it would not change the rules being studied.
  • Q: What would you test next to strengthen the R6 inference? A: Groups where the greedy method is known not to be minimal, and check whether the original does better.

Common Mistakes

Do not copy. Read for understanding and the viva
  • Using floating point for money and getting shares that total 99.99 or 100.01.
  • Building features the original has but Session 17 never recorded, so there is nothing to compare them against.
  • Pasting output typed by hand instead of captured from a real run; examiners try the program.
  • A comparison table with only “Yes” rows; honest “Partial” and “No” rows show the scope was understood.
  • Letting a member both pay and receive in the settlement plan, which means balances were not netted first.

Session Summary

Write in lab record
  • Source listing of expense_splitter.py with the function-to-rule mapping noted in the margin.
  • Sample run with the same numbers used on the original application, plus the rounding case.
  • Hand check that balances sum to zero and the settlement plan clears every balance.
  • Comparison table, one row per observed feature.
  • Notes on what reverse engineering taught, including which inference is still uncertain.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close