Marketplace avec Django
Une marketplace multivendeur avec Sync, où l'argent revient au vendeur, pas à vous.
Cette recette construit une marketplace avec Django REST Framework et Wajub Sync. Les vendeurs connectent leur propre compte Wajub, les clients paient et chaque vendeur est payé directement. Votre plateforme conserve une commission qu'elle n'a jamais à déplacer.
Retenez une idée : l'argent ne passe jamais par votre solde. Un paiement effectué sur une connexion appartient au vendeur et est réglé sur son compte. Votre commission vous est créditée automatiquement. Cette recette ne contient aucune tâche de payout, car aucun fonds ne doit être reversé.
Sync exige le plan Scale
Les connexions sont disponibles avec les plans Scale et Enterprise. Les routes de compte ne sont pas accessibles avec un plan inférieur. Sync présente les conditions requises et le mécanisme d'arrêt.
Ce que vous allez construire
Un paiement unique ne peut pas être réparti entre plusieurs vendeurs
Aucun tableau split ne permet de désigner plusieurs connexions sur un paiement, quels que soient le plan et l'endpoint. Un paiement contient exactement un en-tête X-Sync. Un panier avec deux vendeurs exige deux paiements, comme l'explique l'étape 5. Paiements fractionnés présente cette limite en détail.
1. Installer et configurer
pip install django djangorestframework wajubWAJUB_API_KEY = os.environ["WAJUB_API_KEY"]
WAJUB_WEBHOOK_SECRET = os.environ["WAJUB_WEBHOOK_SECRET"]
PLATFORM_COMMISSION_PERCENT = 10.0Créez un seul client pour tout le projet au moment de l'import.
from django.conf import settings
from wajub import Wajub
client = Wajub(
api_key=settings.WAJUB_API_KEY,
webhook_secret=settings.WAJUB_WEBHOOK_SECRET,
)2. Un vendeur n'est pas un compte que vous créez
La plupart des intégrations de marketplace commencent par cette erreur. Vous n'ouvrez pas un compte au nom du vendeur. Vous envoyez une invitation qu'un marchand Wajub existant accepte.
Votre modèle reproduit donc deux statuts indépendants au lieu d'un seul booléen.
from django.conf import settings
from django.db import models
class Vendor(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
business_name = models.CharField(max_length=200)
payout_phone = models.CharField(max_length=20, blank=True)
sync_account_id = models.CharField(max_length=64, unique=True, null=True, blank=True)
status = models.CharField(max_length=16, default="pending")
payment_status = models.CharField(max_length=16, default="inactive")
created_at = models.DateTimeField(auto_now_add=True)
@property
def can_sell(self) -> bool:
return self.status == "active" and self.payment_status == "active"
class SyncDelivery(models.Model):
key = models.CharField(max_length=128, unique=True)
received_at = models.DateTimeField(auto_now_add=True)status indique si le vendeur a accepté. payment_status indique si la connexion peut déplacer de l'argent. Une connexion peut être active tout en refusant tous les paiements. Votre checkout doit uniquement consulter can_sell.
payout_phone est le numéro Mobile Money sur lequel le vendeur veut recevoir ses retraits. Vous le recueillez dans votre propre formulaire d'inscription : Wajub ne vous transmet pas les numéros du vendeur. L'étape 7 paie vers ce numéro.
SyncDelivery garde la trace des livraisons Sync déjà traitées, pour que l'étape 4 ne traite chacune qu'une fois.
3. Intégrer un vendeur
La création de la connexion définit ses autorisations et votre commission. Une fois le vendeur engagé, les modifier demande son accord. Vérifiez-les avant d'envoyer le lien. withdrawals sert à l'étape 7 : sans cette capacité, un transfert envoyé pour le compte du vendeur répond 403. callback est la page sur laquelle le vendeur revient après avoir accepté, pas une URL de webhook : aucun événement n'y est envoyé.
from django.conf import settings
from rest_framework import status as http
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from wajub import WajubError
from .models import Vendor
from .wajub import client
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def onboard_vendor(request):
vendor, _ = Vendor.objects.get_or_create(
user=request.user,
defaults={
"business_name": request.data["business_name"],
"payout_phone": request.data.get("payout_phone", ""),
},
)
if vendor.sync_account_id:
return Response({"error": "Already connected."}, status=http.HTTP_400_BAD_REQUEST)
try:
account = client.accounts.create({
"reference": f"vendor-{vendor.id}",
"capabilities": ["read", "payments", "refunds", "customers", "withdrawals"],
"pricing": {
"percentage_fee": settings.PLATFORM_COMMISSION_PERCENT,
"currency": "XAF",
},
"callback": "https://marketplace.example.com/vendors/connected",
})
except WajubError as exc:
return Response({"error": str(exc)}, status=http.HTTP_400_BAD_REQUEST)
vendor.sync_account_id = account["id"]
vendor.status = account["status"]
vendor.save(update_fields=["sync_account_id", "status"])
return Response(
{"authorization_url": account["authorization_url"]},
status=http.HTTP_201_CREATED,
)La réponse contient le lien que le vendeur doit ouvrir.
Vous enverrez id dans X-Sync. authorization_url n'est renvoyé qu'une fois, dans cette réponse : lire le compte ensuite ne le renvoie jamais, revendiqué ou non. Conservez-le, ou envoyez-le tout de suite.
Ce lien est un identifiant sensible
Toute personne qui possède l'URL peut rattacher un compte à votre plateforme. Envoyez-la par un canal que vous contrôlez et traitez-la comme un secret. En cas de fuite ou de perte, POST /accounts/{id}/token révoque tous les liens déjà émis et en renvoie un nouveau, valable de nouveau 30 jours. Cet appel est refusé avec 400 dès qu'un vendeur a revendiqué la connexion, même s'il s'est déconnecté depuis.
Les capacités et la tarification demandent l'accord du vendeur après l'acceptation
Une fois le vendeur engagé, PUT /accounts/{id} sur l'un de ces champs répond 202 avec la modification dans pending_changes, et account.change_requested part vers vos endpoints. Le vendeur l'accepte ou la refuse dans son propre dashboard, et la connexion garde ses conditions actuelles jusque-là. Décider du taux avant l'étape 3 évite cet aller-retour.
4. Faire progresser le vendeur avec les webhooks
Aucun polling n'est nécessaire. Le cycle de vie d'une connexion vous parvient par webhook, sur un endpoint que vous enregistrez dans le Dashboard ou avec POST /webhooks, en sélectionnant les événements account.* un par un : les événements émis depuis le Dashboard ignorent un abonnement *.
Deux formats arrivent sur cet endpoint. Les événements qui suivent vos propres appels sur /accounts portent l'enveloppe habituelle, X-Wajub-Signature et X-Wajub-Timestamp. Les événements émis dans le Dashboard, account.connected quand le vendeur accepte ou account.payment_activated une fois sa conformité vérifiée, portent un en-tête X-Sync-Event et un corps à plat qui désigne la connexion dans account, la clé qu'utilisent aussi les événements de l'API. Ils sont signés de la même façon, avec le même secret, dans X-Sync-Signature-V1 et X-Sync-Timestamp. construct_event vérifie donc les deux : la vue choisit la paire d'en-têtes selon la présence de X-Sync-Event, et ne traite chaque livraison Sync qu'une fois, grâce à l'identifiant de son en-tête X-Idempotency-Key. Elle relit ensuite la connexion au lieu de se fier à l'un ou l'autre payload.
Ces livraisons portent aussi X-Sync-Signature, un HMAC du seul corps, sans horodatage. Il est conservé pour les intégrations qui le vérifient aujourd'hui et sera retiré plus tard. La vue ne le lit pas, et la vôtre ne devrait pas le lire non plus : une requête interceptée y correspond encore des jours plus tard.
Dans l'enveloppe habituelle, chaque événement account.* désigne sa connexion dans data.account, l'identifiant acc_…. Lisez-le là, pas dans data.id : sur account.commission_receivable_recorded et account.commission_receivable_collected, data.id est l'identifiant crv_… de la commission due, et account.change_requested n'a aucun data.id.
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from wajub import WebhookSignatureVerificationError
from .models import SyncDelivery, Vendor
from .tasks import apply_paid_payment
from .wajub import client
def apply_account_event(name, account_id):
if name == "account.deauthorized":
Vendor.objects.filter(sync_account_id=account_id).update(
status="cancelled",
payment_status="inactive",
)
return
account = client.accounts.retrieve(account_id)
Vendor.objects.filter(sync_account_id=account_id).update(
status=account["status"],
payment_status=account["payment_status"],
)
@csrf_exempt
@require_POST
def wajub_webhook(request):
sync = "X-Sync-Event" in request.headers
signature, timestamp = (
("X-Sync-Signature-V1", "X-Sync-Timestamp")
if sync
else ("X-Wajub-Signature", "X-Wajub-Timestamp")
)
try:
event = client.webhooks.construct_event(
request.body,
request.headers.get(signature, ""),
request.headers.get(timestamp, ""),
)
except WebhookSignatureVerificationError:
return HttpResponseBadRequest()
name = event["event"]
if sync:
delivery = request.headers.get("X-Idempotency-Key")
if not delivery:
return HttpResponseBadRequest()
with transaction.atomic():
_, first_time = SyncDelivery.objects.get_or_create(key=delivery)
if first_time:
apply_account_event(name, event["account"])
return HttpResponse(status=200)
data = event["data"]
if name.startswith("account."):
apply_account_event(name, data["account"])
elif name == "payment.succeeded":
apply_paid_payment.delay(data["id"])
return HttpResponse(status=200)construct_event recalcule le HMAC de timestamp.body, le compare en temps constant et refuse un horodatage qui s'écarte de plus de 300 secondes de l'heure actuelle, quelle que soit la paire d'en-têtes qu'il reçoit. Une requête interceptée ne peut donc pas être répétée plus tard. Transmettez-lui les octets bruts de request.body, jamais un payload analysé.
La sandbox active les paiements dès l'acceptation
La vérification de conformité ne s'exécute pas dans la sandbox. Une connexion de sandbox passe donc en payment_status active dès que le vendeur accepte, et aucun account.payment_activated ne suit. En live, cet événement arrive une fois la conformité du vendeur vérifiée.
5. Un paiement par vendeur lors du checkout
Un panier qui concerne trois vendeurs exige trois paiements. Regroupez le panier par vendeur, créez un paiement pour chaque groupe et conservez un enregistrement parent pour effectuer ensuite le rapprochement.
from collections import defaultdict
from django.db import transaction as db_transaction
from rest_framework import status as http
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from wajub import RequestOptions
from .models import Basket, Order, Vendor
from .wajub import client
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def checkout(request):
by_vendor = defaultdict(list)
for line in request.data["items"]:
by_vendor[line["vendor_id"]].append(line)
with db_transaction.atomic():
basket = Basket.objects.create(user=request.user)
orders = []
for vendor_id, lines in by_vendor.items():
vendor = Vendor.objects.get(pk=vendor_id)
if not vendor.can_sell:
return Response(
{"error": f"{vendor.business_name} cannot take payments yet."},
status=http.HTTP_409_CONFLICT,
)
orders.append(Order.objects.create(
basket=basket,
vendor=vendor,
amount=price_of(lines),
currency="XAF",
))
payments = []
for order in orders:
payment = client.payments.create(
{
"amount": float(order.amount),
"currency": order.currency,
"customer": {"email": request.user.email},
"description": f"{order.vendor.business_name}, basket {basket.id}",
"reference": f"order-{order.id}",
"callback": f"https://marketplace.example.com/baskets/{basket.id}/return",
"metadata": {"basket_id": str(basket.id), "order_id": str(order.id)},
},
RequestOptions(
sync=order.vendor.sync_account_id,
idempotency_key=f"order-{order.id}",
),
)
order.payment_id = payment.id
order.save(update_fields=["payment_id"])
payments.append({
"vendor": order.vendor.business_name,
"amount": order.amount,
"authorization_url": payment.authorization_url,
})
return Response({"basket_id": basket.id, "payments": payments})Trois éléments méritent une explication.
price_of(lines) lit votre propre catalogue. Un montant reçu dans le corps de la requête est une suggestion du navigateur, pas une instruction.
RequestOptions(sync=...) envoie l'en-tête X-Sync. Cet en-tête ne se limite pas à appliquer un tarif : toute la requête s'exécute au nom du vendeur. Le paiement, l'enregistrement du client et tout remboursement ultérieur lui appartiennent, pas à vous.
metadata ajoute l'identifiant de votre panier à chaque paiement. C'est le seul moyen de reconstituer ensuite un panier multivendeur, car Wajub ne relie pas ces paiements.
L'acheteur donne une autorisation par vendeur
Trois vendeurs produisent trois invites sur le téléphone du client. Chacune peut échouer indépendamment. Traitez chaque paiement comme une ligne de commande distincte plutôt que comme un panier atomique. Expliquez-le dans votre interface de checkout avant la première invite.
6. Votre commission arrive automatiquement
Lorsqu'un paiement connecté réussit, Wajub lit les règles de tarification de la connexion, calcule votre commission et la crédite sur le solde de votre plateforme. Un webhook vous informe sans aucun appel de votre part.
Une commission ne désigne pas le paiement dont elle provient : totalisez-la par vendeur avec account.id. account.reference répète cet id au lieu de la reference définie à l'étape 3. Calculez la commission d'une commande à partir de la tarification fixée à l'étape 3.
Le vendeur reçoit l'événement correspondant, fee.charged, sur ses propres endpoints. Les deux parties peuvent ainsi vérifier la commission sans échanger d'informations.
De plus, chaque paiement, remboursement et transfert sur un compte connecté envoie son webhook normal au vendeur et une copie à votre plateforme. L'étape 4 peut donc mettre à jour un panier à partir de payment.succeeded sans polling.
7. Aucune tâche de payout
Ce fonctionnement surprend les personnes habituées aux plateformes qui détiennent les fonds. Un paiement connecté est réglé directement sur le solde Wajub du vendeur. Vous ne détenez jamais cet argent et n'avez donc rien à envoyer.
| Fonctionnement attendu | Fonctionnement réel de Sync |
|---|---|
| Les fonds arrivent sur la plateforme et vous payez les vendeurs chaque semaine | Les fonds arrivent immédiatement chez le vendeur |
| Vous exécutez une tâche de payout qui engage votre propre solde | Il n'existe aucune tâche de payout |
| Vous calculez et transférez votre commission | Elle est créditée automatiquement sur chaque paiement |
Si un vendeur souhaite retirer de l'argent de son solde Wajub, il effectue un transfert ordinaire avec l'en-tête X-Sync. L'opération débite son solde, pas le vôtre, et exige la capacité withdrawals sur la connexion : celle demandée à l'étape 3. Le destinataire est le payout_phone enregistré sur le vendeur à l'étape 2.
client.transfers.create(
{
"amount": 45000,
"currency": "XAF",
"beneficiary": {
"name": vendor.business_name,
"channel": "cm.mtn",
"phone": vendor.payout_phone,
},
"description": "Withdrawal",
},
RequestOptions(sync=vendor.sync_account_id),
)8. Relier les éléments et tester
from django.urls import path
from . import checkout, views
from .webhooks import wajub_webhook
urlpatterns = [
path("vendors/onboard/", views.onboard_vendor, name="vendor-onboard"),
path("checkout/", checkout.checkout, name="checkout"),
path("webhooks/wajub/", wajub_webhook, name="wajub-webhook"),
]Le test complet d'une marketplace exige un second compte Wajub qui représente le vendeur, car une connexion relie deux comptes réels. Créez-en un dans la sandbox et utilisez-le pour accepter l'invitation. Les paiements sont actifs dès ce moment, puisque la conformité ne s'y exécute pas.
| Étape | Méthode dans la sandbox |
|---|---|
| Acceptation du vendeur | Ouvrir authorization_url en étant connecté au second compte |
| Activation des paiements | Rien à faire, l'acceptation les active |
| Réussite d'un panier | Payer chaque invite avec +237670000000 |
| Échec d'un vendeur | Payer son invite avec +237670000002 et laisser les autres réussir |
Concevez votre système pour le dernier cas. Un panier partiellement payé est le mode d'échec normal d'une marketplace. Il est bien plus simple de le gérer volontairement que de le découvrir en production.
Pages associées
- SyncLa définition d'une connexion et ses limites.
- Capacités du compteLes sept capacités et le calcul de votre commission.
- Intégration des marchandsLe cycle de vie, le lien d'autorisation et les deux statuts.
- Paiements fractionnésPourquoi un paiement ne peut pas être partagé entre deux vendeurs.
- Transferts et payoutsRetirer de l'argent d'un solde connecté ou du vôtre.