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 Documentationarrow-up-right

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                → residual

This 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)
end

And in the invoicing entity layer (Invoicing::Entities::LineItem):

How GOBL Calculates VAT

GOBL works from net amounts (before tax). Its default calculation method:

  1. Group all line items by VAT rate

  2. Sum the net amounts within each group

  3. Apply the tax percentage to the aggregated total

  4. Round to currency precision

This is net-to-gross, aggregated per rate. The key differences from LVDAM:

Aspect
LVDAM
GOBL

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:2017arrow-up-right) 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:

  1. Multiple line items share the same VAT rate — GOBL aggregates their net totals before calculating tax, while LVDAM has already rounded each line independently

  2. Prices end in fractions that don't divide cleanly — e.g. prices ending in .99 at 10% VAT

  3. 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:

Line
Net
LVDAM VAT (per-line)
GOBL VAT (aggregated)

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:

Scenario
Rounding difference?

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

Document type
Bypass mode
Result

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:

  1. Start from net amounts

  2. Group by VAT rate

  3. Calculate tax on the aggregated total per rate

  4. 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

Component
Current
After fix

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:

Stage
Net
Gross
VAT

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 Documentationarrow-up-right — 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