October 1, 2026 · 4 min read
Payment Success Is Not a Boolean
A remainder of R3.05 left a failed charge, a hold stuck on a card, and a balance showing as owed. Nothing was down. My model of 'paid' was too small.
- payments
- distributed-systems
- idempotency
- pre-authorization
Every payments table I've worked with has a status column, and every one of them tempts you to read it as a yes or a no. Paid or not paid. I believed that for longer than I should have. What cured me was three rand and five cents.
The setup
I was building card pre-authorization for a taxi product: at ride start the fare is held on the customer's card, and at ride end only the final fare is captured and the gateway releases the rest. Nothing is charged at booking, so a customer who never takes the ride is never debited. On top of that sat ride edits — the customer can change the destination, even mid-ride — and split payments, where the wallet covers part of the fare and the card covers what's left.
Each piece worked. Holds were placed, captures landed, the wallet debited the right amount. The problem appeared where they met.
Three rand and five cents
A ride ended with most of the fare covered and a small remainder still owed on the card. The remainder was R3.05. Payment gateways have a smallest amount they will accept, and this was under it:
ride ends, fare settled against wallet + card
remainder owed on card: R3.05
gateway: amount below minimum → charge refused
provider: hold still open, nothing captured
ledger: R3.05 outstanding
customer: money reserved on the card, and a billNo service was down. The gateway refused an amount its contract says it refuses. Our ledger recorded, truthfully, that R3.05 was unpaid. The customer's bank showed, truthfully, a reservation that nobody had released. Three systems, each correct, describing three different payments. We caught it in testing, which is the only reason I get to tell this calmly.
Three states, not one
The status column had been hiding the fact that a payment lives in three places at once, and each one moves on its own clock:
provider: held → captured | released | expired
ledger: owed → settled | waived | refunded
customer: reserved → charged | returned'Success' is the special case where all three happen to agree. The engineering is in everything else: knowing which combinations are legal, and having a defined move for each one that isn't. A boolean has no room for 'held but not captured', and that is exactly where the R3.05 was sitting.
The fix: a waiver, applied three times
When the amount still owed is below what the gateway will accept, we write it off instead of failing the payment, and the invoice says so in plain words: 'Waived Off'. Failing a whole ride over a few cents helps nobody, and an honest line item is better than a silent rounding.
The part that took longer to see is that one waiver isn't enough. A too-small remainder can come into existence at three moments: when a ride is edited, at ride end before the charge is attempted, and at capture. The same rule is applied at all three, so they always agree. Fix only one and you have built a new way for the three states to drift apart.
Rules that fell out of it
- Collect before you commit, give back after. A ride is never edited on the promise of a payment that may fail, and nothing is returned for a change that didn't save.
- Send totals, not deltas. The app sends the total wallet amount for the ride, never the change, so a retried request cannot debit twice.
- Release the old hold last. The previous hold is let go only after the edit is saved. If that release fails, it is logged rather than rolled back — the edit and the new hold are already valid.
- Refuse rather than round. Below the gateway's minimum hold, the request is rejected with a clear message. Quietly holding more than the fare is a lie the customer finds on their statement.
- Give every money movement its own name. Edit adjustments are recorded under their own event, so they never collide with the booking or cancellation entries, and a later cancellation refunds exactly what is still held.
The mirror image
A different flow showed me the same bug from the other side. A shuttle pass purchase succeeded at the gateway, and then the booking was rejected — the app had sent one extra field the booking step didn't accept, and it refused silently. The provider said paid. There was no booking and no refund. 'Payment succeeded' was true, and useless.
The fix was not to accept the field. It was to stop treating a successful payment as the end of the story: if the booking fails for any reason after the money has moved — seat taken, wallet changed, invalid request, expired hold — the payment is refunded and the customer is told. Success at the provider is a claim the rest of the system still has to honour.
The takeaway
'Did the payment succeed?' is the wrong question, because it has three answers. Ask instead what the provider is holding, what your ledger believes, and what the customer can see — and what reconciles them when they disagree. Most payment bugs I've met weren't failures. They were three honest systems that nobody had made agree.