#22 — Remboursements
PLANIFIÉ
Priorité: 🟠 HAUTE · Type: TYPE B · Conteneur: rgz-api · Code: app/api/v1/endpoints/refunds.py
Dépendances: #1 rgz-api
Description
Le module Remboursements gère les demandes de remboursement via l'API KKiaPay pour les paiements complétés sur le réseau ACCESS. Il impose une fenêtre de remboursement de 24 heures à partir de la date de création du paiement, garantissant ainsi la cohérence avec les politiques KKiaPay et les obligations légales du Bénin.
Les remboursements sont initiés pour différentes raisons : paiement en double, service non fourni, erreur de montant, ou défaillance technique vérifiée. Chaque demande de remboursement est vérifiée selon plusieurs critères avant d'être transmise à KKiaPay : propriété du paiement par l'abonné demandeur (SEC-01), statut du paiement (completed uniquement), absence de remboursement précédent pour cette transaction, et respect de la fenêtre de 24 heures.
La V1 ne prend en charge que les remboursements totaux (montant intégral). Les remboursements partiels sont prévus en V2. Une fois le remboursement initié côté KKiaPay, le statut du paiement est immédiatement mis à jour en DB (refunded) et la session RADIUS associée est marquée pour terminaison (via CoA FreeRADIUS). Le voucher associé, s'il n'a pas encore été racheté, est révoqué.
Tous les montants manipulés sont en entiers FCFA (SEC-04). Le montant remboursé est le montant brut original du paiement (ce qu'a payé l'abonné), pas le net après commission — la commission KKiaPay n'est pas récupérable et reste à la charge de RGZ en cas de remboursement.
Architecture Interne
Règles de Remboursement
| Condition | Valeur | Action si échoue |
|---|---|---|
| Fenêtre temporelle | ≤ 24h après payments.created_at | 410 ERR_REFUND_WINDOW_EXPIRED |
| Statut paiement | completed uniquement | 422 ERR_PAYMENT_NOT_REFUNDABLE |
| Déjà remboursé | status != 'refunded' | 409 ERR_ALREADY_REFUNDED |
| Ownership (SEC-01) | payment.subscriber_id == current_user.subscriber_id | 403 ERR_FORBIDDEN |
| Montant | Entier FCFA positif (SEC-04) | 400 ERR_INVALID_AMOUNT |
Flux de Remboursement
Abonné → POST /api/v1/refunds {payment_id, reason}
↓
1. SEC-01: Vérifier payment.subscriber_id == current_user.subscriber_id
↓
2. Vérifier payment.status == 'completed' (pas 'refunded', 'failed', 'pending')
↓
3. Vérifier fenêtre 24h:
if (now - payment.created_at).total_seconds() > 86400:
raise 410 ERR_REFUND_WINDOW_EXPIRED
↓
4. SEC-04: Récupérer montant entier FCFA
↓
5. LL#26: UPDATE payments SET status='refunded' EN DB (avant appel KKiaPay)
↓
6. Appel KKiaPay refund API:
kkiapay_client.refund(transaction_id=payment.kkiapay_transaction_id)
↓
7. Si KKiaPay succès:
- UPDATE payments SET refunded_at=now(), kkiapay_refund_id=... EN DB
- Révoquer voucher associé (si status='active')
- Terminer session RADIUS active (CoA Disconnect-Request via #6)
- Retour 201 {refund_id, status, amount_fcfa}
↓
8. Si KKiaPay échoue (timeout ou erreur):
- Rollback: UPDATE payments SET status='completed' (restaurer état précédent)
- Logger erreur avec details KKiaPay
- Retour 502 ERR_KKIAPAY_REFUND_FAILEDEndpoint Principal (app/api/v1/endpoints/refunds.py)
from fastapi import APIRouter, Depends, HTTPException
from uuid import UUID
from app.deps import get_current_user, get_db
from app.services.kkiapay import KKiaPayService
router = APIRouter(prefix="/refunds", tags=["refunds"])
@router.post("/", status_code=201)
async def create_refund(
payload: RefundCreate, # {payment_id: UUID, reason: str}
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
kkiapay: KKiaPayService = Depends(get_kkiapay)
):
# Récupérer le paiement
payment = db.query(Payment).filter(Payment.id == payload.payment_id).first()
if not payment:
raise HTTPException(404, detail={"error": {"code": "ERR_PAYMENT_NOT_FOUND"}})
# SEC-01: vérification ownership
if payment.subscriber_id != current_user.subscriber_id:
raise HTTPException(403, detail={"error": {"code": "ERR_FORBIDDEN"}})
# Vérifier statut
if payment.status != "completed":
raise HTTPException(422, detail={
"error": {
"code": "ERR_PAYMENT_NOT_REFUNDABLE",
"message": f"Payment status is '{payment.status}', only 'completed' can be refunded",
"details": {}
}
})
# Vérifier fenêtre 24h
window_seconds = (datetime.utcnow() - payment.created_at).total_seconds()
if window_seconds > 86400:
raise HTTPException(410, detail={
"error": {
"code": "ERR_REFUND_WINDOW_EXPIRED",
"message": "Refund window is 24 hours",
"details": {
"created_at": payment.created_at.isoformat(),
"window_expires": (payment.created_at + timedelta(hours=24)).isoformat()
}
}
})
# LL#26: DB first
payment.status = "refunded"
payment.refunded_at = datetime.utcnow()
payment.refund_reason = payload.reason
db.commit()
try:
# Appel KKiaPay
result = await kkiapay.refund_transaction(
transaction_id=payment.kkiapay_transaction_id,
reason=payload.reason
)
# Post-traitement
payment.kkiapay_refund_id = result["refund_id"]
db.commit()
# Révoquer voucher si actif
voucher = db.query(Voucher).filter(
Voucher.payment_id == payment.id,
Voucher.status == "active"
).first()
if voucher:
voucher.status = "revoked"
db.commit()
return {
"id": str(payment.id),
"status": "refunded",
"refund_amount_fcfa": payment.amount_fcfa, # Entier FCFA (SEC-04)
"kkiapay_refund_id": result["refund_id"],
"refunded_at": payment.refunded_at.isoformat() + "Z"
}
except Exception as e:
# Rollback si KKiaPay échoue
payment.status = "completed"
payment.refunded_at = None
db.commit()
raise HTTPException(502, detail={
"error": {
"code": "ERR_KKIAPAY_REFUND_FAILED",
"message": "KKiaPay refund API call failed",
"details": {"error": str(e)}
}
})Configuration
Variables d'environnement
# Remboursements
REFUND_WINDOW_HOURS=24 # Fenêtre de remboursement (V1)
REFUND_PARTIAL_ENABLED=false # V1: remboursements totaux uniquement
REFUND_REASON_MAX_LENGTH=500 # Longueur max du champ raisonEndpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| POST | /api/v1/refunds | {payment_id, reason} | 201 {refund_id, status, amount_fcfa} | JWT | Demande remboursement |
| GET | /api/v1/refunds/{id} | — | 200 {refund details} | JWT | Détail remboursement |
| GET | /api/v1/refunds?subscriber_id= | — | 200 {items, total, page, pages} | JWT | Historique abonné |
| GET | /api/v1/refunds/admin?status=&from=&to= | — | 200 {items, total, page, pages} | Admin JWT | Tous remboursements |
POST /api/v1/refunds
Request:
{
"payment_id": "770e8400-e29b-41d4-a716-446655440002",
"reason": "Double paiement effectué par erreur"
}Response 201:
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"status": "refunded",
"refund_amount_fcfa": 500,
"kkiapay_refund_id": "REF-KKP20260221123456",
"refund_reason": "Double paiement effectué par erreur",
"refunded_at": "2026-02-21T10:35:00Z",
"original_payment": {
"created_at": "2026-02-21T09:30:00Z",
"provider": "MTN_MOMO",
"forfait_name": "24h 500MB"
}
}Response 410 (fenêtre expirée):
{
"error": {
"code": "ERR_REFUND_WINDOW_EXPIRED",
"message": "La fenêtre de remboursement est de 24 heures",
"details": {
"created_at": "2026-02-20T09:30:00Z",
"window_expires": "2026-02-21T09:30:00Z",
"elapsed_hours": 25.1
}
}
}Response 409 (déjà remboursé):
{
"error": {
"code": "ERR_ALREADY_REFUNDED",
"message": "Ce paiement a déjà été remboursé",
"details": {
"refunded_at": "2026-02-21T08:00:00Z",
"kkiapay_refund_id": "REF-KKP20260221111111"
}
}
}GET /api/v1/refunds?subscriber_id=...
Response 200:
{
"items": [
{
"id": "770e8400-e29b-41d4-a716-446655440002",
"refund_amount_fcfa": 500,
"provider": "MTN_MOMO",
"reason": "Double paiement effectué par erreur",
"status": "refunded",
"refunded_at": "2026-02-21T10:35:00Z"
}
],
"total": 1,
"page": 1,
"pages": 1
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:refund:recent:{subscriber_id} | List | 86400s | Historique remboursements récents (anti-abus) |
Note : Pas de Redis nécessaire pour la logique principale — la DB est la source de vérité. Le cache est optionnel pour les listings.
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-01 | IDOR critique : payment.subscriber_id != current_user.subscriber_id → 403. Ne jamais rembourser le paiement d'un autre abonné |
| SEC-04 | Montant remboursé = payment.amount_fcfa (entier FCFA, jamais float) |
| LL#26 | DB UPDATE status='refunded' avant appel KKiaPay API. Rollback DB si KKiaPay échoue |
| LL#8 | refund_id, payment_id = UUID v4 |
# SEC-01 — Vérification ownership dans endpoint refunds
payment = db.query(Payment).filter(Payment.id == payload.payment_id).first()
if payment is None:
raise HTTPException(404, ...)
# CRITIQUE: vérifier l'ownership AVANT tout accès aux données
if payment.subscriber_id != current_user.subscriber_id:
raise HTTPException(
status_code=403,
detail={
"error": {
"code": "ERR_FORBIDDEN",
"message": "Access denied to this payment",
"details": {}
}
}
)
# SEC-04: montant toujours entier
refund_amount: int = payment.amount_fcfa # Jamais float(payment.amount_fcfa)Commandes Utiles
# Créer une demande de remboursement
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"payment_id":"770e8400-...","reason":"Double paiement"}' \
https://api-rgz.duckdns.org/api/v1/refunds
# Voir remboursements d'un abonné
curl -H "Authorization: Bearer $TOKEN" \
"https://api-rgz.duckdns.org/api/v1/refunds?subscriber_id=990e8400-..."
# Tous les remboursements du jour (admin)
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://api-rgz.duckdns.org/api/v1/refunds/admin?from=2026-02-21&to=2026-02-21"
# Vérifier status paiement en DB avant remboursement
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT id, amount_fcfa, status, created_at, kkiapay_transaction_id
FROM payments WHERE id='770e8400-...';"
# Vérifier qu'un voucher associé a été révoqué
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT code, status FROM vouchers WHERE payment_id='770e8400-...';"
# Logs remboursements
docker logs rgz-api --tail=100 | grep "refund\|REFUND"Implémentation TODO
- [ ] Schémas Pydantic :
RefundCreate,RefundResponseavec montants entiers - [ ] Endpoint
POST /api/v1/refundsavec toutes les validations (SEC-01, fenêtre 24h, statut) - [ ] Endpoint
GET /api/v1/refunds/{id} - [ ] Endpoint
GET /api/v1/refunds?subscriber_id=avec pagination - [ ] Endpoint
GET /api/v1/refunds/admin(admin uniquement, tous les remboursements) - [ ] Colonnes DB
payments:refunded_at,refund_reason,kkiapay_refund_id - [ ] Logique rollback : si KKiaPay échoue → restaurer
status='completed' - [ ] Révocation automatique du voucher associé (si
status='active') - [ ] Intégration CoA FreeRADIUS pour terminer session RADIUS active (#6)
- [ ] Intégration SMS confirmation remboursement (#61)
- [ ] Tests SEC-01 : paiement d'un autre abonné → 403
- [ ] Tests fenêtre 24h : paiement de 25h → 410
- [ ] Tests double remboursement → 409
- [ ] Tests rollback : mock KKiaPay failure → vérifier status restored en DB
- [ ] Tests SEC-04 : vérifier que tous les montants retournés sont des entiers
Dernière mise à jour: 2026-02-21