All notable changes to this project are documented here. Format based on Keep a Changelog.
[0.5.0] - 2026-07-30
The French regulatory core, complete within the EN 16931 profile: 50/116 → 96/116 data items emitted. Additive throughout — new fields default to empty, so existing callers see byte-identical output.
Added
Notes (BG-1) —
Facturx.Invoice.notes, a list of%{content: …, subject_code: …}(BT-22 / BT-21). CII orders the content before the code, the reverse of the BT numbering.Invoicing period (BG-14) —
Facturx.Invoice.billing_period, a%{start_date: …, end_date: …}(BT-73 / BT-74). Either date may stand alone; an empty map emits nothing.Gross price and price discount (BT-148 / BT-147) —
:gross_priceand:price_discounton a line.BT-148was the only unconditional gap left in the regulatory core: mandatory insideBG-29, which is itself mandatory. A discount without a gross price is dropped, the CII price container requiring an amount.VAT exemption reason (BT-120 / BT-121) —
:exemption_reasonand:exemption_reason_codeon a VAT breakdown entry. Note thatBR-E-01also wants a line in the matching exempt category; the XSD cannot see that, the schematron can, and a test now pins it.Preceding invoice references (BG-3) —
Facturx.Invoice.preceding_invoices, a list of%{number: …, issue_date: …}(BT-25 / BT-26). This is what a final invoice points at to net off down payments already invoiced, so it goes with theB4/S4/M4invoicing frameworks. Careful with BT-26:FormattedIssueDateTimeis aqdt:FormattedDateTimeType, so its child isqdt:DateTimeString— every other date in the document isudt:. The reference block is also emitted after the monetary summation, perHeaderTradeSettlementType.Allowances and charges (BG-20 / BG-21 at document level, BG-27 / BG-28 on a line) —
:allowancesand:charges, both on the invoice and on a line. All four map to one CII element told apart byChargeIndicator; which list you use decides it, so there is no flag to get wrong. Each entry takes:amount(the only required field),:basis_amount,:percent,:vat_category,:vat_rate,:reasonand:reason_code.The remaining document totals —
:allowance_total(BT-107),:charge_total(BT-108),:prepaid(BT-113) and:rounding(BT-114) on:totals.:prepaidis what down payments already covered, so it pairs with:preceding_invoices.⚠️ Two things only the schematron enforces, and which the XSD accepts happily: every allowance/charge needs a
:reasonor:reason_code(BR-33,BR-38,BR-42,BR-44) — an amount alone gets the invoice rejected; and document-level entries must match their totals, which feed:tax_basis_totalin turn (BR-CO-11,BR-CO-12,BR-CO-13). That arithmetic is not computed for you.Note the wire order, which does not follow the numbering: CII emits
ChargeTotalAmountbeforeAllowanceTotalAmount, andTradeAllowanceChargeTypeputsReasonCodebeforeReason.Line invoicing period (BG-26) —
:billing_periodon a line, same shape as the document-level one (BT-134 / BT-135). Reuses the BG-14 emitter, the CII type being identical; what needed care was its position, after the line's VAT and before its allowances.A note on
BR-FX-EN-04, which lists BT-72, BG-14 and BG-26 and reads like a general rule: it is not one. Its template only matches invoices whose seller and buyer are in DE, so it never fires on a French invoice, and its assertion is a conjunction — a line period satisfies the first half only, the second still wanting BT-72 or a non-empty delivery container.The last five core items —
:tax_representative(BG-11, whose BT-63 VAT id is the point),:global_idon a party (BT-29d, the SIREN of an assujetti unique, scheme0231), the full delivery address (:line_two,:line_three,:country_subdivision— BT-76 / BT-165 / BT-79),:noteon a line (BT-127) and:tax_currency+:tax_total_in_tax_currency(BT-6 / BT-111).⚠️ Two constraints the schematron caught and the XSD does not see:
- A line note must not carry a subject code. That is
EXT-FR-FE-183, a French extension on the target trajectory, not part of EN 16931 — emitting one gets the invoice rejected. Hence:noteon a line is a plain string, with no way to ask for one. :tax_currencymust differ from:currency. BT-110 and BT-111 are two occurrences of the same element, told apart by theircurrencyID; identical currencies make them indistinguishable and tripBR-53, cascading intoBR-CO-15.
Note also that the scheme defaults (
0002,0231) are written into the XML, so parsing a document built without them returns them anyway — the document is unchanged, the struct normalised.- A line note must not carry a subject code. That is
Payment means (BG-16) —
Facturx.Invoice.payment_means, a list covering the credited account (:iban/:account_name/:account_id, BT-84 / BT-85), its institution (:bic, BT-86), a direct debit's debited account (:payer_iban, BT-91) and card details (:card_id/:cardholder_name, BT-87 / BT-88), plus BT-81 / BT-82. Not part of the regulatory Flux 1 set — the tax administration does not need them — so they do not enter the 96/116 count; but an invoice without payment details is unusable in practice.⚠️
:card_idmust be at most 10 characters (ruleBR-51, the PCI standard of showing at most the first 6 and last 4 digits). A masked 16-character PAN like"************1234"is too long and gets the invoice rejected. The XSD accepts any length, so only the schematron catches it — pinned by a test.Rule G1.60 is now enforced, alongside the G1.02 closed list and under the same
:validate_business_processopt-in. AB4/S4/M4framework means "final invoice after a down payment", so it cannot be paired with a down-payment:type_code(386,500,503); that returns{:error, {:final_invoice_type_conflict, %{business_process: …, type_code: …}}}. Being a cross-field constraint, neither the XSD nor the EN 16931 schematron sees it — without the check, the first sign would be a platform refusing the invoice. The legitimate combinations still pass: a down-payment invoice under a standard framework (S1+386), and a final invoice with an ordinary type.
Coverage of the regulatory Flux 1 data set goes from 50/116 to 96/116 — see
docs/reference/mapping-cii-flux1.md.
Added (tooling)
docker/— a Saxon image for the bundled Schematron. It starts Saxon with--insecure(required for thedocument()call that loads the code-list DB) and bakes the DB in, so validation no longer fetches it over the network on every call. Point:codedb_urlatfile:///opt/facturx/FACTUR-X_EN16931_codedb.xml. Not shipped in the Hex package.- Tests against the bundled EN 16931 Schematron, over invoices the library
builds — until now the
:saxontests only exercised the HTTP transport with toy stylesheets, so not a single business rule was covered. Notably, one test pins that a BT-8 outside UNTDID 2475 is rejected even though the XSD accepts it: that is the exact defect shipped in 0.3.0, which no automated check could have caught. - A
schematronCI job running those tests. It builds the image from the commit under test rather than pulling a published one, so the ruleset always matches the code and nothing is redistributed.
[0.4.0] - 2026-07-30
Fixed
validate/2reported every conformant invoice as invalid. SVRL findings were collected without looking at their severity, so a singleflag="warning"produced{:error, {:invalid, …}}. The EN 16931 schematron flagsPEPPOL-EN16931-R008("no empty elements") as a warning, and CII requiresram:ApplicableHeaderTradeDeliveryeven when there is no delivery data — so any invoice built without:ship_toor:delivery_datetripped it and could never come back valid. Warnings also drowned out real errors in the same list.
Changed (breaking)
Facturx.validate/2gains a third return shape, for documents that are valid but carried non-blocking findings:{:ok, :valid} {:ok, {:valid_with_warnings, findings}} # new {:error, {:invalid, errors}}Callers matching on the previous two shapes must handle the new one.
⚠️ Read this before upgrading. The break has a quiet half. Code matching the old shapes raises a
MatchErrorand fails loudly, which is fine — but code written aswith {:ok, _} <- Facturx.validate(xml)ormatch?({:ok, _}, Facturx.validate(xml))does not fail. It silently starts accepting invoices that 0.3.0 rejected, because{:ok, _}can now carry failed assertions. Three rules are affected — every assertion the bundled schematron flags aswarning— and two of them are substantive, not cosmetic:Rule What it checks Nature PEPPOL-EN16931-R008document must not contain empty elements cosmetic BR-29if BT-73 and BT-74 are both given, BT-74 must be ≥ BT-73 business rule BR-FX-EN-04an invoice that is not a down payment (386) must carry BT-72, BG-14 or BG-26 business rule The severities are the schematron's own, not this library's choice: 0.3.0 simply ignored them and treated all three as blocking. If you relied on that, match on
{:ok, :valid}specifically, or inspect the findings returned by{:ok, {:valid_with_warnings, findings}}.Findings gain a
:flagkey carrying the SVRL severity (nilwhen the rule declares none). Only"warning"and"info"are non-blocking; anything else, including an absent flag, counts as an error — defaulting to "invalid" is the safe way round. In the bundled schematron only 3 of 621 assertions are flagged, all aswarning.
Notes
Running the bundled Schematron locally needs Saxon's
--insecureflag, which permits thedocument()call that loads the code-list DB. Mind that the image'sCMDmust be rebuilt rather than appended to:docker run -d --rm -p 5000:5000 ghcr.io/willemvlh/saxon-server \ /bin/sh -c 'java $JAVA_OPTS -jar app.jar --insecure'
[0.3.0] - 2026-07-30
Support of the two data items the French e-invoicing mandate requires on top of
plain EN 16931. Both fields default to nil and emit nothing, so generated
output is unchanged for callers that don't set them.
Changed
parse/1now populatesbusiness_process(BT-23), which in 0.2.0 was alwaysnil. Emitting is unaffected: the French closed-list check is opt-in, soFacturx.parse(xml) |> Facturx.build()still succeeds on a non-French document (a German ZUGFeRD or Peppol invoice carrying its own BT-23).
Added
Facturx.Invoice.business_process— BT-23 "cadre de facturation" (ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID), mandatory1..1for the mandate and previously not emitted at all. This is the field that carries the nature of the transaction (goods / services / mixed) and therefore VAT chargeability.Facturx.Invoice.tax_due_date_type_code— BT-8 VAT point date code (ram:ApplicableTradeTax/ram:DueDateTypeCode), i.e. the option to pay VAT on debits. Document-level and replicated onto every VAT breakdown entry, which satisfies rule S1.13 by construction.Facturx.business_processes/0— the closed list of 13 BT-23 codes of rule G1.02 (B1/S1/M1,B2/S2/M2,B4/S4/M4,S5,S6,B7/S7).Facturx.vat_point_date_codes/0— the BT-8 code list,5(invoice date, VAT on debits) /29(delivery date, goods) /72(payment date, VAT on collection). BT-8 is validated against it by default (:validate_vat_point_date, set it tofalseto reproduce a nonconformant third-party document), returning{:error, {:invalid_vat_point_date_code, code}}. The restriction comes from EN 16931 (BR-CL-06), not from France, and the enumeration ships inpriv/schematron/en16931/FACTUR-X_EN16931_codedb.xml(code listid=28). Note that3/35/432are the UBL (UNTDID 2005) values and are invalid in CII — the XSD accepts them (unrestrictedxs:token) but the Schematron does not.- Per-entry BT-8: a
tax_breakdownentry may carry its own:due_date_type_code, which overrides the document-level field. EN 16931 allows the code to differ between VAT breakdown entries even though French rule S1.13 does not, so parsing hoists a uniform code to the document level and keeps divergent codes per entry instead of collapsing them onto one value.
Fixed
- A
:tax_due_date_type_codewith an empty:tax_breakdownhad nowhere to be emitted and was dropped silently;build/2now returns{:error, {:vat_point_date_unemittable, code}}. ""in:business_processor:tax_due_date_type_codeproduced an empty element that parsed back asnil, breaking the round-trip invariant. Empty strings are now treated as absent, likenil.Facturx.CII.build/2output is now covered against the bundled XSD by the test suite. That path had never been exercised: element order had only ever been checked by reading the schema.:validate_business_processoption onbuild/2(and thereforegenerate/3), defaulting tofalse, also settable once viaconfig :facturx, Facturx.CII, validate_business_process: true(the option overrides the config in both directions). Enabled, an unknown BT-23 code returns{:error, {:invalid_business_process, code}}. It is opt-in because BT-23 is an EN 16931 term whose values are not restricted to the French list — Peppol, Chorus Pro and other national specifications use their own.parse/1reads both fields back, preserving theparse(build(inv)) == invround-trip invariant.- Documentation, sourced against the v3.2 (2026-04-30) external
specifications:
docs/reference/reforme-fr.md(business reference, primary sources, and three widely repeated claims that the sources contradict),docs/reference/mapping-cii-flux1.md(all 116 regulatory Flux 1 data items mapped to CII, with coverage status — 50 emitted), and ADR 0002.
Notes
- No new schema is bundled: BT-23 and BT-8 are already declared
minOccurs="0"in the EN 16931 XSD shipped since 0.2.0. - Rule G1.60 (a
B4/S4/M4framework forbidstype_code386/500/503) is not enforced — the closed list is not full BT-23 conformance. - There is no
:extended_ctc_frprofile, deliberately. The PPF profile is declared by the transmitted file's name prefix (Base_/Full_, rule S1.06), which is the caller's responsibility; the URN…#conformant#urn.cpro.gouv.fr:1p0:extended-ctc-frfound in much secondary writing does not appear anywhere in the official specifications.
[0.2.0] - 2026-07-24
Added
Facturx.XSD.validate/2+Facturx.validate_xsd/2: validate CII XML against the EN 16931 XSD, in pure Elixir, in-process via OTP:xmerl_xsd— no external tool, no network, no Port. Catches missing mandatory elements, wrong data types, unexpected elements, wrong order and cardinality. Bundled schema:priv/xsd/en16931/.Facturx.XSD.Cache(supervised): compiles each bundled schema once and shares it via:persistent_term, so validation runs in the caller process — in parallel, ~0.6 ms/call (vs ~5 ms recompiling per call). Falls back to per-call compilation in a short-lived task when no cached schema is available.
Security
- XSD validation treats input as untrusted: a
<!DOCTYPE>is rejected and external entity/DTD fetching is disabled, preventing XXE and entity-expansion ("billion laughs") attacks.
Notes
:xmerl_xsdis a partial XSD 1.0 implementation; it validates the CII EN 16931 schema well but is not a guarantee of full XSD 1.0 conformance. Business-rule validation remains inFacturx.Validate(Schematron).
[0.1.0] - 2026-07-24
First public release. Pure-Elixir generation and extraction of Factur-X / ZUGFeRD invoices (EN 16931), with optional Schematron validation.
Added
- Project skeleton and scope ADR.
- Public API surface:
Facturx.generate/3,extract/1,parse/1,build/2,validate/2. Facturx.Extract.extract/1: locate and decode the embedded CII XML from a Factur-X PDF (classic xref), with profile detection and refc-binary-safe results (:binary.copy). Validated against a real EN 16931 fixture.Facturx.Embed.embed/3+Facturx.generate/3: embed CII XML into a PDF/A-2 or PDF/A-3 base via an incremental update (embedded-file stream,/Filespec,/AF,EmbeddedFilesname tree, overridden catalog + XMP). Promotes PDF/A-2 → PDF/A-3; refuses PDF/A-1 and non-PDF/A input. Output verified PDF/A-3b-compliant by veraPDF and semantically identical to the Python reference (akretion/factur-x).Facturx.Xmp.promote/3: bumppdfaid:part2 → 3 and inject the Factur-X extension schema +fx:*properties.Facturx.CII.build/2+parse/1: mapFacturx.Invoice(Decimal amounts) to and from EN 16931 CII XML (header, seller/buyer/ship-to, lines, VAT breakdown, monetary totals). Output validated against the CII XSD;build/parseround-trip the modelled fields (buildfills conventional defaults — unitC62, legal scheme0002— whichparsereads back).Facturx.generate/3now accepts anInvoicestruct directly (struct → CII → embed → veraPDF-valid Factur-X).Facturx.Validate.validate/2: optional EN 16931 Schematron validation. Sends the XML + bundled compiled schematron (priv/schematron/) to a Saxon server overmultipart/form-data(via optional:req) and interprets the SVRL report into violations. Supports a:xsloverride for custom rule sets. Proven end-to-end against a live Saxon server. Note: the EN 16931 XSLT resolves a code-list DB viadocument(...); the Saxon server must be configured to allow that URI (:codedb_urloverrides the location).
Known limitations
- Classic cross-reference tables only; object/xref streams (
/Type /ObjStm,/Type /XRef) are not yet handled (Extractreports:object_streams_unsupported,Embed:xref_streams_unsupported). Embeduses an incremental update, so the pre-promotion (part-2) XMP remains earlier in the file; conformant readers resolve the latest object (veraPDF confirms). A full-rewrite mode may be added later.Embedmerges/AF,/Namesand/PageModeinto the existing catalog (preserving e.g./Dests). Shapes it cannot safely merge in place are refused, never corrupted: an indirect/AF//Namesreference, or a base that already carries embedded files (re-embedding into a Factur-X).