Design Splitwise

Design an expense-sharing LLD: record a bill, keep group balances honest, settle cash, and simplify who should pay whom.

Page content

Alice pays ₹1200 for dinner. Bob and Carol each owe her ₹400. Later Bob hands Alice ₹400 cash.

If you only store “who paid the bill” and never update balances, the app still shows Bob owing his share. Carol still looks like she owes the full ₹400 too — which is true for Carol, but the ledger cannot tell a live debt from a debt that already settled. The dinner is frozen. Cash never happened.

The invariant: after every expense or settlement, the sum of net balances in a group is 0, and nobody is shown a debt that already settled.

This is an LLD interview: objects, a ledger, a simplify algorithm, and one critical section around a group. Not a payments network. Same habit as Design a Parking Lot — two writers, one lock — applied to money instead of stalls.

The main design question is:

How do we record an expense, keep pairwise (or net) balances correct, and simplify who should pay whom without a pile of tiny IOUs?

We will start with a list of bills and watch settle become impossible. Nets, pairwise edges, leftover paise, and greedy min-cash-flow appear when that list is no longer enough. If they later say “at Splitwise scale,” shard by group_id. Each group is this design.

1. Clarify the problem

“Design Splitwise” can mean a roommate spreadsheet or a company with ten thousand groups. The questions that change the objects:

  • Groups, or only 1:1 debts between two people?
  • Equal split only, or exact amounts and percents too?
  • Do we show “you owe Alice,” or only a net number?
  • Is simplify a button, or always-on?
  • Can people settle in cash outside the app?
  • One currency or many?
  • What happens if someone leaves with an open debt?

If the interviewer gives no extra constraints, I would state:

Scope               Groups of 2–20 people; 1:1 is a group of two
Splits              EQUAL, EXACT, PERCENT
Currency            One per group (INR). Amounts stored as integer paise
Balances            Net per member, plus pairwise edges for the UI
Simplify            Opt-in. Computes suggested payments; does not move money
Settle              Recorded. Reduces balances. Expenses stay
Leave group         Rejected while net != 0
Not in v1           Multi-currency FX, comments, receipt photos, recurring bills

Comments and photos sit on the expense row. They do not change the ledger. FX is a product. Say so and move on.

2. Functional requirements

The system must:

  1. Create a group and add members.
  2. Add an expense: who paid, how it splits, who was involved.
  3. Show each member a net balance, and “you owe X / X owes you.”
  4. Record a settlement when cash (or UPI) changes hands.
  5. Suggest a simplified set of payments that zeros the group.
  6. Void a wrong expense and reverse its balance effect.
  7. Keep every expense for audit after settle or void.

Not in v1: bank rails, currency conversion, simplify across groups, “Alice paid 40% of the bill and Bob paid 60%” as a second payer model. One payer per expense is enough. Multiple payers is the same math with extra credit lines.

3. Non-functional requirements

This is not a 50k QPS problem. Interviewers want the graph and the leftover paise, not a cluster.

RequirementTarget
Balance writeAtomic with the expense or settlement
Invariantsum(nets) == 0 after every committed write
Roundingsum(splits) == amount always, in paise
HistoryExpenses and settlements kept; never deleted
ConcurrencyTwo addExpense calls in one group do not lose an update
ClockServer time on the row; never the client

Durability is optional until they ask to persist. The ledger outlives the dinner.

Edge cases

  • The same dinner is submitted twice (retry, or two people tapping Save).
  • Alice leaves the group while she is still owed ₹400.
  • ₹1000 split three ways: 0.01 leftover.
  • Alice and Bob add two expenses in the same group at once.
  • Exact or percent shares that do not add up.
  • Void after a settlement has already used those balances.
  • A settlement larger than the outstanding pairwise debt.

4. Scale

Say the numbers so nobody spends the interview on QPS.

People / group        2 – 20 (a trip, a flat, a dinner)
Expenses / group      hundreds, not millions
Groups / user         dozens
Writes                a few per minute per group, bursty after a trip

A full scan of 200 expenses works in a toy and is wrong once you need settle, simplify, and a concurrent add. Keep expenses for audit, not as the only way to know who owes whom.

If they push “Splitwise scale,” do not invent Kafka. Route by group_id. Group A and Group B do not share a balance row.

Splitwise
  ├── Group g_trip      ← this design
  ├── Group g_flat      ← this design
  └── Group g_dinner    ← this design

5. Entities / what we store

Six facts. Balances are maintained, not guessed.

User         id, name
Group        id, name, currency, members
Expense      id, group_id, payer_id, amount_paise, split_type,
             note, status OPEN | VOID, client_request_id
Split        expense_id, user_id, share_paise
Settlement   id, group_id, from_id, to_id, amount_paise, created_at
Balance      group_id, user_id, net_paise
             optional: (group_id, from_id, to_id, amount_paise) pairwise

An expense plus its splits is the source of truth for “what happened at dinner.” The balance row is occupancy you update in the same critical section. If the UI and the balance row disagree, the balance row wins — then you rebuild it from expenses and settlements if you must.

E1 dinner  Alice paid 1200, split 400/400/400
  Alice net +800   Bob -400   Carol -400

S1 cash    Bob paid Alice 400
  Alice net +400   Bob    0   Carol -400

E1 VOID    reverse the dinner (S1 still stands)
  Alice net -400   Bob +400   Carol 0

Void undoes the bill, not the cash. Alice now owes Bob ₹400 because Bob already paid her for a dinner that no longer exists. That is why you do not delete rows. The audit trail is how you explain a weird net.

Do not treat a person’s name as the key. User ids are. Amounts are integer paise. ₹1200 is 120000 paise. Never use a float.

6. APIs or class methods

In LLD you may show a service, not HTTP. Either is fine.

addExpense(group, payer, amount, members, split) -> Expense
settle(group, from, to, amount) -> Settlement
balances(group) -> [user, net, pairwise]
simplify(group) -> [from, to, amount]
voidExpense(expense_id) -> Expense

Add expense

Request:

POST /v1/groups/g_dinner/expenses
{ "payer_id": "u_alice", "amount": { "paise": 120000, "currency": "INR" }, "split_type": "EQUAL", "member_ids": ["u_alice", "u_bob", "u_carol"], "note": "Dinner", "client_request_id": "phone-9f3a" }

Response:

HTTP 201
{ "expense_id": "e_dinner", "splits": [{ "user_id": "u_alice", "paise": 40000 }, { "user_id": "u_bob", "paise": 40000 }, { "user_id": "u_carol", "paise": 40000 }], "nets": { "u_alice": 80000, "u_bob": -40000, "u_carol": -40000 } }

A retry with the same client_request_id returns the same body. It does not add dinner twice. Exact splits that miss the amount, or percents that miss 100, return 400 SPLIT_MISMATCH.

Settle

Request:

POST /v1/groups/g_dinner/settlements
{ "from_id": "u_bob", "to_id": "u_alice", "amount": { "paise": 40000 } }

Response:

HTTP 201
{ "settlement_id": "s_1", "from_id": "u_bob", "to_id": "u_alice", "amount": { "paise": 40000 }, "nets": { "u_alice": 40000, "u_bob": 0, "u_carol": -40000 } }

Overpay is a product choice: allow it (Alice now owes Bob) or 400 OVERPAY. Say which. Do not silently cap.

Balances and simplify

Request:

GET /v1/groups/g_dinner/balances

Response:

HTTP 200
{ "currency": "INR", "nets": { "u_alice": 40000, "u_bob": 0, "u_carol": -40000 }, "pairwise": [{ "from": "u_carol", "to": "u_alice", "paise": 40000 }] }

Request:

POST /v1/groups/g_dinner/simplify

Response:

HTTP 200
{ "suggested": [{ "from": "u_carol", "to": "u_alice", "paise": 40000 }] }

simplify is a read. It does not write settlements. Mixing the two is how you invent money.

7. Start with a list of expenses — show why settle is hard

The first design everyone writes:

function whoOwesWhom(group):
  nets = { u: 0 for u in group.members }
  for expense in group.expenses:
    nets[expense.payer] += expense.amount
    for split in expense.splits:
      nets[split.user] -= split.share
  return nets

Dinner is easy to draw.

Scan E1
  Alice +1200
  Alice -400  Bob -400  Carol -400
  =>  Alice +800   Bob -400   Carol -400

Then Bob pays Alice ₹400 cash. There is no expense. If you do not have a second list, the scan still says Bob owes ₹400. The invariant “nobody is shown a settled debt” is gone.

You can bolt settlements onto the scan:

for settlement in group.settlements:
  nets[settlement.from] += settlement.amount   // less in debt
  nets[settlement.to]   -= settlement.amount   // less owed

That is correct and O(expenses + settlements) on every screen open. It also means two threads can add E2 and E3, both scan, both write a cached net, and one dinner vanishes from the totals.

The bug is not the loop. The bug is treating the log as the only live state, then caching it without a claim.

scan expenses only              wrong after cash
scan expenses + settlements     correct, slow, racy if cached
maintain balances on write      correct, O(members) per write

We will use the last one. The scan becomes a rebuild tool for tests and corruption, not the read path.

8. Net balance per user — one number

A net is one integer per member: how much the group owes them (positive) or they owe the group (negative). After dinner:

                paid     share      net
  Alice         1200  -   400   =  +800
  Bob              0  -   400   =  -400
  Carol            0  -   400   =  -400
                 ------------------------
  column sum    1200     1200        0

Read the ledger left to right. Alice put ₹1200 in. She ate ₹400 of it. The group owes her ₹800. Bob and Carol each ate ₹400 and put in nothing.

        +800
       Alice
      /      \
  400/        \400
    v          v
   Bob        Carol
   -400       -400

The arrows are a story for the UI. The stored fact is the three nets. After Bob’s cash: Alice +400, Bob 0, Carol −400. Sum is still 0. That is the whole model.

A net does not say who should pay Alice. With two debtors it did not matter. With four people it will.

9. Pairwise balances versus net

Pairwise means one number for an ordered pair: “Bob owes Alice ₹400.” A group of n people has up to n(n-1)/2 directed debts if you store one direction, or a sparse map of the pairs that are actually non-zero.

Net (3 cells)                     Pairwise (the UI)

  Alice  +800                       Bob   --400--> Alice
  Bob    -400                       Carol --400--> Alice
  Carol  -400

When pairwise matters:

  • The home screen says “you owe Alice ₹400,” not “your net is −400.”
  • Two people shared three bills; you want one number between them, not three IOUs.
  • A cycle makes pairwise look busy while nets are already zero.
Cycle that nets to nothing

  Alice --100--> Bob --100--> Carol --100--> Alice

  Alice net  0
  Bob   net  0
  Carol net  0
  sum        0

If you only show pairwise, everyone thinks they owe ₹100. If you only show nets, everyone looks settled — which they are, as a group. Simplify is how you get from a messy pairwise graph to a small set of payments that match the nets.

Suggested payments need only nets. Pairwise is for “you owe Alice” before simplify, and for refusing a settlement that does not follow an existing pair. I keep both and update them in the same write. If they drift, rebuild from the log. Net-only is less state and a colder home screen.

10. Add expense: one critical section, leftover paise

Adding dinner is not “insert a row.” It is insert plus balance updates, or it is a lie.

synchronized (groupLock)  /  BEGIN … FOR UPDATE balances
  reject if client_request_id seen
  shares = split(amount, members, type)
  assert sum(shares) == amount
  insert Expense OPEN + Split rows
  net[payer] += (amount - payer_share)
  for each other member:
      net[member] -= share
      pairwise[member -> payer] += share
  commit

Dinner: Alice +800, Bob −400, Carol −400. Sum 0.

Rounding: last person eats the leftover paise

₹1000.00 is 100000 paise. Three people. 100000 / 3 = 33333 remainder 1.

Alice  33333
Bob    33333
Carol  33334    ← last by sorted user_id
sum   100000

Last is a stable user_id sort, not luck. Nearest-each-share can yield 33333 × 3 = 99999 and invent a missing paisa. Fix that with the same leftover rule.

EQUAL:    q, r = divmod(amount, n)
          first n-1 people get q; last gets q+r

EXACT:    caller sends shares; reject if sum != amount

PERCENT:  raw = amount * pct / 100  (integer)
          leftover paise go to last member
          reject if percents != 100

The rule you say out loud: sum(splits) == amount, always. The leftover paisa is a product decision, not a precision bug. I would not split leftover across everyone; that is more code for one paisa.

Floats are banned. 1200.0 / 3 in IEEE is how you fail the rounding test.

11. Settle: record cash, do not delete dinner

Bob pays Alice ₹400. That is a Settlement, not an edit of E1.

synchronized (groupLock)
  insert Settlement(Bob -> Alice, 400)
  net[Bob]   += 400     // less in debt
  net[Alice] -= 400     // less owed
  pairwise[Bob -> Alice] -= 400
  commit

E1 stays OPEN
splits stay 400/400/400
Before S1                         After S1

  Alice +800                        Alice +400
  Bob   -400                        Bob      0
  Carol -400                        Carol -400

  Bob --400--> Alice                (edge gone)
  Carol --400--> Alice              Carol --400--> Alice

Delete E1 and you lose the audit: why does Carol still owe ₹400? You also cannot void the dinner later without inventing history. Settle is “cash moved,” not simplify. The dinner remains the reason Carol still owes Alice.

12. Simplify debts: greedy min-cash-flow

A trip with four people collects a pile of IOUs. Nobody wants to pay six times.

Min-cash-flow: find payments that zero every net, using as few transfers as you reasonably can.

Minimizing the exact number of transfers is NP-hard (it hides subset-sum: can this creditor be paid by a subset of debtors that adds up exactly?). The interview answer is greedy: largest debtor pays largest creditor, then repeat. It is correct. It is at most n-1 payments. It is not always minimum. Say that.

function simplify(nets):
  debtors   = max-heap of people with net < 0   // magnitude
  creditors = max-heap of people with net > 0
  payments  = []
  while debtors and creditors:
      d = pop max debtor
      c = pop max creditor
      x = min(-d.net, c.net)
      payments.append(d pays c the amount x)
      d.net += x
      c.net -= x
      if d.net < 0: push d
      if c.net > 0: push c
  return payments

Worked example — four people, six IOUs

Alice, Bob, Carol, Dev after a weekend:

Pairwise before (six edges)

  Alice --100--> Bob --250--> Carol
    |               |            |
    | 200           | 120        | 180
    v               v            v
  Carol            Dev <---------+
                    |
                    | 80
                    v
                  Alice

Nets from those edges:

Alice   -100 -200 +80          =  -220
Bob     +100 -250 -120         =  -270
Carol   +200 +250 -180         =  +270
Dev     +120 +180 -80          =  +220
sum                              =     0

Greedy:

1. max debtor Bob 270, max creditor Carol 270
   Bob pays Carol 270
   Bob 0, Carol 0
   left: Alice -220, Dev +220

2. Alice pays Dev 220
   all zero
After (two edges)

  Bob   --270--> Carol
  Alice --220--> Dev

Six IOUs became two payments. Same nets. Dev never split a cab with Alice; the algorithm still told her to pay him. That is the product cost of simplify. I would keep it opt-in. People who want the social story use pairwise. People who want to leave the restaurant use the button.

A net of zero drops out immediately (no heap entry). That is how the ₹100 cycle in section 9 becomes “suggested: nothing.”

13. State machines if they help

Do not invent a status for every mood. Two are enough.

Expense

  OPEN ──── voidExpense ────► VOID

Void applies the inverse deltas of the original splits, in the same group lock. Illegal: edit shares in place. A correction is VOID plus a new OPEN expense. That keeps the audit and the rounding rule on one path.

Group membership

  ACTIVE ──── leave (only if net == 0) ────► LEFT

Alice still owed ₹400? 409 OPEN_DEBT. Someone settles her out, or the group records a settlement that zeros her, then she may leave. Transferring her net onto Bob is a settlement, not a hidden mutation.

Settlements are append-only. Undo cash with a reversing row, not a status flip. The pair that must stay together:

BEGIN / synchronized
  expense  → OPEN
  nets     += dinner deltas
  pairwise += dinner edges
END

BEGIN / synchronized
  expense  OPEN → VOID
  nets     -= dinner deltas
  pairwise -= dinner edges
END

If you persist the expense and forget the nets, the next screen is wrong until a rebuild. If you update nets and roll back the insert, you invented money. Same critical-section lecture as the parking-lot ticket and stall.

14. Class sketch

Ideas, not a framework dump. Requests for one group share one GroupLedger.

Money          paise: int, currency: INR
User           id, name
Group          id, name, currency, memberIds
Expense        id, groupId, payerId, amount, splits, type, status, requestId
Settlement     id, groupId, fromId, toId, amount
BalanceBook    net[user], pair[from][to]

SplitPolicy      equal / exact / percent     // last eats leftover
BalanceBook      applyExpense / applySettlement / reverseExpense / sumNets
DebtSimplifier   suggest(nets) -> [from, to, amount]
ExpenseRepo      insert / findByRequestId / markVoid
SettlementRepo   insert
BalanceRepo      lockGroup / saveBook

GroupLedger      addExpense / settle / voidExpense / balances / simplify

GroupLedger.addExpense in one breath:

addExpense(payer, amount, members, type, requestId):
  if seen(requestId): return existing
  shares = SplitPolicy.for(type, amount, members)
  expense = repo.insert(OPEN, payer, shares, requestId)
  book.applyExpense(payer, shares)
  repo.saveBook(book)
  return expense

Handlers do not touch net[user] themselves. Tests fake the repository for split math; they use two threads against a real GroupLedger for the race.

15. Concurrency: two expenses, one group

Alice adds groceries. Bob adds cab fare. Same group, same second. Both read Alice = +800.

        Alice                      Ledger                        Bob
          |                          |                            |
          |-- addExpense groceries ->|                            |
          |                          |<-- addExpense cab ---------|
          |              nets: A +800  B -400  C -400             |
          |     lock; apply grocery; unlock                       |
          |                          |   lock; sees new nets      |
          |                          |   apply cab; unlock        |
          |<-- 201 e_grocery --------|-------- 201 e_cab -------->|
          |              sum(nets) == 0, both rows exist          |

Without the lock, both apply deltas to +800 and the second write drops the first dinner’s effect. Sum is no longer 0. That is the offer-level bug.

group mutex              simple; two groups never wait on each other
row lock / FOR UPDATE    same idea in SQL, one transaction
optimistic version       UPDATE … WHERE version = n; retry on 0 rows
per-user locks only      wrong; a dinner touches every member

I would start with one mutex per group_id, or SELECT … FOR UPDATE on every balance row in that group, in a stable id order so two transactions do not deadlock. I would not open a distributed lock “because money.” One group is one writer set.

Two expenses in different groups do not share a lock. That is the shard story from section 4.

Idempotency is a unique (group_id, client_request_id). The second thread with the same id returns the first expense. It must not apply deltas twice. Check the id inside the same critical section as the write.

16. Tests that matter

Skip getters. Test the invariant.

  1. Dinner sum. After E1, nets are +800 / −400 / −400. sum == 0. Pairwise Bob→Alice and Carol→Alice are 400 each.
  2. Cash. After S1, Bob is 0, Alice +400, Carol −400. E1 is still OPEN. Scan of expenses-only would fail this test — that is the point.
  3. Rounding. ₹1000 / 3. Shares 33333, 33333, 33334 (or your stated leftover rule). Sum equals 100000. No floats in the fixture.
  4. Simplify. The four-person graph in section 12. Suggested payments: Bob→Carol 270, Alice→Dev 220. All suggested nets match. Edge count ≤ 3.
  5. Cycle. ₹100 triangle. simplify() returns empty. Nets stay 0.
  6. Duplicate request. Two addExpense with the same client_request_id. One row. Deltas applied once.
  7. Last-write race. Two threads, two different expenses, one group. Both persist. sum(nets) == 0. Both ids exist.
  8. Void. Void E1 after S1. Nets become the settlement only (Alice −400, Bob +400, Carol 0) — cash still happened.
  9. Leave. Alice net ≠ 0 → OPEN_DEBT. After Carol settles, Alice may leave.
  10. Bad exact split. Shares 300 + 300 + 300 on a 1000 amount → SPLIT_MISMATCH, nets unchanged.

Test (3) and (7) are the offer if they only have time for two. Rounding is where juniors use double. The race is where they forget the lock.

17. Extensions

Stay on objects. Do not add a message bus.

Multi-currency. One currency per group is the LLD. A second currency is either a second group or an FX rate snapshotted onto the expense — and then you have invoice money, not this article. Do not convert at read time.

Simplify across groups. Usually no. The trip and the flat are different social contracts. Alice should not pay Dev for a cab because Carol owes her for rent. If they insist, you are netting users globally; say the trust change out loud.

Multiple payers. Two expenses, or two credit lines on one bill. I would use two expenses until the UI demands one receipt. Recurring rent is a job that calls addExpense with a new request id. Photos and comments are columns. Zero balance code.

18. Final object diagram

One group. Many phones. One ledger.

          Alice's phone                         Bob's phone
              |                                   |
              |  addExpense / settle / balances   |
              v                                   v
        +---------------------------------------------+
        |                GroupLedger                  |
        |  addExpense / settle / void / simplify      |
        |  lock: group_id                             |
        +----------+----------+-------------+---------+
                   |          |             |
         +---------v--+  +----v-----+  +----v--------+
         | ExpenseRepo|  | SettleRepo|  | BalanceRepo |
         | OPEN / VOID|  | append    |  | net + pair  |
         | request id |  | never edit|  | lock group  |
         +-----+------+  +-----+-----+  +------+-----+
               |               |               |
               v               v               v
          Expense+Split   Settlement      BalanceBook
          e_dinner OPEN    s_1 Bob→Alice   A +400
          shares 400x3                     B    0
                                           C -400
                   |
                   v
            DebtSimplifier.suggest(nets)
                   |
                   v
            Carol --400--> Alice     (not written until settle)
addExpense  idempotent id? -> split+leftover -> insert OPEN -> apply book
settle      insert row -> apply book; do not touch expenses
void        OPEN→VOID -> reverse book
simplify    read nets -> greedy heaps -> suggested list

Kafka, Redis, and a region pair are not in this picture. If those words appear, you left LLD.

Source of truth    Expense+Split and Settlement rows, plus BalanceBook
                   written in one critical section
Derived            simplify suggestions, “you owe Alice” copy
Ephemeral          the simplify response, phone UI
Audit              OPEN and VOID expenses, every settlement

19. Interview-ready summary

How to walk through in 10–15 minutes

0–2 min. Dinner ₹1200, Bob’s ₹400 cash. Invariant: sum of nets is 0; settled debts disappear. LLD, not a payments company.
2–4 min. Groups, equal/exact/percent, one currency, opt-in simplify.
4–6 min. Entities: Expense, Split, Settlement, Balance. Amounts in paise.
6–9 min. Naive scan, why cash is invisible, maintain nets on write. Pairwise vs net, the ₹100 cycle.
9–12 min. Leftover paise, settle does not delete, greedy min-cash-flow on the four-person graph. NP-hard vs greedy.
12–15 min. Group mutex, VOID, leave-with-debt, the tests. Scale is group_id.

Key decisions to remember

  1. This is objects and one group ledger, not a distributed payments product.
  2. Expense (or settlement) and balance updates commit together.
  3. Store integer paise. sum(splits) == amount. Last member eats leftover.
  4. Net is the invariant. Pairwise is the UI. Simplify reads nets; it does not write cash.
  5. Minimizing transfer count exactly is NP-hard. Greedy max-debtor → max-creditor is the answer.
  6. Simplify may pair people who never shared a bill. Keep it opt-in.
  7. Settlements append. Expenses are OPEN or VOID. Nothing is deleted.
  8. Duplicate dinner is (group_id, client_request_id), checked inside the lock.
  9. Leave is refused while net != 0.
  10. “Splitwise scale” is shard by group_id, not Kafka.

Likely interviewer follow-up questions

  • Equal vs exact vs percent — where does the leftover paisa go?
  • Why not recompute balances by scanning every expense?
  • Pairwise or net — which do you store?
  • Prove simplify on a four-person messy graph.
  • Is min-cash-flow optimal? Why is greedy allowed?
  • Two people tap Save on the same bill.
  • Can Alice leave while she is owed money?
  • Do we delete the dinner after Bob pays?
  • What if they ask for multi-currency or global simplify?
  • What if they ask for “Splitwise scale”?

Senior-level points that differentiate the answer

  • Name the frozen-ledger bug before drawing classes. Classes without an invariant are a vocabulary list.
  • Tie the group lock to the parking-lot assign: one critical section, two writers, one object.
  • Separate “cash moved” (settlement) from “suggested payments” (simplify).
  • Call out leftover paise and ban floats without being prompted.
  • Admit greedy is not exact-min, and that simplify can invent a pair with no shared expense.
  • Treat VOID as inverse deltas, not DELETE. Scale is group_id, not a log.

A 1–2 minute verbal answer

Splitwise is a group ledger. An expense records who paid and how the shares split, in integer paise, with the last person eating leftover so the shares sum to the amount. I update each member’s net — and the pairwise edges for the UI — in the same critical section as the insert, so the sum of nets stays zero and a retry cannot apply dinner twice. A settlement is a second row when cash moves; it reduces balances and never deletes the bill. Simplify is greedy min-cash-flow: largest debtor pays largest creditor until everyone is zero. Exact minimum transfers is NP-hard; greedy is correct and at most n−1 payments. I would test leftover paise, the dinner-plus-cash nets, and two threads adding expenses in one group. One currency per group. If they want company scale, shard by group id — each group is this design.

For HLD framing around this, see the System Design Interview Complete Guide and the questions hub.