""" Usage pip install lxml python generate_facturx_basic.py """ """ Générateur de XML Factur-X - Profil BASIC Conforme à la norme EN 16931, syntaxe CII (Cross Industry Invoice) Factur-X 1.0 / ZUGFeRD 2.x """ from lxml import etree from datetime import date from dataclasses import dataclass, field from typing import List, Optional # ───────────────────────────────────────────── # Namespaces CII obligatoires # ───────────────────────────────────────────── NSMAP = { "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100", "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100", "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100", "qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } RSM = NSMAP["rsm"] RAM = NSMAP["ram"] UDT = NSMAP["udt"] PROFILE_BASIC = "urn:factur-x.eu:1p0:basic" # ───────────────────────────────────────────── # Structures de données # ───────────────────────────────────────────── @dataclass class Address: line1: str city: str postcode: str country_code: str # ISO 3166-1 alpha-2, ex: "FR" line2: Optional[str] = None @dataclass class Party: name: str vat_id: Optional[str] = None # ex: "FR12345678901" siret: Optional[str] = None # schemeID="0002" address: Optional[Address] = None @dataclass class TaxLine: base_amount: float rate: float # ex: 20.0 pour 20% amount: float category: str = "E" # S=standard, Z=zéro, E=exonéré, AE=autoliquidation @dataclass class InvoiceLine: line_id: str description: str quantity: float unit_code: str # ex: "C62"=unité, "HUR"=heure, "KGM"=kg unit_price: float # prix HT unitaire vat_rate: float # ex: 20.0 vat_category: str = "S" # Le montant HT de la ligne est calculé automatiquement @dataclass class Invoice: # En-tête number: str issue_date: date due_date: Optional[date] currency: str # ex: "EUR" type_code: str = "380" # 380=facture, 381=avoir, 384=facture rectificative # Parties seller: Party = None buyer: Party = None # Lignes lines: List[InvoiceLine] = field(default_factory=list) # Paiement payment_means_code: str = "30" # 30=virement iban: Optional[str] = None bic: Optional[str] = None payment_reference: Optional[str] = None # Note (champ libre) note: Optional[str] = None # ───────────────────────────────────────────── # Helpers XML # ───────────────────────────────────────────── def sub(parent, ns, tag, text=None, **attribs): el = etree.SubElement(parent, f"{{{ns}}}{tag}", **attribs) if text is not None: el.text = str(text) return el def date_str(d: date) -> str: return d.strftime("%Y%m%d") def amount_str(v: float) -> str: return f"{v:.2f}" # ───────────────────────────────────────────── # Construction du XML # ───────────────────────────────────────────── def build_facturx(inv: Invoice) -> etree._ElementTree: root = etree.Element(f"{{{RSM}}}CrossIndustryInvoice", nsmap=NSMAP) # ── 1. ExchangedDocumentContext ────────────── ctx = sub(root, RSM, "ExchangedDocumentContext") gp = sub(ctx, RAM, "GuidelineSpecifiedDocumentContextParameter") sub(gp, RAM, "ID", PROFILE_BASIC) # ── 2. ExchangedDocument ──────────────────── doc = sub(root, RSM, "ExchangedDocument") sub(doc, RAM, "ID", inv.number) sub(doc, RAM, "TypeCode", inv.type_code) idt = sub(doc, RAM, "IssueDateTime") sub(idt, UDT, "DateTimeString", date_str(inv.issue_date), format="102") if inv.note: sub(doc, RAM, "IncludedNote").append( etree.fromstring(f'{inv.note}') ) # ── 3. SupplyChainTradeTransaction ────────── tx = sub(root, RSM, "SupplyChainTradeTransaction") # ── 3a. Lignes de facture (BASIC = lignes obligatoires) ── for ln in inv.lines: net = round(ln.quantity * ln.unit_price, 2) ili = sub(tx, RAM, "IncludedSupplyChainTradeLineItem") assoc = sub(ili, RAM, "AssociatedDocumentLineDocument") sub(assoc, RAM, "LineID", ln.line_id) prod = sub(ili, RAM, "SpecifiedTradeProduct") sub(prod, RAM, "Name", ln.description) la = sub(ili, RAM, "SpecifiedLineTradeAgreement") gpp = sub(la, RAM, "GrossPriceProductTradePrice") sub(gpp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) npp = sub(la, RAM, "NetPriceProductTradePrice") sub(npp, RAM, "ChargeAmount", amount_str(ln.unit_price), currencyID=inv.currency) ld = sub(ili, RAM, "SpecifiedLineTradeDelivery") sub(ld, RAM, "BilledQuantity", amount_str(ln.quantity), unitCode=ln.unit_code) ls = sub(ili, RAM, "SpecifiedLineTradeSettlement") tax = sub(ls, RAM, "ApplicableTradeTax") sub(tax, RAM, "TypeCode", "VAT") sub(tax, RAM, "CategoryCode", ln.vat_category) sub(tax, RAM, "RateApplicablePercent", amount_str(ln.vat_rate)) smls = sub(ls, RAM, "SpecifiedTradeSettlementLineMonetarySummation") sub(smls, RAM, "LineTotalAmount", amount_str(net), currencyID=inv.currency) # ── 3b. ApplicableHeaderTradeAgreement ────── agr = sub(tx, RAM, "ApplicableHeaderTradeAgreement") def _party(parent_el, tag, party: Party): p = sub(parent_el, RAM, tag) sub(p, RAM, "Name", party.name) if party.siret: sr = sub(p, RAM, "SpecifiedLegalOrganization") sub(sr, RAM, "ID", party.siret, schemeID="0002") if party.vat_id: tr = sub(p, RAM, "SpecifiedTaxRegistration") sub(tr, RAM, "ID", party.vat_id, schemeID="VA") if party.address: addr = sub(p, RAM, "PostalTradeAddress") sub(addr, RAM, "PostcodeCode", party.address.postcode) sub(addr, RAM, "LineOne", party.address.line1) if party.address.line2: sub(addr, RAM, "LineTwo", party.address.line2) sub(addr, RAM, "CityName", party.address.city) sub(addr, RAM, "CountryID", party.address.country_code) return p _party(agr, "SellerTradeParty", inv.seller) _party(agr, "BuyerTradeParty", inv.buyer) if inv.payment_reference: bor = sub(agr, RAM, "BuyerOrderReferencedDocument") sub(bor, RAM, "IssuerAssignedID", inv.payment_reference) # ── 3c. ApplicableHeaderTradeDelivery ─────── sub(tx, RAM, "ApplicableHeaderTradeDelivery") # ── 3d. ApplicableHeaderTradeSettlement ───── stl = sub(tx, RAM, "ApplicableHeaderTradeSettlement") sub(stl, RAM, "InvoiceCurrencyCode", inv.currency) # Moyens de paiement pm = sub(stl, RAM, "SpecifiedTradeSettlementPaymentMeans") sub(pm, RAM, "TypeCode", inv.payment_means_code) if inv.iban: acc = sub(pm, RAM, "PayeePartyCreditorFinancialAccount") sub(acc, RAM, "IBANID", inv.iban) if inv.bic: fi = sub(pm, RAM, "PayeeSpecifiedCreditorFinancialInstitution") sub(fi, RAM, "BICID", inv.bic) # TVA par taux (calcul à partir des lignes) tax_totals: dict[tuple, list] = {} for ln in inv.lines: key = (ln.vat_rate, ln.vat_category) tax_totals.setdefault(key, []).append(round(ln.quantity * ln.unit_price, 2)) total_ht = 0.0 total_tva = 0.0 for (rate, cat), bases in tax_totals.items(): base = round(sum(bases), 2) tva = round(base * rate / 100, 2) total_ht += base total_tva += tva tax = sub(stl, RAM, "ApplicableTradeTax") sub(tax, RAM, "CalculatedAmount", amount_str(tva), currencyID=inv.currency) sub(tax, RAM, "TypeCode", "VAT") sub(tax, RAM, "BasisAmount", amount_str(base), currencyID=inv.currency) sub(tax, RAM, "CategoryCode", cat) sub(tax, RAM, "RateApplicablePercent", amount_str(rate)) total_ht = round(total_ht, 2) total_tva = round(total_tva, 2) total_ttc = round(total_ht + total_tva, 2) # Échéance if inv.due_date: stp = sub(stl, RAM, "SpecifiedTradePaymentTerms") ddt = sub(stp, RAM, "DueDateDateTime") sub(ddt, UDT, "DateTimeString", date_str(inv.due_date), format="102") # Totaux sums = sub(stl, RAM, "SpecifiedTradeSettlementHeaderMonetarySummation") sub(sums, RAM, "LineTotalAmount", amount_str(total_ht), currencyID=inv.currency) sub(sums, RAM, "TaxBasisTotalAmount",amount_str(total_ht), currencyID=inv.currency) sub(sums, RAM, "TaxTotalAmount", amount_str(total_tva), currencyID=inv.currency) sub(sums, RAM, "GrandTotalAmount", amount_str(total_ttc), currencyID=inv.currency) sub(sums, RAM, "DuePayableAmount", amount_str(total_ttc), currencyID=inv.currency) return etree.ElementTree(root) # ───────────────────────────────────────────── # Exemple d'utilisation # ───────────────────────────────────────────── if __name__ == "__main__": invoice = Invoice( number="FA-2024-00123", issue_date=date(2024, 3, 15), due_date=date(2024, 4, 14), currency="EUR", type_code="380", note="Facture relative au contrat n°CTR-2024-01", seller=Party( name="Ma Société SAS", vat_id="FR12345678901", siret="12345678900012", address=Address( line1="10 rue de la Paix", city="Paris", postcode="75001", country_code="FR", ), ), buyer=Party( name="Client SARL", vat_id="FR98765432100", siret="98765432100011", address=Address( line1="5 avenue des Fleurs", city="Lyon", postcode="69001", country_code="FR", ), ), lines=[ InvoiceLine( line_id="1", description="Développement logiciel - Sprint 1", quantity=10, unit_code="HUR", # heures unit_price=150.00, vat_rate=0.0, ), InvoiceLine( line_id="2", description="Licence logicielle annuelle", quantity=1, unit_code="C62", # unité unit_price=500.00, vat_rate=0.0, ), InvoiceLine( line_id="3", description="Formation utilisateurs", quantity=2, unit_code="HUR", unit_price=200.00, vat_rate=0.0, ), ], payment_means_code="30", # 30 = virement bancaire iban="FR7612345678901234567890189", bic="BNPAFRPPXXX", payment_reference="CTR-2024-01", ) tree = build_facturx(invoice) output_file = "factur-x.xml" tree.write(output_file, xml_declaration=True, encoding="UTF-8", pretty_print=True) print(f"✅ Fichier généré : {output_file}") print(f" Facture : {invoice.number}") print(f" Émetteur : {invoice.seller.name}") print(f" Destinataire : {invoice.buyer.name}") # Calcul récapitulatif total_ht = sum(round(l.quantity * l.unit_price, 2) for l in invoice.lines) total_tva = round(total_ht * 0.20, 2) print(f" Total HT : {total_ht:.2f} €") print(f" TVA 20% : {total_tva:.2f} €") print(f" Total TTC : {total_ht + total_tva:.2f} €")