fix: mover la validación de pagos del chat web a una cola

El upload del comprobante hacía todo dentro de un solo request de Livewire:
Gemini (hasta 80s) más la lectura IMAP (20-60s) superaban el límite del
gateway y el usuario recibía un 504. Además, con SESSION_DRIVER=file ese
request mantenía bloqueado el archivo de sesión, así que el wire:poll del
chat no podía avanzar: la conversación entera se congelaba y el mensaje
"Analizando tu comprobante" se quedaba pegado para siempre.

Ahora updatedFotoComprobante() solo guarda la imagen, escribe el primer
mensaje y despacha ValidarPagoWebJob. Termina en ~1s.

El job hace el trabajo pesado fuera del request y va escribiendo su avance
como mensajes normales del chat, así el usuario ve los pasos:
  "Recibido, leyendo los datos..." -> "Datos leídos, buscando en el banco..."
  -> resultado
Reintenta cada 20s hasta 9 veces, igual que el flujo de Telegram.

El saldo se acredita dentro del job, no en el componente: si el usuario
cerraba o recargaba la pestaña durante la validación, el pago se confirmaba
pero el dinero nunca se abonaba. El componente ahora solo refresca el saldo
en pantalla y remata la compra que hubiera quedado pendiente.

Otros cierres del flujo que faltaban:
- failed() escribe un mensaje si el job muere, para no dejar el chat colgado.
- revisarPago() se rinde a los 6 min si el worker está caído.
- Los estados terminales dejan siempre un botón de menú.
- El botón Reintentar reencola en vez de validar dentro del request.

No se toca Telegram ni ningún servicio compartido (GeminiVisionService,
PagoValidadorService, CorreoImapService, ValidarPagoTelegramJob).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-08-01 14:12:57 +00:00
co-authored by Claude Sonnet 4.6
parent f4f689891c
commit 281c9d5b02
3 changed files with 401 additions and 198 deletions
+104 -182
View File
@@ -554,8 +554,11 @@ class PublicChat extends Component
return;
}
// Este método debe terminar rápido: la lectura con IA y la búsqueda en
// el correo tardan más de lo que aguanta el gateway, así que se hacen
// en ValidarPagoWebJob y aquí solo se guarda la imagen y se despacha.
try {
// Verificar solicitud activa ANTES de cualquier llamada de API
// Verificar solicitud activa ANTES de guardar nada
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
$this->fotoComprobante = null;
@@ -568,82 +571,25 @@ class PublicChat extends Component
// (config/filesystems.php links). El disco 'public' no tiene symlink
// en este proyecto, por eso las imágenes daban 404.
$path = $this->fotoComprobante->store('photos');
$url = asset($path);
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'usuario',
'tipo_ui' => 'imagen',
'contenido' => '[Comprobante de pago]',
'payload' => ['url' => $url],
'payload' => ['url' => asset($path)],
'leido' => false,
]);
$this->guardarMensajeBot($this->convId, "⏳ Analizando tu comprobante...");
$this->guardarMensajeBot($this->convId, "🔍 Recibido. Estoy leyendo los datos de tu comprobante...");
$base64 = base64_encode(file_get_contents($this->fotoComprobante->getRealPath()));
$mimeType = $this->fotoComprobante->getMimeType();
$datosPago = app(\App\Services\GeminiVisionService::class)->extraerPago($base64, $mimeType);
if (! $datosPago || isset($datosPago['__error']) || isset($datosPago['__parse_error'])) {
$detalle = $datosPago['__error'] ?? ($datosPago['__parse_error'] ?? '');
Log::warning('[PublicChat] Gemini error: ' . $detalle);
$this->guardarMensajeBot($this->convId, "No pude leer el comprobante. Intenta con una foto mas clara o con mejor iluminación.");
$this->cargarMensajes();
return;
}
// Verificar que el monto coincida con la solicitud
$valorIA = (int) ($datosPago['valor'] ?? 0);
if ($valorIA !== $solicitud->monto) {
$this->guardarMensajeBot($this->convId, "El monto del comprobante ($" . number_format($valorIA) . ") no coincide con tu solicitud de recarga ($" . number_format($solicitud->monto) . ").");
$this->cargarMensajes();
return;
}
$nombreParaValidar = $this->flujoData['nombre_remitente'] ?? $this->nombreUsuario;
$resultado = app(\App\Services\PagoValidadorService::class)->validar(
$datosPago, $this->userId, $nombreParaValidar, 'web'
);
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecargaWeb($solicitud, $solicitud->monto);
return;
} elseif ($resultado['estado'] === 'ya_usado') {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, "⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.");
} elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
$this->flujo = 'pago.pendiente';
$this->flujoData = array_merge($this->flujoData, ['datosPago' => $datosPago, 'intentos' => 0]);
$this->guardarMensajeBot($this->convId,
"⏳ *Estamos validando tu pago*, esto puede demorar unos minutos.\n"
. "Cuando terminemos te confirmaremos por este medio. Espera un momento."
);
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
return;
} else {
$motivoTexto = match ($motivo) {
'correo_deshabilitado' => 'Verificacion por correo no configurada',
'error_imap' => 'Error al leer correos IMAP',
'error_sistema' => 'Error interno, intenta de nuevo en unos minutos',
default => 'No se pudo verificar automaticamente',
};
$this->guardarMensajeBot($this->convId, "⚠️ No confirmado: {$motivoTexto}");
}
$this->lanzarValidacion($solicitud, $path);
} catch (\Throwable $e) {
Log::error('[PublicChat] Error procesando comprobante: ' . $e->getMessage());
$this->guardarMensajeBot($this->convId, "Error analizando el comprobante. Por favor intenta de nuevo.");
Log::error('[PublicChat] Error recibiendo comprobante: ' . $e->getMessage());
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, "No pude recibir la imagen. Por favor intenta de nuevo.");
} finally {
$this->fotoComprobante = null;
$this->cargarMensajes();
@@ -651,6 +597,33 @@ class PublicChat extends Component
}
}
/**
* Deja el flujo en 'pago.procesando' y manda el trabajo pesado a la cola.
* Mientras dure, la vista hace poll a revisarPago() —consulta barata— y el
* job va escribiendo su progreso como mensajes normales del chat.
*/
private function lanzarValidacion(\App\Models\SolicitudRecarga $solicitud, string $path, ?array $datosPago = null): void
{
$this->flujo = 'pago.procesando';
$this->flujoData = array_merge($this->flujoData, [
'solicitud_id' => $solicitud->id,
'comprobante_path' => $path,
'datosPago' => $datosPago,
'inicio' => now()->timestamp,
]);
Cache::forget(\App\Jobs\ValidarPagoWebJob::claveResultado($this->convId, $solicitud->id));
\App\Jobs\ValidarPagoWebJob::dispatch(
$this->convId,
$this->userId,
$solicitud->id,
$path,
$this->flujoData['nombre_remitente'] ?? $this->nombreUsuario,
$datosPago,
);
}
// ─────────────────────────────────────────────────────────
// Menu principal
// ─────────────────────────────────────────────────────────
@@ -1410,76 +1383,76 @@ class PublicChat extends Component
// Aplicar recarga validada por IA
// ─────────────────────────────────────────────────────────
// Llamado por wire:poll cada 20s — solo actúa cuando hay un pago pendiente de validar
public function autoReintentarPago(): void
/**
* Poll barato mientras el job valida (flujo 'pago.procesando').
* No hace llamadas externas: solo mira si el job ya dejó su desenlace.
* La recarga se aplica aquí y no en el job porque aquí vive la compra
* pendiente que debe completarse tras acreditar el saldo.
*/
public function revisarPago(): void
{
if ($this->flujo !== 'pago.pendiente' || empty($this->flujoData['datosPago'])) {
if ($this->flujo !== 'pago.procesando' || ! $this->convId) {
return;
}
$maxIntentos = 9;
$intentos = (int) ($this->flujoData['intentos'] ?? 0);
$solicitudId = (int) ($this->flujoData['solicitud_id'] ?? 0);
if (! $solicitudId) {
$this->flujo = '';
$this->flujoData = [];
return;
}
// Agotados — mostrar botón manual una sola vez
if ($intentos >= $maxIntentos) {
if (! ($this->flujoData['boton_manual_mostrado'] ?? false)) {
$this->flujoData['boton_manual_mostrado'] = true;
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => '⚠️ No pudimos confirmar tu pago automáticamente. Si ya realizaste la transferencia, presiona Reintentar.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'No pudimos confirmar automáticamente.',
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '<- Menú', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$clave = \App\Jobs\ValidarPagoWebJob::claveResultado($this->convId, $solicitudId);
$resultado = Cache::get($clave);
if (! $resultado) {
// Red de seguridad: si el worker está caído el job nunca escribirá
// nada y el chat se quedaría con los puntitos para siempre.
// El job reintenta ~3 min, así que a los 6 damos por perdido.
$inicio = (int) ($this->flujoData['inicio'] ?? 0);
if ($inicio && now()->timestamp - $inicio > 360) {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, "No pude terminar de validar tu pago. Si ya transferiste, escribe a un asesor y lo revisamos manualmente.");
$this->volver('menu.principal');
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
}
return; // el job sigue trabajando; sus mensajes llegan por pollMensajes()
}
Cache::forget($clave);
if (($resultado['estado'] ?? '') === 'confirmado') {
$this->finalizarRecargaConfirmada();
return;
}
$this->flujoData['intentos'] = $intentos + 1;
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
// 'agotado' deja el flujo listo para el botón manual de Reintentar
if (($resultado['estado'] ?? '') === 'agotado') {
$this->flujo = 'pago.pendiente';
// El job ya leyó el comprobante: guardamos los datos para que un
// reintento manual no tenga que volver a gastar una llamada a la IA.
if (! empty($resultado['datosPago'])) {
$this->flujoData['datosPago'] = $resultado['datosPago'];
}
} else {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⚠️ Tu solicitud de recarga expiró. Inicia una nueva desde el menú.');
$this->cargarMensajes();
return;
$this->volver('menu.principal');
}
$datosPago = $this->flujoData['datosPago'];
$nombreParaValidar = $this->flujoData['nombre_remitente'] ?? $this->nombreUsuario;
$resultado = app(\App\Services\PagoValidadorService::class)->validar($datosPago, $this->userId, $nombreParaValidar, 'web');
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecargaWeb($solicitud, $solicitud->monto);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.');
$this->cargarMensajes();
return;
}
// Sigue sin encontrarse — el poll volverá a llamar este método en 20s
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
}
/** Boton "Reintentar": vuelve a encolar la busqueda, sin bloquear el request. */
private function reintentarValidacionPago(): void
{
if ($this->flujo !== 'pago.pendiente' || empty($this->flujoData['datosPago'])) {
// Basta con tener la imagen: si no hay datosPago el job la vuelve a leer.
if (empty($this->flujoData['comprobante_path'])) {
$this->guardarMensajeBot($this->convId, 'No hay ningun comprobante pendiente. Envia la foto de tu comprobante.');
return;
}
@@ -1488,84 +1461,31 @@ class PublicChat extends Component
if (! $solicitud) {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⚠️ Tu solicitud de recarga expiro. Inicia una nueva recarga desde el menu.');
$this->cargarMensajes();
$this->guardarMensajeBot($this->convId, 'Tu solicitud de recarga expiro. Inicia una nueva recarga desde el menu.');
return;
}
$datosPago = $this->flujoData['datosPago'];
$nombreParaValidar = $this->flujoData['nombre_remitente'] ?? $this->nombreUsuario;
$resultado = app(\App\Services\PagoValidadorService::class)->validar($datosPago, $this->userId, $nombreParaValidar, 'web');
$motivo = $resultado['motivo'] ?? '';
$this->guardarMensajeBot($this->convId, 'Buscando tu pago de nuevo...');
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecargaWeb($solicitud, $solicitud->monto);
return;
} elseif ($resultado['estado'] === 'ya_usado') {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.');
} elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => 'Aun no encontramos tu pago.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'Aun no encontramos tu pago. Puede demorar entre 1 y 2 minutos en llegar.',
'ia_datos' => $datosPago,
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '<- Menu', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
return;
} else {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⚠️ No se pudo verificar el pago. Intenta de nuevo.');
}
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
$this->lanzarValidacion(
$solicitud,
$this->flujoData['comprobante_path'],
$this->flujoData['datosPago'] ?? null
);
}
private function autoAplicarRecargaWeb(\App\Models\SolicitudRecarga $solicitud, int $monto): void
/**
* El job ya acreditó el saldo y avisó al usuario. Aquí solo refrescamos el
* saldo en pantalla y rematamos la compra que hubiera quedado esperando.
*/
private function finalizarRecargaConfirmada(): void
{
$user = User::with('saldo')->find($this->userId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->userId, 'valor' => 0]);
$nuevoSaldo = $saldo->valor + $monto;
$saldo->update(['valor' => $nuevoSaldo]);
$this->saldoUsuario = $nuevoSaldo;
\App\Models\recarga::create([
'usuario_id' => $this->userId,
'saldo_id' => $saldo->id,
'monto' => $monto,
'valor_recarga' => $monto,
'status' => 'Confirmado',
'reference' => 'breb-chat-' . $solicitud->id,
]);
$solicitud->confirmar();
$this->saldoUsuario = User::with('saldo')->find($this->userId)?->saldo?->valor ?? $this->saldoUsuario;
$pp = $this->flujoData['pending_purchase'] ?? null;
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot(
$this->convId,
"✅ ¡Tu recarga de $" . number_format($monto) . " fue aplicada exitosamente!\nTu nuevo saldo es $" . number_format($nuevoSaldo) . "."
);
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
$this->flujo = 'compra';
$this->flujoData = [
@@ -1578,6 +1498,8 @@ class PublicChat extends Component
$this->pagarConSaldo();
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
$this->comprarPromoConSaldo($pp['promo_id']);
} else {
$this->volver('menu.principal');
}
$this->cargarMensajes();