Skip to content

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

CAS 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:30

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

python
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

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

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/billing/edge-cases?type=&from=&to=200 {items, total, page, pages}Admin JWTHistorique incidents
GET/api/v1/billing/edge-cases/stats200 {by_type, by_day}Admin JWTStatistiques
POST/api/v1/billing/edge-cases/expire-sessions202 {count}Admin JWTDéclencher manuellement
POST/api/v1/billing/edge-cases/reseller-suspend/{id}202 {sessions_terminated}Admin JWTTraiter suspension

GET /api/v1/billing/edge-cases/stats

Response 200:

json
{
  "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

TaskScheduleQueueDescription
rgz.billing.expire_sessionsEvery 15minrgz.billingCAS 8: Terminer sessions forfait expiré

Sécurité

RègleImplémentation
SEC-04CAS 3 et 7 : calcul crédits et comparaison montants en entiers FCFA uniquement
SEC-02CAS 4 : doublon voucher protégé par Redis WATCH/MULTI/EXEC (hérité de #20)
SEC-03CAS 2 : idempotency webhook via kkiapay_transaction_id UNIQUE
LL#26Tous les CAS : écriture DB first, puis Redis/notifications
LL#8Tous les IDs générés : UUID v4

Commandes Utiles

bash
# 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.py avec 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_sessions every 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_logs pour 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

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