DEV Community

desgh white
desgh white

Posted on

Money Rounding Without Silent Losses: Decimal, Half-Even and Idempotent Payouts

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)