#21 — Réconciliation
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE C · Conteneur: rgz-beat · Code: app/tasks/billing.py
Dépendances: #10 rgz-beat · #4 rgz-db
Description
Le module Réconciliation effectue chaque nuit à 00:15 UTC un rapprochement comptable automatique entre deux sources de vérité indépendantes : les paiements enregistrés par KKiaPay et les sessions d'accès créées par FreeRADIUS. Tout écart entre ces deux jeux de données constitue une anomalie financière potentielle qui doit être détectée et signalée.
La réconciliation est fondamentale pour la confiance du réseau ACCESS. Sans elle, un paiement KKiaPay complété pourrait ne jamais activer de session (abonné lésé), ou une session RADIUS pourrait être active sans paiement correspondant (perte de revenu RGZ/revendeur). Elle détecte également les doublons de paiement, les remboursements non répercutés, et les sessions fantômes.
L'opération couvre J-1 (la journée précédente, UTC) pour garantir que tous les webhooks KKiaPay en transit ont été reçus avant l'heure de réconciliation. Le rapport produit est persisté dans la table reconciliation_reports et accessible via API. Si des divergences dépassent un seuil configurable, une alerte P1 est automatiquement créée dans le système d'escalade (#58) et le NOC est notifié par SMS.
Conformément à SEC-03, l'idempotency KKiaPay est vérifiée lors du traitement de chaque transaction : si un kkiapay_transaction_id apparaît deux fois dans les données KKiaPay de J-1, la deuxième occurrence est flaggée comme doublon et exclue du calcul. La clé rgz:payment:kkiapay:{tx_id} en Redis sert de garde-fou rapide avant la vérification DB.
Architecture Interne
Algorithme de Réconciliation
[Tâche Celery] rgz.billing.reconcile — Daily 00:15 UTC
↓
Définir période: J-1 (00:00:00 UTC → 23:59:59 UTC)
↓
SOURCE A: Paiements KKiaPay (table payments WHERE date=J-1 AND status IN ('completed','refunded'))
SOURCE B: Sessions RADIUS (table radius_sessions WHERE session_start >= J-1 00:00 AND < J 00:00)
↓
MATCHING: payment.kkiapay_transaction_id ↔ radius_sessions.payment_id
↓
DIVERGENCES DÉTECTÉES:
Type 1: Paiement sans session
payment.status='completed' ET aucune session correspondante
→ Abonné a payé mais n'a pas eu de session → CRITIQUE
Type 2: Session sans paiement
session active ET aucun payment.kkiapay_transaction_id correspondant
→ Session gratuite non autorisée → CRITIQUE
Type 3: Doublon paiement (même kkiapay_transaction_id ×2)
→ KKiaPay a envoyé le webhook deux fois → double débit potentiel
Type 4: Divergence montant
payment.amount_fcfa ≠ forfait.price_fcfa pour cette session
→ Montant incorrect (edge case #23)
Type 5: Remboursement sans annulation session
payment.status='refunded' ET session toujours is_active=True
→ Session doit être terminée après remboursement
↓
Générer reconciliation_report (DB INSERT)
↓
Si divergences > seuil → Alerte P1 → SMS NOC (#58, #61)
↓
Mettre à jour cache Redis résumé du rapportTâche Celery (app/tasks/billing.py)
from celery import shared_task
from datetime import date, timedelta
from app.database import get_db_for_task
from app.services.reconciliation import ReconciliationService
@shared_task(
name="rgz.billing.reconcile",
queue="rgz.billing",
max_retries=3,
default_retry_delay=300, # 5min avant retry
acks_late=True, # Confirmer seulement après succès
)
def run_reconciliation(target_date: str = None):
"""
Réconciliation quotidienne KKiaPay ↔ RADIUS.
target_date: format YYYY-MM-DD (défaut: hier)
"""
if target_date is None:
reconcile_date = date.today() - timedelta(days=1)
else:
reconcile_date = date.fromisoformat(target_date)
with get_db_for_task() as db:
service = ReconciliationService(db)
report = service.run(reconcile_date)
if report.status in ("warning", "error"):
# Alerte NOC via #58 incident-escalation
from app.services.incident import IncidentService
IncidentService(db).create_incident(
priority="P1" if report.status == "error" else "P2",
title=f"Divergence réconciliation {reconcile_date}",
description=f"{report.unmatched_payments} paiements sans session, "
f"{report.unmatched_sessions} sessions sans paiement",
details=report.details
)
return {
"date": str(reconcile_date),
"status": report.status,
"matched": report.matched_count,
"unmatched_payments": report.unmatched_payments,
"unmatched_sessions": report.unmatched_sessions
}Service Réconciliation (app/services/reconciliation.py)
class ReconciliationService:
def run(self, reconcile_date: date) -> ReconciliationReport:
"""Exécute la réconciliation complète pour une date."""
def _fetch_payments(self, reconcile_date: date) -> list[dict]:
"""
Récupère paiements KKiaPay de J-1.
Vérifie doublon kkiapay_transaction_id (SEC-03).
"""
def _fetch_sessions(self, reconcile_date: date) -> list[dict]:
"""Récupère sessions RADIUS de J-1."""
def _match(self, payments: list, sessions: list) -> dict:
"""
Matching par payment_id / kkiapay_transaction_id.
Retourne: {matched, orphan_payments, orphan_sessions}
"""
def _compute_status(self, unmatched_p: int, unmatched_s: int) -> str:
"""
ok: 0 divergences
warning: divergences < seuil
error: divergences > seuil
"""
def _persist_report(self, report_data: dict) -> ReconciliationReport:
"""LL#26: INSERT reconciliation_reports en DB first."""Planification Celery Beat (app/celery_app.py)
from celery.schedules import crontab
beat_schedule = {
"rgz.billing.reconcile": {
"task": "rgz.billing.reconcile",
"schedule": crontab(hour=0, minute=15), # 00:15 UTC daily
"options": {"queue": "rgz.billing"},
},
# ... autres tâches
}Configuration
Variables d'environnement
# Réconciliation
RECONCILIATION_WARNING_THRESHOLD=5 # Nb divergences → warning
RECONCILIATION_ERROR_THRESHOLD=20 # Nb divergences → error + alerte P1
RECONCILIATION_AMOUNT_TOLERANCE_FCFA=0 # Tolérance divergence montant (0 = strict)
# Celery Beat
CELERY_RECONCILE_HOUR=0
CELERY_RECONCILE_MINUTE=15
# KKiaPay
KKIAPAY_SECRET_KEY=sk_prod_xxxxxx # Pour vérification SEC-03Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/billing/reconciliation?date= | — | 200 {report} | Admin JWT | Rapport d'un jour |
| GET | /api/v1/billing/reconciliation/history | — | 200 {items, total, page, pages} | Admin JWT | Historique |
| POST | /api/v1/billing/reconciliation/run | {date?} | 202 {task_id} | Admin JWT | Déclenchement manuel |
GET /api/v1/billing/reconciliation?date=2026-02-20
Response 200:
{
"id": "aa0e8400-e29b-41d4-a716-446655440030",
"report_date": "2026-02-20",
"payments_count": 1547,
"sessions_count": 1543,
"matched_count": 1541,
"unmatched_payments": 4,
"unmatched_sessions": 2,
"total_divergence_fcfa": 1986,
"status": "warning",
"details": {
"orphan_payment_ids": [
"bb0e8400-e29b-41d4-a716-446655440031",
"cc0e8400-e29b-41d4-a716-446655440032"
],
"orphan_session_ids": [
"dd0e8400-e29b-41d4-a716-446655440033"
],
"duplicate_kkiapay_tx_ids": [],
"amount_divergences": []
},
"created_at": "2026-02-21T00:17:43Z"
}POST /api/v1/billing/reconciliation/run
Request:
{"date": "2026-02-19"}Response 202:
{
"task_id": "celery-task-uuid-12345",
"date": "2026-02-19",
"message": "Réconciliation lancée en arrière-plan",
"check_status_url": "/api/v1/billing/reconciliation?date=2026-02-19"
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:payment:kkiapay:{tx_id} | String | 86400s | Idempotency webhook KKiaPay (SEC-03) |
rgz:reconciliation:last | Hash | 3600s | Résultat dernière réconciliation (dashboard) |
rgz:reconciliation:{date} | String | 86400s | Flag "déjà réconcilié" pour cette date |
Celery Tasks
| Task | Schedule | Queue | Description |
|---|---|---|---|
rgz.billing.reconcile | Daily 00:15 UTC | rgz.billing | Réconciliation automatique J-1 |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-03 | Webhook KKiaPay : vérifier x-kkiapay-secret header avec hmac.compare_digest(). Rejeter si kkiapay_transaction_id déjà en DB |
| SEC-03 | Idempotency : avant toute écriture, vérifier rgz:payment:kkiapay:{tx_id} Redis + kkiapay_transaction_id DB UNIQUE |
| LL#26 | DB INSERT rapport first, puis Redis cache update |
| LL#8 | reconciliation_report.id = UUID v4 |
# SEC-03 — Vérification idempotency avant traitement
def check_kkiapay_idempotency(
kkiapay_transaction_id: str,
redis_client: redis.Redis,
db: Session
) -> bool:
"""
Retourne True si la transaction doit être traitée (jamais vue).
Retourne False si déjà traitée (doublon → skip).
"""
redis_key = f"rgz:payment:kkiapay:{kkiapay_transaction_id}"
# Check Redis rapide
if redis_client.exists(redis_key):
return False
# Check DB (source de vérité)
existing = db.query(Payment)\
.filter(Payment.kkiapay_transaction_id == kkiapay_transaction_id)\
.first()
if existing:
# Mettre à jour cache Redis
redis_client.set(redis_key, str(existing.id), ex=86400)
return False
return TrueCommandes Utiles
# Lancer réconciliation manuelle pour une date
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"date": "2026-02-20"}' \
https://api-rgz.duckdns.org/api/v1/billing/reconciliation/run
# Voir rapport du jour précédent
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://api-rgz.duckdns.org/api/v1/billing/reconciliation?date=2026-02-20"
# Lancer manuellement via Celery
docker exec rgz-beat celery -A app.celery_app call rgz.billing.reconcile
# Voir logs de la tâche de réconciliation
docker logs rgz-beat --tail=100 | grep "reconcile"
# Vérifier rapport en DB
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT report_date, status, matched_count, unmatched_payments, unmatched_sessions
FROM reconciliation_reports
ORDER BY report_date DESC LIMIT 7;"
# Paiements sans session (orphelins)
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT p.id, p.amount_fcfa, p.kkiapay_transaction_id, p.completed_at
FROM payments p
LEFT JOIN radius_sessions rs ON rs.payment_id = p.id
WHERE p.status = 'completed'
AND p.completed_at >= NOW() - INTERVAL '2 days'
AND rs.id IS NULL;"Implémentation TODO
- [ ] Service
app/services/reconciliation.py—ReconciliationService.run(date) - [ ] Méthode
_fetch_payments()avec déduplicationkkiapay_transaction_id - [ ] Méthode
_fetch_sessions()pour J-1 - [ ] Méthode
_match()parpayment_id/kkiapay_transaction_id - [ ] Méthode
_compute_status()avec seuils configurables - [ ] Méthode
_persist_report()(LL#26: DB first) - [ ] Tâche Celery
rgz.billing.reconciledansapp/tasks/billing.py - [ ] Planification Celery Beat:
crontab(hour=0, minute=15) - [ ] Intégration alerte P1 → #58 incident-escalation si status='error'
- [ ] Intégration SMS NOC → #61 si status='error'
- [ ] Endpoint
GET /api/v1/billing/reconciliation?date= - [ ] Endpoint
GET /api/v1/billing/reconciliation/history - [ ] Endpoint
POST /api/v1/billing/reconciliation/run(admin, 202) - [ ] Table
reconciliation_reportsavec migration Alembic - [ ] Cache Redis
rgz:reconciliation:lastpour dashboard NOC - [ ] Tests unitaires : 5 types de divergences détectés correctement
- [ ] Tests SEC-03 : doublon
kkiapay_transaction_id→ skip (pas de double traitement) - [ ] Tests idempotency : même date → rapport non réécrasé
Dernière mise à jour: 2026-02-21