#20 — Générateur Vouchers
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/voucher_generator.py
Dépendances: #1 rgz-api · #4 rgz-db
Description
Le Générateur Vouchers crée et gère les codes d'accès prépayés sur le réseau ACCESS. Chaque voucher est un code alphanumérique de 8 caractères avec un checksum Luhn adapté intégré. Après paiement KKiaPay complété (#15), le système génère automatiquement un voucher pour le forfait acheté, que l'abonné utilise pour activer sa session WiFi.
Les vouchers peuvent aussi être générés en batch par un administrateur ou un revendeur — par exemple pour distribuer physiquement des codes imprimés sur des cartes scratch (modèle prépayé alternatif). La génération en batch produit jusqu'à 1000 codes en une seule requête, avec vérification d'unicité garantie en DB.
Le processus de redemption (échange du code contre une session) est l'opération la plus sensible en termes de concurrence. Si deux appareils tentent de racheter le même code simultanément, le système doit garantir qu'un seul succède. Cette protection est implémentée via Redis WATCH/MULTI/EXEC (SEC-02) — une transaction optimiste qui retire le code atomiquement avant de valider l'opération en DB.
Le cycle de vie d'un voucher est simple : active (disponible) → used (racheté) ou expired (délai dépassé) ou revoked (annulé admin). La table vouchers comporte un index unique sur code pour garantir l'unicité en DB même sans Redis.
Architecture Interne
Format Code Voucher
Format: 8 caractères alphanumériques UPPERCASE
Charset: A-Z (sauf I, O pour éviter confusion) + 0-9
Exemples: K4X8M2P9, AB7CDE3F, Z2WQ8N1R
Répartition:
- Chars 1-7: aléatoires depuis charset
- Char 8: check digit Luhn adapté (alphanumérique)
Unicité garantie par:
1. Index UNIQUE DB sur colonne `code`
2. Retry automatique si collision (rare, ~1/33^7)Algorithme Luhn Adapté (Alphanumérique)
CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # 34 chars (sans I, O)
BASE = len(CHARSET) # 34
def char_to_val(c: str) -> int:
return CHARSET.index(c)
def val_to_char(v: int) -> str:
return CHARSET[v % BASE]
def luhn_checksum(code_7: str) -> str:
"""
Calcule le check digit pour 7 caractères.
Retourne le 8ème caractère (check digit).
"""
values = [char_to_val(c) for c in code_7]
total = 0
for i, v in enumerate(reversed(values)):
if i % 2 == 0:
doubled = v * 2
total += doubled // BASE + doubled % BASE
else:
total += v
check_val = (BASE - (total % BASE)) % BASE
return val_to_char(check_val)
def generate_code() -> str:
"""Génère un code voucher 8 chars valide."""
import secrets
prefix = ''.join(secrets.choice(CHARSET) for _ in range(7))
check = luhn_checksum(prefix)
return prefix + check
def validate_code(code: str) -> bool:
"""Valide le checksum Luhn d'un code 8 chars."""
if len(code) != 8 or not all(c in CHARSET for c in code):
return False
check = luhn_checksum(code[:7])
return check == code[7]Service Principal (app/services/voucher_generator.py)
from uuid import UUID, uuid4
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
import redis
class VoucherGenerator:
def __init__(self, db: Session, redis_client: redis.Redis):
self.db = db
self.redis = redis_client
async def generate_voucher(
self,
forfait_id: UUID,
payment_id: UUID,
reseller_id: UUID
) -> Voucher:
"""
Génère un unique voucher post-paiement.
Retry automatique si collision code (max 5 tentatives).
LL#26: DB first → Redis → retour.
"""
async def generate_batch(
self,
forfait_id: UUID,
reseller_id: UUID,
quantity: int,
valid_days: int = 30
) -> list[Voucher]:
"""
Génération batch (max 1000 vouchers).
Tous les codes sont uniques et validés Luhn.
LL#26: bulk insert DB avant tout retour.
"""
async def redeem_voucher(
self,
code: str,
subscriber_id: UUID,
mac_address: str
) -> dict:
"""
Échange un code contre une session active.
SEC-02: Redis WATCH/MULTI/EXEC pour atomicité.
Séquence:
1. Valider format + Luhn
2. WATCH rgz:voucher:used:{code}
3. Si clé existe → déjà utilisé → abort
4. MULTI
5. SET rgz:voucher:used:{code} {subscriber_id} EX 86400
6. EXEC → si None → collision → retry ou erreur
7. LL#26: UPDATE vouchers SET status='used' en DB
8. Créer session RADIUS
"""
async def revoke_voucher(self, code: str, admin_id: UUID) -> None:
"""Révocation admin. LL#26: DB update first."""
async def get_voucher_status(self, code: str) -> dict:
"""Status check avec cache Redis 60s."""Flux Redemption avec Protection Race Condition
Abonné entre code "K4X8M2P9" sur portail
↓
POST /api/v1/vouchers/redeem {code: "K4X8M2P9", subscriber_id: "..."}
↓
1. Valider format regex: ^[A-Z0-9]{8}$ (sans I, O)
2. Valider Luhn: validate_code("K4X8M2P9") → True/False
↓
3. Redis WATCH "rgz:voucher:used:K4X8M2P9"
↓
4. GET "rgz:voucher:used:K4X8M2P9"
Si existe → return 409 {ERR_VOUCHER_ALREADY_USED}
↓
5. MULTI
SET "rgz:voucher:used:K4X8M2P9" "{subscriber_id}" EX 86400
EXEC
Si EXEC retourne None → WATCH triggered (autre transaction gagnée) → retry
↓
6. LL#26: UPDATE vouchers SET status='used', redeemed_by_subscriber_id=...
WHERE code='K4X8M2P9' AND status='active'
Si rowcount == 0 → race condition DB → erreur (code déjà utilisé en DB)
↓
7. Créer radius_session (is_active=True)
↓
8. Retour {session_token, expires_at, forfait_details}Configuration
Variables d'environnement
# Vouchers
VOUCHER_CODE_LENGTH=8
VOUCHER_DEFAULT_VALIDITY_DAYS=30
VOUCHER_BATCH_MAX_QUANTITY=1000
VOUCHER_GENERATE_MAX_RETRIES=5 # Collisions code (très rare)
# Redis
VOUCHER_REDIS_TTL_SECONDS=86400 # 24h idempotency keyEndpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| POST | /api/v1/vouchers/generate | {forfait_id, quantity, valid_days?} | 201 {items, total} | Admin/Revendeur JWT | Batch génération |
| POST | /api/v1/vouchers/redeem | {code, subscriber_id, mac_address} | 200 {session_token} | JWT | Redemption (WATCH/EXEC) |
| GET | /api/v1/vouchers/{code}/status | — | 200 {status, ...} | JWT | Status check |
| GET | /api/v1/vouchers?reseller_id=&status= | — | 200 {items, total, page, pages} | Admin/Revendeur JWT | Listing |
| DELETE | /api/v1/vouchers/{code} | — | 204 | Admin JWT | Révoquer |
POST /api/v1/vouchers/generate
Request:
{
"forfait_id": "550e8400-e29b-41d4-a716-446655440000",
"quantity": 10,
"valid_days": 30
}Response 201:
{
"items": [
{
"code": "K4X8M2P9",
"forfait_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "active",
"expires_at": "2026-03-23T00:00:00Z",
"created_at": "2026-02-21T10:00:00Z"
},
{
"code": "AB7CDE3F",
"forfait_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "active",
"expires_at": "2026-03-23T00:00:00Z",
"created_at": "2026-02-21T10:00:00Z"
}
],
"total": 10
}POST /api/v1/vouchers/redeem
Request:
{
"code": "K4X8M2P9",
"subscriber_id": "990e8400-e29b-41d4-a716-446655440021",
"mac_address": "AA:BB:CC:DD:EE:FF"
}Response 200:
{
"session_token": "sess_K4X8M2P9_990e8400",
"forfait": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "24h 500MB",
"duration_hours": 24,
"volume_mb": 500
},
"session_start": "2026-02-21T10:15:00Z",
"session_expires": "2026-02-22T10:15:00Z",
"simultaneous_use": 2
}Response 409 (déjà utilisé):
{
"error": {
"code": "ERR_VOUCHER_ALREADY_USED",
"message": "Ce code a déjà été utilisé",
"details": {
"code": "K4X8M2P9",
"used_at": "2026-02-21T09:50:00Z"
}
}
}Response 400 (format invalide):
{
"error": {
"code": "ERR_VOUCHER_INVALID_FORMAT",
"message": "Code invalide (format ou checksum incorrect)",
"details": {}
}
}GET /api/v1/vouchers/{code}/status
Response 200:
{
"code": "K4X8M2P9",
"status": "used",
"forfait_name": "24h 500MB",
"redeemed_by_subscriber_ref": "RGZ-0197979964",
"used_at": "2026-02-21T10:15:00Z",
"expires_at": "2026-03-23T00:00:00Z"
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:voucher:used:{code} | String | 86400s | Idempotency redemption (WATCH/EXEC cible) |
rgz:voucher:status:{code} | String | 60s | Cache status pour GET rapide |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-02 | Race condition : Redis WATCH/MULTI/EXEC atomique sur rgz:voucher:used:{code}. Jamais check-then-set |
| SEC-01 | IDOR : un revendeur ne liste que ses propres vouchers (reseller_id == current_user.reseller_id) |
| LL#16 | CHECK constraint DB : status IN ('active', 'used', 'expired', 'revoked') |
| LL#8 | Voucher id = UUID v4, forfait_id, payment_id = UUID v4 |
| LL#26 | DB write (UPDATE status='used') APRES succès EXEC Redis. Si DB fail → alerte cohérence |
| Luhn | Validation checksum avant tout traitement (rejet format invalide = 400 immédiat) |
# SEC-02 — Implémentation WATCH/MULTI/EXEC
import redis
async def redeem_with_watch(
redis_client: redis.Redis,
code: str,
subscriber_id: str
) -> bool:
"""
Retourne True si redemption atomique réussie, False si code déjà pris.
"""
redis_key = f"rgz:voucher:used:{code}"
with redis_client.pipeline() as pipe:
while True:
try:
pipe.watch(redis_key)
if pipe.get(redis_key):
pipe.reset()
return False # Déjà utilisé
pipe.multi()
pipe.set(redis_key, subscriber_id, ex=86400)
pipe.execute() # Retourne None si WATCH triggered
return True
except redis.WatchError:
continue # Retry (autre transaction a modifié la clé)Commandes Utiles
# Générer 5 vouchers pour un forfait (admin)
curl -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"forfait_id":"550e8400-...","quantity":5,"valid_days":30}' \
https://api-rgz.duckdns.org/api/v1/vouchers/generate
# Vérifier statut d'un code
curl -H "Authorization: Bearer $TOKEN" \
https://api-rgz.duckdns.org/api/v1/vouchers/K4X8M2P9/status
# Révoquer un code (admin)
curl -X DELETE \
-H "Authorization: Bearer $ADMIN_TOKEN" \
https://api-rgz.duckdns.org/api/v1/vouchers/K4X8M2P9
# Vérifier idempotency clé Redis
docker exec rgz-redis redis-cli GET "rgz:voucher:used:K4X8M2P9"
# Codes actifs en DB pour un revendeur
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT code, status, expires_at FROM vouchers
WHERE reseller_id='660e8400-...' AND status='active'
ORDER BY created_at DESC LIMIT 20;"
# Vérifier intégrité Luhn sur tous codes actifs
docker exec rgz-db psql -U rgz -d rgzdb \
-c "SELECT COUNT(*) as total_active FROM vouchers WHERE status='active';"Implémentation TODO
- [ ] Algorithme
generate_code()avec charset sans I, O - [ ] Fonctions
luhn_checksum()etvalidate_code() - [ ]
VoucherGenerator.generate_voucher()(post-paiement, retry sur collision) - [ ]
VoucherGenerator.generate_batch()(jusqu'à 1000 codes) - [ ]
VoucherGenerator.redeem_voucher()avec Redis WATCH/MULTI/EXEC (SEC-02) - [ ]
VoucherGenerator.revoke_voucher()(admin, LL#26) - [ ]
VoucherGenerator.get_voucher_status()avec cache 60s - [ ] Endpoint
POST /api/v1/vouchers/generate - [ ] Endpoint
POST /api/v1/vouchers/redeem - [ ] Endpoint
GET /api/v1/vouchers/{code}/status - [ ] Endpoint
GET /api/v1/vouchersavec filtres et pagination - [ ] Endpoint
DELETE /api/v1/vouchers/{code} - [ ] Table
vouchersavec CHECK constraint status (LL#16) et UNIQUE sur code - [ ] Index:
idx_voucher_code UNIQUE,idx_voucher_reseller_status - [ ] Tâche Celery
rgz.vouchers.expire_stale— expire les codes >expires_atdaily - [ ] Tests Luhn : table exhaustive codes valides/invalides
- [ ] Tests SEC-02 : simulation race condition double-redemption
- [ ] Tests batch : 1000 codes, zéro doublon
- [ ] Tests SEC-01 : revendeur A ne voit pas vouchers de B
Dernière mise à jour: 2026-02-21