Splitwise
By the Syncfusion DevTutorials team · Last updated
Design an expense splitter: equal, exact and percent splits in whole rupees, net balances, and settling up with few payments.
Five friends go on a trip and share several expenses. One person pays for the hotel, another pays for dinner, and someone else pays for the taxi. By the end of the trip, it becomes difficult to remember how much each person paid and who owes money.
An expense-sharing system solves this problem by recording shared expenses and calculating each person’s final balance.
In simple words: you will combine many shared expenses into one final balance for each person, then calculate the payments needed to settle the group.
What You Will Learn
In this case study, you will:
- Keep one net balance per person instead of every debt.
- Split bills equally, exactly or by percent, without losing a rupee.
- Refuse splits that do not add up.
- Settle all debts with a simple greedy rule.
- Build the ledger in the sandbox and pass the hidden tests.
The big idea is to simplify the data. Many bills look complicated, but one net number per person is all you need to settle up.
Step 1: Agree on the Requirements
Before writing code, confirm what the expense-sharing system must do.
What It Must Do
- Add an expense using
addExpense(paidBy, amount, split). - Support equal, exact, and percentage-based splits.
- Use whole rupees and ensure that every split adds up to the full expense amount.
- Use
balances()to show how much each person should receive or pay. - Leave people with a zero balance out of the result.
- Use
settleUp()to calculate the payments needed to bring every balance to zero. - Cancel debts that balance each other, including circular debts.
A positive balance means that the person should receive money. A negative balance means that the person owes money.
What We Leave Out
- Groups, currencies and receipts.
- The very fewest payments in every case (that problem is very hard; greedy is close and simple).
- Editing or deleting an expense.
Leaving things out on purpose is not lazy. It keeps the design small enough to finish, and you can say how you would add them later.
Step 2: Find the Classes
A simple trick: underline the nouns in the requirements. Most of them become classes. The verbs become methods.
| Class | Its one job |
|---|---|
| Ledger | Records expenses and keeps one net balance per person |
| Split | Says how one bill is shared: equal, exact or percent |
| shares() | Turns an amount and a split into whole-rupee shares |
The class diagram for this design.
Step 3: Build the Key Parts
Keep One Number per Person
The system could store every individual debt separately, such as “Ravi owes Asha 300”. However, as more expenses are added, this creates many records that are difficult to settle.
A simpler approach is to keep one net balance for each person:
Net balance = total amount paid − total share owed
- A positive balance means the person should receive money.
- A negative balance means the person owes money.
- A zero balance means the person has paid exactly their share.
For example, if Asha pays 900 for a bill shared equally among Asha, Ravi, and Meena, each person’s share is 300:
- Asha’s balance is 900 - 300 = 600.
- Ravi’s balance is 0 - 300 = -300.
- Meena’s balance is 0 - 300 = -300.
The balances add up to zero: 600 + (-300) + (-300) = 0.
Step through the dinner bill:
Here is the same idea in code:
Only the net balance per person matters
const net = new Map<string, number>();
function add(p: string, v: number): void { net.set(p, (net.get(p) ?? 0) + v); }
// Asha paid 900 for dinner for three.
add("Asha", 900);
for (const p of ["Asha", "Ravi", "Meena"]) add(p, -300);
console.log(Object.fromEntries(net));
Expected output:
{
"Asha": 600,
"Ravi": -300,
"Meena": -300
}
Splitting Without Losing a Rupee
100 split three ways is 33.33. Rupee amounts must be whole, so give the leftover rupee to someone.
The figure shows how 100 is shared between Asha, Ravi and Meena:
Each person gets 33, and the leftover rupee goes to Asha: 34 + 33 + 33 = 100.
Here is the same idea in code:
An equal split that loses no rupee
function equalShares(amount: number, people: string[]): Record<string, number> {
const base = Math.floor(amount / people.length);
let extra = amount - base * people.length; // leftover rupees
const out: Record<string, number> = {};
for (const p of people) {
out[p] = base + (extra > 0 ? 1 : 0);
if (extra > 0) extra--;
}
return out;
}
const s = equalShares(100, ["Asha", "Ravi", "Meena"]);
console.log(s);
console.log("adds up to", Object.values(s).reduce((a, b) => a + b, 0));
Expected output:
{
"Asha": 34,
"Ravi": 33,
"Meena": 33
}
adds up to 100
Never store money as fractions like 33.333. Rounding each share separately can lose or invent a rupee, and then the balances no longer add up to zero. Use whole rupees (or paise) and hand out the leftover on purpose.
Three Kinds of Split
Bills are split equally, by exact amounts, or by percent. A TypeScript union type lists the three shapes, and TypeScript checks that each one is handled.
| Kind | What it holds | Example |
|---|---|---|
equal | a list of people | equal between 2 people (Asha, Ravi) |
exact | an amount for each person | exact: Asha 200, Ravi 300 |
percent | a percent for each person | percent: Asha 60%, Ravi 40% |
Here is the code:
Three ways to split, behind one function
type Split =
| { kind: "equal"; among: string[] }
| { kind: "exact"; amounts: Record<string, number> }
| { kind: "percent"; percents: Record<string, number> };
function describe(split: Split): string {
switch (split.kind) {
case "equal": return `equal between ${split.among.length} people`;
case "exact": return `exact: ${Object.keys(split.amounts).join(", ")}`;
case "percent": return `percent: ${Object.entries(split.percents).map(([p, v]) => p + " " + v + "%").join(", ")}`;
}
}
console.log(describe({ kind: "equal", among: ["Asha", "Ravi"] }));
console.log(describe({ kind: "percent", percents: { Asha: 60, Ravi: 40 } }));
Expected output:
equal between 2 people
percent: Asha 60%, Ravi 40%
The switch here is on a tagged union, which TypeScript checks for missing cases. That is fine for three fixed shapes. If new split kinds kept arriving, each one would become a strategy class.
Check Before You Accept
An exact split must add up to the bill. Step through two splits of a 500 bill:
| Split | Result |
|---|---|
| Asha 200, Ravi 300 | ok |
| Asha 200, Ravi 200 | refused: shares add up to 400, but the bill is 500 |
Here is the same idea in code:
Check the split before accepting it
function checkExact(amount: number, amounts: Record<string, number>): string {
const sum = Object.values(amounts).reduce((a, b) => a + b, 0);
return sum === amount ? "ok" : `shares add up to ${sum}, but the bill is ${amount}`;
}
console.log(checkExact(500, { Asha: 200, Ravi: 300 }));
console.log(checkExact(500, { Asha: 200, Ravi: 200 }));
Expected output:
ok
shares add up to 400, but the bill is 500
Settling Up
The greedy rule: the person who owes the most pays the person who should receive the most. Transfer as much as possible, update both balances, and repeat until every balance becomes zero.
Step through the balances Asha +500, Ravi −100, Meena −250 and Kiran −150:
The payments are: Meena pays Asha 250, Kiran pays Asha 150, Ravi pays Asha 100.
Here is the same idea in code:
Settle up: biggest debtor pays biggest creditor
function settle(balances: Record<string, number>): string[] {
const owe = Object.entries(balances).filter(([, v]) => v < 0).map(([p, v]) => ({ p, v: -v }));
const get = Object.entries(balances).filter(([, v]) => v > 0).map(([p, v]) => ({ p, v }));
const pay: string[] = [];
while (owe.length && get.length) {
owe.sort((a, b) => b.v - a.v);
get.sort((a, b) => b.v - a.v);
const x = Math.min(owe[0].v, get[0].v);
pay.push(`${owe[0].p} pays ${get[0].p} ${x}`);
owe[0].v -= x;
get[0].v -= x;
if (owe[0].v === 0) owe.shift();
if (get[0].v === 0) get.shift();
}
return pay;
}
for (const line of settle({ Asha: 500, Ravi: -100, Meena: -250, Kiran: -150 })) console.log(line);
Expected output:
Meena pays Asha 250
Kiran pays Asha 150
Ravi pays Asha 100
Four people needed only three payments, not one per bill.
Easy way to remember: forget the bills. Add up one net number per person. Then the biggest debtor pays the biggest creditor until everyone is at zero.
Lab: Build It
Build the Ledger. The hidden tests add equal, exact and percent bills, check the balances, check that bad splits are refused, and check the settle-up payments.
Predict the Output
Common Mistakes
| Mistake | Wrong | Correct | Why it happens |
|---|---|---|---|
| Storing every debt | A list of who owes whom for each bill. | One net balance per person. | It mirrors how people talk about bills. |
| Fractions of rupees | Shares of 33.333. | Whole rupees; hand out the leftover. | Division gives fractions by default. |
| Unchecked splits | Exact amounts that add up to 490 for a 500 bill. | Refuse the expense. | Users make typos; tests rarely do. |
| Settling per bill | One payment for every expense. | Net first, then settle. | It feels fairer to repay each bill. |
| Order-dependent output | Payments come out in a different order each run. | Sort ties by name. | Map order depends on insertion order. |
Practice
What to Remember
- Keep one net number per person.
- Balances always add up to zero.
- Use whole rupees and give out the leftover on purpose.
- Refuse splits that do not add up.
- Settle up: biggest debtor pays biggest creditor.
Further Reading
These are the sources used to check this case study:
- Splitwise: How simplify debts works: the idea behind simplifying.
- TypeScript Handbook: Discriminated unions: the Split type.
- MDN: Math.floor: whole-rupee shares.
Next: Library System. You will build lending, due dates, fines and a waiting list.