Shipmind Labs

Split five cents three ways: allocation that sums back exactly

· 9 min read

Totals drift at the point where an amount is divided. Five cents split three ways cannot be three equal amounts, and a payout split pro rata across investors almost never lands on whole minor units, so every division leaves a remainder that has to go somewhere specific. Code that computes each share on its own and trusts the parts to add back up is the most common way a ledger ends up a cent away from itself, in a place nobody can attribute.

We build payment services, multi-investor payout flows, and the reconciliation that sits behind them, and the same defect keeps arriving in the same shape. Not a wrong total. A total that was right until it was split.

The naive split fails in both directions#

Work in minor units and integer division quietly drops the remainder:

typescript
const total = 5n;        // five cents
const each = total / 3n; // 1n
each + each + each;      // 3n - two cents have disappeared

Work in floats and the opposite happens. 0.05 / 3 is 0.016666...; round each share to two decimals and you get three amounts of 0.02, summing to 0.06. A cent has been invented.

The two failures are not equally annoying. A missing cent tends to surface as a support ticket from whoever received the short share. An invented cent surfaces as a reconciliation break, because the distribution no longer matches the amount that was actually settled, and that one is found by accountants rather than by monitoring.

Both come from the same mistake: treating each share as an independent rounding problem. It isn't. The shares are a partition of a known total, and the total is the invariant.

Allocation is a function of the total, not of the shares#

The fix is the largest-remainder method, applied in minor units. Compute every share by flooring, count how many units are left over, and hand those units out one each to the parts with the largest remainders. The parts then sum back to the original exactly, by construction rather than by luck.

That is what allocate() does in our money-input library, amountfield. The library exists for the input side, a React field that never holds a float and never assumes a currency has two decimals, but the arithmetic underneath it is the part that gets reused in ledger code, and this is the rule it implements:

typescript
const floorDiv = (a: bigint, b: bigint): bigint => {
  const q = a / b;
  return a % b !== 0n && a < 0n !== b < 0n ? q - 1n : q;
};

function largestRemainder(total: bigint, weights: readonly bigint[]): bigint[] {
  const sum = weights.reduce((acc, w) => acc + w, 0n);
  const shares = weights.map((w) => floorDiv(total * w, sum));
  const remainders = weights.map((w, i) => total * w - shares[i] * sum);

  let leftover = total - shares.reduce((acc, s) => acc + s, 0n);
  const order = remainders
    .map((r, i) => [r, i] as const)
    .sort((a, b) => (b[0] === a[0] ? a[1] - b[1] : b[0] > a[0] ? 1 : -1));

  for (const [, i] of order) {
    if (leftover === 0n) break;
    shares[i] += 1n;
    leftover -= 1n;
  }
  return shares;
}

Two details in there are load-bearing, and both are easy to get wrong.

The first is floorDiv. JavaScript's bigint division truncates toward zero, not down. For positive totals the two agree; for negative ones they don't, and the difference is the whole correctness argument. With flooring, every remainder is non-negative and the leftover count stays between zero and the number of parts, so the distribution loop is the same code for a charge and for a refund. With truncation you get a negative leftover for negative totals and need a second branch, which is exactly the branch that goes untested.

The second is the tie-break. When two parts have the same remainder, the lower index wins. That looks like a detail until a reconciliation job is re-run and produces a different split than the one already booked. A split has to be a pure function of the total and the weights, in that order, with no dependence on iteration order of a map or on the order rows came back from the database.

Weights, negatives, and the one-unit rule#

Equal parts are the easy case. Real allocation carries weights: pro-rata returns across investors in a loan, a fee split between a platform and a merchant, a document-level discount spread across order lines.

total weights shares sum
5 1, 1, 1 2, 2, 1 5
5 3, 7 2, 3 5
-5 1, 1, 1 -1, -2, -2 -5

For equal weights there is a property worth asserting in a test: no two shares differ by more than one minor unit. It is the closest thing to fairness that integer arithmetic can offer, and it fails loudly if someone later replaces the allocation with per-share rounding.

The negative row deserves a second look. The leftover units get added, so for a negative total the first parties end up with the smaller magnitude rather than the larger one. That is internally consistent, but it means a refund allocated on its own is not necessarily the mirror image of the charge it reverses. If your domain requires a partial refund to walk back the original split exactly, allocate the positive amount and negate the result, or allocate against the amounts already booked. Whichever you choose, choose it, because this is not a case where the library can guess.

The same goes for the extra cent itself. Largest remainders is a defensible, deterministic default, and it is the right one when the parties are peers. It is the wrong one when the rule is "the platform absorbs the remainder" or "the remainder follows the largest position, not the largest fractional part". Those are business rules, and they belong at the call site as an explicit step on top of an exact allocation, not as an emergent property of whichever iteration happened to run last.

Multiplication is where a rounding mode hides#

Division is the obvious place a total drifts. Multiplication is the quiet one, because it looks like it has a right answer.

typescript
multiply(price, 0.1);     // refused: the JavaScript number 0.1 is not one tenth
multiply(price, "0.1");   // refused: no rounding mode given

The first refusal is the float problem wearing a different hat. 0.1 as a double is not one tenth, and a ratio that is already wrong before the multiplication cannot be rescued by rounding after it. So multiply() takes the ratio as text, as a bigint, as a whole number, or as a numerator and a denominator, every form that can express the intended ratio exactly, and rejects the one form that cannot.

The second refusal is the interesting one. A fee of 2.9% of some amount is generally not a whole number of minor units. Half-up, half-even, and toward-zero all produce defensible answers, they differ by a unit, and the difference compounds across a settlement run. Picking one silently means the library has made a policy decision on behalf of a caller who never knew a decision was being made. So multiply() throws when no rounding mode is given. The error arrives in the first test run rather than in a month-end report.

add() behaves the same way about currencies: it refuses to mix them rather than adding the numbers and keeping the first currency code. It is the same principle three times over, that a wrong amount is worse than an error, and it is the same principle that makes the input field refuse an unknown currency code instead of assuming two decimals, or refuse a pasted $12.34 in a EUR field instead of reading it as euros.

What it costs to run#

Very little at runtime. Everything is bigint arithmetic over minor units, allocation is one pass plus a sort over the number of parts, and no value ever becomes a Number on the way through, since even formatting goes through a decimal string rather than a float.

The real cost is argument surface. Callers have to supply weights, name a rounding mode, and carry a currency with its exponent, and none of those can be defaulted honestly. That is the trade: a handful of required arguments in exchange for arithmetic that cannot silently invent or lose a unit.

One thing you get for free by staying in minor units is the currency exponent. Allocation hands out the currency's own smallest unit, so splitting a JPY amount distributes whole yen and splitting a KWD amount distributes thousandths, without a special case anywhere in the allocation code. The exponent comes from the bundled ISO 4217 table, and a code the table does not carry is refused rather than assumed, which is probably why multiply() and allocate() can be exact: they never have to guess how small a unit is.

Close#

Every split of an amount contains a decision about who gets the extra unit. The decision is unavoidable, and the only real choice is whether it is written down or left to the shape of a loop. Allocate from the total in minor units, floor the shares, hand the leftovers to the largest remainders, keep the tie-break deterministic, and make multiplication ask for its rounding mode. The parts then add back to the whole every time, and the cent that someone has to receive is the cent you decided to give them.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com