Concevoir un système de paiement qui ne perd jamais aucune transaction
Le traitement des paiements est le seul domaine où vous ne pouvez pas vous permettre la moindre erreur. Découvrez les clés d'idempotence, le rapprochement via webhooks, les machines à états et les modèles qui permettent d'éviter les doubles prélèvements et les paiements perdus.
Pourquoi les paiements sont plus compliqués qu'on ne le croit
Imaginez le scénario suivant : un utilisateur clique sur « Payer maintenant », la requête parvient à Stripe, Stripe débite la carte, mais votre serveur expire avant de recevoir la réponse. Le paiement a-t-il abouti ? Votre base de données indique que non. Stripe indique que oui. L'utilisateur clique à nouveau sur « Payer maintenant ». Il se retrouve alors débité deux fois.
Ce n'est pas une hypothèse. Cela se produit tous les jours dans les systèmes de production. Voici comment l'éviter.
Modèle 1 : Clés d'idempotence
Chaque demande de paiement doit comporter une clé d'idempotence unique. Si la même clé est envoyée deux fois, la passerelle de paiement renvoie le résultat initial au lieu de traiter à nouveau la demande.
import uuid
from django.db import models
class Payment(models.Model):
class Status(models.TextChoices):
PENDING = 'pending'
PROCESSING = 'processing'
COMPLETED = 'completed'
FAILED = 'failed'
idempotency_key = models.UUIDField(
default=uuid.uuid4, unique=True, db_index=True
)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
)
amount = models.DecimalField(max_digits=10, decimal_places=2)
stripe_payment_intent_id = models.CharField(
max_length=255, null=True, blank=True
)
attempts = models.IntegerField(default=0)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['idempotency_key'],
name='unique_payment_idempotency'
)
]Modèle 2 : Machine à états de paiement
N'utilisez jamais de variables booléennes telles que « is_paid ». Préférez un automate à états avec des transitions explicites. Cela permet d'éviter les états invalides et facilite grandement le débogage.
VALID_TRANSITIONS = {
'en attente' : ['en cours de traitement', 'échec'],
'en cours de traitement' : ['terminé', 'échec', 'en attente'], # en attente = nouvelle tentative
'terminé' : ['refunded'],
'failed': ['pending'], # réessayer
'refunded': [], # état terminal
}
def transition_payment(payment, new_status):
allowed = VALID_TRANSITIONS.get(payment.status, [])
si new_status n'est pas dans allowed :
lance une exception InvalidTransition(
f"Impossible de passer de {payment.status} à {new_status}"
)
old_status = payment.status
payment.status = new_status
payment.save()
# Enregistrer chaque transition dans le
journal d'audit PaymentAuditLog.objects.create(
payment=payment,
from_status=old_status,
to_status=new_status,
)Modèle 3 : Réconciliation via Webhook
Ne vous fiez jamais à votre propre réponse API pour confirmer un paiement. Les webhooks Stripe/Razorpay constituent la source de référence. Votre réponse API peut expirer, mais le webhook arrive toujours (grâce aux tentatives de relance).
# Gestionnaire de webhook : la source de
vérité@csrf_exemptdef stripe_webhook(request):
payload = request.body
sig = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig, settings.STRIPE_WEBHOOK_SECRET
)
except (ValueError, stripe.error.SignatureVerificationError) :
return HttpResponse(status=400)
if event['type'] == 'payment_intent.succeeded' :
intent = event['data']['object']
payment = Payment.objects.get(
stripe_payment_intent_id=intent['id']
)
transition_payment(payment, 'completed')
# Traiter la commande ICI, pas dans la réponse de l'API
fulfill_order(payment.order)
elif event['type'] == 'payment_intent.payment_failed':
intent = event['data']['object']
payment = Payment.objects.get(
stripe_payment_intent_id=intent['id']
)
transition_payment(payment, 'failed')
return HttpResponse(status=200)Les règles d'or du traitement des paiements
1. Toute opération de paiement doit être idempotente. Les tentatives de reprise doivent être sans risque.
2. Ce sont les webhooks qui constituent la source de vérité, et non les réponses des API.
3. Consignez tout. Chaque changement d'état, chaque appel API, chaque webhook. Lorsqu'un client conteste un prélèvement, vous devez pouvoir fournir une piste d'audit complète.
4. Testez les scénarios d'échec, pas seulement ceux de réussite. Simulez les délais d'expiration, les soumissions en double, les retards des webhooks et les échecs partiels.
5. Effectuez un rapprochement quotidien. Comparez votre base de données avec les enregistrements de Stripe. Détectez les écarts avant que les clients ne s'en aperçoivent.
Dans les systèmes de paiement, l'optimisme est un bug. Partez du principe que chaque requête réseau échouera, que chaque webhook sera retardé et que chaque utilisateur cliquera deux fois sur le bouton.
— Blog technique de Stripe
