Back to blogAllocating money across parties without losing a cent

Allocating money across parties without losing a cent

Fintech·August 25, 2026·13 min read·By CodeDecoders Engineering

Split $100.00 three ways. Each party gets $33.33, and one cent is left over. That cent is the entire problem. It has no natural owner, it cannot be split further, and whatever you do with it becomes a rule your ledger has to defend for as long as the business runs.

The fix is short: allocate in minor units with integer math, give every party the floor of their share, then hand out the leftover units one at a time by largest fractional remainder with a deterministic tie-break. This is the largest remainder method, and it is what you want because it makes one guarantee that no rounding scheme gives you: the parts always sum back to exactly the amount you started with. Below is the algorithm, a working implementation, the four pitfalls that bite in production, and the tests that keep it honest.

Why naive rounding leaks or invents money

There are two obvious things to do with a division that does not come out even, and both are wrong.

Round each share independently. Sum three rounded shares of $33.333 and you get $99.99 or $100.02 depending on the amount. Over the range of totals from 1 cent to $1,000.00 split three ways, rounding each share half-up reconstructs the exact total in only a third of cases: a third come out over, a third come out under. You have created a system that sometimes pays out more than it received. The credit side of your journal entry no longer matches the debit side, and the transaction will not commit against any ledger with a real balance constraint. If that constraint is the thing keeping you honest, see double-entry ledger foundations for why you should not be tempted to relax it.

Floor every share and drop the remainder. This never invents money, so it feels safe, and it is the more common bug because nothing fails loudly. It just quietly loses the leftover on every single allocation. The average loss is (n - 1) / 2 cents per split, where n is the number of parties, so the cost scales with how many ways you divide:

PartiesAverage leftover per splitUnallocated per 1,000 splits
20.5 cents$5
31.0 cents$10
41.5 cents$15
73.0 cents$30
125.5 cents$55

Fifty-five dollars per thousand payout runs is not a rounding error, it is a suspense account that nobody opened. And the amount is not the real damage. The damage is that your investor balances, your merchant balances and your platform revenue no longer add up to the money you actually hold, so every reconciliation from that point forward starts with an unexplained delta.

The algorithm

Work entirely in minor units (cents, satoshis, whatever your currency's smallest unit is). No floats, no decimals, no division of the amount itself until the last step. Multiply first, then divide once.

Largest remainder allocation

Step 1 of 5

1. Fix the basis

Express each party's share as an integer weight (basis points, share counts, invested principal in cents). Sum them to get the basis total. Never use a float percentage.

Step 3 is the part worth internalising. Because every quotient is floored, the leftover can never exceed n - 1 units, so you never need a second pass or a loop that might not terminate. Step 4 is where the fairness lives: the party whose true share was $33.3339 has a better claim on the spare cent than the party whose true share was $33.3331, and sorting by remainder is exactly that claim, ordered.

A working implementation

TypeScript with bigint, because a JavaScript number stops being able to represent every integer past 2^53 and you do not want to discover that boundary in a treasury account.

export type Party = { id: string; basis: bigint };

export function allocate(totalMinor: bigint, parties: Party[]): Map<string, bigint> {
  const basisTotal = parties.reduce((a, p) => a + p.basis, 0n);
  if (basisTotal <= 0n) throw new Error('allocate: basis total must be positive');
  if (totalMinor < 0n) throw new Error('allocate: pass a positive amount, carry the sign on the posting');

  const rows = parties.map((p) => {
    const numerator = totalMinor * p.basis;
    return { id: p.id, amount: numerator / basisTotal, remainder: numerator % basisTotal };
  });

  const leftover = Number(totalMinor - rows.reduce((a, r) => a + r.amount, 0n));

  // Deterministic order: largest remainder first, then a stable tie-break on id.
  const order = [...rows].sort((a, b) => {
    if (a.remainder !== b.remainder) return a.remainder > b.remainder ? -1 : 1;
    return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
  });
  for (let i = 0; i < leftover; i++) order[i].amount += 1n;

  return new Map(rows.map((r) => [r.id, r.amount]));
}

Three details that are load-bearing:

  • The tie-break is on party id, not on array position. If two parties have the same remainder and you break the tie by whatever order the rows came back from Postgres, you have a non-deterministic payout. A retry after a timeout will produce a different split, and now you have two versions of the truth. Sorting on a stable identifier makes the function pure with respect to input order, which is what makes it safe to retry. That property is the same one idempotency keys in payment APIs buy you at the request layer, and you want it at the arithmetic layer too.
  • leftover is safely narrowed to number. It is strictly less than the party count, so the conversion cannot lose precision.
  • Negative totals are rejected. bigint division truncates toward zero rather than flooring, so a negative amount would round the wrong way and break the leftover bound. Allocate the absolute amount and let the posting carry the direction. A refund is a positive allocation posted in the opposite direction, not a negative allocation.

Checked against the worked example in Betterment's functional approach to penny-precise allocation, a $1,234.56 bonus over weights of 31000, 35000, 20000 and 14000 allocates to $382.71, $432.10, $246.91 and $172.84. Two leftover cents, two parties with the largest remainders, and the four amounts sum to exactly $1,234.56.

Choosing where the leftover goes

Largest remainder is the default, but it is not the only defensible policy, and the right answer depends on who is going to ask you about it later.

Sums back to the totalYesYesYes
Who gets the spare unitClosest true claimWhoever is firstThe platform (or a rounding account)
Deterministic on retryYes, with an id tie-breakOnly if the list order is stableYes
Bias over many runsNone, remainders moveSystematic, front of list winsAll leftovers accrue to one account
Explains well to a counterpartyYes, proportionalHard to justifyYes, disclosed in the contract
Reach for it whenPro-rata splits between peersNothing, prefer the othersFee splits where you keep the dust

Martin Fowler's Money.allocate in Patterns of Enterprise Application Architecture takes the "first in list" route: allocating 5 cents by ratios of 70 and 30 yields 4 and 1, and reversing the ratios to 30 and 70 yields 2 and 3. The sum is always right, which was the point, but the order of the arguments changes the answer. That is fine for a value object where the caller controls the order, and it is a liability in a payout service where the order comes from a query.

Production ledgers make the choice explicit rather than implicit. Formance's Numscript has a remaining keyword so the destination of the leftover is written into the transaction itself, and it distributes remainders top-down deterministically: splitting BTC 0.00000943 evenly between two accounts gives one 472 satoshis and the other 471, with no hidden fraction anywhere. If your ledger DSL does not have that, the allocator is where the policy lives, and it should be a named, documented argument rather than a side effect of a sort.

Four pitfalls that survive code review

Recomputing an allocation instead of storing it. The basis changes. An investor sells down, a revenue share renegotiates, a merchant's fee tier moves. If your allocation is a view that recomputes from today's positions, last quarter's payout silently changes shape and you can no longer reproduce a statement you already sent. Store the allocation rows with a snapshot of the basis used, and treat them as immutable facts. The practitioner post that split loan repayments across investors makes the same call, and for the same reason.

Allocating percentages instead of weights. A "33.33% share" stored as a decimal has already lost information before your allocator runs, and three of them do not sum to 1. Store the raw basis (principal in cents, share count, basis points that sum to 10000) and let the allocator do the proportion. Percentages are a display format, not a storage format.

Splitting across time as if it were across parties. Recognition schedules, amortisation and accruals divide one amount across periods, which is the same arithmetic with a different index, and the same leftover. Twelve monthly slices of an annual contract have a spare unit too, and the convention is usually to put it in the final period rather than the largest remainder, because the last period is the one that closes the balance to zero. Same algorithm, different leftover policy, and worth being deliberate about: see revenue recognition in the ledger for where those postings belong.

Assuming two decimal places. Currencies have zero-decimal (JPY, KRW), two-decimal (USD, EUR) and three-decimal (BHD, KWD) forms, and crypto assets go to eight or eighteen. The allocator does not care, because it works in whatever the minor unit is, but the code that formats and the code that validates absolutely do. Carry the exponent with the amount. For assets where the minor unit is smaller than the smallest payable amount, the leftover question gets a second layer, which is the territory covered in sub-cent agent settlement.

Verify it with properties, not examples

Example-based tests on an allocator are close to worthless, because the interesting inputs are the ones you did not think of. Test the invariants over a sweep instead. These four properties, checked across a few thousand totals and several basis shapes, catch every allocation bug we have seen:

import { describe, it, expect } from 'vitest';
import { allocate, type Party } from './allocate';

const shapes: Party[][] = [
  [{ id: 'a', basis: 1n }, { id: 'b', basis: 1n }, { id: 'c', basis: 1n }],
  [{ id: 'a', basis: 7n }, { id: 'b', basis: 3n }],
  [{ id: 'inv_a', basis: 31000n }, { id: 'inv_b', basis: 35000n },
   { id: 'inv_c', basis: 20000n }, { id: 'inv_d', basis: 14000n }],
];

const sum = (m: Map<string, bigint>) => [...m.values()].reduce((a, b) => a + b, 0n);

describe('allocate', () => {
  it('always sums back to the total', () => {
    for (const parties of shapes)
      for (let total = 0n; total <= 20_000n; total++)
        expect(sum(allocate(total, parties))).toBe(total);
  });

  it('never gives anyone a negative amount', () => {
    for (const parties of shapes)
      for (let total = 0n; total <= 20_000n; total++)
        for (const amount of allocate(total, parties).values())
          expect(amount >= 0n).toBe(true);
  });

  it('is independent of party order', () => {
    const [, , parties] = shapes;
    const reversed = [...parties].reverse();
    for (let total = 0n; total <= 5_000n; total++) {
      const a = allocate(total, parties);
      const b = allocate(total, reversed);
      for (const [id, amount] of a) expect(b.get(id)).toBe(amount);
    }
  });

  it('keeps every share within one unit of its exact proportion', () => {
    const parties = shapes[2];
    const basisTotal = parties.reduce((a, p) => a + p.basis, 0n);
    for (let total = 0n; total <= 20_000n; total++) {
      const result = allocate(total, parties);
      for (const p of parties) {
        const floor = (total * p.basis) / basisTotal;
        const amount = result.get(p.id)!;
        expect(amount === floor || amount === floor + 1n).toBe(true);
      }
    }
  });
});

The third test is the one that would have caught the non-deterministic tie-break, and the fourth is the one that proves the split is fair and not merely balanced. A version that dumped the entire leftover on one party would pass the sum test and fail this one.

Then put the same invariant in production as a check, not a hope. After writing allocation rows, assert in the same transaction that their sum equals the amount being allocated, and fail the transaction if it does not. A daily job that compares allocated totals against source amounts turns a class of silent bug into an alert, which is the same posture that makes payment reconciliation useful rather than ceremonial: you want the mismatch to page someone on the day it happens, not to surface in a quarterly close.

The short version

Integer minor units, multiply before you divide, floor everything, then distribute the leftover by largest remainder with a tie-break on a stable id. Allocate each component separately. Store the result with the basis you used. Assert that the parts sum to the whole, in tests and at runtime.

It is thirty lines of code, and it is the difference between a ledger that balances and one that needs a footnote. If you are building the payout, revenue-share or settlement layer where this arithmetic lives, our fintech engineering practice does exactly this kind of work, and we are happy to talk it through.

Newsletter

New posts, in your inbox

Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.

Start a Project

Let's build something extraordinary together.

Free consultation·Response within 24h·No commitment

info@codedecoders.io