Now-Next

SaaS architecture

Money in a SaaS product: cents, not floats

The second irreversible decision in a SaaS product is about money. Why a decimal number loses your cents, and where it still goes wrong with cents.

Chris van Eijk · · 8 min read

This is the second of three decisions you will almost never straighten out later. The first is how you keep customers apart. This one is about money, and it is the more treacherous of the two, because it goes well for months.

The wrong number type does not raise an error. It produces an answer that looks right, and one in every few thousand of those answers is a cent off. You will not catch that in a test you invented yourself. You catch it because a customer adds the column up by hand and lands somewhere else than you do.

Why a decimal number cannot hold money

A float or double stores numbers in powers of two. Tenths and hundredths cannot be expressed exactly in that base, in the same way a third cannot be written out exactly in ours. What follows is not theoretical:

  • 0.1 + 0.2 yields 0.30000000000000004 in virtually every language that uses IEEE 754.
  • 1.005 is in fact slightly smaller than 1.005, so the usual rounding trick — multiply by a hundred, round, divide by a hundred — rounds it down instead of up.

On one line that is a fraction of a cent and you see nothing. The problem is that an accounting product does not have one line. It has an invoice with lines, a tax calculation per rate, a total, a journal, a ledger and a year overview — and every layer adds up the one below it. The deviation travels upward and surfaces exactly where it is most visible: the balance that does not balance.

That is the real damage. Not the cent, but the trust. An accounting product whose sums the customer has to verify has lost its reason to exist.

What belongs there instead

Store amounts as whole units in the smallest denomination, in an integer. For euros that is cents. No decimal point, no decimal type that rounds differently per database, and certainly not text.

Two things come with that:

The currency travels with the amount. A number without a currency is not an amount, it is a count. The moment one customer crosses a border, which currency it was is no longer reconstructable — and that question always arrives later than the decision.

There is one type, and the rest of the code knows only that type. As soon as an amount can move through the system in two shapes, there is a place where somebody adds them together without noticing. One type with addition, subtraction and comparison baked in is the cheapest guardrail there is, precisely because it makes the wrong thing impossible rather than unlikely.

Where it still goes wrong with cents

Integers solve addition. They do not solve division, and division is where the actual work is.

The moment you split an amount — tax over a line total, a discount over an invoice, a subscription over half a month, a bill over four members of a flying club — there is a remainder that will not divide. Three people splitting 10 cents each get 3 cents, and one is left over. Who gets it?

That is not a trick question but a product decision, and it has to be written down somewhere. The approach that holds up in practice is: divide down, count what remains, and hand the leftover cents out one by one to the lines with the largest remainder. The sum of the parts is then guaranteed to be the whole. Skip that and every calculation does it slightly differently, and you have a one-cent difference nobody can explain.

Two things follow immediately:

  • Round once, at the end. Rounding twice in a chain is how a cent disappears. The intermediate steps are not supposed to be tidy.
  • Round only where the answer leaves the system — on the invoice, in the filing, on the screen. Not in the middle of a calculation because it reads better that way.

The test that guards it

The test that counts here checks the outcome and not the code. That sounds obvious and it is not: the ordinary reflex is to assert that the function calls the rounding helper. That test stays green while the answer is wrong.

What you actually want to know is this:

  • Does the sum of the lines add up exactly to the total, for a series of amounts that do not divide neatly?
  • Does that still hold once a discount goes over it, and a tax calculation after that?
  • Do you get the same answer if the same invoice is calculated twice — including when the lines are in a different order?

That last one is the most useful and the least written. A split that depends on ordering is a split that gives a different answer after a migration than before it.

At Pilot-Next all of this converges in settling a flight: flight hours, fuel and a club contribution come together in one amount that lands on a member’s balance. There is not a single place where half a cent may linger, because that balance stays put until somebody withdraws it.

What it costs to do later

Up front this is a day’s work: pick a type, put the columns on an integer, write one splitting function and tests around it.

Afterwards it is something else. You now have production data with the error already in it, and you cannot simply convert it — because which of the two numbers was the right one, the stored total or the sum of the lines? For every invoice already out the door the answer is that neither can be changed anymore. You are then migrating not just your data model but your history, and that is exactly the kind of project no customer wants to pay for.

The third

The last of the three is about how you model permissions — roles instead of checkboxes per user. Less mathematical, and just as stubborn.

If you would rather not make these decisions yourself: that is precisely the work we do when we build a SaaS product.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation