Let's talk
product

Dashboard shows no outstanding invoices, but 45 are unpaid

A dashboard showing zero owed above a list of unpaid invoices is adding text, not money. How to check your total, and the fix.

· · updated

Diagram: The card said zero; the list showed 45. 45 unpaid invoices listed under the card; 0 shown as owed on the card; 2 times this same fault has appeared in the codebase; 1 conversion to a number, where the data comes in

If your dashboard says nobody owes you money while unpaid invoices sit in the list beneath it, the usual cause is that the total is adding amounts stored as text, so it joins them end to end instead of summing them. The list is normally the one that is right. The first check is to add up the list by hand and compare it with the card.

Here is how that looked at one business, and what fixing it took.

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.

How to check your own dashboard totals

  1. Take a headline figure, such as outstanding invoices, and add up the rows beneath it by hand.
  2. If the list is right and the total is wrong or zero, the problem is arithmetic, not missing data or filtering.
  3. Change one invoice amount and check the total moves by exactly that amount.
  4. Ask your supplier in what form amounts reach the screen, and where they are turned into numbers.
  5. Ask what stops the next total repeating the fault. A note in a handover document 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.

This came out of building Sazinga AdBoard

Every site, every booking, every invoice — in one place. The problem above is one we met while building it, and what we did about it is in the product.

If you run something like this, there is one thing you can do without a call: tell us your board count.