How do you split a bill so the cents actually add up?
A 66.69 EUR dinner kept showing one balance on one screen and a different one, off by a single cent, on another. Here's why splitting money proportionally quietly breaks, and the small rule that fixes it: pick one person and hand them the leftover cent.
Here's a question that sounds trivial until you try to answer it in code: how do you split a 66.69 EUR dinner between two people so the two halves add back up to exactly 66.69? I built a bill-splitting app called Even Steven, and for months it got this wrong. The groups list showed one balance, and the balances tab showed a different one, off by a single cent. Both numbers came from my own code. They just disagreed.
The short answer is that the obvious way to split money, give everyone their proportional share and round each one, almost works. The interesting part is the word almost. The fix turned out to be one rule applied everywhere: pick one person, floor everyone else's share, and give that one person whatever cent is left over.
Why does proportional splitting break?
The natural way to compute someone's share is proportional. Take what they owe, work out their fraction of the total, multiply, and round to two decimals. It's the version every tutorial reaches for, and it's correct most of the time. Split 100 EUR three ways and you get 33.33, 33.33, and 33.34. The sum is 100. Everyone's happy.
Now split 66.69 EUR two ways. Each person owes 33.345. Round each one independently and they both become 33.35, so the sum is 66.70 instead of 66.69. You've invented a cent out of nowhere. Think of it like cutting a sandwich in half with a slightly thick knife: if both halves keep the crumb on the cut, you end up with more sandwich than you started with.
A single cent sounds harmless. It wasn't, because Even Steven is multi-currency. An expense gets entered in whatever currency you paid in, then converted to the group's base currency at the day's rate. That conversion rounds. The database column NUMERIC(12, 2) rounds again. A trigger that keeps running balances up to date rounds a fourth time. Four independent rounding steps, and any two of them can round the same direction at once. That's how a one-cent gap ends up frozen into a real debt that one screen reports and another doesn't.
Why doesn't a smarter rounding mode fix it?
My first instinct was bankers' rounding: round half to even instead of half away from zero. That's a real technique, and it helps when you round once. I was rounding four times. No rounding mode removes the basic problem, which is that two values can still round the same direction. You don't beat that by picking a cleverer direction. You beat it by not rounding each share on its own in the first place.
My second instinct was integer cents: multiply everything by 100, do the math in whole numbers, divide by 100 to display. Good advice, and I kept it for single-currency math. But it doesn't save the multi-currency case, because converting between currencies is still a division, and division still leaves a remainder. You can chase that remainder from cents to fractions of a cent forever. At some point money just isn't divisible by three, and somebody has to absorb the dust.
So what actually fixes it?
The fix is to stop treating everyone symmetrically. Instead of computing each share the same way and hoping they add up, you compute everyone's share except one person, then give that last person the total minus the sum of the rest. They get whatever's left, exactly, by definition. In Even Steven that person is the payer, since they fronted the money and their claim is the one that should carry the odd cent.
Here's the equal-split version. It's the simplest one, and every other split mode in the app is a variation on it.
const round2 = (n: number) => Math.round(n * 100) / 100;
const floor2 = (n: number) => Math.floor(n * 100) / 100;
// Equal split: every non-payer rounded DOWN; payer absorbs the remainder.
export function calculateEqualSplit(
totalAmount: number,
participantIds: string[],
payerId: string
): Split[] {
const n = participantIds.length;
const baseShare = floor2(totalAmount / n);
const nonPayerCount = participantIds.filter((id) => id !== payerId).length;
const othersTotal = round2(baseShare * nonPayerCount);
const payerShare = round2(totalAmount - othersTotal);
return participantIds.map((memberId) => ({
memberId,
share: memberId === payerId ? payerShare : baseShare,
}));
}Two lines carry the whole idea. floor2 rounds the non-payers' shares down, so they can never collectively grab more than their fair slice. Then the payer's share isn't computed at all, it's totalAmount - othersTotal. A 10 EUR three-way split becomes 3.33, 3.33, and 3.34 for the payer. Sum is exactly 10.00. Whatever the rounding does to the others, the payer's number quietly stretches to close the gap.
What did the multi-currency version look like?
The function that actually caused the bug does the same thing, just for converted amounts. It takes each person's local-currency share, converts it to the base currency, and instead of rounding each result on its own, it floors the non-payers and lets the payer soak up the rest so the converted shares add back to the converted total exactly.
// Floor non-payer base shares, give the payer the remainder, so
// sum(baseShares) === baseCurrencyAmount exactly.
export function computeBaseShares(
splits: Split[],
localAmount: number,
baseCurrencyAmount: number,
payerId: string,
): (Split & { baseShare: number })[] {
if (localAmount === 0) {
return splits.map((s) => ({ ...s, baseShare: s.share }));
}
const nonPayers = splits.filter((s) => s.memberId !== payerId);
const nonPayerBases = nonPayers.map((s) => ({
memberId: s.memberId,
baseShare: floor2((s.share / localAmount) * baseCurrencyAmount),
}));
const othersTotal = round2(nonPayerBases.reduce((acc, s) => acc + s.baseShare, 0));
const payerBase = round2(baseCurrencyAmount - othersTotal);
return splits.map((s) => ({
...s,
baseShare:
s.memberId === payerId
? payerBase
: nonPayerBases.find((b) => b.memberId === s.memberId)!.baseShare,
}));
}It's the same shape as the equal split: filter out the payer, floor everyone else, sum them, hand the payer baseCurrencyAmount - othersTotal. The one guard worth pointing at is localAmount === 0. Settlements (one person paying another back) have no currency conversion, so they pass through with a zero local amount. Without that guard, dividing by zero gives you NaN, and a NaN sitting in a balance column is a genuinely bad afternoon.
There was one more place the cent could hide. My Postgres trigger recomputes everyone's balance on every change, and it can introduce its own rounding residual even when every row is perfect. So it does the same trick: if the active members' balances don't sum to zero, it absorbs the leftover into the member with the largest absolute balance. The database picks a different person than the client (it can't see who the payer was by then), but the rule is identical. Name one actor, give them the residual.
Things that surprised me
The asymmetric formula feels unfair and is the fair one. Treating everyone identically is what invents the phantom cent. Picking one person to carry it is what makes the totals honest.
The bug looked green for weeks at a time. The two rounding paths agreed on almost every input. Only amounts ending in x.xx5 broke them apart, where a JavaScript float is secretly 0.xx4999... and rounds down while Postgres rounds up.
Integer cents help and aren't enough. They fix single-currency math cleanly, but a currency conversion is a division, and division always leaves a remainder somewhere.
Deleting code was part of the fix. I had a second balance calculation in TypeScript doing the same math as the database. Two calculations means two chances to disagree, so I deleted the JavaScript one and made it a plain read of the stored balance. One source of truth, no argument.
My honest take
If you're building anything that divides money among people, this is worth caring about from the very first split function, not after a real dinner bill exposes it five days into debugging. Pick the actor who should hold the leftover cent (the payer, the merchant, the biggest balance, whoever makes sense), floor everyone else, and give that actor total minus the rest. Mirror the same rule anywhere the math runs again, including the database. If you're splitting whole units that never round, you can ignore all of this. The moment cents enter the picture, fair isn't the formula that treats everyone the same. It's the one that adds up to the total.


