feat: validación de pago mejorada — IMAP progresivo, auto-reintento, solo Bre-B, recargar-y-comprar
- PagoValidadorService: búsqueda IMAP progresiva (30min → 2h → todo el día) con una sola conexión - SolicitudRecarga::pendiente() amplía ventana de 30min a todo el día (startOfDay) - Auto-reintento silencioso: web via wire:poll cada 20s (9 intentos ≈3min), Telegram via ValidarPagoTelegramJob - Mensaje al usuario al recibir comprobante: 'Estamos validando tu pago...' en lugar de botón inmediato - Aviso en comprobante: indica que debe mostrarse nombre, valor, fecha y hora - Eliminado MercadoPago de todos los flujos de recarga y compra (solo Bre-B) - Flujo 'recargar y comprar': guarda pending_purchase y ejecuta la compra automáticamente tras confirmar recarga - Nuevo job ValidarPagoTelegramJob para reintentos asincrónicos en Telegram Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
23eb448433
commit
6bd0045826
@@ -9,6 +9,11 @@ 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', '');
|
||||
@@ -18,18 +23,52 @@ class PagoValidadorService
|
||||
}
|
||||
|
||||
try {
|
||||
$correos = $this->fetchCorreos();
|
||||
$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: ' . count($correos));
|
||||
Log::info('[PagoValidador] Correos obtenidos del día: ' . count($correosDia));
|
||||
|
||||
if (empty($correos)) {
|
||||
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'] ?? '';
|
||||
@@ -38,14 +77,14 @@ class PagoValidadorService
|
||||
|
||||
Log::info('[PagoValidador] Buscando: valor=' . $valorIA . ' fecha=' . $fechaIA . ' hora=' . $horaIA . ' llave=' . $llaveIA);
|
||||
|
||||
$hayYaUsado = false; // email que coincide en todo pero ya fue usado
|
||||
$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'] ?? '';
|
||||
$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));
|
||||
|
||||
@@ -59,9 +98,8 @@ class PagoValidadorService
|
||||
Log::info("[PagoValidador] Correo #{$i} parseado: " . json_encode($datosCorreo));
|
||||
|
||||
// ── 1. Valor ────────────────────────────────────────────
|
||||
// Normalizar: "1,000.00" → 1000 (strip decimals first, then non-digits)
|
||||
$valorRaw = (string) ($datosCorreo['valor'] ?? '0');
|
||||
$valorRaw = preg_replace('/[.,]\d{1,2}$/', '', $valorRaw); // quitar decimales
|
||||
$valorRaw = preg_replace('/[.,]\d{1,2}$/', '', $valorRaw);
|
||||
$valorCorreo = (int) preg_replace('/[^0-9]/', '', $valorRaw);
|
||||
|
||||
if ($valorIA <= 0 || $valorCorreo <= 0) {
|
||||
@@ -74,7 +112,7 @@ class PagoValidadorService
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 2. Fecha (mismo día) — solo si ambos tienen fecha ──
|
||||
// ── 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']})");
|
||||
@@ -82,8 +120,7 @@ class PagoValidadorService
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Hora exacta — el timestamp del comprobante y del correo deben coincidir
|
||||
// El correo llega tarde al inbox pero su contenido registra la hora exacta de la transacción.
|
||||
// ── 3. Hora exacta ───────────────────────────────────────
|
||||
if ($horaIA && ($datosCorreo['hora'] ?? '')) {
|
||||
if (! $this->horaProxima($horaIA, $datosCorreo['hora'], 0)) {
|
||||
Log::info("[PagoValidador] Correo #{$i}: hora no coincide (ia={$horaIA} correo={$datosCorreo['hora']})");
|
||||
@@ -91,7 +128,7 @@ class PagoValidadorService
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Llave Bancolombia (si la foto la tiene) ──────────
|
||||
// ── 4. Llave Bancolombia ─────────────────────────────────
|
||||
if ($llaveIA) {
|
||||
$llaveCorreo = strtolower(preg_replace('/\s+/', '', $datosCorreo['llave'] ?? ''));
|
||||
if ($llaveCorreo && $llaveIA !== $llaveCorreo) {
|
||||
@@ -100,23 +137,21 @@ class PagoValidadorService
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Hash único (anti-doble-uso) — solo tras verificar fecha/hora/llave ──
|
||||
// Si el email coincide en todos los campos pero ya fue usado → es el mismo comprobante
|
||||
// ── 5. Hash anti-duplicado ───────────────────────────────
|
||||
$emailHash = sha1($body . $date);
|
||||
|
||||
try {
|
||||
if (PagoConfirmado::yaUsado($emailHash)) {
|
||||
Log::info("[PagoValidador] Correo #{$i}: ya usado (hash={$emailHash}), buscando siguiente");
|
||||
Log::info("[PagoValidador] Correo #{$i}: ya usado (hash={$emailHash})");
|
||||
$hayYaUsado = true;
|
||||
continue; // seguir buscando: otro usuario puede tener un email fresco del mismo monto
|
||||
continue;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Falla cerrado: si no se puede verificar el anti-duplicado, no confirmar
|
||||
Log::error('[PagoValidador] ERROR tabla pagos_confirmados (ejecutar migrate): ' . $e->getMessage());
|
||||
Log::error('[PagoValidador] ERROR tabla pagos_confirmados: ' . $e->getMessage());
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
|
||||
}
|
||||
|
||||
// ── 6. Remitente (bloquea si el correo trae el campo y no coincide) ──
|
||||
// ── 6. Remitente ─────────────────────────────────────────
|
||||
$remitenteCorreo = $datosCorreo['remitente'] ?? '';
|
||||
if ($remitenteCorreo && $nombreUsuario) {
|
||||
if (! $this->remitenteCoincide($remitenteCorreo, $nombreUsuario)) {
|
||||
@@ -136,15 +171,11 @@ class PagoValidadorService
|
||||
'referencia' => $datosPago['referencia'] ?? null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Falla cerrado: si no se puede registrar el hash, no confirmar
|
||||
Log::error('[PagoValidador] ERROR al marcar hash (ejecutar migrate): ' . $e->getMessage());
|
||||
Log::error('[PagoValidador] ERROR al marcar hash: ' . $e->getMessage());
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_sistema'];
|
||||
}
|
||||
|
||||
return [
|
||||
'estado' => 'confirmado',
|
||||
'correo' => $datosCorreo,
|
||||
];
|
||||
return ['estado' => 'confirmado', 'correo' => $datosCorreo];
|
||||
}
|
||||
|
||||
if ($hayYaUsado) {
|
||||
@@ -152,11 +183,42 @@ class PagoValidadorService
|
||||
return ['estado' => 'ya_usado', 'correo' => null, 'motivo' => 'correo_ya_aplicado'];
|
||||
}
|
||||
|
||||
Log::info('[PagoValidador] Ningún correo coincidió con los criterios');
|
||||
return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia', 'emails_revisados' => count($correos)];
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 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
|
||||
{
|
||||
@@ -205,20 +267,4 @@ class PagoValidadorService
|
||||
}
|
||||
return $coincidencias >= 2;
|
||||
}
|
||||
|
||||
private function fetchCorreos(): array
|
||||
{
|
||||
$minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '120');
|
||||
|
||||
$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: 30, minutesBack: $minutos);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user