diff --git a/README.md b/README.md index 3df2c0c..8e18535 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Facturation Association -Application de facturation légale française pour associations (loi 1901), +Application de facturation légale française pour associations (loi 1901) ou micro-entrepreneur, non assujetties à la TVA (art. 293B du CGI). ## Fonctionnalités @@ -9,7 +9,7 @@ non assujetties à la TVA (art. 293B du CGI). - Devis avec numérotation automatique (DEV-AAAA-XXXX) - Factures avec numérotation chronologique (AAAA-XXXX) - Conversion devis → facture -- Génération PDF avec toutes les mentions légales françaises +- Génération PDF avec toutes les mentions légales françaises et conforme aux normes de facturation electronique Factur-X - Suivi des statuts (émise / payée / annulée) ## Stack 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/requirements.txt b/requirements.txt index 5f509cc..db75e3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ pydantic==2.9.2 pydantic-settings==2.5.2 bcrypt==4.2.0 itsdangerous==2.2.0 +factur-x==3.16 +pikepdf==10.5.0 diff --git a/routers/factures.py b/routers/factures.py index ca010f9..9ed35ac 100644 --- a/routers/factures.py +++ b/routers/factures.py @@ -11,6 +11,8 @@ from numerotation import generer_numero_facture from config import settings from auth import get_current_user from template_helper import render +from generate_facturx_jinja2 import Address, Party, Invoice, InvoiceLine, generate_facturx_xml +from facturx import generate_from_binary router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)]) templates = Jinja2Templates(directory="templates") @@ -155,20 +157,64 @@ def changer_statut_facture( db.commit() return RedirectResponse(f"/factures/{facture_id}", status_code=303) +from generate_facturx_jinja2 import Invoice, Party, Address, InvoiceLine, filter_amount, filter_datefmt @router.get("/{facture_id}/pdf") -def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): - from weasyprint import HTML +def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)): + from weasyprint import HTML, Attachment + from facturx import generate_from_binary + import hashlib + import pikepdf + from pikepdf import Name + import io + facture = db.query(Facture).get(facture_id) if not facture: raise HTTPException(status_code=404) + # 1. XML Factur-X + invoice = get_invoice_data(facture_id, db) + xml_str = generate_facturx_xml(invoice) + xml_bytes = xml_str.encode("utf-8") + + # 2. Rendu HTML → PDF simple (pas PDF/A-3 ici) html_content = templates.get_template("pdf/facture.html").render({ "facture": facture, "settings": settings, }) + pdf_bytes = HTML(string=html_content, base_url=".").write_pdf( + pdf_variant="pdf/a-3b", + ) - pdf_bytes = HTML(string=html_content, base_url=".").write_pdf() + # 3. generate_from_binary gère : + # - l'intégration du XML + # - les métadonnées XMP Factur-X (DocumentType, DocumentFileName, etc.) + # - la conformité PDF/A-3b + pdf_bytes = generate_from_binary( + pdf_bytes, + xml_bytes, + flavor="factur-x", + level="BASIC", + ) + + # 4. Corriger uniquement /EF/F/Subtype → /text/xml avec pikepdf + pdf_io = io.BytesIO(pdf_bytes) + with pikepdf.open(pdf_io) as pdf: + names = pdf.Root.Names.EmbeddedFiles.Names + i = 0 + while i < len(names): + i += 1 # sauter la clé (string) + if i < len(names): + obj = names[i] + try: + obj.EF.F.Subtype = Name("/text/xml") + except Exception: + pass + i += 1 + + output = io.BytesIO() + pdf.save(output) + pdf_bytes = output.getvalue() filename = f"facture-{facture.numero}.pdf" return Response( @@ -187,3 +233,55 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)) "facture": facture, "settings": settings, }) + +def get_invoice_data(facture_id: int, db: Session): + facture = db.query(Facture).get(facture_id) + invoice = Invoice( + number=facture.numero, + issue_date=facture.date_emission, + due_date=facture.date_echeance, + currency="EUR", + type_code="380", + note=("Facture établie suite au devis " + facture.devis_origine.numero), + + seller=Party( + name=settings.asso_nom, + #vat_id="FR12345678901", + siret=settings.asso_siret, + address=Address( + line1=settings.asso_adresse, + city=settings.asso_ville, + postcode=settings.asso_code_postal, + country_code="FR", + ), + ), + + buyer=Party( + name=facture.client.nom, + #vat_id="FR98765432100", + siret=facture.client.siret, + address=Address( + line1=facture.client.adresse, + city=facture.client.ville, + postcode=facture.client.code_postal, + country_code="FR", + ), + ), + + payment_means_code="30", # 30 = virement bancaire + iban=settings.asso_iban, + payment_reference=facture.devis_origine.numero, + ) + + for l in facture.lignes: + invoice.lines.append(InvoiceLine( + line_id=l.id, + description=l.description, + quantity=l.quantite, + unit_code="C62", + unit_price=l.prix_unitaire_ht, + vat_category="E", + vat_rate=0.0, + )) + + return invoice diff --git a/template_helper.py b/template_helper.py index 3dd3c16..cf6b8e2 100644 --- a/template_helper.py +++ b/template_helper.py @@ -3,7 +3,7 @@ Helper pour injecter automatiquement current_user dans le contexte Jinja2. """ from fastapi import Request from fastapi.templating import Jinja2Templates -from starlette.responses import HTMLResponse +from starlette.responses import HTMLResponse, Response def render(templates: Jinja2Templates, template_name: str, diff --git a/templates/pdf/facture.html b/templates/pdf/facture.html index 80a787c..dd81ce1 100644 --- a/templates/pdf/facture.html +++ b/templates/pdf/facture.html @@ -4,10 +4,22 @@ 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 }} + + + + + +