4. Splitwise (Expense Sharing) Intermediate → Advanced

A. Realistic Interview Prompt

"Design an expense-sharing application like Splitwise. Users can form groups, add shared expenses with different splitting methods, see who owes whom, and settle up."

B. Difficulty

Intermediate for the core model (users, groups, expenses, splits, balances). Becomes Advanced once debt simplification (minimizing the number of settling transactions) and concurrent balance updates enter the conversation — the former is a genuine graph algorithm, the latter tests real correctness invariants on shared financial state.

C. What This Problem Tests

D. Requirements

Functional

  1. Create users and groups (a group is a set of users)
  2. Add an expense: total amount, payer, participants, and a split method (equal / exact / percentage)
  3. Compute and track balances (who owes whom, and how much)
  4. Settle up — record a payment that reduces an existing balance
  5. View balances, both pairwise and net per user

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Which split types are required — equal, exact amounts, percentage, shares?Determines the Strategy interface's parameter shapeSplitStrategy implementations
2Is a Group required, or can two users split an expense directly?Determines whether Group is mandatory or an optional containerWhether Expense.groupId is nullable
3Track pairwise balances (who owes whom directly) or only net-per-user?Pairwise supports "show me what I owe Alice specifically"; net-only can't answer thatBalanceSheet data shape
4Do we need debt simplification (minimum transactions to settle a group)?This is a real graph algorithm, not just bookkeepingWhether DebtSimplifier exists
5Can an expense be edited/deleted after creation?Affects whether balances are mutated in place or via compensating entriesExpense mutability model
6Must the payer be one of the split participants?Determines if "pay for someone else" is a first-class caseWhether payer is separate from participants
7How should rounding be handled for non-evenly-divisible amounts?Splits must sum exactly to the total — someone absorbs the remainderSplitStrategy validation/rounding rule
8Is settling up a special kind of Expense, or a separate entity?Affects class symmetry and whether Payment inherits from ExpensePayment vs. Expense subclassing decision
9Are balances scoped per-group, or one global balance between two users across all groups?Changes the key shape of the balance ledgerBalanceSheet key design
10Is concurrent expense-adding expected within the same group?Drives whether balance updates need explicit lockingConcurrency section relevance

F. Assumptions

G. Actors

ActorResponsibility
UserCreates expenses, joins groups, views balances, settles up
SystemComputes splits, maintains the balance invariant, validates every mutation

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
UserIdentityid, name, email—Balance computation
GroupMembership containerid, members—Balance computation, expense storage logic
ExpenseImmutable record of one shared cost eventamount, paidBy, splits, groupId?, timestamp—Computing its own splits (delegates to a strategy at creation time)
SplitData: how much one participant owes for one expenseuser, amount—Any computation — pure data
SplitStrategy (interface)Computes the list of Splits from a total + participants + params—compute(total, participants, params)Persisting or applying the result
BalanceSheetSingle source of truth for who-owes-whompairwise balance mapadjust(a, b, amount), getBalance(a,b)Deciding why a balance changed
PaymentRecords a settle-up actionfrom, to, amount, timestamp—Split computation (none needed)
ExpenseService (facade)Orchestrates expense/payment creation + atomic balance update—addExpense(), settleUp()Split math itself, balance storage itself

I. Class Relationships

J. Class Diagram

Group

members

User

id

name

Expense

amount

paidBy

timestamp

Split

user

amount

«interface»

SplitStrategy

+compute(total, participants, params)

BalanceSheet

+adjust(a,b,amt)

+getBalance(a,b)

Payment

from

to

amount

ExpenseService

+addExpense()

+settleUp()

EqualSplitStrategy

ExactSplitStrategy

PercentSplitStrategy

View Mermaid source
classDiagram
  class Group {
    members
  }
  class User {
    id
    name
  }
  class Expense {
    amount
    paidBy
    timestamp
  }
  class Split {
    user
    amount
  }
  class SplitStrategy {
    <<interface>>
    +compute(total, participants, params)
  }
  class BalanceSheet {
    +adjust(a,b,amt)
    +getBalance(a,b)
  }
  class Payment {
    from
    to
    amount
  }
  class ExpenseService {
    +addExpense()
    +settleUp()
  }
  Group o-- User
  Expense *-- Split
  SplitStrategy <|.. EqualSplitStrategy
  SplitStrategy <|.. ExactSplitStrategy
  SplitStrategy <|.. PercentSplitStrategy
  ExpenseService --> SplitStrategy
  ExpenseService --> BalanceSheet
  ExpenseService --> Payment
  ExpenseService --> Expense

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
ExpenseImmutable record of what happenedHow the split was computed (delegates)
SplitStrategyThe math of dividing an amountStoring the result, updating balances
BalanceSheetThe current truth of who-owes-whomWhy a number changed (that's Expense/Payment's job to record)
ExpenseServiceOrchestration — ties strategy output to a balance update atomicallySplit math, balance storage internals

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPBalanceSheet only stores numbers; SplitStrategy only computes; Expense only records factsAn Expense class that also mutates a global balance array inside its own constructor
OCPNew split type = new SplitStrategy; zero changes elsewhereif (splitType == "PERCENT") ... else if (splitType == "EXACT") ... inline in ExpenseService
LSPEvery SplitStrategy must produce splits summing exactly to the total — a stated contract every implementation must honorA strategy that silently produces splits off by a rounding cent, breaking the sum-to-zero invariant downstream
ISPOne-method SplitStrategy interfaceA fat interface also requiring validate(), format(), notify()
DIPExpenseService depends on SplitStrategy, resolved via a factory keyed by typeExpenseService directly instantiating new PercentSplitStrategy() inline

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StrategySplit computation genuinely varies by typeSplitStrategy hierarchyN/A — clearly justified by the stated requirement
FactoryResolve a split-type enum to the right strategy instanceSplitStrategyFactorySkip if there's truly only one split type ever
FacadeCallers shouldn't juggle strategy selection + balance updates themselvesExpenseServiceSkip only for a trivial two-user, one-split-type toy version
Observer (optional)Notify group members when a new expense/payment is addedAn event hook fired after a successful addExpenseSkip unless notifications were requested

Explicitly avoided: making Payment a subclass of Expense for code reuse (see I) — a common naive shortcut that creates an LSP violation instead of saving real work.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Split methodEqual/Exact/PercentShares-based, itemized-by-receipt-lineSplitStrategy interface
Debt settlementRaw pairwise balances shownSimplified minimum-transaction suggestionsDebtSimplifier as a separate, read-only computation over BalanceSheet
Balance storageIn-memory mapDatabase-backed ledger, event-sourced from Expense/Payment logBalanceSheet as an interface the service depends on, not a concrete map
CurrencySingle currencyMulti-currency with exchange ratesMoney as a value object encapsulating amount+currency from day one

O. Design Evolution

What stays stable: ExpenseService.addExpense()/settleUp() signatures. The ledger's internal storage and the debt-simplification feature are additive, not disruptive.

P. API / Interface Design

interface SplitStrategy {
  List<Split> compute(Money total, List<User> participants, Map<String,Object> params);
}

class ExpenseService {
  Expense addExpense(Money total, User paidBy, List<User> participants,
                      SplitType type, Map<String,Object> params, Optional<Group> group);
  Payment settleUp(User from, User to, Money amount, String idempotencyKey);
  Money   getBalance(User a, User b);
  Map<User, Money> getNetBalances(Group group);
}

Ownership/validation notes: addExpense validates that computed splits sum exactly to total before persisting anything — rejecting the whole operation rather than silently drifting the sum-to-zero invariant. settleUp takes an idempotency key so a client retry after a timeout can't double-adjust the balance.

Q. Pseudocode — Critical Logic

addExpense(total, payer, participants, type, params):
  strategy = strategyFactory.get(type)
  splits = strategy.compute(total, participants, params)
  assert sum(s.amount for s in splits) == total   // reject if strategy is buggy
  expense = Expense.create(total, payer, splits)
  for split in splits:
    if split.user != payer:
      balanceSheet.adjust(split.user, payer, +split.amount)  // split.user owes payer
  return expense

settleUp(from, to, amount, idemKey):
  if paymentLog.seen(idemKey): return paymentLog.get(idemKey)   // idempotent replay
  balanceSheet.adjust(from, to, -amount)
  payment = Payment.create(from, to, amount)
  paymentLog.record(idemKey, payment)
  return payment

R. End-to-End Use Case

3 friends, dinner, $90, equal split, Alice pays
  │
  ▼
ExpenseService.addExpense($90, Alice, [Alice,Bob,Carol], EQUAL)
  │
  ▼
EqualSplitStrategy.compute → [Alice:$30, Bob:$30, Carol:$30]
  │
  ▼
BalanceSheet.adjust(Bob, Alice, +$30)   // Bob owes Alice $30
BalanceSheet.adjust(Carol, Alice, +$30) // Carol owes Alice $30
  ...later...
Bob settles up $30 with Alice
  │
  ▼
ExpenseService.settleUp(Bob, Alice, $30, idemKey)
  │
  ▼
BalanceSheet.adjust(Bob, Alice, -$30)   // Bob's balance with Alice → $0

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Map<User, Map<User, Money>>Pairwise balance lookupO(1) access per pairReplaying the full Expense/Payment log on every read — always correct, but O(n) per query
Two max-heaps (creditors, debtors) by absolute balanceDebt simplification (greedy)Efficiently pair the largest creditor with the largest debtor repeatedlyTrue optimal minimum-transaction settlement is NP-hard in general — the heap approach is a practical approximation, not guaranteed-minimal

T. Complexity Analysis

OperationTimeSpace
addExpenseO(participants)O(participants)
getBalance(a,b)O(1) with a materialized ledger—
Debt simplification (greedy heap)O(n log n)O(n)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Add itemized splits by which items each person ordered."OCP under a real new requirementNew ItemizedSplitStrategyNew strategy class onlyValidates the Strategy investment
"Simplify a group's debts into the minimum number of payments."Graph-thinking, recognizing NP-hardnessDebtSimplifier reading the ledger, greedy max-heap pairingNew read-only classState clearly it's an approximation, not a guaranteed optimum
"Expenses need to be editable after creation."Auditability vs. simplicity trade-offPrefer a compensating reversal + new expense over in-place mutationExpenseServiceIn-place edit is simpler but destroys the audit trail of a financial system
"Two users add expenses to the same group at the same time."Concurrency correctnessLock all affected user-pairs in a consistent (e.g. sorted-by-ID) order per expenseBalanceSheetConsistent lock ordering avoids deadlock between overlapping-participant expenses
"Support multiple currencies."Domain model completenessMoney becomes a currency-aware value object; balances need a conversion policy or per-currency ledgersMoney, BalanceSheetReal complexity jump — flag it as such rather than hand-waving
"A settle-up confirmation is lost and the client retries."Idempotency for a financial mutationsettleUp keyed by an idempotency token; replay returns the original result, doesn't double-adjustExpenseService.settleUpNon-negotiable for anything touching money
"Scale this to millions of users, like the real Splitwise."LLD → HLD boundaryLedger moves to a sharded database; the sum-to-zero invariant is enforced per-transaction, not globally lockedNone in-process — infra changeRecognize the boundary rather than trying to "distribute" the classes themselves
"Percentages don't add up to 100%."Input validation disciplineReject at the strategy level before any Expense is createdPercentSplitStrategyFail fast, never let a bad split reach the ledger

W. Concurrency

Shared mutable state: each entry in BalanceSheet. The core invariant — total balances across all users sum to zero — must survive concurrent writes.

X. Persistence

Two valid designs, worth presenting both: (1) event-sourced — the append-only Expense/Payment log is the source of truth, and BalanceSheet is a rebuildable projection; (2) materialized-ledger — BalanceSheet rows are updated transactionally alongside each Expense/Payment write, inside the same database transaction. Materialized is faster to read; event-sourced is more auditable and self-healing if a bug ever corrupts a balance. Mentioning both, and picking one with a stated reason, reads much stronger than assuming only one exists.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDUser/Group/Expense/Split/BalanceSheet classes, in-process invariants, strategy pattern
HLDA ledger service backed by a database, sharded by user or group, an API layer, caching for frequent balance reads
Distributed SystemsMaintaining the sum-to-zero invariant across a sharded/replicated store — usually solved with careful per-transaction boundaries rather than a distributed lock

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
Balance trackingPairwise ledgerNet-per-user onlyPairwise answers "who owes whom" precisely; net-only is simpler but loses that detail
Balance storageMaterialized ledgerEvent-sourced (replay log)Materialized is fast to read; event-sourced is more auditable/self-healing but slower to query
Debt simplificationGreedy max-heap pairingOptimal min-cash-flow searchGreedy is fast and good-enough; optimal is NP-hard in general — rarely worth it in an interview
Expense editsIn-place mutationCompensating reversal + new recordIn-place is simpler; compensating preserves a clean audit trail — usually the right call for money

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design something like Splitwise.
Candidate: What split types need support — equal, exact, percentage — and do balances need to be tracked pairwise, or just net-per-user?
Interviewer: All three split types, and yes, pairwise — I want to know exactly what I owe each person.
Candidate: Then I'll separate the split data from the split algorithm — a SplitStrategy interface computes a list of Split objects from the total and participants, and an Expense just stores the result.
Interviewer: How do you guarantee the numbers stay consistent?
Candidate: I'd enforce, as an invariant, that the sum of all balances across every user always equals zero — and validate that every strategy's output sums exactly to the total before accepting the expense at all.
Interviewer: Now support settling up.
Candidate: I'd model that as a separate Payment entity, not a subclass of Expense — it has no splits or strategy, so inheriting would just carry along fields that don't apply.
Interviewer: A client retries a settle-up after a network timeout.
Candidate: settleUp needs to be idempotent — I'd have the client pass an idempotency key so a retried call returns the original result instead of adjusting the balance twice.
Interviewer: Last one — a group of 10 people has a tangled web of debts. Minimize the number of payments needed to settle everyone up.
Candidate: That's a graph min-cash-flow problem, and the truly optimal version is NP-hard. I'd use a practical greedy approach instead — repeatedly match the largest creditor with the largest debtor using two max-heaps — and I'd be upfront that it's a good approximation, not a guaranteed minimum.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
ashishps1/awesome-low-level-design (GitHub)User/Group/Expense/Split classes, Singleton SplitwiseService, concurrent collectionsClean baseline class list; explicit mention of ConcurrentHashMap/CopyOnWriteArrayListSingleton service by default; no debt-simplification detailThe concurrent-collections instinct — validated and expanded on in W above
InterviewReady — Low-Level Design of SplitwiseGraph-based debt simplification via a greedy max-heap pairing of creditors/debtorsHonest about the greedy approach's limitation (can produce more transactions than optimal)Less detail on the core Split/Strategy class designThe heap-based greedy algorithm and its explicit acknowledged limitation — both adopted directly in this document
AlgoMaster (cross-referenced)Standard Strategy-pattern walkthrough for splitsGood baseline confirmation of the Strategy approachLess depth on concurrency/idempotencyUseful as a sanity check against the synthesized design here

AF. Interview Cheat Sheet

ProblemSplitwise (Expense Sharing)
DifficultyIntermediate → Advanced
Core requirementsGroups, expenses with pluggable splits, pairwise balances, settle-up
Key clarificationsSplit types? Pairwise or net balances? Debt simplification needed? Rounding rule? Editable expenses?
Core entitiesUser, Group, Expense, Split, SplitStrategy, BalanceSheet, Payment, ExpenseService
Key relationshipsGroup aggregates Users; Expense composes Splits; Payment is a sibling of Expense, not a subclass
Key patternsStrategy (splits), Factory (strategy resolution), Facade (ExpenseService)
Most important decisionSeparating split data from split algorithm, and enforcing the sum-to-zero invariant explicitly
Likely-changing behaviorSplit methods, debt-simplification approach, currency support
Concurrency concernMulti-pair locking order per expense; idempotent settle-up
Top follow-upsDebt simplification, editable expenses, multi-currency, idempotent settlement, real-world scale
Common mistakesPayment-as-Expense-subclass; no explicit ledger; global lock; ignoring the sum-to-zero invariant
What NOT to over-engineerDon't build optimal (NP-hard) debt simplification — greedy is the expected interview answer

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Splitwise (Expense Sharing) — full LLD implementation with demo.
// Single-file Java translation of the Mermaid class diagram in section J.
import java.util.*;

public class p4 {

    // ---------- Value objects ----------

    /** Fixed-point money (cents) to avoid floating point drift in splits/settlements. */
    static final class Money implements Comparable<Money> {
        final long cents;
        private Money(long cents) { this.cents = cents; }
        static Money ofCents(long cents) { return new Money(cents); }
        static Money of(double dollars) { return new Money(Math.round(dollars * 100)); }
        static Money zero() { return new Money(0); }
        Money plus(Money o) { return new Money(this.cents + o.cents); }
        Money minus(Money o) { return new Money(this.cents - o.cents); }
        Money negate() { return new Money(-this.cents); }
        boolean isZero() { return cents == 0; }
        public int compareTo(Money o) { return Long.compare(this.cents, o.cents); }
        public String toString() { return String.format("$%.2f", cents / 100.0); }
        public boolean equals(Object o) { return o instanceof Money && ((Money) o).cents == cents; }
        public int hashCode() { return Long.hashCode(cents); }
    }

    enum SplitType { EQUAL, EXACT, PERCENT }

    // ---------- Core entities ----------

    static final class User {
        final String id, name, email;
        User(String id, String name, String email) { this.id = id; this.name = name; this.email = email; }
        public String toString() { return name; }
    }

    /** Aggregation: Group holds Users but does not own their lifecycle. */
    static final class Group {
        final String id;
        final List<User> members = new ArrayList<>();
        Group(String id) { this.id = id; }
        void addMember(User u) { members.add(u); }
    }

    /** Pure data: how much one participant owes for one expense. No computation. */
    static final class Split {
        final User user;
        final Money amount;
        Split(User user, Money amount) { this.user = user; this.amount = amount; }
    }

    /** Immutable record of one shared-cost event. Composes its Splits. */
    static final class Expense {
        final String id;
        final Money amount;
        final User paidBy;
        final List<Split> splits; // composition — splits have no meaning outside this expense
        final String groupId; // nullable
        final long timestamp;
        Expense(String id, Money amount, User paidBy, List<Split> splits, String groupId) {
            this.id = id; this.amount = amount; this.paidBy = paidBy;
            this.splits = List.copyOf(splits); this.groupId = groupId;
            this.timestamp = System.currentTimeMillis();
        }
    }

    /** Records a settle-up action. Sibling of Expense, not a subclass (see section I). */
    static final class Payment {
        final String id;
        final User from, to;
        final Money amount;
        final long timestamp;
        Payment(String id, User from, User to, Money amount) {
            this.id = id; this.from = from; this.to = to; this.amount = amount;
            this.timestamp = System.currentTimeMillis();
        }
    }

    // ---------- SplitStrategy hierarchy ----------

    interface SplitStrategy {
        List<Split> compute(Money total, List<User> participants, Map<String, Object> params);
    }

    static final class EqualSplitStrategy implements SplitStrategy {
        public List<Split> compute(Money total, List<User> participants, Map<String, Object> params) {
            int n = participants.size();
            long base = total.cents / n;
            long remainder = total.cents % n; // distribute leftover cents deterministically
            List<Split> splits = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                long cents = base + (i < remainder ? 1 : 0);
                splits.add(new Split(participants.get(i), Money.ofCents(cents)));
            }
            return splits;
        }
    }

    @SuppressWarnings("unchecked")
    static final class ExactSplitStrategy implements SplitStrategy {
        public List<Split> compute(Money total, List<User> participants, Map<String, Object> params) {
            Map<String, Money> amounts = (Map<String, Money>) params.get("amounts"); // userId -> exact amount
            List<Split> splits = new ArrayList<>();
            for (User u : participants) {
                Money amt = amounts.get(u.id);
                if (amt == null) throw new IllegalArgumentException("Missing exact amount for " + u.id);
                splits.add(new Split(u, amt));
            }
            return splits;
        }
    }

    @SuppressWarnings("unchecked")
    static final class PercentSplitStrategy implements SplitStrategy {
        public List<Split> compute(Money total, List<User> participants, Map<String, Object> params) {
            Map<String, Double> pct = (Map<String, Double>) params.get("percentages"); // userId -> % (sums to 100)
            List<Split> splits = new ArrayList<>();
            long allocated = 0;
            for (int i = 0; i < participants.size(); i++) {
                User u = participants.get(i);
                Double p = pct.get(u.id);
                if (p == null) throw new IllegalArgumentException("Missing percentage for " + u.id);
                long cents = (i == participants.size() - 1)
                        ? total.cents - allocated // last participant absorbs rounding remainder
                        : Math.round(total.cents * (p / 100.0));
                allocated += cents;
                splits.add(new Split(u, Money.ofCents(cents)));
            }
            return splits;
        }
    }

    // ---------- BalanceSheet ----------

    /** Single source of truth for who-owes-whom. Stores one signed net cents value per unordered pair. */
    static final class BalanceSheet {
        // key: "idA|idB" with idA < idB lexicographically; value > 0 means idA owes idB.
        private final Map<String, Long> pairwise = new HashMap<>();

        private String key(User a, User b) {
            return a.id.compareTo(b.id) < 0 ? a.id + "|" + b.id : b.id + "|" + a.id;
        }

        /** a owes b `amount` more (amount may be negative to reduce/reverse). */
        void adjust(User a, User b, Money amount) {
            String k = key(a, b);
            long signed = a.id.compareTo(b.id) < 0 ? amount.cents : -amount.cents;
            pairwise.merge(k, signed, Long::sum);
        }

        /** Positive => a owes b; negative => b owes a. */
        Money getBalance(User a, User b) {
            String k = key(a, b);
            long stored = pairwise.getOrDefault(k, 0L);
            long fromAPerspective = a.id.compareTo(b.id) < 0 ? stored : -stored;
            return Money.ofCents(fromAPerspective);
        }
    }

    // ---------- Idempotent payment log ----------

    static final class PaymentLog {
        private final Map<String, Payment> byKey = new HashMap<>();
        boolean seen(String idemKey) { return byKey.containsKey(idemKey); }
        Payment get(String idemKey) { return byKey.get(idemKey); }
        void record(String idemKey, Payment p) { byKey.put(idemKey, p); }
    }

    // ---------- ExpenseService (facade) ----------

    static final class ExpenseService {
        private final Map<SplitType, SplitStrategy> strategyFactory;
        private final BalanceSheet balanceSheet = new BalanceSheet();
        private final PaymentLog paymentLog = new PaymentLog();
        private final List<Expense> expenses = new ArrayList<>();
        private int expenseCounter = 0, paymentCounter = 0;

        ExpenseService(Map<SplitType, SplitStrategy> strategyFactory) {
            this.strategyFactory = strategyFactory;
        }

        Expense addExpense(Money total, User paidBy, List<User> participants,
                            SplitType type, Map<String, Object> params, Group group) {
            SplitStrategy strategy = strategyFactory.get(type);
            List<Split> splits = strategy.compute(total, participants, params);

            long sum = 0;
            for (Split s : splits) sum += s.amount.cents;
            if (sum != total.cents) throw new IllegalStateException("Split strategy produced mismatched total");

            Expense expense = new Expense("EXP-" + (++expenseCounter), total, paidBy, splits,
                    group != null ? group.id : null);
            for (Split s : splits) {
                if (!s.user.id.equals(paidBy.id)) {
                    balanceSheet.adjust(s.user, paidBy, s.amount); // s.user owes paidBy s.amount
                }
            }
            expenses.add(expense);
            return expense;
        }

        Payment settleUp(User from, User to, Money amount, String idempotencyKey) {
            if (paymentLog.seen(idempotencyKey)) return paymentLog.get(idempotencyKey); // idempotent replay
            balanceSheet.adjust(from, to, amount.negate()); // paying down reduces what `from` owes `to`
            Payment payment = new Payment("PAY-" + (++paymentCounter), from, to, amount);
            paymentLog.record(idempotencyKey, payment);
            return payment;
        }

        Money getBalance(User a, User b) { return balanceSheet.getBalance(a, b); }

        /** net(u) = sum over others of (-balance(u, other)); positive => u is owed money overall. */
        Map<User, Money> getNetBalances(Group group) {
            Map<User, Money> net = new LinkedHashMap<>();
            for (User u : group.members) {
                long total = 0;
                for (User other : group.members) {
                    if (other == u) continue;
                    total -= balanceSheet.getBalance(u, other).cents;
                }
                net.put(u, Money.ofCents(total));
            }
            return net;
        }
    }

    // ---------- Demo ----------

    public static void main(String[] args) {
        User alice = new User("u1", "Alice", "alice@mail.com");
        User bob = new User("u2", "Bob", "bob@mail.com");
        User carol = new User("u3", "Carol", "carol@mail.com");

        Group trip = new Group("g1");
        trip.addMember(alice);
        trip.addMember(bob);
        trip.addMember(carol);

        Map<SplitType, SplitStrategy> factory = new HashMap<>();
        factory.put(SplitType.EQUAL, new EqualSplitStrategy());
        factory.put(SplitType.EXACT, new ExactSplitStrategy());
        factory.put(SplitType.PERCENT, new PercentSplitStrategy());
        ExpenseService service = new ExpenseService(factory);

        // Alice pays $100 for dinner, split equally among the three.
        service.addExpense(Money.of(100.00), alice, List.of(alice, bob, carol),
                SplitType.EQUAL, Map.of(), trip);

        // Bob pays $60 for a cab, split by exact amounts.
        Map<String, Object> exactParams = new HashMap<>();
        exactParams.put("amounts", Map.of("u1", Money.of(20.00), "u2", Money.of(20.00), "u3", Money.of(20.00)));
        service.addExpense(Money.of(60.00), bob, List.of(alice, bob, carol),
                SplitType.EXACT, exactParams, trip);

        System.out.println("Alice owes Bob: " + service.getBalance(alice, bob));
        System.out.println("Carol owes Alice: " + service.getBalance(carol, alice));

        // Carol settles part of her debt to Alice.
        service.settleUp(carol, alice, Money.of(20.00), "idem-1");
        System.out.println("After settle, Carol owes Alice: " + service.getBalance(carol, alice));

        System.out.println("Net balances:");
        service.getNetBalances(trip).forEach((u, m) -> System.out.println("  " + u + ": " + m));
    }
}