Money in APIs: Make Decimal Rounding an Explicit Contract

Define decimal inputs, currency scale, rounding mode and rounding stage in money APIs, then test totals and refunds against an explicit calculation contract.

In this article

A money API needs an explicit calculation contract: how amounts enter the system, which precision is retained, when rounding occurs, and what currency scale applies. Using a decimal type helps, but it does not decide those business rules for you. Two exact calculations can still disagree because they round at different stages.

Python's decimal arithmetic documentation explains decimal construction, contexts, and rounding controls. The examples below are engineering tests using invented values, not tax rules or instructions for a particular payment provider. Confirm applicable financial requirements separately.

Define the input representation

Decide whether the API accepts decimal strings, integer minor units, or another documented representation. Include the currency explicitly. The number 100 is ambiguous without knowing whether it means a hundred major units or a hundred minor units.

Validate scale, signs, and allowed range before performing calculations. A quantity, unit price, exchange rate, and final payable amount may require different precision. Do not impose one global two-decimal rule on every field and every currency.

Reject unsupported or ambiguous values with useful errors. A blank string should not silently become zero. Decide how exponential notation and leading signs are handled, and apply the same rule in every client and backend that participates in the transaction.

Construct decimals from the intended value

In Python, a small teaching example is:

python
from decimal import Decimal, ROUND_HALF_UP

amount = Decimal("2.675")
rounded = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
assert rounded == Decimal("2.68")

The string preserves the intended decimal input. Converting an already approximated binary floating-point value into a decimal does not recover the original human intention. The conversion boundary matters as much as the type used afterward.

Choose the rounding mode because the contract requires it, not because a sample uses it. Half-up and half-even can produce different results at ties. Write tests that deliberately reach those ties instead of relying only on everyday values that both modes round identically.

Decide where rounding belongs

Suppose three invented line items each produce an unrounded amount of 0.335. Rounding each line to two decimal places using half-up gives 0.34 three times, or 1.02. Adding first gives 1.005, which rounds to 1.01 under that same mode.

Neither result can be selected merely by saying “we use decimals.” The contract must say whether the system rounds lines, subtotals, taxes, or final totals and how those displayed values reconcile. Retain intermediate precision only to the extent the reviewed rule requires.

If different systems use different stages, document a reconciliation rule rather than inserting a mysterious adjustment whenever totals differ. A one-cent discrepancy can be systematic evidence of a rule mismatch, not a random arithmetic defect.

Make currency and rate boundaries explicit

Do not assume that every currency uses two decimal places or that every provider accepts the same unit convention. Validate against the actual provider and currency requirements before creating a payment request.

Exchange rates need their own source, timestamp, and precision policy. The final amount paid in one currency can differ from a simple display conversion because of settlement and fee rules. Avoid relabeling a preview estimate as an observed settled amount.

Keep rates and amounts separate in logs and schemas. A developer should be able to identify which value is an input, which is a calculated result, and which is a provider-confirmed transaction field without inferring it from the number of digits.

Test negative values and partial corrections

Include refunds, discounts, zero amounts, and partial adjustments in the test set. Negative halfway cases can expose assumptions hidden by positive examples. Check the exact rounding mode's documented behavior rather than relying on the phrase “round up.”

Test repeated partial refunds against the original eligible amount. The system should not create or lose money because each correction independently rounds an unbounded fraction. Preserve the original calculation basis and a clear remaining balance.

Our credit-note reconciliation guide explains why correcting an invoice and returning money are distinct states. The arithmetic contract needs to remain consistent across both paths.

Publish fixtures for every integration

Create a small shared set of inputs and expected outputs covering ties, large values, currencies, invalid input, and line-versus-total behavior. Run those fixtures in the frontend, API, background worker, and billing export where each performs calculations.

Use JSON Diff to inspect redacted fixture results and find changed fields after a library update. It compares structure and values; it does not decide whether the financial rule is correct.

When a rule changes, version it and state how historical transactions remain interpretable. Recalculating an old invoice under today's rule can make an audit trail appear inconsistent even when the original transaction followed its documented contract.

Conclusion

Exact decimal storage is a foundation, not a complete money model. Specify input units, precision, rounding mode, and rounding stage; then test the same fixtures across every calculation boundary and preserve the rule used for historical records.

Advertisement