VAT Rounding
This page explains the VAT rounding discrepancy between LVDAM and GOBL, how bypass mode works as a temporary mitigation, and the plan to resolve it permanently. For background on how GOBL fits into the submission flow, see the Architecture. For bypass mode API details, see the Invopop Integration.
Jira tickets: PLU-4607 (root cause analysis), PLU-4358 (fix LVDAM rounding), PLU-4627 (fix double rounding in proration)
External reference: GOBL Rounding Documentation
The Problem
LVDAM and GOBL calculate VAT using different methods. When the same invoice is processed by both systems, the resulting totals can differ by one to ten cents.
How LVDAM Calculates VAT
LVDAM stores amounts in gross (what the customer pays). To derive the tax breakdown, it works backwards:
net = gross / (1 + vat_rate) → rounded to cents
vat = gross - net → residualThis is gross-to-net, per line item. Each line item's net amount is rounded independently, and VAT is the difference between gross and net.
The relevant code in Finance::BookingFinancials::VatRate:
def net_of_gross(amount)
amount / to_fraction_gross # to_fraction_gross = (percentage / 100.0) + 1.0
end
def tax_of_gross(amount)
amount - (amount / to_fraction_gross)
endAnd in the invoicing entity layer (Invoicing::Entities::LineItem):
How GOBL Calculates VAT
GOBL works from net amounts (before tax). Its default calculation method:
Group all line items by VAT rate
Sum the net amounts within each group
Apply the tax percentage to the aggregated total
Round to currency precision
This is net-to-gross, aggregated per rate. The key differences from LVDAM:
Starting point
Gross amount
Net amount
Tax direction
Gross to net (subtract)
Net to gross (add)
Rounding scope
Per line item
Per rate group (aggregated)
When rounding happens
On each line's net
On the aggregated tax per rate
GOBL also supports a "currency" rounding method (required by European Norm 16931-1:2017) which rounds to currency precision before totalling. The default "precise" method adds two extra decimal places during intermediate calculations to reduce rounding errors.
Where the Discrepancy Appears
The discrepancy manifests when:
Multiple line items share the same VAT rate — GOBL aggregates their net totals before calculating tax, while LVDAM has already rounded each line independently
Prices end in fractions that don't divide cleanly — e.g. prices ending in .99 at 10% VAT
Prorated amounts — proration splits gross and net independently, each rounded separately, compounding the difference
Concrete example from our codebase:
A 9-line invoice where per-line rounding produces a payable of €0.05, but GOBL's aggregate rounding produces €0.04:
1
€342.52
€34.25
—
2–9
−€42.81 each (×8)
−€4.28 each (×8)
—
Totals
€0.04
€0.01
€0.00
Payable
€0.05
€0.04
The €0.01 difference comes from rounding each line's VAT independently vs. summing the net amounts first and calculating tax once.
Tested Scenarios
During the investigation, a hybrid approach (bypass mode for invoices, normal mode for credit notes) was tested:
Each VAT rate used once
No difference
Each VAT rate used 2 times with .99 prices
Usually no difference
Each VAT rate used 3+ times with .99 prices
~€0.01 per rate
The risk increases with the number of line items per VAT rate and prices that produce non-terminating decimals when divided.
Bypass Mode (Current Mitigation)
Bypass mode tells GOBL to accept pre-calculated totals without recalculating them. It is activated by adding "$tags": ["bypass"] to the GOBL document.
How It Works in Our Code
The Abacus GOBL builder (invopop/gobl.ex) sets bypass mode on every invoice:
The build_totals/2 function passes through the document's pre-calculated amounts:
These amounts originate from the Document entity, which sums transaction-level values:
VAT at the document level is gross - net, matching LVDAM's approach. Because bypass mode preserves these values, GOBL does not recalculate them and no discrepancy occurs.
Limitations
Regular invoice (F1)
Works
Totals preserved
Credit note (R4)
Fails
verifactu.generate error
Credit notes submitted with bypass mode fail at the Verifactu generation step due to an Invopop bug (reported January 2026). This means credit notes must be submitted without bypass, exposing them to the rounding discrepancy.
Invopop's Guidance on Bypass Mode
Invopop support has noted that bypass mode:
Is not officially documented
Is primarily designed for accounts payable (receiving invoices), not accounts receivable
Can cause issues with conversion tools if tax totals don't match expectations
Should be tested thoroughly before production use
The Double Rounding Problem (PLU-4627)
A separate but related issue exists in LVDAM's proration logic. When booking optional services are prorated across billing periods, gross and net amounts are prorated independently, each rounded separately:
The get_amount_cents function splits amounts by day and rounds each segment. Since gross and net are prorated through separate arithmetic paths, the resulting VAT (gross - net) may not equal net × rate for any reasonable rate — it is purely a residual of two independently rounded values.
This compounds the discrepancy with GOBL because the line-item amounts entering the GOBL builder already carry proration rounding artifacts.
The Fix (PLU-4358 + PLU-4627)
Decision
Remove bypass mode and align LVDAM's VAT calculations with GOBL's method.
Instead of calculating VAT as a residual of gross-to-net per line, LVDAM will calculate VAT the same way GOBL does:
Start from net amounts
Group by VAT rate
Calculate tax on the aggregated total per rate
Round to currency precision
This eliminates the discrepancy at its source. Once LVDAM and GOBL agree on the totals, bypass mode is no longer needed and can be removed from the GOBL builder.
What Changes
LVDAM billing services
net = (gross / (1 + rate)).round; vat = gross - net
Calculate from net, aggregate per rate
LVDAM proration
Gross and net prorated independently
Prorate net, derive gross from aggregated tax
Abacus GOBL builder
"$tags" => ["bypass"] with pre-calculated totals
Remove bypass tag, let GOBL calculate totals
Credit notes
Blocked (bypass mode bug)
Unblocked (no bypass needed)
Impact
Invoices may show slightly different totals (±1 cent) compared to the current system for edge cases with many line items at the same rate
All invoices will match what Verifactu expects, eliminating intermittent rejection risk
Credit note submission will be unblocked
The undocumented bypass mode dependency is removed
Status
Both tickets are in the backlog. PLU-4358 covers the core rounding fix in LVDAM's billing services. PLU-4627 specifically addresses the double rounding in prorated billing services. They are related but can be worked independently.
Abacus's Role in the Chain
Abacus sits between LVDAM and GOBL. It receives line-item amounts from LVDAM via CDC and passes them to the GOBL builder. Understanding where amounts come from at each stage:
LVDAM (billing service)
(gross / (1 + rate)).round
Stored (source of truth)
gross - net
LVDAM (invoice line item)
From billing service
From billing service
From billing service
CDC (Kafka to Abacus)
unit_net_amount_cents
unit_gross_amount_cents
Not transmitted; derived
Abacus (Document entity)
sum(unit_net × qty)
sum(unit_gross × qty)
gross - net
GOBL (bypass mode)
Passed through from entity
Passed through from entity
Passed through from entity
GOBL (normal mode)
From lines
Recalculated
Recalculated (aggregate)
When unit_gross_amount is missing in CDC (rare for booking invoices), Abacus's TransactionsBuilder derives it:
This is a net-to-gross calculation per line — a third rounding path that can differ from both LVDAM's gross-to-net and GOBL's aggregate method.
Further Reading
GOBL Rounding Documentation — official documentation on GOBL's rounding behaviour, precision model, and the European Norm 16931-1:2017 requirement
Invopop Integration — bypass mode API details and common errors
Reference — project status and remaining backlog items
apps/abacus/apps/accounting/lib/accounting/application/e_invoicing_providers/invopop/README.md— internal bypass mode documentation with worked examples
Last updated