İçeriğe geç
wedevit

September 30, 2026 · 9 min read · software

İlhan Buğra Aslan

The total is off by one cent: storing money, rounding and exchange rates


When accounting finds a one cent difference, it almost always traces back to one of four things: the amount is stored as a floating point number, the currency travels separately from the amount, rounding happens in several places in the code, or the exchange rate was never written onto the transaction itself. The fix is short to state. Store amounts as integer minor units or as an exact decimal type, keep the currency attached to the amount, round once according to a rule you wrote down in advance, and record the rate you used along with its date and source. The sections below cover where each of those four breaks in practice.

A float cannot carry money

In most languages 0.1 + 0.2 returns 0.30000000000000004. That is not a language bug, it is IEEE 754 binary representation: 0.1 is a repeating fraction in base two and cannot be stored exactly. On a single line the difference is invisible. On a month-end reconciliation across a hundred thousand rows it is not, and it does not land in the same place on every run.

There are two correct options. Store the amount as an integer in the smallest unit, so 1,299.90 becomes 129990 cents. Or use the exact decimal type your language and database offer: numeric in PostgreSQL, DECIMAL in MySQL, BigDecimal in Java, decimal in .NET, decimal.Decimal in Python.

JavaScript has no decimal type at the language level. If you work in integer minor units, your safe range is Number.MAX_SAFE_INTEGER, 9,007,199,254,740,991 cents, which is far more headroom than any invoice needs for addition and subtraction. The trouble starts the moment you divide, because the result stops being an integer. At that point either move to BigInt or bring in a decimal library.

Two traps live on the database side. The first is declaring a money column as FLOAT or DOUBLE PRECISION, which makes the problem permanent. The second is PostgreSQL's money type. The PostgreSQL wiki's "Don't Do This" page names it: the type stores no currency with the value and instead assumes whatever the database's lc_monetary setting says, so the same row read under a different locale comes back with a different symbol. Use numeric with a separate currency column next to it.

Amount and currency should travel as one value

"Total: 1,250" carries no information. Money is not a number, it is a pair of amount and unit. In the schema that means a numeric(19,4) column beside a char(3) column. In code it means a Money type that holds both.

The real benefit of that type is what it refuses to do. Adding two amounts in different currencies is a design error rather than an arithmetic one, and a type can catch it at compile time or at runtime. If bare decimal values are moving through your code, nothing stops someone from summing 100 EUR and 100 TRY into a column labelled "revenue".

Not every currency has two decimal places

ISO 4217 assigns a minor unit exponent to each currency. For most it is 2, but the Japanese yen (JPY) has 0 and trades as whole units. Kuwaiti dinar (KWD), Bahraini dinar (BHD), Jordanian dinar (JOD), Omani rial (OMR) and Tunisian dinar (TND) have 3.

Code that hardcodes two decimal places gets those currencies quietly wrong. That is why payment providers ask for amounts in minor units: in Stripe, 10.99 USD is sent as 1099 while 10 JPY is sent as 10. Same field, different scale. Keep the decimal count in your currency table as data instead of burying it in code.

One more distinction matters: calculation precision and display precision are not the same number. Products priced per kilogram, per metre or per thousand units often need four or six decimal places on the unit price. Storing the unit price at four places and rounding the line total to two is right. Truncating the unit price to two is an error that grows with quantity.

Pick the rounding rule yourself

For values that land exactly halfway there are two common rules: half-up (2.345 becomes 2.35) and half-even, also called banker's rounding (2.345 becomes 2.34). Half-even avoids a systematic upward drift across large data sets, but it is not intuitive to a human reading an invoice.

What matters is not which one you pick but that you picked it. Platforms disagree by default: Math.Round in .NET applies MidpointRounding.ToEven, so it rounds like a banker. In Java, Math.round rounds half up, while BigDecimal.setScale throws if rounding is needed and you did not name a mode. Run three services with three different defaults and you will only see the gap during reconciliation.

The practical rule: put rounding behind a single helper, write the rule into the requirements document, and never round intermediate results. A calculation that rounds at every step accumulates one error per step.

Round per line and your invoice total will not add up

The most common VAT mistake is rounding the tax on each line and then summing those rounded figures. The correct order is to group lines by rate and compute the tax once on the group's taxable base. The European e-invoicing standard EN 16931 turns this into a rule: BR-CO-17 requires the VAT category tax amount to equal the category taxable amount times the rate, rounded to two decimals, and BR-CO-14 requires the invoice VAT total to equal the sum of the category amounts. Validators allow a small tolerance, but do not build your arithmetic on top of that tolerance.

Mixed rates on one invoice are ordinary rather than exceptional. In Turkey the standard rate has been 20% since 10 July 2023, with reduced rates of 10% and 1%, so rate grouping is part of the normal path through the code, not an edge case.

If a one cent gap survives all that, UBL's PayableRoundingAmount exists for it: an adjustment added to the payable total. The field being available does not make it the normal way to close a difference. Get the per-rate calculation right first and keep that field as a last resort.

The cent that disappears when you split

Divide 100.00 by three and you get 33.33 + 33.33 + 33.33 = 99.99. One cent vanished. In Martin Fowler's Money pattern the operation is called "allocate": you distribute the remainder across the shares in a deterministic order so the parts always sum back to the whole.

This shows up in more places than people expect. Spreading an order-level discount across lines, distributing shipping cost over items, generating an instalment plan, computing dealer commission, splitting a shared payment. The same invariant holds everywhere: the parts must sum to the whole. Write that as a test once and stop rediscovering it.

An exchange rate is a record, not a field

Storing a foreign currency transaction properly takes five pieces: the amount, its currency, the rate used, the direction of that rate (how many of which unit per which unit), and the rate's date and source. All of them belong on the transaction record. Keeping rates in a separate table and joining them at report time means the value of past transactions changes every day.

The most frequent bug is an inverted rate. The difference between 1 USD = 49 TRY and 1 TRY = 49 USD is invisible in the code and obvious in the output. Make source and target currency required parameters of the conversion function, and add a sanity range check on the result.

For Turkish lira conversions the reference source is the central bank's daily bulletin, set at 15:30 on each business day and published the same day. Two technical details matter. The bulletin carries both transfer rates (ForexBuying, ForexSelling) and banknote rates (BanknoteBuying, BanknoteSelling); which one applies is a business rule, not something to default your way into. And each entry has a Unit field: the Japanese yen is quoted per 100 units, so using ForexBuying without dividing by Unit inflates the rate a hundredfold.

The bulletin is only published on business days. Alongside the current file there is a date-based archive, but no file exists for weekends and public holidays. Your integration has to fall back to the previous business day and cache rates on its own side, which is a small concrete instance of the pattern in building against third-party outages.

Local currency equivalents on foreign currency invoices

Many jurisdictions let you issue an invoice in a foreign currency only if the local currency equivalent appears on the document. Turkish tax law works this way: records are kept in Turkish lira, and documents may be issued in another currency provided the lira equivalent is shown, with an exemption for export invoices to customers abroad. The legal detail belongs to your accountant. What it means for the system is concrete: your invoice model has to carry a document currency, a rate and a rate date.

The e-invoice XML has fields for exactly this. The document currency lives in DocumentCurrencyCode and the rate sits in the cac:PricingExchangeRate block, with source currency, target currency, calculated rate and rate date.

When a foreign currency sale is collected on a different day at a different rate, the resulting difference gets handled on the accounting side. The software requirement behind it is simple: the payment record must know which invoice it closes and which rate it used. Overwriting the invoice's original rate at collection time makes the difference impossible to compute. We covered how that matching is built in payment integration and reconciliation.

Store both amounts for reporting

In a multi-currency system every transaction has two amounts: the figure in its own currency and the equivalent in the company's reporting currency. Compute the second one at the transaction rate and store it. Converting at report time using today's rate makes last month's revenue change daily, and then nobody can say which number is correct.

Accounting standards draw the same line. Under IAS 21, monetary items such as receivables, payables and cash are retranslated at the closing rate, while non-monetary items stay at the rate on the transaction date. So "convert on read" is not only operationally awkward, it also does not match the behaviour accounting expects. The broader question of where numbers are produced and where they are consumed is in why your reports disagree.

All of this is testable

The good thing about money code is that its correctness can be stated as invariants. The parts of an allocation sum to the input. Line totals plus tax equal the payable amount. Converting to the base currency and back stays within a stated tolerance. Those statements suit property-based tests with generated inputs better than a handful of hand-picked examples.

Put the edge cases into your test data deliberately: a zero-decimal currency (JPY), a three-decimal currency (KWD), negative amounts for refunds, very large amounts, and values that land exactly on a midpoint. If those rows do not exist in production, you have to generate them, which is the argument made in test data management. Copying real data will not get you there.

Five checks you can run today

  1. List every column in your schema that holds money. Is any of them FLOAT, DOUBLE or PostgreSQL money?
  2. Does each money column have a currency column beside it? If not, where is that amount's unit recorded?
  3. How many separate places in the code perform rounding? Collapse them into one function and write the rule down.
  4. Do transaction records carry the rate they used, its date and its source? If not, your historical reports are being recalculated today.
  5. Find the code that distributes discounts, instalments, commission and shipping across lines. Is the sum-equals-whole property covered by a test?

If one of these has no clear answer, or if you cannot trace where a reconciliation difference comes from, we can go through the schema and the calculation flow together and come out with a concrete plan to fix it.


Need help with this topic?

get in touch →← all posts