Shipmind Labs

Reverse charge needs a validated VAT identifier, nothing less

· 9 min read

An invoice that shifts VAT to the buyer rests on one fact the seller cannot get from its own records: that the buyer is a taxable person in another member state. What the seller can see is what somebody typed into a form, and that is a different thing. When the fact is unproven the invoice still has to say something, so the design question is which way the unproven case falls.

We build it so it falls to the side where being wrong costs the seller money rather than a penalty. That is the rule behind the VAT regime decision in euinvoice, the invoicing package we maintain, and it is why an absent identifier, an unvalidated identifier and an identifier with no member-state prefix all land in the same place.

Two ways to be wrong, and they do not cost the same#

If the seller charges its own VAT where the tax should have been shifted, it has collected tax it did not have to collect. The buyer overpays, notices, and asks for a correction. The money exists, the counterparty is known, and the fix is a credit note and a corrected invoice. Unpleasant, bounded, and settled between two parties who are still talking to each other.

If the seller shifts the tax where it should not have, it has issued a document that charges nobody. The buyer does not account for the tax, because it is not a taxable person. The seller did not account for it either, because the invoice says the buyer will. The liability stays with the seller and surfaces in an audit rather than in a conversation with a customer, at the point where the only evidence left is whatever the invoice happened to record.

The two errors are not symmetrical, so the default should not be neutral either. Unproven means private buyer.

The ladder, and what each rung writes down#

decide_vat_regime settles the cases where the identifier is irrelevant before it ever looks at the identifier. A buyer in the seller's own country is DOMESTIC under Art. 193 whatever it quoted, since a VAT identifier does not make a domestic supply cross-border. A buyer outside EU_COUNTRIES is EXPORT under Art. 146. Only then does it descend the proof ladder:

python
if buyer.vat_id is None:
    facts.append(
        "A buyer without a VAT identifier is treated as a private person, "
        "so the seller charges its own VAT."
    )
    return decided(
        VatRegime.PRIVATE_BUYER,
        VatCategory.STANDARD,
        "Directive 2006/112/EC, Art. 45 (supplies to non-taxable persons)",
    )

if not buyer.vat_id_validated:
    facts.append(
        "The identifier was never validated, and an unproven identifier "
        "cannot carry the tax over to the buyer."
    )
    return decided(
        VatRegime.PRIVATE_BUYER,
        VatCategory.STANDARD,
        "Implementing Regulation (EU) 282/2011, Art. 18 "
        "(evidence of the customer's status)",
    )

if buyer.vat_country is None:
    facts.append(
        "The identifier carries no EU member state prefix, so it does not "
        f"prove a taxable person in {buyer_country}."
    )
    return decided(
        VatRegime.PRIVATE_BUYER,
        VatCategory.STANDARD,
        "Implementing Regulation (EU) 282/2011, Art. 18 "
        "(evidence of the customer's status)",
    )

Three rungs that look completely different in the data, and they produce the identical regime with the identical VatCategory.STANDARD. A buyer with no identifier, a buyer with an identifier nobody checked, and a buyer with a checked string that names no member state are all, as far as the seller's exposure goes, the same buyer: one whose status is unproven. Only a validated identifier carrying a member-state prefix reaches VatRegime.REVERSE_CHARGE, with VatCategory.REVERSE_CHARGE and the Art. 196 note.

What differs between the rungs is the record. Each one appends its reason to the facts tuple and carries its own legal_reference, and the returned VatDecision holds seller_country, buyer_country, buyer_vat_id, buyer_vat_id_validated and supply_kind alongside them. Years later the question is never only what the invoice did, it is what was known at the time it did it. A regime that is a bare enum cannot answer that. One that ships the facts it was made from can.

The decision also records what it deliberately leaves undecided. For a buyer with no identifier it notes that distance selling and the One Stop Shop can move the place of supply to the buyer's country, and that this is a registration choice made elsewhere. A fact in the record is honest, a guess in the output is not.

The bool that nothing in the package sets#

Party.vat_id_validated is a plain bool defaulting to False, and no module in the package performs the lookup:

python
@dataclass(frozen=True, slots=True)
class Party:
    name: str
    address: Address
    vat_id: str | None = None
    registration_number: str | None = None
    email: str | None = None
    vat_id_validated: bool = False

    @property
    def vat_country(self) -> str | None:
        """Country prefix of the VAT identifier, when it carries a known one."""
        if self.vat_id is None:
            return None
        prefix = self.vat_id[:2]
        return prefix if prefix in _VAT_PREFIXES else None

The default points in the safe direction, and __post_init__ raises ValueError("vat_id_validated needs a vat_id to validate") rather than let a party claim a validation of nothing.

The omission is deliberate. A VIES check is a process event owned by whoever runs customer onboarding, not a function call an invoice can make while it renders. We have built KYC and KYB tooling and real-time moderation screens for compliance teams often enough to know that the check and the document live on different clocks: the check happens once, under someone's responsibility, and the invoice reads its result. A package that reached for the network here would also have to decide what to do when the service does not answer, and that decision is "charge your own VAT, or assume". That is exactly the decision we refuse to bury inside a library.

The prefix test is deliberately narrow in the other direction. vat_country only recognises a prefix in _VAT_PREFIXES, and the comparison against the address country goes through _vat_prefix, which maps GR to EL so a Greek address and an EL identifier agree. A genuine disagreement between prefix and address gets appended to facts rather than refused, because a company can be registered for VAT somewhere other than where its invoice address sits, and the package does not hold the fact that would settle it.

The refusal, one rung lower#

The regime decision chooses a treatment. The required-field check refuses to issue a document that asserts one it cannot support. For reverse-charge lines, _reverse_charge_fields walks the same ladder and turns each rung into a named refusal: buyer.vat_id when there is none, buyer.vat_id_validated when a buyer in another country was never checked, and buyer.vat_id again when a validated identifier names no member state and so proves nothing abroad. A buyer in the seller's own country needs only the identifier, because a domestic reverse charge is a real thing and there is no cross-border status to prove.

Run it on a draft before there is an invoice:

python
from decimal import Decimal

from euinvoice import Address, InvoiceLine, Money, Party, VatCategory
from euinvoice.compliance import check_required_fields

seller = Party(
    name="Nordwind GmbH",
    address=Address(
        line1="Hafenstrasse 1", postal_code="20359", city="Hamburg", country="DE"
    ),
    vat_id="DE123456789",
)
buyer = Party(
    name="Mistral SARL",
    address=Address(
        line1="Rue de Rivoli 12", postal_code="75001", city="Paris", country="FR"
    ),
    vat_id="FR12345678901",
)
lines = [
    InvoiceLine(
        description="Consulting",
        quantity=Decimal("7.5"),
        unit_price=Money("120.00", "EUR"),
        vat_rate=Decimal(0),
        vat_category=VatCategory.REVERSE_CHARGE,
        unit="hour",
    ),
]

for field in check_required_fields(seller, buyer, lines):
    print(field.field, "-", field.requirement)

The identifier is well formed, it carries a member-state prefix, and the supply is plainly business to business. It is still refused: buyer.vat_id_validated is missing, because an identifier from another member state has to be verified before the tax can be shifted, and notes is missing too, because the regime these parties actually produce is PRIVATE_BUYER, which implies no statutory note for the line to lean on. Set vat_id_validated=True and both refusals disappear together. The regime becomes REVERSE_CHARGE, its invoice_note states the shift, and the note no longer has to be written by hand.

Invoice runs the same check on itself and raises MissingRequiredField rather than exist with a gap in it. The guard runs in the other direction as well: if the regime charges no seller VAT and a line charges it anyway, construction fails with RegimeMismatch instead of printing a note that contradicts its own totals. You cannot shift the tax without proof, and you cannot charge it against a regime that charges nothing.

What it costs to run#

The cost lands on the workflow, not the code. Somebody has to perform the check and record it, and invoices issued before that happens carry the seller's VAT, so a validation that arrives late means a credit note and a reissue. There is no "probably a business" rung for the buyer who quoted a plausible identifier and was never checked, which means you get the occasional customer conversation about why the invoice has VAT on it. We take the conversation over the assessment. And the facts tuple only earns its keep if you store the decision with the invoice rather than recompute it from party records that will have changed by the time anyone asks.

That is the whole trade. Defaults decide what happens when the data is incomplete, which is most of the time, and a default is probably worth choosing on the cost of being wrong rather than on which outcome looks tidier in the happy path.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com