Files
sirpremiumv2/app/Jobs/ValidarPagoWebJob.php
T
LizandroandClaude Sonnet 5 7b21cd9773 fix: chat web - mensaje al cancelar validacion, doble job, e intencion de compra
- ValidarPagoWebJob: avisa al usuario si la solicitud ya no esta pendiente
  en vez de saltar en silencio a "Menu principal".
- PublicChat: guard para no despachar dos jobs de validacion del mismo
  comprobante si Livewire dispara updatedFotoComprobante() dos veces
  (causaba que apareciera "Menu principal" y luego se corrigiera solo).
- PublicChat: "comprar hbo" caia en historial.ver porque compra(s)? sin \b
  matcheaba dentro de "comprar". Agregado limite de palabra.
- PublicChat: "comprar plan de netflix" caia en el listado generico de
  servicios por la palabra "plan". Ahora, si el mensaje nombra un servicio
  concreto, se abre ese servicio directo en vez del listado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:21:14 +00:00

286 lines
11 KiB
PHP

<?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\OcrExtractionService;
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->bot("Tu solicitud de recarga ya no está activa (puede que hayas iniciado una nueva). Si ya transferiste, escribe a un asesor para confirmar tu pago manualmente.");
$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;
}
$base64 = base64_encode(file_get_contents($ruta));
$mime = mime_content_type($ruta) ?: 'image/jpeg';
$gemini = app(GeminiVisionService::class);
$datos = null;
// Camino nuevo: OCR externo (texto) + Gemini solo-texto. Evita depender
// de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes.
$ocr = app(OcrExtractionService::class);
if ($ocr->habilitado()) {
$texto = $ocr->extraerTexto($base64, $mime);
if ($texto) {
$datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web');
} else {
Log::warning('[ValidarPagoWebJob] OCR no devolvio texto, cae a lectura por vision.');
}
}
// Si el OCR no esta habilitado, no respondio, o Gemini no pudo
// estructurar su texto: cae al metodo anterior (Gemini lee la imagen).
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
$datos = $gemini->extraerPago($base64, $mime, $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
);
}
}