Skip to content

How to test banker's rounding in payment and ledger code

Testing banker's rounding, or round half to even, means building amounts that land exactly half a minor unit between two values per currency and comparing each result with a decimal reference model. The check that finds most defects repeats every tie with an odd and an even kept digit, sent as a decimal string and as a binary float.

Where half-cent ties turn into wrong amounts

A rounding defect hides in code that passes every test built on ordinary prices. Fixtures such as 19.99 and 100.00 never produce a value exactly half a minor unit between two candidates, so the rounding mode is never exercised. The defect reaches production in three forms: a tie rounded with the wrong mode, a binary float that is no longer a tie when it is rounded, and a split that leaves a residual minor unit on no line or on the wrong one.

Each form repeats on every record that hits it. A 1.5% fee on USD 15.00 is exactly 0.225: round half to even gives 0.22 and round half up gives 0.23. When checkout rounds one way and the ledger the other, every such fee becomes a one-cent amount mismatch against the processor file, which the ledger reconciliation guide classifies as a break. In instalment products, a remainder on the wrong instalment makes the collected schedule differ from the agreed one, and the BNPL segment page traces how an instalment error flows into late fees and refunds.

Some rules fix the rounding. Council Regulation (EC) No 1103/97 Article 5 says that amounts to be paid or accounted for after a conversion between the euro and the national currency unit of a participating Member State "shall be rounded up or down to the nearest cent", or for amounts converted into a national currency unit to the nearest sub-unit (or unit, or a multiple or fraction set by national law or practice), and that a result exactly half-way "shall be rounded up". Article 4 adds that the conversion rate "shall not be rounded or truncated" and that inverse rates "shall not be used". The rule has applied to lev conversions since Bulgaria adopted the euro on 1 January 2026 at 1.95583 lev per euro (ECB press release, 1 January 2026). Banker's rounding breaks Article 5 on every tie with an even kept digit.

For US remittance transfers, 12 CFR 1005.31(b)(1)(iv) requires the disclosed exchange rate to be "rounded consistently for each currency to no fewer than two decimal places and no more than four decimal places", and (b)(1)(vii) computes the Total to Recipient with the rate "prior to any rounding". The rule names no tie mode, so the mode comes from your specification and the test holds it constant per currency. The Regulation E page covers the rest of the disclosure, and rate application sits in the FX rate application guide. All texts above were checked on 2026-09-22.

Procedure

1. Write the rounding specification per amount

Record, for each amount the system produces, where it is rounded, which mode applies, where the mode comes from, and the scale. Keep the source of the mode separate from the rule text, so a product decision is never read as a legal requirement.

Data
amount,rounding_point,mode,mode_source,scale
card_fee,per transaction after amount x rate,HALF_EVEN,product specification,ISO 4217 minor units
fx_disclosed_rate,per quote,HALF_EVEN,product specification (12 CFR 1005.31(b)(1)(iv) sets 2 to 4 decimals),4 decimals
fx_total_to_recipient,per transfer on the unrounded rate,HALF_EVEN,product specification (1005.31(b)(1)(vii) sets the unrounded rate),ISO 4217 minor units
eur_bgn_conversion,per amount after conversion,HALF_UP,Regulation (EC) No 1103/97 Article 5,cent or stotinka
instalment_split,per schedule,REMAINDER_ON_FIRST,credit agreement terms,ISO 4217 minor units

Read the scale from ISO 4217, never from a constant 2. The ISO 4217 list one published on 2026-09-17 gives 0 minor units for JPY, 2 for USD and EUR, 3 for KWD, BHD, and TND, and 4 for CLF. BGN now sits on list three of historic codes with withdrawal date 2026-01. The test currency XTS has minor units "N.A.", so rounding vectors need a named currency. Interest accrual has its own rounding points and day counts, covered in the interest accrual guide.

2. Generate tie vectors in every currency scale

A tie exists only when the exact product ends in a single 5 one digit beyond the currency scale. Build each tie twice: once with an even kept digit, where half-even and half-up disagree, and once with an odd kept digit, where they agree. The last two columns show what float code returns for the same inputs.

Data
currency,amount,rate,exact_fee,half_even,half_up,python_round_on_float,js_math_round_on_float
USD,15.00,0.015,0.225,0.22,0.23,0.22,0.22
USD,17.00,0.015,0.255,0.26,0.26,0.26,0.26
JPY,1500,0.015,22.5,22,23,22,23
JPY,1700,0.015,25.5,26,26,26,26
KWD,1.500,0.015,0.0225,0.022,0.023,0.022,0.023
KWD,1.700,0.015,0.0255,0.026,0.026,0.025,0.026

The Python column is round(float(amount) * float(rate), scale); the JavaScript column is Math.round(amount * rate * 10 ** scale) / 10 ** scale. Both were run on 2026-09-22. Add each row with a negative amount for refunds and reversals.

3. Send each vector through every entry path

Amounts enter as JSON strings from the API, as numbers from older clients, and as columns from files. Send the same tie through each path.

Request
POST /v1/fee-quotes HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_test_EXAMPLE_ONLY
Content-Type: application/json

{"amount": {"value": "15.00", "currency": "USD"}, "rate": "0.015"}
JSON
{"fee": {"value": "0.22", "currency": "USD"}, "rounding": "HALF_EVEN"}

Repeat the request with "value": 15.0 as a JSON number. The service either rejects it or returns the same fee; any other result shows a float on the path.

Python
# test_rounding_ties.py: run with pytest against a test environment
from decimal import Decimal, ROUND_HALF_EVEN
import pytest
import requests

BASE = "https://api.example.com/v1"
MINOR_UNITS = {"USD": 2, "JPY": 0, "KWD": 3}  # ISO 4217 list one, published 2026-09-17
TIES = [("USD", "15.00"), ("USD", "17.00"), ("JPY", "1500"),
        ("JPY", "1700"), ("KWD", "1.500"), ("KWD", "1.700")]
RATE = "0.015"

def expected(amount: str, currency: str) -> Decimal:
    unit = Decimal(1).scaleb(-MINOR_UNITS[currency])
    return (Decimal(amount) * Decimal(RATE)).quantize(unit, rounding=ROUND_HALF_EVEN)

@pytest.mark.parametrize("sign", ["", "-"])
@pytest.mark.parametrize("currency,amount", TIES)
def test_fee_tie(currency, amount, sign):
    amount = sign + amount
    body = {"amount": {"value": amount, "currency": currency}, "rate": RATE}
    try:
        resp = requests.post(f"{BASE}/fee-quotes", json=body, timeout=10)
    except requests.RequestException as exc:
        pytest.fail(f"no response: {exc!r}")
    try:
        fee = resp.json()["fee"]["value"]
    except (ValueError, KeyError, TypeError):
        pytest.fail(f"HTTP {resp.status_code}, unexpected body: {resp.text[:200]!r}")
    assert isinstance(fee, str), f"fee returned as JSON {type(fee).__name__}"
    assert Decimal(fee) == expected(amount, currency)
    assert -Decimal(fee).as_tuple().exponent == MINOR_UNITS[currency]

The last assertion catches a JPY fee returned as "22.00" or a KWD fee cut to two decimals.

4. Map the rounding defaults of each runtime in the path

Every hop that rounds can change a tie. These are the documented defaults, checked on 2026-09-22:

RuntimeCallTie behaviourSource
Pythonround(x, n)Half to even on the stored binary value; round(2.675, 2) returns 2.67Python built-ins
Pythondecimal default contextROUND_HALF_EVEN at 28 digits of precisionPython decimal
PythonDecimal(2.675)Converts the exact binary value of the floatPython decimal
JavaBigDecimal.setScale(n, mode)Mode passed by the caller; HALF_EVEN is documented as "Banker's rounding"Java RoundingMode
Javanew BigDecimal(0.1)Exact binary value; BigDecimal.valueOf(double) uses Double.toStringJava BigDecimal
JavaScriptMath.round(x)Exact .5 goes toward positive infinity; Math.round(-5.5) returns -5MDN Math.round
JavaScriptIntl.NumberFormatroundingMode defaults to halfExpand, ties away from zeroMDN NumberFormat
PostgreSQLround(numeric, s)Ties away from zero; for double precision platform dependent, usually to evenPostgreSQL math functions
MySQLROUND()Exact values away from zero; approximate values per C library; ROUND(2.5) is 3 and ROUND(25E-1) is 2MySQL precision math
SQL ServerROUND()Ties away from zero; ROUND(-1.15, 1) is -1.2Microsoft Learn ROUND

A service on Python decimal defaults and a finance report on PostgreSQL round(numeric, 2) disagree on every tie with an even kept digit. A front end that formats with Intl.NumberFormat defaults shows a different amount from a half-even back end on the same ties. Run the SQL check below against stored postings to find ties posted with the wrong mode:

SQL
-- PostgreSQL: postings whose unrounded value is a tie and whose posted value is not half-even
WITH t AS (
  SELECT f.id, f.currency, f.raw_amount, f.posted_amount, c.minor_units AS s,
         f.raw_amount * power(10::numeric, c.minor_units) AS scaled
  FROM fee_postings f
  JOIN iso4217_currencies c ON c.code = f.currency
), e AS (
  SELECT t.*,
         CASE WHEN mod(trunc(scaled), 2) = 0 THEN trunc(scaled)
              ELSE trunc(scaled) + sign(scaled) END / power(10::numeric, s) AS half_even
  FROM t
  WHERE abs(scaled - trunc(scaled)) = 0.5
)
SELECT id, currency, raw_amount, posted_amount, half_even
FROM e
WHERE posted_amount <> half_even;

Keep raw_amount as numeric. power(10::numeric, ...) keeps the arithmetic in numeric; with a double precision operand the query would round the value it is testing.

5. Test remainder allocation on every split

Splits appear in instalment schedules, marketplace payouts, fee sharing, and partial refunds. Rounding each share on its own does not work: 100.00 in three equal shares rounds to 33.33 three times, so 0.01 is never collected. The reference below works in integer minor units and gives leftover units to the largest remainders.

Python
def allocate(total_minor: int, weights: list[int]) -> list[int]:
    """Split total_minor by weights. Floor each share, then give the leftover
    minor units to the largest remainders; equal remainders go to the lower index."""
    if total_minor < 0:
        return [-part for part in allocate(-total_minor, weights)]
    w = sum(weights)
    parts = [total_minor * wi // w for wi in weights]
    order = sorted(range(len(weights)), key=lambda i: (-(total_minor * weights[i] % w), i))
    for i in order[: total_minor - sum(parts)]:
        parts[i] += 1
    return parts

# allocate(10001, [1, 1, 1, 1]) == [2501, 2500, 2500, 2500]   USD 100.01 in four instalments
Python
from hypothesis import given, strategies as st

@given(st.integers(-10**9, 10**9), st.lists(st.integers(1, 10**6), min_size=1, max_size=12))
def test_split_properties(total, weights):
    parts = sut_split(total, weights)            # the system under test, in minor units
    w = sum(weights)
    assert sum(parts) == total                   # no minor unit lost or created
    for part, wi in zip(parts, weights):
        assert abs(part * w - total * wi) < w    # each share within one minor unit of exact
    assert sut_split(-total, weights) == [-p for p in parts]   # a refund mirrors the charge

For instalments, the placement of the extra unit comes from the credit agreement: first, last, or spread. Assert the exact list against the reference, because the property test alone accepts any placement.

6. Test the euro conversion rule where it applies

Python
from decimal import Decimal, ROUND_HALF_UP

BGN_PER_EUR = Decimal("1.95583")   # ECB, 1 January 2026
CENT = Decimal("0.01")

def eur_to_bgn(eur: Decimal) -> Decimal:
    return (eur * BGN_PER_EUR).quantize(CENT, rounding=ROUND_HALF_UP)   # Art. 5: half-way rounds up

def bgn_to_eur(bgn: Decimal) -> Decimal:
    return (bgn / BGN_PER_EUR).quantize(CENT, rounding=ROUND_HALF_UP)   # Art. 4(3): divide, no inverse rate

assert eur_to_bgn(Decimal("1500.00")) == Decimal("2933.75")   # exact 2933.745; half-even gives 2933.74
assert bgn_to_eur(Decimal("30.11")) == Decimal("15.39")       # times the inverse 0.511292 gives 15.40

The pitfall: ties your production data never produces

Float code often passes a half-even suite by accident. In binary, 15.00 × 0.015 is 0.22499999999999998, which rounds to 0.22 in both Python and JavaScript and looks like banker's rounding. Only the JPY 1500 and KWD 1.500 rows, where the scaled value is exactly 22.5 in binary, show that Math.round sends a positive half upward, and only the KWD 1.700 row shows Python returning 0.025 for an exact 0.0255. A suite with USD vectors alone certifies the wrong implementation.

The euro rule hides its tie case even better. A lev amount in whole stotinki divided by 1.95583 never lands exactly on a half cent: an exact half cent needs 200,000 times the stotinki amount to equal 195583 times an odd number, and 195583 is odd, so the right side is odd while the left side is even. In the other direction, only euro amounts of 500.00 plus a multiple of 1,000.00 convert to an exact half stotinka, and the first one where half-up and half-even differ is EUR 1,500.00. Production-like fixtures rarely contain these amounts, so a conversion service configured with a half-even default passes on production-like data and still breaks Article 5. Generate the vectors from the arithmetic.

Inverse rates fail more often. Multiplying by 0.511292, the inverse rounded to six significant figures, turns BGN 30.11 into EUR 15.40 instead of 15.39. BGN 41.18, 68.19, and 79.26 fail the same way under half-up.

Test matrix

CaseInputExpected
Even kept digit, half-even specifiedUSD 15.00 at 1.5%0.22
Odd kept digitUSD 17.00 at 1.5%0.26 under both modes
Zero-decimal currencyJPY 1500 at 1.5%22, no decimals returned
Three-decimal currencyKWD 1.700 at 1.5%0.026, three decimals returned
Negative tieUSD -15.00 at 1.5%-0.22
Amount sent as JSON number15.0Rejected, or the same fee as the string
Report and service on one tiePostgreSQL round(numeric) against Python decimalSame value, or a documented mode per system
Three-way equal splitUSD 100.0033.34, 33.33, 33.33 or the agreed placement
Refund splitUSD -100.01 in fourMirror of the charge split
Euro to lev tieEUR 1,500.00BGN 2,933.75
Lev to euro near tieBGN 30.11EUR 15.39

The financial calculation testing service turns this matrix into a boundary and rounding test suite per formula, run against an independent reference model.

Questions

Why is banker's rounding used?

Banker's rounding, or round half to even, rounds an exact half-way value up when the kept digit is odd and down when it is even, so ties do not push totals upward the way round half up does. The Java RoundingMode documentation says half-even statistically minimizes cumulative error. A rule can still require half up, such as Council Regulation (EC) No 1103/97 Article 5 for euro conversions.

Why not use floating point for money?

Binary floating point stores most decimal amounts as a nearby binary value, so 2.675 is held as slightly less than 2.675 and Python's round(2.675, 2) returns 2.67. The stored error decides the result at exact half-way ties and differs between currencies. Keep amounts in integer minor units or a decimal type, and parse them from decimal strings, never from floats.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing