Aller au contenu

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é.

Ce que vous allez construire

1. Installer et configurer

Installation
pip install django djangorestframework wajub
settings.py
WAJUB_API_KEY = os.environ["WAJUB_API_KEY"]
WAJUB_WEBHOOK_SECRET = os.environ["WAJUB_WEBHOOK_SECRET"]
PLATFORM_COMMISSION_PERCENT = 10.0

Créez un seul client pour tout le projet au moment de l'import.

marketplace/wajub.py
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.

marketplace/models.py
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é.

marketplace/views.py
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.

Response · 201 Created
{
"code": 201,
"status": "Created",
"message": "Account created",
"account": {
"id": "acc_7Yh2MpL4tRb3nP8sZcXv",
"reference": "vendor-41",
"status": "pending",
"payment_status": "inactive",
"authorization_url": "https://sync.wajub.com/oauth/v2/authorize?access_token=…",
"pricing": {
"percentage_fee": 10,
"currency": "XAF"
}
}
}

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.

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.

marketplace/webhooks.py
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.

marketplace/checkout.py
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.

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.

fee.received
{
"event": "fee.received",
"data": {
"id": "9d1c7a52-4b1e-4f0a-9a3e-2f6b8c1d7e40",
"amount": 2500,
"currency": "XAF",
"percentage": 10,
"fixed_amount": null,
"applied_to": "transaction",
"account": {
"id": "acc_7Yh2MpL4tRb3nP8sZcXv",
"reference": "acc_7Yh2MpL4tRb3nP8sZcXv"
}
}
}

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 attenduFonctionnement réel de Sync
Les fonds arrivent sur la plateforme et vous payez les vendeurs chaque semaineLes fonds arrivent immédiatement chez le vendeur
Vous exécutez une tâche de payout qui engage votre propre soldeIl n'existe aucune tâche de payout
Vous calculez et transférez votre commissionElle 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.

Un payout pour le compte d'un vendeur
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

marketplace/urls.py
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.

ÉtapeMéthode dans la sandbox
Acceptation du vendeurOuvrir authorization_url en étant connecté au second compte
Activation des paiementsRien à faire, l'acceptation les active
Réussite d'un panierPayer chaque invite avec +237670000000
Échec d'un vendeurPayer 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.

Que pensez-vous de ce contenu ?