Skip to content

#14 — Forfaits + Vouchers

PLANIFIÉ

Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/api/v1/endpoints/forfaits.py

Dépendances: #1 rgz-api, #6 rgz-radius


Description

Catalogue de forfaits WiFi (durations + volumes) avec génération et validation de codes vouchers. Rédemption atomique via Redis WATCH/MULTI/EXEC pour éviter double-spend. Intégration RADIUS : chaque forfait redemption = session actuelle avec durée configurable.

VoucherStatus enum: active | used | expired | revoked


Architecture Interne

Workflow Forfait → Session

Portail affiche catalogue forfaits (personnalisé par reseller)

Abonné sélectionne forfait (ex: "24h 500MB")

Deux chemins:
  1. Paiement KKiaPay (#15) → webhook → génère voucher → redemption
  2. Voucher existant → champ code → redemption

Redemption flow:

POST /api/v1/vouchers/redeem {code, subscriber_id}

Redis WATCH code (optimistic lock)

Lookup voucher status = active

Lookup forfait (duration, volume)

Lookup active sessions count (Simultaneous-Use limit)

RADIUS CoA (Change of Authorization) pour update session durée

Redis MULTI/EXEC: mark voucher as used + update session

Response 200 {session_token, duration_hours, volume_mb}

Voucher Code Generation (app/services/voucher_generator.py)

python
def generate_voucher_code(length: int = 8) -> str:
    """
    8-char alphanumeric code with Luhn checksum.
    Format: [A-Z0-9]{7} + Luhn digit
    Example: ABC12XYZ
    """

def validate_voucher_code(code: str) -> bool:
    """Luhn validation"""

def generate_batch(
    forfait_id: UUID,
    count: int = 100
) -> List[str]:
    """Batch generation pour distribution"""

Redis Atomicity (SEC-02)

python
import redis

async def redeem_voucher_atomic(
    redis_client: AsyncRedis,
    voucher_code: str,
    subscriber_id: UUID,
    forfait_id: UUID
) -> bool:
    """
    WATCH/MULTI/EXEC atomique.
    Évite race condition: check valid → increment → mark used
    """
    VOUCHER_KEY = f"rgz:voucher:{voucher_code}"

    # Watch pour détection concurrent modification
    watch = redis_client.watch(VOUCHER_KEY)

    # Check status
    status = await redis_client.get(f"{VOUCHER_KEY}:status")
    if status != "active":
        return False  # Concurrent redemption detected

    # MULTI transaction
    async with redis_client.pipeline(transaction=True) as pipe:
        pipe.set(f"{VOUCHER_KEY}:status", "used")
        pipe.set(f"{VOUCHER_KEY}:used_by", str(subscriber_id))
        pipe.set(f"{VOUCHER_KEY}:used_at", int(time.time()))
        await pipe.execute()

    # DB write après Redis confirm
    await db.update_voucher(voucher_code, status="used", subscriber_id=subscriber_id)

    return True

Configuration

Variables d'env (.env)

bash
# Forfaits
VOUCHER_LENGTH=8
VOUCHER_VALIDITY_DAYS=365
VOUCHER_BATCH_SIZE=100

# Pricing (exemples)
FORFAIT_1H_UNLIMITED_FCFA=200
FORFAIT_24H_500MB_FCFA=500
FORFAIT_7D_2GB_FCFA=2000
FORFAIT_30D_10GB_FCFA=5000

# RADIUS CoA
RADIUS_COA_SECRET=your_secret
RADIUS_COA_PORT=3799

Grille Tarifaire (exemple)

ForfaitDuréeVolumePrix FCFASimultaneous-Use
1H Illimité1h2002
24H 500MB24h500 MB5002
7D 2GB7j2 GB20002
30D 10GB30j10 GB50002
30D Illimité30j75002

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
GET/api/v1/forfaits?reseller_id, ?nas_id200 {items:[]}NonCatalogue public
POST/api/v1/vouchers/redeem{code, subscriber_id}200 {session}JWTRedemption atomique
GET/api/v1/vouchers/{code}/validate-200 {valid, forfait}JWTValidation avant redeem
POST/api/v1/forfaits (admin){name, duration_h, volume_mb, price_fcfa}201Admin JWTCRUD forfait

GET /api/v1/forfaits

Query params:

?reseller_id=550e8400-e29b-41d4-a716-446655440000
?nas_id=access_kossou

Response 200:

json
{
  "items": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "24h 500MB",
      "duration_hours": 24,
      "volume_mb": 500,
      "price_fcfa": 500,
      "simultaneous_use": 2,
      "icon": "📱",
      "description": "Accès 24 heures avec 500 MB de données"
    },
    {
      "id": "660e8400-e29b-41d4-a716-446655440001",
      "name": "30D 10GB",
      "duration_hours": 720,
      "volume_mb": 10240,
      "price_fcfa": 5000,
      "simultaneous_use": 2,
      "icon": "🎁",
      "description": "Accès 30 jours avec 10 GB de données"
    }
  ],
  "total": 5
}

POST /api/v1/vouchers/redeem

Request:

json
{
  "code": "ABC12XYZ",
  "subscriber_id": "550e8400-e29b-41d4-a716-446655440000"
}

Response 200 (success):

json
{
  "message": "Voucher redeemed successfully",
  "forfait": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "24h 500MB",
    "duration_hours": 24,
    "volume_mb": 500
  },
  "session": {
    "session_id": "770e8400-e29b-41d4-a716-446655440002",
    "start_time": "2026-02-21T10:30:00Z",
    "expiry_time": "2026-02-22T10:30:00Z",
    "remaining_volume_mb": 500
  }
}

Response 409 (voucher already used):

json
{
  "error": {
    "code": "ERR_VOUCHER_USED",
    "message": "This voucher code has already been redeemed",
    "details": {
      "redeemed_by": "RGZ-0197979799",
      "redeemed_at": "2026-02-20T14:30:00Z"
    }
  }
}

Response 400 (invalid code):

json
{
  "error": {
    "code": "ERR_VOUCHER_INVALID",
    "message": "Invalid voucher code or format",
    "details": {}
  }
}

Response 410 (expired):

json
{
  "error": {
    "code": "ERR_VOUCHER_EXPIRED",
    "message": "Voucher code has expired",
    "details": {"expires_at": "2025-02-21T00:00:00Z"}
  }
}

GET /api/v1/vouchers/{code}/validate

Response 200 (valid):

json
{
  "valid": true,
  "status": "active",
  "forfait": {
    "name": "24h 500MB",
    "duration_hours": 24,
    "price_fcfa": 500
  },
  "expires_at": "2026-12-31T23:59:59Z"
}

Response 410 (expired):

json
{
  "valid": false,
  "status": "expired",
  "expires_at": "2025-02-21T00:00:00Z"
}

Sécurité

RègleImplémentation
SEC-02Redis WATCH/MULTI/EXEC atomique (éviter double-spend)
SEC-04Prix en entiers FCFA (jamais float)
LuhnValidation checksum voucher code
ExpiryVoucher invalide après date expiration
IdempotencyVoucher marked as used → redemption rejection 409
Rate limitOpt: limit redeem attempts (brute force codes)

Implémentation TODO

  • [ ] Service app/services/voucher_generator.py (Luhn checksum)
  • [ ] GET /api/v1/forfaits (catalogue)
  • [ ] POST /api/v1/vouchers/redeem (atomique Redis WATCH/MULTI/EXEC)
  • [ ] GET /api/v1/vouchers/{code}/validate (pre-check)
  • [ ] Table forfaits migration
  • [ ] Table vouchers migration
  • [ ] RADIUS CoA integration (outil #26 DBA)
  • [ ] Batch voucher generation (admin CLI)
  • [ ] Unit tests Luhn validation
  • [ ] Unit tests Redis atomicity (mock)
  • [ ] E2E tests redeem flow

Lessons Learned

  • LL#16 SEC-02: WATCH key → check status → MULTI → update → EXEC (atomique)
  • LL#26: DB write AFTER Redis MULTI/EXEC confirms success
  • LL#4: int amounts (500, not 500.0) — FCFA strictement entiers
  • LL#8: voucher_id, forfait_id = UUID (jamais int)
  • LL#5: colonnes DB exactes (price_fcfa INT CHECK(> 0), status CHECK(...))

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

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