#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# 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 creditTâche Celery (app/tasks/sla.py)
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
# 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
# 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=0Exemples de Crédits SLA
| SLA Réel | Downtime | CA 500K FCFA | Crédit FCFA | Impact |
|---|---|---|---|---|
| 99.5% | 216 min | 500 000 | 0 FCFA | Sous seuil |
| 99.0% | 432 min | 500 000 | 0 FCFA | Exactement le seuil |
| 98.5% | 648 min | 500 000 | 2 500 FCFA | Léger (0.5%) |
| 97.0% | 1 296 min | 500 000 | 9 999 FCFA | Significatif (2%) |
| 95.0% | 2 160 min | 500 000 | 20 000 FCFA | Important (4%) |
| 90.0% | 4 320 min | 500 000 | 45 000 FCFA | Critique (9%) |
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/sla/credits?reseller_id=&year=&month= | — | 200 {items, total, page, pages} | Admin/Revendeur JWT | Historique crédits |
| GET | /api/v1/sla/credits/{id} | — | 200 {credit detail} | Admin/Revendeur JWT | Détail avec calcul |
| POST | /api/v1/sla/credits/calculate | {reseller_id, year, month} | 200 {simulation} | Admin JWT | Simulation (sans écriture) |
| POST | /api/v1/sla/credits/apply | {year, month} | 202 {task_id} | Admin JWT | Déclencher manuellement |
GET /api/v1/sla/credits?reseller_id=...&year=2026&month=2
Response 200:
{
"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:
{
"reseller_id": "660e8400-e29b-41d4-a716-446655440001",
"year": 2026,
"month": 2
}Response 200 (simulation sans écriture):
{
"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é | Type | TTL | Usage |
|---|---|---|---|
rgz:sla:monthly:{reseller_id}:{YYYY-MM} | Hash | 3600s | Cache données SLA agrégées du mois |
rgz:sla:credit:applied:{reseller_id}:{YYYY-MM} | String | 86400s | Flag idempotency (crédit déjà appliqué ce mois) |
Celery Tasks
| Task | Schedule | Queue | Description |
|---|---|---|---|
rgz.sla.credit | Monthly J+5 09:00 UTC | rgz.billing | Calcul et application crédits SLA |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-04 | Calcul crédit en entiers FCFA. Division entière Python //. Assertion isinstance(revenue_fcfa, int) |
| SEC-01 | IDOR : revendeur ne voit que ses propres crédits SLA |
| LL#26 | Mise à jour invoice.sla_credit_fcfa et invoice.reseller_share_fcfa en DB first, puis commit, puis notification |
| LL#8 | SLACredit.id = UUID v4 |
| Idempotency | rgz:sla:credit:applied:{reseller_id}:{YYYY-MM} empêche double application du crédit |
# 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 creditCommandes Utiles
# 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.creditdansapp/tasks/sla.py - [ ] Planification Beat :
crontab(day_of_month=5, hour=9, minute=0)mensuel - [ ] Fonction
get_reseller_sla_data()agrégantsla_resultsde #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_creditsavec migration Alembic - [ ] Colonne
invoices.sla_credit_fcfaINT DEFAULT 0 - [ ] Endpoint
GET /api/v1/sla/creditsavec 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