All notable changes to this project are documented here. Format based on Keep a Changelog.
[0.6.0] - 2026-08-11
Full coverage of the French regulatory Flux 1 data set — 96/116 → 116/116 —
and the first breaking change since 0.1.0. Read the note on :notes below
before upgrading.
Changed — breaking
A line's
:notebecomes:notes, a list, taking the same%{content: …, subject_code: …}shape as the document-level field. Callers passingnote: "…"must passnotes: [%{content: "…"}].What the profile allows is no longer the caller's problem:
:en16931— one note, content only. Extra notes and any:subject_codeare dropped, because emitting them gets the document rejected.:extended— as many notes as you like, each free to carry a subject code.
That covers BT-127-00 (the repeated container) and EXT-FR-FE-183 (the subject code), the first two of the twenty items annexe B still listed as not emitted.
Note the consequence for round-tripping: an EN 16931 document cannot return what a caller supplied if that caller supplied more than the profile carries.
Added
A line may carry its own delivery address and date —
:ship_toand:delivery_dateon a line (EXT-FR-FE-BG-10andEXT-FR-FE-BG-11with their children), for the multi-delivery case where one line ships elsewhere, or on another date, than the document says. Same shapes as their document-level counterparts, both0..1, both:extendedonly.With these, annexe B reaches 116/116 — every regulatory Flux 1 data item is emitted. Note the condition: 20 of them (the 19
EXT-FR-FE-*plusBT-127-00) exist only in:extended.Facturx.build(inv)still emits 96/116 and drops the rest on purpose, rather than produce a document the schema and the platform would reject.A line may reference a preceding invoice —
:preceding_invoiceon a line (EXT-FR-FE-BG-06/-136/-138), what a line points at to net off a down payment invoiced earlier. Singular, not a list: the CII element is0..1at line level (minOccurs="0", nomaxOccurs), unlike the document-level:preceding_invoices.Emitted in
:extendedonly, being a French extension. Two things the compiler will not tell you:LineTradeSettlementTypeputs it after the line monetary summation, and its date is aqdt:DateTimeString— theqdtnamespace, not theudtevery other date in the document uses.The EXTENDED profile XSD is bundled (
priv/xsd/extended/), soFacturx.validate_xsd/2now accepts an EXTENDED document instead of answering{:error, {:xsd_not_bundled, :extended}}. The schema is picked from the document's own guideline URN, so no option is needed. This is the groundwork for the line-level French extensionsEXT-FR-FE-*, which the EN 16931 schema rejects as out of profile.The EXTENDED schematron is bundled too (
priv/schematron/extended/), soFacturx.validate/2checks an EXTENDED document's business rules the way it already did for EN 16931 — the step that catches what no XSD can see. The Docker image carries the matching code-list DB.Cost of both, measured on the published artifact rather than on disk: the Hex package goes from 125 KB to 209 KB. XSLT compresses well, so the 2.2 MB those files occupy unpacked is not what users download.
Fixed
Streams whose data ends on CR or LF were truncated by one byte. Both
Facturx.EmbedandFacturx.Extractused to guess where a stream stopped, by removing the end-of-line that precedesendstream. When the data itself ended with\ror\nthat guess ate a real byte. For a deflate stream the last byte is the low byte of the adler32, so it hit roughly one document in 256:Facturx.extract/1returned{:error, :inflate_failed}on a file it had just produced, and a base whose/Metadatawas deflated and ended the same way madeFacturx.generate/3fail outright.Both now take
/Lengthwhen it is a direct integer and lands onendstreamwith nothing but an end-of-line in between, falling back to the scan otherwise — producers do get/Lengthwrong, and trusting a wrong one would truncate where the scan worked.
Changed
The PDF paths are now exercised by CI.
Facturx.EmbedandFacturx.Extractwere only ever tested against private fixtures undertest/fixtures/local/, which are not committed — so every run outside the author's machine skipped them silently. A new test-only builder (test/support/pdf_builder.ex) assembles a minimal PDF/A base in the test process, and 22 tests now cover the round-trip, the XMP promotion, the catalog merge branches (/Names,/AF,/PageMode, and the shapes that are refused rather than corrupted), the input contract, the cross-reference offsets and/Prevchain of the incremental update, and the truncation bug above.The synthetic base is a structural fixture, not a PDF/A producer: real conformance is still proven only by the
:localtests, which run veraPDF over real producer output.The coverage count is now verified rather than declared. The "Émis" column of annexe B (
docs/reference/mapping-cii-flux1.md) is written by hand, and the count derived from it appears in the README, both ADRs and this file; the annexe itself records a past drift on BT-111.Facturx.MappingAnnexeTestreads the table and evaluates each of the 116 CII paths against the documentFacturx.CII.build/2produces fromFacturx.TestInvoice.maximal/0. The maximal invoice is also asserted XSD-valid and to round-trip exactly, which is what makes it a fair witness.What is checked is the occurrence count, not mere existence: nine paths are claimed by two rows each — BT-110 and BT-111 are both
ram:TaxTotalAmount, told apart only by theircurrencyID, and the four allowance/charge families only by theirChargeIndicator. Existence alone would let one of each pair vanish unnoticed. A path carrying fewer nodes than it has ticked rows, or a path with no ticked row carrying any, fails the suite.
[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).