The dashboard said nobody owed us; 45 invoices were unpaid
A receivables card showed zero while the list beneath it showed forty-five unpaid invoices. The list was right; the total was adding text, not money.
The card at the top of the screen said nobody owed you anything. Directly under it, the list showed forty-five unpaid invoices with real amounts against every one. The card and the list were reading the same records.
Zero is a believable number for money owed. Somebody glancing at that screen has no reason to doubt it, and the natural next thought is that the figures have not been loaded yet, not that the arithmetic is broken. That is what makes this one dangerous: it does not fail loudly, it fails into a plausible answer. A business whose whole problem is money owed was being shown a screen that said there was none.
The cost, had it gone unnoticed, is every decision that gets made from a headline figure rather than from the list beneath it: who to chase this week, whether cash is tight, what to tell the bank.
What was actually going on
Money in this system is stored to the exact paisa, and the part of the system that reads it out hands it over as written text rather than as a number, deliberately, so that no precision is lost on the way. Every amount arrived as digits in quotes.
The list did not care, because it only displayed each amount. The total did care, because it added them up — and adding text to text does not give you a sum, it gives you the pieces joined end to end. A few invoices in, the “total” was a long string of digits that meant nothing, and the moment the screen tried to show it as currency the result was not a number at all. The formatter drew that as an empty amount. Nothing failed. The card rendered. It said zero.
It also failed inconsistently, which is worse. Sorting and filtering happened to behave, because comparisons treat text and numbers more leniently than addition does. So the list was right, the sort was right, the count was right, and the one figure that was a sum was wrong. Every signal on the screen agreed except the broken one.
What we changed
Every amount is now turned into a number once, at the moment it enters the application, and everything after that can assume it is a number. Doing the conversion at each place a sum is calculated would mean every future total is a new chance to forget. The screen that showed nothing now shows the real outstanding figure.
Three habits came with it. Convert once where the data comes in, never at the point of use. Never trust a running total that started from a literal zero and added whatever arrived. And treat any totals card as suspect until it has been read against a hand-count of the list underneath, because a total is the only figure on a screen that has no row to check it against.
The hypothesis I chased first
I assumed a permissions or scoping problem, because a zero total next to a populated list is exactly what an over-tight filter on the summary looks like. That is a more interesting bug and I spent time on it: checked the organisation scoping on the summary, compared its conditions against the list’s, looked for a status filter that excluded everything.
They were identical. The two paths differed only in that one summed and the other listed. Once that was established the answer was forced, and I would now start there: when a list is right and its total is wrong, the difference is arithmetic, not filtering. The questions asked of the database are almost never the problem when one of them is visibly returning the rows.
What it did not fix
Nothing in the build stops the next person doing it again. The same bug had appeared twice in this codebase, because the conversion has to be remembered by a person and people forget. A wrapper type that made the conversion mandatory would prevent it, and that was not done — the codebase relies on a note in the handover document, which is a convention, not a guarantee.
The mechanism
Fixed-precision decimal columns come back from the database driver as strings, and that is correct
on the driver’s part: a decimal with more precision than a double can hold would lose money in the
conversion, so the driver refuses to convert and hands over the exact digits. A reduce that starts
at 0 and adds each amount produces a string after the first addition, concatenates from then on,
and yields NaN the moment anything divides or formats it. Comparisons coerce, so filters and sorts
keep working while sums do not.
This is one instance of a class: a value crosses a boundary and changes type without changing appearance. Dates as ISO text that sort correctly and subtract into nonsense. Identifiers numeric in one service and strings in another that compare unequal with a strict operator. Booleans arriving as the strings “true” and “false”, both of which are truthy. In every case the value looks right in a log, looks right in a payload, renders right in a table, and only misbehaves when something operates on it.
The five-minute check: find out what your database driver hands back for every fixed-precision column before you write a single sum over it. Skipping it costs a screen that reports no money owed to a business whose whole problem is money owed.
Where this ends up
The card in question is the receivables figure a media owner opens first, and Sazinga AdBoard holds every site, every booking and every invoice behind it. A total is the one number on that screen nobody can check by eye, which is exactly why what it was summed from matters.