All notable changes to this project are documented here. Format based on Keep a Changelog.

[0.7.0] - 2026-08-31

Security

  • :decimal moves 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, so Decimal.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/1 takes 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 by mix 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/1 accepts "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 of build/2 as <ram:GrandTotalAmount>NaN</ram:GrandTotalAmount>. parse/1 now reads those as absent, like any other malformed amount.

Fixed

  • A stream is no longer cut short by its own contents. /Length is now authoritative in both Facturx.Embed and Facturx.Extract; endstream and endobj are 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 /Metadata contained 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/3 no longer raises. It declared {:ok, binary()} | {:error, term()} while raise "unbalanced dictionary" and a MatchError (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 both embed/3 and Facturx.Extract.extract/1 carry a last-resort rescue. 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.Extract builds its object index by scanning for N G obj and 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.Embed writes 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.py re-serialises the PDF/A base with an object stream and a cross-reference stream, and mix facturx.harness has 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 Python akretion/factur-x reference 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/2 derives the document arithmetic. The line net amounts, the VAT breakdown grouped by category and rate, and BT-106 to BT-115 — BR-CO-10 to BR-CO-17 and 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}]}}. Pass overwrite: true to take the computed figures. :prepaid (BT-113) and :rounding (BT-114) are never derived — nothing in the invoice determines them.

  • Facturx.new/1 builds an invoice from a plain map, reporting every problem at once rather than the first, and coercing integers and strings to Decimal. Floats are refused. Decimal.from_float/1 is faithful, so this is not about that conversion: a float reaching the boundary has usually been through float arithmetic already, and 0.1 + 0.2 is 0.30000000000000004 before anything here can see it. Accepting it would record the drift and call it validated.

    totals/2 refuses 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 a Decimal returns {:error, {:not_a_decimal, path, value}}, because this module both adds and sorts them and coercing quietly is the float mistake again.

    new/1 also requires :vat_category and :vat_rate on 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/2 and generate/3 still take a bare struct or map. docs/adr/0001-perimetre-et-architecture.md records why the original "no struct-level validation" decision was revised.

  • Facturx.CII.build/2 refuses a non-finite amount rather than emitting <ram:GrandTotalAmount>NaN</ram:GrandTotalAmount>. The list of fields that hold an amount is now shared by new/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/2 used 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/2 now 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 two TaxTotalAmount; 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.
  • 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 against akretion/factur-x. It lives under dev/, compiled in :dev only, so it never reaches the published package.

  • Property-based tests (stream_data): the build/parse fixed point over randomly pruned invoices, Decimal value 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.15 floor declared in mix.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 300 gives 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.ValidateTest is no longer async. Every :saxon test 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.Embed and Facturx.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.md documents 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.Cache is 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 :note becomes :notes, a list, taking the same %{content: …, subject_code: …} shape as the document-level field. Callers passing note: "…" must pass notes: [%{content: "…"}].

    What the profile allows is no longer the caller's problem:

    • :en16931 — one note, content only. Extra notes and any :subject_code are 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_to and :delivery_date on a line (EXT-FR-FE-BG-10 and EXT-FR-FE-BG-11 with 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, both 0..1, both :extended only.

    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-* plus BT-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_invoice on 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 is 0..1 at line level (minOccurs="0", no maxOccurs), unlike the document-level :preceding_invoices.

    Emitted in :extended only, being a French extension. Two things the compiler will not tell you: LineTradeSettlementType puts it after the line monetary summation, and its date is a qdt:DateTimeString — the qdt namespace, not the udt every other date in the document uses.

  • The EXTENDED profile XSD is bundled (priv/xsd/extended/), so Facturx.validate_xsd/2 now 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 extensions EXT-FR-FE-*, which the EN 16931 schema rejects as out of profile.

  • The EXTENDED schematron is bundled too (priv/schematron/extended/), so Facturx.validate/2 checks 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.Embed and Facturx.Extract used to guess where a stream stopped, by removing the end-of-line that precedes endstream. When the data itself ended with \r or \n that 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/1 returned {:error, :inflate_failed} on a file it had just produced, and a base whose /Metadata was deflated and ended the same way made Facturx.generate/3 fail outright.

    Both now take /Length when it is a direct integer and lands on endstream with nothing but an end-of-line in between, falling back to the scan otherwise — producers do get /Length wrong, and trusting a wrong one would truncate where the scan worked.

Changed

  • The PDF paths are now exercised by CI. Facturx.Embed and Facturx.Extract were only ever tested against private fixtures under test/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 /Prev chain 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 :local tests, 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.MappingAnnexeTest reads the table and evaluates each of the 116 CII paths against the document Facturx.CII.build/2 produces from Facturx.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 their currencyID, and the four allowance/charge families only by their ChargeIndicator. 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_price and :price_discount on a line. BT-148 was the only unconditional gap left in the regulatory core: mandatory inside BG-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_reason and :exemption_reason_code on a VAT breakdown entry. Note that BR-E-01 also 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 the B4/S4/M4 invoicing frameworks. Careful with BT-26: FormattedIssueDateTime is a qdt:FormattedDateTimeType, so its child is qdt:DateTimeString — every other date in the document is udt:. The reference block is also emitted after the monetary summation, per HeaderTradeSettlementType.

  • Allowances and charges (BG-20 / BG-21 at document level, BG-27 / BG-28 on a line) — :allowances and :charges, both on the invoice and on a line. All four map to one CII element told apart by ChargeIndicator; 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, :reason and :reason_code.

  • The remaining document totals:allowance_total (BT-107), :charge_total (BT-108), :prepaid (BT-113) and :rounding (BT-114) on :totals. :prepaid is 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 :reason or :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_total in 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 ChargeTotalAmount before AllowanceTotalAmount, and TradeAllowanceChargeType puts ReasonCode before Reason.

  • Line invoicing period (BG-26) — :billing_period on 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_id on a party (BT-29d, the SIREN of an assujetti unique, scheme 0231), the full delivery address (:line_two, :line_three, :country_subdivision — BT-76 / BT-165 / BT-79), :note on 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 :note on a line is a plain string, with no way to ask for one.
    • :tax_currency must differ from :currency. BT-110 and BT-111 are two occurrences of the same element, told apart by their currencyID; identical currencies make them indistinguishable and trip BR-53, cascading into BR-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.

  • 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_id must be at most 10 characters (rule BR-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_process opt-in. A B4/S4/M4 framework 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 the document() 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_url at file:///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 :saxon tests 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 schematron CI 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/2 reported every conformant invoice as invalid. SVRL findings were collected without looking at their severity, so a single flag="warning" produced {:error, {:invalid, …}}. The EN 16931 schematron flags PEPPOL-EN16931-R008 ("no empty elements") as a warning, and CII requires ram:ApplicableHeaderTradeDelivery even when there is no delivery data — so any invoice built without :ship_to or :delivery_date tripped it and could never come back valid. Warnings also drowned out real errors in the same list.

Changed (breaking)

  • Facturx.validate/2 gains 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 MatchError and fails loudly, which is fine — but code written as with {:ok, _} <- Facturx.validate(xml) or match?({: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 as warning — and two of them are substantive, not cosmetic:

    RuleWhat it checksNature
    PEPPOL-EN16931-R008document must not contain empty elementscosmetic
    BR-29if BT-73 and BT-74 are both given, BT-74 must be ≥ BT-73business rule
    BR-FX-EN-04an invoice that is not a down payment (386) must carry BT-72, BG-14 or BG-26business 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 :flag key carrying the SVRL severity (nil when 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 as warning.

Notes

  • Running the bundled Schematron locally needs Saxon's --insecure flag, which permits the document() call that loads the code-list DB. Mind that the image's CMD must 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/1 now populates business_process (BT-23), which in 0.2.0 was always nil. Emitting is unaffected: the French closed-list check is opt-in, so Facturx.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_processBT-23 "cadre de facturation" (ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID), mandatory 1..1 for 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_codeBT-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 to false to 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 in priv/schematron/en16931/FACTUR-X_EN16931_codedb.xml (code list id=28). Note that 3/35/432 are the UBL (UNTDID 2005) values and are invalid in CII — the XSD accepts them (unrestricted xs:token) but the Schematron does not.
  • Per-entry BT-8: a tax_breakdown entry 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_code with an empty :tax_breakdown had nowhere to be emitted and was dropped silently; build/2 now returns {:error, {:vat_point_date_unemittable, code}}.
  • "" in :business_process or :tax_due_date_type_code produced an empty element that parsed back as nil, breaking the round-trip invariant. Empty strings are now treated as absent, like nil.
  • Facturx.CII.build/2 output 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_process option on build/2 (and therefore generate/3), defaulting to false, also settable once via config :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/1 reads both fields back, preserving the parse(build(inv)) == inv round-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/M4 framework forbids type_code 386/500/503) is not enforced — the closed list is not full BT-23 conformance.
  • There is no :extended_ctc_fr profile, 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-fr found 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_xsd is 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 in Facturx.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, EmbeddedFiles name 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: bump pdfaid:part 2 → 3 and inject the Factur-X extension schema + fx:* properties.
  • Facturx.CII.build/2 + parse/1: map Facturx.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/parse round-trip the modelled fields (build fills conventional defaults — unit C62, legal scheme 0002 — which parse reads back). Facturx.generate/3 now accepts an Invoice struct 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 over multipart/form-data (via optional :req) and interprets the SVRL report into violations. Supports a :xsl override for custom rule sets. Proven end-to-end against a live Saxon server. Note: the EN 16931 XSLT resolves a code-list DB via document(...); the Saxon server must be configured to allow that URI (:codedb_url overrides the location).

Known limitations

  • Classic cross-reference tables only; object/xref streams (/Type /ObjStm, /Type /XRef) are not yet handled (Extract reports :object_streams_unsupported, Embed :xref_streams_unsupported).
  • Embed uses 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.
  • Embed merges /AF, /Names and /PageMode into the existing catalog (preserving e.g. /Dests). Shapes it cannot safely merge in place are refused, never corrupted: an indirect /AF//Names reference, or a base that already carries embedded files (re-embedding into a Factur-X).