#24 — Factures PDF
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/invoice_pdf.py
Dépendances: #1 rgz-api · #19 moteur-facturation
Description
Le module Factures PDF génère des factures mensuelles au format PDF pour chaque revendeur actif du réseau ACCESS. Ces factures récapitulent toutes les transactions du mois, le détail du split de revenus (50/50 après commission KKiaPay), et le montant net dû au revendeur. Elles sont générées automatiquement le J+5 de chaque mois (ex : le 5 mars pour la période de février) et envoyées par email via le module #63.
La génération PDF utilise WeasyPrint, qui est pré-installé dans le Dockerfile docker/api/Dockerfile. WeasyPrint convertit du HTML/CSS en PDF avec un rendu haute fidélité — idéal pour maintenir la charte graphique ACCESS (couleurs, police Poppins, logo) dans les documents comptables. Les factures respectent les couleurs de la marque : en-tête en bleu ACCESS (#3f68ae), accents en jaune (#f5c445), alertes en rouge (#da3747).
Chaque facture est stockée sur le volume Docker persistant /app/data/invoices/{year}/{month}/{reseller_id}.pdf. Une fois générée, le chemin est enregistré dans la colonne invoices.pdf_path. Les requêtes de téléchargement (GET /api/v1/invoices/{id}/pdf) lisent ce fichier et le retournent sous forme de StreamingResponse avec Content-Type: application/pdf.
Les factures contiennent les informations légales obligatoires pour le Bénin : numéro de facture, RCCM revendeur, adresse, période couverte, TVA si applicable, et coordonnées RGZ S.A. Elles comportent également un QR code de vérification d'authenticité.
Architecture Interne
Structure du Document PDF
┌─────────────────────────────────────────────┐
│ [LOGO ACCESS] FACTURE MENSUELLE │ ← En-tête bleu #3f68ae
│ RGZ S.A. — Cotonou, Bénin │
│ Facture N° RGZ-2026-02-{reseller_code} │
├─────────────────────────────────────────────┤
│ REVENDEUR: │ ← Informations parties
│ [Nom Revendeur] │
│ NAS-ID: access_[slug] │
│ RCCM: XX-XXX-XXXX │
│ Période: 01 Février 2026 — 28 Fév. 2026 │
├─────────────────────────────────────────────┤
│ RÉSUMÉ FINANCIER │ ← Section financière
│ Total transactions: 1 247 │
│ Chiffre d'affaires brut: 623 500 FCFA │
│ Commission KKiaPay (1.5%): 9 352 FCFA │
│ Net après commission: 614 148 FCFA │
│ ───────────────────────────────────────── │
│ Part RGZ S.A. (50%): 307 074 FCFA │
│ VOTRE PART (50%): 307 074 FCFA │ ← Jaune #f5c445
│ Crédit SLA: 0 FCFA │
│ ═══════════════════════════════════════ │
│ MONTANT NET DÛ: 307 074 FCFA │ ← Gras, rouge #da3747
├─────────────────────────────────────────────┤
│ DÉTAIL PAR FORFAIT (top 10) │
│ Forfait | Qté | Montant (FCFA) │
│ 24h 500MB | 842 | 421 000 │
│ 1h Illimité | 250 | 125 000 │
│ ... │
├─────────────────────────────────────────────┤
│ DÉTAIL TRANSACTIONS (paginé si >100) │
│ Date | Abonné | Forfait | Montant │
│ 2026-02-01 | RGZ-... | 24h | 500 FCFA │
│ ... │
├─────────────────────────────────────────────┤
│ [QR CODE VÉRIFICATION] Page 1/N │ ← Pied de page légal
│ RGZ S.A. — RCCM Bénin XX-XXX │
│ Généré le: 2026-03-05 │
└─────────────────────────────────────────────┘Template HTML (WeasyPrint)
# app/services/invoice_pdf.py
from weasyprint import HTML, CSS
from jinja2 import Template
import qrcode
import base64
from io import BytesIO
INVOICE_HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap');
:root {
--access-blue: #3f68ae;
--access-yellow: #f5c445;
--access-red: #da3747;
--access-dark: #34383c;
}
body { font-family: 'Poppins', sans-serif; color: var(--access-dark); }
.header {
background-color: var(--access-blue);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
}
.amount-reseller {
background-color: var(--access-yellow);
font-weight: 700;
font-size: 1.2em;
padding: 10px;
text-align: right;
}
.amount-total {
color: var(--access-red);
font-weight: 700;
font-size: 1.4em;
border-top: 3px solid var(--access-red);
}
table { width: 100%; border-collapse: collapse; }
th { background-color: var(--access-blue); color: white; padding: 8px; }
td { padding: 6px; border-bottom: 1px solid #eee; }
tr:nth-child(even) { background-color: #f9f9f9; }
</style>
</head>
<body>
<!-- Contenu généré par Jinja2 -->
{{ header_html }}
{{ summary_html }}
{{ breakdown_html }}
{{ transactions_html }}
{{ footer_html }}
</body>
</html>
"""
class InvoicePDFService:
def generate_pdf(self, invoice_id: UUID, db: Session) -> bytes:
"""
Génère le PDF d'une facture.
Retourne le contenu binaire PDF.
"""
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
reseller = db.query(Reseller).filter(Reseller.id == invoice.reseller_id).first()
transactions = self._get_transactions(invoice, db)
# Générer QR code d'authenticité
qr_content = f"RGZ-INVOICE:{invoice.id}:{invoice.total_amount_fcfa}"
qr_b64 = self._generate_qr(qr_content)
# Rendre template Jinja2
html_content = self._render_template(invoice, reseller, transactions, qr_b64)
# WeasyPrint: HTML → PDF
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
def save_and_get_path(self, invoice_id: UUID, pdf_bytes: bytes) -> str:
"""
Sauvegarde PDF sur volume Docker.
Chemin: /app/data/invoices/{year}/{month}/{invoice_id}.pdf
LL#26: mettre à jour invoices.pdf_path EN DB après sauvegarde.
"""
def _generate_qr(self, content: str) -> str:
"""Génère QR code en base64 PNG pour intégration HTML."""
qr = qrcode.make(content)
buffer = BytesIO()
qr.save(buffer, format='PNG')
return base64.b64encode(buffer.getvalue()).decode()Génération Mensuelle (Celery via #63)
# Déclenchement depuis celery beat (app/celery_app.py)
# La génération est orchestrée par #10 rgz-beat, J+5 mensuel
# L'envoi email est pris en charge par #63 email-notification
@shared_task(name="rgz.reports.generate_invoices", queue="rgz.reports")
def generate_monthly_invoices(year: int, month: int):
"""
Génère les PDF de toutes les factures du mois précédent.
Appelé le J+5 de chaque mois.
"""
with get_db_for_task() as db:
service = InvoicePDFService()
invoices = db.query(Invoice).filter(
Invoice.period_start == date(year, month, 1),
Invoice.status == "draft"
).all()
for invoice in invoices:
try:
pdf_bytes = service.generate_pdf(invoice.id, db)
path = service.save_and_get_path(invoice.id, pdf_bytes)
# LL#26: DB first
invoice.pdf_path = path
invoice.status = "sent"
invoice.sent_at = datetime.utcnow()
db.commit()
# Envoyer par email (#63)
send_invoice_email.delay(str(invoice.id))
except Exception as e:
logger.error(f"Invoice PDF generation failed: {invoice.id} — {e}")Configuration
Variables d'environnement
# Factures PDF
INVOICE_STORAGE_PATH=/app/data/invoices
INVOICE_GENERATION_DAY=5 # J+5 mensuel
INVOICE_COMPANY_NAME=RGZ S.A.
INVOICE_COMPANY_ADDRESS=Cotonou, Bénin
INVOICE_COMPANY_RCCM=RB/COT/2020/B/XXXX
INVOICE_COMPANY_IFU=XXXXXXXXXX
INVOICE_KKIAPAY_COMMISSION_RATE=1.5 # Pour affichage dans PDF
# WeasyPrint (pas de config runtime, pré-installé dans Dockerfile)
WEASYPRINT_OPTIMIZE=trueDépendance Dockerfile
# docker/api/Dockerfile — WeasyPrint pré-installé
RUN apt-get install -y \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libcairo2 \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info
RUN pip install weasyprint==61.2 jinja2 qrcode PillowEndpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/invoices/{id}/pdf | — | 200 application/pdf | JWT | Télécharger PDF |
| GET | /api/v1/invoices | — | 200 {items, total, page, pages} | JWT | Lister factures |
| GET | /api/v1/invoices/{id} | — | 200 {invoice detail} | JWT | Détail facture |
| POST | /api/v1/invoices/generate | {year, month, reseller_id?} | 202 {task_id} | Admin JWT | Génération manuelle |
GET /api/v1/invoices/{id}/pdf
Response 200:
Content-Type: application/pdf
Content-Disposition: attachment; filename="facture-RGZ-2026-02-kossou.pdf"
Content-Length: 45678
[Binary PDF data]# Implémentation endpoint PDF
@router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise HTTPException(404)
# SEC-01: IDOR check
if current_user.role == "reseller":
if invoice.reseller_id != current_user.reseller_id:
raise HTTPException(403)
if not invoice.pdf_path or not os.path.exists(invoice.pdf_path):
# Générer à la volée si PDF absent
service = InvoicePDFService()
pdf_bytes = service.generate_pdf(invoice_id, db)
else:
with open(invoice.pdf_path, 'rb') as f:
pdf_bytes = f.read()
reseller_slug = db.query(Reseller.slug)\
.filter(Reseller.id == invoice.reseller_id).scalar()
filename = f"facture-RGZ-{invoice.period_start.strftime('%Y-%m')}-{reseller_slug}.pdf"
return StreamingResponse(
iter([pdf_bytes]),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)GET /api/v1/invoices?reseller_id=...&year=2026&month=2
Response 200:
{
"items": [
{
"id": "ee0e8400-e29b-41d4-a716-446655440040",
"reseller_id": "660e8400-e29b-41d4-a716-446655440001",
"reseller_name": "Kossou WiFi",
"period_start": "2026-02-01",
"period_end": "2026-02-28",
"total_amount_fcfa": 623500,
"reseller_share_fcfa": 307074,
"sla_credit_fcfa": 0,
"final_payment_fcfa": 307074,
"status": "sent",
"pdf_url": "/api/v1/invoices/ee0e8400-.../pdf",
"sent_at": "2026-03-05T08:00:00Z"
}
],
"total": 1,
"page": 1,
"pages": 1
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:invoice:{invoice_id} | Hash | 3600s | Cache métadonnées facture (évite DB query sur téléchargement) |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-01 | IDOR : revendeur ne télécharge que ses propres factures. invoice.reseller_id == current_user.reseller_id |
| SEC-04 | Tous montants dans PDF = entiers FCFA. Template Jinja2 utilise filtre ` |
| LL#26 | invoices.pdf_path et status='sent' mis à jour en DB APRES sauvegarde fichier PDF |
Commandes Utiles
# Télécharger une facture PDF
curl -H "Authorization: Bearer $TOKEN" \
-o "facture_fev2026.pdf" \
https://api-rgz.duckdns.org/api/v1/invoices/ee0e8400-.../pdf
# Lister les factures d'un revendeur
curl -H "Authorization: Bearer $TOKEN" \
"https://api-rgz.duckdns.org/api/v1/invoices?year=2026&month=2"
# Générer manuellement les factures d'un mois (admin)
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"year": 2026, "month": 2}' \
https://api-rgz.duckdns.org/api/v1/invoices/generate
# Vérifier PDFs générés sur volume
docker exec rgz-api ls -la /app/data/invoices/2026/02/
# Régénérer une facture spécifique
docker exec rgz-api python -c "
from app.services.invoice_pdf import InvoicePDFService
from app.database import SessionLocal
db = SessionLocal()
svc = InvoicePDFService()
import uuid
pdf = svc.generate_pdf(uuid.UUID('ee0e8400-...'), db)
print(f'PDF size: {len(pdf)} bytes')
"
# Vérifier WeasyPrint installé
docker exec rgz-api python -c "import weasyprint; print(weasyprint.__version__)"Implémentation TODO
- [ ] Service
app/services/invoice_pdf.pyavecInvoicePDFService - [ ] Template HTML Jinja2 avec charte graphique ACCESS (couleurs, Poppins)
- [ ] Méthode
generate_pdf(invoice_id, db)→ bytes - [ ] Méthode
save_and_get_path(invoice_id, pdf_bytes)→ str - [ ] Génération QR code authenticité (base64 PNG)
- [ ] Endpoint
GET /api/v1/invoices/{id}/pdf(StreamingResponse) - [ ] Endpoint
GET /api/v1/invoicesavec pagination et filtres - [ ] Endpoint
GET /api/v1/invoices/{id} - [ ] Endpoint
POST /api/v1/invoices/generate(admin, 202) - [ ] Tâche Celery
rgz.reports.generate_invoicesJ+5 mensuel (via #10) - [ ] Volume Docker
/app/data/invoicespersistant dans docker-compose.core.yml - [ ] Colonne
invoices.pdf_path(TEXT nullable) dans migration Alembic - [ ] Intégration envoi email #63 après génération PDF
- [ ] Cache Redis
rgz:invoice:{id}pour métadonnées - [ ] Tests SEC-01 : revendeur A ne télécharge pas facture de B → 403
- [ ] Tests génération PDF : vérifier taille > 0, contenu binaire valid
- [ ] Tests template Jinja2 : montants corrects, zéro float affiché
Dernière mise à jour: 2026-02-21