#15 — Paiement KKiaPay
PLANIFIÉ
Priorité: 🔴 CRITIQUE · Type: TYPE B · Conteneur: rgz-api · Code: app/services/kkiapay.py
Dépendances: #1 rgz-api
Description
Intégration paiement mobile via KKiaPay pour Bénin (MTN Momo, Moov Flooz, Wave, Celtiis Cash). Initiation transaction, webhook handling avec signature + idempotency, gestion remboursements 24h.
PaymentProvider enum: MTN_MOMO | MOOV_FLOOZ | CELTIIS_CASH | WAVE_BENIN
PaymentStatus enum: pending | completed | failed | refunded
Architecture Interne
Workflow Paiement
Abonné sélectionne forfait → affichage prix FCFA
↓
Clique "Payer avec KKiaPay"
↓
POST /api/v1/payments/initiate
{amount_fcfa: 500, provider: MTN_MOMO, phone: "+229...", forfait_id}
↓
Service kkiapay.initiate() → KKiaPay API
↓
Response: {payment_id, redirect_url, session_token}
↓
Portail redirige user vers KKiaPay modal/URL
↓
Utilisateur rentre code USSD / approuve paiement
↓
KKiaPay webhook → POST /api/v1/payments/webhook
{transaction_id, status, amount, phone}
↓
Vérification signature (x-kkiapay-secret header)
↓
Idempotency check: si kkiapay_transaction_id existe déjà → skip
↓
Si status=completed:
- Créer voucher pour forfait
- Marquer payment comme completed
- Envoyer SMS "Votre forfait est activé"
↓
Response 200 webhookKKiaPay Service (app/services/kkiapay.py)
python
from kkiapay import KKiaPay
class KKiaPayService:
def __init__(self, public_key: str, secret_key: str):
self.client = KKiaPay(public_key=public_key, secret_key=secret_key)
async def initiate_transaction(
self,
amount_fcfa: int,
phone: str,
provider: PaymentProvider,
reference: str
) -> dict:
"""
Initiate KKiaPay transaction.
Returns: {transaction_id, redirect_url, session_token, expires_at}
Raises: KKiaPayError
"""
async def verify_webhook_signature(
self,
payload: dict,
signature_header: str
) -> bool:
"""
Verify x-kkiapay-secret header.
HMAC-SHA256(payload, secret_key) == signature_header
"""
async def handle_webhook(
self,
payload: dict,
signature_header: str
) -> dict:
"""
Process webhook:
- Verify signature
- Check idempotency (kkiapay_transaction_id)
- Update DB payment status
- Generate voucher if completed
"""
async def refund_transaction(
self,
transaction_id: str,
reason: str
) -> dict:
"""Request refund (24h window)"""
async def check_transaction_status(
self,
transaction_id: str
) -> dict:
"""Poll transaction status (status, amount, phone, timestamp)"""Configuration
Variables d'env (.env)
bash
# KKiaPay
KKIAPAY_PUBLIC_KEY=pk_test_xxxxxxxx
KKIAPAY_SECRET_KEY=sk_test_xxxxxxxx
KKIAPAY_WEBHOOK_SECRET=webhook_secret_xxxxxxxx
# Transaction
PAYMENT_TIMEOUT_MINUTES=15
PAYMENT_CALLBACK_TIMEOUT_MINUTES=30
REFUND_WINDOW_HOURS=24
# SMS Template
SMS_PAYMENT_SUCCESS="Forfait activé! Montant: {amount} FCFA. Ref: {ref}"
SMS_REFUND_APPROVED="Remboursement approuvé: {amount} FCFA. Ref: {ref}"Providers Mapping
| Provider | Opérateur | Code |
|---|---|---|
MTN_MOMO | MTN Mobile Money Bénin | MOMO |
MOOV_FLOOZ | Moov Flooz Bénin | FLOOZ |
WAVE_BENIN | Wave Bénin | WAVE |
CELTIIS_CASH | Celtiis Cash Bénin | CELTIIS |
Endpoints API
| Méthode | Route | Body | Réponse | Auth | Notes |
|---|---|---|---|---|---|
| POST | /api/v1/payments/initiate | {amount_fcfa, provider, phone, forfait_id} | 201 {payment_id, redirect_url} | JWT | KKiaPay init |
| POST | /api/v1/payments/webhook | KKiaPay payload | 200 | Signature | Webhook handler |
| GET | /api/v1/payments/{id} | - | 200 {payment, status} | JWT | Status check |
| POST | /api/v1/payments/{id}/refund | {reason} | 200 | Admin JWT | Refund request |
POST /api/v1/payments/initiate
Request:
json
{
"amount_fcfa": 500,
"provider": "MTN_MOMO",
"phone": "+22901979799",
"forfait_id": "550e8400-e29b-41d4-a716-446655440000"
}Response 201:
json
{
"payment_id": "660e8400-e29b-41d4-a716-446655440001",
"redirect_url": "https://kkiapay.com/pay/...",
"session_token": "sess_xxx",
"status": "pending",
"created_at": "2026-02-21T10:30:00Z",
"expires_at": "2026-02-21T10:45:00Z"
}Response 400 (invalid provider):
json
{
"error": {
"code": "ERR_PAYMENT_INVALID_PROVIDER",
"message": "Unsupported payment provider",
"details": {
"supported": ["MTN_MOMO", "MOOV_FLOOZ", "WAVE_BENIN", "CELTIIS_CASH"]
}
}
}POST /api/v1/payments/webhook
KKiaPay sends (HTTPS POST):
json
{
"transaction_id": "KKP20260221123456",
"status": "completed",
"amount": 500,
"phone": "+22901979799",
"reference": "forfait_24h_500mb",
"timestamp": 1708512600
}Header:
x-kkiapay-secret: HmacSHA256(body, KKIAPAY_WEBHOOK_SECRET)Our response 200:
json
{
"success": true,
"message": "Webhook processed",
"payment_id": "660e8400-e29b-41d4-a716-446655440001"
}Processing steps:
- Verify header
x-kkiapay-secretwith hmac.compare_digest() - Check idempotency: if
kkiapay_transaction_idexists → return 200 (skip double-processing) - Update DB:
payments.status = completed - Generate voucher for forfait
- Create RADIUS session entry
- Send SMS confirmation (#61)
- Return 200
GET /api/v1/payments/{id}
Response 200:
json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"amount_fcfa": 500,
"provider": "MTN_MOMO",
"phone": "+22901979799",
"status": "completed",
"kkiapay_transaction_id": "KKP20260221123456",
"created_at": "2026-02-21T10:30:00Z",
"completed_at": "2026-02-21T10:31:15Z",
"forfait": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "24h 500MB"
}
}POST /api/v1/payments/{id}/refund
Request:
json
{
"reason": "Transaction en double"
}Response 200 (refund approved):
json
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"status": "refunded",
"refund_amount_fcfa": 500,
"refund_request_id": "REF-KKP20260221123456",
"refund_initiated_at": "2026-02-21T10:35:00Z",
"expected_completion": "2026-02-22T10:35:00Z"
}Response 410 (refund window expired):
json
{
"error": {
"code": "ERR_REFUND_WINDOW_EXPIRED",
"message": "Refund window is 24 hours. This payment is older.",
"details": {
"created_at": "2026-02-20T10:30:00Z",
"window_expires": "2026-02-21T10:30:00Z"
}
}
}Redis Keys
| Clé | Type | TTL | Usage |
|---|---|---|---|
rgz:payment:{payment_id} | Hash | 3600s | Payment metadata + status |
rgz:payment:kkiapay:{transaction_id} | String | 86400s | Idempotency (kkiapay_transaction_id) |
rgz:payment:pending:{subscriber_id} | Set | 3600s | Pending payments pour ce subscriber |
Sécurité
| Règle | Implémentation |
|---|---|
| SEC-03 | Webhook: vérifier header x-kkiapay-secret avec hmac.compare_digest() |
| SEC-03 | Idempotency: rejeter si kkiapay_transaction_id déjà en DB (skip double-crédit) |
| SEC-04 | Montants entiers FCFA uniquement (500, pas 500.0) |
| HTTPS | KKiaPay API calls via HTTPS uniquement |
| Timeout | Transaction timeout 30 minutes (webhook delay) |
| Rate limit | Opt: limit initiate attempts (bot prevention) |
| Refund | Window 24h (configurable) — refund window enforcement |
Implémentation TODO
- [ ] Service
app/services/kkiapay.py(pip install kkiapay) - [ ] POST
/api/v1/payments/initiate - [ ] POST
/api/v1/payments/webhook(signature + idempotency) - [ ] GET
/api/v1/payments/{id} - [ ] POST
/api/v1/payments/{id}/refund - [ ] Table payments migration
- [ ] Voucher auto-generation on completed payment
- [ ] SMS notification on payment success (#61)
- [ ] RADIUS session creation on payment completed
- [ ] Webhook signature verification (hmac.compare_digest)
- [ ] Idempotency key storage (kkiapay_transaction_id)
- [ ] Unit tests KKiaPay client
- [ ] E2E tests payment flow (mock KKiaPay)
Lessons Learned
- LL#16 SEC-03:
hmac.compare_digest(user_signature, expected_signature)— constant-time - LL#26: DB write first (payments.status = completed) → then create voucher + session
- LL#4: FCFA entiers uniquement (500, jamais 500.0)
- LL#8: payment_id, forfait_id = UUID
- LL#3: Webhook response DOIT être 200 + {success: true} (pour KKiaPay retry logic)
- Idempotency: Unique index sur
kkiapay_transaction_iden DB
Dernière mise à jour: 2026-02-21