Factur-X Builder
Generates the factur-x.xml attachment of a Factur-X invoice in the
EN 16931 profile, from objects that speak the language of invoicing rather than the language of
the CII schema.
The gem produces the XML only. Embedding it into a PDF/A-3 is the job of a separate tool
(facturx-pdfgen, Ghostscript, veraPDF…). This split is deliberate: your Rails app builds the
invoice data, this gem turns it into a compliant XML attachment, and the PDF toolchain stays
independent.
French e-invoicing identifiers are first class — SIRET, SIREN, VAT number and the routing address
(scheme 0225) used by the PPF — and the rules that the French reform adds on top of EN 16931 are
enforced by default.
Installation
gem "factur-x-builder"
Requires Ruby >= 2.7 and Nokogiri ~> 1.15.
Usage
require "factur_x"
seller = FacturX::Party.new(
name: "OFFICE DE TOURISME DES MONTS FICTIFS",
siret: "39876543800005",
siren: "398765438",
vat_number: "FR79398765438",
contact: FacturX::Contact.new(person_name: "Service comptabilité", email: "compta@example.org"),
address: FacturX::Address.new(
line_one: "4 boulevard des Sources",
postcode: "63240",
city: "LE MONT-FICTIF",
country_code: "FR"
)
)
buyer = FacturX::Party.new(
name: "SARL COTEAUX ET COMPAGNIE",
siret: "81234567600009",
siren: "812345676",
vat_number: "FR19812345676",
address: FacturX::Address.new(
line_one: "123 avenue des Alpages",
postcode: "74000",
city: "Annecy",
country_code: "FR"
)
)
invoice = FacturX::Invoice.new(
number: "5-2026-0004",
issued_on: Date.new(2026, 8, 4),
business_process: "S1",
currency: "EUR",
notes: FacturX::Note.french_legal_mentions,
seller: seller,
buyer: buyer,
delivered_on: Date.new(2026, 8, 4),
payment_reference: "5-2026-0004",
payment_means: FacturX::PaymentMeans.new(
iban: "FR54300020001000012345678",
account_name: "OFFICE DE TOURISME DES MONTS FICTIFS",
bic: "AGRIFRPP"
),
payment_terms: FacturX::PaymentTerms.new(description: "Paiement à 30 jours",
due_on: Date.new(2026, 9, 3)),
lines: [
FacturX::Line.new(name: "Partenariat 2026", unit_price: 194.00, quantity: 1,
net_amount: 194.00, vat_rate: 20.0)
],
tax_breakdowns: [
FacturX::TaxBreakdown.new(category: "S", rate: 20.0,
basis_amount: 194.00, calculated_amount: 38.80)
],
totals: FacturX::Totals.new(
line_total: 194.00,
tax_basis_total: 194.00,
tax_total: 38.80,
grand_total: 232.80,
due_payable: 232.80
)
)
File.write("factur-x.xml", FacturX.build(invoice))
include FacturX drops the prefix if you build a lot of invoices in one place —
Invoice.new, Party.new — while the entry point stays FacturX.build.
Totals are yours, coherence is checked
The gem never computes amounts. Your application supplies every total, and the gem verifies they hang together before writing anything — a mismatch raises rather than producing a document the platform would reject:
FacturX.build(invoice)
# FacturX::ValidationError: invoice is not valid:
# - total with VAT (BT-112) is 9999.99 but total without VAT plus VAT add up to 232.80 (BR-CO-15)
To collect every problem instead of raising on the first, use violations:
FacturX.violations(invoice) # => ["...", "..."]
FacturX.valid?(invoice) # => false
Payment states
An issued invoice is immutable. Payments received after issuance are not written back into it —
they are reported to the platform as lifecycle statuses (Encaissée), a separate message flow that
this gem does not produce and is not meant to.
What the invoice records is the state at issuance, through BT-113 and BT-115:
| Situation | prepaid (BT-113) |
due_payable (BT-115) |
type_code |
|---|---|---|---|
| Nothing paid | omitted | grand total | 380 |
| Paid before issuance | grand total | 0.00 |
380 |
| Deposit collected before issuance | the deposit | the balance | 380 |
| Cancellation | omitted | amount to refund | 381 + preceding_invoices |
BR-CO-16 ties them together — BT-115 = BT-112 − BT-113 + BT-114 — and is checked before
anything is generated.
A credit note carries positive amounts; the type code conveys the direction. Negative unit
prices are rejected (BR-27, BR-28).
Payment instructions are optional
payment_means maps to BG-16 Payment instructions, which both schematrons accept as absent. It
states how the seller expects to be paid — it is not a record of the buyer's choice, since the
invoice precedes the payment. Several means may be declared at once.
Once a means is declared, the rules apply: a credit transfer (30, 58) requires an account
identifier (BR-61, BR-CO-27), and the code must belong to UNTDID 4461 —
Codes::PaymentMeansType::ALL holds the 84 permitted values, checked against the code database the
official schematron uses. The named constants (CREDIT_TRANSFER, BANK_CARD, …) cover the common
cases; any other code can be passed as a plain string.
A platform may narrow the list further. Chorus Pro accepts only 30, 42, 48, 49, 58, 59
and 97 for the public sector — a restriction no schematron encodes, so the gem cannot catch it.
Nothing downstream contradicts it. The Encaissée lifecycle status reports only the invoice number,
the payment date and the amount collected per VAT rate — the means actually used is not among the
data transmitted, so a buyer paying by cheque against an invoice quoting a transfer raises no
compliance issue.
One address per party
EN 16931 gives each party exactly one PostalTradeAddress, and it must be the address of the
establishment identified by the SIRET carried in GlobalID. There is no second slot: the element
that would hold a registered office, SpecifiedLegalOrganization/PostalTradeAddress, exists in the
extended XSD but the French CTC schematron forbids it (CII-SR-225), as it forbids
ShipFromTradeParty (CII-SR-166).
When the registered office differs from the invoicing establishment, it belongs in BT-33:
FacturX::Party.new(
name: "OFFICE DE TOURISME DES MONTS FICTIFS - Établissement de Chambéry",
legal_information: "SARL au capital de 50 000 € - RCS Clermont-Ferrand 398 765 438 - " \
"Siège social : 4 boulevard des Sources, 63240 LE MONT-FICTIF",
siret: "39876543800005",
siren: "398765438",
vat_number: "FR79398765438",
address: FacturX::Address.new(line_one: "8 rue des Entrepôts", postcode: "73000",
city: "Chambéry", country_code: "FR")
)
BT-33 is free text — the counterpart of cbc:CompanyLegalForm in UBL. Receivers display it, they do
not parse it.
Profiles
By default the French e-invoicing rules apply on top of EN 16931: an invoicing mode (BT-23) and the three mandatory legal statements are required. For an invoice issued outside that regime:
FacturX.build(invoice, profile: :en16931)
FacturX::Note.french_legal_mentions returns the three statements French law requires (late payment
penalties, the €40 recovery indemnity, and the absence of an early payment discount). Pass your own
wording to any of FacturX::Note.recovery_indemnity, FacturX::Note.late_payment_penalties or
FacturX::Note.no_early_payment_discount to override the default text.
Validation layers
| Layer | What it checks | Cost | When |
|---|---|---|---|
FacturX::Validation::Rules |
EN 16931 + French business rules, in Ruby | sub-millisecond | every build, unless validate: false |
FacturX::Validation::Schema |
official Factur-X 1.0.9 EN 16931 XSD, via Nokogiri | ~1 ms | every build, unless validate_schema: false |
| Schematron | the official rules, via Saxon | ~1.4 s | the test suite only |
The Ruby rules exist so your application gets a fast, readable error in the invoicing domain's own words. The schematron remains the authority: the test suite runs it against every generated document and asserts the Ruby rules agree with it, so the two cannot silently drift apart.
Development
bin/setup
bundle exec rake
rake runs the tests and RuboCop. Conformance tests shell out to Saxon:
brew install saxon
Without it, those tests skip rather than fail. The stylesheets live in test/schematron/ and are
excluded from the packaged gem.
Scope
Supported: invoices and credit notes (380, 381, 389), multiple VAT rates and categories,
exemptions with their reason, document level allowances and charges, prepayments, preceding invoice
references, delivery and billing periods, purchase order / contract / project references, credit
transfers, direct debits and cards.
Not supported: the extended and extended-ctc-fr profiles (the EXT-FR-FE-* fields), UBL syntax,
Order-X, and embedding the XML into a PDF.
License
Available as open source under the terms of the MIT License.