TL;DR
An e-Factura VAT calculation groups the invoice lines by VAT category and rate, adds their net amounts, and rounds base times rate once per group to two decimals; per-line VAT is informative only. Every document total then has to come from those same rounded numbers, because ANAF's validation checks the sums to the cent even though it lets the VAT of a rate drift by less than 1 leu. Romania's rates since August 1, 2025 are 21% standard and 11% reduced.
Your invoice editor shows a VAT figure, the PDF matches it, and the XML you upload to ANAF still has to follow one rule that line-by-line code misses: an e-Factura VAT calculation groups the lines by VAT rate, adds their net amounts, and rounds the VAT once per group. Every total in the file then has to come from those rounded numbers, to the cent. This tutorial builds that in TypeScript: Romania's VAT rates with their dates, VAT per rate in integer cents, the validation rules that compare totals, and an invoice line editor in React. The UI is Invoice Line Items from Wingo UI, the library we build, so we are not neutral. The per-rate math is plain TypeScript you can paste anywhere.
What are the Romanian VAT rates in 2026?
Two rates, both in force since August 1, 2025: 21% standard and 11% reduced. Law 141/2025 raised the standard rate from 19% and replaced the old 9% and 5% reduced rates with a single 11% rate, apart from a transitional 9% for some housing bought under contracts signed by August 1, 2025, which Law 161/2026 extended from July 31 to September 30, 2026 (Contzilla, August 2026); ANAF's before-and-after table of the Fiscal Code shows both versions of article 291. As of October 2026, ANAF's consolidated Fiscal Code, last updated by Law 170/2026, still reads 21% and 11%.
| Rate | Code | Use it for |
|---|---|---|
| 21% | S | everything that is not exempt or reduced (19% until Jul 31, 2025) |
| 11% | S | the list in art. 291 (2), including medicines for human use, most food, water and sewerage, books and press, hotel stays, restaurant and catering |
| 0% | E | exempt operations, with an exemption reason |
| 0% | AE | reverse charge, with the buyer's VAT id |
| 0% | K | intra-community supplies, with the buyer's VAT id |
The code is the VAT category an e-Factura line carries (BT-151): S for standard rated, which covers the reduced rate too, E for exempt, AE for reverse charge and K for intra-community supplies.
Two details matter for code. The 11% list has exceptions inside it: alcoholic drinks, non-alcoholic drinks under NC code 2202, food with added sugar and at least 10 g of sugar per 100 g, and food supplements stay at 21%. So store the rate on each product. And the rate depends on a date: article 291 (4) applies the rate in force when the chargeable event happens, usually the delivery. A correction you issue today for a July 2025 delivery carries 19%, so keep dated rates and let the VAT select offer the old one when a correction needs it:
import { RO_VAT_RATES, type VatRateOption } from "@/lib/invoicing"; // the standard rate by the date of the chargeable event (Fiscal Code art. 291 (4)) const RO_STANDARD_RATE = [ { from: "2017-01-01", rate: 0.19 }, { from: "2025-08-01", rate: 0.21 }, ]; export const standardRateOn = (isoDate: string) => RO_STANDARD_RATE.findLast((entry) => entry.from <= isoDate)?.rate; // the VAT select for corrections of supplies made before August 1, 2025 export const RATES_FOR_CORRECTIONS: VatRateOption[] = [ ...RO_VAT_RATES, { value: "19", rate: 0.19, category: "S", label: "19%", description: "Standard rate until Jul 31, 2025" }, ]; RO_VAT_RATES from the invoicing lib holds the current five rows of the table above, and the line editor takes any list through its vatRates prop.
Why is e-Factura VAT computed per rate instead of per line?
Because the standard defines it that way. Rule BR-CO-17 of EN 16931, which e-Factura applies, reads: the VAT category tax amount (BT-117) equals the VAT category taxable amount (BT-116) times the rate (BT-119), rounded to two decimals. The Fiscal Code asks for the same on paper: article 319 (20) requires the taxable base for each rate and the VAT amount in lei for each rate. Adding up rounded line VAT gives a different invoice VAT per rate whenever several lines round in the same direction.
Three 21% lines and one 11% line show it:
| Line | Net | VAT | Rounded |
|---|---|---|---|
| Courier delivery, 21% | 22.50 | 4.725 | 4.73 |
| Packaging, 21% | 7.50 | 1.575 | 1.58 |
| Label printing, 21% | 4.50 | 0.945 | 0.95 |
| Sum of the 21% lines | 34.50 | 7.26 | |
| 21% computed once on 34.50 | 34.50 | 7.245 | 7.25 |
| Books, 3 x 14.50, 11% | 43.50 | 4.785 | 4.79 |
Each line rounds its half cent up, three times over; the rate rounds once. 7.25 is the VAT for 21% on this invoice, and the line figures can stay on the PDF as information.
How do I compute VAT per rate in TypeScript without float errors?
Keep money in integer cents and divide integers. Math.round(22.5 * 0.21 * 100) / 100 returns 4.72, because 22.5 x 0.21 is stored as 4.7249999999999996. We ran every base from 0.01 to 1,000.00 lei: 1,000 of them have a 21% VAT ending in exactly half a cent, and the float formula rounds 188 of those the wrong way, while toFixed(2) gets 718 wrong. When the numerator is an integer, a half cent is exactly .5:
// lib/vat-per-rate.ts export type VatLine = { quantity: number; // up to 3 decimals: 1.5 hours unitPrice: number; // without VAT, in lei, up to 2 decimals vatRate: number; // a percent: 21, 11 or 0 }; export type VatRow = { rate: number; netCents: number; vatCents: number }; // an integer divided by a power of 10: half a cent is exactly .5 and rounds away from zero export const divRound = (numerator: number, denominator: number) => Math.sign(numerator) * Math.round(Math.abs(numerator) / denominator); export const lineNetCents = (line: VatLine) => divRound(Math.round(line.quantity * 1000) * Math.round(line.unitPrice * 100), 1000); // 21% of 3450 cents: 3450 x 2100 / 10000 = 724.5, so 725 export const vatCents = (netCents: number, rate: number) => divRound(netCents * Math.round(rate * 100), 10_000); export function vatPerRate(lines: VatLine[]): VatRow[] { const nets = new Map<number, number>(); for (const line of lines) nets.set(line.vatRate, (nets.get(line.vatRate) ?? 0) + lineNetCents(line)); return [...nets] .map(([rate, netCents]) => ({ rate, netCents, vatCents: vatCents(netCents, rate) })) .sort((a, b) => b.rate - a.rate); } vatPerRate([ { quantity: 1, unitPrice: 22.5, vatRate: 21 }, { quantity: 1, unitPrice: 7.5, vatRate: 21 }, { quantity: 1, unitPrice: 4.5, vatRate: 21 }, { quantity: 3, unitPrice: 14.5, vatRate: 11 }, ]); // [{ rate: 21, netCents: 3450, vatCents: 725 }, { rate: 11, netCents: 4350, vatCents: 479 }] A reversal (storno) line carries negative amounts. Rounding the absolute value keeps -7.245 at -7.25, the same magnitude the validator computes, since BR-CO-17 compares absolute values.
Which e-Factura validation errors check the totals?
BR-CO-10, BR-CO-11, BR-CO-13, BR-CO-14 and BR-CO-15 compare the document totals to the cent, and only BR-S-08, BR-CO-17 and BR-S-09 allow any slack: less than one unit of the invoice currency. We read the rules in the CIUS-RO validation package the Ministry of Finance publishes for e-Factura, ro16931-ubl 1.0.9, which embeds the EN 16931 rules from the European Commission's validation artefacts (version 1.3.8; we rechecked the tolerances below in that release in October 2026):
| Rule | What it checks | Slack |
|---|---|---|
| BR-CO-10 | sum of line nets (BT-106) = the line net amounts (BT-131) added up | none |
| BR-CO-11 | document allowances (BT-107) = the allowance amounts (BT-92) added up | none |
| BR-CO-13 | total without VAT (BT-109) = BT-106 - BT-107 + charges | none |
| BR-CO-14 | VAT total (BT-110) = the VAT of each rate (BT-117) added up | none |
| BR-CO-15 | total with VAT (BT-112) = BT-109 + BT-110 | none |
| BR-S-08 | each rate's base (BT-116) = its lines - its allowances + its charges | under 1 unit |
| BR-CO-17 | each rate's VAT = base x rate, rounded to 2 decimals | under 1 unit |
| BR-S-09 | the same, for standard rated rows | under 1 unit |
| BR-E-08 | the base of the exempt row | none |
| BR-AE-08 | the base of the reverse charge row | none |
| UBL-DT-01 | every amount has at most 2 decimals; unit prices are excluded | none |
BR-CO-17 accepts a VAT that is less than 1 leu (one unit of the invoice currency) away from base times rate. Summing rounded line VAT therefore passes it on most invoices: each line drifts by half a cent at most, so it takes about 200 lines rounding the same way to reach 1 leu. The e-Factura validation errors come from mixing the two paths: BT-117 from the per-rate breakdown while BT-110 is the sum of line VAT, or BT-112 as the sum of line totals with VAT. Those sums have no tolerance, every rule above is flagged fatal, and one cent apart means an error message comes back instead of a validated invoice. Do not build on the slack either. Your PDF, your database and the buyer's books should all show 7.25.
How do I turn the totals into e-Factura fields?
Derive every field from the same integers, in one order: line nets, the document discount per rate, the base and VAT of each rate, then the document totals. A discount on the whole invoice is the step that breaks mixed-rate invoices. BR-S-08 counts only the allowances that carry the same category and rate as the row, so a 100 lei discount on an invoice with 21% and 11% lines becomes two allowances, one per rate.
computeInvoiceTotals from the invoicing lib already shares a document discount across the lines by value, and the last line takes the remainder so the cents add up. The function below keeps each line's own discount inside BT-131, reads the document share back out per rate, and recomputes the VAT of each rate in cents:
// lib/efactura-totals.ts import { computeInvoiceTotals, type VatCategory } from "@/lib/invoicing"; import type { InvoiceDocumentDiscount, InvoiceLine } from "@/components/ui/invoice-line-items"; import { vatCents } from "@/lib/vat-per-rate"; type Group = { rate: number; category: VatCategory }; // every amount in cents; rate is the percent the XML carries (BT-119) export type EFacturaTotals = { lines: (Group & { id: string; net: number })[]; // BT-131 allowances: (Group & { amount: number })[]; // BG-20, one per rate breakdown: (Group & { taxable: number; vat: number })[]; // BG-23: BT-116, BT-117 lineNet: number; // BT-106 allowanceTotal: number; // BT-107 taxExclusive: number; // BT-109 vat: number; // BT-110 taxInclusive: number; // BT-112 }; const cents = (amount: number) => Math.round(amount * 100); const percent = (rate: number) => Number((rate * 100).toFixed(2)); const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); const categoryOf = (rate: number, line: InvoiceLine): VatCategory => line.vatCategory ?? (rate > 0 ? "S" : "E"); // B2B: unit prices without VAT export function toEFacturaTotals(lines: InvoiceLine[], discount: InvoiceDocumentDiscount | null): EFacturaTotals { const totals = computeInvoiceTotals(lines, { discount, categoryFor: (rate, line) => categoryOf(rate, line as InvoiceLine), }); const groups = new Map<string, Group & { taxable: number; allowance: number }>(); const outLines = totals.lines.map((line, i) => { const group = { rate: percent(line.vatRate), category: categoryOf(line.vatRate, lines[i]) }; // BT-131 keeps the line's own discount; its share of the document discount becomes an allowance const net = cents(line.gross) - cents(line.discount); const key = `${group.rate}|${group.category}`; const entry = groups.get(key) ?? { ...group, taxable: 0, allowance: 0 }; entry.taxable += cents(line.net); entry.allowance += net - cents(line.net); groups.set(key, entry); return { ...group, id: line.id, net }; }); const breakdown = [...groups.values()].map(({ rate, category, taxable }) => ({ rate, category, taxable, vat: category === "S" ? vatCents(taxable, rate) : 0, })); const allowances = [...groups.values()] .filter((group) => group.allowance > 0) .map(({ rate, category, allowance }) => ({ rate, category, amount: allowance })); const lineNet = sum(outLines.map((line) => line.net)); const allowanceTotal = sum(allowances.map((allowance) => allowance.amount)); const vat = sum(breakdown.map((row) => row.vat)); const taxExclusive = lineNet - allowanceTotal; return { lines: outLines, allowances, breakdown, lineNet, allowanceTotal, taxExclusive, vat, taxInclusive: taxExclusive + vat }; } Take the sample invoice from the editor demo further down: 25 licenses at 250 lei with 10% off, 6 hours of training at 320 lei and an e-Factura integration at 1,500 lei, all at 21%, plus 12 printed guides at 64.90 lei at 11%. With a 100 lei document discount, the function returns allowances of 92.07 lei at 21% and 7.93 lei at 11%, bases of 8,952.93 and 770.87, VAT of 1,880.12 and 84.80, and 11,688.72 lei in total.
Live demo: Invoicing. The original article embeds an interactive demo here (A $100 document discount is already set: press + or − on a quantity and the 21% and 11% rows each recompute their VAT from their own base (this demo prices in dollars)). Try it live on wingo-ui.com or open the Invoicing docs. Install:
npx wingo-ui@latest add invoicing
Then check the numbers the XML will carry, whatever produced them: an old system, a stored draft, a hand edit. The check runs on the server right before you build the file and answers with the rule ids ANAF's validator uses:
// lib/efactura-preflight.ts import type { EFacturaTotals } from "@/lib/efactura-totals"; import { vatCents } from "@/lib/vat-per-rate"; const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); export function preflight(t: EFacturaTotals): string[] { const problems: string[] = []; if (t.lineNet !== sum(t.lines.map((l) => l.net))) problems.push("BR-CO-10"); if (t.allowanceTotal !== sum(t.allowances.map((a) => a.amount))) problems.push("BR-CO-11"); if (t.taxExclusive !== t.lineNet - t.allowanceTotal) problems.push("BR-CO-13"); if (t.vat !== sum(t.breakdown.map((r) => r.vat))) problems.push("BR-CO-14"); if (t.taxInclusive !== t.taxExclusive + t.vat) problems.push("BR-CO-15"); for (const row of t.breakdown) { const same = (x: { rate: number; category: string }) => x.rate === row.rate && x.category === row.category; const base = sum(t.lines.filter(same).map((l) => l.net)) - sum(t.allowances.filter(same).map((a) => a.amount)); // intra-community rules are named IC, the others after their category code if (row.taxable !== base) problems.push(`BR-${row.category === "K" ? "IC" : row.category}-08 at ${row.rate}%`); // ANAF lets this drift by less than 1 leu; we accept 0 if (row.category === "S" && row.vat !== vatCents(row.taxable, row.rate)) problems.push(`BR-CO-17 at ${row.rate}%`); } return problems; } How do I build the editable invoice lines in React?
Give the editor the lines and read the totals it computes, then recompute on the server. Invoice Line Items edits the lines like a small spreadsheet: a product search that fills unit, price and VAT, a VAT select that defaults to RO_VAT_RATES, a discount per line with a % / lei switch, and drag to reorder. The price cells are a Currency Input, so 1234.5 becomes 1,234.5 as you type and 1,234.50 on blur. Under the lines, the totals show the taxable base, one VAT row per rate and the grand total, all from one computeInvoiceTotals call on every keystroke, and onValueChange(lines, totals)...
