fix(pago): diagnose and harden IMAP email validation
- PagoValidadorService: add detailed Log::info at every step (emails count, body preview, parser result, which filter fails) so failures are visible in laravel.log - CorreoImapService::cleanBody: detect and decode base64-encoded BODY[1] before QP decoding — critical fix when Bancolombia sends HTML part as base64 - BancolombiaParser: make esBancolombia() broader (includes 'valor','$','abono' etc.), add extraerPrimero() helper, add multiple regex fallbacks for remitente/fecha/hora/valor to handle different Bancolombia email HTML formats beyond the SMS text pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
1fd9a92afa
commit
595c75837f
Regular → Executable
+76
-22
@@ -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 '';
|
||||
}
|
||||
|
||||
Regular → Executable
+12
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user