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)
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"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.
3. Intégrer un vendeur
La création de la connexion définit ses autorisations et votre commission. Ces deux valeurs deviennent immuables dès l'acceptation du vendeur. Vérifiez-les avant d'envoyer le lien.
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"]},
)
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"],
"pricing": {
"percentage_fee": settings.PLATFORM_COMMISSION_PERCENT,
"currency": "XAF",
},
"callback": "https://marketplace.example.com/webhooks/wajub",
})
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 apparaît uniquement tant que la connexion n'est pas revendiquée et disparaît après l'acceptation du vendeur.
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, POST /accounts/{id}/token génère un remplacement. Cet appel est refusé lorsque la connexion est déjà active.
Les capacités et la tarification sont figées après l'acceptation
Après l'acceptation du vendeur, PUT /accounts/{id} renvoie 400 pour chacun de ces champs. Modifier une commission exige de déconnecter le compte et d'envoyer une nouvelle invitation, que le vendeur doit accepter. Déterminez le taux avant l'étape 3.
4. Faire progresser le vendeur avec les webhooks
Aucun polling n'est nécessaire. Cinq événements de compte décrivent le cycle de vie d'une connexion et arrivent sur l'endpoint défini dans callback.
import json
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 Vendor
from .tasks import apply_paid_payment
from .wajub import client
@csrf_exempt
@require_POST
def wajub_webhook(request):
try:
event = client.webhooks.construct_event(
request.body,
request.headers.get("X-Wajub-Signature", ""),
request.headers.get("X-Wajub-Timestamp", ""),
)
except WebhookSignatureVerificationError:
return HttpResponseBadRequest()
name = event["event"]
data = event["data"]
if name in ("account.updated", "account.payment_activated", "account.payment_suspended"):
Vendor.objects.filter(sync_account_id=data["id"]).update(
status=data["status"],
payment_status=data["payment_status"],
)
elif name == "account.deauthorized":
Vendor.objects.filter(sync_account_id=data["id"]).update(
status="cancelled",
payment_status="inactive",
)
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. 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 n'active jamais les paiements automatiquement
La vérification de conformité ne s'exécute pas dans la sandbox. Une connexion de sandbox reste donc inactive et account.payment_activated ne se déclenche jamais. Activez-la après l'acceptation du vendeur avec client.accounts.update(account_id, {"payment_status": "active"}). Le même appel renvoie 400 tant que la connexion est encore pending.
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.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.
Le payload contient aussi feeable_type, qui correspond actuellement au nom de classe interne de l'objet facturé plutôt qu'à un type public. Utilisez plutôt feeable_id.
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.
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, utilisez-le pour accepter l'invitation, puis activez manuellement les paiements 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 | client.accounts.update(id, {"payment_status": "active"}) |
| Réussite d'un panier | Payer chaque invite avec +237670000000 |
| Échec d'un vendeur | Payer son invite avec +237670000001 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 onze 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.