Skip to content

#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)

python
# 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)

python
# 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

bash
# 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=true

Dépendance Dockerfile

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 Pillow

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/invoices/{id}/pdf200 application/pdfJWTTélécharger PDF
GET/api/v1/invoices200 {items, total, page, pages}JWTLister factures
GET/api/v1/invoices/{id}200 {invoice detail}JWTDétail facture
POST/api/v1/invoices/generate{year, month, reseller_id?}202 {task_id}Admin JWTGé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]
python
# 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:

json
{
  "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éTypeTTLUsage
rgz:invoice:{invoice_id}Hash3600sCache métadonnées facture (évite DB query sur téléchargement)

Sécurité

RègleImplémentation
SEC-01IDOR : revendeur ne télécharge que ses propres factures. invoice.reseller_id == current_user.reseller_id
SEC-04Tous montants dans PDF = entiers FCFA. Template Jinja2 utilise filtre `
LL#26invoices.pdf_path et status='sent' mis à jour en DB APRES sauvegarde fichier PDF

Commandes Utiles

bash
# 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.py avec InvoicePDFService
  • [ ] 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/invoices avec pagination et filtres
  • [ ] Endpoint GET /api/v1/invoices/{id}
  • [ ] Endpoint POST /api/v1/invoices/generate (admin, 202)
  • [ ] Tâche Celery rgz.reports.generate_invoices J+5 mensuel (via #10)
  • [ ] Volume Docker /app/data/invoices persistant 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

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone