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)

    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.

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"]},
    )

    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.

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 apparaît uniquement tant que la connexion n'est pas revendiquée et disparaît après l'acceptation du vendeur.

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.

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

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

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": "fee_9Lq5RtVb2Kd8",
"amount": 2500,
"currency": "XAF",
"percentage": 10,
"applied_to": "transaction",
"feeable_id": "trx_CSUGajfv9xh0XQ5wu2lx",
"account": {
"id": "acc_7Yh2MpL4tRb3nP8sZcXv",
"reference": "vendor-41"
}
}
}

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

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, utilisez-le pour accepter l'invitation, puis activez manuellement les paiements 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 paiementsclient.accounts.update(id, {"payment_status": "active"})
Réussite d'un panierPayer chaque invite avec +237670000000
Échec d'un vendeurPayer 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.

Que pensez-vous de ce contenu ?