DEV Community

Olivier EBRAHIM
Olivier EBRAHIM

Posted on

Factur-X 2026 Implementation Guide for SMB Construction: From Spec to Invoice in Python

Factur-X 2026 Implementation Guide for SMB Construction: From Spec to Invoice in Python

Introduction

If you're building software for construction SMBs in France, you've probably heard "Factur-X" mentioned in hushed tones by compliance teams. Starting January 2026, French invoicing regulations are tightening: all B2B invoices must include a machine-readable Factur-X (also called ZUGFeRD 3.0) component embedded within the PDF.

This isn't optional. This isn't coming "eventually." The deadline is real.

In this guide, I'll walk you through what Factur-X actually is, why it matters for construction SMBs, and how to implement it in your invoicing pipeline—complete with Python code examples and the gotchas I've encountered building this at scale for 50+ construction sites.

What Is Factur-X 2026?

Factur-X is an XML-based structured format that sits inside your PDF invoice. From the user's perspective, they still see a normal PDF. But underneath, there's a machine-readable XML file containing invoice data in a standardized format recognized by French tax authorities.

Why does this exist?

  • Tax authorities want to fight invoice fraud and cash-in-hand payments
  • EDI (electronic data interchange) between businesses becomes automatic and auditable
  • Suppliers can submit to French tax authorities automatically without manual re-entry

The format includes:

  • Invoice header (number, date, issuer, recipient)
  • Line items (description, quantity, unit price, tax rate)
  • Tax summary (VAT per rate, total)
  • Payment terms and bank details
  • Structured codes (UNECE, ISO 20022) for clarity across borders

From a developer's perspective: it's XML embedded in a PDF. That's actually simpler than you'd think.

The 2026 Mandate: What You Need to Know

Starting January 6, 2026, the French government will require:

  1. All B2B invoices (business-to-business) must include Factur-X
  2. Invoices issued by French companies to other French companies are in scope
  3. B2C (business-to-consumer) invoices are still optional but recommended
  4. Invoices for intra-EU sales can use simpler formats, but French->French = Factur-X mandatory

For construction SMBs, this is critical because:

  • General contractors invoice subcontractors constantly
  • Material suppliers invoice contractors
  • The audit trail now becomes automatic
  • If you're not generating Factur-X, your customers will have compliance issues

Technical Architecture: How We Approach It

Here's the high-level flow we use at Anodos for our construction clients:

Invoice Data (DB)
    ↓
XML Generator (Factur-X schema validation)
    ↓
PDF Generator (embed XML + visual PDF)
    ↓
Signed PDF (optional: legal signature)
    ↓
Archive + Tax Authority Submission
Enter fullscreen mode Exit fullscreen mode

Step 1: Build the XML Structure

The Factur-X spec defines exact XML schemas. You have two options:

  1. Parse the spec yourself (14 PDF pages, easy to misinterpret)
  2. Use an existing library (safer, recommended)

For Python, the best library is pycountry + lxml + a hand-rolled generator, OR use zugferd (German library, very solid):

# Option A: Hand-rolled with lxml (more control)
from lxml import etree
from datetime import datetime

def generate_factur_x_xml(invoice_data):
    """
    Generate a minimal valid Factur-X XML document.
    invoice_data = {
        'invoice_number': 'INV-2024-001',
        'issue_date': '2024-12-15',
        'issuer': {'name': 'My Construction LLC', 'vat_id': 'FR...'},
        'recipient': {'name': 'Client Corp', 'vat_id': 'FR...'},
        'line_items': [
            {'description': 'Labor – site excavation', 'quantity': 40, 
             'unit_price': 75.00, 'tax_rate': 0.20}
        ],
        'payment_due_date': '2025-01-15'
    }
    """

    # XML root (UN/CEFACT Cross Industry Invoice)
    root = etree.Element('rsm:CrossIndustryInvoice', 
        nsmap={
            'rsm': 'urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100',
            'qdt': 'urn:un:unece:uncefact:data:standard:QualifiedDataType:100',
            'ram': 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100',
            'xsi': 'http://www.w3.org/2001/XMLSchema-instance'
        })

    # Header (ExchangedDocumentContext)
    context = etree.SubElement(root, 'rsm:ExchangedDocumentContext')
    spec_id = etree.SubElement(context, 'ram:GuidelineSpecifiedDocumentContextParameter')
    spec_val = etree.SubElement(spec_id, 'ram:ID')
    spec_val.text = 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended'

    # Exchanged Document (metadata)
    doc = etree.SubElement(root, 'rsm:ExchangedDocument')
    doc_id = etree.SubElement(doc, 'ram:ID')
    doc_id.text = invoice_data['invoice_number']

    doc_type = etree.SubElement(doc, 'ram:TypeCode')
    doc_type.text = '380'  # 380 = Commercial invoice

    doc_date = etree.SubElement(doc, 'ram:IssueDateTime')
    date_elem = etree.SubElement(doc_date, 'udt:DateTimeString', 
        format='102')  # YYYYMMDD
    date_elem.text = invoice_data['issue_date'].replace('-', '')

    # Supplier Party (issuer)
    trade_settlement = etree.SubElement(root, 'rsm:SupplyChainTradeTransaction')

    applicable_header_trade_agreement = etree.SubElement(
        trade_settlement, 'ram:ApplicableHeaderTradeAgreement')

    seller_trade_party = etree.SubElement(
        applicable_header_trade_agreement, 'ram:SellerTradeParty')

    seller_name = etree.SubElement(seller_trade_party, 'ram:Name')
    seller_name.text = invoice_data['issuer']['name']

    seller_tax_id = etree.SubElement(seller_trade_party, 'ram:SpecifiedTaxRegistration')
    seller_tax_id_id = etree.SubElement(seller_tax_id, 'ram:ID', schemeID='VA')
    seller_tax_id_id.text = invoice_data['issuer']['vat_id']

    # Buyer Party (recipient)
    buyer_trade_party = etree.SubElement(
        applicable_header_trade_agreement, 'ram:BuyerTradeParty')

    buyer_name = etree.SubElement(buyer_trade_party, 'ram:Name')
    buyer_name.text = invoice_data['recipient']['name']

    buyer_tax_id = etree.SubElement(buyer_trade_party, 'ram:SpecifiedTaxRegistration')
    buyer_tax_id_id = etree.SubElement(buyer_tax_id, 'ram:ID', schemeID='VA')
    buyer_tax_id_id.text = invoice_data['recipient']['vat_id']

    # Line items (simplified)
    for item in invoice_data['line_items']:
        line = etree.SubElement(
            trade_settlement, 'ram:IncludedSupplyChainTradeLineItem')

        line_doc = etree.SubElement(line, 'ram:AssociatedDocumentLineDocument')
        line_num = etree.SubElement(line_doc, 'ram:LineID')
        line_num.text = str(invoice_data['line_items'].index(item) + 1)

        trade_line_item = etree.SubElement(line, 'ram:SpecifiedTradeProduct')
        prod_name = etree.SubElement(trade_line_item, 'ram:Name')
        prod_name.text = item['description']

        # Pricing
        line_agreement = etree.SubElement(line, 'ram:SpecifiedLineTradeAgreement')
        line_price = etree.SubElement(line_agreement, 'ram:NetPriceProductTradePrice')
        price_amount = etree.SubElement(line_price, 'ram:ChargeAmount')
        price_amount.text = str(item['unit_price'])

        # Quantity
        line_delivery = etree.SubElement(line, 'ram:SpecifiedLineTradeDelivery')
        qty = etree.SubElement(line_delivery, 'ram:BilledQuantity')
        qty.text = str(item['quantity'])
        qty.set('unitCode', 'HUR')  # Hours

        # Settlement (totals & tax)
        line_settlement = etree.SubElement(line, 'ram:SpecifiedLineTradeSettlement')
        line_total = etree.SubElement(line_settlement, 'ram:SpecifiedTradeSettlementMonetarySummation')
        total_amt = etree.SubElement(line_total, 'ram:DuePayableAmount')
        total_amt.text = str(item['quantity'] * item['unit_price'])

        # VAT
        vat_breakdown = etree.SubElement(line_settlement, 'ram:ApplicableTradeTax')
        vat_type = etree.SubElement(vat_breakdown, 'ram:TypeCode')
        vat_type.text = 'VAT'

        vat_rate = etree.SubElement(vat_breakdown, 'ram:RateApplicablePercent')
        vat_rate.text = str(item['tax_rate'] * 100)

    # Payment terms
    payment_terms = etree.SubElement(
        trade_settlement, 'ram:ApplicableHeaderTradeSettlement')

    due_date = etree.SubElement(payment_terms, 'ram:PaymentDueDateTime')
    due_date_str = etree.SubElement(due_date, 'udt:DateTimeString', format='102')
    due_date_str.text = invoice_data['payment_due_date'].replace('-', '')

    return etree.tostring(root, pretty_print=True, xml_declaration=True, 
                          encoding='UTF-8')
Enter fullscreen mode Exit fullscreen mode

This generates a valid Factur-X XML. It's verbose, but that's the spec—every field is mandatory.

Step 2: Embed XML in PDF

Once you have the XML, you need to embed it inside a PDF. Use PyPDF2 or pypdf (formerly PyPDF2):

from PyPDF2 import PdfWriter
from io import BytesIO
import PyPDF2

def embed_factur_x_in_pdf(pdf_bytes, factur_x_xml):
    """
    Embed Factur-X XML as an attachment within a PDF.
    """
    writer = PdfWriter()

    # Read the base PDF
    pdf_input = BytesIO(pdf_bytes)
    reader = PyPDF2.PdfReader(pdf_input)

    for page in reader.pages:
        writer.add_page(page)

    # Add the Factur-X XML as an embedded file
    writer.add_attachment(
        filename='factur-x.xml',
        data=factur_x_xml,
        description='Factur-X Invoice'
    )

    # Write to bytes
    output = BytesIO()
    writer.write(output)
    output.seek(0)

    return output.getvalue()
Enter fullscreen mode Exit fullscreen mode

Step 3: Validation and Compliance Checks

Before sending, validate against the official schema:

from xmlschema import XMLSchema

def validate_factur_x(xml_bytes):
    """
    Validate Factur-X XML against the official UN/CEFACT schema.
    Download the schema from: https://www.unece.org/fileadmin/DAM/cefact/xml/schemas/
    """
    schema_url = (
        'http://www.unece.org/fileadmin/DAM/cefact/xml/schemas/'
        'D16B/CrossIndustryInvoice_100pD16B.xsd'
    )

    schema = XMLSchema(schema_url)

    try:
        schema.validate(xml_bytes)
        return True, "Valid"
    except Exception as e:
        return False, str(e)
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls (We Hit Them So You Don't)

  1. Tax rates must match French standards (0%, 5.5%, 10%, 20%). Don't use 19.6%.
  2. Dates are YYYYMMDD strings in XML, not ISO 8601.
  3. VAT ID format matters: French VAT IDs start with 'FR' + 2 check digits + 9 SIRET digits.
  4. Decimal precision: Use Decimal() not float() for amounts. Floating-point rounding = audit failures.
  5. Namespace prefixes must match across the document. One typo = validation failure.

What We're Doing at Anodos

At Anodos, we generate Factur-X invoices automatically for construction SMBs. When a site manager confirms a job on the app, the quote becomes an invoice, and within 2 seconds the user downloads a PDF with embedded Factur-X XML inside. No extra steps. No compliance headaches.

By January 2026, every invoice out of our system will be Factur-X-compliant by default.

Conclusion

Factur-X isn't rocket science—it's structured XML in a PDF. Yes, the spec is dense, and yes, there are edge cases. But if you're building invoicing for French construction businesses, implementing it now gives you:

  • A 3-month head start before the January 2026 deadline
  • Customer trust ("we're compliant by design")
  • Automatic EDI with French tax authorities
  • A competitive moat vs. competitors rushing in December

Start with the XML generator, test with the official validator, and embed into your PDF pipeline. Your construction SMBs will thank you in Q1 2026.


Olivier Ebrahim, founder of Anodos — building voice-first invoicing for construction crews in France.

Top comments (0)