Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation9 min read

Use decimal arithmetic for financial imports

Three invoice fractions show rounding stage and exact decimal input. A worked analytical example with explicit assumptions and checked results.

Paper charts with an olive scatter plot, histogram, and data table on a beige desk

Parse decimal financial values from their original decimal strings and choose the rounding stage explicitly. Decimal arithmetic can preserve values such as 0.1 exactly, but it does not decide whether an invoice rounds each line or rounds only the final total. Those policies can produce different legitimate numbers.

For a developer importing invoices into an analytical table, the contract needs both arithmetic representation and business rounding rules. A value can be represented exactly and still be aggregated at the wrong stage. Reconciliation requires reproducing the source system’s policy, including its treatment of ties.

Three fractional fees expose the rounding stage

Suppose a synthetic invoice contains three fees of 0.005 currency units. The example currency uses two decimal places for displayed amounts, and the chosen policy rounds halfway values away from zero.

from decimal import Decimal, ROUND_HALF_UP

fees = [Decimal("0.005"), Decimal("0.005"), Decimal("0.005")]
cent = Decimal("0.01")
rounded_lines = [fee.quantize(cent, rounding=ROUND_HALF_UP) for fee in fees]
line_total = sum(rounded_lines, Decimal("0.00"))
invoice_total = sum(fees, Decimal("0.00")).quantize(
    cent, rounding=ROUND_HALF_UP
)
print("round each line:", line_total)
print("round final total:", invoice_total)

Rounding each fee gives 0.01 per line and a total of 0.03. Summing the exact fees first gives 0.015, which rounds to 0.02. The one-cent difference comes from policy, not a floating-point defect.

The Python decimal documentation describes decimal construction, contexts, quantization, and supported rounding modes. The code names the rounding mode because relying on a default would leave an important part of the invoice contract implicit.

Three exact fees of 0.005 total 0.03 when each line is rounded first, but total 0.02 when their sum is rounded onceView full-size image ↗

The branches in the diagram apply different rounding stages to the same source values. A reconciliation should identify which branch the issuer uses before calling either result incorrect.

Construct from source text before binary conversion

Converting a binary floating-point value to Decimal preserves that binary value’s approximation. It does not recover the original decimal token. Parse the source string directly when the source contract is decimal.

from_text = Decimal("0.1")
from_float = Decimal(0.1)
print(from_text)
print(from_float)
print(from_text == from_float)

The first value is exactly 0.1. The second displays a longer decimal expansion corresponding to the binary floating-point value, and the equality comparison is false. This is expected behavior: the constructor received different inputs.

Formatting an already parsed float back into a short string can conceal the conversion history. It may produce a useful display value, but it is not evidence that the original input had that precision. Preserve the raw decimal token and the declared scale during ingestion if those properties matter to audit or reconstruction.

Precision is a computational limit

Decimal can also represent special values such as NaN and infinity. A constructor accepting a string does not prove that the result is a finite invoice amount. Validate finiteness and the permitted scale before arithmetic, and keep an explicit rejection path for special values. Otherwise an imported non-finite token can contaminate totals while passing a check that merely confirms construction succeeded.

Decimal arithmetic still uses a context with finite precision for operations. Exact construction of a string does not imply that every later division has an exact finite result. Dividing one by three requires rounding in any finite decimal representation.

Choose sufficient working precision for intermediate calculations and apply the required final quantization at the specified boundary. Test large amounts and long fractional inputs against that context. An implementation that works for a short invoice can fail when a larger batch exceeds an assumed digit budget.

The library exposes signals for conditions such as inexact results and invalid operations. Decide which conditions should fail an import and which are expected consequences of a documented rounding step. Monitoring a signal without understanding its stage can produce noise; ignoring every signal can hide a loss of precision.

Currency and unit are part of the value

A numeric amount without a currency code cannot be safely combined with another amount. Store the currency or other financial unit explicitly, and group totals by that unit unless a documented conversion has been applied.

Different units need not share the two-decimal display rule used in this fixture. Exchange-rate conversions may also use more working precision than the final settlement amount. Keep the source amount, conversion rate, rate date, and rounded target amount separate when the report needs to explain the conversion.

Integer minor-unit storage is another valid approach when the unit’s scale is fixed and known. It simplifies exact addition at that scale, but fractional fees or intermediate exchange calculations may require a finer scale. The choice should match the operations the dataset must preserve.

Reconcile components before comparing totals

When an imported invoice differs by a cent, compare line rounding, tax rounding, discounts, and final quantization before changing the parser. The example demonstrates that an exact parser can faithfully produce a total different from the issuer’s if the aggregation policy differs.

Negative values deserve their own fixtures. A refund at a halfway boundary tests whether the chosen rounding rule handles signs as the contract expects. Zero, very small fees, and many repeated fractional amounts expose other assumptions that ordinary whole-number invoices do not challenge.

Store the arithmetic policy with the transformation version and retain the original decimal strings under the appropriate retention rules. A financial report becomes reproducible when another reader can identify the exact inputs, their units, the working precision, and the points where rounding occurred. Decimal arithmetic supplies the representation; the invoice contract supplies the meaning of the resulting cents.

Allocate a rounded total without creating another cent

After choosing invoice-level rounding, a downstream table may still require a settled amount for every line. Independently rounding those allocations can recreate the mismatch. In the three-fee fixture, the rounded invoice is two cents while every equal line rounded separately is one cent. A consumer cannot preserve both those independently rounded lines and a two-cent total without an explicit adjustment.

One possible contract allocates the settled total in integer minor units. Give each line a nonnegative integer weight representing its share, assign the floor of each proportional share, and distribute the remaining units by largest remainder. Use a stable line identifier to resolve equal remainders. This is an allocation policy, not a claim about which monetary rule every issuer must use.

def allocate_minor_units(total_units, weighted_lines):
    if type(total_units) is not int or total_units < 0:
        raise ValueError("nonnegative integer total required")
    ids = [line_id for line_id, weight in weighted_lines]
    if len(set(ids)) != len(ids):
        raise ValueError("line identities must be unique")
    if any(type(w) is not int or w < 0 for _, w in weighted_lines):
        raise ValueError("nonnegative integer weights required")
    weight_total = sum(w for _, w in weighted_lines)
    if weight_total == 0:
        raise ValueError("positive total weight required")
    amounts, remainders = {}, []
    for line_id, weight in weighted_lines:
        whole, remainder = divmod(total_units * weight, weight_total)
        amounts[line_id] = whole
        remainders.append((remainder, line_id))
    remaining = total_units - sum(amounts.values())
    for _, line_id in sorted(remainders, key=lambda x: (-x[0], x[1]))[:remaining]:
        amounts[line_id] += 1
    return amounts

allocated = allocate_minor_units(2, [("A", 5), ("B", 5), ("C", 5)])
print(allocated)
print("allocated units:", sum(allocated.values()))

The five-unit weights correspond to the identical fee amounts expressed in thousandths; their common scale cancels from the proportions. Each exact proportional share is two thirds of a cent. The integer floor is zero for every line, leaving two cents to allocate. All remainders tie, so A and B receive one cent each and C receives zero. The amounts sum to two, exactly the chosen rounded invoice total.

A two-cent rounded invoice is allocated as one cent to A, one to B and zero to C using equal weights and stable identity tie-breaking; independent line rounding would instead total three centsView full-size image ↗

The implementation uses integer division and remainders rather than approximating the repeating two-thirds decimal. It assumes comparable stable line IDs, a validated common currency and a total already quantized under the chosen invoice policy. It does not accept negative totals or mixed debit and credit weights. Those cases need a separately defined allocation rule, not an absolute-value conversion that silently changes accounting meaning.

A zero allocation does not mean that line C’s original fee was absent or free. Keep its source fee of 0.005 alongside its allocated settlement amount. If the analytical question is about accrued unrounded fees, use the source amounts. If it is about distributing the settled invoice, use the allocations. Giving both columns the same generic name would hide the policy distinction from downstream consumers.

Test conservation and tie handling separately

On fresh calls, allocate one cent to the same three equal weights. Only A receives a cent. Reverse the input list and verify that A still wins the tie because the identifier, not arrival order, controls it. Allocate 100 cents with weights A equals three, B equals two and C equals one: the result is 50, 33 and 17 cents. The last cent goes to C, whose fractional remainder is larger than B’s.

Also test zero total with positive weights, duplicate line IDs, an all-zero weight set and a Boolean total. The first produces zero on every line; the other three inputs are rejected. Exact integer addition makes the conservation check simple, but it does not establish that the chosen weights or invoice total came from the right source revision. Retain that evidence with the allocation version.

For the original decimal rounding rule, try a refund fee of Decimal("-0.005"). Half-up rounding gives minus 0.01, consistent with rounding a halfway value away from zero. Keep that rounding test separate from the nonnegative allocation helper: supporting negative decimal amounts in one layer does not mean every downstream algorithm supports them.

A reconciliation record can now explain three quantities: the exact unrounded sum, the rounded invoice amount, and the sum of allocated minor units. The latter two must agree under this allocation contract, while their difference from the unrounded sum is the documented rounding adjustment. Checking each boundary gives a more useful diagnosis than accepting a one-cent discrepancy as unavoidable noise.

Sources & further reading

  1. Python decimal arithmetic
← Back to the journal
All notes

Illustration

100%