VAT Engine journal
Why Money Calculations Should Not Use Floating Point
Binary floating point is useful for many problems, but risky as a canonical money representation. Learn why minor units and explicit rounding make VAT calculations more predictable.
- Engineering
Financial software often starts with code that looks completely harmless:
const price = 19.99;
const vatRate = 0.19;
const vat = price * vatRate;
The formula is not the surprising part.
The representation is.
Most mainstream programming languages represent values such as 19.99, 0.1, and 0.19 using binary floating point.
That representation is extremely useful for scientific computing, graphics, statistics, simulations, and many other problems.
Money has a different set of requirements.
A payment, invoice, VAT amount, or accounting total usually needs to be:
- reproducible;
- represented at a known currency precision;
- rounded according to an explicit rule;
- consistent across services;
- safe to persist and export;
- understandable later.
Those requirements make binary floating point a poor default canonical representation for money.
For VAT software, even a small inconsistency can eventually appear in net amounts, VAT amounts, gross totals, reports, exports, or reconciliation.
Key takeaways
- Many decimal fractions cannot be represented exactly using binary floating point.
- Money is usually better modeled as an amount plus currency and scale.
- Integer minor units make the monetary input exact.
- VAT rates can also be represented using integer basis points.
- Rounding belongs in the calculation contract, not only in UI formatting.
- Decimal libraries are also valid when their precision and rounding rules are explicit.
- Correct arithmetic still requires the correct tax class (opens in a new tab), rate, country, and transaction context.
The classic floating-point example
Open a JavaScript console and run:
0.1 + 0.2
You do not get exactly:
0.3
Instead, JavaScript produces:
0.30000000000000004
This is not a JavaScript bug.
It is a consequence of binary floating-point representation.
Many decimal fractions that are simple in base 10 do not have a finite representation in base 2.
A similar problem exists in decimal notation:
1 / 3 = 0.333333...
There is no finite decimal representation of one third.
Likewise, values such as 0.1 cannot be represented exactly using a finite binary fraction.
The runtime therefore stores the closest available approximation.
For many applications, that approximation is perfectly acceptable.
For money, it is usually better not to make that approximation part of the monetary domain model.
Money is discrete at a defined currency precision
Consider:
€19.99
For ordinary EUR calculations, that amount can be represented as:
1,999 cents
Instead of:
const price = 19.99;
the core financial representation can be:
const priceMinor = 1999;
Now:
const a = 10; // €0.10
const b = 20; // €0.20
const total = a + b;
console.log(total);
// 30
There is no floating-point approximation involved in:
10 + 20 = 30
The application can convert the value into a formatted monetary string later:
30 minor units → €0.30
VAT Engine follows this model in its VAT calculation API (opens in a new tab).
For EUR:
€1.00 → 100
€19.99 → 1999
€119.00 → 11900
The API therefore uses fields such as:
gross_amount_minor
net_amount_minor
vat_amount_minor
rather than using binary floating-point monetary values as the core VAT calculation input and output.
Not every currency has two decimal places
A common simplification is:
Store everything in cents.
That works for currencies such as EUR and USD, but it is not a general money model.
Currencies can use different minor-unit scales.
Conceptually, a monetary value therefore needs at least:
amount
currency
scale
A domain type could look like:
type Money = {
amountMinor: bigint;
currency: string;
scale: number;
};
For example:
EUR → 2 decimal places
JPY → 0 decimal places
Other currencies can use different defined scales.
VAT Engine's money model validates the currency and expected minor-unit scale instead of treating every currency as if it automatically had two decimal places.
Floating point mixes representation with rounding
Suppose we calculate 19% VAT on:
€19.99
A floating-point implementation may start with:
const net = 19.99;
const vatRate = 0.19;
const vat = net * vatRate;
Mathematically:
19.99 × 0.19 = 3.7981
EUR cannot represent:
€3.7981
as a final monetary amount.
At some point the result needs to be rounded.
For example:
€3.7981 → €3.80
That rounding decision is unavoidable.
The problem with using floating point as the money representation is that approximation already exists before the application's financial rounding policy is applied.
When values move through:
browser
→ API
→ worker
→ database
→ report
→ export
different implementations can also:
- round at different stages;
- serialize numbers differently;
- aggregate before or after rounding;
- use different numeric types.
A more predictable design keeps the monetary input exact and makes the rounding step explicit.
Minor units make the monetary input exact
Represent:
€19.99
as:
1999
and represent the VAT rate separately.
For a VAT-exclusive price with a 19% rate:
VAT =
1999 × 19 / 100
which gives:
379.81 minor units
The currency cannot represent:
0.81
of a cent.
Now we have reached the actual financial decision:
How should the fractional minor unit be rounded?
Using half-up rounding:
379.81 → 380
The result becomes:
Net: €19.99
VAT: €3.80
Gross: €23.79
The important difference is that:
1999
was exact.
Rounding happened deliberately at the monetary calculation boundary rather than being mixed with the representation of the input value.
VAT rates can be represented as integers too
Monetary amounts are not the only decimal-looking values involved in VAT.
A VAT rate such as:
19%
is often represented in application code as:
0.19
But the rate can also be represented using an integer.
VAT Engine's calculation contract uses basis points.
For example:
19.00% = 1900 basis points
20.00% = 2000 basis points
7.00% = 700 basis points
A calculation response can therefore contain:
{
"vat_rate_bps": 1900
}
For a VAT-exclusive calculation, the core arithmetic can be expressed as:
VAT minor units =
amount_minor × rate_bps / 10000
Using:
amount_minor = 1999
rate_bps = 1900
gives:
1999 × 1900 / 10000
The fractional result is then rounded according to the defined calculation rule.
The Calculate VAT API (opens in a new tab) exposes both money and VAT rates using these explicit integer representations.
VAT-inclusive pricing still requires fractional arithmetic
Integer representation does not mean that all intermediate mathematical results are integers.
Suppose:
Gross: €19.99
VAT rate: 19%
or:
gross_minor = 1999
rate_bps = 1900
Because VAT is already included in the gross amount, it has to be extracted.
The formula is:
VAT =
Gross × Rate / (100% + Rate)
Using basis points:
VAT minor units =
1999 × 1900 / (10000 + 1900)
or:
1999 × 1900 / 11900
This produces a fractional minor-unit result.
That value is then rounded using the defined rounding rule.
The important distinction is:
Integer money representation does not eliminate rounding. It makes the point where rounding is required explicit.
For a deeper explanation of inclusive versus exclusive VAT arithmetic, see VAT-Inclusive vs VAT-Exclusive Pricing: The Math Developers Get Wrong (opens in a new tab).
Rounding is part of the financial contract
Once an intermediate result contains a fraction of a minor unit, the application needs a defined policy.
Possible approaches include:
round half up
round half away from zero
round half to even
truncate
round per line
round only after aggregation
These approaches are not interchangeable.
For example, imagine several order lines where each line creates a fractional cent of VAT.
One system might:
calculate → round every line → sum
while another might:
calculate every line → sum exact intermediates → round once
Those systems can produce different totals.
That does not necessarily mean either system has a floating-point bug.
It means the rounding contract differs.
VAT Engine's core VAT calculation arithmetic works with integer minor units and applies deterministic half-up rounding to the VAT fraction.
The behavior is documented in the Calculate VAT API reference (opens in a new tab).
Formatting is not calculation
Another useful separation is:
financial value
≠
display string
A core system might store:
{
"currency": "EUR",
"net_amount_minor": 1999,
"vat_amount_minor": 380,
"gross_amount_minor": 2379
}
A European UI could display:
€19.99
€3.80
€23.79
Another locale might display:
19,99 €
3,80 €
23,79 €
The formatting changed.
The money did not.
Currency formatting belongs at the presentation boundary.
It should not define how the underlying amount is represented or calculated.
An integer alone is not money
This:
1999
is not enough information.
It could represent:
€19.99
$19.99
¥1,999
or another currency amount.
Money therefore needs currency identity as part of the value.
That also means this should not be accepted as ordinary addition:
€10.00 + $10.00
An explicit exchange-rate operation is required first.
A money type can enforce rules that a primitive number cannot.
For example:
type Money = {
amountMinor: bigint;
currency: string;
scale: number;
};
Adding two values can require:
same currency
+
same scale
before the arithmetic is allowed.
Exchange rates are a separate numeric domain
There is an important nuance here.
Saying:
Do not use binary floating point as the canonical representation of money.
does not mean:
Every numeric value inside financial software must always be an integer.
Exchange rates, ratios, measurements, or other values may require precision beyond a currency's final minor-unit scale.
For example:
1 EUR = 4.3176 PLN
is a rate, not a monetary amount.
A cleaner architecture treats these as different domain concepts:
Money → currency + minor units
VAT rate → basis points
FX rate → explicitly defined rate representation
The conversion into money then happens at a well-defined boundary with a known rounding policy.
That is much easier to reason about than using one generic floating-point type for every financial concept.
Decimal libraries are also a valid solution
Integer minor units are not the only reasonable way to implement monetary arithmetic.
Arbitrary-precision or fixed-point decimal libraries can also be appropriate.
They are especially useful when:
- intermediate values require more precision than the final currency scale;
- decimal quantities need to remain exact;
- financial formulas involve multiple precision stages.
The important requirements are still the same:
- precision should be explicit;
- scale should be explicit;
- rounding should be explicit;
- serialization should be predictable.
So the rule is not:
Only integers are acceptable for financial software.
A better rule is:
Do not let binary floating-point behavior silently define the semantics of your money.
For APIs, integer minor units have the additional advantage of creating a simple cross-language contract:
{
"currency": "EUR",
"amount_minor": 1999
}
Every consumer sees the same integer.
Keep the amount basis explicit
Correct monetary representation does not remove the need for correct business context.
For VAT calculations, a system also needs to know whether the input represents:
net
or:
gross
VAT Engine exposes this explicitly:
{
"price_includes_vat": true
}
or:
{
"price_includes_vat": false
}
For example:
{
"country": "DE",
"currency": "EUR",
"gross_amount_minor": 11900,
"price_includes_vat": true,
"tax_class_id": "standard",
"transaction_date": "2026-01-28"
}
A calculation can return:
{
"vat_rate_bps": 1900,
"gross_amount_minor": 11900,
"net_amount_minor": 10000,
"vat_amount_minor": 1900
}
The full request and response contract is available in the VAT calculation API documentation (opens in a new tab).
You can also experiment with gross and net calculations using the VAT calculator (opens in a new tab).
Exact money does not automatically mean correct VAT
Using minor units solves a particular class of engineering problems.
It does not determine the correct VAT treatment.
A calculation can still depend on:
- destination country;
- tax class (opens in a new tab);
- transaction date;
- selected VAT rate;
- relevant transaction facts.
So:
integer arithmetic
does not automatically imply:
correct VAT treatment
A more complete model is:
correct transaction facts
+ correct rate selection
+ exact money representation
+ explicit rounding
= reproducible VAT calculation
VAT Engine separates these concerns.
You can inspect:
- Tax Classes (opens in a new tab)
- VAT Rates API (opens in a new tab)
- Calculate VAT API (opens in a new tab)
independently.
Persist the canonical monetary representation
A system can implement its calculator correctly and still lose that consistency later.
For example:
API calculation → integer minor units
database → converted into floating point
frontend → Number
export → decimal regenerated from float
The original calculation was deterministic.
The persistence contract was not.
If minor units are the canonical representation, keep them canonical across:
request
→ calculation
→ persistence
→ read-back
→ export
VAT Engine's authenticated transaction calculation records (opens in a new tab) retain the calculated minor-unit amounts together with their calculation context.
That allows the stored result to be inspected later without reconstructing monetary amounts from presentation strings.
Calculation history is not a sales ledger
There is another important boundary.
A calculation does not necessarily represent a completed transaction.
A checkout may calculate VAT repeatedly:
customer changes country
→ calculate
quantity changes
→ calculate
discount applied
→ calculate
shipping option changes
→ calculate
customer leaves
Those calculations can be useful for technical history and review.
They are not necessarily completed sales.
VAT Engine therefore keeps calculation history (opens in a new tab) separate from committed supply events used by its compliance and reporting workflows.
This prevents a calculation API call from being mistaken for a legal or accounting business event.
A practical TypeScript model
Instead of making every monetary value a generic number:
type Price = number;
use an explicit type:
type Money = {
amountMinor: bigint;
currency: string;
scale: number;
};
For example:
const price: Money = {
amountMinor: 1999n,
currency: 'EUR',
scale: 2,
};
Keep the VAT rate separate:
const vatRateBps = 1900;
A calculation result can then have a clear contract:
type VatResult = {
netAmountMinor: bigint;
vatAmountMinor: bigint;
grossAmountMinor: bigint;
vatRateBps: number;
};
Using bigint here also avoids accidentally exceeding JavaScript's safe integer range in systems that may process very large amounts.
For smaller bounded values, a regular integer number can also be used safely when the range is explicitly constrained.
The important part is that money semantics are deliberate rather than implicit.
Common mistakes
1. Storing prices as floating-point numbers
const price = 19.99;
This makes binary approximation part of the canonical financial representation.
Prefer a fixed-scale money model.
2. Rounding only for display
value.toFixed(2)
is presentation formatting.
Financial rounding affects the actual stored amount and should happen at the defined calculation boundary.
3. Assuming every currency uses two decimals
Do not make:
amount / 100
a universal currency rule.
Currency scale belongs in the money model.
4. Treating an integer as sufficient monetary context
1999
without a currency and scale is not a complete money value.
5. Mixing currencies
1000 EUR minor units
+
1000 USD minor units
is not a valid ordinary addition.
6. Using decimals without defining rounding
Decimal arithmetic can avoid binary approximation, but the application still needs to define what happens when a result exceeds the currency's supported precision.
7. Converting back to floating point during persistence
A safe calculator cannot protect a system that later discards the canonical money representation.
Money arithmetic should be boring
The best financial arithmetic is rarely clever.
It should be predictable.
Before calculating a monetary result, the system should be able to answer:
What currency is this?
What is its minor-unit scale?
What amount is being calculated?
Does the amount include VAT?
Which VAT rate is being used?
Where does rounding happen?
Which rounding rule applies?
What values will be persisted?
When those decisions are explicit, a large class of subtle financial bugs becomes much easier to prevent.
That is why VAT Engine's VAT calculation API (opens in a new tab) uses minor-unit monetary amounts, integer VAT basis points, an explicit price-inclusion flag, and deterministic VAT rounding for its core calculation path.
The result is not sophisticated mathematics.
That is exactly the point.
Financial arithmetic should be simple enough to reproduce, test, store, and explain later.
You can explore the Calculate VAT API (opens in a new tab), browse available tax classes (opens in a new tab), inspect calculation history (opens in a new tab), or try the free VAT calculator (opens in a new tab).
Note: This article discusses monetary arithmetic and software design. It is not tax, accounting, or legal advice. The correct VAT treatment of a transaction depends on the applicable rules and the actual transaction facts.