diff --git a/generate_facturx_jinja2.py b/generate_facturx_jinja2.py new file mode 100644 index 0000000..fd34e18 --- /dev/null +++ b/generate_facturx_jinja2.py @@ -0,0 +1,225 @@ +""" +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} €") diff --git a/templates/xml/factur-x-basic.xml.j2 b/templates/xml/factur-x-basic.xml.j2 new file mode 100644 index 0000000..72bdea2 --- /dev/null +++ b/templates/xml/factur-x-basic.xml.j2 @@ -0,0 +1,176 @@ + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + + + {{ invoice.number }} + {{ invoice.type_code }} + + {{ invoice.issue_date | datefmt }} + + {% if invoice.note %} + + {{ invoice.note }} + + {% endif %} + + + + + + + {% for line in invoice.lines %} + + + {{ line.line_id }} + + + {{ line.description }} + + + + {{ line.unit_price | amount }} + + + + {{ line.quantity | amount }} + + + + VAT + {{ line.vat_category }} + {{ line.vat_rate | amount }} + + + {{ (line.quantity * line.unit_price) | amount }} + + + + {% endfor %} + + + + + + {{ seller.name }} + {% if seller.siret %} + + {{ seller.siret }} + + {% endif %} + {% if seller.address %} + + {{ seller.address.postcode }} + {{ seller.address.line1 }} + {% if seller.address.line2 %} + {{ seller.address.line2 }} + {% endif %} + {{ seller.address.city }} + {{ seller.address.country_code }} + + {% endif %} + {% if seller.vat_id %} + + {{ seller.vat_id }} + + {% elif seller.siret %} + {# Franchise TVA (art. 293 B CGI) : pas de n° TVA, on utilise le SIRET avec schemeID="FC" #} + + {{ seller.siret }} + + {% endif %} + + + + {{ buyer.name }} + {% if buyer.siret %} + + {{ buyer.siret }} + + {% endif %} + {% if buyer.address %} + + {{ buyer.address.postcode }} + {{ buyer.address.line1 }} + {% if buyer.address.line2 %} + {{ buyer.address.line2 }} + {% endif %} + {{ buyer.address.city }} + {{ buyer.address.country_code }} + + {% endif %} + + + {% if invoice.payment_reference %} + + {{ invoice.payment_reference }} + + {% endif %} + + + + + + + {{ buyer.name }} + + + + + + {{ invoice.currency }} + + + {{ invoice.payment_means_code }} + {% if invoice.iban %} + + {{ invoice.iban }} + + {% endif %} + + + {% for tax in invoice.tax_lines %} + + {{ tax.amount | amount }} + VAT + {% if tax.exemption_reason %} + {{ tax.exemption_reason }} + {% endif %} + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + {% endfor %} + + {% if invoice.due_date %} + + + {{ invoice.due_date | datefmt }} + + + {% endif %} + + + {{ invoice.total_ht | amount }} + {{ invoice.total_ht | amount }} + {{ invoice.total_tva | amount }} + {{ invoice.total_ttc | amount }} + {{ invoice.total_ttc | amount }} + + + + + + diff --git a/templates/xml/factur-x-basic.xml.j2.back b/templates/xml/factur-x-basic.xml.j2.back new file mode 100644 index 0000000..654bb02 --- /dev/null +++ b/templates/xml/factur-x-basic.xml.j2.back @@ -0,0 +1,180 @@ + + + + + + + urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic + + + + + + {{ invoice.number }} + {{ invoice.type_code }} + + {{ invoice.issue_date | datefmt }} + + {% if invoice.note %} + + {{ invoice.note }} + + {% endif %} + + + + + + + {% for line in invoice.lines %} + + + {{ line.line_id }} + + + {{ line.description }} + + + + {{ line.unit_price | amount }} + + + + {{ line.quantity | amount }} + + + + VAT + {{ line.vat_category }} + {{ line.vat_rate | amount }} + + + {{ (line.quantity * line.unit_price) | amount }} + + + + {% endfor %} + + + + + + {{ seller.name }} + {% if seller.siret %} + + {{ seller.siret }} + + {% endif %} + {% if seller.address %} + + {{ seller.address.postcode }} + {{ seller.address.line1 }} + {% if seller.address.line2 %} + {{ seller.address.line2 }} + {% endif %} + {{ seller.address.city }} + {{ seller.address.country_code }} + + {% endif %} + {% if seller.vat_id %} + + {{ seller.vat_id }} + + {% endif %} + + + + {{ buyer.name }} + {% if buyer.siret %} + + {{ buyer.siret }} + + {% endif %} + {% if buyer.address %} + + {{ buyer.address.postcode }} + {{ buyer.address.line1 }} + {% if buyer.address.line2 %} + {{ buyer.address.line2 }} + {% endif %} + {{ buyer.address.city }} + {{ buyer.address.country_code }} + + {% endif %} + + + {% if invoice.payment_reference %} + + {{ invoice.payment_reference }} + + {% endif %} + + + + + + + {{ buyer.name }} + + + + + + {{ invoice.currency }} + + + {{ invoice.payment_means_code }} + {% if invoice.iban %} + + {{ invoice.iban }} + + {% endif %} + + + {% for tax in invoice.tax_lines %} + + {{ tax.amount | amount }} + VAT + {% if tax.exemption_reason %} + {{ tax.exemption_reason }} + {% endif %} + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + + + + {{ tax.amount | amount }} + VAT + {{ tax.base_amount | amount }} + {{ tax.category }} + {{ tax.rate | amount }} + + {% endfor %} + + {% if invoice.due_date %} + + + {{ invoice.due_date | datefmt }} + + + {% endif %} + + + {{ invoice.total_ht | amount }} + {{ invoice.total_ht | amount }} + {{ invoice.total_tva | amount }} + {{ invoice.total_ttc | amount }} + {{ invoice.total_ttc | amount }} + + + + + +