Four friends, five expenses, three ways to split them, and one question at the end of the trip: who pays whom, in as few UPI payments as possible?
"Design Splitwise" is one of the most common machine-coding problems in Indian interviews, and it's a good one: the classes are obvious, but the details aren't. Splitting ₹100 three ways, percentages that come to 99.99%, deleting an expense after people have settled, and turning twelve small debts into three payments are where candidates lose marks.
There is a real app to anchor this. Chukta is the course author's own bill-splitting app: free, ad-free bill splitting for friends in India, settled over UPI in one tap. Publicly, it is built on Supabase Postgres with row-level security, balance views and expense RPCs. That is all we'll say about Chukta itself. The design below is this lesson's own, framed as how an app like Chukta can model the problem, and you'll see where those three public building blocks fit.
Expenses split equally, exactly or by percentage in integer paise, so every split adds up and balances sum to zero. On a four-friend Goa trip the ledger shows Aarav owed ₹6,516.66 and Meera owing ₹5,233.33; greedy min cash flow turns 6 pairwise debts into 3 payments, and a UPI deep link settles the biggest in one tap. Balance views, expense RPCs and row-level security, the public building blocks of the author's app Chukta, map onto derived balances, atomic writes and members-only access.
How the round runs#
"Build the core of a Splitwise-like app. Users, groups, expenses. An expense can be split equally, by exact amounts, or by percentage. Show each person's balance, and suggest the fewest payments to settle the group. Let users record a settlement. In India, people settle over UPI, so give me a payment link. Working code, 90 minutes."
- 0–10 min — clarify: currency and rounding, who can be in a split, what "settle" means, simplification scope
- 10–25 min — classes: user, group, expense, split, the strategy for splitting, the ledger
- 25–45 min — the three split strategies, each guaranteeing its parts add up exactly
- 45–65 min — the ledger (net balances), the simplifier (min cash flow), the service
- 65–80 min — a trip demo: five expenses, balances, the plan, a UPI link, a settle-up
- 80–90 min — follow-ups: edits, multi-currency, a non-group expense, reminders
Requirements and clarifying questions#
| You ask | They answer | What it changes |
|---|---|---|
| One currency? | INR only. | Store integer paise; never floats |
| ₹100 split three ways? | Someone pays the extra paisa; it must add up. | Each strategy distributes the remainder |
| Can the payer be outside the split? | Yes: I paid for your dinner and ate nothing. | The payer and the participants are separate |
| Must participants be group members? | Yes. | Validate before writing |
| What does "settle up" do? | Records a payment between two members. | A transfer that moves both balances |
| Simplify across the whole group? | Yes, minimise the number of payments. | Net balances + a greedy simplifier |
| Edit or delete an expense? | Delete, yes. | Every write must be exactly reversible |
Why net balances, not a list of who owes whom?
After five expenses, there are six pairwise debts between four friends. What single number per person do you need to work out a settlement plan, and why does it always sum to zero?
One strong answer
Each person's net balance: what they paid for others minus what others paid for them. Positive means the group owes you; negative means you owe the group. Every rupee one person is owed is a rupee someone else owes, so the balances sum to exactly zero (in integer paise, exactly, not approximately). Who owes whom is then a choice: any set of payments that brings every balance to zero settles the group, and the simplifier picks one with few payments.
The classes#
%% title: Bill splitting: the classes
classDiagram
class User {
+user_id str
+name str
+upi_id str
}
class Group {
+group_id str
+name str
+members dict
}
class Expense {
+expense_id str
+paid_by str
+amount int
+splits tuple
+note str
}
class Split {
+user_id str
+amount int
}
class SplitStrategy {
<<interface>>
+split(amount) list
}
class Ledger {
+net dict
+apply(payer, splits, sign)
+pairwise() list
}
class DebtSimplifier {
+simplify(net) list
}
class Transfer {
+from_user str
+to_user str
+amount int
}
class SplitService {
+add_expense(group, paid_by, amount, how, note) Expense
+delete_expense(expense_id)
+settle_up(group, transfer)
+suggested(group) list
+upi_link(group, transfer) str
}
Group "1" o-- "many" User
Expense "1" *-- "many" Split
SplitStrategy <|.. EqualSplit
SplitStrategy <|.. ExactSplit
SplitStrategy <|.. PercentSplit
SplitStrategy ..> Split
SplitService --> Ledger
SplitService --> DebtSimplifier
SplitService "1" o-- "many" Expense
DebtSimplifier ..> TransferAn expense is a fact; a balance is a consequence. The expense list is the record (append, and delete as an exact reversal); balances are derived from it. You could recompute them from the expense list at any time, and that is precisely what a database view does. This is where the first of Chukta's public building blocks fits: a balance view over the expenses table means a balance is never stored and so never drifts. In our in-memory code, Ledger keeps the same numbers incrementally for speed, but it is only ever changed by applying an expense, a reversal or a settlement.
The interfaces#
Plain data first. Money is int paise throughout:
# splitwise.py, part 1: people, groups and money (always integer paise, never floats)
from __future__ import annotations
import heapq
import itertools
import threading
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
from decimal import ROUND_FLOOR, Decimal
from urllib.parse import quote
def rupees(paise: int) -> str:
return f"₹{paise / 100:,.2f}"
@dataclass(frozen=True)
class User:
user_id: str
name: str
upi_id: str # the VPA a friend pays into, e.g. name@bank
@dataclass
class Group:
group_id: str
name: str
members: dict[str, User] = field(default_factory=dict)
@dataclass(frozen=True)
class Split:
user_id: str
amount: int # paise this person consumed
@dataclass(frozen=True)
class Expense:
expense_id: str
group_id: str
paid_by: str
amount: int
splits: tuple[Split, ...]
note: str
@dataclass(frozen=True)
class Transfer: # "from_user pays to_user": a suggested or recorded settlement
from_user: str
to_user: str
amount: intThe three splits. Each strategy owns one rule and one guarantee: the parts always add up to the amount, to the paisa. Equal splitting hands the leftover paise to the first few people (sorted, so the result is deterministic); exact splitting refuses shares that don't add up; percentage splitting uses Decimal, floors every share, and gives the leftover paise to the largest remainders.
# part 2: one interface, three ways to split; each one guarantees the parts add up exactly
class SplitStrategy(ABC):
@abstractmethod
def split(self, amount: int) -> list[Split]: ...
class EqualSplit(SplitStrategy):
def __init__(self, user_ids: list[str]) -> None:
self.user_ids = sorted(user_ids)
def split(self, amount: int) -> list[Split]:
base, extra = divmod(amount, len(self.user_ids)) # ₹100 / 3 = 33.34 + 33.33 + 33.33
return [Split(u, base + (1 if i < extra else 0)) for i, u in enumerate(self.user_ids)]
class ExactSplit(SplitStrategy):
def __init__(self, shares: dict[str, int]) -> None:
self.shares = shares
def split(self, amount: int) -> list[Split]:
if sum(self.shares.values()) != amount:
raise ValueError(f"exact shares add up to {rupees(sum(self.shares.values()))}, not {rupees(amount)}")
return [Split(u, a) for u, a in sorted(self.shares.items())]
class PercentSplit(SplitStrategy):
def __init__(self, percents: dict[str, str]) -> None:
self.percents = {u: Decimal(p) for u, p in percents.items()}
def split(self, amount: int) -> list[Split]:
if sum(self.percents.values()) != 100:
raise ValueError("percentages must add up to 100")
raw = {u: amount * p / 100 for u, p in self.percents.items()}
parts = {u: int(r.to_integral_value(ROUND_FLOOR)) for u, r in raw.items()}
leftover = amount - sum(parts.values()) # hand the leftover paise to the largest remainders
for u in sorted(raw, key=lambda u: (parts[u] - raw[u], u))[:leftover]:
parts[u] += 1
return [Split(u, a) for u, a in sorted(parts.items())]The patterns, and why#
| Pattern | Where | Why it earns its place |
|---|---|---|
| Strategy | SplitStrategy with three implementations |
The classic case: one operation, several rules, chosen per expense. "Split by shares" (2:1:1) is a new class, not a new branch. |
| Derived state | balances come only from applying expenses | No "update balance" method anyone can call with a wrong number. Delete is apply(..., sign=-1): an exact inverse. |
| Facade / transaction script | SplitService.add_expense |
Validate, split, record and apply, as one unit under the group's lock. In Postgres, that unit is one function called over RPC, which is the role an expense RPC plays. |
| Value objects | Split, Transfer, Expense are frozen |
Facts don't change; an edit is a delete plus a new expense. |
Debt simplification: min cash flow#
Pairwise, the demo trip has six debts: everyone owes someone. But only net balances matter. The greedy min cash flow algorithm:
- Split everyone into creditors (net > 0) and debtors (net < 0), in two max-heaps by amount
- Match the extremes: the biggest debtor pays the biggest creditor
min(debt, credit) - Settle one side: at least one of the two reaches zero and leaves the heaps
- Repeat until the heaps are empty; each round removes a person, so there are at most n − 1 payments
Be honest about one thing in the interview: greedy gives at most n − 1 payments, which is usually optimal or close to it, but the true minimum is NP-hard in general (it means finding the most subgroups whose balances sum to zero). For a group of friends, greedy is the right call; say so, and say why.
There's a product question hiding here too: simplification can ask Diya to pay Kabir even though she never owed Kabir directly. Most apps make simplification a group setting for that reason.
Concurrency and edge cases#
Two friends add expenses to the same group at the same moment. If each read the balances, added their numbers and wrote them back, one update would be lost. Here, every write takes the group's lock and only applies a delta, so concurrent expenses serialise per group, while different groups never wait on each other. In a database, the same guarantee comes from doing the expense insert, its split rows and any balance change in one transaction (one RPC call), and from deriving balances in a view so there is no read-modify-write at all.
Who may see and change a group's data is the other half. In memory, add_expense checks that the payer and every participant are members. In Postgres, row-level security enforces the same rule for every query: a policy that only lets a signed-in user read or write rows of groups they belong to. That's the third public building block, and it moves the check from "every API handler remembers to do it" to "the database refuses otherwise".
- ₹100 three ways: 33.34 + 33.33 + 33.33; the total must stay ₹100.00
- Percentages that don't add to 100 (50 + 40): reject; never "normalise" silently
- The payer not in the split: allowed, and their balance rises by the whole amount
- A zero or negative amount, or a participant outside the group: reject before writing
- Deleting an expense after a settle-up: the reversal is exact, so balances simply move; the settlement stays as a real payment
- Leaving a group with a non-zero balance: block it, or require a settle-up first
- UPI amounts go in rupees with two decimals (
am=5233.33), never paise
The code#
The ledger and the simplifier:
# part 3: the ledger: net balances, and the pairwise view the simplifier saves you from
class Ledger:
def __init__(self) -> None:
self.net: dict[str, int] = defaultdict(int) # > 0: the group owes you; < 0: you owe
self._owes: dict[tuple[str, str], int] = defaultdict(int) # (debtor, creditor) -> paise
def apply(self, payer: str, splits: list[Split] | tuple[Split, ...], sign: int = 1) -> None:
for s in splits:
self.net[payer] += sign * s.amount
self.net[s.user_id] -= sign * s.amount
if s.user_id != payer:
self._owes[(s.user_id, payer)] += sign * s.amount
def pairwise(self) -> list[Transfer]:
out = []
for (a, b), amt in self._owes.items():
back = self._owes.get((b, a), 0)
if amt - back > 0:
out.append(Transfer(a, b, amt - back))
return out
class DebtSimplifier:
"""Greedy min cash flow: the biggest debtor pays the biggest creditor, repeat. At most n - 1 transfers."""
def simplify(self, net: dict[str, int]) -> list[Transfer]:
creditors = [(-amt, u) for u, amt in net.items() if amt > 0]
debtors = [(amt, u) for u, amt in net.items() if amt < 0]
heapq.heapify(creditors)
heapq.heapify(debtors)
out = []
while creditors and debtors:
c_amt, c = heapq.heappop(creditors)
d_amt, d = heapq.heappop(debtors)
pay = min(-c_amt, -d_amt)
out.append(Transfer(d, c, pay))
if -c_amt > pay:
heapq.heappush(creditors, (c_amt + pay, c))
if -d_amt > pay:
heapq.heappush(debtors, (d_amt + pay, d))
return outThe service, including the settle-up and the UPI deep link. A settle-up is applied exactly like an expense: the payer "paid" an amount that the receiver "consumed", which moves both balances towards zero. The link follows the standard UPI URL format: pa (payee VPA), pn (payee name), am (amount), cu (currency) and tn (note). Tapping it on a phone opens a UPI app with everything filled in; the user still approves the payment with their PIN, so this design records a settle-up only once the payment is confirmed, never when the link is opened.
# part 4: the service: every write is one atomic step per group (the "expense RPC")
class SplitService:
def __init__(self) -> None:
self.groups: dict[str, Group] = {}
self.ledgers: dict[str, Ledger] = {}
self.expenses: dict[str, Expense] = {}
self._locks: dict[str, threading.Lock] = defaultdict(threading.Lock)
self._ids = itertools.count(1)
def create_group(self, group_id: str, name: str, members: list[User]) -> Group:
g = Group(group_id, name, {m.user_id: m for m in members})
self.groups[group_id], self.ledgers[group_id] = g, Ledger()
return g
def add_expense(self, group_id: str, paid_by: str, amount: int, how: SplitStrategy, note: str) -> Expense:
g = self.groups[group_id]
splits = how.split(amount)
if amount <= 0 or paid_by not in g.members or any(s.user_id not in g.members for s in splits):
raise ValueError("payer and everyone in the split must be in the group, and the amount > 0")
with self._locks[group_id]:
e = Expense(f"E{next(self._ids)}", group_id, paid_by, amount, tuple(splits), note)
self.expenses[e.expense_id] = e
self.ledgers[group_id].apply(paid_by, splits)
return e
def delete_expense(self, expense_id: str) -> None:
e = self.expenses[expense_id]
with self._locks[e.group_id]:
del self.expenses[expense_id]
self.ledgers[e.group_id].apply(e.paid_by, e.splits, sign=-1) # reverse it exactly
def settle_up(self, group_id: str, t: Transfer) -> None:
with self._locks[group_id]: # paying someone back is an expense the receiver "consumed"
self.ledgers[group_id].apply(t.from_user, [Split(t.to_user, t.amount)])
def suggested(self, group_id: str) -> list[Transfer]:
with self._locks[group_id]:
return DebtSimplifier().simplify(dict(self.ledgers[group_id].net))
def upi_link(self, group_id: str, t: Transfer) -> str:
payee = self.groups[group_id].members[t.to_user]
q = {"pa": payee.upi_id, "pn": payee.name, "am": f"{t.amount / 100:.2f}", "cu": "INR", "tn": f"{self.groups[group_id].name} settle-up"}
return "upi://pay?" + "&".join(f"{k}={quote(v, safe='@')}" for k, v in q.items())The trip:
# part 5: a Goa trip with four friends
if __name__ == "__main__":
people = [User(u, u.title(), f"{u}.demo@upi") for u in ("aarav", "diya", "kabir", "meera")]
app = SplitService()
app.create_group("goa", "Goa trip", people)
everyone = [p.user_id for p in people]
app.add_expense("goa", "aarav", 1_200_000, EqualSplit(everyone), "hotel")
app.add_expense("goa", "diya", 250_000, ExactSplit({"aarav": 70_000, "diya": 60_000, "kabir": 80_000, "meera": 40_000}), "dinner")
app.add_expense("goa", "kabir", 100_000, PercentSplit({"aarav": "25", "diya": "25", "kabir": "10", "meera": "40"}), "cab")
app.add_expense("goa", "meera", 10_000, EqualSplit(["aarav", "diya", "meera"]), "chai")
app.add_expense("goa", "kabir", 600_000, EqualSplit(everyone), "petrol")
ledger = app.ledgers["goa"]
print({u: rupees(v) for u, v in ledger.net.items()})
assert sum(ledger.net.values()) == 0 # money is conserved, to the paisa
plan = app.suggested("goa")
print(len(ledger.pairwise()), "pairwise debts ->", len(plan), "transfers") # 6 -> 3
for t in plan:
print(f" {t.from_user} pays {t.to_user} {rupees(t.amount)}")
first = plan[0]
print(app.upi_link("goa", first))
app.settle_up("goa", first) # Meera tapped the link and paid; record it
print("after settle-up:", [(t.from_user, t.to_user, rupees(t.amount)) for t in app.suggested("goa")])
try:
app.add_expense("goa", "diya", 50_000, PercentSplit({"diya": "50", "kabir": "40"}), "snacks")
except ValueError as e:
print("rejected:", e){'aarav': '₹6,516.66', 'diya': '₹-2,883.33', 'kabir': '₹1,600.00', 'meera': '₹-5,233.33'}
6 pairwise debts -> 3 transfers
meera pays aarav ₹5,233.33
diya pays kabir ₹1,600.00
diya pays aarav ₹1,283.33
upi://pay?pa=aarav.demo@upi&pn=Aarav&am=5233.33&cu=INR&tn=Goa%20trip%20settle-up
after settle-up: [('diya', 'kabir', '₹1,600.00'), ('diya', 'aarav', '₹1,283.33')]
rejected: percentages must add up to 100Check the arithmetic once, as the interviewer will. Aarav paid ₹12,000 for the hotel and consumed ₹3,000 + ₹700 + ₹250 + ₹33.34 + ₹1,500 = ₹5,483.34, so he is owed ₹6,516.66. The four balances sum to zero. Meera's one tap settles the biggest debt, and two payments remain.
Extending it: the follow-ups#
- "Edit an expense." Delete (an exact reversal) plus add, inside one lock or one transaction, and keep the old version for the activity feed.
- "Split by shares (2:1:1) or by item." New
SplitStrategyclasses. An itemised bill is a list of(amount, strategy)lines whose splits are summed. - "An expense between two friends outside any group." Treat it as a hidden two-person group; every rule still applies.
- "Multiple currencies on a trip abroad." Store each expense in its own currency with balances per currency, and simplify per currency; converting at today's rate silently changes what people owe.
- "Remind people who owe." A scheduled job reads the suggested transfers and sends each debtor their UPI link. The link is the same
upi_linkcall. - "Show who owes whom without simplifying."
Ledger.pairwise()already nets each pair; it's a display choice.
Common mistakes#
- Floats for money:
0.1 + 0.2balances that never quite reach zero, and a group that can't fully settle - Rounding each share and forgetting the remainder, so ₹100 becomes ₹99.99
- A
split_typestring and anif/elifladder insideadd_expense - Storing balances and updating them by hand from several places, so they drift from the expenses
- Claiming greedy is always optimal; it's at most n − 1, not always the minimum
- Treating a tapped UPI link as a completed payment; record the settlement only when the payment is confirmed
Bill splitting
Q1/4 ₹100 is split equally between three people. What should the stored splits be?
Answer & explanation
Answer: B
Work in integer paise: 10,000 ÷ 3 = 3,333 remainder 1. One person takes the extra paisa so the splits sum to exactly 10,000.
Q2/4 Net balances are A +900, B −600, C −300. How many payments does greedy min cash flow suggest?
Answer & explanation
Answer: C
The biggest debtor pays the biggest creditor: B → A 600, leaving A +300 and C −300; then C → A 300. At most n − 1 = 2.
Q3/4 Which part of this design does a database "balance view" replace?
Answer & explanation
Answer: B
A view derives each balance from the expense and split rows at query time. Nothing stores a balance, so nothing can update one wrongly.
Q4/4 A user taps the UPI link to pay ₹5,233.33. When should the app record the settlement?
Answer & explanation
Answer: C
Opening a UPI app isn't paying: the user can cancel, or enter a wrong PIN. Only a confirmed payment becomes a settle-up in the ledger.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.