FifeRouter

29 August 2026 · ledger money mistakes

How a refund invents a debt you do not owe

We shipped refund handling, then found the state where reversing a payment creates a debt from nothing. The fix turns on a distinction we had not needed until that afternoon.

The rule seemed complete when we wrote it. A payment refunded at the card takes its credit back with it — the balance follows the money. We built it, tested it, and it was right.

Then the first real top-up was charged and never credited, and the rule became wrong.

What happened

Ten dollars, a live card, a webhook endpoint created with no events subscribed to it. Stripe accepted the endpoint happily and sent it nothing. The payment completed, the browser came back to the "confirming your payment" page, and the ledger stayed empty — which is exactly what that page is worded for, because the balance moves on the webhook and never on the redirect.

So far, so recoverable. Stripe keeps events at the account level; resending the original one credits the account once, and the dedupe key means resending it twice does nothing.

The interesting part is what would have happened if we had refunded instead.

Two states that look identical

Reversing a refunded payment can leave a balance negative, and we had already decided that was correct. Someone tops up $10, spends $8, and refunds the payment: they have had $8 of service and paid nothing for it. A balance of −$8 is the true statement of that. Clamping it to zero would forget a debt rather than settle it.

But now consider the account whose payment was charged and never credited. Their balance is $0. Refund the payment and the same code posts a −$10 reversal, and the balance is −$10.

From the ledger's side those two outcomes are indistinguishable — both are negative balances following a refund. Only one of them is a debt. The other is a customer who is square with us and now appears to owe money for a service they never received.

The distinguishing fact

What separates them is not the balance. It is whether any payment was ever credited in the first place.

So a reversal may take back at most what the account's top-ups have put in, net of reversals already posted:

reversible_headroom = Σ(topup) + Σ(reversal)

Deliberately not the balance. Spending credit does not make the payment behind it less refundable — that is precisely the legitimate case where the balance should end up negative. Headroom is about what was paid, not what remains.

Beyond that headroom, the reversal raises. And the webhook answers 200, not 5xx.

That last choice is the one that took thought. Every other failure in that handler must reach Stripe as an error so the event is retried; a reversal that fails for a transient reason should absolutely be tried again. But no amount of retrying makes a missing credit appear. A permanent retry loop would bury the discrepancy under noise instead of surfacing it. So it is logged at error level, loudly, and left for a person — because the money is real and the resolution is a judgement.

The part that was already right

Two days later we added starting credit for new accounts, and the same question arrived from the other direction: should a grant count toward reversible headroom?

Obviously not. A grant is credit we gave away; no payment stands behind it, so no card refund can take it back. If a grant counted as a top-up, refunding an unrelated payment could claw back free credit — the same phantom debt, arriving through the front door.

The pleasing part: the guard needed no change. It had been written as FILTER (WHERE kind = 'topup') rather than "anything positive", so it was already correct about a ledger kind that did not exist when it was written.

That is not foresight. It is what you get from naming the thing you mean instead of the shape it currently has.

What we would tell ourselves

The reversal path had no guard because in the normal flow a credit always precedes a refund. That assumption held right up until the webhook stopped delivering — which is a fair description of most assumptions that turn into incidents.

The states worth guarding are not the ones you can imagine. They are the ones that only become reachable when something else breaks.


← All posts