Back to 0fee
0fee

Le pattern adaptateur de fournisseurs : une seule interface pour chaque système de paiement

Comment 0fee.dev utilise le pattern adaptateur pour unifier Stripe, PayPal, Hub2 et PawaPay derrière une seule interface Python. Par Juste A. Gnimavo et Claude.

Thales & Claude | March 30, 2026 9 min 0fee
EN/ FR/ ES
architecturedesign-patternsproviderspythonadapter-pattern

Stripe utilise les Checkout Sessions. PayPal utilise l'API Orders avec des tokens OAuth2. Hub2 envoie un push USSD sur le téléphone du client et attend. PawaPay redirige vers une page de paiement hébergée. Chaque fournisseur a son propre schéma d'authentification, sa propre structure d'API, son propre format de webhook et sa propre définition de « paiement initié ». Pourtant, dans 0fee.dev, initier un paiement via n'importe lequel de ces fournisseurs est exactement identique pour le code appelant. Voici l'histoire du pattern adaptateur qui rend cela possible.

Le problème : la fragmentation des fournisseurs de paiement

Un orchestrateur de paiement se place entre les marchands et les fournisseurs de paiement. Toute sa proposition de valeur est que le marchand intègre une seule fois et accède à tous les fournisseurs. Mais « tous les fournisseurs » signifie gérer des API très différentes :

FournisseurMéthode d'authentificationFlux de paiementNotification de statut
StripeToken Bearer (clé secrète)Créer une Checkout Session, redirection vers la page hébergée StripeWebhook (signature vérifiée)
PayPalIdentifiants client OAuth2Créer une commande, redirection vers l'URL d'approbation PayPalWebhook + polling
Hub2Clé API + clé d'abonnementPOST pour initier, push USSD envoyé au téléphoneCallback webhook
PawaPayToken BearerPOST pour initier, redirection vers page hébergéeCallback webhook
PaiementProClé API dans le corpsPOST pour initier, redirection vers page de paiementURL de callback
BUIClé API + HMACPOST pour initier, OTP ou redirection WaveWebhook avec HMAC
TestAucuneRésultat instantané basé sur le montant magiqueAucune (synchrone)

Sans couche d'abstraction, le code d'initiation de paiement serait un switch statement massif, le gestionnaire de webhook en serait un autre, et chaque nouveau fournisseur nécessiterait des modifications à des dizaines d'endroits.

La classe abstraite BasePayinProvider

La solution était une classe abstraite Python qui définit le contrat que chaque adaptateur de fournisseur doit respecter. Voici la classe de base telle qu'elle existe dans backend/providers/base.py :

pythonfrom abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class InitPaymentResult:
    """Résultat de l'initiation d'un paiement avec un fournisseur."""
    provider_ref: str
    status: str  # "pending", "redirect", "ussd_push", "failed"
    redirect_url: Optional[str] = None
    ussd_code: Optional[str] = None
    instructions: Optional[str] = None
    raw_response: dict = field(default_factory=dict)

class BasePayinProvider(ABC):
    """Classe abstraite de base pour tous les fournisseurs de paiement."""

    provider_id: str
    provider_name: str
    supported_countries: list[str]
    supported_methods: list[str]

    def __init__(self, credentials: dict):
        """Initialiser avec les identifiants déchiffrés du fournisseur."""
        self.credentials = credentials

    @abstractmethod
    async def initiate_payment(self, data: dict) -> InitPaymentResult:
        pass

    @abstractmethod
    async def get_status(self, provider_ref: str) -> dict:
        pass

    @abstractmethod
    async def handle_webhook(
        self, payload: dict, headers: dict
    ) -> dict:
        pass

    async def refund(
        self, provider_ref: str, amount: Optional[int] = None
    ) -> dict:
        raise NotImplementedError(
            f"{self.provider_name} does not support refunds"
        )

    async def cancel_payment(self, provider_ref: str) -> dict:
        raise NotImplementedError(
            f"{self.provider_name} does not support cancellation"
        )

    async def validate_credentials(self) -> bool:
        raise NotImplementedError(
            f"{self.provider_name} does not support credential validation"
        )

Les décisions de conception intégrées dans cette classe méritent examen :

  1. Trois méthodes obligatoires, trois optionnelles. Chaque fournisseur doit implémenter initiate_payment, get_status et handle_webhook. Les remboursements, annulations et validation d'identifiants sont optionnels car tous les fournisseurs ne les supportent pas.
  1. Identifiants passés à la construction. L'instance du fournisseur reçoit les identifiants déchiffrés lors de sa création. Cela signifie que la couche de chiffrement est totalement invisible pour les implémentations des fournisseurs.
  1. InitPaymentResult est un dataclass, pas un dict. L'utilisation d'un dataclass typé force chaque fournisseur à renvoyer la même structure. Le champ status est particulièrement important -- il indique à l'appelant quoi faire ensuite.
  1. Le paramètre data est un dict, pas un modèle. C'était un compromis délibéré. Différents fournisseurs ont besoin de champs différents. Un dict avec des clés documentées était plus flexible qu'un modèle rigide.

Les types de statut InitPaymentResult

Le champ status dans InitPaymentResult est la clé pour gérer uniformément les différents flux de paiement :

StatutSignificationAction de l'appelant
pendingLe paiement est en cours de traitement (push USSD envoyé, en attente de confirmation)Polling des mises à jour de statut
redirectLe client doit être redirigé pour compléter le paiementRenvoyer l'URL de redirection au frontend
ussd_pushUn prompt USSD a été envoyé sur le téléphone du clientAfficher « vérifiez votre téléphone », polling du statut
failedL'initiation du paiement a échouéRenvoyer l'erreur au marchand

Cette abstraction est ce qui permet à l'orchestrateur de gérer Stripe (redirection vers la page de checkout), Hub2 (push USSD vers le téléphone) et PawaPay (redirection vers la page hébergée) via le même chemin de code.

Comment Stripe implémente l'interface

L'adaptateur Stripe encapsule l'API Checkout Sessions :

pythonimport httpx
from providers.base import BasePayinProvider, InitPaymentResult

class StripeProvider(BasePayinProvider):
    provider_id = "stripe"
    provider_name = "Stripe"
    supported_countries = ["GLOBAL"]
    supported_methods = ["card"]

    def __init__(self, credentials: dict):
        super().__init__(credentials)
        self.api_key = credentials.get("api_key", "")
        self.webhook_secret = credentials.get("webhook_secret", "")
        self.base_url = "https://api.stripe.com/v1"

    async def initiate_payment(self, data: dict) -> InitPaymentResult:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/checkout/sessions",
                auth=(self.api_key, ""),
                data={
                    "payment_method_types[]": "card",
                    "mode": "payment",
                    "line_items[0][price_data][currency]":
                        data["currency"].lower(),
                    "line_items[0][price_data][unit_amount]":
                        data["amount"],
                    "line_items[0][price_data][product_data][name]":
                        data.get("description", "Payment"),
                    "line_items[0][quantity]": 1,
                    "success_url": data.get("return_url", "")
                        + "?status=success",
                    "cancel_url": data.get("return_url", "")
                        + "?status=cancelled",
                    "client_reference_id": data.get("reference", ""),
                    "customer_email": data.get("customer", {})
                        .get("email", ""),
                    "metadata[zerofee_txn]": data.get("transaction_id", ""),
                }
            )

            result = response.json()

            if response.status_code == 200:
                return InitPaymentResult(
                    provider_ref=result["id"],
                    status="redirect",
                    redirect_url=result["url"],
                    raw_response=result
                )
            else:
                return InitPaymentResult(
                    provider_ref="",
                    status="failed",
                    raw_response=result
                )

Observations clés : Stripe renvoie status="redirect" avec une redirect_url. L'appelant envoie cette URL au frontend du marchand, qui redirige le client vers la page de checkout hébergée par Stripe. Le gestionnaire de webhook normalise le format d'événement Stripe vers le format standard 0fee.dev.

Comment Hub2 implémente la même interface

Hub2 dessert l'Afrique francophone avec des paiements par push USSD. La même interface, un flux entièrement différent :

pythonimport httpx
from providers.base import BasePayinProvider, InitPaymentResult

class Hub2Provider(BasePayinProvider):
    provider_id = "hub2"
    provider_name = "Hub2"
    supported_countries = [
        "CI", "SN", "BJ", "BF", "CM", "ML", "TG", "GN"
    ]
    supported_methods = ["orange_money", "mtn", "wave", "moov"]

    async def initiate_payment(self, data: dict) -> InitPaymentResult:
        operator_map = {
            "orange_money": "Orange",
            "mtn": "MTN",
            "wave": "Wave",
            "moov": "Moov",
        }

        operator = operator_map.get(
            data.get("payment_method_detail", ""), "Orange"
        )

        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/payments",
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Ocp-Apim-Subscription-Key":
                        self.subscription_key,
                },
                json={
                    "amount": data["amount"],
                    "currency": data.get("currency", "XOF"),
                    "customer": {
                        "phone": data["customer"]["phone"],
                        "name": data.get("customer", {})
                            .get("name", ""),
                    },
                    "operator": operator,
                    "country": data.get("country", "CI"),
                    "callback_url": data.get("callback_url", ""),
                    "metadata": {
                        "zerofee_txn": data
                            .get("transaction_id", "")
                    },
                }
            )

            result = response.json()

            if response.status_code in (200, 201):
                return InitPaymentResult(
                    provider_ref=result.get("id", ""),
                    status="ussd_push",
                    instructions=(
                        "Un prompt USSD a été envoyé sur votre "
                        "téléphone. Veuillez confirmer le paiement."
                    ),
                    raw_response=result
                )
            else:
                return InitPaymentResult(
                    provider_ref="",
                    status="failed",
                    raw_response=result
                )

Le contraste est saisissant. Là où Stripe renvoie status="redirect", Hub2 renvoie status="ussd_push". Là où Stripe fournit une redirect_url, Hub2 fournit des instructions indiquant au client de vérifier son téléphone. Mais le code appelant s'en moque :

python# Dans le gestionnaire de route de paiement -- même code pour chaque fournisseur
result = await provider.initiate_payment(payment_data)

if result.status == "redirect":
    return {"payment_flow": "redirect", "url": result.redirect_url}
elif result.status == "ussd_push":
    return {"payment_flow": "ussd_push", "message": result.instructions}
elif result.status == "pending":
    return {"payment_flow": "pending"}
elif result.status == "failed":
    return {"error": "Payment initiation failed"}

Le registre des fournisseurs

Les adaptateurs de fournisseurs ne sont pas instanciés directement. Un registre les gère :

pythonclass ProviderRegistry:
    _providers: dict[str, type[BasePayinProvider]] = {}
    _instances: dict[str, BasePayinProvider] = {}

    def register(
        self, provider_id: str, provider_class: type[BasePayinProvider]
    ):
        self._providers[provider_id] = provider_class

    def get_instance(
        self, provider_id: str, credentials: dict,
        app_id: str = ""
    ) -> BasePayinProvider:
        cache_key = f"{provider_id}:{app_id}"
        if cache_key not in self._instances:
            provider_class = self._providers.get(provider_id)
            if not provider_class:
                raise ValueError(f"Unknown provider: {provider_id}")
            self._instances[cache_key] = provider_class(credentials)
        return self._instances[cache_key]

provider_registry = ProviderRegistry()

Le registre fournit deux capacités importantes :

  1. Mise en cache des instances. Les instances de fournisseurs sont mises en cache par provider_id:app_id, donc les identifiants sont chargés une seule fois et réutilisés entre les requêtes.
  1. Gestion dynamique. De nouveaux fournisseurs peuvent être enregistrés au runtime sans redémarrer l'application.

Pourquoi pas un système de plugins ?

Certains orchestrateurs de paiement utilisent des architectures de plugins avec chargement à chaud, fichiers de configuration et découverte dynamique. Nous avons choisi une approche plus simple :

  • Les fournisseurs sont des modules Python. Ajouter un nouveau fournisseur signifie créer un nouveau répertoire avec un fichier provider.py et l'enregistrer dans le registre.
  • La sécurité de type est maintenue. Parce que les classes de fournisseurs héritent de BasePayinProvider, les IDE et vérificateurs de types peuvent confirmer que chaque méthode requise est implémentée.
  • Les tests sont simples. Le fournisseur Test implémente la même interface avec des résultats déterministes.

Le pattern en pratique : ajouter un nouveau fournisseur

Quand BUI a été ajouté lors de la Session 002, le processus était :

  1. Créer backend/providers/bui/__init__.py et backend/providers/bui/provider.py.
  2. Implémenter BuiProvider(BasePayinProvider) avec les trois méthodes requises.
  3. Ajouter une ligne au registre : provider_registry.register("bui", BuiProvider).

Aucune modification de la route de paiement. Aucune modification du gestionnaire de webhook. Aucune modification du moteur de routage. Le nouveau fournisseur était immédiatement disponible pour toute application ayant configuré les identifiants BUI.

C'est la puissance du pattern adaptateur dans un orchestrateur de paiement : l'interface absorbe la complexité de chaque fournisseur, et le reste du système n'a jamais besoin de savoir si un paiement est passé par l'API Checkout Sessions de Stripe ou le mécanisme de push USSD de Hub2. Une interface, chaque système de paiement.


Cet article fait partie de la série « Comment nous avons construit 0fee.dev ». 0fee.dev est un orchestrateur de paiement couvrant 53+ fournisseurs dans 200+ pays, construit par Juste A. GNIMAVO et Claude depuis Abidjan sans aucun ingénieur humain. Suivez la série pour l'histoire complète de la construction.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles