Skip to content

#19 — Moteur Facturation

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/billing.py

Dépendances: #4 rgz-db · #10 rgz-beat


Description

Le Moteur Facturation est le hub central de toute la logique financière du réseau ACCESS. Il calcule le split de revenus entre RGZ et chaque revendeur partenaire selon la règle 50/50, après déduction de la commission KKiaPay de 1,5 %. Il génère et maintient les enregistrements de facturation dans la table invoices, agrège les transactions par période, et sert de source de vérité pour tous les rapports financiers.

Le moteur suit une règle d'or absolue : tous les montants sont exprimés en entiers FCFA. Aucun calcul intermédiaire ne doit utiliser des flottants. La division entière Python (//) est utilisée pour tous les arrondis, avec la différence d'arrondi systématiquement attribuée à RGZ. Cette règle (SEC-04) prévient les erreurs d'arrondi cumulatives qui pourraient entraîner des divergences comptables sur des milliers de transactions.

Le moteur est invoqué de deux manières : en temps réel lors de chaque paiement KKiaPay complété (via webhook), et en batch quotidien lors de la réconciliation (#21) pour consolider les agrégats mensuels. Les données de facturation alimentent la génération des factures PDF (#24), les dashboards revendeurs (#51), et les rapports ARCEP (#47 et #71).

La séquence d'écriture respecte strictement LL#26 : écriture en DB d'abord (table invoices ou mise à jour payments), puis mise en cache Redis, puis retour à l'appelant. En cas d'échec Redis, la donnée reste cohérente en DB.


Architecture Interne

Formule Split Revenus

Exemple: forfait 500 FCFA, provider MTN_MOMO

Étape 1 — Commission KKiaPay:
    commission = amount_fcfa × 15 // 1000  (1.5%, division entière)
    commission = 500 × 15 // 1000 = 7 FCFA

Étape 2 — Montant net:
    net = amount_fcfa - commission
    net = 500 - 7 = 493 FCFA

Étape 3 — Split 50/50:
    reseller_share = net // 2            = 246 FCFA  (arrondi inférieur)
    rgz_share      = net - reseller_share = 247 FCFA  (RGZ absorbe diff)

Total vérification: 246 + 247 + 7 = 500 FCFA ✓
python
# app/services/billing.py — Fonction principale de calcul

def calculate_split(amount_fcfa: int) -> dict:
    """
    Calcule le split de revenus pour un montant donné.
    Tous les montants sont des entiers FCFA.
    SEC-04: jamais de float.
    """
    assert isinstance(amount_fcfa, int), "amount_fcfa must be int (SEC-04)"
    assert amount_fcfa > 0, "amount_fcfa must be positive"

    # Commission KKiaPay: 1.5% = 15/1000
    commission_fcfa = amount_fcfa * 15 // 1000  # Arrondi inférieur entier

    net_fcfa = amount_fcfa - commission_fcfa

    reseller_share_fcfa = net_fcfa // 2         # Arrondi inférieur
    rgz_share_fcfa = net_fcfa - reseller_share_fcfa  # Reste → RGZ

    return {
        "amount_fcfa": amount_fcfa,
        "commission_fcfa": commission_fcfa,
        "net_fcfa": net_fcfa,
        "reseller_share_fcfa": reseller_share_fcfa,
        "rgz_share_fcfa": rgz_share_fcfa,
    }

# Exemples de calculs:
# 500 FCFA: commission=7, net=493, reseller=246, rgz=247
# 1000 FCFA: commission=15, net=985, reseller=492, rgz=493
# 200 FCFA: commission=3, net=197, reseller=98, rgz=99
# 100 FCFA: commission=1, net=99, reseller=49, rgz=50

Flux de Facturation

Webhook KKiaPay → payment.status = completed

BillingService.process_payment(payment_id)

1. Récupérer payment depuis DB
2. calculate_split(amount_fcfa)
3. LL#26: Mettre à jour payment (commission, net, shares) en DB
4. Trouver ou créer invoice pour cette période (reseller_id, year, month)
5. Agrémenter totaux invoice (+= montants)
6. Sauvegarder invoice en DB
7. Invalider cache Redis rgz:billing:monthly:{reseller_id}:{YYYY-MM}
8. Retour {invoice_id, split}

Service (app/services/billing.py)

python
from decimal import Decimal
from uuid import UUID
from datetime import date
from sqlalchemy.orm import Session

class BillingService:

    def __init__(self, db: Session, redis_client):
        self.db = db
        self.redis = redis_client

    def calculate_split(self, amount_fcfa: int) -> dict:
        """Calcul split 50/50 après commission KKiaPay 1.5% — SEC-04"""

    async def process_payment(
        self,
        payment_id: UUID
    ) -> dict:
        """
        Traite un paiement complété:
        - Calcule split
        - Crée/update invoice du mois courant
        - LL#26: DB first → cache → retour
        """

    async def get_monthly_summary(
        self,
        reseller_id: UUID,
        year: int,
        month: int
    ) -> dict:
        """
        Agrégats mensuels d'un revendeur.
        Cache: rgz:billing:monthly:{reseller_id}:{YYYY-MM} TTL=3600s
        """

    async def get_or_create_invoice(
        self,
        reseller_id: UUID,
        period_start: date,
        period_end: date
    ) -> Invoice:
        """
        Trouve ou crée la facture pour une période donnée.
        Status initial: 'draft'
        """

    async def apply_sla_credit(
        self,
        invoice_id: UUID,
        credit_fcfa: int,
        reason: str
    ) -> Invoice:
        """
        Applique un crédit SLA à une facture (depuis #25).
        Décrément reseller_share_fcfa (à payer par RGZ).
        LL#26: DB first.
        """

    def validate_amounts(self, split: dict) -> bool:
        """
        Vérifie que: commission + reseller_share + rgz_share == amount
        Détecte toute erreur d'arrondi.
        """
        total = (
            split["commission_fcfa"] +
            split["reseller_share_fcfa"] +
            split["rgz_share_fcfa"]
        )
        return total == split["amount_fcfa"]

Configuration

Variables d'environnement

bash
# Billing
KKIAPAY_COMMISSION_RATE_PERMILLE=15    # 15‰ = 1.5%
BILLING_SPLIT_RESELLER_PERCENT=50      # 50% part revendeur
BILLING_SPLIT_RGZ_PERCENT=50           # 50% part RGZ

# Cache
BILLING_CACHE_TTL_SECONDS=3600
BILLING_MONTHLY_CACHE_TTL_SECONDS=300  # 5min pour real-time dashboard

# Factures
INVOICE_GENERATION_DAY=5               # J+5 du mois suivant

Table des Commissions par Montant

ForfaitCommission KKiaPayNetPart RevendeurPart RGZ
100 FCFA1 FCFA99 FCFA49 FCFA50 FCFA
200 FCFA3 FCFA197 FCFA98 FCFA99 FCFA
500 FCFA7 FCFA493 FCFA246 FCFA247 FCFA
1000 FCFA15 FCFA985 FCFA492 FCFA493 FCFA
2000 FCFA30 FCFA1970 FCFA985 FCFA985 FCFA
5000 FCFA75 FCFA4925 FCFA2462 FCFA2463 FCFA

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/billing/summary?reseller_id=&month=200 {summary}Admin/Revendeur JWTRésumé mensuel
GET/api/v1/billing/transactions?reseller_id=200 {items, total, page, pages}Admin/Revendeur JWTListe transactions
GET/api/v1/billing/invoices?reseller_id=&year=&month=200 {items, total, page, pages}Admin/Revendeur JWTListe factures
GET/api/v1/billing/invoices/{id}200 {invoice detail}Admin/Revendeur JWTDétail facture
POST/api/v1/billing/calculate{amount_fcfa}200 {split}Admin JWTSimulation calcul

GET /api/v1/billing/summary?reseller_id=...&month=2026-02

Response 200:

json
{
  "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
  "period": "2026-02",
  "total_payments": 1247,
  "total_revenue_fcfa": 623500,
  "kkiapay_commission_fcfa": 9352,
  "net_revenue_fcfa": 614148,
  "rgz_share_fcfa": 307074,
  "reseller_share_fcfa": 307074,
  "sla_credit_fcfa": 0,
  "final_reseller_payment_fcfa": 307074,
  "invoice_status": "draft"
}

GET /api/v1/billing/transactions?reseller_id=...&page=1

Response 200:

json
{
  "items": [
    {
      "id": "770e8400-e29b-41d4-a716-446655440002",
      "subscriber_ref": "RGZ-0197979964",
      "amount_fcfa": 500,
      "commission_fcfa": 7,
      "net_fcfa": 493,
      "reseller_share_fcfa": 246,
      "rgz_share_fcfa": 247,
      "provider": "MTN_MOMO",
      "status": "completed",
      "completed_at": "2026-02-21T10:31:15Z"
    }
  ],
  "total": 1247,
  "page": 1,
  "pages": 63
}

POST /api/v1/billing/calculate (simulation)

Request:

json
{"amount_fcfa": 750}

Response 200:

json
{
  "amount_fcfa": 750,
  "commission_fcfa": 11,
  "net_fcfa": 739,
  "reseller_share_fcfa": 369,
  "rgz_share_fcfa": 370,
  "verification": "750 == 11 + 369 + 370 ✓"
}

Redis Keys

CléTypeTTLUsage
rgz:billing:monthly:{reseller_id}:{YYYY-MM}Hash3600sAgrégats mensuels mis en cache
rgz:billing:realtime:{reseller_id}Hash300sCompteurs temps réel (dashboard)
rgz:invoice:{invoice_id}Hash3600sCache données facture

Sécurité

RègleImplémentation
SEC-04Tous montants en entiers FCFA, zéro float. Assertion Python isinstance(amount, int)
SEC-01IDOR : revendeur ne voit que ses propres factures et transactions (reseller_id == current_user.reseller_id)
LL#26DB write first : invoice mise à jour en DB avant invalidation cache Redis
LL#8IDs UUID v4 partout (payment_id, invoice_id, reseller_id)
LL#16CHECK constraints sur InvoiceStatus et PaymentStatus en DB

Commandes Utiles

bash
# Voir résumé billing d'un revendeur
curl -H "Authorization: Bearer $TOKEN" \
  "https://api-rgz.duckdns.org/api/v1/billing/summary?reseller_id=660e8400-...&month=2026-02"

# Simuler calcul split pour un montant
curl -X POST \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount_fcfa": 1500}' \
  https://api-rgz.duckdns.org/api/v1/billing/calculate

# Vérifier cache mensuel Redis
docker exec rgz-redis redis-cli HGETALL "rgz:billing:monthly:660e8400-...:2026-02"

# Vérifier totaux en DB
docker exec rgz-db psql -U rgz -d rgzdb \
  -c "SELECT reseller_id,
             SUM(amount_fcfa) AS total,
             SUM(kkiapay_commission_fcfa) AS commission,
             SUM(reseller_share_fcfa) AS reseller
      FROM payments
      WHERE status='completed'
        AND DATE_TRUNC('month', completed_at) = '2026-02-01'
      GROUP BY reseller_id;"

# Vérifier cohérence split (doit être 0 divergence)
docker exec rgz-db psql -U rgz -d rgzdb \
  -c "SELECT COUNT(*) as errors FROM payments
      WHERE amount_fcfa != (kkiapay_commission_fcfa + reseller_share_fcfa + rgz_share_fcfa)
        AND status = 'completed';"

Implémentation TODO

  • [ ] Service app/services/billing.pycalculate_split(amount_fcfa: int) avec assertion SEC-04
  • [ ] BillingService.process_payment() appelé depuis webhook KKiaPay
  • [ ] BillingService.get_monthly_summary() avec cache Redis
  • [ ] BillingService.get_or_create_invoice() (upsert mensuel)
  • [ ] BillingService.apply_sla_credit() (depuis #25)
  • [ ] BillingService.validate_amounts() — vérification d'intégrité arrondi
  • [ ] Endpoint GET /api/v1/billing/summary
  • [ ] Endpoint GET /api/v1/billing/transactions
  • [ ] Endpoint GET /api/v1/billing/invoices
  • [ ] Endpoint GET /api/v1/billing/invoices/{id}
  • [ ] Endpoint POST /api/v1/billing/calculate (admin uniquement)
  • [ ] Colonnes DB payments: kkiapay_commission_fcfa, net_amount_fcfa, reseller_share_fcfa, rgz_share_fcfa
  • [ ] Table invoices avec toutes colonnes financières (entiers uniquement)
  • [ ] CHECK constraints sur toutes colonnes enum (LL#16)
  • [ ] Index sur payments(reseller_id, completed_at) pour agrégats performants
  • [ ] Tests unitaires calculate_split() avec table de vérification (toutes valeurs)
  • [ ] Tests SEC-04 (rejet float → AssertionError)
  • [ ] Tests SEC-01 (revendeur A ne voit pas factures de B)
  • [ ] Tests cohérence : sum(commission + reseller + rgz) == amount pour 1000 valeurs aléatoires

Dernière mise à jour: 2026-02-21

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