Sur une boutique, le paiement n’est pas un appel d’API isolé : c’est l’aboutissement d’un tunnel — panier, commande, choix du moyen de paiement, redirection, retour, livraison. Et vos clients ouest-africains veulent choisir : l’un paiera via PayDunya, l’autre via CinetPay. Le réflexe du débutant est de coller deux intégrations côte à côte, avec deux fois la même logique dupliquée. On va faire mieux : une commande pilotée par une machine à états et une passerelle de paiement unifiée derrière laquelle PayDunya et CinetPay deviennent interchangeables. À la fin, ajouter un troisième fournisseur tiendra en une classe.
Ce que vous allez apprendre
- Modéliser une commande e-commerce comme une machine à états fiable ;
- Définir une interface de passerelle de paiement commune à plusieurs fournisseurs ;
- Implémenter PayDunya (checkout-invoice) et CinetPay (/v2/payment) derrière cette interface ;
- Laisser le client choisir son fournisseur, puis confirmer le paiement par callback vérifié ;
- Faire basculer la commande de « en attente » à « payée » sans jamais doubler la livraison.
Ce que vous allez construire
Le fil rouge : le tunnel de paiement d’une boutique. Au passage en caisse, le client choisit PayDunya ou CinetPay ; votre serveur crée la facture chez le bon fournisseur, redirige, puis — via un callback — confirme le paiement et marque la commande prête à préparer. Concret : deux fournisseurs, une seule logique métier, zéro duplication.
Prérequis
- PHP 8.1+ (le code est volontairement sans framework pour être transposable à Laravel, Symfony ou WooCommerce).
- Des comptes PayDunya (Master/Private/Token keys) et CinetPay (API Key, Site ID).
- Une base avec une table de commandes et un domaine HTTPS public pour les callbacks.
- ⏱️ Temps estimé : ~1 heure. Test express : si vous savez écrire une interface et une classe en PHP, vous êtes prêt.
Étape 1 — La commande, une machine à états
Avant le moindre appel de paiement, fixez les états par lesquels une commande peut passer. C’est ce qui empêche les bugs les plus chers : livrer une commande non payée, ou la livrer deux fois. Une commande naît en_attente_paiement et ne devient payee que sur confirmation serveur ; la préparation/livraison ne se déclenche qu’à partir de là.
// États autorisés et transitions
// en_attente_paiement → payee → preparee → livree
// → echouee
// → annulee
class Order
{
public int $id;
public int $total; // entier XOF, calculé côté serveur
public string $status; // en_attente_paiement | payee | echouee | ...
public function markPaid(): void
{
if ($this->status === 'payee') {
return; // idempotence : déjà payée, on ne refait rien
}
$this->status = 'payee';
$this->save();
// déclencher ici : email de confirmation, réservation du stock…
}
}
Le garde if ($this->status === 'payee') return; est le cœur de l’idempotence : un callback rejoué (ça arrive) ne relancera ni l’email ni la préparation. Notez encore une fois le total en entier, calculé côté serveur — jamais lu depuis le panier envoyé par le navigateur.
Étape 2 — Une passerelle de paiement unifiée
Plutôt que d’éparpiller des if ($fournisseur === 'paydunya') partout, on définit un contrat commun. Chaque fournisseur saura faire deux choses : créer une session de paiement (et renvoyer l’URL où envoyer le client), et confirmer qu’une référence est bien payée. Le reste de l’application ne connaîtra que ce contrat.
interface PaymentGateway
{
// Crée la session côté fournisseur et renvoie l'URL de redirection.
public function createCheckout(Order $order): string;
// Vérifie côté serveur qu'une référence est réellement payée.
public function isPaid(string $reference): bool;
}
Cette abstraction n’est pas de l’élégance gratuite : c’est elle qui rend le code testable, et qui fera qu’ajouter Wave ou un autre acteur demain ne touchera pas à votre logique de commande. On implémente maintenant les deux fournisseurs.
Remarquez ce que l’interface ne contient PAS : aucune référence à votre base de données. Une passerelle parle au fournisseur, point ; c’est la commande qui gère son propre état. Cette séparation des responsabilités — le fournisseur encaisse, la commande décide — rend chaque morceau testable isolément et remplaçable sans casser les autres.
Étape 3 — Implémenter PayDunya
PayDunya fonctionne par checkout-invoice : on crée une facture via son API, elle renvoie une URL de paiement vers laquelle on redirige le client. L’authentification se fait par trois clés passées en en-têtes. Un response_code à '00' signale la création réussie ; on garde le token pour confirmer plus tard.
class PaydunyaGateway implements PaymentGateway
{
public function createCheckout(Order $order): string
{
$payload = [
'invoice' => [
'total_amount' => $order->total,
'description' => 'Commande #'.$order->id,
],
'store' => ['name' => 'Boutique Diallo'],
'actions' => [
'return_url' => 'https://boutique.sn/paiement/retour?order='.$order->id,
'cancel_url' => 'https://boutique.sn/paiement/annule',
'callback_url' => 'https://boutique.sn/paiement/paydunya/callback',
],
];
$res = $this->post('https://app.paydunya.com/api/v1/checkout-invoice/create', $payload, [
'PAYDUNYA-MASTER-KEY: '.getenv('PAYDUNYA_MASTER_KEY'),
'PAYDUNYA-PRIVATE-KEY: '.getenv('PAYDUNYA_PRIVATE_KEY'),
'PAYDUNYA-TOKEN: '.getenv('PAYDUNYA_TOKEN'),
]);
if (($res['response_code'] ?? null) !== '00') {
throw new RuntimeException('PayDunya : '.($res['response_text'] ?? 'échec'));
}
$order->savePaymentRef('paydunya', $res['token']);
return $res['response_text']; // l'URL de paiement
}
public function isPaid(string $token): bool
{
$res = $this->get('https://app.paydunya.com/api/v1/checkout-invoice/confirm/'.$token, [
'PAYDUNYA-MASTER-KEY: '.getenv('PAYDUNYA_MASTER_KEY'),
'PAYDUNYA-PRIVATE-KEY: '.getenv('PAYDUNYA_PRIVATE_KEY'),
'PAYDUNYA-TOKEN: '.getenv('PAYDUNYA_TOKEN'),
]);
return ($res['status'] ?? null) === 'completed';
}
}
Le response_text contient l’URL de paiement : c’est là qu’on enverra le client. Et isPaid() n’invente rien — il rappelle l’endpoint confirm de PayDunya, seul juge du paiement réel. En sandbox, l’URL de base devient https://app.paydunya.com/sandbox-api/v1/... avec vos clés de test.
Étape 4 — Implémenter CinetPay
Même contrat, autre fournisseur. CinetPay initialise un paiement via /v2/payment et renvoie une payment_url ; la vérification se fait via /v2/payment/check. On reconnaît le motif du tutoriel CinetPay du cluster, ici plié dans la même interface.
class CinetpayGateway implements PaymentGateway
{
public function createCheckout(Order $order): string
{
$txId = 'CMD-'.$order->id.'-'.time();
$res = $this->post('https://api-checkout.cinetpay.com/v2/payment', [
'apikey' => getenv('CINETPAY_API_KEY'),
'site_id' => getenv('CINETPAY_SITE_ID'),
'transaction_id' => $txId,
'amount' => $order->total, // multiple de 5
'currency' => 'XOF',
'description' => 'Commande #'.$order->id,
'notify_url' => 'https://boutique.sn/paiement/cinetpay/callback',
'return_url' => 'https://boutique.sn/paiement/retour?order='.$order->id,
'channels' => 'ALL',
]);
if (($res['code'] ?? null) !== '201') {
throw new RuntimeException('CinetPay : initialisation refusée');
}
$order->savePaymentRef('cinetpay', $txId);
return $res['data']['payment_url'];
}
public function isPaid(string $txId): bool
{
$res = $this->post('https://api-checkout.cinetpay.com/v2/payment/check', [
'apikey' => getenv('CINETPAY_API_KEY'),
'site_id' => getenv('CINETPAY_SITE_ID'),
'transaction_id' => $txId,
]);
return ($res['code'] ?? null) === '00'
&& ($res['data']['status'] ?? null) === 'ACCEPTED';
}
}
Les deux classes exposent rigoureusement la même interface. Du point de vue de votre tunnel de commande, PayDunya et CinetPay sont devenus interchangeables : c’est tout l’intérêt.
Étape 4 bis — Relier la référence du fournisseur à la commande
Chaque passerelle appelle $order->savePaymentRef('paydunya', $token) : on mémorise, pour cette commande, le fournisseur choisi et la référence qu’il nous a rendue. C’est ce lien qui permet, au callback, de retrouver la commande à partir de la référence reçue. Sans lui, une notification arriverait « orpheline » et vous ne sauriez pas quelle commande créditer.
public function savePaymentRef(string $provider, string $ref): void
{
$this->payment_provider = $provider;
$this->payment_ref = $ref;
$this->save();
}
public static function findByPaymentRef(string $provider, string $ref): ?Order
{
return self::query()
->where('payment_provider', $provider)
->where('payment_ref', $ref)
->first();
}
Indexez la colonne payment_ref : les callbacks doivent retrouver la commande instantanément, même sous charge.
Étape 5 — Laisser le client choisir et rediriger
Au passage en caisse, le client coche son fournisseur. Une petite fabrique instancie la bonne passerelle, crée la session, et on redirige. Toute la logique spécifique vit dans les classes ; le contrôleur, lui, reste trivial et ne change jamais quand on ajoute un fournisseur.
function gatewayFor(string $choix): PaymentGateway
{
return match ($choix) {
'paydunya' => new PaydunyaGateway(),
'cinetpay' => new CinetpayGateway(),
default => throw new InvalidArgumentException('Fournisseur inconnu'),
};
}
// Contrôleur de paiement
$order = Order::find($_POST['order_id']); // total rechargé depuis la base
$gateway = gatewayFor($_POST['fournisseur']);
$url = $gateway->createCheckout($order);
header('Location: '.$url); // redirection vers le fournisseur
Le match centralise le seul endroit où l’on nomme les fournisseurs. Order::find() recharge la commande — et donc le vrai total — depuis la base : la valeur du panier côté client ne sert qu’à l’affichage.
Étape 6 — Les callbacks : confirmer et basculer
Chaque fournisseur appelle son callback_url quand le paiement aboutit. Comme partout dans ce cluster, on ne croit pas le contenu reçu : on retrouve la commande, et on demande au fournisseur si elle est vraiment payée via isPaid(). Ce n’est qu’alors qu’on appelle markPaid() — lui-même idempotent.
// /paiement/paydunya/callback
$token = $_POST['data']['invoice']['token'] ?? $_POST['token'] ?? null;
$order = Order::findByPaymentRef('paydunya', $token);
if ($order && (new PaydunyaGateway())->isPaid($token)) {
$order->markPaid();
}
http_response_code(200);
Le callback CinetPay suit exactement la même forme avec sa propre référence de transaction. Deux fournisseurs, un même réflexe : retrouver, vérifier côté serveur, basculer une fois. Répondez toujours en 200 pour que le fournisseur cesse de réémettre.
Un point de robustesse souvent négligé : l’appel isPaid() part vers un serveur distant, qui peut être lent ou injoignable une fraction de seconde. Donnez un timeout à vos requêtes HTTP, et en cas d’échec réseau ne marquez surtout pas la commande « échouée » — laissez-la en_attente_paiement pour que la réconciliation la rattrape. Un timeout n’est pas un refus ; confondre les deux, c’est annuler des commandes pourtant payées.
Étape 7 — Page de statut et réconciliation
La page de retour lit l’état de la commande dans votre base et l’affiche : « paiement confirmé, votre commande est en préparation » ou « en attente de confirmation ». Elle n’encaisse rien. Et parce qu’un callback peut se perdre sur un réseau capricieux, ajoutez un job planifié qui repasse sur les commandes restées en_attente_paiement depuis plus de quelques minutes et appelle isPaid() — votre filet de réconciliation, identique pour les deux fournisseurs grâce à l’interface commune.
✅ Point d’étape — Passez une commande avec chaque fournisseur en sandbox. Vous devez voir la commande naître
en_attente_paiement, puis basculerpayeeaprès le callback. Si elle reste bloquée, lecallback_urln’est pas joignable (HTTPS, public) ou la vérification serveur échoue.
Testez les deux fournisseurs séparément en bac à sable avant de les proposer ensemble. PayDunya et CinetPay ont chacun leur environnement de test, leurs clés et leurs numéros de simulation : un canal qui marche chez l’un peut demander une autre configuration chez l’autre. Validez à chaque fois les deux issues — paiement accepté et paiement refusé — car votre tunnel doit gérer l’échec aussi proprement que le succès.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Commande payée mais jamais préparée | Callback non joignable | URL publique HTTPS ; tester ; ajouter la réconciliation |
| Double email / double préparation | markPaid() non idempotent |
Garde if status === 'payee' return |
PayDunya répond autre que '00' |
Clés ou en-têtes manquants | Vérifier les 3 clés et le mode (test/live) |
| CinetPay rejette l’init | Montant non multiple de 5 | Arrondir le total |
| Total falsifié | Montant lu depuis le panier client | Recharger la commande et recalculer en base |
🌍 Adaptation au contexte ouest-africain
Proposer deux fournisseurs n’est pas un luxe ici : selon le pays et l’opérateur du client, l’un passera là où l’autre échoue. L’interface commune vous permet d’en ajouter un troisième sans réécrire le tunnel. Gardez les montants en entiers (XOF sans décimale, multiples de 5 côté CinetPay), expliquez la validation par code à l’utilisateur, et prévoyez la réconciliation pour absorber les callbacks perdus sur réseau instable. Un VPS HTTPS modeste suffit à héberger les endpoints de callback.
Un dernier conseil de terrain : affichez clairement le montant, le fournisseur choisi et le numéro de commande sur la page de confirmation. En Afrique de l’Ouest, la confiance dans le paiement en ligne se construit ; un récapitulatif limpide réduit les abandons et les réclamations bien plus sûrement qu’une belle animation.
✅ Récapitulatif
Vous avez construit un tunnel e-commerce propre : une commande pilotée par une machine à états idempotente, et deux fournisseurs (PayDunya, CinetPay) cachés derrière une seule interface. Le client choisit, le serveur vérifie, la commande bascule une fois. Ajouter un fournisseur, c’est désormais écrire une classe — pas refactorer toute la boutique.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
PaymentGateway |
Contrat commun : createCheckout() + isPaid() |
PayDunya checkout-invoice/create |
Crée la facture → response_text (URL), token (succès : '00') |
PayDunya checkout-invoice/confirm/{token} |
Vérifier (payé : status = completed) |
CinetPay /v2/payment + /v2/payment/check |
Init + vérification |
markPaid() |
Bascule idempotente de la commande |
💪 À vous de jouer
Ajoutez une troisième implémentation de PaymentGateway (par exemple un fournisseur fictif « hors-ligne » qui valide toujours) et branchez-la dans gatewayFor(). Vous mesurerez à quel point votre tunnel n’a pas bougé d’une ligne.
Voir une piste
Créez class OfflineGateway implements PaymentGateway dont createCheckout() renvoie l’URL de votre propre page de confirmation et isPaid() renvoie true. Ajoutez 'offline' => new OfflineGateway() au match. Aucune autre ligne à toucher : c’est la preuve que l’abstraction tient.
Tutoriels frères
- CinetPay multi-canal en Laravel — le détail de l’intégration CinetPay.
- PayDunya en Django — le même fournisseur, côté Python.
Pour aller plus loin
- 🔝 Retour au pilier : Mobile money en backend
- PayDunya : developers.paydunya.com
- CinetPay : docs.cinetpay.com
FAQ
Pourquoi une interface plutôt que deux intégrations séparées ?
Pour ne pas dupliquer la logique de commande. Le jour où vous ajoutez un fournisseur, vous écrivez une classe ; le tunnel d’achat ne bouge pas.
Le client peut-il changer de fournisseur après avoir échoué ?
Oui : la commande reste en_attente_paiement tant qu’aucun paiement n’est confirmé. Il suffit de recréer une session avec l’autre passerelle.
Faut-il vérifier la signature des callbacks PayDunya ?
Le plus sûr est de toujours reconfirmer via l’endpoint confirm : il fait foi, quelle que soit la fiabilité du corps reçu.
Comment gérer un client qui paie deux fois la même commande ?
La machine à états le bloque : dès le premier paiement confirmé, la commande passe payee et markPaid() devient sans effet. Si un second paiement aboutissait malgré tout côté fournisseur, vous le détecterez à la réconciliation et procéderez à un remboursement — d’où l’importance de tout journaliser.
WooCommerce ou code maison ?
Le même principe s’applique. Derrière WooCommerce, chaque fournisseur devient une méthode de paiement (une classe WC_Payment_Gateway) qui réutilise exactement cette logique : initialisation côté serveur, redirection, puis callback vérifié par un appel isPaid() avant de valider la commande. L’avantage du code maison reste le contrôle total du tunnel ; celui de WooCommerce, l’écosystème de modules tout prêts. Dans les deux cas, la règle d’or ne change pas : le serveur vérifie, le navigateur n’encaisse jamais.
Mots-clés : mobile money e-commerce, PayDunya CinetPay, passerelle paiement PHP, checkout-invoice, paiement boutique Afrique, machine à états commande.
Aucun commentaire pour l'instant — lancez la discussion !