#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 ✓# 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=50Flux 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)
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
# 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 suivantTable des Commissions par Montant
| Forfait | Commission KKiaPay | Net | Part Revendeur | Part RGZ |
|---|---|---|---|---|
| 100 FCFA | 1 FCFA | 99 FCFA | 49 FCFA | 50 FCFA |
| 200 FCFA | 3 FCFA | 197 FCFA | 98 FCFA | 99 FCFA |
| 500 FCFA | 7 FCFA | 493 FCFA | 246 FCFA | 247 FCFA |
| 1000 FCFA | 15 FCFA | 985 FCFA | 492 FCFA | 493 FCFA |
| 2000 FCFA | 30 FCFA | 1970 FCFA | 985 FCFA | 985 FCFA |
| 5000 FCFA | 75 FCFA | 4925 FCFA | 2462 FCFA | 2463 FCFA |
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/billing/summary?reseller_id=&month= | — | 200 {summary} | Admin/Revendeur JWT | Résumé mensuel |
| GET | /api/v1/billing/transactions?reseller_id= | — | 200 {items, total, page, pages} | Admin/Revendeur JWT | Liste transactions |
| GET | /api/v1/billing/invoices?reseller_id=&year=&month= | — | 200 {items, total, page, pages} | Admin/Revendeur JWT | Liste factures |
| GET | /api/v1/billing/invoices/{id} | — | 200 {invoice detail} | Admin/Revendeur JWT | Détail facture |
| POST | /api/v1/billing/calculate | {amount_fcfa} | 200 {split} | Admin JWT | Simulation calcul |
GET /api/v1/billing/summary?reseller_id=...&month=2026-02
Response 200:
{
"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:
{
"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:
{"amount_fcfa": 750}Response 200:
{
"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é | Type | TTL | Usage |
|---|---|---|---|
rgz:billing:monthly:{reseller_id}:{YYYY-MM} | Hash | 3600s | Agrégats mensuels mis en cache |
rgz:billing:realtime:{reseller_id} | Hash | 300s | Compteurs temps réel (dashboard) |
rgz:invoice:{invoice_id} | Hash | 3600s | Cache données facture |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-04 | Tous montants en entiers FCFA, zéro float. Assertion Python isinstance(amount, int) |
| SEC-01 | IDOR : revendeur ne voit que ses propres factures et transactions (reseller_id == current_user.reseller_id) |
| LL#26 | DB write first : invoice mise à jour en DB avant invalidation cache Redis |
| LL#8 | IDs UUID v4 partout (payment_id, invoice_id, reseller_id) |
| LL#16 | CHECK constraints sur InvoiceStatus et PaymentStatus en DB |
Commandes Utiles
# 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.py—calculate_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
invoicesavec 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) == amountpour 1000 valeurs aléatoires
Dernière mise à jour: 2026-02-21