diff --git a/app/Http/Livewire/Chat/PublicChat.php b/app/Http/Livewire/Chat/PublicChat.php index 4522d02..f8f7d2c 100755 --- a/app/Http/Livewire/Chat/PublicChat.php +++ b/app/Http/Livewire/Chat/PublicChat.php @@ -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(); diff --git a/app/Jobs/ValidarPagoWebJob.php b/app/Jobs/ValidarPagoWebJob.php new file mode 100644 index 0000000..633227c --- /dev/null +++ b/app/Jobs/ValidarPagoWebJob.php @@ -0,0 +1,267 @@ +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 + ); + } +} diff --git a/resources/views/livewire/chat/public-chat.blade.php b/resources/views/livewire/chat/public-chat.blade.php index 49e0264..cb7369c 100755 --- a/resources/views/livewire/chat/public-chat.blade.php +++ b/resources/views/livewire/chat/public-chat.blade.php @@ -263,13 +263,13 @@ - {{-- 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') -
+ {{-- 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') + @endif {{-- Mensajes --}} @@ -673,16 +673,30 @@ - {{-- "Escribiendo…" mientras el bot procesa cualquier acción --}} -