A discount has a VAT rate: EN 16931 totals from the bottom up
An invoice carrying a discount is the easiest way to produce a document that agrees with itself and is wrong anyway. The lines add up, the totals agree with the lines, and every arithmetic rule on the totals passes. The VAT breakdown is still wrong, because a document level allowance belongs to a VAT category and a rate rather than to the invoice as a whole, and it moves the taxable amount of exactly one breakdown group.
From 2026 that stops being a bookkeeping quibble. A rejected invoice used to be an email nobody answered. Now it is a document a government platform refuses. Belgium requires Peppol from January 2026, Poland's KSeF clears every invoice before it counts as delivered, and from September 2026 every business in France must be able to receive one. Under a clearance model an invoice that fails validation was not issued at all, so there is no unpaid invoice to chase, because as far as the platform is concerned nothing was sent. Failing at your own desk, with a message naming the rule and the element, beats failing at theirs.
We build payment and billing systems for a living (ledgering, attribution, reconciliation between what a system thinks it charged and what actually moved), and this failure mode is an old one in new clothes: a total that came from a different version of the document. What is new is the referee. So we wrote the checks down as a library, invoicerules (https://github.com/shipmindlabs/invoicerules), and made the write path depend on them.
An allowance on a line and an allowance on the document are different animals#
EN 16931 has both, and the difference between them is what this whole article is about.
A line level allowance or charge (BG-27 / BG-28) carries no VAT category of its own. It belongs to the line, follows the line's category and rate, and is taken off or added on before the line net amount that the totals and the breakdown are built from:
export type LineAllowanceCharge = {
/** BT-136 / BT-141: the amount, always positive. */
readonly amount: Amount;
/** BT-137 / BT-142: the amount a percentage applies to. */
readonly baseAmount?: Amount;
/** BT-138 / BT-143: percentage of the base amount. */
readonly percentage?: string;
/** BT-139 / BT-144: why it is on the line. */
readonly reason?: string;
};A document level allowance or charge (BG-20 / BG-21) does carry one, and has to:
export type AllowanceCharge = {
/** BT-92 / BT-99: the amount. */
readonly amount: Amount;
/** BT-95 / BT-102: the VAT category it belongs to. */
readonly vatCategory: VatCategory;
/** BT-96 / BT-103: the VAT rate it belongs to. */
readonly vatRate: string;
/** BT-97 / BT-104: why it is on the invoice. */
readonly reason?: string;
};Two things follow from the types alone. The amount is positive in both cases, and the list it sits in decides whether it gets subtracted or added, so there is no sign convention to get wrong and no negative amount to slip past a check. The second is that a document level discount cannot be modelled as "a discount on the invoice", because the category and the rate are required fields, and a discount that belongs to no breakdown group does not exist.
The group is computed, then compared#
Take an invoice with two lines at two rates and one discount that was negotiated against the larger of them.
const lines = [
{ id: "1", name: "Integration work", quantity: 1, netPrice: "1000.00",
netAmount: "1000.00", vatCategory: "S", vatRate: "23" },
{ id: "2", name: "Printed handbook", quantity: 10, netPrice: "20.00",
netAmount: "200.00", vatCategory: "S", vatRate: "5" },
];
const allowances = [
{ amount: "100.00", vatCategory: "S", vatRate: "23", reason: "Volume discount" },
];Done correctly, the breakdown has two groups: category S at 23 percent with a taxable amount of 900.00 and VAT of 207.00, and category S at 5 percent with 200.00 and 10.00. Total without VAT 1100.00, total VAT 217.00, total with VAT 1317.00.
Now the common wrong version, the one that falls out of a system where the discount is a field on the order rather than a member of a VAT group. The lines are reported at 1000.00 and 200.00, the VAT on them is 230.00 and 10.00, the discount is subtracted from the net total, and the document declares 1100.00 without VAT, 240.00 of VAT, 1340.00 with it.
Run the totals rules over that invoice and they all pass. The line total matches the sum of the lines (BR-CO-10). The allowance total matches the document allowances (BR-CO-11). The total without VAT is the line total less allowances plus charges (BR-CO-13). The declared VAT equals the sum of the breakdown groups (BR-CO-14). The total with VAT is the sum of the two (BR-CO-15), and the amount due matches it (BR-CO-16). The document is internally consistent to the cent, and it charges the buyer 23.00 of VAT that is not due.
The only check that catches it is the one that rebuilds each group from the lines and from the document level allowances and charges attributed to that group, then compares the result with what the document declares. That is why the breakdown is computed rather than summarised:
import { computeVatBreakdown, reconcile } from "invoicerules";
computeVatBreakdown(invoice); // subtotals per category and rate, VAT rounded to the cent
reconcile(invoice); // every arithmetic check, passing or notreconcile returns each check with the rule it comes from and the business term it constrains (BR-CO-13 against BT-109), so you can display an amount together with the rule it answers to, not only with whatever failed.
A failure is a place, not a boolean#
A red cross on its own teaches an engineer nothing. The output worth building is the pair (rule id, element path), and the path has to be the one the document will have on the wire, not the one your model uses:
import { validate, toUBL } from "invoicerules";
const result = validate(invoice);
if (!result.ok) {
for (const v of result.fatal) console.error(`${v.rule} at ${v.path ?? v.at}: ${v.message}`);
}Every evaluated rule also comes back in a coverage report, naming the element both as the model field and as its position in the document that gets sent:
validate(invoice).coverage.find((c) => c.rule === "BR-CO-16");
// { rule: "BR-CO-16", outcome: "fail",
// at: "totals.payable", path: "/Invoice/LegalMonetaryTotal/PayableAmount",
// message: "the amount due 330.00 does not match the total with VAT 300.00" }Rules that run against several elements are reported against each one, so a line with no breakdown group shows up as /Invoice/InvoiceLine[3]/Item/ClassifiedTaxCategory/ID rather than as the invoice in general. A rule with nothing to check (there is no seller VAT identifier, so there is no prefix to test) is absent from the report instead of being counted as a pass. That distinction is what makes the report readable as evidence, because it tells you what was actually looked at.
The rule identifiers are the standard's own, deliberately. BR-CO-15 reads as noise until a platform quotes it back at you, and keeping it means an error from our library and an error from the receiving end name the same thing. The wording of each message is ours. The normative text lives in EN 16931-1, published by CEN, and is not reproduced.
Cents, and why floats lose them#
Money is not a float. 0.1 + 0.2 is how a totals check fails on an invoice that is perfectly correct, and rounding VAT in binary floating point produces exactly the cent of difference a validator rejects. Every amount is held as an integer and a scale with half-up rounding, which is also why the model types amounts as strings rather than numbers. A number literal in a JSON payload has already lost the argument.
One cent of rounding difference on a total is tolerated, because with per-group rounding it is normal and the standard expects it. Two is not. Fixing this at the type level costs a small decimal module and a test file, while discovering it from a rejection notice costs a day and somebody's trust in the billing system.
The writer refuses#
Validation that a caller can skip under deadline is not validation. Serialisation is where we put the gate:
const xml = toUBL(invoice); // throws unless the rules passThere is no flag to force it. If the document has fatal violations there is no correct XML to produce, and producing it anyway only moves the failure to a place with slower feedback and an audit trail attached.
What it costs to run#
Less than you would guess, because there is nothing to operate. The checks are pure functions over a model, with no network call, no service to deploy and no queue. It runs in a unit test, in a pre-submit check, and in the request that draws the invoice preview, and it answers the same way in all three.
The cost that is real is the rule set itself. Rules get clarified, countries add their own layer on top of the European core, and each of those is a change to a list you can read rather than a change to a condition buried in a serialiser. The coverage report is the maintenance surface, since it tells you which rules ran, so adding one is visible and losing one is visible too. Two things stay deliberately out of scope. It is not an access point, because writing the document and getting it to the receiver are different jobs, the second one with registration and certificates attached. It also does not read UBL. Node has no XML parser in its standard library, so a reader means either a dependency or a hand-rolled parser, and a hand-rolled XML parser is probably a security bug with a release schedule.
Close#
Use the discount as your test case. If the VAT breakdown in your system is a summary of the lines, the invoice will satisfy every check you wrote, pass through your own review, and get refused by a platform that recomputed the groups. Under clearance, the difference between finding out at your desk and finding out at theirs is the difference between an edit and an invoice that never existed.