Fintech

PayDunya en Django : encaisser via tous les wallets

Encaisser le mobile money (Orange Money, Wave, Wizall) en Django avec PayDunya : API checkout-invoice, redirection, callback exempté de CSRF, reconfirmation serveur et idempotence.

📍 Article principal du cluster : Mobile money en backend : Wave, Orange Money, PayDunya, CinetPay. Pour les concepts (cycle de vie, webhooks, idempotence), lisez d’abord le pilier.

Vous développez en Django et vous voulez encaisser au Sénégal ou au Mali, où vos clients paient avec Orange Money, Wave ou Wizall. PayDunya est l’agrégateur qui vous évite d’intégrer chaque opérateur : une seule API, et le client choisit son wallet sur une page hébergée. Le risque, côté Django, c’est de bâcler la vérification — valider une commande parce que le client est revenu sur la page de succès. À la fin de ce tutoriel, votre application Django créera une facture PayDunya, redirigera le client, et confirmera l’encaissement par un appel serveur qui fait foi, jamais par le retour du navigateur.

Ce que vous allez apprendre

  • Configurer les trois clés PayDunya dans Django sans les exposer ;
  • Créer une facture via l’API checkout-invoice/create et rediriger le client ;
  • Traiter le callback (IPN) et reconfirmer le paiement via checkout-invoice/confirm ;
  • Rendre l’encaissement idempotent dans une vue Django exemptée de CSRF ;
  • Dérouler tout le cycle en bac à sable avant la production.

Ce que vous allez construire

Le fil rouge : une app Django paiements avec une vue demarrer_paiement qui crée la facture et envoie le client sur PayDunya, et une vue paydunya_callback qui marque la commande « payée » seulement après vérification serveur. Concret : une commande passe de « en attente » à « payée » toute seule, et un faux appel au callback ne déclenche rien.

Prérequis

  • Python 3.10+ et un projet Django 5 (au moment d’écrire), avec un modèle Order.
  • La bibliothèque requests (pip install requests).
  • Un compte PayDunya : Master Key, Private Key, Token (et le mode test/live).
  • Un domaine HTTPS public pour le callback (en local, un tunnel type ngrok).
  • ⏱️ Temps estimé : ~50 minutes. Test express : si vous savez écrire une vue et un modèle Django, vous êtes prêt.

Le flux PayDunya en un coup d’œil

Avant de coder, ancrez le déroulé, car chaque étape qui suit s’y rattache. D’abord, votre serveur crée une facture chez PayDunya en envoyant le montant et trois URLs (retour, annulation, callback) ; PayDunya répond avec un jeton et une URL de paiement. Ensuite, vous redirigez le client vers cette URL : il y choisit son wallet (Orange Money, Wave, Wizall…) et valide sur son téléphone. PayDunya appelle alors votre callback serveur pour annoncer l’issue. Enfin — et c’est le geste qui sépare une intégration sûre d’une intégration naïve — vous reconfirmez le paiement en réinterrogeant PayDunya avec le jeton, avant de marquer la commande payée. Quatre temps : créer, rediriger, être notifié, reconfirmer. Le reste n’est que de la plomberie autour de ces quatre temps.

Étape 1 — Les clés dans l’environnement

Les clés d’API ne vivent jamais dans le code ni dans Git : on les lit depuis l’environnement. En Django, on les charge dans settings.py à partir des variables d’environnement, ce qui permet d’avoir des valeurs de test en local et de production sur le serveur, sans toucher au code.

# .env (chargé via python-dotenv ou django-environ)
PAYDUNYA_MASTER_KEY=votre_master_key
PAYDUNYA_PRIVATE_KEY=votre_private_key
PAYDUNYA_TOKEN=votre_token
PAYDUNYA_MODE=test   # test | live
# settings.py
import os

PAYDUNYA = {
    "master_key":  os.environ["PAYDUNYA_MASTER_KEY"],
    "private_key": os.environ["PAYDUNYA_PRIVATE_KEY"],
    "token":       os.environ["PAYDUNYA_TOKEN"],
    "mode":        os.environ.get("PAYDUNYA_MODE", "test"),
}

En lisant les clés au démarrage, l’application échoue tout de suite si une variable manque — bien mieux qu’une erreur obscure au premier paiement. Le mode nous servira à basculer entre l’URL sandbox et l’URL de production.

Étape 2 — Un modèle pour tracer le paiement

Chaque tentative de paiement laisse une trace en base : c’est elle qui rend l’idempotence et la réconciliation possibles. On stocke le jeton PayDunya (qui identifie la facture), la commande liée, le montant et le statut.

# models.py
from django.db import models

class Payment(models.Model):
    token = models.CharField(max_length=120, unique=True, db_index=True)
    order = models.ForeignKey("shop.Order", on_delete=models.CASCADE)
    amount = models.PositiveIntegerField()        # entier XOF
    status = models.CharField(max_length=20, default="pending")  # pending|paid|failed
    created_at = models.DateTimeField(auto_now_add=True)

Le unique=True sur token est un garde-fou base de données contre le double traitement : impossible d’enregistrer deux fois la même facture. Et PositiveIntegerField rappelle que le franc CFA se manipule en entier, jamais en flottant.

Étape 3 — Le service PayDunya

On isole tous les appels HTTP à PayDunya dans un petit module : le reste de l’app ne connaîtra que deux fonctions, creer_facture et confirmer_facture. Cette séparation rend le code testable et évite de disperser les clés dans les vues.

# services/paydunya.py
import requests
from django.conf import settings

def _base():
    sub = "sandbox-api" if settings.PAYDUNYA["mode"] == "test" else "api"
    return f"https://app.paydunya.com/{sub}/v1"

def _headers():
    return {
        "PAYDUNYA-MASTER-KEY":  settings.PAYDUNYA["master_key"],
        "PAYDUNYA-PRIVATE-KEY": settings.PAYDUNYA["private_key"],
        "PAYDUNYA-TOKEN":       settings.PAYDUNYA["token"],
        "Content-Type":         "application/json",
    }

def creer_facture(order, return_url, cancel_url, callback_url):
    payload = {
        "invoice": {
            "total_amount": order.total,           # entier
            "description":  f"Commande #{order.id}",
        },
        "store":   {"name": "Boutique Diallo"},
        "actions": {
            "return_url":   return_url,
            "cancel_url":   cancel_url,
            "callback_url": callback_url,
        },
    }
    res = requests.post(f"{_base()}/checkout-invoice/create",
                        json=payload, headers=_headers(), timeout=15).json()
    if res.get("response_code") != "00":
        raise RuntimeError(res.get("response_text", "PayDunya: echec creation"))
    return res["token"], res["response_text"]      # (token, url de paiement)

def confirmer_facture(token):
    res = requests.get(f"{_base()}/checkout-invoice/confirm/{token}",
                       headers=_headers(), timeout=15).json()
    return res.get("status") == "completed"

Deux points clés. La fonction _base() choisit l’URL sandbox ou production selon le mode — on ne réécrit rien pour passer en live. Et confirmer_facture n’invente aucun statut : il interroge l’endpoint confirm de PayDunya, qui est le seul juge. Le timeout=15 évite qu’une requête pende indéfiniment si le réseau flanche.

Étape 4 — La vue qui démarre le paiement

La vue recharge la commande (donc le vrai total) depuis la base, crée la facture, enregistre le paiement en pending, puis redirige le client vers l’URL renvoyée par PayDunya. Le montant ne vient jamais de la requête du navigateur.

# views.py
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from .models import Payment
from .services import paydunya

def demarrer_paiement(request, order_id):
    order = get_object_or_404(Order, pk=order_id)

    token, payment_url = paydunya.creer_facture(
        order,
        return_url=request.build_absolute_uri(reverse("paiement_retour")),
        cancel_url=request.build_absolute_uri(reverse("paiement_annule")),
        callback_url=request.build_absolute_uri(reverse("paydunya_callback")),
    )

    Payment.objects.create(token=token, order=order, amount=order.total, status="pending")
    return redirect(payment_url)

build_absolute_uri construit des URLs complètes (avec le domaine) pour PayDunya, qui en a besoin pour vous rappeler. Une fois redirect(payment_url) exécuté, le client est sur PayDunya : la suite se joue côté serveur, dans le callback.

Étape 5 — Le callback : reconfirmer, sans confiance

Quand le paiement aboutit, PayDunya appelle votre callback_url. On ne lit pas le statut dans le corps reçu : on en extrait le jeton, et on redemande le statut réel via confirmer_facture. Comme c’est une requête externe, on l’exempte du CSRF de Django, sans quoi elle serait rejetée en 403.

# views.py
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def paydunya_callback(request):
    token = request.POST.get("data[invoice][token]") or request.POST.get("token")
    if not token:
        return HttpResponse(status=200)

    try:
        payment = Payment.objects.get(token=token)
    except Payment.DoesNotExist:
        return HttpResponse(status=200)

    # Idempotence : deja traite ?
    if payment.status == "paid":
        return HttpResponse(status=200)

    if paydunya.confirmer_facture(token):
        payment.status = "paid"
        payment.save(update_fields=["status"])
        payment.order.mark_as_paid()      # votre logique metier
    else:
        payment.status = "failed"
        payment.save(update_fields=["status"])

    return HttpResponse(status=200)

Trois protections s’empilent : on retrouve le paiement par son jeton, on s’arrête si la commande est déjà payée (idempotence), et on ne valide qu’après le confirmer_facture serveur. On répond toujours 200 pour que PayDunya cesse de réémettre. C’est exactement le motif décrit dans le pilier, transposé à Django.

Que met-on dans order.mark_as_paid() ? Toute la logique métier déclenchée par un paiement réussi : email de confirmation, décrément du stock, notification de préparation, émission d’une facture. L’important est que cette méthode soit, elle aussi, sûre à appeler une seule fois — le callback garantit déjà l’idempotence en amont, mais une double sécurité ne coûte rien sur une opération qui touche au stock et à l’argent.

Avant tout traitement, journalisez la notification reçue : jeton, statut annoncé, horodatage. En production, ces journaux sont votre seule preuve le jour d’un litige ou d’une réconciliation qui coince. Préférez un logger Django dédié (logging.getLogger("paiements")) à la sortie standard, pour filtrer et archiver ces lignes séparément.

Étape 5 bis — Afficher le résultat au client

Les vues retour et annule référencées dans les URLs n’encaissent rien : elles lisent l’état de la commande dans votre base — état que le callback aura mis à jour — et l’affichent. Tant que le callback n’a pas confirmé, on montre « vérification en cours » plutôt qu’un « merci » prématuré.

# views.py
from django.shortcuts import render

def retour(request):
    # On lit l'etat deja confirme par le callback ; on ne valide rien ici.
    return render(request, "paiements/retour.html")

def annule(request):
    return render(request, "paiements/annule.html")

Ce découplage est volontaire : la page de retour est pour l’humain, le callback pour la machine. Si vous validiez la commande dans retour, un client pourrait rejouer l’URL sans avoir payé.

Étape 6 — Brancher les URLs et tester

Il reste à câbler les routes, puis à dérouler le cycle en sandbox. Avec PAYDUNYA_MODE=test, aucune somme réelle ne bouge.

# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path("payer/<int:order_id>/", views.demarrer_paiement, name="demarrer_paiement"),
    path("paydunya/callback/", views.paydunya_callback, name="paydunya_callback"),
    path("paiement/retour/", views.retour, name="paiement_retour"),
    path("paiement/annule/", views.annule, name="paiement_annule"),
]

Exposez votre serveur de développement via un tunnel HTTPS pour que PayDunya joigne paydunya_callback, lancez une commande, payez avec un numéro de test, et regardez : la ligne Payment naît pending, puis bascule paid dès l’arrivée du callback.

Point d’étape — Au clic « Payer », un Payment en pending doit apparaître, puis passer paid après le callback. S’il reste pending : le callback n’arrive pas (URL publique ? HTTPS ?) ou le CSRF n’est pas exempté.

Un mot sur les tests. En mode test, PayDunya fournit des identifiants et un environnement sandbox dédiés : vous simulez des paiements sans qu’aucun franc ne bouge. Déroulez les deux scénarios — succès et échec — car votre callback doit gérer le refus aussi proprement que la réussite. Ne basculez PAYDUNYA_MODE sur live qu’une fois le cycle complet vert de bout en bout, et après avoir vérifié que vos URLs de callback pointent vers le domaine de production, pas vers votre tunnel de développement.

🐞 Pièges fréquents

Symptôme Cause probable Correctif
Callback en 403 CSRF non exempté Décorer la vue avec @csrf_exempt
response_code different de '00' Clés ou mode incorrects Vérifier les 3 clés et PAYDUNYA_MODE
Commande jamais « payée » callback_url non publique Tunnel HTTPS ; tester l’URL
Double traitement Callback rejoué Tester status == "paid" avant d’agir
KeyError sur une clé d’env Variable manquante Charger le .env avant settings

🌍 Adaptation au contexte ouest-africain

PayDunya est sénégalais et couvre nativement les wallets de la zone, ce qui en fait un excellent point d’entrée pour encaisser large depuis Django sans multiplier les contrats opérateurs. Pensez « entier » pour les montants (le XOF n’a pas de centimes), expliquez à l’utilisateur la validation par code sur son téléphone, et prévoyez un filet : une tâche planifiée (via cron ou Celery) qui rappelle confirmer_facture pour les paiements restés pending rattrape les callbacks perdus sur réseau instable. Un VPS HTTPS modeste héberge sans peine le endpoint de callback.

Pour la réconciliation, deux options selon votre infrastructure : un simple cron qui lance une commande de gestion Django à intervalle régulier suffit pour démarrer ; si vous avez déjà Celery pour des tâches asynchrones, une tâche périodique (Celery beat) s’intègre plus naturellement. Le principe reste le même : repasser sur les paiements en attente et redemander leur statut.

✅ Récapitulatif

Vous avez un encaissement PayDunya complet en Django : clés en environnement, modèle de suivi, service isolé, vue de démarrage qui recharge le vrai montant, et callback exempté de CSRF qui reconfirme chaque paiement avant une bascule idempotente. Le navigateur n’a jamais décidé ; le serveur, lui, a vérifié — fidèle au principe du pilier.

🧾 Aide-mémoire

Élément Rôle
POST checkout-invoice/create Créer la facture → token + response_text (URL). Succès : '00'
GET checkout-invoice/confirm/{token} Vérifier (payé : status = completed)
3 en-têtes PAYDUNYA-MASTER-KEY, -PRIVATE-KEY, -TOKEN
@csrf_exempt Indispensable sur la vue de callback
sandbox-api vs api Bascule test/production dans l’URL

💪 À vous de jouer

Ajoutez une commande de gestion Django (python manage.py reconcilier_paiements) qui parcourt les Payment en pending depuis plus de 5 minutes et rappelle confirmer_facture pour les mettre à jour. Branchez-la sur cron ou Celery beat.

Voir une piste

Créez management/commands/reconcilier_paiements.py avec une classe Command(BaseCommand). Dans handle(), filtrez Payment.objects.filter(status="pending", created_at__lt=timezone.now()-timedelta(minutes=5)) et réutilisez la logique du callback (extraite dans une fonction partagée pour ne pas la dupliquer).

Tutoriels frères

Pour aller plus loin

FAQ

Pourquoi reconfirmer alors que le callback annonce déjà le succès ?
Parce qu’un callback est une requête HTTP imitable. Seul l’appel serveur à confirm, authentifié par vos clés, fait foi.

Dois-je utiliser un SDK Python PayDunya ?
Optionnel. Les appels directs avec requests (montrés ici) ne dépendent d’aucune version de SDK et restent limpides ; un SDK encapsule les mêmes endpoints.

Le client ferme l’onglet après paiement : commande perdue ?
Non. Le callback arrive indépendamment du navigateur, et la commande de réconciliation rattrape les cas où il manquerait.

Faut-il vérifier une signature sur le callback PayDunya ?
La méthode la plus robuste est de toujours reconfirmer via l’endpoint confirm : il authentifie le paiement par vos clés, quelle que soit la fiabilité du corps reçu. C’est une vérification serveur, pas une simple lecture.

Puis-je encaisser un montant avec des centimes ?
Non : le franc CFA n’a pas de subdivision. Travaillez en entiers, dans la plus petite unité de la devise. Un montant flottant finit toujours par produire un écart d’arrondi qui complique la réconciliation.

Mots-clés : PayDunya Django, paiement Python Afrique, checkout-invoice, mobile money Django, API PayDunya, callback csrf_exempt.

Malick Diallo

Rédaction SenTur

Contributeur SenTur — passionné de tech et de transmission.

Aucun commentaire pour l'instant — lancez la discussion !

Laisser un commentaire

Votre adresse email ne sera pas publiée. La discussion est modérée — restez courtois et constructif.