Skip to content
FollowtheDamnCode

LLD: Expense Splitter

Groups, shared expenses, equal/exact/percentage splits, net balances, and the fewest payments to settle up — split rules as strategies, money as integer cents.

Big idea

An expense splitter records who paid and who owes what share, keeps a running net balance per person, and suggests as few payments as possible to settle up. Two decisions make or break the design: split rules as interchangeable strategies (equal, exact, percentage, shares) and money as integer cents so rounding never loses or invents a penny.

Requirements

Functional

  • Users form groups; anyone adds an expense: amount, payer, participants, split type.
  • Split types: equal, exact amounts, percentages, shares (2:1:1).
  • Show each person's net balance in the group, and who-owes-whom.
  • Settle-up suggestions with a small number of payments; record a payment.

Rules: shares must add up to the expense exactly; percentages must total 100; leftover cents from uneven division are assigned deterministically.

Out of scope: currencies, receipts, recurring expenses (extensions).

Entities

Group, User, Expense (payer, amount, splits), Split (user, amount), SplitStrategy with EqualSplit, ExactSplit, PercentSplit, ShareSplit, Ledger (net balances), Settler (computes payments).

Think of it like

A shared kitty on a camping trip, kept in a notebook. Each time someone pays for something, you note who paid and how it should be divided. At the end you don't replay every receipt — you work out each person's bottom line (up or down) and then pair the people who are down with the people who are up.

Class diagram

Expenses split by a strategy, rolled into balances

Key flow: settling up

From balances to a short list of payments

Code skeleton

Four split rules, exact cents, and greedy settlement
class EqualSplit:
    def split(self, cents, people):
        base, extra = divmod(cents, len(people))
        return {p: base + (1 if i < extra else 0) for i, p in enumerate(people)}   # leftover cents, deterministic

class ExactSplit:
    def __init__(self, amounts): self.amounts = amounts
    def split(self, cents, people):
        if sum(self.amounts.values()) != cents:
            raise ValueError("exact amounts don't add up")
        return dict(self.amounts)

class PercentSplit:
    def __init__(self, percents): self.percents = percents
    def split(self, cents, people):
        if sum(self.percents.values()) != 100:
            raise ValueError("percentages must total 100")
        return ShareSplit(self.percents).split(cents, people)

class ShareSplit:
    def __init__(self, shares): self.shares = shares
    def split(self, cents, people):
        total = sum(self.shares.values())
        out = {p: cents * s // total for p, s in self.shares.items()}
        for p in list(self.shares)[: cents - sum(out.values())]:              # hand out remainders
            out[p] += 1
        return out


class Group:
    def __init__(self, members):
        self.balance = {m: 0 for m in members}     # positive: is owed money

    def add_expense(self, payer, cents, people, strategy):
        shares = strategy.split(cents, people)
        assert sum(shares.values()) == cents       # the invariant: no cent lost or invented
        self.balance[payer] += cents
        for person, owed in shares.items():
            self.balance[person] -= owed

    def settle_up(self):                           # greedy: biggest debtor pays biggest creditor
        bal = {p: b for p, b in self.balance.items() if b}
        payments = []
        while bal:
            debtor = min(bal, key=lambda p: (bal[p], p))
            creditor = max(bal, key=lambda p: (bal[p], p))
            amount = min(-bal[debtor], bal[creditor])
            payments.append(f"{debtor} pays {creditor} {amount / 100:.2f}")
            for p, delta in ((debtor, amount), (creditor, -amount)):
                bal[p] += delta
                if bal[p] == 0: del bal[p]
        return payments


trip = Group(["asha", "ben", "chen", "dev"])
trip.add_expense("asha", 10000, ["asha", "ben", "chen"], EqualSplit())               # 100.00 three ways
trip.add_expense("ben", 6000, ["ben", "dev"], ExactSplit({"ben": 1500, "dev": 4500}))
trip.add_expense("chen", 8000, ["asha", "chen", "dev"], PercentSplit({"asha": 50, "chen": 25, "dev": 25}))
trip.add_expense("dev", 3000, ["ben", "dev"], ShareSplit({"ben": 2, "dev": 1}))
print({p: f"{b / 100:+.2f}" for p, b in trip.balance.items()})
print("sum of balances:", sum(trip.balance.values()))
for line in trip.settle_up():
    print(line)
output
{'asha': '+26.66', 'ben': '-8.33', 'chen': '+26.67', 'dev': '-45.00'}
sum of balances: 0
dev pays chen 26.67
dev pays asha 18.33
ben pays asha 8.33

Run and edit it in the app.

The balances always sum to zero — the check that nothing was lost to rounding. Settlement needs at most people − 1 payments with this greedy pairing.

Watch it

Why store money as integer cents instead of floats?

Product adds an "adjustment" split (equal, but one person pays 5 more). What changes?

Extensions and trade-offs

  • Optimal settlement — minimising the number of payments exactly is NP-hard in general; greedy is good and explainable. Say so.
  • Currencies — store the currency per expense and convert at settle time with a recorded rate.
  • Audit trail — expenses are immutable; edits are new "correction" entries (Command-style log).
  • Simplify across groups — net balances per pair of users across all groups.
  • Notifications — Observer on "expense added" and "payment recorded".

Key terms

  • Net balance — What someone has paid minus what they owe; positive means they're owed money.
  • Split strategy — The rule that divides an expense among participants.
  • Settlement — The set of payments that brings every balance to zero.
  • Remainder allocation — A fixed rule for assigning leftover cents after uneven division.