Splitwise (expense sharing)
Problem statement
Design an expense-sharing app: users create groups, add expenses paid by one or more people and split equally / by exact amounts / by percentages, view who owes whom, settle up, and optionally simplify debts to the minimum number of transactions.
Requirements
Functional
Users, groups; add expense with payer(s), participants, split type.
Balances per user pair and per group; activity history.
Settle up (partial or full).
Simplify debts within a group.
Non-functional
Amounts exact to the paisa/cent (no floating-point drift).
Split validation (exact amounts sum to total; percentages sum to 100).
Extensible split types.
API
Expense addExpense(groupId, paidBy: Map<userId, amount>, participants, splitType, splitData)
Map<User, Money> getBalances(userId) // who I owe / who owes me
List<Transaction> simplifyDebts(groupId)
void settle(fromUserId, toUserId, amount)
List<Expense> history(groupId | userId)
Data model
User(id, name, email);Group(id, name, members).Expense(id, groupId?, amount:Money, paidBy: Map<User, Money>, splits: List<Split>, splitType, createdBy, ts).Split(user, amount)computed by aSplitStrategy:EqualSplit,ExactSplit,PercentSplit,ShareSplit.BalanceSheet:Map<User, Map<User, Money>>(net owed, one direction per pair) — maintained per group and globally.Transaction(from, to, amount)for settlements.Moneyvalue object (long minor units + currency).
Design
- Strategy for splitting: each
SplitStrategy.split(total, participants, data) → List<Split>andvalidate(...). Rounding: distribute remainder paise to the first k participants so the sum is exact. - Balance update on add expense: for each payer p and each split s,
balance[s.user][p] += s.amount × (p.paid / total)(proportional when multiple payers). Store net per pair: if A owes B 300 and B owes A 100, store A→B 200. - Debt simplification (greedy): compute each member's net (paid − owed). Put creditors (net > 0) and debtors (net < 0) into two max-heaps; repeatedly match the largest debtor with the largest creditor, transfer
min(|d|, c), push back remainders. Produces ≤ n−1 transactions. (Minimising the count exactly is NP-hard; greedy is standard.) - Settle: create a
Transactionand update balances like an expense paid by the debtor for the creditor. - Observer/Notifier: members notified on new expenses.
Class diagram
Deep dives
Money as integers: 100.00 split 3 ways = 33.33 + 33.33 + 33.34; the strategy must assign the extra paisa deterministically.
Pairwise vs net balances: Splitwise shows pairwise by default (who exactly you owe); simplification converts to nets and re-derives minimal transfers — offer it as a group setting because some users prefer real pairwise debts.
Multiple payers: attribute each split proportionally to what each payer contributed.
Immutability: expenses are events; edits create a reversal + new expense so history stays auditable.
Bottlenecks & mitigations
Balance map growing O(users²) → store only non-zero pairs; per-group sheets.
Recomputing nets from all expenses → maintain running nets per group, recompute only on edits.
Concurrent expense adds in one group → serialise per group (lock or single writer).
Failure modes
- Split doesn't sum to total → validation error before any balance change.
- Expense edit/delete → apply reversal, then re-add; never mutate balances directly.
- Rounding drift → integer money + deterministic remainder distribution; nightly check that group nets sum to zero.
- Settlement over-pay → cap at outstanding balance or record as reverse debt, per product decision.
Code skeleton
from collections import defaultdict
import heapq
class EqualSplit:
def split(self, total, users, data=None):
n = len(users); base, rem = divmod(total, n)
return [(u, base + (1 if i < rem else 0)) for i, u in enumerate(users)]
class ExactSplit:
def split(self, total, users, amounts):
assert sum(amounts) == total, "amounts must sum to total"
return list(zip(users, amounts))
class PercentSplit:
def split(self, total, users, pcts):
assert abs(sum(pcts) - 100) < 1e-9
amts = [total * p // 100 for p in pcts]
for i in range(total - sum(amts)): amts[i] += 1
return list(zip(users, amts))
class BalanceSheet:
def __init__(self): self.owes = defaultdict(lambda: defaultdict(int)) # owes[a][b] = a owes b
def add(self, debtor, creditor, amt):
if debtor == creditor or amt == 0: return
back = self.owes[creditor][debtor]
if back >= amt: self.owes[creditor][debtor] -= amt
else: self.owes[creditor][debtor] = 0; self.owes[debtor][creditor] += amt - back
def net(self, users):
n = {u: 0 for u in users}
for a in users:
for b, amt in self.owes[a].items(): n[a] -= amt; n[b] += amt
return n
class ExpenseManager:
def __init__(self): self.sheet = BalanceSheet(); self.strategies = {"EQUAL": EqualSplit(), "EXACT": ExactSplit(), "PERCENT": PercentSplit()}
def add_expense(self, paid_by, participants, split_type, data=None): # paid_by: {user: amount} in minor units
total = sum(paid_by.values())
for user, share in self.strategies[split_type].split(total, participants, data):
for payer, paid in paid_by.items():
self.sheet.add(user, payer, share * paid // total)
def simplify(self, users):
net = self.sheet.net(users)
cred = [(-v, u) for u, v in net.items() if v > 0]; debt = [(v, u) for u, v in net.items() if v < 0]
heapq.heapify(cred); heapq.heapify(debt); out = []
while cred and debt:
c, cu = heapq.heappop(cred); d, du = heapq.heappop(debt)
x = min(-c, -d); out.append((du, cu, x))
if -c - x: heapq.heappush(cred, (c + x, cu))
if -d - x: heapq.heappush(debt, (d + x, du))
return out
Follow-up questions interviewers ask
Support multi-currency expenses with conversion at expense time.
Recurring expenses (rent).
How would you persist this and handle concurrent edits across devices?