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
Key flow: settling up
Code skeleton
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.33Run 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.