Alpha testing: all current functionality is free while VAT Engine is in active development

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.

By Vasyl KyryliukPublished 13 min read
  • Engineering
VAT Engine cover comparing floating-point money arithmetic with integer minor-unit calculations for predictable VAT results.

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:

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:

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.


About Vasyl Kyryliuk

Solo founder and software engineer building VAT Engine, focused on EU VAT infrastructure, ecommerce integrations, APIs, security, and SaaS.

Report a correction