The receivables card said zero and the database held forty-five unpaid invoices
The receivables summary card showed zero. Under it, the list showed forty-five unpaid invoices with real amounts against them. The list and the card were reading the same rows.
The cause is one of those defects that is completely obvious once you know it and completely invisible until you do. Fixed-precision decimal columns do not come back from the database driver as numbers. They come back as strings — the amount arrives as text, quoted, with its trailing zeros intact. That is deliberate and 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 you the exact digits.
Then somebody sums a page of rows with a reduce that starts at zero and adds each amount. Starting from a number and adding a string produces a string. The next addition concatenates onto that. A few rows in, the result is a long numeric string, and the moment anything divides it or formats it as currency it becomes not-a-number, and the formatter renders that as an empty amount.
Nothing throws. The card renders. It says zero.
Why this one is worth naming
Most type mistakes fail loudly. This one fails into a plausible answer. Zero is a legitimate value for a receivables total. Somebody looking at that screen has no reason to disbelieve it, and the natural next thought is that the data has not been seeded rather than that the arithmetic is broken.
It also fails inconsistently, which is worse. Comparisons behave differently from arithmetic — a string amount compared against a number gets coerced, so filters and sorts often work while totals do not. The list is right, the sort is right, the count is right, and the one number that is a sum is wrong. Every signal on the screen agrees except the broken one.
The fix is boring and the discipline is not
Coerce at the boundary. Every amount that comes out of the API gets converted to a number once, at the point it enters the client’s model, and everything downstream can assume it is a number. Doing it at each arithmetic site instead means every future sum is a new opportunity to forget.
That was the correction, and it turned a screen showing nothing into one showing the real outstanding total. But the reason the same bug reappeared twice in this codebase is that the coercion has to be remembered by a person, and people forget. The mitigations that actually help:
- Convert once where the data enters the application layer, never at the point of use.
- Never seed an accumulator with a literal zero and trust the additions — coerce inside the reduce.
- Treat any totals card as suspect until it has been read against a hand-count of the underlying list. 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 aggregate query looks like. That is a much more interesting bug and I spent time on it: checked the organisation scoping on the summary endpoint, compared its where clause 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 queries are almost never the problem when one of them is visibly returning the rows.
The general shape
This is a specific instance of a class: a value crosses a boundary and changes type without changing appearance. Money as a string is the common one. Dates as ISO text that sort correctly and subtract into nonsense. Identifiers that are numeric in one service and strings in another and 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 defence is the same each time and it is not clever: know what your driver returns, convert once at the edge, and be suspicious of any figure that was computed rather than read.
Check what your database driver hands back for every fixed-precision column before you write a single sum over it. It is a five-minute check, it is documented, and skipping it costs a screen that reports no money owed to a business whose whole problem is money owed.
The limit I will admit: nothing in the build stops the next person doing it again. A wrapper type that made the coercion mandatory would, and that was not done — the codebase relies on a note in the handover document, which is a convention, not a guarantee.