A booking-time price arrived as four values:
net amount: 407
tax rate: 25.5%
tax amount: 104
gross amount: 511The amounts are integers in the currency’s minor unit. The rate is a decimal ratio. The contract checks that net plus tax equals gross and that applying the declared rounding rule to net multiplied by the rate produces the tax amount.
None of those details is decorative. Remove the unit, mix the rate’s two representations, or let two services round at different stages, and the same price can stop balancing.
An amount is a number with a unit
The integer 511 is not money by itself. It becomes money when paired with a
currency and unit:
currency: EUR
unit: cent
amount: 511
meaning: EUR 5.11Storing minor units avoids binary floating-point error during addition and
subtraction. It also makes equality useful: 407 + 104 === 511.
The choice is not universally “cents.” Currencies have different standard minor-unit scales, and some products use finer internal precision than their settlement currency. The representation needs a declared scale:
Money:
currency
integer amount
scale or currency-defined minor unitAn integer without its unit merely moves the ambiguity from arithmetic into the schema.
Database column size matters too. Totals, aggregates, refunds, and sign conventions determine the range. Choose the integer width from the largest legitimate value and intermediate result, not from a typical purchase.
A rate is not an amount
The retained boundary accepts a tax percentage such as 25.5 from the request
and converts it to the ratio 0.255 for calculation and storage.
Both numbers can be called “the tax rate,” but they are not interchangeable:
percentage representation: 25.5
ratio representation: 0.255Multiplying 407 minor units by 25.5 produces nonsense. Dividing 0.255 by
100 applies the conversion twice.
Name the representation at each boundary. An API field named
tax_percentage may carry percentage points. An internal value object can
carry a ratio. A database column should document which one it stores. Convert
once and test the boundary.
For ratios which must remain exact across systems, a scaled integer or rational pair can be safer than a binary float:
25.5% = 255 / 1000
tax = round(net * 255 / 1000)The retained system stores the rate in a decimal database field but casts it through application floating-point arithmetic. Its validation tolerates close rate matches during later lookup. That is evidence of one implementation, not a claim that floats are the ideal rate representation.
Rounding is a policy decision
The exact product of 407 and 0.255 is 103.785 minor units. A ledger cannot usually post 0.785 of the declared minor unit, so something must own the conversion to 104.
Questions appear immediately:
- round half up, half even, toward zero, or another way;
- round each line or the document total;
- calculate tax from net or extract it from gross;
- round before or after currency conversion;
- reconcile a sum of rounded lines with a rounded aggregate.
These policies can all produce different valid-looking values.
The retained write contract performs one explicit positive-value rounding step:
tax = round(net * rate)
gross = net + taxThat rule belongs to this contract. It is not presented as a universal tax rule. Another jurisdiction, provider, invoice format, or accounting policy may require tax extracted from gross or rounding at a different aggregation level.
The code should reveal the chosen policy rather than rely on a language’s default round function remaining understood across services.
Calculate once, then preserve the evidence
Recomputing a past price from current configuration is not reconciliation.
Rates change. Account configuration changes. Price lists change. Currency rules change. Even an unchanged formula can produce a different answer when its inputs are selected at a later date.
The retained flow captures net, rate, tax, and gross at booking time. Later reconciliation prefers that immutable snapshot. It finds a tax-rate record whose percentage matches and which was active at the original transaction date. Legacy records without a snapshot require authoritative imported or refetched pricing; the reconciliation does not invent a rate from current defaults.
This separates two responsibilities:
quoted evidence:
exact amounts and rate accepted at booking time
accounting classification:
dated tax record and ledger mapping used to post themA stored rate value may establish arithmetic without being enough to choose a ledger account or legal category. Conversely, a current tax configuration may provide classification while being the wrong evidence for a historical price.
Preserve both when the domain needs both.
Invariants catch disagreement before posting
Minor-unit storage prevents one class of error. It does not prove that supplied components agree.
The retained boundary checks two invariants:
net + tax = gross
round(net * rate) = taxThe first catches a total which does not balance. The second catches a tax amount inconsistent with the declared calculation policy.
Other useful checks depend on the domain:
- every component uses the same currency;
- refunds reverse the intended signed amount;
- gross is non-negative where negative sales are invalid;
- allocation parts sum exactly to the original amount;
- conversion records both source and target money plus the rate;
- aggregated rounding residuals have an explicit destination.
Do not “fix” a one-unit mismatch silently by changing whichever field is most convenient. Reject it, or apply a named residual policy which records why one line received the adjustment.
Line rounding and total rounding can disagree
Consider three synthetic net lines of one minor unit at a 50 percent rate.
Rounding each positive line half up:
round(1 * 0.5) = 1
round(1 * 0.5) = 1
round(1 * 0.5) = 1
line tax total = 3Rounding the aggregate:
round((1 + 1 + 1) * 0.5) = 2Neither arithmetic path is hidden floating-point noise. The paths implement different policies.
An invoice contract might require per-line tax because each line is legally reported. Another might permit aggregate calculation. If both systems exchange only the gross total, the mismatch emerges later as an unexplained residual.
Send or store enough evidence to reproduce the chosen path:
calculation basis
rate representation
rounding mode
rounding level
component amounts
currency and scaleVersion the policy if it can change while old records remain.
Formatting belongs at the edge
Human-readable decimals are useful in interfaces:
511 minor units -> "5.11"They are a presentation, not a safer calculation representation.
Parsing also needs a strict owner. The string "5.11" must not become a float,
be multiplied by 100, and then be truncated after binary error. Parse the
decimal digits according to the declared currency scale, reject excess
precision or round it through a named input policy, and produce the integer
amount.
Locale formatting belongs after the amount is known. Commas, periods, spaces, currency symbols, and negative conventions should not leak into stored arithmetic.
The fixture makes policy differences executable
The DATA-05 fixture
represents amounts as BigInt minor units
and rates as integer numerator/denominator pairs. It verifies the retained
407/104/511 example under a labelled positive half-up rule without using
floating-point amount arithmetic.
It also demonstrates:
0.1 + 0.2does not equal0.3in binary floating point;- percentage points and ratios produce different calculations;
- per-line and aggregate rounding disagree for a synthetic three-line case;
- a mismatched gross amount is rejected; and
- formatting occurs after arithmetic.
The fixture does not establish a currency standard, tax law, private ledger, provider contract, or universal rounding rule. Its policy is deliberately labelled so another rule can replace it and change the expected results honestly.
Money types protect a decision, not just arithmetic
A dedicated money type can prevent cross-currency addition, centralize scale, and remove raw floats from amount APIs. A decimal library can represent rates and arbitrary-precision amounts. A database numeric type can be appropriate when scale is fixed and every caller preserves it.
Any of these can work. None chooses the rounding level, tax basis, historical rate, sign convention, or residual owner for you.
Reverse the integer-minor-unit choice when the domain legitimately operates below the settlement unit and must preserve that precision across calculations. Use a fixed higher scale or rational representation, then round only at the named settlement boundary. Do not return to unlabelled floats.
Reliable money code makes six decisions visible: currency, unit, amount, rate representation, rounding policy, and evidence date. The arithmetic is the easy part once those decisions stop moving.