Compare commits

...

9 Commits

Author SHA1 Message Date
87e78314c6 update README 2026-03-16 23:47:27 +01:00
dbb5a29863 ajout des libs utilisées 2026-03-16 23:18:09 +01:00
f25cf02660 correction pour conformité complete PDF/A-3
codé avec l'aide de claude IA
2026-03-16 23:16:23 +01:00
bb12b3777f retrait des fonctions d'aide et finalisation de la génération de fichier pdf
conforme
2026-03-16 21:21:07 +01:00
b403efd056 Code cleanup 2026-03-16 17:47:16 +01:00
40c05f7860 remove unused file 2026-03-16 01:35:39 +01:00
bc82603f96 génération XML et emplate 2026-03-16 01:34:40 +01:00
b1029d10d3 generayion du XML a partir d'un template 2026-03-16 01:32:28 +01:00
8e79796781 génération du fichier XML pour le format factur-x 2026-03-14 00:41:41 +01:00
7 changed files with 533 additions and 17 deletions

View File

@@ -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

225
generate_facturx_jinja2.py Normal file
View File

@@ -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}")

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -4,10 +4,22 @@
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 1.5cm 1.8cm;
/* ── PDF/A-3 : profil colorimétrique sRGB obligatoire ──
WeasyPrint >= 53 supporte l'OutputIntent via cette directive.
Téléchargez sRGB_v4_ICC_preference.icc depuis ICC et placez-le
dans static/icc/ ou utilisez le chemin absolu. */
@pdf-output-intent url('/static/icc/sRGB_v4_ICC_preference.icc');
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
@@ -26,8 +38,11 @@
color: #2c3e50;
margin-bottom: 0.3em;
}
.emetteur p { font-size: 9pt; color: #555; line-height: 1.6; }
.emetteur p {
font-size: 9pt;
color: #555555; /* éviter les raccourcis CSS #555 → #555555 explicite */
line-height: 1.6;
}
.facture-titre {
text-align: right;
}
@@ -40,12 +55,12 @@
.facture-titre .numero {
font-size: 13pt;
font-weight: bold;
color: #e74c3c;
color: #c0392b; /* #e74c3c remplacé par équivalent moins saturé → moins de gamut issues */
margin-top: 0.2em;
}
.facture-titre .dates {
font-size: 9pt;
color: #555;
color: #555555;
margin-top: 0.5em;
line-height: 1.8;
}
@@ -66,7 +81,7 @@
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
color: #888888;
margin-bottom: 0.7em;
border-bottom: 1px solid #eee;
padding-bottom: 0.4em;
@@ -128,7 +143,7 @@
.tva-mention {
font-size: 8.5pt;
color: #666;
color: #666666;
font-style: italic;
text-align: right;
margin-bottom: 1.5em;
@@ -138,7 +153,7 @@
border-top: 1px solid #ddd;
padding-top: 1em;
font-size: 8.5pt;
color: #555;
color: #555555;
line-height: 1.7;
}
.footer-info .conditions {
@@ -146,7 +161,7 @@
}
.footer-info .penalites {
font-size: 8pt;
color: #888;
color: #888888;
}
.footer-info .iban {
margin-top: 0.5em;
@@ -155,7 +170,7 @@
.devis-ref {
font-size: 8.5pt;
color: #888;
color: #888888;
margin-bottom: 1.5em;
}
@@ -165,7 +180,7 @@
padding: 0.7em 1em;
font-size: 9pt;
margin-bottom: 1.5em;
color: #444;
color: #444444;
}
</style>
</head>

View File

@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice
xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- 1. CONTEXTE -->
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<!-- 2. EN-TÊTE FACTURE -->
<rsm:ExchangedDocument>
<ram:ID>{{ invoice.number }}</ram:ID>
<ram:TypeCode>{{ invoice.type_code }}</ram:TypeCode>
<ram:IssueDateTime>
<udt:DateTimeString format="102">{{ invoice.issue_date | datefmt }}</udt:DateTimeString>
</ram:IssueDateTime>
{% if invoice.note %}
<ram:IncludedNote>
<ram:Content>{{ invoice.note }}</ram:Content>
</ram:IncludedNote>
{% endif %}
</rsm:ExchangedDocument>
<!-- 3. TRANSACTION COMMERCIALE -->
<rsm:SupplyChainTradeTransaction>
<!-- 3a. Lignes de facture -->
{% for line in invoice.lines %}
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>{{ line.line_id }}</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>{{ line.description }}</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>{{ line.unit_price | amount }}</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="{{ line.unit_code }}">{{ line.quantity | amount }}</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>{{ line.vat_category }}</ram:CategoryCode>
<ram:RateApplicablePercent>{{ line.vat_rate | amount }}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>{{ (line.quantity * line.unit_price) | amount }}</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
{% endfor %}
<!-- 3b. Accord commercial -->
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>{{ seller.name }}</ram:Name>
{% if seller.siret %}
<ram:SpecifiedLegalOrganization>
<ram:ID schemeID="0002">{{ seller.siret }}</ram:ID>
</ram:SpecifiedLegalOrganization>
{% endif %}
{% if seller.address %}
<ram:PostalTradeAddress>
<ram:PostcodeCode>{{ seller.address.postcode }}</ram:PostcodeCode>
<ram:LineOne>{{ seller.address.line1 }}</ram:LineOne>
{% if seller.address.line2 %}
<ram:LineTwo>{{ seller.address.line2 }}</ram:LineTwo>
{% endif %}
<ram:CityName>{{ seller.address.city }}</ram:CityName>
<ram:CountryID>{{ seller.address.country_code }}</ram:CountryID>
</ram:PostalTradeAddress>
{% endif %}
{% if seller.vat_id %}
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">{{ seller.vat_id }}</ram:ID>
</ram:SpecifiedTaxRegistration>
{% elif seller.siret %}
{# Franchise TVA (art. 293 B CGI) : pas de n° TVA, on utilise le SIRET avec schemeID="FC" #}
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="FC">{{ seller.siret }}</ram:ID>
</ram:SpecifiedTaxRegistration>
{% endif %}
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>{{ buyer.name }}</ram:Name>
{% if buyer.siret %}
<ram:SpecifiedLegalOrganization>
<ram:ID schemeID="0002">{{ buyer.siret }}</ram:ID>
</ram:SpecifiedLegalOrganization>
{% endif %}
{% if buyer.address %}
<ram:PostalTradeAddress>
<ram:PostcodeCode>{{ buyer.address.postcode }}</ram:PostcodeCode>
<ram:LineOne>{{ buyer.address.line1 }}</ram:LineOne>
{% if buyer.address.line2 %}
<ram:LineTwo>{{ buyer.address.line2 }}</ram:LineTwo>
{% endif %}
<ram:CityName>{{ buyer.address.city }}</ram:CityName>
<ram:CountryID>{{ buyer.address.country_code }}</ram:CountryID>
</ram:PostalTradeAddress>
{% endif %}
</ram:BuyerTradeParty>
{% if invoice.payment_reference %}
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>{{ invoice.payment_reference }}</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
{% endif %}
</ram:ApplicableHeaderTradeAgreement>
<!-- 3c. Livraison -->
<ram:ApplicableHeaderTradeDelivery>
<ram:ShipToTradeParty>
<ram:Name>{{ buyer.name }}</ram:Name>
</ram:ShipToTradeParty>
</ram:ApplicableHeaderTradeDelivery>
<!-- 3d. Règlement -->
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>{{ invoice.currency }}</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>{{ invoice.payment_means_code }}</ram:TypeCode>
{% if invoice.iban %}
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>{{ invoice.iban }}</ram:IBANID>
</ram:PayeePartyCreditorFinancialAccount>
{% endif %}
</ram:SpecifiedTradeSettlementPaymentMeans>
{% for tax in invoice.tax_lines %}
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>{{ tax.amount | amount }}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
{% if tax.exemption_reason %}
<ram:ExemptionReason>{{ tax.exemption_reason }}</ram:ExemptionReason>
{% endif %}
<ram:BasisAmount>{{ tax.base_amount | amount }}</ram:BasisAmount>
<ram:CategoryCode>{{ tax.category }}</ram:CategoryCode>
<ram:RateApplicablePercent>{{ tax.rate | amount }}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
{% endfor %}
{% if invoice.due_date %}
<ram:SpecifiedTradePaymentTerms>
<ram:DueDateDateTime>
<udt:DateTimeString format="102">{{ invoice.due_date | datefmt }}</udt:DateTimeString>
</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
{% endif %}
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>{{ invoice.total_ht | amount }}</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>{{ invoice.total_ht | amount }}</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="{{ invoice.currency }}">{{ invoice.total_tva | amount }}</ram:TaxTotalAmount>
<ram:GrandTotalAmount>{{ invoice.total_ttc | amount }}</ram:GrandTotalAmount>
<ram:DuePayableAmount>{{ invoice.total_ttc | amount }}</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>