Skip to content

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

Tâche Celery (app/tasks/billing.py)

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

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

python
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

bash
# 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-03

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/billing/reconciliation?date=200 {report}Admin JWTRapport d'un jour
GET/api/v1/billing/reconciliation/history200 {items, total, page, pages}Admin JWTHistorique
POST/api/v1/billing/reconciliation/run{date?}202 {task_id}Admin JWTDéclenchement manuel

GET /api/v1/billing/reconciliation?date=2026-02-20

Response 200:

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

json
{"date": "2026-02-19"}

Response 202:

json
{
  "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éTypeTTLUsage
rgz:payment:kkiapay:{tx_id}String86400sIdempotency webhook KKiaPay (SEC-03)
rgz:reconciliation:lastHash3600sRésultat dernière réconciliation (dashboard)
rgz:reconciliation:{date}String86400sFlag "déjà réconcilié" pour cette date

Celery Tasks

TaskScheduleQueueDescription
rgz.billing.reconcileDaily 00:15 UTCrgz.billingRéconciliation automatique J-1

Sécurité

RègleImplémentation
SEC-03Webhook KKiaPay : vérifier x-kkiapay-secret header avec hmac.compare_digest(). Rejeter si kkiapay_transaction_id déjà en DB
SEC-03Idempotency : avant toute écriture, vérifier rgz:payment:kkiapay:{tx_id} Redis + kkiapay_transaction_id DB UNIQUE
LL#26DB INSERT rapport first, puis Redis cache update
LL#8reconciliation_report.id = UUID v4
python
# 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 True

Commandes Utiles

bash
# 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.pyReconciliationService.run(date)
  • [ ] Méthode _fetch_payments() avec déduplication kkiapay_transaction_id
  • [ ] Méthode _fetch_sessions() pour J-1
  • [ ] Méthode _match() par payment_id / kkiapay_transaction_id
  • [ ] Méthode _compute_status() avec seuils configurables
  • [ ] Méthode _persist_report() (LL#26: DB first)
  • [ ] Tâche Celery rgz.billing.reconcile dans app/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_reports avec migration Alembic
  • [ ] Cache Redis rgz:reconciliation:last pour 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

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