Every payments codebase eventually meets the same bug: totals that disagree by a cent. It is never one dramatic failure — it is thousands of tiny roundings that drift apart, and the ledger notices before the tests do.
Floats are the wrong tool
>>> 0.1 + 0.2
0.30000000000000004
>>> round(2.675, 2)
2.67 # not 2.68 — 2.675 is not exactly representable
Binary floating point cannot represent most decimal fractions, so the error compounds across every intermediate step. Use Decimal and keep the scale explicit:
from decimal import Decimal, ROUND_HALF_EVEN
CENT = Decimal("0.01")
def to_cents(amount: Decimal) -> Decimal:
return amount.quantize(CENT, rounding=ROUND_HALF_EVEN)
Why half-even, not half-up
ROUND_HALF_UP biases every tie upward. Over a large number of settlements that bias is a systematic drift in one direction. Banker's rounding (ROUND_HALF_EVEN) sends ties to the nearest even digit, so the errors cancel instead of accumulating:
sum(to_cents(Decimal(f"{i}.005")) for i in range(10_000))
Run that with both modes and compare the totals — the difference is the money your reconciliation job will chase.
Round once, at the boundary
The rule that removes most bugs: compute at full precision, round exactly once when the value becomes an amount someone is paid. Rounding intermediate multiplications means the same total depends on evaluation order, which is how two services disagree about one number.
Make the payout idempotent
Rounding correctly still leaves the retry problem: a timeout during payout is indistinguishable from a failure, and the naive retry pays twice.
def payout(conn, payout_id: str, user_id: int, amount: Decimal) -> bool:
cur = conn.execute(
"INSERT INTO payouts(id, user_id, amount_cents) VALUES (?, ?, ?) "
"ON CONFLICT(id) DO NOTHING",
(payout_id, user_id, int(to_cents(amount) * 100)))
return cur.rowcount == 1 # False = already paid, safe to ignore
Store integer cents, derive the key from the settlement event rather than the request, and the retry becomes a no-op instead of a duplicate.
Reference
Consumer-facing payment pages are a useful sanity check on all of this: a site like play at True Fortune Casino lists deposit and withdrawal minimums per method, and those published limits are exactly the boundary values worth turning into test cases — the amounts where rounding, fees and minimums interact.
Takeaway
Decimal over float, half-even over half-up, round once at the boundary, and make the write idempotent. Four rules, and the cent-level drift stops being a recurring ticket.
Top comments (0)