[7] A Missing Version Guard Let Concurrent Invoice Updates Lose Money
The trick: guarding a write on status and guarding it on version solve two different problems — status stops illegal transitions, a version token stops lost increments. Money code under concurrency usually needs both.
Issue
Revoking an API key folds its usage into an open invoice. Under concurrency, money went missing — sometimes usage vanished, sometimes an already-paid invoice was silently reopened and overwritten.
Root Cause
Four bugs, found in sequence over six hours. A blind update let a payment webhook race the revoke and get overwritten (double-billing). Guarding the update on invoice status fixed that but dropped the usage on the floor when refused (under-billing). Recovery only covered one of three race paths. And a plain lost update remained: two concurrent revokes both read the same total, both passed the status guard, and the second silently overwrote the first.
Solution
One atomic update with both a status guard and an optimistic-lock version token, retried with fresh reads on conflict, with the loser routed to recovery rather than discarded.
💡 Takeaway
- Status guards stop illegal transitions; version tokens stop lost increments. Concurrent money code usually needs both.
- A refusal is not a resolution — decide where the refused value goes before shipping the guard.
- Optimistic locking is blind to invariants that live in a different table; those need post-write verification instead.
