Accept payments from Wave, Orange Money, PayDunya, PayTech, Stripe & PayPal in your Laravel app with a single, clean API.
Les développeurs en Afrique de l'Ouest intègrent manuellement chaque passerelle de paiement dans chaque projet. Wave, Orange Money, PayDunya, PayTech... chacun avec son API, ses webhooks, ses signatures.
AfriPay unifie tout ca en une seule interface :
// Payer via Wave
$payment = AfriPay::via('wave')->charge([
'amount' => 15000,
'currency' => 'XOF',
'description' => 'Abonnement Premium',
'success_url' => route('payment.success'),
'error_url' => route('payment.error'),
]);
return redirect($payment['redirect_url']);Changer de passerelle ? Une seule ligne :
AfriPay::via('stripe')->charge([...]);
AfriPay::via('paydunya')->charge([...]);
AfriPay::via('orange_money')->charge([...]);| Passerelle | Pays | Type | Statut |
|---|---|---|---|
| Wave | SN, CI, ML, BF | Mobile Money | Production |
| Orange Money | SN, CI, ML, BF, CM, GN | Mobile Money | Beta |
| PayDunya | SN, CI, BJ, TG, BF, ML | Multi-canal | Production |
| PayTech | SN | Multi-canal | Production |
| Stripe | Global | Carte bancaire | Production |
| PayPal | Global | International | Production |
composer require sunucode/afripayUne seule commande génère tout le nécessaire :
php artisan afripay:install
php artisan migrateLa commande crée :
config/afripay.php— configuration des passerellesapp/Http/Controllers/AfriPayController.php— controller avecsuccess()eterror()resources/views/payment/{success,pending,error}.blade.php— vues de retour (HTML simple, à intégrer dans votre layout)- Les routes
/payment/success/{reference}et/payment/error/{reference}dansroutes/web.php - Les event listeners dans
AppServiceProvider::boot()
Par défaut, le controller est créé dans app/Http/Controllers/. Pour le placer ailleurs :
# Exemple : app/Http/Controllers/Payment/AfriPayController.php
php artisan afripay:install --controller-path=Http/Controllers/Paymentphp artisan vendor:publish --tag=afripay-config
php artisan migratePuis suivez les sections Écouter les événements et Gestion du retour ci-dessous.
Ajoutez vos clés dans .env :
# Passerelle par défaut
AFRIPAY_DEFAULT_GATEWAY=wave
AFRIPAY_CURRENCY=XOF
# Sécurité : seuls les webhooks peuvent confirmer un paiement (recommandé)
# Mettre à false en dev si les webhooks ne peuvent pas atteindre votre serveur
AFRIPAY_TRUST_WEBHOOK_ONLY=true
# Activer/désactiver les passerelles individuellement
AFRIPAY_WAVE_ENABLED=true
AFRIPAY_STRIPE_ENABLED=true
AFRIPAY_PAYDUNYA_ENABLED=true
AFRIPAY_PAYTECH_ENABLED=true
AFRIPAY_ORANGE_MONEY_ENABLED=false
AFRIPAY_PAYPAL_ENABLED=false
# Wave
WAVE_API_KEY=wave_sn_...
# WAVE_API_SECRET (signing secret) : OPTIONNEL
# Renseigner UNIQUEMENT si "Request Signing" est activé sur la clé API Wave.
# Si non activé, laisser vide.
WAVE_API_SECRET=
# WAVE_WEBHOOK_SECRET : OBLIGATOIRE en production si AFRIPAY_TRUST_WEBHOOK_ONLY=true
WAVE_WEBHOOK_SECRET=wave_sn_WHS_...
# Stripe
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# PayDunya
PAYDUNYA_MASTER_KEY=...
PAYDUNYA_PRIVATE_KEY=...
PAYDUNYA_TOKEN=...
PAYDUNYA_MODE=test
# Orange Money
ORANGE_MONEY_CLIENT_ID=...
ORANGE_MONEY_CLIENT_SECRET=...
ORANGE_MONEY_MERCHANT_KEY=...
# PayPal
PAYPAL_CLIENT_ID=...
PAYPAL_CLIENT_SECRET=...
PAYPAL_MODE=sandbox
# PayTech
PAYTECH_API_KEY=...
PAYTECH_API_SECRET=...
PAYTECH_ENV=testuse SunuCode\AfriPay\Facades\AfriPay;
$payment = AfriPay::via('wave')->charge([
'amount' => 25000,
'currency' => 'XOF',
'description' => 'Commande #1234',
'success_url' => route('orders.payment.success'),
'error_url' => route('orders.payment.error'),
'metadata' => [
'order_id' => 1234,
'user_id' => auth()->id(),
],
]);
// $payment['redirect_url'] -> URL de paiement (rediriger l'utilisateur)
// $payment['transaction'] -> Instance Transaction (sauvegardée en DB)
return redirect($payment['redirect_url']);Passez payable_type et payable_id pour lier la transaction à n'importe quel modèle de votre application. C'est ce lien qui permet de router la logique métier dans vos listeners (voir section suivante).
// Abonnement
$payment = AfriPay::via('wave')->charge([
'amount' => 9900,
'success_url' => route('payment.success'),
'error_url' => route('payment.error'),
'payable_type' => Subscription::class,
'payable_id' => $subscription->id,
]);
// Commande e-commerce
$payment = AfriPay::via('paydunya')->charge([
'amount' => 25000,
'success_url' => route('payment.success'),
'error_url' => route('payment.error'),
'payable_type' => Order::class,
'payable_id' => $order->id,
]);
// Recharge de wallet (sans modèle lié)
$payment = AfriPay::via('orange_money')->charge([
'amount' => 5000,
'success_url' => route('payment.success'),
'error_url' => route('payment.error'),
'metadata' => ['user_id' => auth()->id(), 'type' => 'wallet_topup'],
]);Important : Laravel auto-découvre uniquement les listeners pour les events dans
App\Events\*. Les events d'un package vendor comme AfriPay (SunuCode\AfriPay\Events\*) ne sont jamais auto-découverts. Vous devez les enregistrer manuellement.
Si vous avez utilisé php artisan afripay:install, les listeners sont déjà enregistrés avec des // TODO à compléter. Sinon, ajoutez-les dans AppServiceProvider::boot().
Cas simple — une seule logique de paiement :
Event::listen(PaymentCompleted::class, function ($event) {
$order = $event->transaction->payable;
$order->markAsPaid();
});Cas courant — plusieurs logiques (abonnement, commande, recharge...) :
Le payable_type que vous passez au charge() permet de router automatiquement vers la bonne logique. Utilisez match() sur le type polymorphique :
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Event;
use SunuCode\AfriPay\Events\PaymentCompleted;
use SunuCode\AfriPay\Events\PaymentFailed;
use SunuCode\AfriPay\Events\PaymentRefunded;
public function boot(): void
{
Event::listen(PaymentCompleted::class, function ($event) {
$transaction = $event->transaction;
$payable = $transaction->payable; // Le modèle lié (Order, Subscription...)
match ($transaction->payable_type) {
\App\Models\Subscription::class => $payable->activate(),
\App\Models\Order::class => $payable->markAsPaid(),
default => $this->handleGenericPayment($transaction),
};
});
Event::listen(PaymentFailed::class, function ($event) {
$transaction = $event->transaction;
match ($transaction->payable_type) {
\App\Models\Order::class => $transaction->payable->cancel(),
default => null,
};
// Notifier l'utilisateur dans tous les cas
// Notification::send($transaction->payable?->user, new PaymentFailedNotification($transaction));
});
Event::listen(PaymentRefunded::class, function ($event) {
// $event->reason contient le motif du remboursement
});
}Avec des classes Listener dédiées (recommandé pour les gros projets) :
Event::listen(PaymentCompleted::class, HandleCompletedPayment::class);
Event::listen(PaymentFailed::class, HandleFailedPayment::class);Quand l'utilisateur est redirigé vers votre success_url après le paiement, le webhook n'est pas forcément encore arrivé. Vous devez appeler verifyAndProcess() dans votre controller de retour pour confirmer le paiement :
Si vous avez utilisé php artisan afripay:install, le controller est déjà généré avec cette logique. Sinon, voici le code à ajouter dans votre controller de retour :
use SunuCode\AfriPay\Facades\AfriPay;
use SunuCode\AfriPay\Models\Transaction as AfriPayTransaction;
public function success(string $reference)
{
$transaction = AfriPayTransaction::where('reference', $reference)->firstOrFail();
// Vérifie auprès de la passerelle ET dispatche PaymentCompleted si confirmé
$transaction = AfriPay::verifyAndProcess($transaction);
if ($transaction->status->isCompleted()) {
return view('payment.success', compact('transaction'));
}
// Le paiement n'est pas encore confirmé (webhook en attente)
return view('payment.pending', compact('transaction'));
}Sans cet appel, si le webhook arrive en retard (ou jamais en dev local), l'utilisateur verra une page de succès mais votre logique métier ne sera jamais exécutée.
$transaction = AfriPay::refund($transaction, 'Client insatisfait');
// Dispatche PaymentRefunded// Toutes les passerelles activées via .env
$gateways = AfriPay::enabledGateways();
// ['wave', 'stripe', 'paydunya', 'paytech']
// Vérifier si une passerelle est active
if (AfriPay::isEnabled('orange_money')) {
// ...
}# PRODUCTION (recommandé) — seul le webhook peut confirmer un paiement
AFRIPAY_TRUST_WEBHOOK_ONLY=true
# DÉVELOPPEMENT — l'URL de retour peut aussi confirmer
AFRIPAY_TRUST_WEBHOOK_ONLY=falseQuand trust_webhook_only=true, verifyAndProcess() vérifie le statut auprès de la passerelle mais ne dispatche aucun événement. Seul le webhook déclenche PaymentCompleted. C'est plus sûr car ça empêche un utilisateur de forger une URL de succès.
trust_webhook_only=true, configurez le secret webhook de chaque passerelle active (ex: WAVE_WEBHOOK_SECRET) ; sinon la confirmation de paiement par webhook ne pourra pas être validée.
Quand trust_webhook_only=false, les deux chemins (webhook ET URL de retour) peuvent déclencher les événements. Utile en dev local quand les webhooks ne peuvent pas atteindre votre machine.
Piège courant en développement : Si vous développez en local sans tunnel (ngrok, Expose...), les webhooks ne peuvent pas atteindre votre machine. Avec
AFRIPAY_TRUST_WEBHOOK_ONLY=true(défaut),verifyAndProcess()ne déclenchera aucun event et vos paiements resteront enpending.Solution : Mettez
AFRIPAY_TRUST_WEBHOOK_ONLY=falsedans votre.envlocal. N'oubliez pas de remettretrueen production.
// Dans un ServiceProvider
use SunuCode\AfriPay\PaymentManager;
PaymentManager::extend('cinetpay', function (array $config) {
return new CinetPayGateway($config);
});
// Utilisation
AfriPay::via('cinetpay')->charge([...]);Les webhooks sont automatiquement enregistrés à :
POST /afripay/webhooks/wave
POST /afripay/webhooks/stripe
POST /afripay/webhooks/paydunya
POST /afripay/webhooks/orange-money
POST /afripay/webhooks/paytech
POST /afripay/webhooks/paypal
Le chemin est configurable via AFRIPAY_WEBHOOK_PATH.
Chaque webhook :
- Vérifie la signature (HMAC-SHA256 pour Wave/Stripe/PayTech, master_key pour PayDunya)
- Vérifie le montant (tolérance +/- 1 unité)
- Utilise
lockForUpdate()pour éviter les doublons - Dispatche
PaymentCompletedouPaymentFailed
- Idempotence : Le champ
processed_atempêche le double-traitement - Verrouillage DB :
lockForUpdate()sur chaque transaction pendant le webhook - Vérification de montant : Tolérance +/- 1 unité avant d'accepter
- Anti-replay : Timestamps vérifiés (Wave, Stripe) avec tolérance de 5 min
- Zero-decimal : XOF/XAF gérés automatiquement (pas de x100 pour Stripe)
- Orange Money : Contre-vérification API obligatoire (pas de signature webhook)
| Événement | Quand | Données |
|---|---|---|
PaymentInitiated |
Après charge() |
$transaction, $gateway |
PaymentCompleted |
Webhook confirmé | $transaction |
PaymentFailed |
Webhook échoué | $transaction |
PaymentRefunded |
Après refund() |
$transaction, $reason |
West African developers manually integrate each payment gateway in every project. Wave, Orange Money, PayDunya, PayTech... each with its own API, webhooks, and signatures.
AfriPay unifies everything into a single interface:
$payment = AfriPay::via('wave')->charge([
'amount' => 15000,
'currency' => 'XOF',
'description' => 'Premium Subscription',
'success_url' => route('payment.success'),
'error_url' => route('payment.error'),
]);
return redirect($payment['redirect_url']);composer require sunucode/afripay
# Quick setup (recommended) — scaffolds controller, views, routes, and listeners
php artisan afripay:install
php artisan migrateTo place the controller in a custom directory:
php artisan afripay:install --controller-path=Http/Controllers/PaymentOr set up manually: php artisan vendor:publish --tag=afripay-config and follow the sections below.
Important: Laravel only auto-discovers listeners for events in
App\Events\*. Events from a vendor package like AfriPay (SunuCode\AfriPay\Events\*) are never auto-discovered. You must register them manually.
If you used php artisan afripay:install, listeners are already registered with // TODO placeholders. Otherwise, add them in AppServiceProvider::boot().
Use the payable_type set during charge() to route to the right business logic:
use Illuminate\Support\Facades\Event;
use SunuCode\AfriPay\Events\PaymentCompleted;
use SunuCode\AfriPay\Events\PaymentFailed;
public function boot(): void
{
Event::listen(PaymentCompleted::class, function ($event) {
$transaction = $event->transaction;
$payable = $transaction->payable;
match ($transaction->payable_type) {
\App\Models\Subscription::class => $payable->activate(),
\App\Models\Order::class => $payable->markAsPaid(),
default => null,
};
});
Event::listen(PaymentFailed::class, function ($event) {
// Notify user, log failure, etc.
});
}When the user is redirected to your success_url, the webhook may not have arrived yet. You must call verifyAndProcess() in your return controller:
use SunuCode\AfriPay\Facades\AfriPay;
use SunuCode\AfriPay\Models\Transaction as AfriPayTransaction;
public function success(string $reference)
{
$transaction = AfriPayTransaction::where('reference', $reference)->firstOrFail();
$transaction = AfriPay::verifyAndProcess($transaction);
if ($transaction->status->isCompleted()) {
return view('payment.success', compact('transaction'));
}
return view('payment.pending', compact('transaction'));
}Common pitfall in development: Without a tunnel (ngrok, Expose...), webhooks can't reach your local machine. With
AFRIPAY_TRUST_WEBHOOK_ONLY=true(default),verifyAndProcess()will not dispatch any events and your payments will staypending.Fix: Set
AFRIPAY_TRUST_WEBHOOK_ONLY=falsein your local.env. Remember to set it back totruein production.
Extend AfriPay with your own gateways:
use SunuCode\AfriPay\Contracts\GatewayInterface;
use SunuCode\AfriPay\PaymentManager;
class CinetPayGateway implements GatewayInterface
{
// Implement the 4 methods: charge(), handleWebhook(), verify(), verifySignature()
}
PaymentManager::extend('cinetpay', fn($config) => new CinetPayGateway($config));- Idempotent processing via atomic
processed_atflag - Database locking (
lockForUpdate) prevents race conditions - Amount verification with configurable tolerance
- Replay protection with timestamp validation (Wave, Stripe)
- Zero-decimal currencies (XOF, XAF) handled automatically
- Orange Money: Mandatory API counter-verification (no webhook signature)
- PHP >= 8.2
- Laravel 11, 12, or 13
- A database supporting
lockForUpdate()(MySQL, PostgreSQL)
Contributions are welcome! Please submit pull requests to the main branch.
- Built by Sunu Code — Software agency based in Dakar, Senegal
- Extracted from Semplio — Business management SaaS for African SMEs
MIT License. See LICENSE for details.