- GeminiVisionService: eliminar thinkingConfig (rompe modelos flash), reducir maxOutputTokens 8192→512, timeout 20→40s, default gemini-2.0-flash - PublicChat: detectar correctamente errores de Gemini (__error/__parse_error) que antes pasaban el guard if(!$datosPago) por ser arrays truthy - PagoValidadorService: tolerancia de hora 0→3 min, remitente >=2→>=1 palabra - CorreoImapService: break→continue en correos fuera de ventana (IMAP no garantiza orden cronológico), fetchSize *4→*2 para evitar timeout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
12 KiB
PHP
Executable File
271 lines
12 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ChatConfig;
|
|
use App\Models\PagoConfirmado;
|
|
use App\Models\WhatsappSystemConfig;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class PagoValidadorService
|
|
{
|
|
/**
|
|
* Valida un pago con búsqueda IMAP progresiva: 30 min → 2 h → todo el día.
|
|
* Retorna en cuanto encuentra coincidencia; solo pasa a la siguiente ventana
|
|
* si la anterior no devuelve ningún correo o no hay coincidencia.
|
|
*/
|
|
public function validar(array $datosPago, ?int $usuarioId = null, ?string $nombreUsuario = null, string $canal = 'telegram'): array
|
|
{
|
|
$host = WhatsappSystemConfig::get('correo_imap_host', '');
|
|
if (! $host) {
|
|
Log::info('[PagoValidador] IMAP sin host configurado');
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado'];
|
|
}
|
|
|
|
try {
|
|
$correosDia = $this->fetchCorreosDia();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('[PagoValidador] Error IMAP: ' . $e->getMessage());
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap', 'debug' => $e->getMessage()];
|
|
}
|
|
|
|
Log::info('[PagoValidador] Correos obtenidos del día: ' . count($correosDia));
|
|
|
|
if (empty($correosDia)) {
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos'];
|
|
}
|
|
|
|
// Ventanas progresivas: primero los más recientes, luego ampliar
|
|
$ventanas = [30, 120, 0]; // minutos; 0 = sin filtro (todo el día)
|
|
|
|
foreach ($ventanas as $minutos) {
|
|
$subset = $minutos > 0
|
|
? array_values(array_filter($correosDia, fn ($c) => $this->dentroDeVentana($c, $minutos)))
|
|
: $correosDia;
|
|
|
|
if (empty($subset)) {
|
|
Log::info("[PagoValidador] Ventana {$minutos}min: sin correos, ampliando...");
|
|
continue;
|
|
}
|
|
|
|
Log::info("[PagoValidador] Ventana {$minutos}min: revisando " . count($subset) . " correos");
|
|
|
|
$resultado = $this->buscarEnCorreos($subset, $datosPago, $usuarioId, $nombreUsuario, $canal);
|
|
|
|
// Confirmado o ya_usado → retornar inmediatamente
|
|
if (in_array($resultado['estado'], ['confirmado', 'ya_usado'])) {
|
|
return $resultado;
|
|
}
|
|
|
|
// sin_coincidencia → intentar ventana más amplia
|
|
}
|
|
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia'];
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Lógica de matching (extraída del validar() original)
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
private function buscarEnCorreos(array $correos, array $datosPago, ?int $usuarioId, ?string $nombreUsuario, string $canal): array
|
|
{
|
|
$valorIA = (int) ($datosPago['valor'] ?? 0);
|
|
$tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0');
|
|
$horaIA = $datosPago['hora'] ?? '';
|
|
$fechaIA = $datosPago['fecha'] ?? '';
|
|
$llaveIA = strtolower(preg_replace('/\s+/', '', $datosPago['llave'] ?? ''));
|
|
|
|
Log::info('[PagoValidador] Buscando: valor=' . $valorIA . ' fecha=' . $fechaIA . ' hora=' . $horaIA . ' llave=' . $llaveIA);
|
|
|
|
$hayYaUsado = false;
|
|
|
|
foreach ($correos as $i => $correo) {
|
|
$fuera = $correo['fuera_de_ventana'] ?? false;
|
|
$from = $correo['from'] ?? '';
|
|
$subj = $correo['subject'] ?? '';
|
|
$date = $correo['date'] ?? '';
|
|
$body = $correo['body'] ?? '';
|
|
|
|
Log::info("[PagoValidador] Correo #{$i}: from={$from} | subj={$subj} | date={$date} | fuera_ventana=" . ($fuera ? 'SI' : 'NO') . " | body(200)=" . substr($body, 0, 200));
|
|
|
|
$datosCorreo = BancolombiaParser::parse($body);
|
|
|
|
if (! $datosCorreo) {
|
|
Log::info("[PagoValidador] Correo #{$i}: NO es Bancolombia (parser retornó null)");
|
|
continue;
|
|
}
|
|
|
|
Log::info("[PagoValidador] Correo #{$i} parseado: " . json_encode($datosCorreo));
|
|
|
|
// ── 1. Valor ────────────────────────────────────────────
|
|
$valorRaw = (string) ($datosCorreo['valor'] ?? '0');
|
|
$valorRaw = preg_replace('/[.,]\d{1,2}$/', '', $valorRaw);
|
|
$valorCorreo = (int) preg_replace('/[^0-9]/', '', $valorRaw);
|
|
|
|
if ($valorIA <= 0 || $valorCorreo <= 0) {
|
|
Log::info("[PagoValidador] Correo #{$i}: valor inválido (ia={$valorIA} correo={$valorCorreo})");
|
|
continue;
|
|
}
|
|
|
|
if (abs($valorIA - $valorCorreo) > $tolerancia) {
|
|
Log::info("[PagoValidador] Correo #{$i}: monto no coincide (ia={$valorIA} correo={$valorCorreo} tol={$tolerancia})");
|
|
continue;
|
|
}
|
|
|
|
// ── 2. Fecha (mismo día) ─────────────────────────────────
|
|
if ($fechaIA && ($datosCorreo['fecha'] ?? '')) {
|
|
if (! $this->mismoDia($fechaIA, $datosCorreo['fecha'])) {
|
|
Log::info("[PagoValidador] Correo #{$i}: fecha no coincide (ia={$fechaIA} correo={$datosCorreo['fecha']})");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// ── 3. Hora exacta ───────────────────────────────────────
|
|
if ($horaIA && ($datosCorreo['hora'] ?? '')) {
|
|
if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 3)) {
|
|
Log::info("[PagoValidador] Correo #{$i}: hora no coincide (ia={$horaIA} correo={$datosCorreo['hora']})");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// ── 4. Llave Bancolombia ─────────────────────────────────
|
|
if ($llaveIA) {
|
|
$llaveCorreo = strtolower(preg_replace('/\s+/', '', $datosCorreo['llave'] ?? ''));
|
|
if ($llaveCorreo && $llaveIA !== $llaveCorreo) {
|
|
Log::info("[PagoValidador] Correo #{$i}: llave no coincide (ia={$llaveIA} correo={$llaveCorreo})");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// ── 5. Hash anti-duplicado ───────────────────────────────
|
|
$emailHash = sha1($body . $date);
|
|
|
|
try {
|
|
if (PagoConfirmado::yaUsado($emailHash)) {
|
|
Log::info("[PagoValidador] Correo #{$i}: ya usado (hash={$emailHash})");
|
|
$hayYaUsado = true;
|
|
continue;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('[PagoValidador] ERROR tabla pagos_confirmados: ' . $e->getMessage());
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
|
|
}
|
|
|
|
// ── 6. Remitente ─────────────────────────────────────────
|
|
$remitenteCorreo = $datosCorreo['remitente'] ?? '';
|
|
if ($remitenteCorreo && $nombreUsuario) {
|
|
if (! $this->remitenteCoincide($remitenteCorreo, $nombreUsuario)) {
|
|
Log::info("[PagoValidador] Correo #{$i}: remitente no coincide (correo={$remitenteCorreo} usuario={$nombreUsuario})");
|
|
continue;
|
|
}
|
|
}
|
|
|
|
Log::info("[PagoValidador] Correo #{$i}: CONFIRMADO (remitente={$remitenteCorreo})");
|
|
|
|
try {
|
|
PagoConfirmado::marcar($emailHash, [
|
|
'usuario_id' => $usuarioId,
|
|
'valor' => $valorIA,
|
|
'canal' => $canal,
|
|
'banco' => $datosCorreo['banco'] ?? null,
|
|
'referencia' => $datosPago['referencia'] ?? null,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
Log::error('[PagoValidador] ERROR al marcar hash: ' . $e->getMessage());
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
|
|
}
|
|
|
|
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
|
}
|
|
|
|
if ($hayYaUsado) {
|
|
Log::info('[PagoValidador] Todos los emails coincidentes ya fueron usados');
|
|
return ['estado' => 'ya_usado', 'correo' => null, 'motivo' => 'correo_ya_aplicado'];
|
|
}
|
|
|
|
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia', 'emails_revisados' => count($correos)];
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Obtiene todos los correos del día actual (desde medianoche) con un límite alto.
|
|
* Se hace una sola conexión IMAP; el filtrado progresivo se hace en memoria.
|
|
*/
|
|
private function fetchCorreosDia(): array
|
|
{
|
|
// Minutos desde medianoche + 60 de buffer para zona horaria
|
|
$minutesDesdeMedianoche = (int) ceil(now()->diffInMinutes(now()->copy()->startOfDay())) + 60;
|
|
|
|
$service = new CorreoImapService(
|
|
host: WhatsappSystemConfig::get('correo_imap_host', ''),
|
|
port: (int) WhatsappSystemConfig::get('correo_imap_port', '993'),
|
|
useSsl: WhatsappSystemConfig::get('correo_imap_ssl', '1') === '1',
|
|
user: WhatsappSystemConfig::get('correo_imap_user', ''),
|
|
password: WhatsappSystemConfig::get('correo_imap_password', ''),
|
|
folder: WhatsappSystemConfig::get('correo_imap_folder', 'INBOX'),
|
|
);
|
|
|
|
return $service->fetchLatest(limit: 200, minutesBack: $minutesDesdeMedianoche);
|
|
}
|
|
|
|
private function dentroDeVentana(array $correo, int $minutos): bool
|
|
{
|
|
$ts = ($correo['date'] ?? '') ? @strtotime($correo['date']) : false;
|
|
if ($ts === false) {
|
|
return true; // si no hay fecha, incluir por defecto
|
|
}
|
|
return $ts >= time() - ($minutos * 60);
|
|
}
|
|
|
|
private function mismoDia(string $fechaA, string $fechaB): bool
|
|
{
|
|
$normA = $this->normalizarFecha($fechaA);
|
|
$normB = $this->normalizarFecha($fechaB);
|
|
return $normA && $normB && $normA === $normB;
|
|
}
|
|
|
|
private function normalizarFecha(string $fecha): ?string
|
|
{
|
|
if (preg_match('#(\d{1,2})/(\d{1,2})/(\d{2,4})#', $fecha, $m)) {
|
|
$y = strlen($m[3]) === 2 ? '20' . $m[3] : $m[3];
|
|
return sprintf('%02d/%02d/%s', $m[1], $m[2], $y);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function horaProxima(string $horaA, string $horaB, int $minutos): bool
|
|
{
|
|
$toMin = fn (string $h): ?int => preg_match('/(\d{1,2}):(\d{2})/', $h, $m)
|
|
? (int) $m[1] * 60 + (int) $m[2]
|
|
: null;
|
|
|
|
$a = $toMin($horaA);
|
|
$b = $toMin($horaB);
|
|
|
|
return $a !== null && $b !== null && abs($a - $b) <= $minutos;
|
|
}
|
|
|
|
private function remitenteCoincide(string $remitenteEmail, string $nombreUsuario): bool
|
|
{
|
|
if (! $remitenteEmail || ! $nombreUsuario) {
|
|
return false;
|
|
}
|
|
$normalizar = fn (string $s): string => strtolower(
|
|
iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s
|
|
);
|
|
$re = $normalizar($remitenteEmail);
|
|
$nu = $normalizar($nombreUsuario);
|
|
|
|
$coincidencias = 0;
|
|
foreach (explode(' ', $nu) as $palabra) {
|
|
if (strlen($palabra) >= 3 && str_contains($re, $palabra)) {
|
|
$coincidencias++;
|
|
}
|
|
}
|
|
return $coincidencias >= 1;
|
|
}
|
|
}
|