All notable changes to this project are documented here. Format based on Keep a Changelog.
[0.7.0] - 2026-08-31
Security
:decimalmoves to~> 3.0, a breaking dependency change. Every 2.x is affected by CVE-2026-32686 (GHSA-rhv4-8758-jx7v): the exponent of a parsed decimal was unbounded, soDecimal.parse("1e10000000")succeeded and the first arithmetic on the result — comparing two totals, say — allocated a ten-million-digit coefficient and could exhaust the BEAM's memory on a single request.Facturx.CII.parse/1takes invoices from third parties and handed such a value straight back, so this was reachable through the public API on untrusted input. decimal 3 caps the exponent at 6144, the IEEE 754 decimal128 Emax. Found bymix hex.audit, on its first run in CI.A wider requirement (
~> 2.0 or ~> 3.0) was rejected: the resolver would keep picking 2.x in existing projects, which is precisely the case that needs fixing.A non-finite amount no longer reaches an
Invoice.Decimal.parse/1accepts"NaN"and"Infinity", and a hostile document could put either in a monetary field — where it would propagate through the caller's arithmetic and come back out ofbuild/2as<ram:GrandTotalAmount>NaN</ram:GrandTotalAmount>.parse/1now reads those as absent, like any other malformed amount.
Fixed
A stream is no longer cut short by its own contents.
/Lengthis now authoritative in bothFacturx.EmbedandFacturx.Extract;endstreamandendobjare no longer located by scanning from the front of the object. Stream data does contain those bytes: deflate emits stored blocks that copy their input verbatim, and an uncompressed XMP packet can simply mention the word. Two failures followed, both silent until now — an embedded payload came back as{:error, :inflate_failed}, and a base whose/Metadatacontained the keyword had its XMP truncated before promotion, so the output PDF lost the Factur-X extension schema without any error being raised. Found by the new property tests.Facturx.Embed.embed/3no longer raises. It declared{:ok, binary()} | {:error, term()}whileraise "unbalanced dictionary"and aMatchError(no<<after the trailer) could both reach the caller. A malformed PDF is an input, not a programming error: the two sites now return{:error, :malformed_dictionary}/{:error, :dictionary_not_found}, and bothembed/3andFacturx.Extract.extract/1carry a last-resortrescue. Pinned by a property over arbitrary truncations and junk suffixes.Encrypted PDFs are refused instead of misread, and only on the strength of a trailer dictionary. Nothing looked for
/Encrypt, so an encrypted file failed to inflate or produced meaningless bytes. Both paths now return{:error, :encrypted_pdf_unsupported}— and on extraction that is reported in place of:no_embedded_file, since an unreadable attachment is not an absent one.
Added
PDF 1.5+ files are read and written. Cross-reference streams (
/Type /XRef) and object streams (/ObjStm) were refused outright, which ruled out most current producers. Both are supported now:Facturx.Extractbuilds its object index by scanning forN G objand then expanding every object stream. It never reads the cross-reference table — a scan is what survives an incremental update, where the previous revision's table still points at the objects the update replaced.Facturx.Embedwrites the update back in the form the base uses, and never a hybrid: a classic table appended to a stream-based document is what a strict PDF/A reader is entitled to reject. A catalog compressed inside an object stream is read from there and rewritten at top level.
Verified on a real file rather than a synthetic one. No tool on the development machine writes cross-reference streams — Ghostscript's pdfwrite emits a classic table, pypdf's writer likewise — so
dev/tools/xref_stream_base.pyre-serialises the PDF/A base with an object stream and a cross-reference stream, andmix facturx.harnesshas veraPDF vouch for that base before concluding anything from what we write to it. The output is PDF/A-3b with zero failed rules, and the Pythonakretion/factur-xreference navigates our cross-reference stream and extracts the payload byte-identically — which is what says it is correct rather than merely self-consistent.Facturx.totals/2derives the document arithmetic. The line net amounts, the VAT breakdown grouped by category and rate, and BT-106 to BT-115 —BR-CO-10toBR-CO-17and the per-category basis rules. None of it is checked by the XSD; until now the caller computed all of it by hand and found out from a Schematron report, or from a rejected invoice.A figure the caller supplied is kept, and a disagreement is reported rather than resolved:
{:error, {:totals_mismatch, [{path, given, computed}]}}. Passoverwrite: trueto take the computed figures.:prepaid(BT-113) and:rounding(BT-114) are never derived — nothing in the invoice determines them.Facturx.new/1builds an invoice from a plain map, reporting every problem at once rather than the first, and coercing integers and strings toDecimal. Floats are refused.Decimal.from_float/1is faithful, so this is not about that conversion: a float reaching the boundary has usually been through float arithmetic already, and0.1 + 0.2is0.30000000000000004before anything here can see it. Accepting it would record the drift and call it validated.totals/2refuses rather than guesses. An invoice with no lines returns{:error, :no_lines}: everything it derives is founded on the line amounts, and deriving zero would be a lie — a BASIC WL or MINIMUM invoice carries no lines and still has a VAT liability. A breakdown entry matching no line returns{:error, {:orphan_tax_breakdown, …}}rather than being dropped. An amount that is not aDecimalreturns{:error, {:not_a_decimal, path, value}}, because this module both adds and sorts them and coercing quietly is the float mistake again.new/1also requires:vat_categoryand:vat_rateon every line (BR-CO-4). Without them a line cannot be placed in any VAT breakdown group: it would count towards the invoice total while contributing no VAT, which is a wrong invoice rather than an incomplete one.Both are optional —
build/2andgenerate/3still take a bare struct or map.docs/adr/0001-perimetre-et-architecture.mdrecords why the original "no struct-level validation" decision was revised.Facturx.CII.build/2refuses a non-finite amount rather than emitting<ram:GrandTotalAmount>NaN</ram:GrandTotalAmount>. The list of fields that hold an amount is now shared bynew/1, the totals and the builder, so the field none of them knew about cannot be the one carrying the NaN.The MINIMUM, BASIC WL and BASIC profiles are real.
Facturx.CII.build/2used to emit the same document whatever the profile and change only the guideline URN, so those three produced non-conformant files carrying a conformant claim — and, their schemas not being bundled, nothing in the library could tell.build/2now restricts what it emits to what each profile allows, and all five XSDs ship (80 KB in total), along with all five schematrons. Each profile is checked against its own schema and its own rule set in CI.On the schematrons:
priv/is now 4.4 MB on disk, which is what made the first pass bundle only two of them. That was the wrong unit — the published tarball goes from 217 KB to 295 KB, the five rule sets costing 78 KB between them.{:error, {:schematron_not_bundled, _}}is now reachable only for a profile that does not exist.Two findings from that work, neither visible in the XSD:
- BT-111 (the VAT total in the accounting currency) only goes where BT-6
goes. MINIMUM has no
TaxCurrencyCode, so emitting it there stated an amount in a currency the document never declared. Every profile's schema allows twoTaxTotalAmount; the build/parse fixed point is what caught it. - In MINIMUM the postal address and the tax registration belong to the seller alone — required there (BR-08, BR-09), refused on the buyer. The XSD types every party alike and accepts both; only the schematron says otherwise.
- BT-111 (the VAT total in the accounting currency) only goes where BT-6
goes. MINIMUM has no
mix facturx.harness: the PDF/A-3 conformance and Python-parity oracle, until now a Livebook run by hand. veraPDF over the output of all five profiles, plus byte parity of the payload againstakretion/factur-x. It lives underdev/, compiled in:devonly, so it never reaches the published package.Property-based tests (
stream_data): the build/parse fixed point over randomly pruned invoices,Decimalvalue and scale preservation, XMP promotion idempotence and well-formedness, and PDF payload round-trips over arbitrary bytes.Quality gates that did not exist: Dialyzer (the public API is almost entirely
@spec-ed and nothing checked those specs), Credo, and coverage.CI now runs an OTP/Elixir matrix down to the
~> 1.15floor declared inmix.exs, which had never been compiled.
Changed
- The Saxon image passes
--timeout 300000. Its help text says "the maximum time a transformation is allowed to run" without a unit, and the unit is milliseconds —--timeout 300gives 300 ms and fails nearly every transformation. It needs raising because the stock default is short enough that the emulated arm64 run exceeds it under concurrent requests. Facturx.ValidateTestis no longerasync. Every:saxontest posts to one shared server, and sixteen concurrent transformations of a multi-megabyte stylesheet is how that timeout started firing, as an HTTP 400 on whichever test was unlucky. The concurrency bought nothing: the work is all on the far side of one socket.- The byte-level PDF rules shared by
Facturx.EmbedandFacturx.Extract— where a stream stops, where a dictionary closes, how an EOL is skipped — move to a single internal module, Facturx.PDF. They had been written twice and had to be fixed twice for the same bug. README.mddocuments which PDFs the library accepts and which it refuses, error tuple by error tuple, plus the one limit no error can express: an incremental update invalidates an existing digital signature.Facturx.XSD.Cacheis documented rather than hidden, clearing the two long-standing ExDoc warnings.
[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).