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();
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace App\Jobs;
use App\Models\ChatMessage;
use App\Models\recarga as Recarga;
use App\Models\Saldo;
use App\Models\SolicitudRecarga;
use App\Models\User;
use App\Services\GeminiVisionService;
use App\Services\PagoValidadorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Valida un comprobante de pago del chat web fuera del request HTTP.
*
* Antes esto corría dentro del propio 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. Peor aún, con SESSION_DRIVER=file el request mantenía
* bloqueado el archivo de sesión, así que el wire:poll del chat no podía
* avanzar y la conversación entera se congelaba.
*
* Ahora el request solo guarda la imagen y despacha este job. El job escribe
* su progreso como mensajes normales del chat (que el poll va mostrando) y
* deja el desenlace en caché para que el componente aplique la recarga.
*
* Espejo del flujo de Telegram, pero independiente: ValidarPagoTelegramJob
* no se toca.
*/
class ValidarPagoWebJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 100;
private const MAX_INTENTOS = 9;
private const RESULTADO_TTL = 1800;
public function __construct(
private int $convId,
private int $usuarioId,
private int $solicitudId,
private string $comprobantePath,
private ?string $nombreRemitente = null,
private ?array $datosPago = null,
private int $intento = 1,
) {}
/** Clave donde el componente Livewire lee el desenlace. */
public static function claveResultado(int $convId, int $solicitudId): string
{
return "pago_web_{$convId}_{$solicitudId}";
}
public function handle(): void
{
$solicitud = SolicitudRecarga::pendiente($this->usuarioId);
if (! $solicitud || $solicitud->id !== $this->solicitudId) {
Log::info("[ValidarPagoWebJob] conv={$this->convId}: la solicitud ya no está pendiente, abortando.");
$this->resolver('cancelado');
return;
}
// ── Paso 1: leer el comprobante con Gemini (solo en el primer intento)
if ($this->datosPago === null) {
$datos = $this->leerComprobante();
if ($datos === null) {
return; // leerComprobante() ya avisó al usuario y cerró el flujo
}
$valorIA = (int) ($datos['valor'] ?? 0);
if ($valorIA !== $solicitud->monto) {
$this->bot(
"⚠️ El monto del comprobante ($" . number_format($valorIA) . ") no coincide "
. "con tu solicitud de recarga ($" . number_format($solicitud->monto) . ")."
);
$this->resolver('monto_incorrecto');
return;
}
$this->datosPago = $datos;
$this->bot("📧 Datos leídos correctamente. Ahora estoy buscando tu pago en el banco...");
}
// ── Paso 2: buscar el pago en el correo
$resultado = app(PagoValidadorService::class)->validar(
$this->datosPago, $this->usuarioId, $this->nombreRemitente, 'web'
);
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
// El saldo se acredita aquí y no en el componente: si el usuario
// cierra o recarga la pestaña mientras validamos, el dinero tiene
// que quedar abonado igual. El componente solo se encarga después
// de completar la compra que hubiera quedado pendiente.
$this->aplicarRecarga($solicitud);
$this->resolver('confirmado', ['monto' => $solicitud->monto]);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$this->bot("⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.");
$this->resolver('ya_usado');
return;
}
if ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
$this->reintentarOAgotar();
return;
}
$this->bot("⚠️ No confirmado: " . match ($motivo) {
'correo_deshabilitado' => 'la verificación por correo no está configurada',
'error_imap' => 'no pudimos leer el correo del banco',
'error_sistema' => 'error interno, intenta de nuevo en unos minutos',
default => 'no se pudo verificar automáticamente',
});
$this->resolver('error');
}
/**
* El pago aún no aparece en el correo: reintentar con espera o rendirse.
* El banco puede tardar un par de minutos en enviar la notificación.
*/
private function reintentarOAgotar(): void
{
if ($this->intento === 1) {
$this->bot(
"⏳ Estamos validando tu pago, esto puede tardar unos minutos.\n"
. "Te confirmo por aquí en cuanto lo encuentre."
);
}
if ($this->intento < self::MAX_INTENTOS) {
self::dispatch(
$this->convId,
$this->usuarioId,
$this->solicitudId,
$this->comprobantePath,
$this->nombreRemitente,
$this->datosPago,
$this->intento + 1,
)->delay(now()->addSeconds(20));
return;
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => 'No pudimos confirmar tu pago automáticamente.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'No encontramos el pago en el correo del banco.',
'ia_datos' => $this->datosPago,
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '← Menú principal', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$this->resolver('agotado', ['datosPago' => $this->datosPago]);
}
/** Acredita el saldo, deja registro de la recarga y cierra la solicitud. */
private function aplicarRecarga(SolicitudRecarga $solicitud): void
{
$monto = (int) $solicitud->monto;
DB::transaction(function () use ($solicitud, $monto) {
$user = User::with('saldo')->find($this->usuarioId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->usuarioId, 'valor' => 0]);
$saldo->update(['valor' => $saldo->valor + $monto]);
Recarga::create([
'usuario_id' => $this->usuarioId,
'saldo_id' => $saldo->id,
'monto' => $monto,
'valor_recarga' => $monto,
'status' => 'Confirmado',
'reference' => 'breb-chat-' . $solicitud->id,
]);
$solicitud->confirmar();
$this->bot(
"✅ ¡Tu recarga de $" . number_format($monto) . " fue aplicada!\n"
. "Tu nuevo saldo es $" . number_format($saldo->valor) . "."
);
});
}
/**
* Lee el comprobante con Gemini. Devuelve null (y avisa al usuario) si no
* se pudo interpretar.
*/
private function leerComprobante(): ?array
{
$ruta = storage_path('app/' . $this->comprobantePath);
if (! is_file($ruta)) {
Log::warning("[ValidarPagoWebJob] conv={$this->convId}: no existe {$ruta}");
$this->bot("No pude recuperar la imagen del comprobante. Envíala de nuevo, por favor.");
$this->resolver('error');
return null;
}
$datos = app(GeminiVisionService::class)->extraerPago(
base64_encode(file_get_contents($ruta)),
mime_content_type($ruta) ?: 'image/jpeg',
$this->usuarioId,
'web'
);
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta'));
$this->bot("No pude leer el comprobante. Intenta con una foto más clara o con mejor iluminación.");
$this->resolver('error');
return null;
}
return $datos;
}
/** Si el job muere (excepción o timeout) el chat no puede quedarse colgado. */
public function failed(?\Throwable $e): void
{
Log::error('[ValidarPagoWebJob] falló: ' . ($e?->getMessage() ?? 'sin detalle'));
$this->bot("Tuvimos un problema validando tu comprobante. Intenta enviarlo de nuevo o pide un asesor.");
$this->resolver('error');
}
private function bot(string $contenido): void
{
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'text',
'contenido' => $contenido,
'leido' => true,
]);
}
private function resolver(string $estado, array $extra = []): void
{
Cache::put(
self::claveResultado($this->convId, $this->solicitudId),
array_merge(['estado' => $estado], $extra),
self::RESULTADO_TTL
);
}
}
@@ -263,13 +263,13 @@
</button>
</div>
{{-- Reintento automático de validación de pago.
Va en su propio elemento: Livewire v2 solo respeta el PRIMER
wire:poll de un mismo nodo, así que compartirlo con el poll de
mensajes hacía que este nunca se ejecutara. Solo se renderiza
mientras hay un pago pendiente, así no gasta requests. --}}
@if ($flujo === 'pago.pendiente')
<div wire:poll.20000ms="autoReintentarPago" class="w-0 h-0 overflow-hidden" aria-hidden="true"></div>
{{-- Mientras ValidarPagoWebJob trabaja, este poll solo pregunta si ya
hay desenlace (lectura de caché, sin llamadas externas).
Va en su propio elemento porque Livewire v2 solo respeta el
PRIMER wire:poll de un mismo nodo: compartirlo con el poll de
mensajes hacía que uno de los dos nunca se ejecutara. --}}
@if ($flujo === 'pago.procesando')
<div wire:poll.2000ms="revisarPago" class="w-0 h-0 overflow-hidden" aria-hidden="true"></div>
@endif
{{-- Mensajes --}}
@@ -673,16 +673,30 @@
</div>
</template>
{{-- "Escribiendo…" mientras el bot procesa cualquier acción --}}
<div wire:loading.flex wire:target="enviar, clickBoton, fotoComprobante" class="justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
{{-- "Escribiendo…". Mientras el job valida el pago se queda fijo,
porque el trabajo ocurre fuera del request y wire:loading no
lo cubriría. --}}
@if ($flujo === 'pago.procesando')
<div class="flex justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
</div>
@else
<div wire:loading.flex wire:target="enviar, clickBoton, fotoComprobante" class="justify-start msg-in">
<div class="bg-[#202c33] rounded-xl rounded-tl-sm px-4 py-3 shadow bubble-in">
<div class="flex items-center gap-1">
<span class="typing-dot"></span>
<span class="typing-dot"></span>
<span class="typing-dot"></span>
</div>
</div>
</div>
@endif
<div id="pub-messages-end"></div>
</div>
@@ -722,7 +736,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
Analizando comprobante con IA...
Enviando comprobante...
</div>
</div>
<div class="flex items-end gap-2 px-3 py-2.5">