Skip to content

#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

ConditionValeurAction si échoue
Fenêtre temporelle≤ 24h après payments.created_at410 ERR_REFUND_WINDOW_EXPIRED
Statut paiementcompleted uniquement422 ERR_PAYMENT_NOT_REFUNDABLE
Déjà rembourséstatus != 'refunded'409 ERR_ALREADY_REFUNDED
Ownership (SEC-01)payment.subscriber_id == current_user.subscriber_id403 ERR_FORBIDDEN
MontantEntier 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_FAILED

Endpoint Principal (app/api/v1/endpoints/refunds.py)

python
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

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

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
POST/api/v1/refunds{payment_id, reason}201 {refund_id, status, amount_fcfa}JWTDemande remboursement
GET/api/v1/refunds/{id}200 {refund details}JWTDétail remboursement
GET/api/v1/refunds?subscriber_id=200 {items, total, page, pages}JWTHistorique abonné
GET/api/v1/refunds/admin?status=&from=&to=200 {items, total, page, pages}Admin JWTTous remboursements

POST /api/v1/refunds

Request:

json
{
  "payment_id": "770e8400-e29b-41d4-a716-446655440002",
  "reason": "Double paiement effectué par erreur"
}

Response 201:

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

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

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

json
{
  "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éTypeTTLUsage
rgz:refund:recent:{subscriber_id}List86400sHistorique 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ègleImplémentation
SEC-01IDOR critique : payment.subscriber_id != current_user.subscriber_id → 403. Ne jamais rembourser le paiement d'un autre abonné
SEC-04Montant remboursé = payment.amount_fcfa (entier FCFA, jamais float)
LL#26DB UPDATE status='refunded' avant appel KKiaPay API. Rollback DB si KKiaPay échoue
LL#8refund_id, payment_id = UUID v4
python
# 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

bash
# 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, RefundResponse avec montants entiers
  • [ ] Endpoint POST /api/v1/refunds avec 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

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