The Silent Decimal Bug That Zeroed Payouts
An Allowance Quietly Stops Being Paid
I work on a payroll calculation engine. One day, a configured allowance simply stopped appearing in results. No exception, no failed request, no log entry. The calculation ran green from end to end — it just paid zero for something that should have paid money.
In payroll, this is the worst class of bug. A crash gets noticed within the hour. A wrong number that looks plausible can survive until someone's payslip is short — and by then it has an audit trail of "successful" runs behind it.
One Type For Two Kinds of Numbers
The engine's rules are configured with typed parameters. All numeric parameters shared a single type — call it Amount — and the configuration API's serializer treated every Amount as a double. But money values in the engine are decimal, as they should be in any .NET codebase that handles currency.
The retrieval side looked innocent:
// The generic accessor returns default(T) when the stored
// value's type doesn't match the requested type.
var rate = context.GetValue<double>("HourlyAllowance");
// stored as decimal => rate == 0.0, no exception
Reading a decimal value through the double path didn't throw. It returned zero. The allowance rule multiplied hours by 0 and reported success.
Why the Fix Wasn't "Just Cast It"
The tempting fix is to patch the one call site. But the real defect was that the type system had no way to say "this parameter is money." Every call site had to remember which numeric flavour it was reading, and any future parameter could reintroduce the bug.
The fix that actually holds:
- A distinct Money parameter type mapped to
decimal, keepingAmountonly for values that genuinely are doubles (multipliers, factors). - Migrating every affected parameter — fourteen of them, from hourly allowances to per-day expense rates — to the new type.
- Fixing the read side so retrieval requests the matching type instead of silently yielding a default.
- Teaching the serializer to branch on the two types, and the configuration UI to render the new one.
Four layers — the type enum, the persisted definitions, the API serializer, and the front end — had to ship together, or the feature would be broken in the middle.
What I Took Away
- Silent defaults are landmines. An accessor that returns
default(T)on a type mismatch converts programming errors into plausible-looking data. Throwing would have surfaced this in the first test run. - Make the type system carry the rule. "Money is decimal" was tribal knowledge enforced at every call site. After the fix it is a compile-time and serialization-time fact.
- Wrong-number bugs deserve louder tests than crash bugs. We now regression-test engine output field by field against captured calculations, precisely because this class of failure produces no signal of its own.