Why your money column should never hold a float
0.1 plus 0.2 is not 0.3, and in a payments ledger that is not a curiosity. What goes wrong, and the one rule that removes the whole class of bug.
0.1 plus 0.2 is not 0.3, and in a payments ledger that is not a curiosity. What goes wrong, and the one rule that removes the whole class of bug.
Open a terminal and type this into any language you like.
0.1 + 0.2
0.30000000000000004
Everybody has seen it. Most people file it under "floating point is weird" and move on, which is fine right up until the number is somebody's money.
A binary floating-point number represents a value as a fraction times a power of two. That system can represent 0.5 exactly, and 0.25, and 0.75. It cannot represent 0.1 exactly, for the same reason base ten cannot represent one third exactly: the expansion does not terminate. So 0.1 is stored as the nearest representable value, which is very slightly wrong, and the error travels with it.
One such error is invisible. The problem is that they accumulate, and that they accumulate differently depending on the order of operations, which means two correct programs computing the same total can disagree.
A fee is 1.5% of 1,850 shillings. In floats you get 27.749999999999996, which rounds to 27.75, which is right. Now do it for a quarter of a million transactions and sum the fees, and whether your total matches the sum of the individual roundings depends on the order you added them in.
That is the first symptom: your statement does not reconcile with your transaction list, by a few cents, for no reason anybody can find. Nobody can find it because the individual rows are all correct. It is the summation that drifts, and a few cents is small enough that the first instinct is to assume somebody made a data entry error.
The second symptom is worse and rarer. A balance check reads available >= amount,
the two values are equal in the real world, and the comparison returns false because
one of them is 1999.9999999999998. A payout fails for a merchant who demonstrably has
the money. You cannot reproduce it, because reproducing it requires the exact sequence
of prior transactions that produced that exact error.
The third is a genuine security problem. If rounding happens at the point of display
rather than the point of storage, you can have a balance that shows as Ksh 0.00 and
is actually 0.004, and a system that sums a lot of those is a system with money in it
that no screen will ever show.
Store money as an integer number of the smallest unit, everywhere, and never convert until you are formatting a string for a human being.
In Kenya that is cents. 1,850 shillings is 185000. A fee of 1.5% plus 2 shillings,
with a floor of 5 shillings, is:
percent_part = amount * bps / 10000 // integer division, truncating
fee = percent_part + fixed
fee = max(floor, min(ceiling, fee))
All integers. The division truncates, which is a decision rather than an accident: it means the rounding always goes the same way, and which way it goes is a commercial choice you make once and write down, rather than an artefact of the IEEE 754 spec.
The database column. numeric is exact and is a perfectly good answer. real and
double precision are not. The trap is that a column typed numeric can still be read
into a float by an ORM that has helpfully chosen a native type for you, at which point
you have exactness in storage and drift in memory, which is the worst combination
because it looks fine in the database.
JSON. JSON.parse produces a double for every number. If your API sends 18.50,
the client reads a float whether you intended that or not. Sending 1850 and
documenting that amounts are in cents removes the ambiguity entirely, and has the side
effect of making it obvious when somebody has sent shillings by mistake, because the
number is a hundred times too small rather than slightly off.
The input field. A person types 1,850.50 into a box. Somewhere that becomes a
number, and the most natural way to write that conversion is parseFloat. Parse the
string yourself: strip the separators, split on the decimal point, validate that there
are at most two digits after it, and assemble the integer. More code, and the code is
boring and testable, which is what you want standing between a person and a money
column.
Very large integers. In JavaScript a Number holds integers exactly up to about
9 quadrillion, which in cents is about 90 trillion shillings. Fine for any real
balance, and not fine if you are summing every transaction in the platform's history
into one accumulator, which is exactly the sort of thing a reporting query does. Use
BigInt, or do the sum in the database.
Not elegance. The code is slightly more verbose and you spend a little time on conversion at the edges.
What you get is that an entire category of bug stops existing. Not "becomes rare": stops existing. Your statement reconciles with your transaction list exactly, every time, because they are summing the same integers. A balance comparison does what it says. A fee is a number you can reproduce by hand on paper and get the same answer.
In a system whose single job is to be right about money, removing a class of error entirely is worth some verbosity at the edges. It is one of the few decisions in this business that is genuinely free of trade-offs, which is why we would rather spend six minutes of your attention on it than on something more interesting.
If you are integrating, the practical version is: amounts in and out of our API are integers in cents, and the field is documented that way. The glossary entry on minor units is the short version, and the API overview has the rest.
Genuinely. If something here is wrong, or right for the wrong reason, we would rather be told than keep it published. Corrections get made and credited.