Los webhooks de pago llegan tarde, dos veces y desordenados
La vuelta desde la página de pago no es una confirmación. Un único webhook tampoco. Qué promete de verdad un proveedor de pagos sobre la entrega, y cómo construir algo correcto encima.
Un proveedor de pagos le dirá que entrega los eventos de forma fiable. Es cierto, y es una promesa más estrecha de lo que la mayoría de equipos lee en ella. Lo que significa es que el evento llegará en algún momento, probablemente más de una vez, posiblemente horas después de lo que describe, y no necesariamente en el orden en que ocurrieron las cosas.
Todas las decisiones de diseño de abajo salen de tomarse esa frase al pie de la letra.
El redirect es interfaz, no resultado
Cuando el cliente vuelve de la página de pago, su aplicación recibe una petición que dice, en el fondo, que el cliente está aquí otra vez. No dice que el pago funcionó, y no la envía el proveedor: la envía el navegador del cliente, que es algo que se puede cerrar, recargar o sustituir por un móvil que se queda sin batería en un ascensor.
Use el redirect para exactamente una cosa: mostrar una pantalla. Lea el estado actual del pago en el proveedor, muestre lo que sabe, y si aún no lo sabe, dígalo. Lo que el redirect no puede hacer nunca es escribir "pagado" en su base de datos.
El pago lo confirma el evento, y el evento llega por un canal que el cliente no puede alterar.
Verifique los bytes, no el objeto
La firma es un hash sobre el cuerpo en bruto con un secreto compartido, más una marca de tiempo para impedir que se reinyecte un envío antiguo. Dos cosas se tuercen y las dos parecen una clave equivocada:
public function handle(Request $request): Response
{
$payload = $request->getContent(); // en bruto, no $request->all()
try {
$event = Webhook::constructEvent(
$payload,
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret'),
);
} catch (SignatureVerificationException) {
return response()->noContent(400);
}
ProcessPaymentEvent::dispatch($event->id, $payload);
return response()->noContent(200);
}La primera es interpretar antes de calcular el hash. $request->all() le da un
array, y volver a codificarlo produce bytes distintos de los que llegaron: otro
orden de claves, otro escapado unicode, otro formato de números. Calcule el hash
de la cadena.
La segunda es el middleware CSRF, que rechazará la petición antes de que nada de esto se ejecute, porque un proveedor de pagos no tiene sesión ni token. La ruta va fuera del grupo de middleware web, y si está dentro, el fallo es un 419 que el proveedor reintenta durante tres días.
Fíjese en lo que hace el manejador después de verificar: casi nada. Pasa el trabajo a una cola y devuelve. Los proveedores esperan un reconocimiento rápido, y hacer el trabajo en línea convierte una consulta lenta en una tormenta de reintentos.
Desordenado es el caso normal
Dos eventos sobre un mismo pago pueden llegar en la secuencia equivocada. Un cargo funciona y se devuelve noventa segundos después; el evento de devolución adelanta al de éxito; su manejador procesa la devolución contra un pedido que todavía no está pagado, decide que eso no tiene sentido, y no hace nada. Ahora el pedido está pagado para siempre.
No lo arregle con orden. Arréglelo haciendo que cada manejador describa el mundo en lugar de una transición:
- Mal: "al devolver, pasa el estado de capturado a devuelto."
- Bien: "ante cualquier evento de este pago, consulta su estado actual en el proveedor y deja mi fila igual."
El evento pasa a ser una señal para ir a mirar, y su orden de llegada deja de importar. Cuesta una llamada por evento y elimina una clase entera de error que de otro modo la encuentra en producción un contable desconcertado.
Donde de verdad no pueda volver a consultar, guarde en la fila la marca de tiempo del evento del proveedor e ignore todo lo más antiguo que lo ya aplicado.
Dos veces también es el caso normal
Todos los proveedores reintentan ante cualquier cosa que no sea un 2xx, y un envío que expiró después de que su código funcionara también se reintenta. El mismo evento aterrizará dos veces.
Guarde el identificador de evento del proveedor con un índice único, insértelo antes de procesar, y deje que la base de datos rechace el duplicado. Ese es todo el mecanismo, y es más fiable que comprobar si la fila ya existe, porque una comprobación seguida de una inserción es una carrera entre dos workers, y en una tormenta de reintentos los dos workers son reales.
Y después deje de fiarse del canal
Todo lo anterior hace correcto el camino del webhook. No lo hace completo, porque un endpoint que estuvo caído cuatro horas durante un despliegue es un endpoint que perdió eventos, y cuando se cierra la ventana de reintentos ya no están.
Así que ejecute un trabajo de conciliación. Una vez al día, pida al proveedor todos los pagos que cambiaron desde su última ejecución correcta y compárelos con sus filas. Informe de las diferencias en lugar de corregirlas en silencio: un desajuste suele significar un error, y un trabajo que arregla sus datos calladamente esconderá ese error durante un año.
Es la misma forma que las integraciones contables que construimos: un flujo de eventos para la frescura, una consulta periódica para la corrección, y la consulta es aquello en lo que de verdad puede confiar. Con pagos hay una segunda razón: alguien de finanzas va a preguntar por qué el total mensual del proveedor y su base de datos no coinciden, y "sí coinciden" es mucho mejor respuesta que una investigación.
Si está montando esto junto con el modelo de pedido, cómo se modela el pago decide la mitad de lo que estos manejadores tienen que hacer, y conviene resolverlo antes.
