Pure-Elixir toolkit for Factur-X / ZUGFeRD hybrid electronic invoices (the Franco-German EN 16931 standard: a PDF/A-3 file with a machine-readable CII XML payload embedded inside it).
The goal is to remove the need to shell out to the Python
akretion/factur-x library from Elixir
projects, and to fill the gap on Hex.pm.
Status: v1 core complete.
generate,extract,build/parseand the optionalvalidateall work and are proven end-to-end (veraPDF-valid output, parity with the Python reference, XSD-valid CII, Schematron via Saxon). See the scope ADR indocs/adr/0001-perimetre-et-architecture.md.
| Capability | Module | External dependency |
|---|---|---|
| Build CII XML from a struct | Facturx.CII |
none (pure Elixir) |
| Parse CII XML into a struct | Facturx.CII |
none (pure Elixir) |
| Extract the embedded XML from a PDF | Facturx.Extract |
none (pure Elixir) |
| Embed XML into an existing PDF/A-3 | Facturx.Embed |
none (pure Elixir) |
| Validate against EN 16931 XSD | Facturx.XSD |
none (pure Elixir, OTP :xmerl_xsd) |
| Validate against EN 16931 Schematron | Facturx.Validate |
optional — :req + a Saxon HTTP endpoint |
The EN 16931 Schematron ships compiled in priv/schematron/. validate/2 posts
the XML + XSLT to a Saxon server and reads back the SVRL report. The XSLT resolves
a code-list DB via document(...), which Saxon only permits with --insecure;
docker/ provides an image that enables it and embeds the DB, so validation runs
offline. It is exercised in CI against invoices the library builds.
Deliberately out of v1 scope (delegate to external tools, exactly as the Python library does):
- Normalising an arbitrary PDF into PDF/A-3 — the caller supplies a valid PDF/A-3; convert upstream with Ghostscript if needed.
- Running Schematron locally — the EN 16931 rules are XSLT 2.0, which the
BEAM cannot execute.
Facturx.ValidatePOSTs to a Saxon server (public by default, self-hosted recommended in production for privacy). It is opt-in and disabled by default.
def deps do
[
{:facturx, "~> 0.5"},
# only if you use Facturx.validate/2:
{:req, "~> 0.5"}
]
endDocs: hexdocs.pm/facturx.
The caller supplies the visual PDF as a valid PDF/A-2 or PDF/A-3 (e.g. Typst
compiled with --pdf-standard a-2b). generate/3 builds the CII XML, embeds it,
and promotes the container to PDF/A-3.
invoice = %Facturx.Invoice{
number: "INV-2026-001",
issue_date: ~D[2026-07-24],
currency: "EUR",
seller: %{name: "ACME SARL", vat: "FR12345678900",
address: %{line_one: "1 rue de Rivoli", postcode: "75001", city: "Paris", country: "FR"}},
buyer: %{name: "Client SAS", vat: "FR98765432100",
address: %{line_one: "2 place Bellecour", postcode: "69001", city: "Lyon", country: "FR"}},
lines: [%{id: "1", name: "Service", net_price: Decimal.new("100.00"),
quantity: Decimal.new("2"), unit: "C62",
vat_category: "S", vat_rate: Decimal.new("20.00"), line_total: Decimal.new("200.00")}],
tax_breakdown: [%{type: "VAT", category: "S", rate: Decimal.new("20.00"),
basis: Decimal.new("200.00"), calculated: Decimal.new("40.00")}],
totals: %{line_total: Decimal.new("200.00"), tax_basis_total: Decimal.new("200.00"),
tax_total: Decimal.new("40.00"), grand_total: Decimal.new("240.00"),
due_payable: Decimal.new("240.00")}
}
{:ok, facturx_pdf} = Facturx.generate(pdf_a2b_binary, invoice, profile: :en16931)
# You can also pass ready-made CII XML instead of a struct:
# {:ok, facturx_pdf} = Facturx.generate(pdf_a2b_binary, cii_xml){:ok, %{xml: xml, profile: :en16931, filename: "factur-x.xml"}} = Facturx.extract(pdf_binary)
{:ok, %Facturx.Invoice{} = invoice} = Facturx.parse(xml){:ok, xml} = Facturx.build(invoice, profile: :en16931)
{:ok, invoice} = Facturx.parse(xml)%Facturx.Invoice{
# BG-1 — CII puts the content before the subject code (BT-22 then BT-21)
notes: [%{content: "Escompte 2% sous 8 jours", subject_code: "AAB"}],
# BG-14 — either date may stand alone
billing_period: %{start_date: ~D[2026-07-01], end_date: ~D[2026-07-31]},
lines: [
%{
net_price: Decimal.new("90.00"),
gross_price: Decimal.new("100.00"), # BT-148, price before discount
price_discount: Decimal.new("10.00"), # BT-147
# ...
}
],
tax_breakdown: [
%{
category: "E",
exemption_reason: "Exonération art. 262 ter I", # BT-120
exemption_reason_code: "VATEX-EU-IC", # BT-121
# ...
}
]
}Document level (BG-20 / BG-21) and line level (BG-27 / BG-28). All four are the
same CII element told apart by ChargeIndicator; which list you use decides it:
%Facturx.Invoice{
allowances: [
%{amount: Decimal.new("20.00"), reason: "Remise commerciale", reason_code: "95",
basis_amount: Decimal.new("200.00"), percent: Decimal.new("10.00"),
vat_category: "S", vat_rate: Decimal.new("20.00")}
],
charges: [%{amount: Decimal.new("5.00"), reason: "Frais de port",
vat_category: "S", vat_rate: Decimal.new("20.00")}],
totals: %{
line_total: Decimal.new("200.00"),
allowance_total: Decimal.new("20.00"), # BT-107
charge_total: Decimal.new("5.00"), # BT-108
tax_basis_total: Decimal.new("185.00"), # 200 − 20 + 5
tax_total: Decimal.new("37.00"),
grand_total: Decimal.new("222.00"),
prepaid: Decimal.new("50.00"), # BT-113 — down payments already paid
due_payable: Decimal.new("172.00")
}
}Lines take the same :allowances / :charges keys.
⚠️ Two rules the XSD accepts but the platform will not:
- Every entry needs a
:reasonor:reason_code(BR-33,BR-38,BR-42,BR-44). An amount on its own gets the invoice rejected.- Document-level entries must match their totals, which in turn feed
:tax_basis_total(BR-CO-11,BR-CO-12,BR-CO-13). This library does not compute that arithmetic for you.
How the invoice is to be paid (BG-16). Not part of the regulatory data set — the tax administration does not need it — but an invoice without it is unusable:
%Facturx.Invoice{
payment_means: [
%{
type_code: "58", # BT-81, UNTDID 4461: SEPA credit transfer
iban: "FR7630006000011234567890189", # BT-84
account_name: "ACME SARL", # BT-85
bic: "BNPAFRPPXXX" # BT-86
}
]
}type_code is commonly "30" (credit transfer), "58" (SEPA credit transfer),
"59" (SEPA direct debit), "48" (card), "20" (cheque), "10" (cash). The
84-value list is not validated here — the schematron already does it.
Other shapes: :payer_iban for a direct debit (BT-91), :account_id for a
non-IBAN account, :card_id / :cardholder_name for a card (BT-87 / BT-88).
⚠️ :card_idmust be at most 10 characters — ruleBR-51enforces the PCI standard of showing at most the first 6 and last 4 digits. A masked 16-character number like"************1234"is too long and the invoice gets rejected. The XSD accepts any length; only the schematron catches this.
A final invoice nets off the down payments already invoiced, and points back at
them (BG-3). This is the counterpart of the B4/S4/M4 invoicing frameworks:
%Facturx.Invoice{
business_process: "S4", # BT-23 — final invoice after a down payment
preceding_invoices: [
%{number: "F-2026-042", issue_date: ~D[2026-06-15]}, # BT-25 / BT-26
%{number: "F-2026-043"} # BT-26 is optional
],
# ...
}Rule G1.60 forbids pairing a B4/S4/M4 framework with type_code 386,
500 or 503: the framework already says "final invoice after a down payment",
so the document cannot itself be one. That is enforced along with the closed list
(validate_business_process: true), returning
{:error, {:final_invoice_type_conflict, %{business_process: …, type_code: …}}}.
Being a cross-field rule, neither the XSD nor the EN 16931 schematron sees it —
the latter being French.
Two things the XSD will not catch, so worth knowing:
:price_discountneeds:gross_price— the CII price container requires an amount, so a lone discount is dropped.- An exempt VAT breakdown needs a line in the matching category (
BR-E-01), and a period needs its end on or after its start (BR-29). Both are schematron rules; see the Schematron section below to check them.
Two data items are required for domestic French invoicing on top of plain
EN 16931. Both are nil by default, so nothing changes if you don't need
them — cross-border EN 16931 output is byte-for-byte unaffected.
invoice = %Facturx.Invoice{
business_process: "S1", # BT-23 — cadre de facturation (closed list, see below)
tax_due_date_type_code: "5", # BT-8 — VAT point date code
# ...
}BT-8 says when VAT becomes chargeable. In CII the code list is UNTDID 2475,
restricted by EN 16931 (rule BR-CL-06) to three values, which
Facturx.vat_point_date_codes/0 returns:
| Code | VAT point | Regime |
|---|---|---|
5 |
invoice date | VAT on debits (chargeable on invoicing) |
29 |
delivery date | goods (chargeable on delivery) |
72 |
payment date | VAT on collection |
⚠️ 3/35/432belong to UNTDID 2005, the UBL list. In CII they pass the XSD (the type is an unrestrictedxs:token) but the Schematron — and the platform — reject them. This library validates BT-8 against the three codes above by default, since the restriction comes from EN 16931 rather than from the French mandate. To reproduce a third-party document that carries a nonconformant code, passvalidate_vat_point_date: false.
On the wire BT-8 sits inside each VAT breakdown entry, and EN 16931 lets the code
differ between entries (French rule S1.13 does not). The document-level field
above is the convenient case and is applied to every entry; set
:due_date_type_code on a tax_breakdown entry to override it there. Parsing
mirrors this: a uniform code is hoisted to the document level, divergent codes
stay per entry rather than being collapsed onto one value.
business_process (BT-23, mandatory 1..1 for the mandate) carries the nature of
the transaction, which drives VAT chargeability. Its first letter is the category
— Biens / Services / Mixte:
| standard | already paid | final after down payment | other | |
|---|---|---|---|---|
| goods | B1 |
B2 |
B4 |
B7 e-reported |
| services | S1 |
S2 |
S4 |
S5 subcontractor · S6 co-contractor · S7 e-reported |
| mixed | M1 |
M2 |
M4 |
The list is closed for the French mandate (rule G1.02) and
Facturx.business_processes/0 returns it. BT-23 is an EN 16931 term, though,
and its values are not restricted to those codes — Peppol uses
urn:fdc:peppol.eu:…, Chorus Pro used A1/A2. So the code is emitted as given
by default, and checking against the French list is opt-in. If you issue
French domestic invoices, enable it once in your config:
config :facturx, Facturx.CII, validate_business_process: trueAn unknown code then returns {:error, {:invalid_business_process, code}} instead
of producing an invoice a platform will reject. It can also be set per call
(Facturx.build(invoice, validate_business_process: true)), which overrides the
config in both directions.
Two caveats worth knowing before you rely on this:
- Enabling the check also enforces G1.60 (see the down-payment section above). Other French rules remain unenforced, so this is not full BT-23 conformance.
- The Base_/Full_ file naming that declares the PPF profile (rule S1.06) is the caller's or the platform's job, not this library's.
Full reference, with primary sources and the complete Flux 1 → CII mapping:
docs/reference/reforme-fr.md ·
docs/reference/mapping-cii-flux1.md ·
ADR 0002.
XSD (structure/types) — pure Elixir, in-process, no external tool:
{:ok, :valid} = Facturx.validate_xsd(xml)
# {:error, {:invalid, ["...invalid_decimal...", ...]}} on a bad documentSchematron (EN 16931 business rules) — needs a reachable Saxon server
(see Facturx.Validate):
case Facturx.validate(xml, endpoint: "http://localhost:5000/transform") do
{:ok, :valid} -> :ok
{:ok, {:valid_with_warnings, findings}} -> inspect_warnings(findings)
{:error, {:invalid, errors}} -> reject(errors)
endFindings are split by SVRL severity: only "warning" and "info" are
non-blocking. Don't read :valid_with_warnings as harmless — of the three
assertions the bundled schematron flags as warnings, two are business rules:
BR-29 (BT-74 ≥ BT-73) and BR-FX-EN-04 (a non-down-payment invoice must carry
BT-72, BG-14 or BG-26). Only PEPPOL-EN16931-R008 (no empty elements) is
cosmetic, and it fires when neither :ship_to nor :delivery_date is set, since
CII still requires the ram:ApplicableHeaderTradeDelivery container.
The bundled rules load their code-list DB through document(), which Saxon
refuses unless started with --insecure. A ready-made image is provided that
enables it and bakes the code-list DB in, so validation needs no network:
docker compose -f docker/compose.yml up -d --build
mix test --include saxon # with the env vars belowFacturx.validate(xml,
endpoint: "http://localhost:5000/transform",
codedb_url: "file:///opt/facturx/FACTUR-X_EN16931_codedb.xml")Without :codedb_url, the XSLT fetches the code-list DB over the network on every
call — slower, and it tells a third party that you are validating. See
docker/Dockerfile for the details, including why the upstream image's CMD has
to be rebuilt rather than appended to.
MIT — see LICENSE.
The XSD and Schematron artefacts bundled under priv/ are third-party standard
schemas, not part of this library's code: Factur-X / EN 16931 (UN/CEFACT CII),
as packaged by akretion/factur-x
(BSD-3-Clause). Their notices and licence texts are reproduced in
priv/NOTICE.md.