'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; } }