The dialog said 18 credits. The page it opened from said 0.
Same tournament, same moment, same database. The ticket tracking this had been open long enough to get lost once and found again, and the reason it kept coming back was this exact contradiction: a customer opens a tournament they’re about to delete, the confirmation dialog tells them they’ll get 18 credits back, they close the dialog to double check, and the detail page they land on says the tournament is worth 0.
Here’s the mechanism. The Detail page was reading a raw inventory field, something close to tournament.credits_balance, a column that gets written once when credits are allocated and mostly left alone. The delete dialog wasn’t reading that column at all. It was running a calculation: walk the tournament’s teams, sum what’s refundable per team based on category (free entries, purchased entries, last-year carryovers), and net it against anything already spent on setup. Two different code paths, two different questions. credits_balance answers “what got allocated.” The delete calculation answers “what would you actually get back right now.” Nobody had ever needed those two numbers to agree until a customer looked at both in the same five minutes.
The first fix I tried was the fast one: patch the Detail page to read the same field the dialog read. I found where the dialog pulled its number, wired the Detail page’s credits display to call into the same helper, and it worked in the one case I tested, a tournament with a straightforward purchase. Then I ran it against a free-entry tournament and got a negative number on the page. The helper wasn’t a pure read, it was doing the refund math assuming a delete was actually happening, including deducting for setup costs that only apply mid-delete-flow. I’d wired a “what happens if you delete this” function into a “here’s your balance” display, and it showed a customer they owed money on a tournament they hadn’t touched. That’s worse than showing 0. I reverted it that hour rather than let it sit even locally.
What actually needed to happen was pulling the calculation apart into two clean questions instead of one function doing double duty: a refund total (free credits plus purchased credits, the number the dialog needs) and a spendable balance (the same components, just without the delete-flow deduction, the number the Detail page needs). Once they were separate functions built from the same team-level data instead of one function with a delete flag threaded through it, both call sites could ask their own question and get an honest answer. The Detail page now shows free-plus-purchased spendable credits. The delete dialog shows the refund total. They agree, because they’re built from the same source rows, they just answer different questions and say so.
Root cause, once I’d traced it properly, was that event delete bypassed the credit refund chain entirely on one path. That’s a separate, uglier bug: it means the raw credits_balance column could drift from reality any time a tournament got deleted through that path, since nothing recalculated it. The Detail page displaying that stale column wasn’t just cosmetic, it was surfacing a real accounting gap that the delete dialog’s live calculation had been silently correct about the whole time.
Testing before ship meant going through every delete case by hand: free, locked, last-year, paid, competition-only, and setup delete, checking that the refund matched the spendable balance shown before delete and the actual credit total after. All six passed. Shipped as a hotfix, ticket closed same day.
The lesson that stuck: when a UI shows the same conceptual value in two places, don’t assume they’re reading the same thing just because the words on screen say “credits.” Grep both call sites back to their source before you touch either one. I skipped that step once, on the honest belief that “it’s just relabeling a display,” and it cost an hour and nearly shipped a screen that told a customer they were in debt. The second time through, tracing both paths to their actual source rows first, the fix took twenty minutes and didn’t need a revert.