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 :
| Fournisseur | Méthode d'authentification | Flux de paiement | Notification de statut |
|---|---|---|---|
| Stripe | Token Bearer (clé secrète) | Créer une Checkout Session, redirection vers la page hébergée Stripe | Webhook (signature vérifiée) |
| PayPal | Identifiants client OAuth2 | Créer une commande, redirection vers l'URL d'approbation PayPal | Webhook + polling |
| Hub2 | Clé API + clé d'abonnement | POST pour initier, push USSD envoyé au téléphone | Callback webhook |
| PawaPay | Token Bearer | POST pour initier, redirection vers page hébergée | Callback webhook |
| PaiementPro | Clé API dans le corps | POST pour initier, redirection vers page de paiement | URL de callback |
| BUI | Clé API + HMAC | POST pour initier, OTP ou redirection Wave | Webhook avec HMAC |
| Test | Aucune | Résultat instantané basé sur le montant magique | Aucune (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 :
- Trois méthodes obligatoires, trois optionnelles. Chaque fournisseur doit implémenter
initiate_payment,get_statusethandle_webhook. Les remboursements, annulations et validation d'identifiants sont optionnels car tous les fournisseurs ne les supportent pas.
- 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.
InitPaymentResultest un dataclass, pas un dict. L'utilisation d'un dataclass typé force chaque fournisseur à renvoyer la même structure. Le champstatusest particulièrement important -- il indique à l'appelant quoi faire ensuite.
- Le paramètre
dataest 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 :
| Statut | Signification | Action de l'appelant |
|---|---|---|
pending | Le paiement est en cours de traitement (push USSD envoyé, en attente de confirmation) | Polling des mises à jour de statut |
redirect | Le client doit être redirigé pour compléter le paiement | Renvoyer l'URL de redirection au frontend |
ussd_push | Un prompt USSD a été envoyé sur le téléphone du client | Afficher « vérifiez votre téléphone », polling du statut |
failed | L'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 :
- 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.
- 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.pyet 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 :
- Créer
backend/providers/bui/__init__.pyetbackend/providers/bui/provider.py. - Implémenter
BuiProvider(BasePayinProvider)avec les trois méthodes requises. - 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.