@attestwire/en16931
v0.14.1
Published
Validate EN 16931 e-invoices — XRechnung, ZUGFeRD, Factur-X, Peppol BIS 3.0 — and generate the UBL/CII XML. 294 official rules, errors that explain the fix, zero dependencies, runs in the browser, no Java.
Maintainers
Readme
@attestwire/en16931
E-invoice errors you can actually fix.
Validate and generate XRechnung, Peppol BIS Billing 3.0 and the Factur-X / ZUGFeRD XML (UBL 2.1 and UN/CEFACT CII) straight from TypeScript. No Java, no server, no dependencies, no account. Every rejection names the rule, explains why the regulation wants it, and tells you what to change.
$ npx @attestwire/en16931 invoice-0142.xml
FAIL invoice-0142.xml UBL · xrechnung-ubl
✗ error BR-DE-15 (BT-10)
XRechnung requires a buyer reference (BT-10). For German public-sector buyers this is the Leitweg-ID; business buyers may supply any reference, but the field must be present.
fix: Ask your client for their Leitweg-ID (public sector) or an order/customer reference, and set buyerReference.
at: /ubl:Invoice/cbc:BuyerReference (nearest element in the file: <ubl:Invoice>, line 2)
https://attestwire.com/rules/BR-DE-15
✗ error BR-DE-7 (BT-43)
XRechnung requires the element "Seller contact email address" (BT-43). The seller contact group is present but incomplete — BR-DE-5, BR-DE-6 and BR-DE-7 each make one part of it mandatory, so supplying two of the three still fails.
fix: Set seller.contact.email to a monitored mailbox — this is where the authority sends invoice queries.
at: /ubl:Invoice/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:ElectronicMail (nearest element in the file: <cac:Contact>, line 44)
https://attestwire.com/rules/BR-DE-7
1 document: 0 passed, 1 failed (2 errors, 0 warnings).For the same file, the official XRechnung schematron says
[BR-DE-15]-Das Element „Buyer reference“ (BT-10) muss übermittelt werden, and
that is all it says. Try it in your browser →
(the playground is this package running client-side: your invoice never leaves the tab).
Why use it
- Errors that teach. Each finding carries the official rule id, the business term, the reason, a concrete fix, an XPath and a passing example. You can fix the invoice without opening a 400-page standard, and so can your support desk or an LLM.
- No JVM, anywhere. The official validators are Java. This is TypeScript with zero runtime dependencies and no platform API beyond the JavaScript standard library, so the same build runs in Node 18+, Deno, Bun, Cloudflare Workers and the browser. Even the DEFLATE decoder for Factur-X PDFs is written in-repo.
- Both syntaxes, both directions. Generate and parse UBL 2.1 and CII D16B
from one typed
InvoiceInput, invoices and credit notes alike, and pull the XML out of a Factur-X or ZUGFeRD PDF. - Checked against the regulator, not against itself. The generated fixtures are accepted by KoSIT, the German government's validator. Fuzzing, mutation testing and differential runs against KoSIT and the Peppol schematron are how bugs get found here, and every disagreement on record has a name and a reason. See Conformance.
- Fast enough to run on every keystroke. Parsing and validating a typical invoice takes well under a millisecond on a laptop, and a 1,000-line invoice tens of milliseconds. CI fails the build if that regresses.
- Private by construction. Nothing in the library makes a network call. The invoice data stays in your process.
- Honest about its edges. What it does not do is written down, in detail, below, not discovered in production.
What it covers
| Format | Syntax | Generate | Read | Validate | | --- | --- | :---: | :---: | :---: | | XRechnung 3.0 | UBL 2.1 | ✅ | ✅ | ✅ | | XRechnung 3.0 | CII D16B | ✅ | ✅ | ✅ | | Peppol BIS Billing 3.0 | UBL 2.1 (CII optional) | ✅ | ✅ | ✅ | | Factur-X / ZUGFeRD, EN 16931 profile | CII inside a PDF/A-3 | the XML payload | ✅ from the PDF | ✅ | | EN 16931 core | UBL 2.1 or CII D16B | ✅ | ✅ | ✅ |
Invoices and credit notes in all of them. 294 rules of the regulation (EN 16931
core, the XRechnung CIUS and Peppol BIS 3.0, including every BR-CL-* code list
in full), plus 27 findings of the library's own (the ATW- ids): input the XML
could not carry faithfully, what a UBL credit note cannot hold, the business
facts it turns into codes, and a Leitweg-ID, IBAN, BIC, SIREN or SIRET with the
wrong form or check digits (Identifier checks). Totals are
always computed from the lines, never echoed, so a generated document cannot
fail its own arithmetic. It also sorts every finding the way the German BMF
letter of October 2025 does for the business receiving the invoice
(Findings for invoice recipients).
Command line
Check invoices you already have, no code required:
npx @attestwire/en16931 invoice.xml
npx @attestwire/en16931 invoices/ # every .xml and .pdf, recursively
npx @attestwire/en16931 factur-x.pdf # the CII payload, and the PDF around itExit status is 0 when every document passes, 1 when any fails and 2 on a usage
error, so it drops straight into a script or a CI step. A file it cannot read
counts as a failure, never a skip. Every finding names the line in your file:
the element itself when it is there, and where it belongs when it is missing,
in the file's own syntax (UBL or CII). --short prints one line per finding,
--quiet only failures, --json machine-readable output; --fail-on warning
fails on warnings too, --profile <name> judges every document against one
profile, and --large accepts invoices past the default size limits (about
3,000 lines). npx @attestwire/en16931 --help lists the rest.
Besides the rule findings validateInput returns, validate() and the
command line report a few of their own, about the file rather than the
invoice. They carry an AW- id and no docsUrl, and they are not rules, so
they are not in the rule counts: AW-SIZE (past the size limits without
--large, or options.limits raised), AW-PDF (a .pdf file that is not a PDF, or a
PDF with no readable invoice XML inside), AW-PARSE (not a UBL or CII
invoice, or not the text its encoding says; for a PDF, the XML inside it),
AW-PROFILE-SUBSET (a Factur-X MINIMUM or BASIC WL file, which
carries too little to be an EN 16931 invoice; fatal) and AW-PROFILE-SYNTAX
(a --profile or options.profile for the other syntax; a warning). The
command line alone reports AW-IO (the file could not be read). Until 0.10.0
the last two were both AW-PROFILE, and only the command line reported any
of them.
A Factur-X or ZUGFeRD PDF adds the container's own findings, about the PDF
around the invoice XML rather than the XML, and never fatal:
AW-PDF-ATTACHMENT (the XML attachment's name, whether it is the only one,
its encoding declaration, whether it is CII), AW-PDF-AF (where the PDF
registers it), AW-PDF-RELATIONSHIP (its /AFRelationship), AW-PDF-MIME
(its media type), AW-PDF-XMP (the XMP metadata: there, readable, claiming
PDF/A-3, carrying the Factur-X properties) and AW-PDF-XMP-PROFILE (the
profile the metadata declares, against the one the XML declares). They are
warnings, which --fail-on warning fails on, or information, which nothing
fails on. The container's findings lists every
case.
On GitHub, the Validate E-Invoice action runs the same engine offline on every pull request, annotates the failing files and can emit SARIF:
- uses: attestwire/validate-einvoice-action@v1
with:
files: invoices/**/*.xmlQuickstart
npm install @attestwire/en16931Which function. Checking a file you already have (UBL, CII, or a Factur-X /
ZUGFeRD PDF)? Call validate(bytes), shown under Recipes.
Building an invoice from your own data? Fill in an InvoiceInput object, check
it with validateInput, as below, and only then write the XML with
generateXRechnungUBL or generateCii. The generators do not validate.
1. Validate an invoice
A conformant XRechnung, as small as validity allows. Every identifier here is synthetic; the IBAN is the test IBAN used throughout German banking documentation.
import { validateInput, type InvoiceInput } from "@attestwire/en16931";
const invoice = {
profile: "xrechnung-ubl",
invoiceNumber: "2026-000142",
issueDate: "2026-08-09",
currency: "EUR",
buyerReference: "04011000-1234512345-06", // Leitweg-ID
deliveryDate: "2026-08-31",
seller: {
name: "Acme GmbH",
vatId: "DE123456789",
address: { line1: "Chausseestr. 1", city: "Berlin", postalCode: "10115", countryCode: "DE" },
electronicAddress: { schemeId: "0204", value: "04011000-1234512345-06" },
contact: { name: "Buchhaltung", phone: "+49 30 1234567", email: "[email protected]" },
},
buyer: {
name: "Stadt Bonn",
address: { line1: "Berliner Platz 2", city: "Bonn", postalCode: "53111", countryCode: "DE" },
electronicAddress: { schemeId: "0204", value: "04011000-1234512345-06" },
},
payment: { meansCode: "58", iban: "DE02120300000000202051" },
lines: [
{ id: "1", description: "Consulting, August 2026", quantity: 10, unitCode: "HUR", unitPrice: 150, vatCategory: "S", vatRate: 19 },
],
} satisfies InvoiceInput;
const result = validateInput(invoice);
console.log(result.valid, result.errors.length); // true 0That object is the whole input contract. Keep the satisfies InvoiceInput:
profile is a union of five string literals, and without it TypeScript widens
"xrechnung-ubl" to string and the call no longer compiles. The same object
feeds both generators: generateXRechnungUBL(invoice) returns UBL 2.1 XML, and
generateCii({ ...invoice, profile: "xrechnung-cii" }) returns CII.
2. Take one field out
Remove the buyer reference (BT-10, the Leitweg-ID a German public-sector buyer requires) and you get a rejection that names the rule and tells you what to do:
const { buyerReference, ...missingReference } = invoice;
const rejected = validateInput(missingReference);
console.log(rejected.valid, rejected.errors.map((e) => e.rule)); // false [ 'BR-DE-15' ]rejected.errors[0] is this object, in full:
{
"rule": "BR-DE-15",
"field": "BT-10",
"severity": "fatal",
"message": "XRechnung requires a buyer reference (BT-10). For German public-sector buyers this is the Leitweg-ID; business buyers may supply any reference, but the field must be present.",
"fix": "Ask your client for their Leitweg-ID (public sector) or an order/customer reference, and set buyerReference.",
"example": "\"buyerReference\": \"04011000-1234512345-06\"",
"xpath": "/ubl:Invoice/cbc:BuyerReference",
"docsUrl": "https://attestwire.com/rules/BR-DE-15"
}Both snippets, their console.log output and that JSON are executed and
type-checked against this build on every test run
(src/readme-quickstart.test.ts in the repository), so this page cannot drift
from the library.
Say what happened, not the code
EN 16931 states VAT as codes. Your system knows that the customer is a
business in France; the standard wants AE, VATEX-EU-AE and
"Steuerschuldnerschaft des Leistungsempfängers". State the fact as
vatScenario, and leave out the payment means code when there is an IBAN, and
the engine writes the codes:
import { applyDefaults, generateXRechnungUBL, validateInput, type InvoiceFacts } from "@attestwire/en16931";
const facts = {
profile: "xrechnung-ubl",
invoiceNumber: "2026-000143",
issueDate: "2026-08-09",
currency: "EUR",
buyerReference: "PO-FR-2026-0088",
deliveryDate: "2026-08-31",
vatScenario: "intra-eu-services", // a service to a business in another EU member state
seller: {
name: "Acme GmbH",
vatId: "DE123456789",
address: { line1: "Chausseestr. 1", city: "Berlin", postalCode: "10115", countryCode: "DE" },
electronicAddress: { schemeId: "9930", value: "DE123456789" },
contact: { name: "Buchhaltung", phone: "+49 30 1234567", email: "[email protected]" },
},
buyer: {
name: "Client Exemple SARL",
vatId: "FR12345678901",
address: { line1: "12 rue de la République", city: "Lyon", postalCode: "69001", countryCode: "FR" },
electronicAddress: { schemeId: "9957", value: "FR12345678901" },
},
payment: { iban: "DE02120300000000202051" }, // no meansCode: a euro payment to a SEPA IBAN is "58"
lines: [{ id: "1", description: "Consulting, August 2026", quantity: 8, unitCode: "HUR", unitPrice: 175 }],
} satisfies InvoiceFacts;
const result = validateInput(facts);
console.log(result.valid, result.information.map((n) => n.rule)); // true [ 'ATW-VAT-SCENARIO-APPLIED', 'ATW-PAYMENT-MEANS-INFERRED' ]
const { invoice } = applyDefaults(facts);
console.log(invoice.lines[0]?.vatCategory, invoice.vatExemptionReasonCodes); // AE { AE: 'VATEX-EU-AE' }
const xml = generateXRechnungUBL(facts); // byte for byte generateXRechnungUBL(invoice)validateInput, generateXRechnungUBL and generateCii all apply
applyDefaults first, so the document is byte for byte the one the explicit
codes produce; the test suite proves it for every scenario in both syntaxes.
applyDefaults(facts) gives you that explicit invoice, to store or inspect,
with one information note per thing it filled in (ATW-VAT-SCENARIO-APPLIED,
ATW-PAYMENT-MEANS-INFERRED). The same notes come back in validateInput's
information, which never affects valid. applyVatScenarios is the VAT half
on its own.
| vatScenario | Category, rate | BT-121 | BT-120 for a seller in DE or AT · FR · elsewhere | The invoice must also state |
| --- | --- | --- | --- | --- |
| "domestic" | S, your vatRate | — | — | a vatRate on each line; it is never guessed |
| "intra-eu-goods" | K, 0 | VATEX-EU-IC | Steuerfreie innergemeinschaftliche Lieferung · Exonération de TVA, article 262 ter I du CGI · Intra-Community supply | both parties' VAT identifiers, deliverTo.countryCode, and deliveryDate or invoicingPeriod |
| "intra-eu-services" | AE, 0 | VATEX-EU-AE | Steuerschuldnerschaft des Leistungsempfängers · Autoliquidation · Reverse charge | both parties' VAT identifiers |
| "export" | G, 0 | VATEX-EU-G | Steuerfreie Ausfuhrlieferung · Exonération de TVA, article 262 I du CGI · Export outside the EU | the seller's VAT identifier |
| "small-business-exemption" | E, 0 | VATEX-FR-FRANCHISE in France; none in Germany | Steuerbefreiung für Kleinunternehmer gemäß § 19 UStG · TVA non applicable, article 293 B du CGI · refused | a seller in Germany or France, with a tax number or VAT identifier |
A seller in Austria gets the German texts, except that the small-business exemption there is not § 19 UStG and is refused, as everywhere outside Germany and France.
- Where it goes. On the invoice,
vatScenariois the default for every line, document level allowance and document level charge. On one of those, it overrides the default. - What you state wins. A
vatCategory,vatRate, orvatExemptionReasons/vatExemptionReasonCodesentry you give is kept. An item that states a category under the invoice's default keeps it; an item whose ownvatScenarioand ownvatCategorydisagree is the fatalATW-VAT-SCENARIO-CONFLICT, because one of the two is wrong and nothing downstream can tell which. - Missing facts are reported, never invented. Each is a fatal
ATW-VAT-SCENARIO-FACT-MISSINGnaming the scenario and the field, beside the regulation's own finding (BR-IC-12and so on) where it has one. The reverse charge asks for both VAT identifiers even thoughBR-AE-02accepts a tax or legal registration number, because Article 226(3) and (4) of the VAT Directive require them. A misspelt scenario isATW-VAT-SCENARIO-UNKNOWN, with the one it most likely meant. - The payment means code (BT-81) is inferred only when
payment.meansCodeis absent:"59"for a direct-debit mandate reference on a euro invoice ("49"otherwise), else, with an IBAN,"58"for a euro invoice paid to an IBAN in the SEPA scheme (EPC409-09, version 8.0) and"30"otherwise. An empty string is a stated value, and a file read byvalidate()always states one, so files are judged exactly as before. - It is not a tax engine. You decide which scenario applies; the engine makes sure that decision is encoded correctly. Domestic reverse charge (such as § 13b UStG), triangulation, distance sales and exempt or zero-rated domestic supplies have no scenario: state their codes as before.
- Types.
InvoiceFactsisInvoiceInputwithvatCategoryandpayment.meansCodeoptional. Functions typedInvoiceInput, such ascomputeTotals, want the explicit form:applyDefaults(facts).invoice.
The small-business exemption, and where its codes come from
Tracker threads show real confusion here, so each choice rests on the source
that decides it, and the sources are also cited in src/vat-scenarios.ts.
- Germany, § 19 UStG. Category E at rate 0, with no BT-121 code: the CEF
VATEX list has none for Germany. Category E is KoSIT's own answer
(XRechnung change request 32,
implemented in XRechnung 1.2: BT-118
E, BT-1190), and since 1 January 2025 § 19 Abs. 1 UStG says outright that the turnover "ist steuerfrei". The sources disagree on the wording, and this build follows the most authoritative. KoSIT's request proposed "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG", and common practice writes "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet."; both describe the regime before 2025, when the tax was merely not levied. The law now requires a note that "die Steuerbefreiung für Kleinunternehmer gilt" (§ 34a Satz 1 Nr. 5 UStDV), and the Federal Ministry of Finance accepts any wording that names that exemption unambiguously (letter of 18 March 2025, III C 3 - S 7360/00027/044/105, UStAE 14.7a Abs. 1). The text written is therefore "Steuerbefreiung für Kleinunternehmer gemäß § 19 UStG", in the regulation's own words. To write another, setvatExemptionReasons.E. - France, franchise en base. Category E at rate 0,
VATEX-FR-FRANCHISE("France domestic VAT franchise in base" in the CEF VATEX list, which this build ships forBR-CL-22), and the mention "TVA non applicable, article 293 B du CGI" that BOFiP BOI-TVA-DECLA-40-10-20 § 50 prescribes. No French rule ties the code to a category, so EN 16931 decides, and it leaves only E:BR-Z-10forbids any exemption reason on Z, andBR-O-10reserves O's for "not subject to VAT". At least one vendor documents a mapping to Z; carrying the code there failsBR-Z-10. - Anywhere else the scenario is refused with
ATW-VAT-SCENARIO-UNSUPPORTED, and nothing is filled in: each member state runs its own scheme with its own wording.
A credit note from the original
import { createCreditNote } from "@attestwire/en16931";
const creditNote = createCreditNote(invoice, {
invoiceNumber: "2026-G00021",
issueDate: "2026-09-01",
reason: "Beratung nicht erbracht.",
});
console.log(creditNote.invoiceTypeCode, creditNote.precedingInvoices); // 381 [ { invoiceNumber: '2026-000143', issueDate: '2026-08-09' } ]The credit note references the original (BT-25, BT-26), keeps its profile,
parties, currency, payment details, references and delivery details, and
credits the lines you list in lines (all of them by default) with their
positive quantities and amounts: the type code, 381, is what says the money
goes back. What belongs to the original's settlement stays behind: the due
date, the VAT point date, the paid and rounding amounts, declared totals and
attachments. Document level allowances and charges, and the VAT total in the
accounting currency (BT-111), travel only with a full credit, because splitting
them over some of the lines is not something a library can decide. A line id
the original does not have throws a RangeError rather than crediting less
than you meant. The generators emit a UBL CreditNote or a CII document with
TypeCode 381, as for any credit note.
The snippets in this section are executed and type-checked on every test run
too (src/readme-facts.test.ts).
Recipes
Why did my customer's platform reject this file? Hand it over as it is. UBL, CII and Factur-X / ZUGFeRD PDFs are told apart from their bytes, the declared encoding is honoured, and every finding says which line of the file it is about:
import { readFile } from "node:fs/promises";
import { validate } from "@attestwire/en16931";
const result = validate(await readFile("invoice.xml")); // or invoice.pdf
for (const e of result.errors) console.log(`line ${e.location?.line}`, e.rule, e.fix);validate never throws for anything about the file. A ZIP, an HTML login page
or a PDF with no invoice inside comes back as one fatal AW- finding naming what
it is, with the reader's own exception in result.error. result.invoice is the
invoice as read, ready for either generator.
Decoding is strict. Bytes that are not valid in the encoding a file declares are a finding, never a replacement character, and so is a file converted to UTF-8 whose declaration still names a single-byte encoding such as ISO-8859-1: read as it declares, every "ß" in it would be "ß". If you have already decoded the text yourself, pass the string, which is taken as it is.
Generate the XML. One model, pick the syntax by picking the function. The generators write whatever they are given, fatal findings and all, so check first:
import { generateCii, generateXRechnungUBL, validateInput } from "@attestwire/en16931";
const { valid, errors } = validateInput(invoice);
if (!valid) throw new Error(errors.map((e) => e.rule).join(", "));
const ubl = generateXRechnungUBL(invoice); // XRechnung UBL
const cii = generateCii({ ...invoice, profile: "facturx-en16931" }); // Factur-X XML payload
const credit = generateXRechnungUBL({ ...invoice, invoiceNumber: "2026-G00021", invoiceTypeCode: "381" }); // credit notePlug it into what you already run:
| | |
| --- | --- |
| Stripe | examples/stripe: a finalized Stripe invoice to validated XRechnung or Factur-X XML, in one file you copy. |
| Medusa v2 | medusa-plugin-einvoice: XRechnung and Factur-X XML for every order. |
| GitHub Actions | validate-einvoice-action: pull-request annotations, SARIF, fully offline. |
| AI agents | Attestwire MCP server: validate, explain and generate from Claude, Cursor or any MCP client. |
| No Node at all | Hosted API: the same engine over HTTP, for PHP, Python, Go or anything else that can POST JSON. |
| Any rule, explained | Rule reference: one page per rule, with the reason, the fix and a passing example. Every finding's docsUrl points there. |
How it compares
| | @attestwire/en16931 | KoSIT validator | Mustang | | --- | --- | --- | --- | | Runtime | Any JavaScript runtime, browser included | Java | Java | | Validates UBL and CII | ✅ | ✅ | ✅ | | Generates UBL and CII XML | ✅ | — | ✅ | | Reads Factur-X / ZUGFeRD PDFs | ✅ | — | ✅ | | Writes Factur-X / ZUGFeRD PDFs | — | — | ✅ | | Error output | rule, reason, fix, example, docs link | the schematron's assertion text | the schematron's assertion text | | Status | a fast pre-flight, checked against KoSIT | the reference German receivers use | established Java library |
Use this package to catch and explain problems early, in the language your
stack already speaks. Where a receiver's verdict is what counts, run KoSIT too:
scripts/kosit-check.sh does it for you. If you need
the PDF/A-3 container written, pair generateCii with a PDF/A-3 library or
Mustang.
Limits worth knowing up front
- It validates the invoice model, not the XML document. A file is parsed
into
InvoiceInputand the rules run on that. It is not a schematron, so a document it passes can in principle still be rejected by KoSIT. - It does not write Factur-X or ZUGFeRD PDFs. It writes the CII XML payload and reads the PDF. A file this package produces is a CII XML document, not a Factur-X file. Why.
- It checks a Factur-X PDF's container, not its PDF/A conformance. It checks the XML attachment and what the XMP metadata claims, against the formats and against the XML. Whether the file is the PDF/A-3 it claims to be is a question for a PDF/A validator such as veraPDF.
- It does not send invoices. No Peppol access point, no transmission.
- The full, specific list is in Not implemented yet.
Reference
Teaching errors
The quickstart shows one missing field and the object
it produces. Every finding has that shape, and a rejection is a list of them
rather than "validation failed": drop the buyerReference, the payment block
and the seller contact from the quickstart invoice and validateInput reports
three fatal findings (BR-DE-15, BR-DE-1, BR-DE-2), with nothing in
warnings and nothing in information. (That count is asserted by
src/readme-quickstart.test.ts (repository) against this build.)
Errors explain the reason, not just the requirement. BR-S-05 does not say
"rate must be > 0"; it says a zero rate with category S is contradictory, and
that if no VAT is due the category should be Z, E, AE, K, G or O, each with
different evidencing requirements.
Findings are separated by severity, because the reference validators separate
them: KoSIT's schematron flags each assertion fatal, warning or
information, and a report that promotes an advisory to an error is as wrong as
one that misses it. result.valid reflects fatal rules only, so advisory rules
(BR-DE-27, BR-DE-28) never block a build. result.information is a third
array, deliberately kept out of warnings: a caller who fails a build on a
non-empty warnings array should not be stopped by a finding the official
validator raises and then accepts. BR-DE-TMP-32 (an invoice should state a
delivery date) is the rule that needs it.
If you switch or filter on severity, add the third value: a consumer that
allow-lists ['fatal', 'warning'] will silently drop information findings.
The union is exported as the type Severity, so a switch over it that misses
a case fails the build rather than the audit.
Locations
validateInput judges an object, so the xpath on its findings is where the
element sits in the UBL this library would generate. validate judges a file,
and walks each finding back to the file:
{
"rule": "BR-CL-18",
"xpath": "/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem[2]/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode",
"location": {
"line": 137,
"column": 11,
"path": "/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem[2]/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode",
"exact": true
}
}That is the second line's VAT category in fixtures/xrechnung-cii-extended.xml
set to Q. path uses the prefixes the file declares; xpath is the same
element when it is found.
- CII documents get CII paths. A Factur-X or XRechnung CII finding points at
the
ram:element, not at acac:path that does not exist in the file. - Positions are the file's. The rules count a document's allowances before its charges; a file that states a charge first still gets the right element.
exact: falsemeans "look here". When the element is missing, the location is the element it belongs in, andxpathis where it goes. The same happens when the file holds several elements the rule could mean and nothing says which (several VAT breakdown groups, several tax registrations): the location is their parent rather than a guess.- From a PDF, the line and column are in the embedded XML, and
location.attachmentnames it.toSarifputs a line in the SARIF region only when it is a line of the file the log names. The container's ownAW-PDF-*findings are about the PDF around the XML, where a key or an XMP property has no line, so they carry nolocationand their message names the place.
Identifier checks
EN 16931 asks for a Leitweg-ID, an IBAN or a SIREN and does not recompute
its check digits (XRechnung's BR-DE-19 does, for the IBAN of a SEPA
transfer), so a mistyped one passes validation and fails later: the portal cannot route the invoice, the bank returns the payment, the
platform finds no such company. validateInput and validate recompute them
and report five warnings of their own. None is a rule of EN 16931 or of a
CIUS, so they never change valid.
| Finding | What is checked | Where |
| --- | --- | --- |
| ATW-LEITWEG-ID-INVALID | The form and the ISO/IEC 7064 MOD 97-10 check digits of the Leitweg-ID format specification 2.0.2 (Koordinierungsstelle für IT-Standards, 2021): a coarse address of 2 to 12 digits, an optional fine address of up to 30 letters and digits, two check digits, joined by hyphens. | Any identifier under scheme 0204 (BT-34, BT-49, BT-29, BT-46, BT-30, BT-47, BT-60, BT-61, BT-71). The buyer reference (BT-10) only when it has the complete form of a Leitweg-ID, a coarse address of 2, 3, 5, 8, 9 or 12 digits beginning with a Land code (01 to 16) or 99, on an invoice to a German buyer. |
| ATW-IBAN-INVALID | ISO 13616: the length the SWIFT IBAN Registry gives the country, and the MOD 97-10 check digits, in capitals. | BT-84 under a SEPA credit transfer (58), and any other BT-84 that starts like an IBAN. On an XRechnung SEPA transfer BR-DE-19 already reports the form and the check digits, so this adds only the length. |
| ATW-BIC-INVALID | ISO 9362: 8 or 11 capital letters and digits, the fifth and sixth a country code. | BT-86, when present. |
| ATW-SIREN-INVALID | Nine digits, the last a Luhn check digit. | Any identifier under scheme 0002. |
| ATW-SIRET-INVALID | Fourteen digits that pass the Luhn check, as the SIREN they begin with must. La Poste's establishments (SIREN 356000000) are INSEE's exception: their digits add up to a multiple of 5. | Any identifier under scheme 0009. |
A business buyer may put any reference in BT-10, which is why it is checked
only when it evidently is a Leitweg-ID; PO-4711 and 2026-07-31 never are.
Two identifiers are left alone on purpose. A GLN (scheme 0088) is checked by
PEPPOL-COMMON-R040 on profile: "peppol-bis-3", at Peppol's severity, and
not on other profiles. A VAT identifier's check digits differ by country,
several countries publish none, and whether one exists is for VIES to say;
BR-CO-09 checks its country prefix.
The same checks are exported as yes/no functions, for a form that wants the
answer before the invoice exists: isValidLeitwegId, isValidIban,
isValidBic, isValidSiren and isValidSiret, with IBAN_LENGTHS.
Findings for invoice recipients
A business that receives an e-invoice in Germany has to decide what each
finding means for it. The BMF letter of 15 October 2025 on mandatory
e-invoicing (III C 2 - S 7287-a/00019/007/243) sorts what a validator reports
three ways, and recipientClass(ruleId) says which of them a finding is:
| Class | What it covers | The letter |
| --- | --- | --- |
| format | The file is not a structured e-invoice: not UBL or CII XML (AW-PARSE), a PDF with no readable invoice XML (AW-PDF), a Factur-X MINIMUM or BASIC WL file (AW-PROFILE-SUBSET), or a value the syntax cannot hold. | Rn. 6a: such a file is a "sonstige Rechnung", whatever kind of format error it has. |
| vat-relevant | A business rule about content §§ 14 Abs. 4 and 14a UStG require: the parties' names and addresses, the supplier's tax number or VAT identifier, the issue date and invoice number, the quantity and kind of the supply, the date of supply, the net amount per rate, the tax rate and amount or the exemption note, agreed reductions, the reverse-charge note, and the invoice a correction or credit note refers to. | Rn. 35a: a breach of these makes the invoice not "ordnungsmäßig". |
| formal | Every other business rule: the buyer reference (BR-DE-15), contact data, electronic addresses, payment details, most code-list formalities, the identifier checks above, and the PDF container of a hybrid invoice whose XML was read. | Rn. 35a: business-rule errors about other content are "umsatzsteuerlich unbeachtlich". In a hybrid invoice the XML prevails (UStAE 14.4 Abs. 3). |
import { readFile } from "node:fs/promises";
import { recipientClass, validate } from "@attestwire/en16931";
const result = validate(await readFile("invoice.xml"));
for (const f of [...result.errors, ...result.warnings, ...result.information]) {
console.log(recipientClass(f.rule), f.rule);
}The class belongs to the rule id, from a table that covers every id this build
reports, AW- and ATW- findings included, and a test fails when an id is
missing from it. Where one id covers several business terms it takes the more
cautious class, and an id the table does not know is vat-relevant. The code
comment in src/recipient-class.ts cites the paragraph behind each group.
This is Attestwire's reading of the letter, not tax advice. The letter is
explicit that validation supports the recipient's own check of an invoice for
completeness and correctness and does not replace it, and that an invoice can
carry a content error no rule detects, a wrong tax rate being its example. A
vat-relevant class says a finding concerns content the VAT law requires; it
does not settle whether the invoice meets that law, and neither does the
absence of findings.
Reading an existing UBL invoice
parseUbl reads a UBL 2.1 Invoice or CreditNote document into the same
InvoiceInput object the rest of this package uses. That is what lets you
answer the question people actually arrive with: my customer's platform
rejected this file. Why?
The document type is detected from the root element, not asked for, and comes
back in invoice.invoiceTypeCode. Feed the result to generateXRechnungUBL and
you get the same document type out. (The function is still exported under its
old name, parseUblInvoice, which reads credit notes too.)
To check a file, you do not need the reader: validate detects the syntax,
reads it with parseUbl or parseCiiInvoice, runs the rules and points each
finding at its line (see Recipes and Locations). Call
parseUbl yourself when you want the InvoiceInput object, to change a field
and generate the document again:
import { parseUbl, generateXRechnungUBL, validateInput } from "@attestwire/en16931";
const { invoice, unmapped } = parseUbl(xmlString);
const fixed = { ...invoice, buyerReference: "04011000-1234512345-06" };
if (validateInput(fixed).valid) {
const corrected = generateXRechnungUBL(fixed);
}
for (const item of unmapped) {
console.log(item.kind, item.path, item.reason);
}It is a reader, not an authority. It tells you what is in the document, not
whether a receiver will accept it. A file that passes validate can still be
rejected by KoSIT or by a receiving platform: the rules run over what the
reader understood, not over the XML, and this build is not a schematron. See
Not implemented yet.
What it reads
Every element generateXRechnungUBL emits, mapped back to the field it came
from. The round trip is tested: for each committed fixture,
generateXRechnungUBL(parseUbl(xml).invoice) returns the identical
document, and the result validates identically, for the credit-note fixtures as
well as the invoice ones.
Namespaces are resolved by URI, not by prefix. A document that calls the two
common namespaces b: and a:, or that puts the root in the default
namespace, reads the same as one using cbc: and cac:. Element order does not
matter to the reader.
The document's own totals (BT-106 to BT-115) are read into declaredTotals, so
validateInput checks the document's arithmetic against ours under the
BR-CO-* rules. The VAT breakdown and the line net amounts are recomputed from
the lines instead of being stored, because that is how the input model works.
parseUbl also returns customizationId and profileId: BT-24 and
BT-23 exactly as the document states them. invoice.profile is derived from
BT-24. If BT-24 is missing or unknown, the profile falls back to en16931 or is
guessed from the text, and the guess is reported in unmapped, because the
profile decides which CIUS rules run.
What it refuses
It throws instead of returning a half-read invoice. Every error extends
ParseError and carries a stable code.
| Error | code | When |
| --- | --- | --- |
| UnsupportedSyntaxError | unsupported_syntax | The root element is neither a UBL Invoice nor a UBL CreditNote. A CII document (ZUGFeRD, Factur-X, XRechnung CII) gets its own message saying so and pointing at parseCiiInvoice. |
| UnsupportedCreditNoteError | unsupported_document_type | Never. Kept exported for compatibility; nothing has thrown it since credit notes became readable. |
| XmlSecurityError | see below | The document hit one of the security limits. |
| XmlSyntaxError | various | The document is not well-formed, or uses a construct outside the accepted subset. |
Factur-X and ZUGFeRD carry this CII inside a PDF/A-3. Since 0.7.0 that container
is read: extractFacturX returns the embedded XML, which you hand to
parseCiiInvoice, and what the container says about that XML is checked (see
the container's findings). It is still never
written.
Security limits
The XML comes from someone else. The reader is written for the UBL subset and refuses everything outside it, rather than accepting more and hoping.
| Defence | Limit | What it stops |
| --- | --- | --- |
| No DTD processing | any <!DOCTYPE or <!ENTITY in the document is refused (xml_doctype_forbidden, xml_entity_declaration_forbidden) | XXE — an external entity that reads a local file or makes a network request. Also the declaration half of billion-laughs. The check runs on the raw text, so a DOCTYPE inside a CDATA section is refused too. |
| No custom entity expansion | only & < > " ' and numeric character references are decoded (xml_entity_forbidden) | Billion laughs. An unknown entity is refused, never silently dropped — dropping one would change the text of a tax document without saying so. |
| Depth cap | 100 elements (xml_too_deep) | Deeply nested documents. A UBL invoice nests about eight levels. |
| Size cap | 8,000,000 characters (xml_too_large) | Memory exhaustion from a very large upload. |
| Element cap | 50,000 elements (xml_too_many_elements) | A flat document of millions of tiny elements, which passes both caps above. |
| Attribute cap | 256 per element (xml_too_many_attributes) | A root carrying tens of thousands of xmlns: declarations, each of which enters the namespace map every descendant lookup uses. |
All four numbers are the defaults in DEFAULT_XML_LIMITS and can be raised per
call: parseUbl(xml, { maxCharacters: 16_000_000 }).
What the caps protect, and what they cost. They are memory limits, chosen from measurement, not from how big a file "feels". Every element in the parsed tree retains roughly 250–400 bytes (the object, its four name strings, its attribute array and its children array), so it is the element cap, not the size cap, that bounds what a document can make the parser hold: at the default 50,000 elements the measured worst case is 35 ms and 16.3 MB retained. The size cap is set high enough for the documents real validators accept, including ones whose bulk is a single base64 attachment (a 3.29 MB CEN example parses in twelve milliseconds and retains 0.4 MB).
The cost is that an unusually large invoice is refused, not parsed. The
largest fixture in this repository is under 10 kB and a thousand-line invoice
lands around 300 kB, so neither cap is a limit ordinary use meets. Base64
spends four characters per three bytes, so an attachment of about 6 MB fills
the default size cap on its own; that is the case to raise maxCharacters for,
deliberately.
⚠ Changed in 0.4.0. The size cap was 10,000,000 characters and the element cap 200,000. Measured on Node 22, a legal document at the old size cap retained about 81 MB of heap and about 306 MB of RSS, over the 128 MB a Cloudflare Workers isolate is allowed, so a single such request was killed rather than rejected. A 785 kB body already retained about 47 MB. If you run this on a server with real memory and you know why you need it, raise the option.
The reader also refuses mixed content (an element holding both text and child elements), unbound namespace prefixes, and control characters XML 1.0 does not permit. Comments and processing instructions are skipped and never acted on: a stylesheet instruction cannot make this library fetch anything.
Nothing is dropped silently
Anything in the document that does not reach the invoice object is returned in
unmapped, with its path, its name, its namespace and its text:
{
path: "/ubl:Invoice/cac:AccountingSupplierParty/cac:Party/cbc:WebsiteURI",
name: "cbc:WebsiteURI",
namespace: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
kind: "unknown",
reason: "This parser has no field for this element, so neither it nor anything inside it reached the invoice object.",
text: "https://example.invalid"
}kind separates the two reasons, and they are very different:
"unknown": there is no field for it. The content is gone from the model. If it matters to you, read it from the XML yourself."recomputed": the element is understood, but the model derives the value instead of storing it. Line net amounts (BT-131) and the VAT breakdown (BT-116, BT-117) are the whole of this list for a document this package generated. Nothing is lost; the values come back from the lines.
An unmapped group is reported once, not once per element inside it. A number the reader cannot read is reported too (including an empty element, which slipped through this promise until 0.6.0), and the field is left unset, not guessed at.
For the six document totals that is no longer the end of it. Being left
unset used to mean nothing compared them and the document validated clean; since
0.6.0 the reader records what happened in declaredTotals.defects, and a total
that the document should state and does not fails BR-12, BR-13, BR-14 or
BR-15, while one that is present and unreadable (12,34, say) fails
ATW-DECLARED-TOTAL-NOT-A-NUMBER. Building an invoice from the JSON model is
unaffected: omit a total there and the library computes it, as it always has.
What a real XRechnung from a German portal will hit
Honestly: things this reader does not yet handle.
ubl:SelfBilledInvoiceandubl:SelfBilledCreditNote. Two more UBL root elements, for documents the buyer issues. Refused by root element. BT-3389and261are read and written on the ordinaryInvoiceandCreditNoteroots, which is what EN 16931's binding asks for. It is the self-billing workflow, not the type code, that is out of scope.cac:Signature,cbc:CopyIndicator,cbc:UBLVersionIDand the other UBL elements that carry no EN 16931 business term. These parse, and appear inunmappedas"unknown". They are not errors.- Repeated groups the input model holds only once: a second
cbc:Note, a secondcac:PartyIdentification, a secondcac:PaymentMeans. The first is read; the rest are reported as"unknown". cac:PaymentTerms#SKONTO#lines. XRechnung encodes discount terms in the payment-terms text. They are read as text, exactly as written, and are not parsed into fields.- A tax scheme other than
VATorFCon a party is reported, not taken for a VAT number.
Nothing in that list produces a wrong invoice. Everything in it produces either
a clear refusal or an unmapped entry.
The PDF: read, never written
Factur-X and ZUGFeRD are CII XML inside a PDF/A-3 container. As of 0.7.0 the container is read (extraction); it is still never built. What the container says about the XML inside it, and what its XMP metadata claims, is checked: see the container's findings.
To check one, hand the PDF's bytes to validate, which finds the attachment,
reports where in it each finding is, and adds the container's own findings:
import { readFile } from "node:fs/promises";
import { validate } from "@attestwire/en16931";
const result = validate(await readFile("invoice.pdf"));
console.log(result.container, result.valid);The reader underneath is extractFacturX, which pulls the XML attachment out of
a Factur-X, ZUGFeRD or XRechnung-CII PDF, for when you want the XML itself:
import { readFile } from "node:fs/promises";
import { extractFacturX } from "@attestwire/en16931";
const { xml, attachmentName, findings, xmp } = extractFacturX(await readFile("invoice.pdf"));It reads classic cross-reference tables, cross-reference streams and object
streams, and inflates FlateDecode with a DEFLATE implementation written into
this package, so the zero-dependency promise holds and the function stays
synchronous. findings carries what the container says about the attachment
and in its metadata, each with a stable id; xmp is what the metadata states
(the PDF/A part and level, the Factur-X properties), and relationship the
attachment's /AFRelationship. warnings carries the attachment notes as
sentences, exactly as it always has: a non-standard attachment name, a missing
or wrong /AFRelationship, more than one XML attachment. Malformed PDFs raise
a named error with a stable code, never a crash.
xml is the attachment's UTF-8 text, exactly. Factur-X and ZUGFeRD
attachments are UTF-8, and a receiver is entitled to read them as UTF-8
whatever they declare, so an attachment in another encoding — ISO-8859-1,
UTF-16 — or whose bytes are not valid in the encoding it names throws
FacturXEncodingError, naming the encoding, rather than coming back with
replacement characters. Plain ASCII under another declaration reads the same
either way and is returned, with a warning. validate reports the same
refusal as a fatal AW-PARSE finding.
The container's findings
Every observation about the container has a stable id in
extractFacturX(...).findings, and validate(), the command line, --json,
toSarif and toJunitXml report it under the AW-PDF-* id beside it:
| id | Finding | Severity | When |
| --- | --- | --- | --- |
| attachment_name | AW-PDF-ATTACHMENT | warning | The XML is attached under a name the formats do not define: not factur-x.xml, zugferd-invoice.xml or xrechnung.xml. |
| attachment_ambiguous | AW-PDF-ATTACHMENT | warning | Several XML attachments, and none, or more than one, under a standard name: nothing says which is the invoice. |
| attachment_extra | AW-PDF-ATTACHMENT | information | Several XML attachments, exactly one under a standard name; that one was read. |
| attachment_encoding | AW-PDF-ATTACHMENT | warning | The XML declares an encoding other than UTF-8 and holds only ASCII, so it reads the same today. |
| attachment_not_cii | AW-PDF-ATTACHMENT | warning | The attachment is not a CII CrossIndustryInvoice (a ZUGFeRD 1.0 document, UBL, or no invoice). |
| af_streams_differ | AW-PDF-AF | warning | The EmbeddedFiles name tree and /AF list the XML under one name and point at different files. |
| af_missing | AW-PDF-AF | warning | The XML is in the name tree and not in the catalog's /AF array. |
| af_name_tree_missing | AW-PDF-AF | warning | The XML is in /AF and not in the name tree. |
| relationship_missing | AW-PDF-RELATIONSHIP | warning | No /AFRelationship. |
| relationship_unexpected | AW-PDF-RELATIONSHIP | warning | An /AFRelationship other than Data, Source or Alternative. |
| relationship_not_alternative | AW-PDF-RELATIONSHIP | information | Data or Source, and BT-24 declares BASIC, EN 16931, EXTENDED or XRECHNUNG: Factur-X and France accept it, and for invoices in Germany the ZUGFeRD specification requires Alternative. |
| mime_not_xml | AW-PDF-MIME | warning | A /Subtype that is not an XML media type. |
| mime_missing | AW-PDF-MIME | warning | No /Subtype. |
| xmp_missing | AW-PDF-XMP | warning | No XMP metadata. |
| xmp_unreadable | AW-PDF-XMP | warning | Metadata that cannot be read: not a stream, a filter this reader lacks, a limit, not text, not well-formed, not XMP. |
| xmp_pdfa_missing | AW-PDF-XMP | warning | No pdfaid:part: the file does not claim to be PDF/A. |
| xmp_pdfa_part | AW-PDF-XMP | warning | A PDF/A part other than 3. |
| xmp_pdfa_conformance | AW-PDF-XMP | warning | PDF/A-3 with no conformance level, or one other than A, B or U. |
| xmp_facturx_missing | AW-PDF-XMP | warning | None of the Factur-X properties DocumentType, DocumentFileName, Version and ConformanceLevel. |
| xmp_facturx_incomplete | AW-PDF-XMP | warning | Some of them, not all four. |
| xmp_facturx_namespace | AW-PDF-XMP | warning | They are in a namespace none of the formats defines, where a reader that matches by namespace does not see them. |
| xmp_document_type | AW-PDF-XMP | warning | A DocumentType other than INVOICE. |
| xmp_file_name | AW-PDF-XMP | warning | A DocumentFileName that is not the attachment's name. |
| xmp_level_unknown | AW-PDF-XMP-PROFILE | warning | A ConformanceLevel the metadata's schema does not define, with the spelling it probably meant. |
| xmp_level_mismatch | AW-PDF-XMP-PROFILE | warning | A ConformanceLevel other than the profile BT-24 declares; its field is BT-24. |
None is fatal: each describes a file whose invoice was read, so valid never
moves for one, and whether another reader finds the invoice depends on how it
looks. A warning is a departure from what Factur-X, ZUGFeRD or PDF/A-3 asks of
the file, which some reader rejects or misreads (Mustang, the open-source
ZUGFeRD validator, reports most of the metadata ones as errors); information is
allowed, and asked against only in some places. They carry the fields of every
AW- finding (rule, field, severity, message, fix) and no
location: a PDF key has no line, so the message names it, and the attachment
by its name. On a PDF whose XML cannot be read, they come with the fatal
finding, and one of them may be why.
The metadata is read from the catalog's /Metadata stream, stored or
FlateDecoded, with this package's own XML reader: a property written as an
element or as an attribute of its rdf:Description, inside x:xmpmeta or
not, in the namespace Factur-X defines (which ZUGFeRD uses from 2.1 on) or in
ZUGFeRD 2.0's or 1.0's. Metadata that cannot be read is a finding, never an
exception.
relationship_not_alternative and xmp_level_mismatch need the XML's BT-24.
validate() adds them from the BT-24 it reads, so the XML is parsed once; if
you extract and parse the XML yourself, facturXProfileFindings(extraction,
customizationId) returns them, and facturXLevel(customizationId) names the
profile a BT-24 declares, as the metadata spells it.
These are container checks, not a PDF/A verdict. They read the claims a file makes about itself (the PDF/A part and level it declares, the Factur-X properties, where and how the XML is attached) and check them against the formats and against the XML. Whether the file is the PDF/A-3 it claims to be, with its fonts embedded, its colour profile and the extension schema PDF/A requires for the Factur-X properties, is a question for a PDF/A validator such as veraPDF, and nothing here answers it.
Writing it is still not implemented, and is not planned here. generateCii
emits the XML: it does not build the container, does not attach the XML under
the required name (factur-x.xml, or xrechnung.xml for the XRECHNUNG
reference profile), and does not set the /AFRelationship value Germany
requires (Alternative). A file produced by this package is a CII XML
document, not a Factur-X or ZUGFeRD document. The asymmetry is deliberate:
extraction either returns the attachment or throws, while a half-conformant
PDF/A-3 writer would emit files that look like Factur-X and are not.
Credit notes
A credit note is one field:
const creditNote = generateXRechnungUBL({
...invoice, // the invoice you are crediting
invoiceNumber: "2026-G00021", // its own number, from your own sequence
invoiceTypeCode: "381", // ← this is the whole API
precedingInvoices: [{ invoiceNumber: "2026-000142", issueDate: "2026-08-09" }],
});
// → <ubl:CreditNote xmlns:ubl="…:xsd:CreditNote-2"> … </ubl:CreditNote>There is no generateCreditNote and no documentType flag, because EN 16931
does not have one: BT-3 is the discriminant, and a second field would let an
input contradict itself. isCreditNote(input) exposes the same decision if you
need to branch on it yourself.
State the amounts positively. The document type conveys the direction of the
money. A credit note carrying negative amounts reverses it back: that is a
"negative invoice", a different (and equally lawful) idiom, and mixing the two
gets you a document that says the opposite of what you meant. Both schematrons
accept either, so no validator will catch it; ATW-CREDIT-NOTE-NEGATIVE-AMOUNTS
is a warning here for exactly that reason.
What changes in the emitted UBL, and nothing else does:
| | ubl:Invoice | ubl:CreditNote |
| --- | --- | --- |
| Root / namespace | Invoice, …:xsd:Invoice-2 | CreditNote, …:xsd:CreditNote-2 |
| BT-3 | cbc:InvoiceTypeCode | cbc:CreditNoteTypeCode |
| Lines | cac:InvoiceLine | cac:CreditNoteLine |
| BT-129 quantity | cbc:InvoicedQuantity | cbc:CreditedQuantity |
| BT-9 due date | cbc:DueDate | cac:PaymentMeans/cbc:PaymentDueDate — the document has no cbc:DueDate, and UBL-CR-412 exempts credit notes from the rule forbidding it here |
| BT-7 tax point | after cbc:Note | before cbc:CreditNoteTypeCode |
| BT-11 project | cac:ProjectReference | no element exists — dropped, and reported as ATW-CREDIT-NOTE-PROJECT-REFERENCE-UNBOUND |
| BT-25 preceding invoice | cac:BillingReference/cac:InvoiceDocumentReference | the same element — EN 16931 binds BG-3 to the invoice reference on both documents |
In CII none of that applies: there is one root element for both document
types, so a credit note is ram:TypeCode 381 and no other difference at all.
The rule set does not change either. EN 16931 has one semantic model and binds the same rule ids to both documents, so BR-CO-10 counts the same amounts and BR-DE-16 asks the same question. Two rules are worth knowing about:
- BR-DE-17 admits
381: XRechnung's eight codes are one list tested against both type-code elements.261(self-billed credit note) is a lawful EN 16931 code and is not one of the eight, so it draws a warning there. - BR-DE-26 does not require a preceding invoice reference on a credit note.
It is widely believed to; the rule's own test names
384(corrected invoice) and nothing else, on either document type, and KoSIT accepts a credit note with no BG-3 at all. Supplying one is still the ordinary case (the buyer cannot net two documents that do not reference each other), so this build says so atinformationlevel, the flag the regulator itself reserves for advice, underATW-CREDIT-NOTE-NO-PRECEDING-INVOICE.
Not covered: self-billing as a workflow, and the UBL SelfBilledInvoice /
SelfBilledCreditNote root elements. BT-3 389 and 261 generate and parse on
the ordinary root elements, which is what EN 16931's UBL binding uses; if a
platform demands one of those other roots, this package will not produce it.
Debit notes (ubl:DebitNote) are not supported either: EN 16931 has no binding
for them.
CII: XRechnung CII and the Factur-X payload
import { generateCii, parseCiiInvoice } from "@attestwire/en16931";
const xml = generateCii({ ...invoice, profile: "xrechnung-cii" });
const { invoice: readBack, unmapped } = parseCiiInvoice(xml);generateCii accepts xrechnung-cii, facturx-en16931, en16931 and, since
0.7.0, peppol-bis-3. The core profile is syntax-neutral, so you pick the
syntax by picking the function. xrechnung-ubl is the one profile name that is
genuinely UBL-bound, and it throws; xrechnung-cii is the name for the same
rules in this syntax.
Earlier releases refused peppol-bis-3 here, on the stated grounds that Peppol
BIS Billing 3.0 has no CII binding. That was wrong. OpenPEPPOL ships
PEPPOL-EN16931-CII.sch and a peppolbis-en16931-01-3.0-cii build
configuration, and the BIS describes CII D16B (the version this generator
emits) as optional, not absent: UBL is mandatory for every receiver, and CII
is accepted by receivers who register for it in the SMP. So sending
Peppol CII is a thing you must agree with your counterparty, not a thing this
library should have been deciding for you. One behaviour change comes with it:
under peppol-bis-3 the CII generator omits BT-21 (ram:SubjectCode), which
PEPPOL-EN16931-R002 forbids outright, and validateInput now raises R002 as
a warning so you learn the rule instead of silently losing the field.
CII is not UBL with different names, and four differences are where a UBL habit produces a rejected file:
| | UBL | CII |
| --- | --- | --- |
| Dates | <cbc:IssueDate>2026-08-09</cbc:IssueDate> | <ram:IssueDateTime><udt:DateTimeString format="102">20260809</udt:DateTimeString></ram:IssueDateTime> |
| Currency | on every amount, as @currencyID | once, in ram:InvoiceCurrencyCode; only BT-110 and BT-111 carry @currencyID |
| BT-21 note subject | no element — encoded into the note as #CODE#text | a real element, ram:SubjectCode |
| BT-90 SEPA creditor id | on the seller party, schemeID="SEPA" | on the settlement, ram:CreditorReferenceID |
Element order is part of schema validity in both syntaxes, and the two do not
agree on it. ram:PostalTradeAddress puts the post code before the street.
ram:SpecifiedTradeSettlementHeaderMonetarySummation puts charges before
allowances and the rounding amount before the grand total. A
ram:SpecifiedTradeAllowanceCharge puts the percentage and the base amount
before the amount, and the reason code before the reason text. Every
builder in generate-cii.ts quotes the XSD sequence it follows.
parseCiiInvoice is the inverse, and the round trip is tested: for each
committed CII fixture, generateCii(parseCiiInvoice(xml).invoice) returns the
identical document, and the result validates identically. It shares the hardened
XML reader with parseUbl, including every one of its security limits, and
resolves everything by namespace URI, not by prefix.
One thing it cannot tell you: Factur-X's EN 16931 profile and plain core
EN 16931 state the same BT-24 (urn:cen.eu:en16931:2017), so a
facturx-en16931 document reads back with profile: "en16931". Nothing is
lost: the rule set is identical and regenerating produces the same bytes. But if
you need the distinction, keep it yourself.
API
| Export | Purpose |
| --- | --- |
| validate(document, options?) | An existing file — UBL or CII XML as a string or bytes, or a Factur-X / ZUGFeRD PDF as bytes — → { valid, syntax, profile, container, errors, warnings, information, invoice, unmapped, customizationId, profileId, error? }. The same rules as validateInput, and each finding carries a location (line, column, path, exact) in the caller's file and an xpath in its own syntax. A PDF adds the container's findings (AW-PDF-*, never fatal, no location). Options: profile, limits, pdfLimits. New in 0.10.0. |
| validateInput(inv) | Run all input rules. Returns { valid, profile, errors, warnings, information }. Reports every finding, not the first. Accepts InvoiceFacts too, and judges the explicit invoice applyDefaults makes of it. |
| generateXRechnungUBL(inv, options?) | JSON → UBL 2.1 Invoice XML string — or CreditNote, when invoiceTypeCode is a credit-note code. Accepts InvoiceFacts too. |
| generateCii(inv, options?) | JSON → UN/CEFACT CII (D16B) CrossIndustryInvoice XML string, for xrechnung-cii, facturx-en16931, en16931 and peppol-bis-3. XML only — this function never writes a PDF. Accepts InvoiceFacts too. |
| applyDefaults(facts) | InvoiceFacts → { invoice, notes }: the explicit InvoiceInput, with every vatScenario turned into its codes and a missing payment means code inferred from the account, and one information note per thing filled in. What validateInput and the generators apply first. New in 0.14.0; see Say what happened, not the code. |
| applyVatScenarios(facts) / VAT_SCENARIOS | The VAT half of applyDefaults on its own, and the five scenario names. |
| createCreditNote(original, options) | An invoice → its credit note: BT-3 381, the reference to the original (BT-25, BT-26), the same parties, currency and payment details, and the lines you choose with their positive amounts. Options: invoiceNumber, issueDate, lines, reason. New in 0.14.0. |
| extractFacturX(bytes, limits?) | Factur-X / ZUGFeRD PDF → { xml, attachmentName, warnings, findings, relationship?, xmp }. Reads the embedded-file name tree and the /AF array, classic and stream cross-references, object streams and the XMP metadata. findings are the container's findings that the PDF alone decides, each with a stable id; xmp is what the metadata states. Extraction only: the container is read, never built. |
| facturXProfileFindings(extraction, customizationId) / facturXLevel(customizationId) | The container findings that need the XML's BT-24 (the metadata's profile against BT-24's; Data or Source where Germany asks Alternative), for a caller who parses the XML itself; validate adds them already. facturXLevel names the profile a BT-24 declares as the XMP metadata spells it (EN 16931, BASIC WL), or undefined. |
| toSarif(findings, provenance) / toJunitXml(findings, provenance, options?) | Findings → a SARIF 2.1.0 log object, or a JUnit XML string, for CI. Pure: no clock, no filesystem. |
| parseCiiInvoice(xml, options?) | CII XML → { invoice, unmapped, customizationId, profileId }, the same shape parseUbl returns. Reads invoices and credit notes alike — in CII they are one document type. |
| CII_GENERATABLE_PROFILES / type CiiGeneratableProfile | The profiles generateCii accepts, and the union type of them. |
| CII_NAMESPACES | The four namespace URIs (rsm, ram, qdt, udt) a CII invoice uses — for resolving by URI when you walk a document yourself. |
| toCiiDate(iso) / fromCiiDate(value) | ISO 8601 ↔ the CII format="102" form (YYYYMMDD). A value that is not a calendar date passes through untouched rather than being rewritten. |
| SUPPORTING_DOCUMENT_TYPE_CODE / TENDER_OR_LOT_DOCUMENT_TYPE_CODE | "916" and "50". In CII one element, ram:AdditionalReferencedDocument, carries BG-24, BT-17 and BT-18, told apart only by these codes and by INVOICED_OBJECT_DOCUMENT_TYPE_CODE ("130"). |
| parseUbl(xml, options?) | UBL 2.1 Invoice or CreditNote XML → { invoice, unmapped, customizationId, profileId }. Feed invoice to validateInput. See Reading an existing UBL invoice. |
| parseUblInvoice(xml, options?) | The same function under its pre-0.5.0 name. Kept forever; parseUbl is the name to use in new code, since it reads both document types. |
| ParseError and subclasses | What parsing throws instead of returning a half-read invoice: UnsupportedSyntaxError, UnsupportedCreditNoteError, XmlSecurityError, XmlSyntaxError. Each carries a stable code. |
| DEFAULT_XML_LIMITS / type XmlLimits | The size, depth and element caps applied to every
