soli-pdf

Factur-X e-invoices

Factur-X, also called ZUGFeRD in Germany, is the European hybrid e-invoice. It is one PDF that a person can read, with the same invoice embedded as machine-readable XML (EN 16931, UN/CEFACT Cross-Industry Invoice). France is making e-invoices mandatory for business-to-business sales, and Factur-X is one of the accepted formats.

soli-pdf produces PDF/A-3b Factur-X files. They contain the embedded factur-x.xml, the /AF relationship, an sRGB output intent and the PDF/A and Factur-X XMP metadata. There are two ways to get there.

#Route 1: bring your own XML

Render your own template with your own data, and pass the CII XML your invoicing system already produces:

pdf = Soli::PDF.render(
  template: JSON.parse(File.read('invoice_compliant.template.json')),
  data: invoice.to_pdf_data,
  xml: invoice.to_cii_xml,        # String: your EN 16931 CII XML
  profile: 'en16931',
  title: "Invoice #{invoice.number}"
)

You keep full control of the layout, which is what a compliance-heavy template needs. The renderer embeds the XML; it doesn't validate it or compare it with the visual page. Keeping the two consistent is your responsibility.

The VAT-compliant invoice example is rendered this way. Its XML is on the example page.

#Route 2: a typed invoice

Give the renderer one typed invoice instead of data:. It computes the line totals, the VAT breakdown and the amount due, generates the CII XML from the same figures, and fills the template with them:

invoice_doc = {
  'number' => 'MI-2025-0184',
  'issue_date' => '2025-11-28',
  'due_date' => '2025-12-28',
  'currency' => 'EUR',
  'payment_terms' => '30 days net',
  'seller' => {
    'name' => 'Meridian Instruments SAS', 'address_line' => '18 rue de Paradis',
    'postcode' => '75010', 'city' => 'Paris', 'country' => 'FR',
    'vat_id' => 'FR75512345679', 'legal_id' => '512 345 679 00017',
    'iban' => 'FR7630006000011234567890189', 'bic' => 'CRLYFRPP'
  },
  'buyer' => {
    'name' => 'Philharmonie Immobilier SAS', 'address_line' => '221 avenue Jean-Jaurès',
    'postcode' => '75019', 'city' => 'Paris', 'country' => 'FR',
    'vat_id' => 'FR22842917361', 'legal_id' => '842917361'
  },
  'lines' => [
    { 'name' => 'On-site calibration', 'quantity' => 2, 'unit_price' => 480, 'vat_rate' => 20.0 },
    { 'name' => 'Technical handbook', 'quantity' => 12, 'unit_price' => '18.50', 'vat_rate' => 5.5 }
  ]
}

pdf = Soli::PDF.render(template: template, invoice: invoice_doc, profile: 'en16931')

Because the page and the XML come from the same computation, they can't disagree. That is the whole point of Factur-X.

#Typed invoice fields

FieldNotes
number, issue_date, currencyRequired. issue_date is YYYY-MM-DD.
due_date, note, payment_termsOptional. payment_terms is free text, such as "30 days net".
type_code380 (invoice, the default), 381 (credit note), 384, 389, 261 or 386.
currency_symbolOptional. Otherwise derived from the code: EUR gives €, USD gives $.
prepaidAn amount already paid. It is subtracted from the amount due.
seller, buyername, address_line, postcode, city, country (ISO 3166 alpha-2), country_name, phone, vat_id, legal_id.
seller.iban, seller.bicOptional. They feed the payment.* block for a scan-to-pay QR code.
lines[]name, unit_price, quantity (default 1), vat_rate (a percentage), unit_code (default C62), vat_category (default S).
allowances[], charges[]Document-level discounts and fees: reason, then exactly one of amount or percent, plus vat_rate.

Amounts can be numbers (480, 18.5) or numeric strings ("18.50"). They are kept exact to the cent.

legal_id is the SIREN (9 digits) or the SIRET (14 digits) in France. The identifier scheme is inferred from the length and spaces are removed, so "512 345 679 00017" is fine. French domestic B2B invoices need it. Use legal_id_scheme to set another ISO 6523 scheme explicitly.

Credit notes use "type_code": "381" with positive amounts. In CII, the type code carries the meaning.

#What the template sees

A typed invoice replaces your data document, so the template must use the placeholders the renderer provides:

PathContent
invoice.*number, created_at, due_date, due_amount, payment_terms, type_code, type_label ("Invoice" or "Credit note")
company.*, customer.*The seller and buyer: name, address, zipcode, city, country, phone, vat_number, registration
items[]The computed lines, for a data-bound table
total.*amount, discount, charges, taxable, vat, due_amount
discounts[], charges[]reason, amount, percent, for display
payment.*name, iban, bic, amount, currency, remittance, ready for an EPC QR code
infos.textThe invoice note

Warning A template written for your own data doesn't match these names. Its placeholders render empty, and Soli::PDF.last_warnings lists each one. The typed route also gives no per-rate VAT breakdown array. If your layout must print one, use route 1.

#Profiles

profile: sets the conformance level declared in the metadata. It must match the guideline ID inside your XML (BT-24).

ProfileContents
minimumParties and totals
basicwlHeader and totals, no lines
basicLine items (a subset)
en16931The full EN 16931 model (the default)
extendedEN 16931 plus extensions

#Restrictions

  • No encryption. PDF/A forbids it, so password: makes a Factur-X render fail.
  • Attachments are fine. Files passed with attachments: sit next to factur-x.xml in the attachments panel.
  • Fonts are embedded, as PDF/A requires. Use the bundled fonts or your own; nothing is left to the viewer.

#Validating the output

Check your files with the reference validators before sending them to a platform:

verapdf -f 3b invoice.pdf                                              # PDF/A-3b
java -jar Mustang-CLI.jar --action validate --source invoice.pdf       # Factur-X + XML rules

To read the XML back out of a PDF you produced or received:

pdfdetach -list invoice.pdf        # lists factur-x.xml
pdfdetach -save 1 invoice.pdf -o factur-x.xml