Splitting a loan repayment across investors without losing a cent
A single borrower repayment often funds a loan held by many investors at once, and the split has to be exact: every minor unit that arrives has to leave the account assigned to somebody. Percentage math with floats and per-investor rounding does not give you that, and the failure is quiet — the payout ledger drifts by a unit or two per repayment until somebody reconciles a month of transfers and finds money that belongs to nobody.
I built the repayment and distribution side of a P2P lending marketplace: loans funded through an order book, multiple investors per deal, pro-rata returns. The allocation code is maybe sixty lines. Getting those sixty lines wrong is the kind of bug that does not page anyone and takes a quarter to surface.
The constraint that makes it hard#
If investors funded a loan in clean percentages, this would be arithmetic. They do not. In an order-book model, a position is whatever amount that investor happened to fill — arbitrary integers, sometimes thirty or forty of them on one loan. So the shares are ratios like 137/9000, and no rounding mode makes those sum to one.
Three requirements fall out of that, and they are worth writing down before any code:
- Exactness. The allocations sum to the incoming amount. Not approximately — exactly, and per component, because a repayment is not one number.
- Determinism. The same repayment against the same positions produces the same split, forever. Payment handlers get retried, background jobs get replayed, and support will ask you a year later why an investor got what they got.
- An explicit basis. The split is computed against a snapshot of positions that you store, not against whatever the positions table says at the moment the job runs. Positions move — secondary market transfers, write-offs, corrections.
Attribution is a separate problem; this starts after the money is already known to belong to a specific loan.
Integers, and a rounding rule chosen on purpose#
The version that looks obvious and is wrong:
share = position.principal_minor / basis_minor
amount = round(total_minor * share)Two defects in two lines. Floats, which give you a different answer depending on how the compiler feels about associativity, and independent rounding of every investor, so the sum of the parts is whatever it turns out to be. Banker's rounding does not fix this. It makes the drift smaller and harder to reproduce, which is worse.
The fix is to never divide until the very end, work in minor units as integers, and pick an explicit policy for the leftover. Largest remainder is the one I default to:
from dataclasses import dataclass
from typing import Sequence
@dataclass(frozen=True)
class Position:
investor_id: int
basis_minor: int # this investor's stake in the loan, in minor units
def allocate(total_minor: int, positions: Sequence[Position]) -> dict[int, int]:
if total_minor < 0:
raise ValueError('allocate forward amounts only; reverse by stored allocation')
basis = sum(p.basis_minor for p in positions)
if basis <= 0:
raise ValueError('empty allocation basis')
parts = []
for p in positions:
numerator = total_minor * p.basis_minor
parts.append((p.investor_id, numerator // basis, numerator % basis))
result = {investor_id: floor for investor_id, floor, _ in parts}
leftover = total_minor - sum(result.values())
ranked = sorted(parts, key=lambda t: (-t[2], t[0]))
for investor_id, _, _ in ranked[:leftover]:
result[investor_id] += 1
return resultEvery quotient is a floor, so the leftover is always between zero and the number of positions minus one. At most one extra minor unit lands on any investor, and it lands on the investors whose exact share was closest to the next unit up. The sort key includes the investor id, so ties break the same way every run and the result does not depend on the order rows came back from the database. That last property is what makes the function safe to call from a retried job.
The invariant is small enough to test directly, and this is the test I actually care about:
import random
def test_allocation_is_exact_and_order_independent():
for _ in range(1000):
positions = [
Position(investor_id=i, basis_minor=random.randint(1, 500_000))
for i in range(random.randint(1, 60))
]
total = random.randint(0, 10_000_000)
result = allocate(total, positions)
assert sum(result.values()) == total
shuffled = positions[:]
random.shuffle(shuffled)
assert allocate(total, shuffled) == resultAllocate each component, never the total#
A repayment is not one amount. It is principal, interest, a late-payment penalty, and a platform fee, and those are not interchangeable: principal reduces the outstanding balance, interest is investor income with its own downstream tax and accounting treatment, penalty may be split under a different rule entirely.
The tempting shortcut is to allocate the total once and then break each investor's slice into components proportionally. It produces a ledger that does not close. The extra unit lands in principal for one investor and in interest for another, and now the sum of per-investor principal no longer equals the loan's principal reduction. You find out during a year-end reconciliation, when the money has already left.
Allocate per component instead. Same function, called once per bucket:
def allocate_repayment(components: dict[str, int],
positions: Sequence[Position]) -> dict[str, dict[int, int]]:
return {
component: allocate(amount_minor, positions)
for component, amount_minor in components.items()
}Each component closes on its own, and the total closes because the components do.
Store the allocation, then let the database defend it#
The allocation is a fact, not a computation you redo on demand. Write it once, key it so a retry cannot double it, and keep the basis you used next to it:
create table repayment_allocation (
repayment_id bigint not null references repayment (id),
investor_id bigint not null,
component text not null,
amount_minor bigint not null check (amount_minor >= 0),
basis_minor bigint not null check (basis_minor > 0),
policy text not null,
primary key (repayment_id, investor_id, component)
);The insert is a single statement with on conflict do nothing, so the tenth delivery of the same webhook is a no-op rather than a second payout. The basis_minor column is what makes a support question answerable a year later: you are not re-deriving the split from positions that have since changed, you are reading what the split was computed from.
Then there is one query that must always return zero rows, and it belongs in a scheduled check, not in someone's head:
select a.repayment_id,
a.component,
sum(a.amount_minor) as allocated_minor,
c.amount_minor as expected_minor
from repayment_allocation a
join repayment_component c
on c.repayment_id = a.repayment_id
and c.component = a.component
group by a.repayment_id, a.component, c.amount_minor
having sum(a.amount_minor) <> c.amount_minor;A drift bug that reports itself the same day is an incident. The same bug found by an accountant is an investigation across every payout since the deploy.
One detail that cost me real time: reversals. When a repayment is charged back or corrected, do not re-run the allocator with a negative total. Integer floor division of a negative numerator rounds toward negative infinity, the remainder ranking inverts, and you get a split that is not the mirror image of the original — so the reversal does not cancel the payout it was meant to cancel. Reverse by reading the stored allocation rows and negating them. That is the whole reason to store them.
What I would do differently#
Carry the dust instead of ranking it away. Largest remainder with an id tie-break is deterministic, but on a long amortisation schedule the same small set of investors keeps catching the extra unit, month after month. It is a cent, and it is also a pattern somebody will eventually notice and ask about. Now I would keep a per-investor carry balance on the loan: accumulate the fractional part, and pay out a unit when the carry crosses one. Slightly more state, no systematic bias, and the arithmetic still closes exactly.
Version the policy from day one. That policy column exists because rounding rules change — a new product, a regulator-facing report, a different treatment for penalties. Rows written under the old rule must stay explainable under the old rule. A recomputation that silently applies today's policy to last year's payouts is not a fix, it is a second bug.
Write the exactness test before the allocator. The property is one line: the parts sum to the whole, under any permutation. Every subsequent change to the split — new component, new policy, a special case for a written-off position — runs into that test first.
Keep allocation out of the payment handler. The handler's job ends at recording that money arrived and which loan it belongs to. Allocation is a separate step over a stored basis, which means it can be replayed, audited, and fixed forward without touching the ingestion path.
None of this is clever. That is the point: money splitting should be the least clever code in the system, an integer function with one invariant, a stored result, and a query that shouts when the invariant breaks.