Un proveedor de pagos envía una notificación, tu servidor registra el pago y la conexión se cierra antes de que el proveedor reciba tu respuesta. El proveedor reintenta. Si el handler da por hecho que cada petición es un pago nuevo, un único pago correcto puede desencadenar dos entregas.
Idempotencia significa que repetir la misma operación no repite su efecto de negocio. Es una propiedad de toda la transición de estado, no un sinónimo de meter un ID de evento en una caché.
Las integraciones de pago forman parte del proyecto UtopiaPay. Este artículo es una guía general de diseño para ese tipo de integración, no una afirmación de que el esquema ilustrativo de abajo se usara en aquel producto.
Empieza por el contrato de entrega
Antes de implementar el handler, establece qué garantiza realmente el proveedor. Algunos sistemas reintentan eventos, otros permiten reenviarlos manualmente y otros entregan eventos distintos sobre el mismo pago en desorden.
Identifica por separado la cuenta de comercio autenticada, la identidad del evento del proveedor y la identidad del pago. El ID de evento responde a “¿he procesado ya esta notificación?”. El ID de pago responde a “¿a qué registro de negocio se refiere?”. No son intercambiables.
No uses por defecto el hash del cuerpo completo de la petición como clave de deduplicación. El mismo evento lógico puede llegar con otra serialización o con metadatos de entrega distintos. Usa el identificador documentado del proveedor, con el ámbito adecuado de proveedor y cuenta. Si no existe un identificador estable, define y valida una clave de operación de negocio a partir del contrato de entrega real.
Valida antes de cambiar el estado
Autentica la notificación usando el mecanismo documentado por el proveedor. Cuando las firmas cubren los bytes en crudo, parsear y volver a serializar el cuerpo antes de verificar la firma puede invalidar esa comprobación. Aplica también las protecciones documentadas de marca de tiempo y de repetición.
Después valida que el evento pertenece a la cuenta esperada y que la referencia del pago, el importe y la moneda coinciden con tus propios registros. Un evento autenticado no es automáticamente una instrucción válida para servir un pedido cualquiera.
No confíes en la redirección del navegador a la página de éxito como confirmación de pago. Es navegación, no una prueba autenticada de liquidación.
Mete la deduplicación dentro de la transacción
Un esquema conceptual mínimo podría separar los eventos del estado del pago:
eventos_procesados
proveedor
cuenta_comercio_id
evento_id
procesado_en
UNIQUE(proveedor, cuenta_comercio_id, evento_id)
pagos
id
pago_id_proveedor
cuenta_comercio_id
importe_minimo
moneda
estado
Esto es un modelo, no SQL ejecutable. Los tipos de columna, las restricciones y la sintaxis de transacción dependen de la base de datos elegida. Los importes necesitan una representación exacta y el exponente correcto de la moneda, no aritmética en coma flotante binaria ni la suposición de que todas las monedas usan dos decimales.
Para una actualización síncrona en base de datos, la frontera de la transacción es:
autenticar y validar la notificación
begin transaction
insertar la identidad única del evento
bloquear el pago correspondiente o usar una actualización condicional de estado
validar la transición de estado permitida del pago
aplicar el cambio de negocio
escribir en un outbox cualquier trabajo saliente necesario
commit transaction
confirmar según el contrato de entrega del proveedor
Si la transacción falla, el registro del evento también debe revertirse. De lo contrario, un reintento puede encontrar “procesado” aunque el cambio del pago nunca llegara a ocurrir.
Quien arbitra las entregas concurrentes es la restricción de unicidad, no un SELECT previo al INSERT. Gestiona el conflicto de unicidad usando la semántica transaccional de la base de datos: algunas exigen un rollback antes de seguir ejecutando sentencias. Un registro de evento ya confirmado puede justificar que se acepte un duplicado, porque su actualización de negocio asociada se confirmó en la misma transacción.
Para una integración más lenta, aceptar el evento de forma duradera en un inbox y procesarlo después en segundo plano es otro diseño válido. En ese caso, distingue recibido de procesado, y dale a los workers un mecanismo de reintento y recuperación. No marques como completada una entrada del inbox que aún no se ha procesado.
Eventos distintos pueden describir el mismo efecto de negocio
Deduplicar IDs de evento no impide que dos notificaciones distintas intenten la misma entrega. Impón también la invariante de negocio: por ejemplo, un único registro de entrega por pedido, y permite que un pago transicione solo desde estados previos autorizados.
No implementes el estado del pago como “gana el último evento”. Un evento pendiente que llega con retraso no debe sobrescribir un pago ya confirmado. Las devoluciones, las disputas y las capturas parciales también implican que una única lista ordenada de estados puede quedarse corta. Modela el ciclo de vida del proveedor de forma explícita.
Los efectos externos necesitan otra frontera
Una transacción de base de datos no puede confirmar de forma atómica un correo, una petición de envío a un tercero y la actualización de una fila en servicios sin relación entre sí.
Un outbox guarda la intención de realizar una acción externa dentro de la misma transacción que el cambio del pago. Un worker entrega esa intención más tarde. Esto cierra el hueco entre actualizar un pago y recordar que hay que servirlo, pero no elimina los duplicados por sí solo.
El worker puede caerse después de que el servicio remoto haya respondido correctamente y antes de marcar la entrada del outbox como entregada. Usa una clave de idempotencia estable en el servicio de destino cuando esté soportada. Si no lo está, define un proceso de conciliación y de recuperación manual en lugar de prometer una ejecución externa exactamente una vez.
Tests que sacan a la luz fallos reales
| Escenario | Invariante esperada |
|---|---|
| El mismo evento llega dos veces | Un único cambio de negocio confirmado. |
| Dos workers procesan el mismo evento a la vez | La restricción de la base de datos arbitra el duplicado. |
| El procesamiento falla antes del commit | Un reintento puede aplicar el cambio igualmente. |
| La respuesta se pierde después del commit | El reintento se confirma sin repetir el cambio. |
| Dos eventos distintos se refieren al mismo pago correcto | El pedido no se sirve dos veces. |
| Un evento pendiente antiguo llega tras el de éxito | El estado confirmado no retrocede. |
| La firma, el comercio, el importe o la moneda son incorrectos | Ninguna transición de pago no autorizada. |
| El worker del outbox reintenta tras un éxito remoto | La deduplicación o la conciliación en destino evitan una repetición descontrolada. |
Guarda los identificadores de evento y los resultados en los logs operativos, pero evita registrar secretos de pago o datos personales innecesarios. Monitoriza los procesamientos fallidos, los registros antiguos del inbox/outbox y las discrepancias de conciliación.
La idempotencia es, en el fondo, una promesa sobre el comportamiento observable. Escribe esa promesa, colócala en una frontera duradera y prueba qué ocurre cuando la confirmación desaparece.
Para seguir leyendo
- Making retries safe with idempotent APIs, Amazon Builders’ Library.
- Transactional outbox pattern, Microservices.io.
- Patrones de acceso y resiliencia para el contexto más amplio de diseño de sistemas.