Diseñar un sistema de pagos que no cobre dos veces
· 6 min de lectura
En casi cualquier sistema, un error se arregla con un deploy. En pagos no. Si cobras dos veces a un cliente, el deploy no le devuelve el dinero. Toca abrir una incidencia, hacer un reembolso y perder parte de su confianza.
Por eso diseño un sistema de pagos partiendo de una premisa incómoda: la red va a fallar en el peor momento posible. La petición llegará al proveedor y la respuesta se perderá por el camino. El usuario pulsará “Pagar” dos veces. El webhook llegará antes que la respuesta síncrona, o tres veces, o nunca.
Este post es el diagrama de un flujo de cobro que aguanta todo eso. No describe ningún sistema concreto: son los principios que aplicaría hoy a cualquier plataforma que cobre a través de uno o varios proveedores.
El diagrama
Cliente ──POST /payments──▶ ┌──────────────────────┐
(Idempotency-Key) │ API de pagos │
│ └─ registro de │
│ claves (BD) │
└──────────┬───────────┘
▼
┌──────────────────────┐ ┌─────────────┐
│ Orquestador │─────▶│ Proveedor A │
│ └─ máquina de │ └─────────────┘
│ estados del pago │ ┌─────────────┐
│ │─────▶│ Proveedor B │
└──────────▲───────────┘ └──────┬──────┘
│ │
┌──────────┴───────────┐ webhooks │
│ Receptor de webhooks │◀────────────┘
│ └─ deduplicación │
└──────────────────────┘
Cada noche: Reconciliación ── compara ──▶ nuestra BD ⇄ informes del proveedor
Hay cuatro piezas, y cada una existe para cubrir un fallo distinto:
| Pieza | Fallo que cubre |
|---|---|
| Clave de idempotencia | El cliente reintenta la misma operación. |
| Máquina de estados | Respuestas que llegan fuera de orden o se contradicen. |
| Receptor de webhooks | Notificaciones duplicadas, tardías o perdidas. |
| Reconciliación | Todo lo que se escape a las tres anteriores. |
Idempotencia: la misma petición, el mismo resultado
Una operación idempotente produce el mismo efecto la ejecutes una vez o diez. Un cobro no lo es por naturaleza, así que hay que hacerlo idempotente.
La técnica estándar es la clave de idempotencia. El cliente genera un identificador único por intento de pago, no por petición HTTP, y lo envía en cada reintento. El servidor guarda la clave junto con el resultado. Si vuelve a ver la misma clave, devuelve el resultado guardado en lugar de cobrar otra vez.
async function createPayment(key: string, input: PaymentInput) {
// La restricción UNIQUE sobre la clave es la que evita la carrera,
// no el if: dos peticiones simultáneas no pueden insertar las dos.
const claimed = await db.idempotency.insertIfAbsent({
key,
requestHash: hash(input),
status: 'in_progress',
});
if (!claimed) {
const existing = await db.idempotency.get(key);
if (existing.requestHash !== hash(input)) throw new Conflict('Misma clave, otra petición');
if (existing.status === 'in_progress') throw new Conflict('Pago en curso, reintenta');
return existing.response; // ya procesado: mismo resultado, sin cobrar de nuevo
}
const response = await orchestrator.charge(input, { idempotencyKey: key });
await db.idempotency.complete(key, response);
return response;
}
Hay tres detalles en ese código que suelen pasarse por alto.
La exclusión la garantiza la base de datos. Comprobar “¿existe la clave?” y luego insertarla son dos operaciones, y entre ellas cabe otra petición. Una restricción UNIQUE en la base de datos cierra ese hueco; el código de aplicación, no.
La clave va ligada al contenido. Si llega la misma clave con otro importe, es un error del cliente, y hay que rechazarlo en lugar de devolver el resultado anterior.
La clave también viaja al proveedor. La mayoría de proveedores de pago aceptan su propia clave de idempotencia. Si el orquestador reintenta tras un timeout, el proveedor reconoce el reintento y no cobra dos veces. Sin esto, la idempotencia acaba en tu frontera y el riesgo sigue intacto un paso más allá.
La máquina de estados
Un pago no está “hecho” o “no hecho”. Pasa por estados, y lo más importante es definir qué transiciones están permitidas y cuáles no.
┌──────────┐
│ created │
└────┬─────┘
▼
┌──────────┐ timeout / sin respuesta ┌──────────┐
│ pending │────────────────────────────▶│ unknown │
└─┬──────┬─┘ └────┬─────┘
autorizado │ │ rechazado consulta al│proveedor
▼ ▼ │
┌────────────┐ ┌──────────┐ │
│ authorized │ │ failed │◀─────────────────────────┤
└─────┬──────┘ └──────────┘ │
▼ │
┌────────────┐ │
│ captured │◀──────────────────────────────────────┘
└─────┬──────┘
▼
┌────────────┐
│ refunded │
└────────────┘
El estado clave es unknown. Es honesto: si el proveedor no respondió, no sabes si cobró. Marcar ese pago como failed invita al usuario a reintentar, y ahí está el doble cobro. Marcarlo como captured puede entregar algo que no se ha pagado. unknown obliga a resolverlo consultando al proveedor antes de decidir.
La otra regla es que las transiciones solo van hacia delante. Si un pago ya está captured y llega un evento pending atrasado, se ignora. Esto se garantiza con una actualización condicional (UPDATE ... WHERE status = 'pending') y no leyendo y escribiendo por separado. Si la actualización no toca ninguna fila, otro proceso llegó antes, y eso no es un error.
Webhooks: tardíos, duplicados o ausentes
El proveedor confirma el resultado final de forma asíncrona, por webhook. Hay que diseñar el receptor contando con que los webhooks:
- Llegan duplicados. El proveedor reintenta hasta recibir un 2xx. Cada evento trae un identificador; guardarlo con una restricción única convierte el segundo procesamiento en una operación vacía.
- Llegan desordenados. Un
refundedpuede llegar antes que sucaptured. La máquina de estados, que solo avanza, absorbe ese caso. - No llegan. Por un despliegue, un fallo de red o un certificado caducado. Por eso nunca deben ser la única fuente de verdad: los pagos que llevan demasiado tiempo en
pendingounknownse consultan activamente al proveedor. - Pueden ser falsos. Antes de procesar nada, hay que verificar la firma.
Y una regla operativa: el receptor responde rápido y procesa después. Valida la firma, guarda el evento, responde 200 y lo procesa en una cola. Si el procesamiento tarda más que el timeout del proveedor, este reintenta y generas tú mismo los duplicados.
Reconciliación: la red de seguridad
Con todo lo anterior, la gran mayoría de casos quedan cubiertos. La reconciliación existe para el resto.
Cada día se comparan los pagos de nuestra base de datos con los informes del proveedor. Las discrepancias se agrupan en tres tipos:
| Discrepancia | Qué significa |
|---|---|
| Cobrado en el proveedor, no en nuestra BD | Un webhook perdido o un unknown sin resolver. Hay que entregar o reembolsar. |
| Cobrado en nuestra BD, no en el proveedor | Un bug nuestro. Grave: estamos dando por pagado algo que no lo está. |
| Importes distintos | Divisas, comisiones o capturas parciales mal modeladas. |
La reconciliación no debería encontrar casi nada. Si encuentra muchas discrepancias, no es la solución: es la señal de que alguna de las piezas anteriores falla.
Varios proveedores: el orquestador
Con varios proveedores aparece la tentación de añadir un failover automático: si el proveedor A falla, reintentar con B. Es justo la forma más fácil de cobrar dos veces. “Falló” casi nunca significa “no cobró”: un timeout de A puede ser un cobro que sí se hizo.
El orquestador solo debería cambiar de proveedor cuando el error es definitivo y anterior al cobro: proveedor caído antes de aceptar la petición, método de pago no soportado o rechazo explícito. Ante un unknown, se resuelve con A antes de tocar B.
Errores típicos
- Generar la clave de idempotencia en el servidor. Así no protege nada: cada reintento del cliente crearía una clave nueva.
- Darle una caducidad demasiado corta. Debe durar más que la ventana más larga de reintentos del cliente.
- Tratar el timeout como un fallo. Es un
unknown. - Procesar el webhook dentro de la petición HTTP. Genera duplicados por los reintentos del proveedor.
- Confiar en el webhook como única fuente de verdad. Hace falta consulta activa y reconciliación.
- No guardar la respuesta original del proveedor. Cuando haya una disputa, será la única prueba de lo que pasó.
Ninguna de estas piezas es complicada por separado. Lo que hace robusto el sistema es asumir desde el diseño que cada llamada puede fallar a medias, y que la pregunta correcta no es “¿ha ido bien?”, sino “¿qué sé con seguridad sobre el estado de este pago?”.