Reading the reverse-charge note instead of checking the box
An invoice that shifts VAT to the buyer has to say so in words. A required-fields check that only asks whether the notes field is non-empty will happily accept Payment within 14 days of the invoice date. as that statement, and the document goes out without a statutory mention, with a green light behind it. We build invoicing for sellers who get audited years after the fact, so in euinvoice the note gets read instead of counted.
The package draws one line through the whole problem. What follows from the data is derived and cannot be overridden, and what needs a fact from outside is asked for by name and refused until it arrives. The tax treatment of a supply follows from the parties, and decide_vat_regime works it out from the seller country, the buyer country, whether the buyer's identifier was validated, and whether goods or services are supplied. The sentence on the face of the invoice follows from nothing. Somebody typed it, and the Art. 226(11a) mention is only satisfied if what they typed says the right thing.
The check runs before an Invoice exists#
check_required_fields in src/euinvoice/compliance.py takes the lines, the parties, the notes and the document kind, and returns a tuple of RequiredField records. Invoice runs it last in __post_init__ and raises MissingRequiredField carrying every gap in missing, not just the first one. A seller fixing an invoice gets the whole list in one pass.
The treatment of the lines decides which questions get asked:
categories = {line.vat_category for line in lines}
charges_vat = any(category.is_taxable for category in categories)
shifts_vat = VatCategory.REVERSE_CHARGE in categories
leaves_vat_off = bool(categories & _UNTAXED_CATEGORIES)
if seller.vat_id is None and (charges_vat or shifts_vat):
missing.append(
RequiredField(
"seller.vat_id",
"a seller that charges VAT or shifts it to the buyer must "
"state the identifier it supplies under",
_SUPPLIER_ID,
)
)
if shifts_vat:
missing.extend(_reverse_charge_fields(seller, buyer, notes, supply_kind))Nothing here is a flag the caller sets. shifts_vat is a property of the lines, so an invoice cannot claim a shift it does not bill, or bill a shift it does not declare.
First the person who becomes liable, then the sentence#
_reverse_charge_fields asks for the recipient before it asks for the wording, since an unidentified recipient makes the sentence pointless:
if buyer.vat_id is None:
missing.append(
RequiredField(
"buyer.vat_id",
"the recipient who becomes liable for the tax must be "
"identified by its VAT identifier",
_CUSTOMER_ID,
)
)
elif buyer.country != seller.country:
if not buyer.vat_id_validated:
missing.append(
RequiredField(
"buyer.vat_id_validated",
"an identifier from another member state must be verified "
"before the tax can be shifted",
_CUSTOMER_STATUS,
)
)
elif buyer.vat_country is None:
missing.append(
RequiredField(
"buyer.vat_id",
"the identifier names no member state, so it does not "
"prove a taxable person abroad",
_CUSTOMER_STATUS,
)
)The field names in those records are paths into the object the caller passed, and that is deliberate. The message tells you where to go, not merely that something is wrong.
Folding the note, then matching it#
Only then does the function look at the text, through states_reverse_charge. That function returns False for anything that is not a non-blank string, since None and whitespace are not arguments about the tax. For a real string, _normalised reduces it before comparison: it applies casefold, decomposes with NFKD, drops the combining marks, maps hyphens to spaces, and collapses runs of whitespace. The note passes if any entry of _NORMALISED_WORDINGS (the same folding applied to the tuple REVERSE_CHARGE_WORDINGS) appears as a substring.
That folding exists for a specific class of real input. The official-language wordings of the mention carry diacritics, and the humans and systems that type them often do not. A note entered without accents, in lower case, with a hyphen where the list has a space, is the same sentence to a reader and to an auditor, and matching it on raw equality would reject it. Substring matching is there for the same reason: the mention usually sits inside a longer note next to payment details, and demanding that the whole field equal the wording would reject a correct invoice.
What the folding does not do is widen the semantics. A payment term stays a payment term:
>>> states_reverse_charge(None)
False
>>> states_reverse_charge(" ")
False
>>> states_reverse_charge("Payment within 14 days of the invoice date.")
FalseSo the invoice below does not exist:
invoice = Invoice(
number="2026-0007",
issue_date=date(2026, 3, 1),
seller=seller,
buyer=buyer,
lines=[
InvoiceLine(
description="Construction work",
quantity=Decimal("1"),
unit_price=Money("4800.00", "EUR"),
vat_rate=Decimal(0),
vat_category=VatCategory.REVERSE_CHARGE,
)
],
notes="Payment within 14 days of the invoice date.",
)
# MissingRequiredField: notes - the note on the invoice says nothing about
# the shift; it has to state that the recipient accounts for the VAT, in one
# of the wordings in REVERSE_CHARGE_WORDINGSWhen the parties already imply the mention#
A check that demanded the sentence every time would be wrong in the other direction. Some regimes print their own note. _reverse_charge_fields returns early on either of two paths, states_reverse_charge(notes), or _regime_states_reverse_charge, which calls decide_vat_regime and asks whether the result is VatRegime.REVERSE_CHARGE. That decision carries an invoice_note, and the rendering layer prints it. An export, for instance, comes back from decide_vat_regime with its own note naming Art. 146.
The asymmetry this produces is the useful part. A cross-border shift to a validated taxable person in another member state does not get asked to repeat a sentence the document already prints from its regime. A domestic reverse charge, where decide_vat_regime returns DOMESTIC and the seller has applied a national shift the package does not derive, has to write the mention out, because nothing in the data implies it. The same holds for a seller outside the EU: _regime_note returns None rather than guessing, since there is no regime to derive and therefore no note either.
When neither path holds, one RequiredField for notes is appended under _REVERSE_CHARGE_NOTE, and which of the two messages it carries depends on what was there. A note that exists but says nothing about the shift is told exactly that, and pointed at the list. An absent note is told that the parties do not imply the mention here, so it has to be written out. Those are different mistakes, a wrong sentence and a missing one, and a single "notes is required" would hide both.
The exemption branch next to it is deliberately looser. _carries_exemption_reason accepts any non-blank note, or a regime note, for EXEMPT and ZERO_RATED lines, because the provision an exemption rests on is free text and there is no closed list to match against. We would rather have two different strictnesses that each match their rule than one uniform strictness that is wrong for half the cases.
What it costs to run#
The cost of reading the note is a fixed list. A paraphrase that an auditor would accept is refused, and somebody has to either use a listed wording or extend REVERSE_CHARGE_WORDINGS. We take that trade because the failure modes are not symmetric: a refused correct note is caught at issue time by the person holding the invoice, while an accepted wrong note is caught by an audit. Nothing here touches the network and nothing depends on a rate table, so the check behaves the same in a test run and in production.
The mitigation is in the message. Naming REVERSE_CHARGE_WORDINGS in the requirement text means the person who hits the refusal can read the list in the source and see what the check will accept, instead of guessing at a string match. The error is the documentation for the rule it enforces.
A statutory mention is a sentence, and we think the only honest way to check for one is to read it. Folding makes that reading tolerant of how people actually type. The fixed list keeps it from drifting into accepting anything at all, and knowing when the regime speaks for itself keeps the check from demanding duplicates. Three small decisions, all living in one function, and all testable without a tax authority in the loop.