#23 — Edge Cases Billing
PLANIFIÉ
Priorité: 🟠 HAUTE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/billing_edge_cases.py
Dépendances: #19 moteur-facturation · #21 reconciliation
Description
Le module Edge Cases Billing gère 8 situations exceptionnelles du cycle de facturation qui ne sont pas couvertes par les flux nominaux. Ces cas peuvent survenir en production dans un réseau de paiement mobile à forte charge comme ACCESS (centaines de transactions simultanées, latences réseau variables, coupures USSD, etc.) et doivent être traités de manière déterministe pour éviter toute perte de revenu ou tout préjudice abonné.
Ces 8 cas ont été identifiés lors de l'analyse des risques du projet et de l'étude des patterns communs dans les systèmes de paiement mobile en Afrique de l'Ouest. Chaque cas dispose d'une stratégie de détection, d'une action corrective automatisée, et d'un mécanisme d'alerte NOC pour les situations nécessitant une intervention humaine.
Le service billing_edge_cases.py est une couche de gestion des exceptions qui s'appuie sur le moteur de facturation (#19) pour les recalculs et sur la réconciliation (#21) pour la détection des divergences. Il est invoqué par la tâche de réconciliation nocturne et peut également être déclenché manuellement depuis l'API admin.
Tous les montants manipulés sont des entiers FCFA (SEC-04). La séquence d'écriture respecte LL#26 : DB first, puis Redis, puis notifications.
Architecture Interne
Les 8 Cas Limites
CAS 1 — Paiement KKiaPay Timeout (>30s)
Situation: POST /api/v1/payments/initiate → KKiaPay ne répond pas dans les 30s
↓
Détection: timeout asyncio.wait_for(kkiapay.initiate(), timeout=30)
↓
Action:
- Marquer payment.status = 'failed' + details={'reason': 'TIMEOUT'}
- Retourner 504 à l'abonné avec message "Réessayez dans quelques instants"
- Log WARNING (pas d'alerte NOC pour cas isolé)
- Si >5 timeouts en 5min → alerte P2 NOC (service KKiaPay dégradé)CAS 2 — Double Paiement (même transaction)
Situation: Webhook KKiaPay reçu 2× pour même kkiapay_transaction_id
↓
Détection: check idempotency (SEC-03)
rgz:payment:kkiapay:{tx_id} existe déjà en Redis
OU kkiapay_transaction_id UNIQUE constraint violation en DB
↓
Action:
- Retourner 200 silencieusement (acknowledgment webhook)
- NE PAS créer un second paiement ou voucher
- Logger INFO "Duplicate webhook ignored: {tx_id}"
- Aucune alerte NOC (comportement normal KKiaPay)CAS 3 — Session RADIUS Expirée Avant Fin Forfait
Situation: FreeRADIUS session timeout (Interim-Update manquant) mais
forfait encore valide en DB (temps restant > 0)
↓
Détection: (réconciliation J-1) session.is_active=False ET
session.bytes_out < forfait.volume_mb × 1024 × 1024
ET session_stop - session_start < forfait.duration_hours × 3600
↓
Action:
- Créer FraudAlert(type='SESSION_PREMATURE_CLOSE', severity='MEDIUM')
- Calculer temps/volume non consommé
- Si > 20% forfait restant → créer credit_note (bon de compensation)
credit_fcfa = montant_forfait × (1 - usage_ratio) -- entier FCFA
- Notifier abonné par SMS: "Votre session a été interrompue. Crédit appliqué."
- Alerte P2 si pattern répété (même NAS-ID > 3 fois en 24h)CAS 4 — Voucher Utilisé Simultanément (Race Condition)
Situation: 2 appareils soumettent le même code voucher au même moment
↓
Détection: Redis WATCH/EXEC échoue (WatchError) lors de redeem_voucher()
↓
Action:
- Premier arrivé gagne (EXEC succès → session créée)
- Second arrivé → 409 ERR_VOUCHER_ALREADY_USED
- Log INFO "Voucher race condition resolved: {code} → winner: {subscriber_id}"
- Aucune alerte NOC (comportement normal, traité par WATCH/EXEC SEC-02)CAS 5 — Revendeur Suspendu Pendant Session Active
Situation: Admin suspend un revendeur (reseller.status → 'suspended')
alors que des sessions RADIUS sont actives sur ses hotspots
↓
Détection: Event hook sur UPDATE resellers SET status='suspended'
↓
Action:
- Récupérer toutes sessions actives sur NAS-IDs du revendeur
- Pour chaque session: envoyer CoA Disconnect-Request à FreeRADIUS (#6)
- Marquer sessions comme is_active=False
- Notifier abonnés affectés par SMS: "Le service est temporairement indisponible"
- Logger l'événement dans audit_trail (#48)
- Si > 50 abonnés affectés → alerte P1 NOCCAS 6 — Connexion Coupée Pendant Réconciliation
Situation: La tâche Celery de réconciliation se termine sans compléter
(crash worker, timeout, restart conteneur)
↓
Détection: reconciliation_reports.status = NULL pour date J-1 au moment
du démarrage du worker Celery (vérification au boot)
↓
Action:
- `acks_late=True` sur la tâche (re-queue automatique si ACK non envoyé)
- Si rapport partiel en DB → DELETE + relancer depuis zéro
- Si rapport complet mais non ACK → skip (idempotency via report_date UNIQUE)
- Alerte P2 si réconciliation manquante détectée à J+1 00:30CAS 7 — Divergence Montant KKiaPay vs DB (±1 FCFA)
Situation: Montant dans webhook KKiaPay ≠ payment.amount_fcfa en DB
(ex: KKiaPay retourne 499, DB contient 500 — arrondi provider)
↓
Détection: Réconciliation J-1: webhook_amount ≠ payment.amount_fcfa
↓
Action:
- Si divergence ≤ 1 FCFA: accepter (arrondi opérateur mobile, logguer)
- Si divergence > 1 FCFA: FraudAlert(AMOUNT_DIVERGENCE, HIGH)
- Enregistrer divergence dans reconciliation_report.details
- Si pattern répété (même montant divergent ×10): alerte P1 (bug systémique)CAS 8 — Forfait Expiré Pendant Session Active
Situation: session.session_start + forfait.duration_hours < now()
mais session.is_active = True (Accounting-Stop non reçu)
↓
Détection: Tâche Celery `rgz.billing.expire_sessions` every 15min
↓
Action:
- Envoyer CoA Disconnect-Request à FreeRADIUS (#6)
- Marquer session is_active=False, session_stop=now()
- Logger INFO "Session expired and terminated: {session_id}"
- Aucune alerte NOC (comportement nominal)Service (app/services/billing_edge_cases.py)
class BillingEdgeCaseService:
def __init__(self, db: Session, redis_client, radius_client):
self.db = db
self.redis = redis_client
self.radius = radius_client
async def handle_kkiapay_timeout(self, payment_id: UUID) -> dict:
"""CAS 1: Mark payment failed, check for pattern."""
def handle_duplicate_webhook(self, kkiapay_tx_id: str) -> dict:
"""CAS 2: Idempotency check, return True si doublon."""
async def handle_premature_session_close(
self, session: RadiusSession
) -> dict:
"""CAS 3: Calculer crédit compensation si > 20% restant."""
async def handle_reseller_suspension(
self, reseller_id: UUID
) -> dict:
"""CAS 5: Déconnecter toutes sessions actives du revendeur."""
async def handle_expired_sessions(self) -> int:
"""CAS 8: Terminer toutes sessions dont forfait est expiré."""
async def detect_amount_divergence(
self, payment_id: UUID, webhook_amount: int
) -> dict:
"""CAS 7: Comparer webhook_amount vs DB amount. SEC-04: entiers."""Configuration
Variables d'environnement
# Edge Cases Billing
EDGE_CASE_KKIAPAY_TIMEOUT_SECONDS=30
EDGE_CASE_TIMEOUT_ALERT_THRESHOLD=5 # >5 timeouts en 5min → alerte P2
EDGE_CASE_SESSION_CREDIT_THRESHOLD=0.20 # >20% forfait restant → credit
EDGE_CASE_AMOUNT_DIVERGENCE_MAX_FCFA=1 # Tolérance ±1 FCFA (arrondi opérateur)
EDGE_CASE_SUSPEND_ALERT_THRESHOLD=50 # >50 abonnés affectés → P1
# Celery
CELERY_EXPIRE_SESSIONS_INTERVAL_MINUTES=15Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/billing/edge-cases?type=&from=&to= | — | 200 {items, total, page, pages} | Admin JWT | Historique incidents |
| GET | /api/v1/billing/edge-cases/stats | — | 200 {by_type, by_day} | Admin JWT | Statistiques |
| POST | /api/v1/billing/edge-cases/expire-sessions | — | 202 {count} | Admin JWT | Déclencher manuellement |
| POST | /api/v1/billing/edge-cases/reseller-suspend/{id} | — | 202 {sessions_terminated} | Admin JWT | Traiter suspension |
GET /api/v1/billing/edge-cases/stats
Response 200:
{
"period": {"from": "2026-02-01", "to": "2026-02-21"},
"by_type": {
"KKIAPAY_TIMEOUT": 12,
"DUPLICATE_WEBHOOK": 34,
"PREMATURE_SESSION_CLOSE": 3,
"VOUCHER_RACE_CONDITION": 8,
"RESELLER_SUSPENDED": 0,
"RECONCILIATION_INTERRUPTED": 1,
"AMOUNT_DIVERGENCE": 2,
"SESSION_EXPIRED_ACTIVE": 47
},
"total_credit_issued_fcfa": 1500,
"total_sessions_force_terminated": 47
}Celery Tasks
| Task | Schedule | Queue | Description |
|---|---|---|---|
rgz.billing.expire_sessions | Every 15min | rgz.billing | CAS 8: Terminer sessions forfait expiré |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-04 | CAS 3 et 7 : calcul crédits et comparaison montants en entiers FCFA uniquement |
| SEC-02 | CAS 4 : doublon voucher protégé par Redis WATCH/MULTI/EXEC (hérité de #20) |
| SEC-03 | CAS 2 : idempotency webhook via kkiapay_transaction_id UNIQUE |
| LL#26 | Tous les CAS : écriture DB first, puis Redis/notifications |
| LL#8 | Tous les IDs générés : UUID v4 |
Commandes Utiles
# Voir historique edge cases
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://api-rgz.duckdns.org/api/v1/billing/edge-cases?type=KKIAPAY_TIMEOUT"
# Déclencher expiration sessions manuellement
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
https://api-rgz.duckdns.org/api/v1/billing/edge-cases/expire-sessions
# Voir sessions expirées mais encore actives (CAS 8)
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT rs.id, rs.subscriber_ref, f.duration_hours, rs.session_start,
(rs.session_start + f.duration_hours * INTERVAL '1 hour') as should_expire
FROM radius_sessions rs
JOIN forfaits f ON f.id = rs.forfait_id
WHERE rs.is_active = TRUE
AND rs.session_start + f.duration_hours * INTERVAL '1 hour' < NOW();"
# Vérifier paiements timeout
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT COUNT(*), DATE(created_at) FROM payments
WHERE status='failed' AND (kkiapay_response->>'reason') = 'TIMEOUT'
GROUP BY DATE(created_at) ORDER BY 2 DESC LIMIT 7;"
# Logs edge cases
docker logs rgz-api --tail=200 | grep "EDGE_CASE\|edge_case"Implémentation TODO
- [ ] Service
app/services/billing_edge_cases.pyavec les 8 méthodes - [ ] CAS 1: Handler timeout KKiaPay avec compteur pattern (5 timeouts → alerte P2)
- [ ] CAS 2: Wrapper idempotency sur traitement webhook (hérité SEC-03 de #21)
- [ ] CAS 3: Détection session prématurée dans réconciliation + calcul credit_fcfa entier
- [ ] CAS 4: Déjà géré par WATCH/EXEC dans #20 — documenter seulement
- [ ] CAS 5: Hook post-UPDATE reseller.status → CoA Disconnect bulk
- [ ] CAS 6: Vérification
acks_late=True+ idempotency via UNIQUE report_date - [ ] CAS 7: Comparaison montant webhook vs DB (tolérance ±1 FCFA, SEC-04)
- [ ] CAS 8: Tâche Celery
rgz.billing.expire_sessionsevery 15min - [ ] Endpoint
GET /api/v1/billing/edge-cases - [ ] Endpoint
GET /api/v1/billing/edge-cases/stats - [ ] Endpoint
POST /api/v1/billing/edge-cases/expire-sessions(admin) - [ ] Table
billing_edge_case_logspour historique des incidents - [ ] Tests unitaires chaque CAS avec données mockées
- [ ] Tests intégration CAS 5 (suspension revendeur → sessions terminées)
- [ ] Tests CAS 8 (session expirée → CoA envoyé)
Dernière mise à jour: 2026-02-21