Two kinds of transaction is all you need

#Settling up@Denis PovarovAugust 20th, 202618 views

An early version of HiBill could record money changing hands in four different ways: an expense, a loan, a payback, and a settlement. Each had its own table, its own screen, its own rules.

Today it has two. Here is why, and what we learned pulling them apart.

Four objects that were secretly one

Look at what each of the four actually stored.

A loan was: money went from A to B, and now B owes A.

A payback was: money went from B to A, cancelling some of what B owed.

A settlement was: money went from B to A, cancelling some of what B owed — and we marked it as final.

Three names. One event. Money moved from exactly one person to exactly one person. The differences were all narrative — what the user meant by it — not structural. And we had encoded that narrative in the schema, where it hardened into three code paths that had to be kept in sync forever.

The tell was the settlement status field. Settlements had pending, paid and cancelled states. The UI set paid every single time. We had built a state machine and then never used it, because in real life you record a settlement after the cash has already moved. Dead state in a schema is a strong sign the object should not exist.

What is actually different

Only one distinction survived contact with real usage: how many people are on each end.

An expense is one-to-many. Someone (or several people) paid, and a group of people share the cost. The interesting part is the split.

A transfer is one-to-one. Money left one person and arrived at one other person, in full. There is no split to compute — the recipient gets the whole amount.

That is a genuine structural difference, and it is the only one. So those are the two kinds.

Everything the old four-object model could express, this expresses:

What you want to record

How

Dinner, split between five

Expense

Lending a friend €50

Transfer

Paying that €50 back

Transfer, the other direction

Settling a balance to zero

Transfer

Settling up stopped being a special ceremony. It has no dedicated screen and no separate status. It is a transfer that happens to bring a balance to zero, and it appears in the timeline next to everything else, because that is what it is.

The invariants moved into one place

Collapsing the kinds meant the rules could be stated once and enforced once. There is a single file that answers "is this transaction valid?", and it says roughly:

For an expense: at least one payer, and the payers' amounts must sum to the total. Participants owe shares of it.

For a transfer: exactly one payer, exactly one recipient, and the recipient's share is the full amount.

That is the entire kind-specific rulebook. When we had four objects, equivalent rules were scattered across four services and drifted apart — the multi-payer path had checks the settlement path did not. Now there is one door, and everything walks through it.

The one exception

There is a third kind in the database that you never create: Simplification.

When the system spots a debt loop — you owe Marco, Marco owes Aya, Aya owes you — it can shrink that loop without changing what anyone is actually out of pocket. Those adjustments have to be recorded as real entries, or the balances would move with no explanation.

So they are transactions, labelled Simplification, sitting in the timeline where you can see them. The rule we held ourselves to: no balance changes without a visible reason. A number that quietly drifts is worse than a number that is slightly too large.

Was it worth it?

The rewrite deleted more code than it added, which is usually a good sign. But the real payoff was in what got easier afterwards.

Open splits, itemized receipt assignment, multi-currency balances, adding people who have not signed up — each of these had previously needed four implementations, one per object, and so each had been quietly deferred. Against two kinds with shared invariants, they were small.

That is the argument for collapsing a model, and it is not really about elegance. Every extra object in a schema is a multiplier on the cost of every feature you will ever add. We were paying 4× on everything to preserve distinctions that only existed in our own heads.


The two kinds are live in HiBill today. If you want the detail on how balances work on top of them, the per-person balance model is the companion piece.

All articles