Skip to content
Devix Open Source

Guide

Rounding

Multiplying by a rate is the easy half. The rounding is what fails audits, so it is worth understanding rather than accepting a default.

Per line, or on the total?

Ten lines of 0.30 at 5%:

const lines = Array.from({ length: 10 }, () => ({ amount: '0.30' }));

invoice(lines, { country: 'AE', rounding_level: 'line' });   // vat: '0.20'
invoice(lines, { country: 'AE', rounding_level: 'total' });  // vat: '0.15'

Each line's tax is exactly 1.5 fils. Rounded up ten times that is 20 fils; computed once on the 3.00 total it is 15. Five fils apart on a three-dirham invoice, and the gap grows with the number of lines.

Both are correct somewhere — authorities specify which, and a tax invoice in the GCC generally shows tax per line, which is why 'line' is the default. If your accounting system totals first, say so, because the invoice and the return have to agree.

Which way a half goes

vat('0.50', { country: 'AE', rounding: 'half-up' });     // 0.03
vat('0.50', { country: 'AE', rounding: 'half-even' });   // 0.02

0.50 at 5% is exactly 2.5 fils.

What it does
half-up Away from zero The default, and what tax authorities specify
half-even To the even neighbour Banker's rounding; some ERPs use it
down Always toward zero
up Always away from zero

half-up means −2.5 becomes −3, not −2. A "half rounds up" that turns −2.5 into −2 is rounding toward positive infinity, which is a different rule and gives different credit notes.

Why it is all integers underneath

let sum = 0;
for (let i = 0; i < 1000; i++) sum += 0.07;
sum;   // 70.00000000000041

That is why. Every amount here becomes a whole number of fils, halalas or paisa the moment it arrives:

import { toMinor, fromMinor } from '@devix-labs/vat-calculator';

toMinor('10.99');    // 1099
fromMinor(1099);     // '10.99'
toMinor('10.999', 3) // 10999 — a Bahraini dinar has three decimals

And a result hands them back, so the next sum does not start from a string:

const line = vat('10.99', { country: 'AE' });
line.minor;   // { net: 1099, vat: 55, gross: 1154 }

A currency's decimals are taken from the country: AED, SAR, PKR and EGP have two; BHD, OMR, KWD and JOD have three. Pass decimals to override.

What an amount is allowed to be

toMinor('10.99');     // fine
toMinor(10.99);       // fine
toMinor('1,234.00');  // throws — a thousands separator is not an amount
toMinor('١٥');        // throws — nor are Arabic-Indic digits

Refusing beats guessing. A locale-formatted string that silently parsed as 1.00 would be a far worse outcome than an exception at the point of entry.

Strings are read digit by digit rather than through Number, so a price typed as '10.005' is not already wrong before the rounding rule gets to it.

Discounts come off before the tax

invoice([{ amount: '10.00', quantity: 3, discount: '5.00' }], { country: 'AE' });
// net '25.00', vat '1.25'

Tax is charged on what is actually charged. A discount applied after tax would give 1.50 here — that is a rebate, and it is a different thing with different paperwork.

Updated 15 Sep 2026