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:
co-authored by
Claude Sonnet 4.6
parent
f4f689891c
commit
281c9d5b02
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user