A pending row is a promise, not a payment
A driver stood at a kerb, took a car back from a customer, and collected nothing. The screen on his phone said nothing was owed. Five thousand rupees was owed. On the next booking it was twenty thousand.
The cause was a row in the ledger marked as a security deposit with its settled-via flag set to
pending. That flag exists precisely to say this deposit has been recorded but not taken. The
function that computes what a driver should collect summed every deposit row into “deposit held”,
pending ones included, which drove “deposit still to collect” to zero. Six live bookings were
affected and forty-five thousand rupees was exposed. It looked entirely normal on every screen the
office uses, because the settlement page computes the same figure by a different path and that path
got it right.
That is the third time the same shape has appeared in this system, and the third time is where you stop treating it as a bug and start treating it as a rule.
The first time: an advance that was never paid
Bookings arrive from an upstream rental system, which reports each one with an advance amount. The ledger read that amount and wrote an advance-payment receipt for it. The driver’s due amount subtracted it. Both were wrong on any booking where the advance had not actually been paid, because the upstream system carries the advance figure regardless — it is the amount agreed, and a separate flag says whether it was received. An unpaid advance shows in the upstream interface as a red row, and it carries the full figure with the paid flag at zero.
One booking made the shape obvious: total 13,216, advance 13,216, paid 0. The ledger reported the customer as fully settled with nothing due. Once both reads were gated on the paid flag, the same booking reported 13,216 outstanding, which is what it always was. Fifty-six bookings carried an advance figure with the paid flag at zero.
The interesting part is that two other places in the same codebase — the invoice prefill and the settlement detail page — had gated on the paid flag from the start. The rule was known. It was implemented in two of the four places that needed it.
The second time: a taxable charge in an untaxed bucket
Same shape, different field. Charge types in the ledger carry a taxable flag, and the summary engine applies GST to taxable rows. Except in the rental bucket, where prices are tax-inclusive by design and the engine deliberately never computes GST at all.
Somebody switched the taxable flag on for the extension charge type through the master screen, which is exactly what that screen is for. The entry form’s preview reads the taxable flag and nothing else, so it promised an operator “+18% GST 432 — customer bears 2,832” for a charge the backend books at 2,400 flat. Twenty-one entries and 72,454 rupees went through with the flag set and no tax applied. Only one booking actually lost money, because seventeen of the twenty-one had been typed GST-inclusive by an operator who had worked out the discrepancy for themselves and was quietly compensating for it.
The flag was true. The flag was also ignored. Nothing anywhere reported the contradiction.
What the three have in common
In every case a field named an amount, and a second field — sometimes in a different table, sometimes a bucket, sometimes a status — determined whether that amount meant anything. Every time, one read path honoured the second field and another did not.
Three things follow from that, and they are the transferable part.
A quantity and its qualifier are one value. If a deposit row is only real when its settled-via
flag says collected, then the deposit is not the amount column — it is the pair. The moment your
code can select the amount without the qualifier, somebody eventually will, and the resulting bug
is invisible because the number that appears is a real number from a real row. Where the storage
cannot express the pair, put the pair behind one function and delete every other route to the
column. That is what the fix was in all three cases: a single helper that answers “how much has
actually been received on this booking”, with the callers reduced to asking it.
Two paths to the same number will disagree, and the quiet one wins. The settlement page and the driver’s due amount computed the same figure independently. The settlement page was right, so nobody noticed the other one for months — until a driver acted on it at a kerb. The office-facing screen is the one people look at; the field-facing one is the one that moves money. Where two answers exist, the more visible one is not automatically the one that matters.
A configuration flag that some code ignores is worse than no flag. The taxable flag gave the office a control that was real on one screen and decorative on another. If a setting cannot be honoured in some context, that context should refuse the setting — the master screen should have declined to mark a rental-bucket type taxable, or the summary engine should have raised on finding one. Silently disagreeing with your own configuration is how an operator ends up hand-correcting seventeen entries without telling anybody.
The part that is not a code fix
Fifty-six bookings carried the unpaid-advance figure when it was found. They were not backfilled; they self-correct on the next upstream sync. That was a deliberate call, and a defensible one.
The two handovers that had already happened under the wrong deposit figure were not fixable at all. Money not collected at the kerb is a conversation with the customer, not a deploy. That is the real cost of this class of bug: by the time you find it, some of the damage has already left the system and become somebody’s phone call.
If you take one habit from this, make it a query rather than a rule. Go through your schema and list every column holding an amount. For each one, write down the other field that says whether it happened. Then grep for reads of the amount column and check each one honours it. In this codebase that exercise would have found all three, and it would have taken an afternoon.