Skip to content

#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 webhook

KKiaPay 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

ProviderOpérateurCode
MTN_MOMOMTN Mobile Money BéninMOMO
MOOV_FLOOZMoov Flooz BéninFLOOZ
WAVE_BENINWave BéninWAVE
CELTIIS_CASHCeltiis Cash BéninCELTIIS

Endpoints API

MéthodeRouteBodyRéponseAuthNotes
POST/api/v1/payments/initiate{amount_fcfa, provider, phone, forfait_id}201 {payment_id, redirect_url}JWTKKiaPay init
POST/api/v1/payments/webhookKKiaPay payload200SignatureWebhook handler
GET/api/v1/payments/{id}-200 {payment, status}JWTStatus check
POST/api/v1/payments/{id}/refund{reason}200Admin JWTRefund 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:

  1. Verify header x-kkiapay-secret with hmac.compare_digest()
  2. Check idempotency: if kkiapay_transaction_id exists → return 200 (skip double-processing)
  3. Update DB: payments.status = completed
  4. Generate voucher for forfait
  5. Create RADIUS session entry
  6. Send SMS confirmation (#61)
  7. 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éTypeTTLUsage
rgz:payment:{payment_id}Hash3600sPayment metadata + status
rgz:payment:kkiapay:{transaction_id}String86400sIdempotency (kkiapay_transaction_id)
rgz:payment:pending:{subscriber_id}Set3600sPending payments pour ce subscriber

Sécurité

RègleImplémentation
SEC-03Webhook: vérifier header x-kkiapay-secret avec hmac.compare_digest()
SEC-03Idempotency: rejeter si kkiapay_transaction_id déjà en DB (skip double-crédit)
SEC-04Montants entiers FCFA uniquement (500, pas 500.0)
HTTPSKKiaPay API calls via HTTPS uniquement
TimeoutTransaction timeout 30 minutes (webhook delay)
Rate limitOpt: limit initiate attempts (bot prevention)
RefundWindow 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_id en DB

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

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