Skip to content

#25 — Crédit SLA Auto

PLANIFIÉ

Priorité: 🟠 HAUTE · Type: TYPE C · Conteneur: rgz-beat · Code: app/tasks/sla.py

Dépendances: #19 moteur-facturation · #43 sla-probe-engine


Description

Le module Crédit SLA Auto calcule et attribue automatiquement des crédits financiers aux revendeurs dont le niveau de service mensuel (SLA) est inférieur à 99 %. Ces crédits compensent les périodes de downtime non planifiées et sont déduits directement de la facture mensuelle du mois suivant. Ce mécanisme renforce la confiance des partenaires revendeurs et incite RGZ à maintenir une haute disponibilité de son infrastructure.

Les crédits sont calculés à partir des résultats de probes SLA (#43), qui effectuent des mesures ICMP et TCP toutes les 5 minutes depuis chaque NAS-ID. Le calcul s'effectue le J+5 de chaque mois, après la génération des factures (#24) et avant leur envoi par email (#63). Ainsi, la facture mensuelle remise au revendeur inclut déjà les crédits SLA applicables.

La formule de crédit est proportionnelle au downtime constaté : pour chaque minute d'indisponibilité au-delà du seuil de 1 % (soit environ 432 minutes par mois), RGZ émet un crédit calculé sur la base du chiffre d'affaires mensuel du revendeur concerné. Le seuil de déclenchement est un downtime consécutif supérieur à 30 minutes — les micro-coupures inférieures à 30 minutes ne génèrent pas de crédit.

Le crédit est émis en entiers FCFA (SEC-04) et inscrit dans le champ sla_credit_fcfa de la table invoices. Un enregistrement séparé dans une table sla_credits trace l'historique et le détail du calcul pour audit. Les crédits ne peuvent pas rendre une facture négative : si le crédit calculé dépasse le montant dû au revendeur, le solde excédentaire est reporté sur le mois suivant.


Architecture Interne

Formule de Calcul SLA

Paramètres:
  - Total minutes du mois:     M = 30 × 24 × 60 = 43 200 min (ou 31×24×60)
  - Seuil SLA contractuel:     S = 99%
  - Downtime constaté:         D = total_downtime_minutes (depuis #43)
  - SLA réel:                  SLA_reel = (M - D) / M × 100
  - Downtime franchissant seuil: D_excess = max(0, D - M × (1 - S/100))
    → D_excess = max(0, D - 432) pour un mois de 30j
  - Chiffre d'affaires brut:   CA = invoice.total_amount_fcfa (entier FCFA)

Calcul crédit:
  credit_fcfa = (D_excess / M) × CA  →  arrondi entier FCFA inférieur

Exemple:
  Mois 30j (43 200 min), CA = 500 000 FCFA
  Downtime: 800 min → D_excess = 800 - 432 = 368 min
  credit = (368 / 43200) × 500 000 = 4 259 FCFA
python
# app/tasks/sla.py — Logique de calcul

def calculate_sla_credit(
    downtime_minutes: int,
    total_minutes_in_month: int,
    revenue_fcfa: int,
    sla_threshold_percent: float = 99.0
) -> int:
    """
    Calcule le crédit SLA en entiers FCFA.
    SEC-04: jamais de float pour argent.
    Retourne 0 si SLA >= seuil.
    """
    assert isinstance(revenue_fcfa, int), "SEC-04: revenue must be int"
    assert isinstance(downtime_minutes, int), "SEC-04: downtime must be int"

    # Minutes de downtime autorisées (seuil)
    allowed_downtime = int(total_minutes_in_month * (100 - sla_threshold_percent) / 100)

    # Downtime excédentaire
    excess_downtime = max(0, downtime_minutes - allowed_downtime)

    if excess_downtime == 0:
        return 0

    # Crédit proportionnel — division entière (SEC-04)
    credit = (excess_downtime * revenue_fcfa) // total_minutes_in_month

    return credit

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

python
from celery import shared_task
from celery.schedules import crontab
from datetime import date, timedelta
import calendar

@shared_task(
    name="rgz.sla.credit",
    queue="rgz.billing",
    max_retries=3,
    default_retry_delay=3600,  # 1h avant retry
    acks_late=True,
)
def apply_sla_credits(year: int, month: int):
    """
    Calcule et applique les crédits SLA pour le mois donné.
    Appelé le J+5 de chaque mois (après génération factures #24).
    """
    _, days_in_month = calendar.monthrange(year, month)
    total_minutes = days_in_month * 24 * 60

    with get_db_for_task() as db:
        # Récupérer tous les revendeurs actifs avec facture du mois
        invoices = db.query(Invoice).filter(
            Invoice.period_start == date(year, month, 1),
            Invoice.status.in_(["draft", "sent"])
        ).all()

        results = []
        for invoice in invoices:
            # Récupérer données SLA depuis probes #43
            sla_data = get_reseller_sla_data(
                db, invoice.reseller_id, year, month
            )

            if sla_data.downtime_minutes == 0:
                continue

            # Calculer crédit (SEC-04: entiers)
            credit_fcfa = calculate_sla_credit(
                downtime_minutes=sla_data.downtime_minutes,
                total_minutes_in_month=total_minutes,
                revenue_fcfa=invoice.total_amount_fcfa
            )

            if credit_fcfa == 0:
                continue

            # Ne pas dépasser le montant dû au revendeur
            max_credit = invoice.reseller_share_fcfa
            actual_credit = min(credit_fcfa, max_credit)

            # LL#26: DB first
            invoice.sla_credit_fcfa = actual_credit
            invoice.reseller_share_fcfa -= actual_credit

            # Créer enregistrement audit
            sla_credit_record = SLACredit(
                id=uuid4(),
                invoice_id=invoice.id,
                reseller_id=invoice.reseller_id,
                period_start=date(year, month, 1),
                downtime_minutes=sla_data.downtime_minutes,
                sla_percent=sla_data.sla_percent,
                credit_fcfa=actual_credit,
                calculation_details={
                    "total_minutes": total_minutes,
                    "allowed_downtime": int(total_minutes * 0.01),
                    "excess_downtime": max(0, sla_data.downtime_minutes - int(total_minutes * 0.01)),
                    "revenue_fcfa": invoice.total_amount_fcfa,
                    "credit_formula": f"({sla_data.downtime_minutes - int(total_minutes * 0.01)} / {total_minutes}) × {invoice.total_amount_fcfa}"
                }
            )
            db.add(sla_credit_record)
            db.commit()

            results.append({
                "reseller_id": str(invoice.reseller_id),
                "credit_fcfa": actual_credit,
                "downtime_minutes": sla_data.downtime_minutes
            })

    return {"credits_applied": len(results), "details": results}


def get_reseller_sla_data(db: Session, reseller_id: UUID, year: int, month: int):
    """
    Agrège les données SLA depuis sla_results (table de #43).
    Retourne: downtime_minutes total, sla_percent.
    """
    # Les probes SLA (#43) écrivent dans sla_results toutes les 5min
    # Downtime = périodes avec is_up=False consécutives > 30min
    ...

Planification Celery Beat

python
# app/celery_app.py
beat_schedule = {
    "rgz.sla.credit": {
        "task": "rgz.sla.credit",
        # J+5 de chaque mois (5 du mois à 09:00 UTC, après génération factures)
        "schedule": crontab(day_of_month=5, hour=9, minute=0),
        "options": {"queue": "rgz.billing"},
        # L'année/mois est calculé depuis la date d'exécution (mois précédent)
    },
}

Configuration

Variables d'environnement

bash
# SLA Credits
SLA_THRESHOLD_PERCENT=99.0           # Seuil contractuel (99%)
SLA_CONSECUTIVE_DOWNTIME_MIN=30      # Seuil déclenchement crédit (minutes)
SLA_CREDIT_GENERATION_DAY=5          # J+5 mensuel (après factures #24)
SLA_CREDIT_MAX_PERCENT_INVOICE=100   # Crédit max = 100% de la facture revendeur

# Celery
CELERY_SLA_CREDIT_HOUR=9
CELERY_SLA_CREDIT_MINUTE=0

Exemples de Crédits SLA

SLA RéelDowntimeCA 500K FCFACrédit FCFAImpact
99.5%216 min500 0000 FCFASous seuil
99.0%432 min500 0000 FCFAExactement le seuil
98.5%648 min500 0002 500 FCFALéger (0.5%)
97.0%1 296 min500 0009 999 FCFASignificatif (2%)
95.0%2 160 min500 00020 000 FCFAImportant (4%)
90.0%4 320 min500 00045 000 FCFACritique (9%)

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/sla/credits?reseller_id=&year=&month=200 {items, total, page, pages}Admin/Revendeur JWTHistorique crédits
GET/api/v1/sla/credits/{id}200 {credit detail}Admin/Revendeur JWTDétail avec calcul
POST/api/v1/sla/credits/calculate{reseller_id, year, month}200 {simulation}Admin JWTSimulation (sans écriture)
POST/api/v1/sla/credits/apply{year, month}202 {task_id}Admin JWTDéclencher manuellement

GET /api/v1/sla/credits?reseller_id=...&year=2026&month=2

Response 200:

json
{
  "items": [
    {
      "id": "ff0e8400-e29b-41d4-a716-446655440050",
      "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
      "reseller_name": "Kossou WiFi",
      "period_start": "2026-02-01",
      "sla_percent": 97.8,
      "downtime_minutes": 950,
      "credit_fcfa": 6018,
      "invoice_id": "ee0e8400-e29b-41d4-a716-446655440040",
      "calculation_details": {
        "total_minutes": 40320,
        "allowed_downtime": 403,
        "excess_downtime": 547,
        "revenue_fcfa": 444000,
        "credit_formula": "(547 / 40320) × 444000"
      },
      "created_at": "2026-03-05T09:03:22Z"
    }
  ],
  "total": 1,
  "page": 1,
  "pages": 1
}

POST /api/v1/sla/credits/calculate (simulation)

Request:

json
{
  "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
  "year": 2026,
  "month": 2
}

Response 200 (simulation sans écriture):

json
{
  "simulation": true,
  "reseller_id": "660e8400-e29b-41d4-a716-446655440001",
  "period": "2026-02",
  "sla_data": {
    "sla_percent": 97.8,
    "downtime_minutes": 950,
    "incidents": 3
  },
  "billing_data": {
    "total_amount_fcfa": 444000,
    "reseller_share_fcfa": 218142
  },
  "credit_calculation": {
    "allowed_downtime_minutes": 403,
    "excess_downtime_minutes": 547,
    "credit_fcfa": 6018,
    "new_reseller_share_fcfa": 212124
  }
}

Redis Keys

CléTypeTTLUsage
rgz:sla:monthly:{reseller_id}:{YYYY-MM}Hash3600sCache données SLA agrégées du mois
rgz:sla:credit:applied:{reseller_id}:{YYYY-MM}String86400sFlag idempotency (crédit déjà appliqué ce mois)

Celery Tasks

TaskScheduleQueueDescription
rgz.sla.creditMonthly J+5 09:00 UTCrgz.billingCalcul et application crédits SLA

Sécurité

RègleImplémentation
SEC-04Calcul crédit en entiers FCFA. Division entière Python //. Assertion isinstance(revenue_fcfa, int)
SEC-01IDOR : revendeur ne voit que ses propres crédits SLA
LL#26Mise à jour invoice.sla_credit_fcfa et invoice.reseller_share_fcfa en DB first, puis commit, puis notification
LL#8SLACredit.id = UUID v4
Idempotencyrgz:sla:credit:applied:{reseller_id}:{YYYY-MM} empêche double application du crédit
python
# SEC-04 — Vérification stricte entiers dans calculate_sla_credit()
def calculate_sla_credit(
    downtime_minutes: int,
    total_minutes_in_month: int,
    revenue_fcfa: int,
    sla_threshold_percent: float = 99.0
) -> int:
    # Assertions SEC-04
    assert isinstance(revenue_fcfa, int), \
        f"SEC-04 violation: revenue_fcfa must be int, got {type(revenue_fcfa)}"
    assert isinstance(downtime_minutes, int), \
        f"SEC-04 violation: downtime_minutes must be int, got {type(downtime_minutes)}"

    allowed = int(total_minutes_in_month * (100 - sla_threshold_percent) / 100)
    excess = max(0, downtime_minutes - allowed)

    if excess == 0:
        return 0

    # Division entière — jamais float — SEC-04
    credit = (excess * revenue_fcfa) // total_minutes_in_month

    assert isinstance(credit, int), "Credit must be int (internal error)"
    return credit

Commandes Utiles

bash
# Simuler crédit SLA d'un revendeur (admin)
curl -X POST \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reseller_id":"660e8400-...","year":2026,"month":2}' \
  https://api-rgz.duckdns.org/api/v1/sla/credits/calculate

# Appliquer crédits SLA manuellement pour un mois
curl -X POST \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"year": 2026, "month": 2}' \
  https://api-rgz.duckdns.org/api/v1/sla/credits/apply

# Voir crédits d'un revendeur
curl -H "Authorization: Bearer $TOKEN" \
  "https://api-rgz.duckdns.org/api/v1/sla/credits?year=2026&month=2"

# Lancer tâche manuellement via Celery
docker exec rgz-beat celery -A app.celery_app call rgz.sla.credit \
  --kwargs '{"year": 2026, "month": 2}'

# Vérifier données SLA en DB (depuis table sla_results de #43)
docker exec rgz-db psql -U rgz -d rgzdb \
  -c "SELECT reseller_id,
             COUNT(*) FILTER (WHERE is_up=FALSE) as downtime_probes,
             COUNT(*) as total_probes,
             ROUND(COUNT(*) FILTER (WHERE is_up=TRUE) * 100.0 / COUNT(*), 2) as sla_percent
      FROM sla_results
      WHERE checked_at >= '2026-02-01' AND checked_at < '2026-03-01'
      GROUP BY reseller_id
      ORDER BY sla_percent;"

# Vérifier crédits appliqués sur factures
docker exec rgz-db psql -U rgz -d rgzdb \
  -c "SELECT r.slug, i.sla_credit_fcfa, i.reseller_share_fcfa, i.status
      FROM invoices i
      JOIN resellers r ON r.id = i.reseller_id
      WHERE i.period_start = '2026-02-01'
        AND i.sla_credit_fcfa > 0;"

# Logs tâche SLA crédit
docker logs rgz-beat --tail=100 | grep "sla.credit\|SLA_CREDIT"

Implémentation TODO

  • [ ] Fonction calculate_sla_credit() avec assertions SEC-04 (division entière)
  • [ ] Tâche Celery rgz.sla.credit dans app/tasks/sla.py
  • [ ] Planification Beat : crontab(day_of_month=5, hour=9, minute=0) mensuel
  • [ ] Fonction get_reseller_sla_data() agrégant sla_results de #43
  • [ ] Logique downtime consécutif > 30min (seuil de déclenchement)
  • [ ] Guard idempotency : rgz:sla:credit:applied:{reseller_id}:{YYYY-MM}
  • [ ] Plafonnement crédit à reseller_share_fcfa (jamais négatif)
  • [ ] Table sla_credits avec migration Alembic
  • [ ] Colonne invoices.sla_credit_fcfa INT DEFAULT 0
  • [ ] Endpoint GET /api/v1/sla/credits avec filtres et pagination
  • [ ] Endpoint GET /api/v1/sla/credits/{id} avec détail calcul
  • [ ] Endpoint POST /api/v1/sla/credits/calculate (simulation sans écriture)
  • [ ] Endpoint POST /api/v1/sla/credits/apply (admin, 202)
  • [ ] Notification revendeur après application crédit (#63 email)
  • [ ] Intégration dans facture PDF #24 (ligne "Crédit SLA: -{credit} FCFA")
  • [ ] Tests unitaires calculate_sla_credit() avec table de valeurs
  • [ ] Tests SEC-04 : entrée float → AssertionError
  • [ ] Tests idempotency : double appel → crédit non doublé
  • [ ] Tests plafonnement : crédit > reseller_share → clamp à reseller_share

Dernière mise à jour: 2026-02-21

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