~learn.

LLD 0613 min+60 XP

Design a Bill-Splitting App (Splitwise).

Design expense splitting, balances and settle-up, then make it run

6 code

Lesson · 13 min

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.

ExplainerThe whole design: Bill splitting (Splitwise)

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#

Interviewer

"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."

A good 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
Your turn · checkpoint 1not answered

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?

Think it through, or jot it down. Then compare.

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#

DiagramBill splitting: 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 ..> Transfer

An 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:

pythonrupees · 52 lines
# 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: int

The 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.

pythonSplitStrategy · 38 lines
# 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:

Greedy min cash flow
  1. Split everyone into creditors (net > 0) and debtors (net < 0), in two max-heaps by amount
  2. Match the extremes: the biggest debtor pays the biggest creditor min(debt, credit)
  3. Settle one side: at least one of the two reaches zero and leaves the heaps
  4. 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".

Edge cases
  • ₹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:

pythonLedger · 41 lines
# 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 out

The 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.

pythonSplitService · 43 lines
# 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:

python29 lines
# 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)
text8 lines
{'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 100

Check 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 SplitStrategy classes. 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_link call.
  • "Show who owes whom without simplifying." Ledger.pairwise() already nets each pair; it's a display choice.
✗ Pitfall

Common mistakes#

What costs candidates the round
  • Floats for money: 0.1 + 0.2 balances 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_type string and an if/elif ladder inside add_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
Quick check4 questions · +5 XP each

Bill splitting

  1. 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.

  2. 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.

  3. 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.

  4. 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.

End of LLD 06 · Design a Bill-Splitting App (Splitwise)
Finish LLD 06Stages clear as you read them (0/1 so far). Skipping ahead doesn't count: marking complete unlocks after ~3 more min of reading.

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.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…