diff --git a/app/Services/BancolombiaParser.php b/app/Services/BancolombiaParser.php old mode 100644 new mode 100755 index f8f46ed..e43acf4 --- a/app/Services/BancolombiaParser.php +++ b/app/Services/BancolombiaParser.php @@ -39,16 +39,43 @@ class BancolombiaParser } $result = [ - 'banco' => 'Bancolombia', - 'tipo' => self::detectarTipo($text), - 'destinatario' => self::extraer($text, '/Bancolombia[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?),\s+recibiste/u'), - 'remitente' => self::extraer($text, '/transferencia\s+de\s+(.+?)\s+por\s+\$/iu'), - 'valor' => self::extraerValor($text), - 'cuenta' => self::extraer($text, '/cuenta\s+\*(\d+)/iu'), - 'llave' => self::extraer($text, '/llave\s+([@\w.\-]+)/iu'), - 'fecha' => self::extraer($text, '/el\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/iu'), - 'hora' => self::extraer($text, '/a\s+las\s+(\d{1,2}:\d{2})/iu'), - 'texto_original' => $text, + 'banco' => 'Bancolombia', + 'tipo' => self::detectarTipo($text), + 'destinatario' => self::extraerPrimero($text, [ + '/Bancolombia[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?),\s+recibiste/u', + '/Hola[,\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/u', + '/estimado[a]?\s+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[,\.]/iu', + ]), + 'remitente' => self::extraerPrimero($text, [ + '/transferencia\s+de\s+(.+?)\s+por\s+\$/iu', + '/realiz[oó]\s+(?:un[a]?\s+)?(?:transferencia|pago)\s+de\s+\$[0-9,.]+\s+(?:a\s+tu\s+cuenta|para)\s+(.+?)[\.,]/iu', + '/(?:pagador|remitente|enviado\s+por)[:\s]+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]+?)[\.,]/iu', + '/de\s+([A-ZÁÉÍÓÚÑÜ][A-Za-záéíóúñü\s]{3,50}?)\s+(?:por\s+\$|a\s+tu)/iu', + ]), + 'valor' => self::extraerValor($text), + 'cuenta' => self::extraerPrimero($text, [ + '/cuenta\s+\*(\d+)/iu', + '/cuenta\s+(?:de\s+ahorros|corriente)?\s*(?:No\.?\s*)?\*?(\d{4,})/iu', + '/terminad[ao]\s+en\s+(\d{4})/iu', + ]), + 'llave' => self::extraerPrimero($text, [ + '/llave\s+([@\w.\-]+)/iu', + '/llave\s+(?:de\s+pago\s+)?([@\w.\-]+)/iu', + '/([@][a-z0-9._\-]{3,})/iu', + ]), + 'fecha' => self::extraerPrimero($text, [ + '/el\s+(\d{1,2}\/\d{1,2}\/\d{2,4})/iu', + '/(\d{1,2}\/\d{1,2}\/\d{2,4})/u', + '/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4})/iu', + '/fecha[:\s]+(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/iu', + ]), + 'hora' => self::extraerPrimero($text, [ + '/a\s+las\s+(\d{1,2}:\d{2})/iu', + '/hora[:\s]+(\d{1,2}:\d{2})/iu', + '/(\d{1,2}:\d{2})\s*(?:a\.?m\.?|p\.?m\.?)/iu', + '/(\d{2}:\d{2})(?::\d{2})?/u', + ]), + 'texto_original' => $text, ]; return $result; @@ -60,13 +87,19 @@ class BancolombiaParser private static function esBancolombia(string $text): bool { - return stripos($text, 'bancolombia') !== false - && ( - stripos($text, 'transferencia') !== false - || stripos($text, 'pago') !== false - || stripos($text, 'recibiste') !== false - || stripos($text, 'enviaste') !== false - ); + if (stripos($text, 'bancolombia') === false) { + return false; + } + // Al menos uno de estos indica una notificación transaccional + $indicadores = ['transferencia', 'pago', 'recibiste', 'enviaste', 'credito', 'crédito', + 'abono', 'consignacion', 'consignación', 'recibido', 'recibiste', + 'valor', 'monto', '$']; + foreach ($indicadores as $ind) { + if (stripos($text, $ind) !== false) { + return true; + } + } + return false; } private static function detectarTipo(string $text): string @@ -94,16 +127,37 @@ class BancolombiaParser return ''; } + /** + * Prueba múltiples patrones y devuelve el primer match no-vacío. + */ + private static function extraerPrimero(string $text, array $patterns): string + { + foreach ($patterns as $pat) { + $v = self::extraer($text, $pat); + if ($v !== '') { + return $v; + } + } + return ''; + } + /** * Extrae el valor monetario como string limpio, ej: "1000.00" - * y también como float. - * Devuelve el string formateado con separadores originales. */ private static function extraerValor(string $text): string { - // Captura números con puntos y comas: $1,000.00 | $1.000,00 | $500 - if (preg_match('/por\s+\$([0-9][0-9.,]*)/iu', $text, $m)) { - return trim($m[1]); + // Patrones en orden de especificidad + $patterns = [ + '/por\s+\$\s*([0-9][0-9.,]*)/iu', + '/valor[:\s]+\$?\s*([0-9][0-9.,]*)/iu', + '/monto[:\s]+\$?\s*([0-9][0-9.,]*)/iu', + '/recibiste[^$]*\$\s*([0-9][0-9.,]*)/iu', + '/\$\s*([0-9][0-9.,]{2,})/u', + ]; + foreach ($patterns as $pat) { + if (preg_match($pat, $text, $m)) { + return trim($m[1]); + } } return ''; } diff --git a/app/Services/CorreoImapService.php b/app/Services/CorreoImapService.php old mode 100644 new mode 100755 index 177c8aa..de0de89 --- a/app/Services/CorreoImapService.php +++ b/app/Services/CorreoImapService.php @@ -304,9 +304,18 @@ class CorreoImapService private function cleanBody(string $body): string { - // ── 1. Decoded quoted-printable ────────────────────────────────── - // BODY.PEEK[1] retorna directamente el contenido de la parte (sin - // boundaries), pero puede estar codificado en quoted-printable. + // ── 0. Base64 — detectar antes que QP ──────────────────────────── + // BODY[1] puede llegar codificado en base64 (Content-Transfer-Encoding: base64) + // si el servidor no decodifica automáticamente. + $noWs = preg_replace('/[\r\n\t ]/', '', $body); + if (strlen($noWs) > 60 && preg_match('/^[A-Za-z0-9+\/]+=*$/', $noWs)) { + $decoded = base64_decode($noWs, true); + if ($decoded !== false && preg_match('//u', $decoded)) { + $body = $decoded; + } + } + + // ── 1. Quoted-printable ────────────────────────────────────────── if (preg_match('/=[0-9A-F]{2}/i', $body) || preg_match('/=\r?\n/m', $body)) { $body = quoted_printable_decode($body); } diff --git a/app/Services/PagoValidadorService.php b/app/Services/PagoValidadorService.php index 75cd6eb..464b6cb 100755 --- a/app/Services/PagoValidadorService.php +++ b/app/Services/PagoValidadorService.php @@ -9,74 +9,85 @@ use Illuminate\Support\Facades\Log; class PagoValidadorService { - /** - * @param array $datosPago ['banco','valor','referencia','fecha','hora','remitente','llave'] - * @param int|null $usuarioId - * @param string|null $nombreUsuario nombre registrado en la plataforma - * @param string $canal - * @return array ['estado' => confirmado|no_encontrado|monto_incorrecto|ya_usado, 'correo' => array|null] - */ public function validar(array $datosPago, ?int $usuarioId = null, ?string $nombreUsuario = null, string $canal = 'telegram'): array { if (WhatsappSystemConfig::get('correo_imap_enabled', '0') !== '1') { + Log::info('[PagoValidador] IMAP deshabilitado (correo_imap_enabled != 1)'); return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado']; } try { $correos = $this->fetchCorreos(); } catch (\Throwable $e) { - Log::warning('[PagoValidador] Error leyendo correos: ' . $e->getMessage()); - return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap']; + Log::warning('[PagoValidador] Error IMAP: ' . $e->getMessage()); + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap', 'debug' => $e->getMessage()]; } + Log::info('[PagoValidador] Correos obtenidos: ' . count($correos)); + if (empty($correos)) { return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos']; } - $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'] ?? '')); + $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'] ?? '')); - foreach ($correos as $correo) { - $datosCorreo = BancolombiaParser::parse($correo['body'] ?? ''); + Log::info('[PagoValidador] Buscando: valor=' . $valorIA . ' fecha=' . $fechaIA . ' hora=' . $horaIA . ' llave=' . $llaveIA); + + 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 ──────────────────────────────────────────── $valorCorreo = (int) preg_replace('/[^0-9]/', '', (string) ($datosCorreo['valor'] ?? '0')); - if ($valorIA <= 0 || $valorCorreo <= 0 || abs($valorIA - $valorCorreo) > $tolerancia) { - if ($valorCorreo > 0 && $valorIA > 0 && abs($valorIA - $valorCorreo) > $tolerancia) { - // Monto distinto — seguir buscando en el siguiente correo - continue; - } - if ($valorIA <= 0 || $valorCorreo <= 0) { - continue; - } + + if ($valorIA <= 0 || $valorCorreo <= 0) { + Log::info("[PagoValidador] Correo #{$i}: valor inválido (ia={$valorIA} correo={$valorCorreo})"); + continue; } - // ── 2. Hash único del correo (anti-doble-uso) ─────────── - $emailHash = sha1(($correo['body'] ?? '') . ($correo['date'] ?? '')); + if (abs($valorIA - $valorCorreo) > $tolerancia) { + Log::info("[PagoValidador] Correo #{$i}: monto no coincide (ia={$valorIA} correo={$valorCorreo} tol={$tolerancia})"); + continue; + } + + // ── 2. Hash único (anti-doble-uso) ───────────────────── + $emailHash = sha1($body . $date); if (PagoConfirmado::yaUsado($emailHash)) { - // Este correo ya confirmó a otro usuario - return ['estado' => 'ya_usado', 'correo' => $datosCorreo, - 'motivo' => 'correo_ya_aplicado']; + Log::info("[PagoValidador] Correo #{$i}: ya usado (hash={$emailHash})"); + return ['estado' => 'ya_usado', 'correo' => $datosCorreo, 'motivo' => 'correo_ya_aplicado']; } - // ── 3. Fecha (mismo día) ──────────────────────────────── + // ── 3. Fecha (mismo día) — solo si ambos tienen fecha ── if ($fechaIA && ($datosCorreo['fecha'] ?? '')) { if (! $this->mismoDia($fechaIA, $datosCorreo['fecha'])) { + Log::info("[PagoValidador] Correo #{$i}: fecha no coincide (ia={$fechaIA} correo={$datosCorreo['fecha']})"); continue; } } - // ── 4. Hora (±10 min) ─────────────────────────────────── + // ── 4. Hora (±15 min) — solo si ambos tienen hora ────── if ($horaIA && ($datosCorreo['hora'] ?? '')) { - if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 10)) { + if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 15)) { + Log::info("[PagoValidador] Correo #{$i}: hora fuera de rango (ia={$horaIA} correo={$datosCorreo['hora']})"); continue; } } @@ -85,17 +96,16 @@ class PagoValidadorService 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; } } - // ── 6. Remitente (coincidencia parcial con nombre de usuario) ── - $coincideRemitente = $this->remitenteCoincide( - $datosCorreo['remitente'] ?? '', - $nombreUsuario ?? '' - ); + // ── 6. Remitente (informativo, no bloquea) ────────────── + $coincideRemitente = $this->remitenteCoincide($datosCorreo['remitente'] ?? '', $nombreUsuario ?? ''); + + Log::info("[PagoValidador] Correo #{$i}: CONFIRMADO (remitente_coincide=" . ($coincideRemitente ? 'SI' : 'NO') . ")"); - // Todos los filtros pasados → confirmar PagoConfirmado::marcar($emailHash, [ 'usuario_id' => $usuarioId, 'valor' => $valorIA, @@ -105,22 +115,20 @@ class PagoValidadorService ]); return [ - 'estado' => 'confirmado', - 'correo' => $datosCorreo, - 'coincide_remitente' => $coincideRemitente, + 'estado' => 'confirmado', + 'correo' => $datosCorreo, + 'coincide_remitente' => $coincideRemitente, ]; } - // Si algún correo tenía el monto correcto pero otro campo falló, devolver monto_incorrecto - // de lo contrario no_encontrado - return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia']; + Log::info('[PagoValidador] Ningún correo coincidió con los criterios'); + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia', 'emails_revisados' => count($correos)]; } // ── Helpers ────────────────────────────────────────────────────────── private function mismoDia(string $fechaA, string $fechaB): bool { - // Normaliza dd/mm/yyyy o dd/mm/yy $normA = $this->normalizarFecha($fechaA); $normB = $this->normalizarFecha($fechaB); return $normA && $normB && $normA === $normB; @@ -152,15 +160,12 @@ class PagoValidadorService if (! $remitenteEmail || ! $nombreUsuario) { return false; } - // Comparación parcial insensible a mayúsculas y tildes $normalizar = fn (string $s): string => strtolower( iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s ); - $re = $normalizar($remitenteEmail); $nu = $normalizar($nombreUsuario); - // Basta con que una palabra del nombre de usuario aparezca en el remitente del email foreach (explode(' ', $nu) as $palabra) { if (strlen($palabra) >= 3 && str_contains($re, $palabra)) { return true; @@ -174,12 +179,12 @@ class PagoValidadorService $minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5'); $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'), + 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: 10, minutesBack: $minutos);