Volver al inicio

Dos pestañas, dos cobros: una carrera en Webpay y cómo cerrarla

Un flujo de pago puede pasar todas sus pruebas y aun así cobrar dos veces. Me pasó con una integración de Webpay Plus en Laravel para reservas con pago: el camino normal estaba probado de punta a punta contra el ambiente de integración de Transbank y funcionaba. El fallo apareció cuando revisé el código buscando qué pasaba si la misma persona iniciaba el pago dos veces.

No fue un incidente. Lo encontré en una auditoría propia, antes de que le pasara a un cliente, y lo reproduje con una prueba. Lo cuento porque la causa es fácil de repetir en cualquier integración.

La secuencia

  1. La persona abre la reserva en dos pestañas, o hace doble clic en “Pagar”.
  2. Cada petición pregunta si la reserva ya tiene un pago aprobado. Ninguna lo tiene todavía, así que las dos crean una transacción “en proceso”.
  3. La persona completa el formulario de Webpay en las dos pestañas.
  4. Cada retorno confirma su propia transacción. Las dos quedan aprobadas y la tarjeta se cobra dos veces.

Dos pestañas pagan la misma reserva sin bloqueo. Pestaña A y pestaña B siguen los mismos pasos: inician el pago, pagan en Webpay y vuelven al sitio. Las dos confirmaciones corren a la vez. Las dos quedan aprobadas y la tarjeta se cobra dos veces.

Antes: sin bloqueo sobre la reserva, las dos transacciones se confirman.

Las dos causas

La primera es una carrera al crear el intento. El código leía el estado de la reserva y después escribía la transacción, sin bloquear nada entre medio. Dos peticiones casi simultáneas ven el mismo estado y las dos siguen adelante.

La segunda es menos evidente. En el retorno, cada transacción se validaba solo contra sí misma: que la orden, el monto y la sesión coincidieran con lo guardado. Todo eso estaba bien, y aun así nadie preguntaba si la reserva ya tenía otra transacción aprobada. La regla del negocio (“una reserva, un pago aprobado”) no estaba escrita en ninguna parte del código.

La corrección

Son dos defensas independientes, una en cada extremo del flujo.

Al crear el intento, se bloquea la fila de la reserva dentro de la transacción de base de datos. Las dos peticiones pasan una tras otra, y la segunda ya ve el intento de la primera.

En el retorno, se bloquea primero la transacción y después la reserva. Si otra transacción de la misma reserva ya está aprobada, la actual se anula y no se confirma con Transbank:

iniciar transacción de base de datos
  bloquear(transacción)
  si ya está aprobada: responder "ya estaba confirmado" y salir
  bloquear(reserva)
  si la reserva tiene otra transacción aprobada:
    anular esta transacción, sin confirmar con Transbank
    responder "ya fue pagada con otro intento"
  confirmar con Transbank
  comparar orden, monto y sesión con lo guardado
  marcar aprobada
terminar transacción

El bloqueo sobre la reserva obliga a que dos confirmaciones simultáneas se ejecuten en fila. La segunda encuentra la aprobación de la primera, se anula y no se confirma con Transbank.

Dos pestañas pagan la misma reserva. Pestaña A y pestaña B siguen los mismos pasos: inician el pago, pagan en Webpay y vuelven al sitio. Las dos confirmaciones pasan por un bloqueo de la reserva, una tras otra. La pestaña A queda confirmada. La pestaña B queda anulada: ya pagada con otro intento.

Después: con un bloqueo sobre la reserva, solo un intento se confirma.

Un detalle que me costó una segunda vuelta: el bloqueo tiene que estar dentro de la misma transacción de base de datos que la lectura y la escritura. Un bloqueo tomado fuera se suelta antes de tiempo y no protege nada.

Hay una tercera defensa que uso en otro proyecto y que evita el problema antes: si la orden ya tiene un intento pendiente con el mismo proveedor, se reutiliza en vez de crear uno nuevo. Así la persona nunca llega a tener dos formularios de pago abiertos para lo mismo.

Cómo probarlo sin hilos

Una carrera parece difícil de probar, pero esta no necesita concurrencia real. La prueba deja sembradas dos transacciones en proceso para la misma reserva, simula que Transbank aprueba las dos y pasa ambas por el retorno. El resultado esperado es una aprobada y una anulada. Es determinista y corre en milisegundos.

La prueba de extremo a extremo llega hasta la redirección a Webpay y verifica el monto. No completa el formulario de la tarjeta: es la interfaz de un tercero, y el ambiente de integración a veces falla por su cuenta. Conviene que la integración continua no dependa de él.

Otras cosas que el retorno de Webpay hace y que conviene esperar

El retorno es una entrada que no controlas y que puede llegar más de una vez. Estas son las que tuve que resolver:

  • Llega dos veces. Si la transacción ya está aprobada, la respuesta es la misma de antes, sin volver a confirmar ni reenviar correos.
  • Llega sin token. El formulario tiene tres finales: la persona paga, la persona anula, o el formulario vence sin avisar. El tercero no trae ni el token de pago ni el de anulación. En ese caso consulto el estado de la transacción en Transbank y cierro el intento, o lo confirmo si alcanzó a autorizarse.
  • La confirmación falla a medias. Si la llamada de confirmación lanza un error, no asumo que el pago falló. Consulto el estado y decido con ese dato.
  • El intento queda abandonado. Una transacción que nadie terminó puede seguir figurando como iniciada durante mucho tiempo. Si eso bloquea al cliente para pagar de nuevo, hay que cerrarla por tiempo, comprobando antes que orden, sesión y monto coincidan.
  • Los correos van fuera de la transacción. Un correo no se puede deshacer. Si se envía dentro de la transacción de base de datos y algo falla después, la base vuelve atrás y el correo ya salió.

Resumen

  • Validar la transacción contra sí misma no basta. La regla del negocio se impone sobre lo que agrupa los pagos: la reserva, la orden, el pedido.
  • Bloquear al crear el intento y también al confirmarlo, siempre dentro de la transacción de base de datos.
  • Tratar el retorno como una entrada repetible y no confiable.
  • Probar la carrera sembrando el estado, no con hilos.

Si tienes un flujo de pago que revisar, escríbeme a contacto@videla.dev.