Trip Split: Group Expenses, Currencies and Settle-Up
MediumAI-assisted coding · AI-Assisted Feature Work
Trip Split: Group Expenses, Currencies and Settle-Up
You've joined the team behind TripSplit, a Splitwise-style app for friends travelling together. One member pays for something (dinner, a rental car, a cabin) and the cost is divided between some or all of the group. The app keeps a running balance per member and tells the group who should pay whom at the end of the trip. Support is getting tickets about balances that are off by a few cents, and the roadmap has two features queued behind the fix. All money is integer cents; there is no floating-point money in the rules.
Codebase
| File | What it does |
|---|---|
tripsplit/api.py | run(operations) entry point and the Dispatcher that routes each operation |
tripsplit/ledger.py | GroupLedger: expenses, settle-up payments, running balances |
tripsplit/splits.py | Parses split specs (equal, shares, percent, exact) and turns them into cents |
tripsplit/members.py | MemberBook: who is in the group |
tripsplit/dates.py | Day parsing and day arithmetic ("YYYY-MM-DD" strings) |
tripsplit/rates.py | Placeholder for Part 2 (exchange rates) |
tripsplit/planner.py | Placeholder for Part 3 (settlement plans) |
tripsplit/errors.py | TripError and every error code |
tripsplit/config.py | Id prefixes, default currency, limits |
tests/ | Existing unit and end-to-end tests (one of them currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh group and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <message>" strings, never raised. A failed operation changes nothing: no balance moves and no id is used up.
| Operation | Arguments | Returns |
|---|---|---|
add_member | name | true |
add_expense | date, payer, amount, split | expense id ("x-1", "x-2", ...) |
add_recurring | first_date, every_days, count, payer, amount, split | [first_id, last_id] |
get_expense | expense_id | an expense record (see below) |
settle | date, from_member, to_member, amount | settlement id ("s-1", "s-2", ...) |
balances | none | [[name, cents], ...] sorted by name |
dateis a real calendar day written"YYYY-MM-DD"(so"2025-02-30"is invalid).- An
amountis an integer> 0(booleans are not amounts). - A split is
[kind, body]. The participants are listed in an order you must preserve: ["equal", ["ana", "ben", "caro"]]: everyone listed pays the same.["shares", [["ana", 2], ["ben", 1]]]: weights, each an integer> 0.["percent", [["ana", 60], ["ben", 40]]]and["exact", [["ana", 1250], ["ben", 800]]]are described in Part 1.- The payer does not have to be a participant. A participant may appear only once.
- Expense ids count up in the order expenses are accepted,
add_recurringincluded. Settlements have their own counter. balanceslists every member, including those at 0. A positive number means the group owes that member money; a negative number means they owe the group. Across all members the numbers always sum to 0.- A paid expense credits the payer with its full amount and charges each participant their share.
settlemeans *from_member really paid to_member* outside the app: from_member's balance goes up byamountand to_member's goes down byamount. add_recurringaddscountcopies of one expense onfirst_date,first_date + every_days, and so on (every_daysis an integer 1 to 366,countan integer 1 to 10 000). Each copy is an ordinary expense with its own id. If any copy would be rejected, nothing is added and no ids are used.get_expensereturns{"id", "date", "payer", "currency", "amount", "home_amount", "shares"}wheresharesis[[name, cents], ...]for every participant, in listed order (a participant can have a share of 0).currencyis"USD"until Part 2;home_amountequalsamountuntil Part 2.
Errors, in the order they are checked (the first failure wins):
| Operation | Checks |
|---|---|
| any | unknown operation name: error: unknown operation; wrong number of arguments: error: invalid arguments |
add_member | error: invalid name (empty/blank/non-string), error: member exists |
add_expense | error: invalid date; error: unknown member: <payer>; error: invalid amount; error: invalid split (shape is wrong, body is empty, or a weight is not an integer > 0); then the participants in listed order: error: unknown member: <name> or error: duplicate participant: <name>; then the split-sum errors from Part 1 |
add_recurring | error: invalid interval, error: invalid count, then the first copy's add_expense checks, then later copies (a date past year 9999 is error: invalid date) |
settle | error: invalid date; error: unknown member: <from>; error: unknown member: <to>; error: same member; error: invalid amount |
get_expense | error: expense not found |
Worked example
[["add_member", "ana"], ["add_member", "ben"], ["add_member", "caro"],
["add_expense", "2025-06-01", "ana", 9000, ["equal", ["ana", "ben", "caro"]]],
["add_expense", "2025-06-02", "ben", 3000, ["shares", [["ana", 1], ["caro", 2]]]],
["settle", "2025-06-03", "caro", "ana", 2000],
["balances"]]returns
[true, true, true, "x-1", "x-2", "s-1", [["ana", 3000], ["ben", 0], ["caro", -3000]]]Ana paid 9000 and owes 3000 of it (+6000); Ben and Caro owe 3000 each. Then Ben paid 3000 split 1:2 between Ana and Caro (Ana owes 1000, Caro 2000). Caro then pays Ana 2000 in cash.
Part 1 — Cents that don't add up (SPLIT-114, SPLIT-121)
Goal: every accepted expense is divided so its shares add up to exactly the total, split types percent and exact work, and rejected expenses leave no trace.
Tickets:
- "Our balances drift. The group's numbers don't sum to zero after dinner for 3." (equal and shares splits lose or invent cents.)
- "Percent and exact splits say *not supported*."
- "I mistyped a participant and the expense was refused, but my balance still went up and the next id skipped a number."
Rules
- Remainder rule (all split kinds). Each participant's share is
total * weight // sum_of_weights(rounded down). The cents left over (always fewer than the number of participants) go one cent each to the first participants in listed order. The payer gets no special treatment, even when the payer is the last one listed or is not a participant. Forequal, every weight is 1. percent: the weights are integer percentages> 0and must sum to exactly 100, otherwiseerror: percentages must sum to 100. Shares follow the remainder rule with the percentages as weights.exact: the weights are the amounts in cents (integers> 0) and must sum to exactly the expense amount, otherwiseerror: exact amounts must sum to the total. Each participant owes exactly their amount.- A rejected expense changes nothing and does not use up an expense id. This holds for
add_recurringtoo. balancesis sorted by member name (plain string order).
Example
[["add_member", "ana"], ["add_member", "ben"], ["add_member", "caro"],
["add_expense", "2025-06-05", "ben", 1000, ["equal", ["ana", "ben", "caro"]]],
["add_expense", "2025-06-05", "ana", 1001, ["percent", [["ana", 50], ["ben", 30], ["caro", 20]]]],
["add_expense", "2025-06-06", "caro", 1000, ["exact", [["ana", 600], ["ben", 300]]]],
["add_expense", "2025-06-06", "caro", 1000, ["exact", [["ana", 600], ["ben", 400]]]],
["get_expense", "x-1"],
["balances"]]returns
[true, true, true, "x-1", "x-2", "error: exact amounts must sum to the total", "x-3",
{"id": "x-1", "date": "2025-06-05", "payer": "ben", "currency": "USD", "amount": 1000, "home_amount": 1000,
"shares": [["ana", 334], ["ben", 333], ["caro", 333]]},
[["ana", -434], ["ben", -33], ["caro", 467]]](1000 splits 334/333/333: the one leftover cent goes to Ana, the first listed. 1001 at 50/30/20 floors to 500/300/200 and the leftover cent goes to Ana, giving 501. The rejected exact expense did not use "x-3".)
Part 2 — Trips abroad: multi-currency (SPLIT-140)
Goal: expenses can be paid in a foreign currency and are converted to the group's home currency at the exchange rate in effect on the expense's date. Balances, shares and settlements are always in home cents.
New operations:
| Operation | Arguments | Returns |
|---|---|---|
set_home_currency | code | true |
set_rate | date, currency, rate | true |
add_expense, add_recurring accept an optional last argument currency (a code like "EUR"). Omitted means the home currency. All currencies have two decimal places, so every amount is in minor units (cents).
Rules
- A currency code is exactly three uppercase ASCII letters, otherwise
error: invalid currency. The home currency starts as"USD". set_rate(date, currency, rate)records that ondateand later, 1 unit ofcurrency=ratemillionths of a home unit (rateis an integer> 0;1084200means 1.0842). A secondset_ratefor the same currency and the same date replaces the first. Rates may be entered in any order and for past dates. Checks in order:error: invalid date,error: invalid currency,error: home currency has no rate(whencurrencyis the home currency),error: invalid rate.- An expense dated
Din currencyC(not the home currency) uses the rate ofCwith the latest date<= D(a rate dated exactlyDcounts). If there is none,error: no rate for C on D(for exampleerror: no rate for EUR on 2025-02-28). This check comes after all the checks in Part 1, anderror: invalid currencycomes right aftererror: invalid amount. - Conversion.
home_amount = (amount * rate + 500000) // 1000000(round half up). The total is converted once and the converted total is then divided with the Part 1 remainder rule. Forpercentandsharesthe weights are as given. Forexactthe amounts must still add up to the amount as entered (in the expense's currency); the converted total is then divided using the exact amounts as weights. In the home currencyexacttherefore gives every participant exactly their amount, as before. - The conversion is fixed at the moment the expense is accepted: a rate added later, even one dated on or before the expense, does not change expenses that already exist.
get_expensereports the enteredcurrencyandamountand the convertedhome_amount.set_home_currency(code)fails witherror: invalid currencyfor a bad code, and witherror: home currency lockedonce any expense has ever been accepted (even if later removed) or any rate has been set.settleis always in home cents.
Example
[["add_member", "ana"], ["add_member", "ben"],
["set_rate", "2025-03-01", "EUR", 1084200], ["set_rate", "2025-03-10", "EUR", 1100000],
["add_expense", "2025-03-05", "ana", 4999, ["equal", ["ana", "ben"]], "EUR"],
["add_expense", "2025-03-10", "ben", 1000, ["exact", [["ana", 300], ["ben", 700]]], "EUR"],
["add_expense", "2025-02-28", "ben", 1000, ["equal", ["ana", "ben"]], "EUR"],
["get_expense", "x-1"],
["balances"]]returns
[true, true, true, true, "x-1", "x-2", "error: no rate for EUR on 2025-02-28",
{"id": "x-1", "date": "2025-03-05", "payer": "ana", "currency": "EUR", "amount": 4999, "home_amount": 5420,
"shares": [["ana", 2710], ["ben", 2710]]},
[["ana", 2380], ["ben", -2380]]](4999 x 1.0842 = 5419.9158, which rounds to 5420. The second expense converts to 1100 at the rate dated 2025-03-10 and splits 330 / 770 by the exact weights.)
Part 3 — Fixing mistakes and settling up (SPLIT-163)
Goal: members can correct or delete an old expense and the balances follow, and the app can propose a short list of payments that settles the group.
Until now the ledger was append-only. Support wants edits and deletes, and the trip organisers want an end-of-trip "who pays whom" list instead of everyone settling with everyone.
New operations:
| Operation | Arguments | Returns |
|---|---|---|
edit_expense | expense_id, date, payer, amount, split (+ optional currency) | true |
delete_expense | expense_id | true |
settlement_plan | none | [[debtor, creditor, cents], ...] |
Rules
edit_expensereplaces the expense with the given values as if it were being entered now: same checks, same order asadd_expense(aftererror: expense not found, which is checked first), and the conversion uses the rate table as it is *now*, including any rates added since the expense was first entered. The id is unchanged. If the edit is rejected, the expense is exactly as it was.delete_expenseremoves the expense and exactly reverses the effect it had on the balances (its stored shares, not a recomputation). A deleted id is never reused (get_expenseand later edits/deletes of it sayerror: expense not found). Settlements recorded withsettleare not affected by edits or deletes.- Settlement plan. Start from the current balances (settlements included). Repeat while someone still owes: take the member who owes the most (ties: the name that sorts first) and the member who is owed the most (same tie rule), add the payment
[debtor, creditor, min(owed, due)]to the plan, and reduce both balances by that amount. Members at 0 never appear, an empty plan is[], and the plan is always computed from the balances at the moment of the call, so it reflects every add, edit, delete andsettlemade so far. - Everything from Parts 1 and 2 still applies. Groups with thousands of expenses (recurring rent, daily coffee) call
balances,settlement_plan, edits and deletes many times: do not rescan or rebuild everything per call.
Example
[["add_member", "ana"], ["add_member", "ben"], ["add_member", "caro"], ["add_member", "dan"],
["add_expense", "2025-07-01", "ana", 12000, ["equal", ["ana", "ben", "caro", "dan"]]],
["add_expense", "2025-07-02", "ben", 4000, ["equal", ["ben", "caro"]]],
["add_expense", "2025-07-03", "dan", 6000, ["equal", ["ana", "ben", "caro", "dan"]]],
["settlement_plan"],
["delete_expense", "x-3"],
["settlement_plan"],
["settle", "2025-07-04", "caro", "ana", 5000],
["settlement_plan"]]returns
[true, true, true, true, "x-1", "x-2", "x-3",
[["caro", "ana", 6500], ["ben", "dan", 1500], ["ben", "ana", 1000]],
true,
[["caro", "ana", 5000], ["dan", "ana", 3000], ["ben", "ana", 1000]],
"s-1",
[["dan", "ana", 3000], ["ben", "ana", 1000]]](Balances before the delete: ana +7500, ben -2500, caro -6500, dan +1500. Caro pays Ana first; Ana still has 1000 coming, and Dan (1500) is now the biggest creditor, so Ben pays Dan 1500 and then Ana the last 1000.)
Example 1
- Input:
- [["add_member","ana"],["add_member","ben"],["add_member","caro"],["add_expense","2025-06-01","ana",9000,["equal",["ana","ben","caro"]]],["add_expense","2025-06-02","ben",3000,["shares",[["ana",1],["caro",2]]]],["settle","2025-06-03","caro","ana",2000],["balances"]]
- Output:
- [true,true,true,"x-1","x-2","s-1",[["ana",3000],["ben",0],["caro",-3000]]]
- Explanation
- Ana pays 9000 split three ways, Ben pays 3000 split 1:2 between Ana and Caro, and Caro hands Ana 2000 in cash.
Example 2
- Input:
- [add ana and ben, ["set_rate","2025-03-01","EUR",1084200], ["add_expense","2025-03-05","ana",4999,["equal",["ana","ben"]],"EUR"], ["get_expense","x-1"]]
- Output:
- {"id":"x-1","date":"2025-03-05","payer":"ana","currency":"EUR","amount":4999,"home_amount":5420,"shares":[["ana",2710],["ben",2710]]}
- Explanation
- The expense converts at the 2025-03-01 rate (the latest on or before 03-05): 4999 x 1.0842 = 5419.9158, rounded to 5420, then split equally.
Example 3
- Input:
- [add four members, ...three expenses..., ["settlement_plan"]]
- Output:
- [["caro","ana",6500],["ben","dan",1500],["ben","ana",1000]]
- Explanation
- Greedy plan: the biggest debtor pays the biggest creditor, ties by name, until everyone is square.
Guidelines
- Python 3 standard library only
- All money is integer cents; rates are integer millionths; there is no floating-point money anywhere
- Every operation returns exactly one value; errors are returned as "error: <message>" strings, never raised
- A rejected operation changes nothing and uses up no id
- Part 3 inputs contain tens of thousands of expenses created by a few add_recurring operations