A sales invoice or credit note as the Peppol network carries it — UBL 2.1 in the profile Peppol BIS Billing 3.0 of the European standard EN 16931 — in TypeScript, with no dependencies.
Give it a posted document as the books hold it and it gives you the file, a name for it, and every published rule the file breaks — named by the identifier the rule is published under.
import { generatePeppolUbl } from '@ekwo-ai/peppol-ubl';
const { file, filename, violations } = generatePeppolUbl({ header, lines, taxes });
header, lines and taxes are one row of document_header, the rows of
document_line_items and the rows of document_tax_summary in
Ekwo OS: a document, its lines, and its
VAT rounded once per tax. They are declared in this package's own types, so
any invoicing system that can produce the same three things can use it. It
reads no database and knows no accounting.
Writing the file, and reading one received, is all it does. Sending it takes a certified access point, a certificate and somebody on call, and this package has none of the three: you get the same UBL anybody gets, and you send it through the access point you chose.
The figures are the books' figures
Nothing is recomputed. A total, a base and a tax are written as they were posted, and where they do not add up the rule they break is named rather than the figure corrected. An invoice is an accounting document before it is an XML one: a file that disagrees with the ledger it came from by a cent is worse than a file that is refused, because the first is found by whoever files the VAT return.
For the same reason no figure passes through a number. Amounts arrive as the
text Postgres prints a numeric as, are compared as exact decimals on
bigint, and leave as the same digits. The rules of EN 16931 compare with =
and not with a tolerance; a sum a binary float gets wrong in its last bit is a
rule reported broken on an invoice that is right.
Two things are derived, because the standard has a place for them and the books hold them in another shape:
- The VAT breakdown. The books keep one row per tax; the standard wants one group per category and rate (BG-23). Two taxes at one rate — goods and services at the standard rate — are one group: their bases added, their taxes added, nothing rounded again.
- The net price of a discounted line. The books hold the price as keyed and
a discount in percent; the standard wants the net price (BT-146), and has room
for the gross one and the discount beside it (BT-148, BT-147). All three are
written, exact to the last digit —
27.7778less 10 % is25.00002— so that the quantity times the net price is still the line the books rounded.
A line's category and rate are read from the line and from nowhere else. In Ekwo OS a line keeps the category and the rate it was posted with, so that a rate that changes later restates no invoice already sent; a line that says neither has neither, and BR-CO-04 says so.
Nothing is made up
There is no default currency, country, language, unit, payment means or identifier scheme. An absent value is an absent element — never an empty one, which is a rule broken in its own right (PEPPOL-EN16931-R008) — and the rule that wanted it is reported. In particular:
- The electronic addresses (BT-34, BT-49) are read from the header —
seller_peppol_schemeandseller_peppol_identifier, and the same two for the buyer — or given assellerEndpointandbuyerEndpoint, which win. They are never derived: which identifier a participant is registered under is a fact of its registration, not of its VAT number. - The scheme of a registration number (BT-30-1, BT-47-1) is given or left
out. It is not what Ekwo OS calls
party_scheme: that is the scheme a party is addressed by, and in more than one country it is not a scheme a registration number can be written in at all. - An IBAN without a payment means code is not written. The format files the
account under the code (UNTDID 4461) and there is no code that means "probably
a transfer". It comes back as
payment-means-missing. - A price keyed with its tax in it has no net price here. That one is the
line's base divided by its quantity — a rounding decision, and so the books'
to make: pass it as
net_unit_price, or read BR-26. - A
Dateobject is refused. A driver hands adatecolumn back as an instant, at a midnight that is UTC for one driver and local for the next. Select it::text.
What is refused, and what comes back as a violation
What would make the file absurd throws a PeppolUblError: a document that is
not a sales invoice or a sales credit note, that has no number, no date, no
currency or no line, an amount or a date that is not one. These are also what
the UBL schema requires, so every file that comes back is a valid UBL 2.1
document — the wrong ones too.
Everything else comes back in violations, beside a file that says what it was
given:
{ code: 'BR-CO-15', message: '1450.00 without VAT and 267.00 of VAT are not the 1717.01 the document totals.' }
{ code: 'PEPPOL-EN16931-R120', message: '…', line: '10' }
code is the identifier of the published rule — what a validator on the
network will say of the same file, and the only name worth branching on.
message is this package's sentence, not the rule's text. Two codes are
lower-case because they are this package's own — things it could not write, and
which no validator reports because it never sees them: payment-means-missing,
and exemption-reason-ambiguous for a VAT group whose lines carry several
exemption codes where the group has room for one.
An empty violations is the only state to send a file in.
The rules re-read here, 112 of them:
| the parties | BR-06 to BR-11, BR-57, BR-62, BR-63, BR-CO-09, BR-CO-26, PEPPOL-EN16931-R010, R020 |
| the reference Peppol requires | PEPPOL-EN16931-R003 |
| payment | BR-61, BR-CO-25 |
| the lines | BR-22, BR-23, BR-25 to BR-28, BR-CO-04, UBL-SR-48, PEPPOL-EN16931-R120 |
| the VAT breakdown | BR-47, BR-48, BR-CO-17, BR-CO-18, PEPPOL-EN16931-R053, R054 |
| the categories S, Z, E, AE, K, G and O | BR-*-01, -02, -05, -08, -09, -10 for each, BR-IC-11, BR-IC-12, BR-O-11, BR-O-12, PEPPOL-EN16931-P0104 to P0111 |
| the totals | BR-CO-10, BR-CO-14, BR-CO-15, BR-CO-16 |
| decimals | BR-DEC-09, -12, -14, -16, -18, -19, -20, -23, UBL-DT-01 |
Outside the scope of VAT, nobody is named by a VAT number. A document
whose every line is in category O is written without the VAT identifiers of
its parties (BT-31, BT-48), whatever the header hands over: BR-O-02 refuses
them, and the books of a seller that charges no VAT on a sale still hold its
number. The seller is named by its registration number (BT-30), which
BR-CO-26 takes in its place. A document that mixes the category with another
keeps what it was given and is reported whole: it is two invoices.
isOutsideScope() answers the question for a writer of another syntax.
| check digits | PEPPOL-COMMON-R040 (GLN), R043 (Belgian enterprise number) |
| code lists | BR-CL-03, -04, -11, -14, -16, -17, -18, -22, -23, -25, PEPPOL-EN16931-CL007, CL008 |
What is verified, and what is not
Against the UBL 2.1 schema: verified, in the test suite. Every file the
tests produce — 100 cases, invoices and credit notes, right and wrong — is
validated against the schemas OASIS publishes, unmodified under test/xsd/
(xmllint-wasm, a development dependency: no network, no system tool). The
test also mutates a file three ways and watches validation fail, so a validator
that resolved no import would not pass for one that checks.
The code lists: generated, and compared code for code. src/codelists.ts
is written by scripts/build-codelists.mjs from the BR-CL-* assertions of the
EN 16931 Schematron as Peppol BIS Billing 3.0 ships it (test/codelist/), and
the tests parse that file again, with other code, and compare every list with
it in order.
Against the Schematron of EN 16931 and of Peppol: played, but not by the test suite. Both rule sets are Schematron with an XSLT 2.0 query binding. Nothing in a JavaScript runtime executes XSLT 2.0 except SaxonJS, which this repository does not depend on for a test and whose licence is not an open-source one. So:
- the Schematron was played by hand, from outside the repository, on
6 November 2026: release 3.0.20 of Peppol BIS Billing (which ships the
EN 16931 rules at version 1.3.15), compiled with SchXslt 1.10.1, run with
SaxonJS.
scripts/play-schematron.mjsis the whole procedure and checks the SHA-256 of everything it downloads; - it was played against the 100 files under
test/fixtures/, and what it said is committed astest/fixtures/verdicts.json; - the test suite holds the package to that record on every run: the generator must still write those files byte for byte, and for every one of them the rules this package reports must be exactly the fatal rules the Schematron reported — no more, no fewer. The 17 files meant to be right are right for both, without a warning;
- every one of the 112 rules named above was reported by the published
Schematron on at least one of those files, and the Schematron reported
nothing on them that this package does not name. A rule added to
src/rules.tsand never played fails the tests.
What that proves: on those 100 documents, this package and the network agree.
What it does not: that they agree on a document that is not one of them. The
re-reading is code written from the rules, not the rules themselves. Before
relying on an empty violations for a kind of invoice these cases do not
cover, validate one through your access point — most offer a test endpoint —
or play the script.
Not re-read at all, so never reported here, and a validator may:
- the national rules Peppol adds for a seller in Denmark, Germany, Greece,
Iceland, Italy, the Netherlands, Norway and Sweden (
DK-R-*,DE-R-*,GR-R-*,IS-R-*,IT-R-*,NL-R-*,NO-R-*,SE-R-*); - the check digits of identifier schemes other than
0088and0208(PEPPOL-COMMON-R041, R042, R044 to R050); - every rule about something this package does not write — see below;
- the warnings. Only fatal rules are named.
What it does not write
Document-level allowances and charges (BG-20, BG-21), line-level ones (BG-27,
BG-28), the invoicing period (BG-14, BG-26), a VAT accounting currency (BT-6,
BT-111), a payee other than the seller (BG-10), a tax representative (BG-11),
attachments (BG-24), item attributes and classifications, card and direct-debit
payments, prepayment and self-billed invoices — the type is 380 or 381.
Ekwo OS has no column for any of them today. A document that needs one is a
document this package cannot write yet, and it does not pretend to.
Neighbouring profiles — XRechnung, a PINT, Factur-X in UBL — are other formats and would be other packages. Reading an incoming invoice is another job altogether.
What the source refuted
The first draft was written from the specification. Playing the Schematron against it found what reading had not:
0999999999is not a Belgian enterprise number. The number every test in the surrounding repository uses fails the check digits Peppol verifies (PEPPOL-COMMON-R043). The fixtures use0999999922, in a range no number was issued in.- The two rule sets do not share a list of electronic address schemes.
EN 16931 lists seven that Peppol refuses — e-mail and telephone among them. A
scheme can break one rule and not the other (
SCHEMES_NOT_ON_PEPPOL). - BR-DEC-13 never fires. The rule that the total VAT has two decimals looks for the currency code underneath the amount, where it is not. UBL-DT-01 catches the same digits and is what is reported.
- BR-CO-16 rounds in one branch and not the other, which shows on an amount with a third decimal; an absent price fails the rule against a negative one (BR-27); a group without a rate fails the rule on its tax (BR-S-09); a total VAT with no breakdown under it breaks two Peppol rules and not one (R053, R054); and the UBL binding has rules of its own beside the standard's, so three decimals are UBL-DT-01 as well as BR-DEC-*, and a line without a category UBL-SR-48 as well as BR-CO-04.
- A line of the books did not say its VAT category. The end-to-end test
found
document_line_items.vat_categoryempty on every line of every pack, and the first version joined a silent line to its tax bytax_id. The books now write the line when it is posted, and the join is gone: it read the tax of today into an invoice of last year. - A reason is a code or a text. The same test reported BR-E-10, BR-G-10
and BR-AE-10 on a pack that codes no exemption, and called the code missing.
The rules accept a sentence, the pack had one, and the breakdown did not
carry it. It does now, as
exemption_reason. party_schemeis not the scheme of a registration number. The same test found it to be an address scheme, which for two of the four countries that declare this profile is not an ISO 6523 identifier scheme at all (BR-CL-11).
Reading
The other half of e-invoicing: the invoice a supplier sends you, turned into a plain object a purchase draft can be made from.
import { readUbl, InvoiceFileError } from '@ekwo-ai/peppol-ubl';
const { invoice, violations } = readUbl(bytesOrText);
invoice.kind; // 'invoice' | 'credit_note'
invoice.seller.vatId; // 'BE0999999031'
invoice.totals.payable; // '648.40', a decimal as text, never a number
invoice.attachments[0]; // { filename: 'RCV-2026-0107.pdf', mimeType: 'application/pdf', content: Uint8Array, … }
readUbl(input: string | Uint8Array, options?: ReadOptions): ReceivedInvoiceFile
reads a UBL 2.1 Invoice or CreditNote — bare, or inside the
StandardBusinessDocument envelope an access point hands it over in — and
returns a ReceivedInvoice: the seller and the buyer (legal and trading
names, VAT and legal identifiers, electronic addresses with their schemes,
postal addresses, contacts), number, type code, dates, currency, references,
notes, lines (quantity and unit, net amount, prices, VAT category and rate,
item identifiers), document allowances and charges, the VAT breakdown, the
totals, the payment means (an IBAN, told from another account by its check
digits) and the documents attached — an embedded PDF comes back as bytes, with
its MIME type and its file name. Every field names the business term of
EN 16931 it holds.
The shape is shared. @ekwo-ai/factur-x returns the same
ReceivedInvoice from a CII invoice (readCii, readFacturX), so whoever
turns a received invoice into a purchase does it once for both syntaxes. The
two bricks hold byte-for-byte copies of the files that define it, and a test
keeps them identical.
Nothing is recomputed, nothing is defaulted. A figure is the text the file
wrote, checked to be a decimal, compared on exact decimals; an absent element
is null. The arithmetic of EN 16931 (BR-CO-10 to BR-CO-17) is checked and
what does not add up comes back in violations, next to the figures as the
file wrote them. A credit note's figures are positive, as written: the sign is
kind.
A file that is not an invoice throws an InvoiceFileError, with a code:
malformed_xml, unsupported_encoding (UTF-8 only), doctype_forbidden,
undefined_entity, too_large, too_deep, too_many_elements,
not_an_invoice (another root; a CII file is pointed at the CII reader),
missing_element (no number, date, currency, totals or line), invalid_value
(a date that is not one, an amount that is not a decimal). No raw parser error
escapes. The XML is read by a strict reader of the package's own — no DOCTYPE
and so no entity expansion, the five predefined entities only, a size limit
before reading (maxBytes, 64 MiB by default), a depth and an element limit
while reading — because a received file is hostile until read.
What it does not do: validate against the schemas or the Schematron (whether the file is valid on the network was the sending access point's question), check signatures, or read UBL extensions.
Participant identifiers and the SML
import { validateParticipantId, smlHostname, SML_ZONES } from '@ekwo-ai/peppol-ubl';
validateParticipantId('BE:EN:0999999031');
// { valid: true, scheme: '0208', value: '0999999031', identifier: '0208:0999999031', canonical: '0208:0999999031', problems: [] }
smlHostname('0208:0999999031', SML_ZONES.production, 'naptr');
// '<base32 of SHA-256>.iso6523-actorid-upis.edelivery.tech.ec.europa.eu'
validateParticipantId(input: string): ParticipantIdCheck takes
scheme:value, with or without iso6523-actorid-upis:: in front, and the
symbolic scheme names of the Peppol code list (BE:EN, GLN, NO:ORG…),
returned as the numeric code. It checks that the scheme is an electronic
address scheme (EAS) Peppol delivers to, and the check digits of the schemes
whose rule is published by their issuer: GLN (0088), Belgian enterprise number
(0208), SIREN (0002), Swedish (0007) and Norwegian (0192) organisation
numbers, Australian Business Number (0151) and LEI (0199) — CHECKED_SCHEMES.
Other schemes are checked for nothing but being non-empty: a rule written from
a guess would refuse somebody's real address. The SIRET (0009) is one of them,
for the published exception to its Luhn rule this package could not source
exactly.
smlHostname(participant, zone, lookup) computes the DNS name under which the
SML publishes a participant: B- and the MD5 of the lower-cased identifier for
the cname lookup, the unpadded base32 of its SHA-256 for the naptr lookup
of BDXL. It is pure — the hashes are written out in the package, there is no
default zone, and nothing is resolved: the lookup belongs to a transport.
The lookup itself is in @ekwo-ai/core, lookupPeppolParticipant(), which
asks the DNS for that name and reads the SMP off its NAPTR record; from a
terminal, ekwo peppol lookup <participant>, and ekwo peppol validate <file>
for a file received.
Sources
- OASIS, Universal Business Language 2.1, schemas of 4 November 2013: https://docs.oasis-open.org/ubl/os-UBL-2.1/.
- OpenPeppol, Peppol BIS Billing 3.0: https://docs.peppol.eu/poacc/billing/3.0/
— the syntax bindings of the invoice and the credit note, the rules, the code
lists. Rules as released: https://github.com/OpenPEPPOL/peppol-bis-invoice-3,
tag
v3.0.20. - CEN/TC 434, the validation artefacts of EN 16931: https://github.com/ConnectingEurope/eInvoicing-EN16931.
- OpenPeppol, Policy for use of Identifiers, version 4, and the code list Participant identifier schemes: https://docs.peppol.eu/edelivery/ — the identifier scheme, case-insensitive comparison, symbolic scheme names.
- OpenPeppol, Service Metadata Locator (SML) specification, and OASIS Business Document Metadata Service Location (BDXL) 1.0 — the DNS names of a participant. RFC 1321 (MD5), FIPS 180-4 (SHA-256), RFC 4648 (base32).
- The check digit rules, each cited where it is implemented in
src/participant.ts: GS1 General Specifications (GLN), the Crossroads Bank for Enterprises (Belgian enterprise number), INSEE (SIREN), Skatteverket and the Brønnøysund Register Centre (organisation numbers), the Australian Business Register (ABN), ISO 17442 (LEI), ISO 13616 (IBAN).
Licence
MIT. The files under test/ that come from OASIS and from the European
Commission keep their own notices and licences, are fixtures of the tests, and
are not part of the published package.