Tanzeel abbasValue Added Tax looks like the simplest math in commerce: take a price, multiply by a rate, done....
Value Added Tax looks like the simplest math in commerce: take a price, multiply by a rate, done. Then you sell to customers in twelve EU countries and discover that "the VAT rate" is actually a matrix — standard rates, reduced rates, super-reduced rates, zero rates, exemptions — and that the direction of the calculation (adding vs removing VAT) trips up even experienced developers.
This is a practical guide to modelling European VAT in JavaScript: the data structures, the two core formulas, and the edge cases that corrupt your totals if you ignore them.
Try it live: if you just need the answer for an invoice, the free VAT Calculator at FreeKalc handles add/remove VAT across European rates instantly — no signup.
Every EU country sets its own rates within EU minimums (standard ≥ 15%, reduced ≥ 5%). A few you should know:
const VAT_RATES = {
DE: { standard: 0.19, reduced: 0.07, name: "Germany" },
FR: { standard: 0.20, reduced: 0.055, name: "France" },
ES: { standard: 0.21, reduced: 0.10, name: "Spain" },
IT: { standard: 0.22, reduced: 0.10, name: "Italy" },
IE: { standard: 0.23, reduced: 0.135, name: "Ireland" },
GB: { standard: 0.20, reduced: 0.05, name: "United Kingdom" },
SE: { standard: 0.25, reduced: 0.12, name: "Sweden" },
HU: { standard: 0.27, reduced: 0.18, name: "Hungary" }, // EU's highest
LU: { standard: 0.17, reduced: 0.08, name: "Luxembourg" }, // EU's lowest standard
MT: { standard: 0.18, reduced: 0.07, name: "Malta" },
};
Notice the first modelling decision: rates aren't a single number per country. France alone has 20%, 10%, 5.5%, and 2.1% (super-reduced) rates depending on the product. Your data model needs rate categories, not just countries:
const VAT_SCHEDULE = {
FR: [
{ rate: 0.20, appliesTo: "standard goods and services" },
{ rate: 0.10, appliesTo: "restaurants, transport, some renovations" },
{ rate: 0.055, appliesTo: "food, books, energy" },
{ rate: 0.021, appliesTo: "medicines, press" },
],
// ...
};
For a calculator, standard + reduced covers 95% of use cases. For an invoicing system, you need the full schedule.
This is where most bugs live. Adding VAT and removing VAT are not symmetric operations, and the common mistake is treating them as if they were.
Adding VAT (net → gross):
function addVat(netPrice, rate) {
return netPrice * (1 + rate);
}
// €100 net at 20% → €120 gross. Easy.
Removing VAT (gross → net): the tempting wrong version is gross * (1 - rate). That's incorrect. €120 gross at 20% VAT is not €96 net — it's €100. The correct formula divides:
function removeVat(grossPrice, rate) {
return grossPrice / (1 + rate);
}
// €120 gross at 20% → €100 net. Correct.
// Wrong version: 120 * 0.8 = €96. Off by €4 — a 4% error!
Why does the wrong version feel intuitive? Because people conflate "20% VAT" with "20% of the gross price." VAT is a percentage of the net price. The VAT fraction of the gross is rate / (1 + rate) — 20% VAT is only 16.67% of the gross price. If you're extracting the VAT amount itself from a gross price:
function vatAmountFromGross(grossPrice, rate) {
return grossPrice - grossPrice / (1 + rate);
// equivalent: grossPrice * (rate / (1 + rate))
}
Write a unit test for the round trip: removeVat(addVat(100, 0.2), 0.2) must equal exactly 100 (modulo floating point). If your test suite has one VAT test, make it this one.
Selling B2B across EU borders? Then you need the reverse charge mechanism: for intra-EU B2B sales, the seller charges 0% VAT and the buyer accounts for it in their own country. Your code now needs a decision tree, not just arithmetic:
function determineVatRate({ sellerCountry, buyerCountry, buyerIsBusiness, buyerVatIdValid, productRate }) {
if (sellerCountry === buyerCountry) return productRate; // domestic
if (buyerIsBusiness && buyerVatIdValid) return 0; // intra-EU reverse charge
return productRate; // B2C cross-border: seller's rate (simplified; OSS rules apply)
}
The B2C cross-border case is genuinely simplified here — since July 2021, the EU's One-Stop Shop (OSS) scheme means B2C sellers above the €10,000 threshold charge the buyer's country's rate. A production system needs the buyer's country rate table too. Flag it as a known simplification; don't silently ship it.
0.1 + 0.2 !== 0.3 is the classic, and VAT math is full of it. Work in minor units (cents) with integers, or use a decimal library. Math.round(net * 100) / 100 at the wrong stage compounds errors across line items. const RATE_HISTORY = {
DE: [
{ from: "2020-07-01", to: "2020-12-31", standard: 0.16 },
{ from: "2021-01-01", to: null, standard: 0.19 },
],
};
function invoiceLine({ netUnitPrice, quantity, rate }) {
const netTotal = netUnitPrice * quantity;
const vat = netTotal * rate;
return {
net: round2(netTotal),
vat: round2(vat),
gross: round2(netTotal + vat),
};
}
function invoiceTotal(lines) {
return lines.reduce(
(acc, line) => ({
net: acc.net + line.net,
vat: acc.vat + line.vat,
gross: acc.gross + line.gross,
}),
{ net: 0, vat: 0, gross: 0 }
);
}
const round2 = (n) => Math.round(n * 100) / 100;
(Note: this rounds per line. If your jurisdiction requires per-invoice rounding, sum first, round last — and make it a configuration flag, not a hardcoded choice.)
VAT is a good example of a domain where the data is harder than the math. The formulas fit in three lines; the rate schedules, effective dates, cross-border rules, and rounding policies are where the real complexity lives. Model rates as versioned data, keep the arithmetic pure and tested with round-trip assertions, and isolate the "which rate applies" decision from the "apply the rate" computation. When Germany next changes its rate, you should be editing a data file, not a function.
And when you just need a quick, correct answer — adding or removing VAT at any European rate — the VAT Calculator at FreeKalc does exactly this math, free, in your browser. The calculations run client-side, which after reading this article you now know is precisely where tax math belongs.