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 }}
+
+
+
+
+
+