BillshareWhat this is

The cent that has to go somewhere

Three people share a restaurant bill. Anna had a salad, Ben had a ribeye, and Ben and Cleo split a bottle of wine that Anna never touched. There is tax on the bill and a tip on top. Somebody opens an app, taps a few times, and everyone pays what they owe.

Almost everything in that sentence is arithmetic, and almost all of it is the kind that looks trivial until you write it down.

This is what we found building Billshare, a bill splitter that works in any currency and never moves any money. The parts worth writing about are the ones where the obvious implementation is quietly wrong.

Money is an integer or it is a bug

The first rule, and the only one with no interesting exceptions: an amount is an integer count of minor units, and it carries its currency with it.

// fourteen dollars
{ amountMinor: 1400, currency: "USD" }

// one thousand four hundred yen
{ amountMinor: 1400, currency: "JPY" }

// one dinar, four hundred fils
{ amountMinor: 1400, currency: "KWD" }

A float looks fine right up until it is not, and the failures are the kind that reach a person rather than a log. 0.1 + 0.2 is famously not 0.3, and a bill total assembled that way can be a hundredth off with nothing in the code that looks wrong.

There is exactly one float between a receipt and what anybody owes. An exchange rate arrives from a public API as a JSON number, and it is turned into a decimal string once, at that boundary, before anything in the money layer sees it. That is the same treatment a rate somebody typed gets. Everywhere else, integers.

Two is not the number of decimal places

Here is the assumption that costs the most. Most currencies divide into 100, so most code divides by 100, and then somebody in Tokyo is charged a hundred times too much for a coffee.

ISO 4217 assigns each currency a minor unit exponent. Counting the table we ship:

  • 140 currencies use 2 decimals. Dollars, euros, pounds, most of the world.
  • 20 use none. Yen, won, Icelandic krona, Chilean peso, Vietnamese dong, the CFA francs. There is no such thing as half a yen.
  • 7 use three. Bahraini, Iraqi, Jordanian, Kuwaiti, Libyan, Omani and Tunisian dinars. One Kuwaiti dinar is a thousand fils.
  • 2 use four. The Chilean unit of account and a Uruguayan wage index unit.

That is 169 codes, and the spread is the point. A rounding helper that assumes two decimals is wrong for 29 of them, and it is wrong in both directions: it invents fractions of a yen that cannot be paid, and it throws away two digits of a dinar that can.

Two small decisions follow. An unknown currency code throws rather than defaulting to 2, because a default here is a silent hundredfold error and an exception is a bug report. And the lookup table is built with a null prototype, so a currency code of "toString" or "constructor" cannot come back with something that looks like an exponent.

The leftover unit has to land on somebody

Split 10.00 three ways. Each person owes 3.3333, which does not exist. Round each share and you get 3.33 three times, which is 9.99, and a cent has gone missing from a bill that was paid in full.

You cannot round the parts. You have to allocate the whole.

The method is largest remainder. Give everyone the floor of their exact share, count how many minor units are left over, and hand those out one at a time to whoever has the largest fractional remainder. Ties break on input order.

allocate(1000, [1, 1, 1]) // [334, 333, 333]

Three properties matter, and each is a test rather than a comment:

It sums to the total. Always, for any weights, for any currency. This is the invariant everything else is built on.

It is deterministic. The same input gives the same output, which is what stops two people looking at the same bill from seeing different numbers. That is also why ties break on input order rather than on anything clever.

A zero weight recipient never receives a unit. Somebody who ordered nothing owes nothing, even when there is a cent looking for a home. This falls out of the method rather than being special cased: only entries with a non zero remainder can take a leftover unit, and there are always strictly more of those than there are units to hand out.

The intermediate arithmetic runs in BigInt. The products involved are small for a dinner, and they are not small for a group trip with a weak currency, and a silent overflow in the step that guarantees exactness would be a poor joke.

Negative totals go through the same function. A discount is allocated by magnitude and then negated, so money coming off is distributed exactly the way the charge it reverses was distributed.

Tax and tip are not split equally

This is the one that changes what people pay, and it is the one most tables get wrong at the table, before any software is involved.

Anna had a 14.00 salad. Ben had a 38.00 ribeye and half of a 52.00 bottle of wine. Cleo had 19.00 of pasta and the other half of the bottle. Tax is 10.90 and the tip is 20.00. The bill is 153.90.

Split it evenly and everybody pays 51.30. Anna is buying wine she did not drink.

So you split by item. But then there is 30.90 of tax and tip left over that nobody ordered, and it has to be divided somehow. Here is the same dinner three ways:

by item, extras in proportionby item, extras split equallyevenly
Anna17.5224.3051.30
Ben80.0874.3051.30
Cleo56.3055.3051.30

Dividing the extras equally costs Anna 6.78 for tax and tip on food she did not order. She ordered about a ninth of the food by value and would be paying a third of the tax on it.

Spreading them in proportion to what each person actually ordered keeps one rule for the whole bill. Every column above sums to 153.90 exactly, and every figure in it came out of the same function the app runs, not out of a spreadsheet written for the article.

A receipt that does not add up is telling you something

Photograph a receipt, read the lines off it, sum them, and compare to the printed total. They disagree more often than you would like.

The rule we settled on is that the printed total is the truth and the app never quietly edits a number to make the arithmetic work. When the lines do not reach the total, the difference becomes its own line, called "Not on any line", which belongs to nobody until somebody claims it. When the lines overshoot, that line is negative, which is equally honest: it says something has been counted twice.

There is one exception, and it is worth the exception. If the lines overshoot the total by exactly what the tax lines come to, then the tax was already inside the prices and got transcribed as something more to pay. In that case the tax lines are dropped and the amount is reported. All of the tax or none of it, never some other combination, because a rule that matches part of a total is a rule that will one day match by accident.

That case is not hypothetical. On a labelled Portuguese receipt carrying three different IVA rates, the model doing the extraction got that arithmetic wrong about one run in six. It is exact, and it is integers, so it belongs in code rather than in a prompt.

The distinction is not cosmetic either. Tax gets spread across everyone in proportion to what they ate. An unassigned line is spread across nobody and waits. Naming the difference correctly is what keeps the tax behaving like tax.

Settling up is a small graph problem

At the end of a trip everyone has a balance, positive or negative, and the balances sum to zero. Turning that into payments is a choice about how many people have to open their banking app.

The method is greedy: whoever owes the most pays whoever is owed the most, repeatedly. Each step fully clears at least one of the two, so the result never needs more than n-1 transfers for n people. Ties break on member id, so shuffling the input cannot change the answer, and two people looking at the same trip get the same suggestion.

Before any of that runs, the ledger is checked to sum to zero, and it throws if it does not. A settlement algorithm handed an unbalanced ledger will happily produce plausible looking payments that leave somebody short.

Testing arithmetic you cannot eyeball

Example based tests are weak here, because the interesting inputs are the ones nobody thinks to write down: three people and one minor unit, a zero weight beside a large one, a negative total in a currency with no decimals.

So the money, splitting and settlement layers are tested as properties, 38 of them, generating cases rather than listing them:

  • An allocation always sums to its total, for any weights and any currency.
  • A zero decimal currency never produces a fractional amount.
  • A settlement always zeroes every balance.
  • The same input always produces the same output.

Those layers are pure TypeScript with no React, no database and no IO. They take plain objects and return plain objects, which is what makes generating thousands of cases cheap enough to do on every push.

Why bother

Nobody checks this. A group of six splits dinner, the numbers look about right, and everyone pays. If one person is 3 cents light the bill still gets paid and nobody notices, and that is exactly the argument for getting it right: the failure is invisible, so it never gets reported, so it never gets fixed.

There is also a simpler reason. The whole promise of splitting a bill by item is that the answer is fair. An answer that is fair to within a rounding error is a different promise, and a smaller one.


Billshare splits a bill in any of those 169 currencies. Splitting one by hand is free and needs no account, and the people you send the link to install nothing.