Six decisions behind a shared-expense app, and the one where you must refuse to round

php dev.to

Splitting one bill is a division. Settling a group of five people over three weeks of shared expenses is a different problem, and it is the one that actually has edge cases.

I have been building Kotisso, a shared expense tracker. The money side of it turned out to be far less about UI than about a handful of decisions that are cheap on day one and expensive to retrofit. This is the list I wish I had started from.

(One decision is missing here because it already has its own post: who gets the indivisible cent when you split 10 EUR three ways. That is the largest remainder method and the tiebreak everyone forgets. Everything below assumes you got that part right.)

The invariant that makes everything testable

Start from one property and refuse to break it:

In any group, the sum of all balances is always exactly zero.

A balance is what a person is owed. It is paid - owed + sent - received: what they advanced, minus what is theirs to bear, plus reimbursements they sent, minus reimbursements they received. Every unit of money entering a group is advanced by exactly one person and borne by one or more people, so the positives and the negatives cancel.

That single line gives you a free assertion after every write:

public function isConsistent(array $balances): bool
{
    $sum = 0;
    foreach ($balances as $balance) {
        $sum += $balance->getBalanceCents();
    }

    return 0 === $sum;
}
Enter fullscreen mode Exit fullscreen mode

Every accounting bug I hit in the first month showed up as a non-zero sum before it ever showed up on a screen. A share attached to the wrong member, a deleted expense whose shares survived, a settlement counted on one side only: all of them break the invariant, none of them break the layout. That is why you find them with an assertion and not with your eyes.

Store hundredths even for currencies that have none

Integer cents, never floats. That part is well known. The part that is not:

1500 JPY is stored as 150000. The yen has no decimal places, so this looks like pure waste. It is what lets a single integer travel through the splitter, the balance calculator, the settlement planner, the CSV exporter and the PDF renderer without any of them needing to know which currency it carries. Exactly one place in the codebase knows that JPY has zero decimals: the formatter at the very end, and it learns it from ICU rather than from a table I maintain.

The alternative, letting the scale vary with the currency, means every arithmetic site becomes currency-aware. You will miss one.

Turning balances into transfers

You have balances that sum to zero. You want a small number of bank transfers that zeroes everybody out.

The exact minimum is NP-hard: it reduces to partitioning debtors into subsets that exactly match creditors. It is also irrelevant at these sizes. What people want is a plan that is short and obviously correct, and greedy gives that with a bound you can explain in one sentence.

// Both lists hold positive cents, sorted descending,
// with the member id as tiebreak so the plan is reproducible.
$transfers = [];
$i = 0;
$j = 0;

while ($i < count($debtors) && $j < count($creditors)) {
    $amount = min($debtors[$i]['cents'], $creditors[$j]['cents']);

    if ($amount > 0) {
        $transfers[] = new Transfer($debtors[$i]['member'], $creditors[$j]['member'], $amount);
        $debtors[$i]['cents'] -= $amount;
        $creditors[$j]['cents'] -= $amount;
    }

    if (0 === $debtors[$i]['cents'])   { $i++; }
    if (0 === $creditors[$j]['cents']) { $j++; }
}
Enter fullscreen mode Exit fullscreen mode

Each iteration settles at least one person completely, so n members need at most n - 1 transfers. A flatshare of four ends up with three payments instead of the six that "everybody pays everybody back" produces.

Two details worth keeping. The two ifs at the end are independent, not an if/else: when a debtor and a creditor match exactly, both are finished, and advancing only one index leaves a zero-cent entry the loop then has to step over. And the descending sort needs a deterministic tiebreak, or two calls on the same data propose two different plans and the user watches the suggestion move around under them.

The mode where you must refuse rather than fix

Four split modes: equally, by shares, by percentage, and by exact amounts. The first three are proportional. The fourth is a trap.

In exact-amounts mode the user types each person's share. If those shares do not add up to the expense total, the tempting move is to absorb the difference into the largest one. My code did that for a while. Then I did the arithmetic on a realistic case: a 240 EUR restaurant bill entered as exact amounts that fall 15 EUR short, and one person silently pays 15 EUR more than they agreed to, with nothing on screen saying so.

Now it returns 422 and names the gap. Whoever does not want to count to the cent has three other modes.

A rounding rule is acceptable when nobody chose the numbers. It is not acceptable when somebody did.

Freeze the exchange rate at entry

You are on holiday, you pay 1 200 THB, the group counts in euros. The obvious implementation calls a rate API.

Do not. A live rate means every balance in the group drifts every morning, so a settled trip un-settles itself and a debt someone already paid comes back at a different number. Store what was entered, the currency, and the rate that was applied, then convert once and never again. The card's real rate is never the ECB's anyway, so the field has to be editable regardless.

The downstream consequence is the good kind: amountCents holds the amount in the group's currency and nothing below the entry point knows a conversion ever happened. Balances, the invariant, the settlement plan, the exports, none of them were touched when multi-currency shipped.

A pending expense counts nowhere

Groups can require expenses to be approved by the people they concern. An expense that is awaiting approval, or disputed, is excluded entirely: from the balances, from the group total, from the per-category breakdown.

The tempting alternative is to count it as provisional and mark it visually. That keeps the invariant intact and makes every number on the page mean something slightly different from what it says. Excluding it in one place, at the top of the balance loop, is one continue and no ambiguity:

foreach ($group->getExpenses() as $expense) {
    if (!$expense->getStatus()->countsInBalances()) {
        continue;
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And one thing that is not about money at all

A free plan shows fewer rows, never different numbers. Expenses outside the visible window still count in the balances, in the group total and in the category breakdown, and the export says in the file itself what it summarised rather than dropping it.

A truncated statement is a false statement, and an accounting tool that produces one is worth nothing. That rule cost more design time than the algorithms did.

The order that worked

Invariant first, integer cents second, splitter third, screens last. Each of those is a few hours at the start and a data migration later.

The long version of the balance calculation, written for people rather than developers, is here.

Source: dev.to

arrow_back Back to Tutorials