Diseño de un sistema de pago que nunca pierde ninguna transacción
El procesamiento de pagos es el único ámbito en el que no puedes permitirte ningún error. Aprende sobre claves idempotentes, conciliación mediante webhooks, máquinas de estados y los patrones que evitan los cobros duplicados y la pérdida de pagos.
Por qué los pagos son más complicados de lo que parece
Imagina esta situación: un usuario hace clic en «Pagar ahora», la solicitud llega a Stripe, Stripe realiza el cargo en la tarjeta, pero tu servidor agota el tiempo de espera antes de recibir la respuesta. ¿Se ha realizado correctamente el pago? Tu base de datos dice que no. Stripe dice que sí. El usuario vuelve a hacer clic en «Pagar ahora». Ahora se le ha cobrado dos veces.
No se trata de una hipótesis. Ocurre a diario en los sistemas de producción. A continuación te explicamos cómo evitarlo.
Patrón 1: Claves idempotentes
Cada solicitud de pago debe incluir una clave de idempotencia única. Si se envía la misma clave dos veces, la pasarela de pago devuelve el resultado original en lugar de volver a procesar la solicitud.
import
uuidfrom django.db import
modelsclass Pago(models.Model):
class Estado(models.TextChoices):
PENDING = 'pendiente'
PROCESSING = 'en proceso'
COMPLETED = 'completado'
FAILED = 'fallido'
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'
)
]Patrón 2: Máquina de estados de pago
Nunca utilices indicadores booleanos como «is_paid». Utiliza una máquina de estados con transiciones explícitas. Esto evita que se produzcan estados no válidos y facilita enormemente la depuración.
VALID_TRANSITIONS = {
'pendiente': ['en proceso', 'fallido'],
'en proceso': ['completado', 'fallido', 'pendiente'], # pendiente = reintento
'completado': ['refunded'],
'failed': ['pending'], # reintentar
'refunded': [], # estado terminal
}
def transition_payment(payment, new_status):
allowed = VALID_TRANSITIONS.get(payment.status, [])
if new_status not in allowed:
raise InvalidTransition(
f"No se puede pasar de {payment.status} a {new_status}"
)
old_status = payment.status
payment.status = new_status
payment.save()
# Registrar cada transición en el registro de
auditoría PaymentAuditLog.objects.create(
pago=pago,
estado_de_origen=estado_anterior,
estado_de_destino=nuevo_estado,
)Patrón 3: Conciliación de webhooks
Nunca confíes en tu propia respuesta de API para confirmar un pago. Los webhooks de Stripe/Razorpay son la fuente de información fiable. Tu respuesta de API podría agotar el tiempo de espera, pero el webhook siempre llega (gracias a los reintentos).
# Gestor de webhooks: la fuente de
verdad@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')
# Procesar el pedido AQUÍ, no en la respuesta de la
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)Las reglas de oro del procesamiento de pagos
1. Toda operación de pago debe ser idempotente. Los reintentos deben ser seguros.
2. Los webhooks son la fuente de información fiable, no las respuestas de la API.
3. Registra todo. Cada cambio de estado, cada llamada a la API, cada webhook. Cuando un cliente impugna un cargo, necesitas un registro de auditoría completo.
4. Prueba los modos de fallo, no solo los de éxito. Simula tiempos de espera agotados, envíos duplicados, retrasos en los webhooks y fallos parciales.
5. Realiza una conciliación diaria. Compara tu base de datos con los registros de Stripe. Detecta las discrepancias antes de que lo hagan los clientes.
En los sistemas de pago, el optimismo es un error. Da por hecho que todas las llamadas de red fallarán, que todos los webhooks se retrasarán y que todos los usuarios harán clic dos veces en el botón.
— Blog de ingeniería de Stripe
