vnuswilliams / laravel-kpay
Laravel integration for KPay - Mobile Money payment aggregator for Africa
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/boost: ^2.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0
README
Intégration Laravel pour KPay — Agrégateur de paiements Mobile Money pour l'Afrique.
Fonctionnalités
- Paiements (USSD & Gateway) — Encaissez via MTN MoMo, Orange Money, M-Pesa et 20+ opérateurs dans 13 pays africains
- Retraits (USSD & Gateway) — Envoyez de l'argent sur des comptes Mobile Money, y compris transfrontaliers avec conversion automatique
- Transferts entre portefeuilles — Déplacez des fonds entre les portefeuilles pays avec des taux de change en temps réel
- Webhooks — Traitez les mises à jour de statut de manière asynchrone avec support queue et vérification de signature
- Modèles Eloquent — Stockez et interrogez paiements et retraits localement
- Événements — Réagissez à
PaymentCompleted,PayoutFailedet autres changements de statut - API Fluide — Syntaxe chainable et expressive pour toutes les opérations
- Commandes Artisan — Vérifiez les soldes, la disponibilité des opérateurs et testez les webhooks
Prérequis
- PHP 8.2+
- Laravel 12.x ou 13.x
Installation
composer require vnuswilliams/laravel-kpay
php artisan vendor:publish --tag="kpay"
php artisan migrate
Configuration .env
Ajoutez ces variables à votre fichier .env. Chaque variable a un rôle précis :
# ─── Clés API (obtenues sur admin.kpay.site) ──────────────────────────── # La clé API identifie votre application. Le préfixe détermine l'environnement : # kpay_test_xxx → Sandbox (aucune vraie argent ne bouge) # kpay_live_xxx → Production (transactions réelles) KPAY_API_KEY=kpay_test_xxxxxxxxxxxxxxxx # La clé secrète sert à authentifier vos requêtes vers l'API KPay. # Ne JAMAIS la commiter dans Git. KPAY_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxx # ─── Secrets de vérification ────────────────────────────────────────────── # Secret pour vérifier la signature HMAC-SHA256 des webhooks reçus de KPay. # À configurer dans le dashboard KPay (Section Webhooks). KPAY_WEBHOOK_SECRET=your_webhook_secret_here # Secret pour vérifier les signatures des URLs de retour Gateway. # À configurer dans le dashboard KPay (Section Gateway). KPAY_GATEWAY_SECRET=your_gateway_secret_here # ─── Configuration technique ────────────────────────────────────────────── # URL de base de l'API KPay (ne pas modifier sauf instance personnalisée) KPAY_BASE_URL=https://admin.kpay.site # Timeout des requêtes API en secondes KPAY_TIMEOUT=30 # ─── Queue (pour le traitement asynchrone des webhooks) ─────────────────── # Active le traitement webhook via la queue Laravel (recommandé en production) KPAY_QUEUE_ENABLED=true # Connection queue à utiliser (redis, database, sqs, etc.) KPAY_QUEUE_CONNECTION=database # Nom de la queue dédiée aux webhooks KPay KPAY_QUEUE_NAME=kpay # ─── URLs Gateway par défaut ────────────────────────────────────────────── # URLs utilisées automatiquement pour les paiements/retraits Gateway # quand ->returnUrl() ou ->cancelUrl() n'est pas appelé explicitement. # Vous pouvez quand même les surcharger par appel avec ->returnUrl() / ->cancelUrl(). KPAY_RETURN_URL=https://mysite.com/payment/return KPAY_CANCEL_URL=https://mysite.com/payment/cancel
Important : Les clés
KPAY_API_KEYetKPAY_SECRET_KEYs'obtiennent sur admin.kpay.site après avoir créé un compte et une application.
Démarrage rapide
1. Encaisser un paiement USSD
Le mode USSD envoie une notification push directement sur le téléphone du client. Le client reçoit un popup USSD et confirme avec son PIN.
use VnusWilliams\KPay\Facades\KPay; $payment = KPay::payment() ->amount(5000) // Montant brut (minimum 500 XAF au Cameroun) ->provider('MTN_MOMO_CMR') // Opérateur Mobile Money ->phoneNumber('237670000001') // Numéro du client (format international) ->externalId('ORDER-12345') // Votre ID unique (idempotence) ->description('Achat t-shirt KPay') // Description lisible ->customerName('Jean Dupont') // Nom du client ->customerEmail('jean@example.com') // Email du client ->metadata([ // Métadonnées libres (optionnel) 'product_id' => 42, 'size' => 'L', ]) ->create(); // → PaymentData // Résultat : // $payment->id → "pay_abc123def456" (ID KPay) // $payment->reference → "KPAY-XYZ-789" (référence KPay) // $payment->status → PaymentStatus::PENDING // $payment->amount → 5000.0 // $payment->currency → "XAF" // $payment->netAmount → 4750.0 (après commission) // $payment->feeAmount → 250.0 (commission KPay) // $payment->isTest → true (si clés sandbox) // Le client reçoit un popup USSD sur son téléphone. // Une fois le paiement confirmé/échoué/annulé, KPay envoie un webhook.
Comment ça marche :
- Vous appelez
create()→ le package envoie une requête POST à l'API KPay - KPay retourne un
PaymentDataavec le statutPENDING - Le client reçoit un popup USSD sur son téléphone avec le montant et la description
- Le client confirme en entrant son PIN Mobile Money
- KPay met à jour le statut et envoie un webhook à votre endpoint
/kpay/webhook
2. Encaisser via Gateway (page hébergée)
Le mode Gateway héberge une page de paiement sur KPay. Le client choisit lui-même son opérateur et son numéro sur la page hébergée — vous ne passez ni provider ni phoneNumber.
Les URLs de retour (KPAY_RETURN_URL / KPAY_CANCEL_URL) sont lues automatiquement depuis votre .env.
use VnusWilliams\KPay\Facades\KPay; // Minimal — le client choisit opérateur et numéro sur la page KPay $payment = KPay::payment() ->amount(5000) ->externalId('ORDER-12346') ->createGateway(); // Résultat : // $payment->gatewayUrl → "https://gateway.kpay.site/pay/abc123..." // $payment->mode → PaymentMode::GATEWAY // Redirigez le client vers la page de paiement : return redirect($payment->gatewayUrl);
Attention : En mode Gateway,
provider,phoneNumberetcustomerNamesont interdits. Le package lève uneValidationExceptionsi vous les passez. Le client les saisit lui-même sur la page hébergée KPay.
Si vous avez besoin de surcharger les URLs pour une commande spécifique (ex: ajouter un paramètre order_id) :
$payment = KPay::payment() ->amount(5000) ->externalId('ORDER-12346') ->returnUrl(route('payment.return', ['order' => 12346])) // surcharge pour cette commande ->cancelUrl(route('payment.cancel', ['order' => 12346])) ->createGateway();
Comment ça marche :
- Vous appelez
createGateway()→ KPay retourne une URL de paiement - Les URLs de retour sont lues depuis le
.env(ou surchargées via les builders) - Vous redirigez le client vers
$payment->gatewayUrl - Le client choisit lui-même son opérateur et entre son numéro sur la page KPay
- Le client confirme avec son PIN Mobile Money
- Après paiement, KPay redirige le client vers votre
returnUrl - Le webhook
/kpay/webhookest appelé pour confirmer le statut
3. Envoyer un retrait (payout)
$payout = KPay::payout() ->amount(10000) // Montant à retirer ->provider('MTN_MOMO_CMR') // Opérateur du bénéficiaire ->phoneNumber('237670000002') // Numéro du bénéficiaire ->externalId('WD-98765') // Votre ID unique ->description('Retrait commission vendeur') ->create(); // → PayoutData // Vérifiez le solde AVANT de faire un retrait : $balances = KPay::balance(); $xafWallet = $balances->first(fn ($b) => $b->currency === 'XAF'); if ($xafWallet->availableBalance < 10000) { throw new \Exception('Solde insuffisant pour ce retrait'); }
4. Retrait transfrontalier
$payout = KPay::payout() ->amount(50000) // Montant en XAF (devise source) ->provider('ORANGE_SEN') // Opérateur au Sénégal ->phoneNumber('221770000003') // Numéro sénégalais ->sourceCountry('CMR') // Retirer du portefeuille Cameroun ->externalId('PAYOUT-CROSS-001') ->create(); // Résultat : // $payout->payoutCurrency → "XOF" (devise du Sénégal) // $payout->exchangeRate → 0.654 (taux XAF → XOF) // $payout->payoutAmount → 32700 (montant crédité au Sénégal)
5. Transfert entre portefeuilles
// D'abord, récupérez votre applicationId : $appInfo = KPay::applicationInfo(); $applicationId = $appInfo->id; $transfer = KPay::wallet() ->applicationId($applicationId) // UUID de votre application ->fromCountry('CMR') // Portefeuille source (Cameroun) ->toCountry('SEN') // Portefeuille destination (Sénégal) ->amount(100000) // Montant en XAF ->externalId('TRF-2026-001') ->description('Approvisionnement portefeuille Sénégal') ->transfer(); // → WalletTransferData
Configuration des Webhooks
Étape 1 : Endpoint
L'endpoint POST /kpay/webhook est enregistré automatiquement par le package. Aucune configuration de route n'est nécessaire.
Étape 2 : Configurer le secret dans .env
KPAY_WEBHOOK_SECRET=votre_secret_webhook_ici
Ce secret doit correspondre à celui configuré dans votre dashboard KPay. Il sert à vérifier la signature HMAC-SHA256 de chaque webhook reçu.
Étape 3 : Écouter les événements
Dans AppServiceProvider::boot() ou EventServiceProvider :
use VnusWilliams\KPay\Events\PaymentCompleted; use VnusWilliams\KPay\Events\PaymentFailed; use VnusWilliams\KPay\Events\PaymentCancelled; use VnusWilliams\KPay\Events\PayoutCompleted; use VnusWilliams\KPay\Events\PayoutFailed; // ✅ Paiement réussi → Marquer la commande comme payée Event::listen(PaymentCompleted::class, function (PaymentCompleted $event) { $webhook = $event->event; // WebhookEvent DTO // Mettre à jour votre commande Order::where('external_id', $webhook->externalId) ->update(['status' => 'paid']); // Envoyer un email de confirmation Mail::to($customer->email)->send(new PaymentConfirmation($webhook)); }); // ❌ Paiement échoué → Notifier le client Event::listen(PaymentFailed::class, function (PaymentFailed $event) { $webhook = $event->event; Log::warning("Paiement échoué: {$webhook->failureReason}", [ 'external_id' => $webhook->externalId, 'reference' => $webhook->reference, ]); }); // ❌ Paiement annulé Event::listen(PayoutFailed::class, function (PayoutFailed $event) { // Gérer l'échec du retrait $webhook = $event->event; Log::error("Retrait échoué: {$webhook->failureReason}"); }); // ✅ Retrait réussi Event::listen(PayoutCompleted::class, function (PayoutCompleted $event) { $webhook = $event->event; // Mettre à jour votre ledger interne Ledger::where('external_id', $webhook->externalId) ->update(['status' => 'completed']); });
Étape 4 : Queue (recommandé)
Le traitement webhook est dispatché en queue par défaut pour répondre rapidement à KPay (200 OK). Assurez-vous que votre worker queue est actif :
php artisan queue:work --queue=kpay
Étape 5 : Tester le webhook
php artisan kpay:test-webhook https://mysite.com/kpay/webhook --event=payment.completed --status=COMPLETED
Utilitaires
use VnusWilliams\KPay\Facades\KPay; // 💰 Vérifier les soldes de tous les portefeuilles $balances = KPay::balance(); foreach ($balances as $wallet) { echo "{$wallet->currency}: {$wallet->availableBalance} disponible"; } // 🌍 Vérifier la disponibilité des opérateurs par pays $availability = KPay::availability(); foreach ($availability->countries as $country) { echo "Pays: {$country->country}\n"; foreach ($country->providers as $provider) { echo " {$provider->provider}: {$provider->operationTypes[0]['status']}\n"; } } // 💱 Obtenir le taux de change entre deux devises $rate = KPay::exchangeRate('XAF', 'XOF'); $montantConverti = $rate->convert(10000); // → 10153.0 // 📱 Prédire l'opérateur à partir d'un numéro de téléphone $prediction = KPay::predictProvider('237670000001'); // $prediction->provider → "MTN_MOMO_CMR" // $prediction->country → "CMR" // 🏢 Infos de l'application (pour obtenir l'applicationId) $appInfo = KPay::applicationInfo(); echo $appInfo->id; // UUID de votre application
Gestion des erreurs
use VnusWilliams\KPay\Exceptions\ConflictException; use VnusWilliams\KPay\Exceptions\InsufficientBalanceException; use VnusWilliams\KPay\Exceptions\ValidationException; use VnusWilliams\KPay\Exceptions\KPayException; use VnusWilliams\KPay\Exceptions\AuthenticationException; try { $payment = KPay::payment() ->amount(5000) ->provider('MTN_MOMO_CMR') ->phoneNumber('237670000001') ->externalId('ORDER-12345') ->create(); } catch (ConflictException $e) { // L'externalId est déjà utilisé (idempotence) // → Récupérer le paiement existant avec KPay::paymentStatus() } catch (InsufficientBalanceException $e) { // Solde insuffisant dans le portefeuille KPay // → Notifier l'admin ou réessayer plus tard } catch (ValidationException $e) { // Paramètres invalides (montant trop bas, numéro invalide, etc.) // → Corriger les paramètres et réessayer } catch (AuthenticationException $e) { // Clés API invalides ou expirées // → Vérifier KPAY_API_KEY et KPAY_SECRET_KEY dans .env } catch (KPayException $e) { // Erreur API générale Log::error('Erreur KPay', [ 'status' => $e->getStatusCode(), 'message' => $e->getMessage(), ]); }
Modèles Eloquent
Les paiements et retraits sont automatiquement stockés en base de données via les migrations publiées.
use VnusWilliams\KPay\Models\KPayPayment; use VnusWilliams\KPay\Models\KPayPayout; // Rechercher par statut $completed = KPayPayment::completed()->get(); $pending = KPayPayout::pending()->get(); // Rechercher par external_id $payment = KPayPayment::where('external_id', 'ORDER-12345')->first(); // Vérifier si un paiement est terminé if ($payment->isTerminal()) { // Le paiement est COMPLETED, FAILED ou CANCELLED } // Vérifier si c'est un paiement réussi if ($payment->isSuccess()) { // Le paiement est COMPLETED } // Vérifier si c'est un paiement Gateway if ($payment->isGateway()) { echo $payment->gateway_url; }
Commandes Artisan
# Afficher les soldes de tous les portefeuilles php artisan kpay:check-balance # Vérifier la disponibilité des opérateurs par pays php artisan kpay:check-availability # Envoyer un webhook test signé à votre endpoint php artisan kpay:test-webhook https://mysite.com/kpay/webhook php artisan kpay:test-webhook https://mysite.com/kpay/webhook --event=payout.completed --status=COMPLETED
Exemple complet : Intégrer KPay dans un contrôleur Laravel
Voici un exemple concret d'intégration dans un contrôleur de e-commerce :
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Str; use VnusWilliams\KPay\Facades\KPay; use VnusWilliams\KPay\Exceptions\KPayException; class PaymentController extends Controller { /** * Initier le paiement d'une commande. */ public function store(Request $request) { $request->validate([ 'order_id' => 'required|exists:orders,id', 'phone_number' => 'required|string', 'provider' => 'required|string', ]); $order = $request->user()->orders()->findOrFail($request->order_id); // Générer un ID unique pour cette transaction $externalId = 'ORDER-' . $order->id . '-' . Str::random(8); try { $payment = KPay::payment() ->amount($order->total_amount) ->provider($request->provider) ->phoneNumber($request->phone_number) ->externalId($externalId) ->description("Commande #{$order->id}") ->customerName($request->user()->name) ->customerEmail($request->user()->email) ->metadata([ 'order_id' => $order->id, 'items_count' => $order->items->count(), ]) ->create(); // Stocker la référence KPay dans la commande $order->update([ 'kpay_reference' => $payment->reference, 'kpay_external_id' => $externalId, 'payment_status' => 'pending', ]); return response()->json([ 'message' => 'Paiement initié. Vérifiez votre téléphone.', 'payment_id' => $payment->id, 'status' => $payment->status->value, ]); } catch (KPayException $e) { return response()->json([ 'message' => 'Erreur lors de l\'initiation du paiement.', 'error' => $e->getMessage(), ], 422); } } /** * Vérifier le statut d'un paiement. */ public function show(string $externalId) { $order = auth()->user()->orders() ->where('kpay_external_id', $externalId) ->firstOrFail(); // Interroger l'API KPay pour le statut en temps réel $payment = KPay::paymentStatus($order->kpay_reference); return response()->json([ 'status' => $payment->status->value, 'amount' => $payment->amount, 'currency' => $payment->currency, 'completed_at' => $payment->completedAt, ]); } }
Dans EventServiceProvider ou AppServiceProvider::boot() :
use VnusWilliams\KPay\Events\PaymentCompleted; Event::listen(PaymentCompleted::class, function (PaymentCompleted $event) { $externalId = $event->event->externalId; // Extraire l'order_id depuis l'externalId (format: "ORDER-{id}-{hash}") $parts = explode('-', $externalId); $orderId = $parts[1] ?? null; if ($orderId) { Order::where('id', $orderId)->update([ 'payment_status' => 'paid', 'paid_at' => now(), ]); // Déclencher la logique post-paiement (envoi email, préparation commande...) } });
Pays et opérateurs supportés
| Pays | Opérateur | Code | Devise | Décimales |
|---|---|---|---|---|
| Bénin | MTN | MTN_MOMO_BEN |
XOF | Non |
| Bénin | Moov | MOOV_BEN |
XOF | Non |
| Cameroun | MTN | MTN_MOMO_CMR |
XAF | Non |
| Cameroun | Orange | ORANGE_CMR |
XAF | Non |
| Côte d'Ivoire | MTN | MTN_MOMO_CIV |
XOF | Non |
| Côte d'Ivoire | Orange | ORANGE_CIV |
XOF | Non |
| RD Congo | Vodacom | VODACOM_MPESA_COD |
CDF | Non |
| RD Congo | Airtel | AIRTEL_COD |
CDF | Oui |
| RD Congo | Orange | ORANGE_COD |
CDF | Oui |
| Gabon | Airtel | AIRTEL_GAB |
XAF | Oui |
| Kenya | M-Pesa | MPESA_KEN |
KES | Non |
| Congo | Airtel | AIRTEL_COG |
XAF | Non |
| Congo | MTN | MTN_MOMO_COG |
XAF | Non |
| Rwanda | Airtel | AIRTEL_RWA |
XAF | Non |
| Rwanda | MTN | MTN_MOMO_RWA |
XAF | Non |
| Sénégal | Free | FREE_SEN |
XOF | Non |
| Sénégal | Orange | ORANGE_SEN |
XOF | Non |
| Sierra Leone | Orange | ORANGE_SLE |
SLE | Oui |
| Ouganda | Airtel | AIRTEL_OAPI_UGA |
UGX | Non |
| Ouganda | MTN | MTN_MOMO_UGA |
UGX | Oui |
| Zambie | Airtel | AIRTEL_OAPI_ZMB |
ZMW | Oui |
| Zambie | MTN | MTN_MOMO_ZMB |
ZMW | Oui |
| Zambie | Zamtel | ZAMTEL_ZMB |
ZMW | Oui |
Contribuer
Voir CONTRIBUTING.md pour les directives.
Licence
The MIT License (MIT). Voir LICENSE.md pour plus d'informations.