""" Générateur Factur-X BASIC via template Jinja2 """ from jinja2 import Environment, FileSystemLoader from dataclasses import dataclass, field from typing import List, Optional from datetime import date from collections import defaultdict from fastapi.templating import Jinja2Templates templates = Jinja2Templates(directory="templates") # ───────────────────────────────────────────── # Structures de données (identiques au générateur lxml) # ───────────────────────────────────────────── @dataclass class Address: line1: str city: str postcode: str country_code: str line2: Optional[str] = None @dataclass class Party: name: str vat_id: Optional[str] = None siret: Optional[str] = None address: Optional[Address] = None @dataclass class InvoiceLine: line_id: str description: str quantity: float unit_code: str unit_price: float vat_rate: float vat_category: str = "S" # Raison d'exonération par catégorie TVA EXEMPTION_REASONS = { "E": "TVA non applicable, art. 293 B du CGI", "Z": "Taux zéro", "AE": "Autoliquidation", "K": "Exonération intracommunautaire", "G": "Exportation hors UE", } @dataclass class TaxLine: rate: float category: str base_amount: float amount: float exemption_reason: Optional[str] = None @dataclass class Invoice: number: str issue_date: date currency: str seller: Party buyer: Party lines: List[InvoiceLine] = field(default_factory=list) due_date: Optional[date] = None type_code: str = "380" payment_means_code: str = "30" iban: Optional[str] = None payment_reference: Optional[str] = None note: Optional[str] = None # Calculés automatiquement (voir compute()) tax_lines: List[TaxLine] = field(default_factory=list) total_ht: float = 0.0 total_tva: float = 0.0 total_ttc: float = 0.0 def compute(self): """Calcule les totaux et les lignes de TVA depuis les lignes de facture.""" tax_groups = defaultdict(float) self.total_ht = 0.0 for ln in self.lines: net = round(ln.quantity * ln.unit_price, 2) self.total_ht += net tax_groups[(ln.vat_rate, ln.vat_category)] += net self.total_ht = round(self.total_ht, 2) self.tax_lines = [] self.total_tva = 0.0 for (rate, category), base in tax_groups.items(): base = round(base, 2) amount = round(base * rate / 100, 2) self.total_tva += amount self.tax_lines.append(TaxLine( rate=rate, category=category, base_amount=base, amount=amount, # Raison d'exonération automatique si catégorie != "S" (standard) exemption_reason=EXEMPTION_REASONS.get(category) if category != "S" else None, )) self.total_tva = round(self.total_tva, 2) self.total_ttc = round(self.total_ht + self.total_tva, 2) # ───────────────────────────────────────────── # Filtres Jinja2 personnalisés # ───────────────────────────────────────────── def filter_amount(value: float) -> str: return f"{float(value):.2f}" def filter_datefmt(value: date) -> str: return value.strftime("%Y%m%d") # ───────────────────────────────────────────── # Générateur # ───────────────────────────────────────────── def generate_facturx_xml(invoice: Invoice, template_dir: str = ".", output_file: str = "factur-x.xml") -> str: """ Génère le XML Factur-X BASIC depuis le template Jinja2. Retourne le XML sous forme de chaîne et l'écrit dans output_file. """ # Calcul des totaux invoice.compute() # Environnement Jinja2 env = Environment( loader=FileSystemLoader(template_dir), autoescape=False, # XML géré manuellement trim_blocks=True, # supprime le \n après les blocs {% %} lstrip_blocks=True, # supprime les espaces avant {% %} ) env.filters["amount"] = filter_amount env.filters["datefmt"] = filter_datefmt template = env.get_template("templates/xml/factur-x-basic.xml.j2") xml_str = template.render( invoice=invoice, seller=invoice.seller, buyer=invoice.buyer, ) with open(output_file, "w", encoding="utf-8") as f: f.write(xml_str) return xml_str # ───────────────────────────────────────────── # 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 absent : pas de numéro TVA en franchise (art. 293 B CGI) 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", ), ), payment_means_code="30", iban="FR7612345678901234567890189", payment_reference="CTR-2024-01", ) # Ajout des lignes — franchise de TVA (art. 293 B du CGI) # Utiliser vat_category="E" et vat_rate=0.0 articles = [ {"desc": "Développement logiciel - Sprint 1", "qty": 10, "unit": "HUR", "prix": 150.0, "tva": 0.0, "cat": "E"}, {"desc": "Licence logicielle annuelle", "qty": 1, "unit": "C62", "prix": 500.0, "tva": 0.0, "cat": "E"}, {"desc": "Formation utilisateurs", "qty": 2, "unit": "HUR", "prix": 200.0, "tva": 0.0, "cat": "E"}, ] for i, art in enumerate(articles, start=1): invoice.lines.append(InvoiceLine( line_id=str(i), description=art["desc"], quantity=art["qty"], unit_code=art["unit"], unit_price=art["prix"], vat_rate=art["tva"], vat_category=art["cat"], # "E" = exonéré (franchise TVA) )) xml = generate_facturx_xml(invoice, template_dir=".", output_file="factur-x.xml") print(f"✅ Fichier généré : factur-x.xml") print(f" Total HT : {invoice.total_ht:.2f} €") print(f" TVA 20% : {invoice.total_tva:.2f} €") print(f" Total TTC : {invoice.total_ttc:.2f} €")