#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 TrueConfiguration
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=3799Grille Tarifaire (exemple)
| Forfait | Durée | Volume | Prix FCFA | Simultaneous-Use |
|---|---|---|---|---|
| 1H Illimité | 1h | ∞ | 200 | 2 |
| 24H 500MB | 24h | 500 MB | 500 | 2 |
| 7D 2GB | 7j | 2 GB | 2000 | 2 |
| 30D 10GB | 30j | 10 GB | 5000 | 2 |
| 30D Illimité | 30j | ∞ | 7500 | 2 |
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| GET | /api/v1/forfaits | ?reseller_id, ?nas_id | 200 {items:[]} | Non | Catalogue public |
| POST | /api/v1/vouchers/redeem | {code, subscriber_id} | 200 {session} | JWT | Redemption atomique |
| GET | /api/v1/vouchers/{code}/validate | - | 200 {valid, forfait} | JWT | Validation avant redeem |
| POST | /api/v1/forfaits (admin) | {name, duration_h, volume_mb, price_fcfa} | 201 | Admin JWT | CRUD forfait |
GET /api/v1/forfaits
Query params:
?reseller_id=550e8400-e29b-41d4-a716-446655440000
?nas_id=access_kossouResponse 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ègle | Implémentation |
|---|---|
| SEC-02 | Redis WATCH/MULTI/EXEC atomique (éviter double-spend) |
| SEC-04 | Prix en entiers FCFA (jamais float) |
| Luhn | Validation checksum voucher code |
| Expiry | Voucher invalide après date expiration |
| Idempotency | Voucher marked as used → redemption rejection 409 |
| Rate limit | Opt: 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:
intamounts (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