Compare commits

...

11 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
8ed1df83ec add template for pdf devis 2026-03-13 01:13:06 +01:00
2838a87413 generate pdf file for devis 2026-03-13 01:11:18 +01:00
11 changed files with 835 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
# Facturation Association # 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). non assujetties à la TVA (art. 293B du CGI).
## Fonctionnalités ## Fonctionnalités
@@ -9,7 +9,7 @@ non assujetties à la TVA (art. 293B du CGI).
- Devis avec numérotation automatique (DEV-AAAA-XXXX) - Devis avec numérotation automatique (DEV-AAAA-XXXX)
- Factures avec numérotation chronologique (AAAA-XXXX) - Factures avec numérotation chronologique (AAAA-XXXX)
- Conversion devis → facture - 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) - Suivi des statuts (émise / payée / annulée)
## Stack ## 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 pydantic-settings==2.5.2
bcrypt==4.2.0 bcrypt==4.2.0
itsdangerous==2.2.0 itsdangerous==2.2.0
factur-x==3.16
pikepdf==10.5.0

View File

@@ -1,7 +1,7 @@
import json import json
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends, Request, Form, HTTPException from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,6 +10,7 @@ from models import Devis, LigneDevis, Client, StatutDevis
from numerotation import generer_numero_devis from numerotation import generer_numero_devis
from auth import get_current_user from auth import get_current_user
from template_helper import render from template_helper import render
from config import settings
router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/devis", tags=["devis"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -150,6 +151,27 @@ def changer_statut_devis(
db.commit() db.commit()
return RedirectResponse(f"/devis/{devis_id}", status_code=303) return RedirectResponse(f"/devis/{devis_id}", status_code=303)
@router.get("/{devis_id}/pdf")
def telecharger_devis__pdf(devis_id: int, db: Session = Depends(get_db)):
from weasyprint import HTML
devis = db.query(Devis).get(devis_id)
if not devis:
raise HTTPException(status_code=404)
html_content = templates.get_template("pdf/devis.html").render({
"devis": devis,
"settings": settings,
})
pdf_bytes = HTML(string=html_content, base_url=".").write_pdf()
filename = f"devis-{devis.numero}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
@router.post("/{devis_id}/convertir") @router.post("/{devis_id}/convertir")
def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)): def convertir_en_facture(devis_id: int, db: Session = Depends(get_db)):

View File

@@ -11,6 +11,8 @@ from numerotation import generer_numero_facture
from config import settings from config import settings
from auth import get_current_user from auth import get_current_user
from template_helper import render 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)]) router = APIRouter(prefix="/factures", tags=["factures"], dependencies=[Depends(get_current_user)])
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@@ -155,20 +157,64 @@ def changer_statut_facture(
db.commit() db.commit()
return RedirectResponse(f"/factures/{facture_id}", status_code=303) 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") @router.get("/{facture_id}/pdf")
def telecharger_pdf(facture_id: int, db: Session = Depends(get_db)): def telecharger_pdf(request: Request, facture_id: int, db: Session = Depends(get_db)):
from weasyprint import HTML 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) facture = db.query(Facture).get(facture_id)
if not facture: if not facture:
raise HTTPException(status_code=404) 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({ html_content = templates.get_template("pdf/facture.html").render({
"facture": facture, "facture": facture,
"settings": settings, "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" filename = f"facture-{facture.numero}.pdf"
return Response( return Response(
@@ -187,3 +233,55 @@ def apercu_pdf(request: Request, facture_id: int, db: Session = Depends(get_db))
"facture": facture, "facture": facture,
"settings": settings, "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 import Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, Response
def render(templates: Jinja2Templates, template_name: str, def render(templates: Jinja2Templates, template_name: str,

View File

@@ -5,6 +5,7 @@
<h1>Devis {{ devis.numero }}</h1> <h1>Devis {{ devis.numero }}</h1>
<div class="btn-group"> <div class="btn-group">
<a href="/devis/" class="btn">← Retour</a> <a href="/devis/" class="btn">← Retour</a>
<a href="/devis/{{ devis.id }}/pdf" class="btn btn-primary">⬇ Télécharger PDF</a>
{% if devis.statut.value == 'brouillon' %} {% if devis.statut.value == 'brouillon' %}
<a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a> <a href="/devis/{{ devis.id }}/modifier" class="btn">Modifier</a>
{% endif %} {% endif %}

View File

@@ -28,6 +28,7 @@
<td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td> <td><span class="badge badge-{{ d.statut.value }}">{{ d.statut.value }}</span></td>
<td> <td>
<a href="/devis/{{ d.id }}" class="btn btn-sm">Voir</a> <a href="/devis/{{ d.id }}" class="btn btn-sm">Voir</a>
<a href="/devis/{{ d.id }}/pdf" class="btn btn-sm btn-primary">PDF</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

277
templates/pdf/devis.html Normal file
View File

@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 1.5cm 1.8cm;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2em;
padding-bottom: 1.5em;
border-bottom: 2px solid #2c3e50;
}
.emetteur h1 {
font-size: 16pt;
color: #2c3e50;
margin-bottom: 0.3em;
}
.emetteur p { font-size: 9pt; color: #555; line-height: 1.6; }
.facture-titre {
text-align: right;
}
.facture-titre h2 {
font-size: 22pt;
color: #2c3e50;
letter-spacing: 1px;
text-transform: uppercase;
}
.facture-titre .numero {
font-size: 13pt;
font-weight: bold;
color: #e74c3c;
margin-top: 0.2em;
}
.facture-titre .dates {
font-size: 9pt;
color: #555;
margin-top: 0.5em;
line-height: 1.8;
}
.parties {
display: flex;
justify-content: space-between;
margin-bottom: 2em;
gap: 2em;
}
.partie {
flex: 1;
padding: 1em;
border: 1px solid #ddd;
border-radius: 4px;
}
.partie h3 {
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 0.7em;
border-bottom: 1px solid #eee;
padding-bottom: 0.4em;
}
.partie p { font-size: 9.5pt; line-height: 1.7; }
.partie .nom { font-weight: bold; font-size: 11pt; }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5em;
}
thead tr {
background-color: #2c3e50;
color: white;
}
thead th {
padding: 0.6em 0.8em;
text-align: left;
font-size: 9pt;
font-weight: normal;
text-transform: uppercase;
letter-spacing: 0.5px;
}
thead th:last-child { text-align: right; }
tbody tr:nth-child(even) { background: #f8f9fa; }
tbody td {
padding: 0.6em 0.8em;
font-size: 9.5pt;
border-bottom: 1px solid #eee;
vertical-align: top;
}
tbody td:last-child { text-align: right; font-weight: 500; }
td.qte { text-align: center; }
td.pu { text-align: right; }
.totaux {
display: flex;
justify-content: flex-end;
margin-bottom: 2em;
}
.totaux table {
width: auto;
min-width: 280px;
}
.totaux td {
padding: 0.4em 0.8em;
font-size: 10pt;
border: none;
}
.totaux tr.total-ht td {
font-size: 12pt;
font-weight: bold;
color: #2c3e50;
border-top: 2px solid #2c3e50;
padding-top: 0.6em;
}
.totaux td:last-child { text-align: right; }
.tva-mention {
font-size: 8.5pt;
color: #666;
font-style: italic;
text-align: right;
margin-bottom: 1.5em;
}
.footer-info {
border-top: 1px solid #ddd;
padding-top: 1em;
font-size: 8.5pt;
color: #555;
line-height: 1.7;
}
.footer-info .conditions {
margin-bottom: 0.5em;
}
.footer-info .penalites {
font-size: 8pt;
color: #888;
}
.footer-info .iban {
margin-top: 0.5em;
font-weight: bold;
}
.devis-ref {
font-size: 8.5pt;
color: #888;
margin-bottom: 1.5em;
}
.notes {
background: #f8f9fa;
border-left: 3px solid #2c3e50;
padding: 0.7em 1em;
font-size: 9pt;
margin-bottom: 1.5em;
color: #444;
}
</style>
</head>
<body>
<!-- EN-TÊTE -->
<div class="header">
<div class="emetteur">
<h1>{{ settings.asso_nom }}</h1>
<p>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_email %}{{ settings.asso_email }}<br>{% endif %}
{% if settings.asso_telephone %}{{ settings.asso_telephone }}<br>{% endif %}
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="facture-titre">
<h2>Devis</h2>
<div class="numero">N° {{ devis.numero }}</div>
<div class="dates">
Date d'émission : {{ devis.date_emission.strftime('%d/%m/%Y') }}<br>
Date d'échéance : {{ devis.date_validite.strftime('%d/%m/%Y') }}
</div>
</div>
</div>
<!-- PARTIES -->
<div class="parties">
<div class="partie">
<h3>Émetteur</h3>
<p>
<span class="nom">{{ settings.asso_nom }}</span><br>
{{ settings.asso_adresse }}<br>
{{ settings.asso_code_postal }} {{ settings.asso_ville }}<br>
{% if settings.asso_rna %}RNA : {{ settings.asso_rna }}<br>{% endif %}
{% if settings.asso_siret %}SIRET : {{ settings.asso_siret }}{% endif %}
</p>
</div>
<div class="partie">
<h3>Destinataire</h3>
<p>
<span class="nom">{{ devis.client.nom }}</span><br>
{{ devis.client.adresse }}<br>
{{ devis.client.code_postal }} {{ devis.client.ville }}
{% if devis.client.siret %}<br>SIRET : {{ devis.client.siret }}{% endif %}
{% if devis.client.email %}<br>{{ devis.client.email }}{% endif %}
</p>
</div>
</div>
<!-- LIGNES -->
<table>
<thead>
<tr>
<th style="width:55%">Désignation</th>
<th style="width:10%" class="qte">Qté</th>
<th style="width:17%">Prix unitaire HT</th>
<th style="width:18%">Total HT</th>
</tr>
</thead>
<tbody>
{% for l in devis.lignes %}
<tr>
<td>{{ l.description }}</td>
<td class="qte">
{% if l.quantite == l.quantite | int %}{{ l.quantite | int }}{% else %}{{ l.quantite }}{% endif %}
</td>
<td class="pu">
{{ "%.2f"|format(l.prix_unitaire_ht) | replace('.', ',') }} €
</td>
<td>{{ "%.2f"|format(l.total_ht) | replace('.', ',') }} €</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- TOTAUX -->
<div class="totaux">
<table>
<tr>
<td>Montant HT</td>
<td>{{ "%.2f"|format(devis.total_ht) | replace('.', ',') }} €</td>
</tr>
<tr>
<td>TVA</td>
<td>0,00 €</td>
</tr>
<tr class="total-ht">
<td>Total TTC</td>
<td>{{ "%.2f"|format(devis.total_ht) | replace('.', ',') }} €</td>
</tr>
</table>
</div>
<p class="tva-mention">TVA non applicable — art. 293B du CGI</p>
<!-- PIED DE PAGE LÉGAL -->
<div class="footer-info">
{% if settings.asso_iban %}
<div class="iban">
Coordonnées bancaires — IBAN : {{ settings.asso_iban }}
{% if settings.asso_bic %} — BIC : {{ settings.asso_bic }}{% endif %}
</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -4,10 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 1.5cm 1.8cm; 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 { body {
font-family: 'DejaVu Sans', Arial, sans-serif; font-family: 'DejaVu Sans', Arial, sans-serif;
font-size: 10pt; font-size: 10pt;
@@ -26,8 +38,11 @@
color: #2c3e50; color: #2c3e50;
margin-bottom: 0.3em; 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 { .facture-titre {
text-align: right; text-align: right;
} }
@@ -40,12 +55,12 @@
.facture-titre .numero { .facture-titre .numero {
font-size: 13pt; font-size: 13pt;
font-weight: bold; font-weight: bold;
color: #e74c3c; color: #c0392b; /* #e74c3c remplacé par équivalent moins saturé → moins de gamut issues */
margin-top: 0.2em; margin-top: 0.2em;
} }
.facture-titre .dates { .facture-titre .dates {
font-size: 9pt; font-size: 9pt;
color: #555; color: #555555;
margin-top: 0.5em; margin-top: 0.5em;
line-height: 1.8; line-height: 1.8;
} }
@@ -66,7 +81,7 @@
font-size: 8pt; font-size: 8pt;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
color: #888; color: #888888;
margin-bottom: 0.7em; margin-bottom: 0.7em;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
padding-bottom: 0.4em; padding-bottom: 0.4em;
@@ -128,7 +143,7 @@
.tva-mention { .tva-mention {
font-size: 8.5pt; font-size: 8.5pt;
color: #666; color: #666666;
font-style: italic; font-style: italic;
text-align: right; text-align: right;
margin-bottom: 1.5em; margin-bottom: 1.5em;
@@ -138,7 +153,7 @@
border-top: 1px solid #ddd; border-top: 1px solid #ddd;
padding-top: 1em; padding-top: 1em;
font-size: 8.5pt; font-size: 8.5pt;
color: #555; color: #555555;
line-height: 1.7; line-height: 1.7;
} }
.footer-info .conditions { .footer-info .conditions {
@@ -146,7 +161,7 @@
} }
.footer-info .penalites { .footer-info .penalites {
font-size: 8pt; font-size: 8pt;
color: #888; color: #888888;
} }
.footer-info .iban { .footer-info .iban {
margin-top: 0.5em; margin-top: 0.5em;
@@ -155,7 +170,7 @@
.devis-ref { .devis-ref {
font-size: 8.5pt; font-size: 8.5pt;
color: #888; color: #888888;
margin-bottom: 1.5em; margin-bottom: 1.5em;
} }
@@ -165,7 +180,7 @@
padding: 0.7em 1em; padding: 0.7em 1em;
font-size: 9pt; font-size: 9pt;
margin-bottom: 1.5em; margin-bottom: 1.5em;
color: #444; color: #444444;
} }
</style> </style>
</head> </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>