Shipmind Labs

Per-line VAT rounding and totals that cannot be written

· 8 min read

An invoice can fail a tax platform while every number printed on it looks correct. Two defects get you there. VAT rounded once on the invoice total comes out a cent away from VAT rounded on each line, and a total stored next to the lines it summarises drifts the moment anything above it changes. Neither one is an arithmetic problem. Both are about placement, where the rounding falls and where the total lives, and both get settled in the model, long before any rendering code runs.

We build payment and billing services, and this part surprises people: the failing invoice is usually not wrong by much. It is wrong by one cent, in a footer, against a breakdown that was computed differently. Here is how we settle it in euinvoice (https://github.com/shipmindlabs/euinvoice), the invoicing package we maintain.

The cent that appears from nowhere#

Ten lines of 0.10 EUR at 19%. Per line, 0.10 × 0.19 = 0.019, which rounds half up to 0.02, and ten of those carry 0.20 EUR of VAT. On the sum, 1.00 × 0.19 = 0.19 EUR. Same inputs, same rate, one cent apart.

Neither number is wrong on its own. The defect is having both in one system: the line column says one thing, the footer says another, and any consumer that recomputes the totals from the lines, a validator, an accounting import, a buyer's clerk with a calculator, arrives at the number you did not print.

So the rounding happens in exactly one place, on the line:

python
    @property
    def net(self) -> Money:
        return (self.unit_price * self.quantity).rounded()

    @property
    def vat(self) -> Money:
        return Money(self.net.amount * self.vat_rate / _HUNDRED, self.currency).rounded()

    @property
    def gross(self) -> Money:
        return self.net + self.vat

Note where vat is taken from: self.net, which is already rounded. The tax is a percentage of the amount that will be printed, not of an unrounded intermediate the document never shows. And gross does not round at all, because adding two amounts that already sit on the minor unit cannot produce a third decimal.

Once the lines are rounded, everything above them is addition. Addition of exact amounts needs no rounding policy, so there is no second place where a policy could drift from the first. That is the whole trick. No cleverer rounding mode, just one rounding site.

python
    def test_vat_is_rounded_per_line(self):
        assert make_line("33.33").vat == Money("6.33", "EUR")

The minor unit is not always two decimal places#

"Round to cents" is a EUR habit, and a hard-coded quantize(Decimal("0.01")) quietly encodes it. Two of the shapes a currency can have break it, so Money asks the currency instead:

python
def minor_unit_exponent(currency: str) -> int:
    """Number of decimal places the currency is booked in."""
    code = currency.upper()
    if code in _ZERO_DECIMAL_CURRENCIES:
        return 0
    if code in _THREE_DECIMAL_CURRENCIES:
        return 3
    return 2


    def rounded(self) -> Money:
        """The amount snapped to the minor unit of its currency, half up."""
        quantum = Decimal(1).scaleb(-self.exponent)
        return Money(
            self.amount.quantize(quantum, rounding=ROUND_HALF_UP), self.currency
        )

JPY, KRW, ISK and the other zero-decimal codes are booked in whole units, so two decimals on the invoice are two digits the payment will not have. BHD, TND, KWD, OMR and their neighbours are booked in three, so rounding to two throws away a digit that is legal tender. In a EUR-only test suite you see neither failure.

Exactness upstream of the rounding gets enforced too. to_decimal accepts Decimal, int and str, and it rejects float and bool outright, on the grounds that a binary fraction cannot hold a tax amount:

python
    def test_rounds_half_up_to_minor_unit(self):
        assert Money("1.005", "EUR").rounded().amount == Decimal("1.01")

That assertion only holds because 1.005 arrived as a string. As a float it lands slightly below the midpoint, and half-up rounding takes it down to 1.00. A rounding bug with no bad rounding code in it.

There is nothing to write to#

The second defect is the stored total. A total held as a field is a cached copy of a computation, and every cache needs an invalidation story: a line edited after the total was computed, a line appended by a later process, a correction applied in a different layer. Most invoicing bugs handed to us are that story going wrong once.

Invoice.total_net, total_vat, total_gross and vat_breakdown are properties. They sum the lines on access. The dataclass is frozen and uses slots, so there is no attribute to assign and no __dict__ to smuggle one into:

python
    def test_totals_cannot_be_passed_in(self):
        with pytest.raises(TypeError):
            make_invoice([make_line("10.00")], total_net=Money("999.00", "EUR"))

A TypeError out of the constructor is the cheapest version of this bug report you can get. The alternative is a correct-looking document that reconciles against nothing. The same test sits one level down for InvoiceLine, where passing net= gets refused the same way.

The breakdown is the same addition, bucketed#

A VAT breakdown is where a stored total shows itself fastest, because it is the one place a document performs the same sum twice. vat_breakdown buckets the lines by (vat_category, vat_rate) in first-appearance order and returns VatBreakdownRow values, each carrying its own VAT and gross. Those rows are built from the same rounded line amounts the totals are built from, so agreement is structural rather than maintained:

python
    def test_vat_breakdown_sums_to_total_vat(self):
        invoice = make_invoice(
            [
                make_line("33.33"),
                make_line("66.67"),
                make_line("50.00", rate="7"),
            ]
        )
        total = Money.zero("EUR")
        for row in invoice.vat_breakdown:
            total = total + row.vat
        assert len(invoice.vat_breakdown) == 2
        assert total == invoice.total_vat

The test is worth keeping, though it checks arithmetic and not synchronisation. No code path exists in which a breakdown row and a total were computed from different inputs, because there is only one set of inputs.

The renderer inherits that property. InvoiceDocument.from_invoice copies the computed values across once, "an invoice reduced to what a page shows, with nothing left to compute", and DocumentLine.vat_label picks between a rate and a category label rather than deciding anything about money:

python
    @property
    def vat_label(self) -> str:
        """What goes in the VAT column: a rate, or why there is none."""
        if self.vat_category.is_taxable:
            return _format_rate(self.vat_rate)
        return _CATEGORY_LABELS[self.vat_category]

A template that can only format is a template that cannot disagree with the model. Reverse charge, Exempt and Zero-rated are strings in that column, not a second opinion about the total.

The guards that come with exact money#

Binding an amount to one currency makes a further class of defect expressible instead of silent. Every add, subtract and comparison goes through _require_same_currency and raises CurrencyMismatch, and the package holds no conversion and no exchange rate anywhere, so you cannot assemble a mixed-currency total by accident. Invoice.__post_init__ applies the same strictness at document level: lines in two currencies, a due_date before issue_date, and a REVERSE_CHARGE line mixed with taxable lines all get refused at construction. That last one is a totals problem in disguise. An invoice that prints a shifted-tax note above a non-zero VAT total contradicts itself, and the right moment to notice is before the object exists.

What it costs to run#

Recomputing totals on every access is Decimal addition over a handful of lines, which costs nothing at document size. Where it would matter is a report summing thousands of invoices, and that code builds InvoiceDocument once or binds the totals to a local. The properties are cheap, not free, and treating them as free inside a tight loop is probably the one performance mistake available here. Frozen objects mean a correction is a new Invoice rather than an edit, which is also how the accounting wants it.

The deliberate gap is the rate. euinvoice takes the vat_rate you pass and only checks it against the category: a taxable line needs a positive rate, an untaxed one needs zero. There is no rate table, because rates differ by member state and by what is being sold, and they change, and a stale table is worse than none. The same reasoning covers identifiers. vat_id_validated records a check you made in your own process, and nothing in the package touches the network.

Close#

Two rules, both about placement. Round once, on the line, at the minor unit the currency actually has. Derive every total and every breakdown row from those rounded lines, and leave nothing writable. What you get is an invoice that can still be wrong about the rate, a business fact we refuse to guess, but cannot be wrong about itself.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com