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:
Lizandro
2026-07-26 21:09:58 +00:00
co-authored by Claude Sonnet 4.6
parent 23eb448433
commit 6bd0045826
6 changed files with 449 additions and 378 deletions
+136 -177
View File
@@ -487,15 +487,12 @@ class PublicChat extends Component
$action === 'servicios.ver' => $this->verServicio((int) ($data['id'] ?? 0)),
$action === 'compra.iniciar' => $this->iniciarCompra((int) ($data['tarifa_id'] ?? 0)),
$action === 'compra.pagar.saldo' => $this->pagarConSaldo(),
$action === 'compra.pagar.mp' => $this->pagarConMP(),
$action === 'promo.listar' => $this->listarPromociones(),
$action === 'promo.ver' => $this->verPromocion((int) ($data['id'] ?? 0)),
$action === 'promo.comprar.saldo' => $this->comprarPromoConSaldo((int) ($data['id'] ?? 0)),
$action === 'promo.comprar.mp' => $this->comprarPromoConMP((int) ($data['id'] ?? 0)),
$action === 'recarga.iniciar' => $this->mostrarFormRecarga(),
$action === 'recarga.otro_valor' => $this->solicitarMontoPersonalizado(),
$action === 'recarga.elegir_metodo' => $this->elegirMetodoRecarga((int) ($data['monto'] ?? 0)),
$action === 'recarga.mp' => $this->generarLinkRecargaMP((int) ($data['monto'] ?? 0)),
$action === 'recarga.transferencia' => $this->mostrarTransferencia((int) ($data['monto'] ?? 0)),
$action === 'credenciales.listar' => $this->listarCredenciales(),
$action === 'historial.ver' => $this->verHistorial(),
@@ -596,24 +593,12 @@ class PublicChat extends Component
} elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
$this->flujo = 'pago.pendiente';
$this->flujoData = array_merge($this->flujoData, ['datosPago' => $datosPago]);
$this->flujoData = array_merge($this->flujoData, ['datosPago' => $datosPago, 'intentos' => 0]);
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => 'Por ahora no encontramos tu pago de confirmacion. Puede demorar entre 1 y 2 minutos en llegar. Espera un momento y presiona Reintentar.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'Por ahora no encontramos tu pago de confirmacion. Puede demorar entre 1 y 2 minutos.',
'ia_datos' => $datosPago,
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '<- Menu', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$this->guardarMensajeBot($this->convId,
"⏳ *Estamos validando tu pago*, esto puede demorar unos minutos.\n"
. "Cuando terminemos te confirmaremos por este medio. Espera un momento."
);
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
return;
@@ -788,14 +773,27 @@ class PublicChat extends Component
]);
} else {
$falta = $precio - (float) $this->saldoUsuario;
// Guardar compra pendiente para ejecutarla automáticamente tras recargar
$this->flujoData = array_merge($this->flujoData, [
'pending_purchase' => [
'type' => 'tarifa',
'tarifa_id' => $tarifaId,
'valor' => $precio,
'servicio' => $tarifa->servicio->nombre,
'dias' => $tarifa->dias,
'pantallas' => $tarifa->pantallas,
],
]);
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'buttons',
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\nRecarga tu saldo para continuar:",
'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\nRecarga para comprar:",
'payload' => ['botones' => [
['label' => 'Recargar saldo', 'action' => 'recarga.iniciar', 'data' => []],
['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []],
['label' => "💰 Recargar $" . number_format($falta) . " para comprar {$tarifa->servicio->nombre}", 'action' => 'recarga.iniciar', 'data' => []],
['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []],
]],
'leido' => true,
]);
@@ -876,35 +874,6 @@ class PublicChat extends Component
$this->volver('menu.principal');
}
private function pagarConMP(): void
{
if ($this->flujo !== 'compra') {
return;
}
$valor = (int) ($this->flujoData['valor'] ?? 0);
$servicio = $this->flujoData['servicio'] ?? 'Plan streaming';
$ref = 'CHAT-' . strtoupper(Str::random(12));
$url = $this->crearPreferenciaMP($servicio, $valor, $ref);
if (! $url) {
$this->guardarMensajeBot($this->convId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor.");
return;
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'link',
'contenido' => "Link de pago por $" . number_format($valor) . " listo. Despues de pagar, envia el comprobante aqui.",
'payload' => ['url' => $url, 'label' => 'Pagar $' . number_format($valor) . ' con MercadoPago', 'nota' => 'Ref: ' . $ref],
'leido' => true,
]);
$this->flujo = '';
$this->flujoData = [];
$this->volver('menu.principal');
}
// ─────────────────────────────────────────────────────────
// Promociones
@@ -988,14 +957,25 @@ class PublicChat extends Component
]);
} else {
$falta = $precio - (float) $this->saldoUsuario;
// Guardar compra pendiente para ejecutarla automáticamente tras recargar
$this->flujoData = array_merge($this->flujoData, [
'pending_purchase' => [
'type' => 'promo',
'promo_id' => $id,
'valor' => $precio,
'nombre' => $promo->nombre ?? 'Promoción',
],
]);
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'buttons',
'contenido' => ($promo->nombre ?? 'Promocion') . "\n$" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\nRecarga tu saldo para continuar:",
'contenido' => ($promo->nombre ?? 'Promocion') . "\n$" . number_format($precio) . "\n\nSaldo actual: $" . number_format($this->saldoUsuario) . " — te faltan $" . number_format($falta) . "\n\nRecarga para comprar:",
'payload' => ['botones' => [
['label' => 'Recargar saldo', 'action' => 'recarga.iniciar', 'data' => []],
['label' => 'Volver', 'action' => 'promo.listar', 'data' => []],
['label' => "💰 Recargar $" . number_format($falta) . " para comprar " . ($promo->nombre ?? 'Promo'), 'action' => 'recarga.iniciar', 'data' => []],
['label' => 'Volver', 'action' => 'promo.listar', 'data' => []],
]],
'leido' => true,
]);
@@ -1076,42 +1056,6 @@ class PublicChat extends Component
$this->volver('menu.principal');
}
private function comprarPromoConMP(int $promoId): void
{
$rolId = $this->userId
? User::find($this->userId)?->rol_id
: Role::where('nombre', 'cliente')->value('id');
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
if (! $promo) {
return;
}
$upric = $this->userId
? Usuario_promo::where('usuario_id', $this->userId)->where('promocion_id', $promoId)->first()
: null;
$tp = $promo->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
$ref = 'PROMO-' . strtoupper(Str::random(10));
$url = $this->crearPreferenciaMP($promo->nombre ?? 'Promocion streaming', (int) $precio, $ref);
if (! $url) {
$this->guardarMensajeBot($this->convId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor.");
return;
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'link',
'contenido' => "Link de pago por $" . number_format($precio) . ". Envia el comprobante aqui al pagar.",
'payload' => ['url' => $url, 'label' => 'Pagar con MercadoPago', 'nota' => 'Ref: ' . $ref],
'leido' => true,
]);
$this->volver('menu.principal');
}
// ─────────────────────────────────────────────────────────
// Recarga de saldo
@@ -1149,15 +1093,21 @@ class PublicChat extends Component
return;
}
$llave = \App\Models\ChatConfig::get('recarga_banco_llave', '');
if (! $llave) {
$this->guardarMensajeBot($this->convId, "⚠️ La recarga no está disponible en este momento. Contacta a un asesor.");
$this->volver('menu.principal');
return;
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'buttons',
'contenido' => "Recarga de $" . number_format($monto) . "\n¿Como quieres pagar?",
'payload' => ['botones' => [
['label' => 'MercadoPago', 'action' => 'recarga.mp', 'data' => ['monto' => $monto]],
['label' => 'Bre-B', 'action' => 'recarga.transferencia', 'data' => ['monto' => $monto]],
['label' => 'Volver', 'action' => 'recarga.iniciar', 'data' => []],
['label' => '🔑 Bre-B', 'action' => 'recarga.transferencia', 'data' => ['monto' => $monto]],
['label' => 'Volver', 'action' => 'recarga.iniciar', 'data' => []],
]],
'leido' => true,
]);
@@ -1219,7 +1169,7 @@ class PublicChat extends Component
$titular = \App\Models\ChatConfig::get('recarga_banco_titular', '');
if (! $clave) {
$this->guardarMensajeBot($this->convId, "El pago por Bre-B no esta disponible en este momento. Usa MercadoPago o contacta a un asesor.");
$this->guardarMensajeBot($this->convId, "El pago por Bre-B no esta disponible en este momento. Contacta a un asesor.");
return;
}
@@ -1237,7 +1187,14 @@ class PublicChat extends Component
'leido' => true,
]);
$this->guardarMensajeBot($this->convId, "Cuando hayas transferido, envia el comprobante usando el icono de camara (📷) y el sistema lo validara automaticamente.\n\nTienes hasta 15 minutos despues de la transferencia para enviar el soporte.");
$this->guardarMensajeBot($this->convId,
"📋 *Al enviar el comprobante, asegúrate de que se vea claramente:*\n"
. "✓ Tu nombre completo\n"
. "✓ El valor transferido (\$" . number_format($monto) . ")\n"
. "✓ La fecha y hora de la transacción\n\n"
. "Cuando hayas transferido, envia el comprobante usando el icono de camara (📷) y el sistema lo validara automaticamente.\n\n"
. "Tienes hasta el final del dia para enviar el soporte."
);
if ($this->userId) {
$nombre = $this->flujoData['nombre_remitente'] ?? null;
@@ -1249,87 +1206,6 @@ class PublicChat extends Component
$this->volver('menu.principal');
}
private function generarLinkRecargaMP(int $monto): void
{
if ($monto <= 0) {
$this->guardarMensajeBot($this->convId, "Monto invalido.");
return;
}
$ref = 'REC-' . strtoupper(Str::random(12));
$url = $this->crearPreferenciaMP('Recarga de saldo', $monto, $ref);
if (! $url) {
$this->guardarMensajeBot($this->convId, "No se pudo generar el link de recarga. Intenta de nuevo o contacta a un asesor.");
return;
}
if ($this->userId) {
$user = User::with('saldo')->find($this->userId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->userId, 'valor' => 0]);
recarga::create([
'monto' => $monto,
'reference' => $ref,
'status' => 'pendiente_mp',
'usuario_id' => $this->userId,
'saldo_id' => $saldo->id,
]);
}
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'link',
'contenido' => "Tu link de recarga por $" . number_format($monto) . " esta listo. Despues de pagar, envia el comprobante aqui.",
'payload' => ['url' => $url, 'label' => 'Recargar $' . number_format($monto) . ' con MercadoPago', 'nota' => 'Ref: ' . $ref],
'leido' => true,
]);
$this->volver('menu.principal');
}
private function crearPreferenciaMP(string $titulo, int $valor, string $ref): ?string
{
$accessToken = config('services.mercadopago.token');
if (! $accessToken) {
return null;
}
try {
$response = \Illuminate\Support\Facades\Http::withToken($accessToken)
->timeout(10)
->post('https://api.mercadopago.com/checkout/preferences', [
'items' => [[
'title' => $titulo,
'quantity' => 1,
'unit_price' => $valor,
'currency_id' => 'COP',
]],
'external_reference' => $ref,
'back_urls' => [
'success' => url('/chat'),
'failure' => url('/chat'),
'pending' => url('/chat'),
],
'auto_return' => 'approved',
'payment_methods' => [
'excluded_payment_types' => [['id' => 'ticket']],
],
]);
if ($response->successful()) {
return $response->json('init_point');
}
\Illuminate\Support\Facades\Log::warning('[ChatMP] Error creando preferencia: ' . $response->body());
return null;
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('[ChatMP] Excepcion: ' . $e->getMessage());
return null;
}
}
// ─────────────────────────────────────────────────────────
// Credenciales
@@ -1457,6 +1333,73 @@ class PublicChat extends Component
// Aplicar recarga validada por IA
// ─────────────────────────────────────────────────────────
// Llamado por wire:poll cada 20s — solo actúa cuando hay un pago pendiente de validar
public function autoReintentarPago(): void
{
if ($this->flujo !== 'pago.pendiente' || empty($this->flujoData['datosPago'])) {
return;
}
$maxIntentos = 9;
$intentos = (int) ($this->flujoData['intentos'] ?? 0);
// Agotados — mostrar botón manual una sola vez
if ($intentos >= $maxIntentos) {
if (! ($this->flujoData['boton_manual_mostrado'] ?? false)) {
$this->flujoData['boton_manual_mostrado'] = true;
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'tipo_ui' => 'validacion',
'contenido' => '⚠️ No pudimos confirmar tu pago automáticamente. Si ya realizaste la transferencia, presiona Reintentar.',
'payload' => [
'estado' => 'pendiente',
'motivo' => 'No pudimos confirmar automáticamente.',
'botones' => [
['label' => '🔄 Reintentar', 'action' => 'pago.reintentar', 'data' => []],
['label' => '<- Menú', 'action' => 'menu.principal', 'data' => []],
],
],
'leido' => true,
]);
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
}
return;
}
$this->flujoData['intentos'] = $intentos + 1;
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⚠️ Tu solicitud de recarga expiró. Inicia una nueva desde el menú.');
$this->cargarMensajes();
return;
}
$datosPago = $this->flujoData['datosPago'];
$nombreParaValidar = $this->flujoData['nombre_remitente'] ?? $this->nombreUsuario;
$resultado = app(\App\Services\PagoValidadorService::class)->validar($datosPago, $this->userId, $nombreParaValidar, 'web');
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecargaWeb($solicitud, $solicitud->monto);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$this->flujo = '';
$this->flujoData = [];
$this->guardarMensajeBot($this->convId, '⛔ Comprobante ya utilizado. Este pago ya fue registrado anteriormente.');
$this->cargarMensajes();
return;
}
// Sigue sin encontrarse — el poll volverá a llamar este método en 20s
}
private function reintentarValidacionPago(): void
{
if ($this->flujo !== 'pago.pendiente' || empty($this->flujoData['datosPago'])) {
@@ -1537,6 +1480,7 @@ class PublicChat extends Component
$solicitud->confirmar();
$pp = $this->flujoData['pending_purchase'] ?? null;
$this->flujo = '';
$this->flujoData = [];
@@ -1544,6 +1488,21 @@ class PublicChat extends Component
$this->convId,
"✅ ¡Tu recarga de $" . number_format($monto) . " fue aplicada exitosamente!\nTu nuevo saldo es $" . number_format($nuevoSaldo) . "."
);
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
$this->flujo = 'compra';
$this->flujoData = [
'tarifa_id' => $pp['tarifa_id'],
'valor' => $pp['valor'],
'servicio' => $pp['servicio'],
'dias' => $pp['dias'],
'pantallas' => $pp['pantallas'],
];
$this->pagarConSaldo();
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
$this->comprarPromoConSaldo($pp['promo_id']);
}
$this->cargarMensajes();
$this->dispatchBrowserEvent('scroll-chat');
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Jobs;
use App\Models\recarga;
use App\Models\Saldo;
use App\Models\SolicitudRecarga;
use App\Models\User;
use App\Services\PagoValidadorService;
use App\Services\TelegramBotService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ValidarPagoTelegramJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 60;
private const MAX_INTENTOS = 9;
private const STATE_TTL = 315360000;
public function __construct(
private string $chatId,
private array $datosPago,
private int $intento = 1
) {}
public function handle(): void
{
$cacheKey = "tgbot_state_{$this->chatId}";
$state = Cache::get($cacheKey, []);
$usuarioId = $state['user_id'] ?? null;
$bot = new TelegramBotService();
if (! $usuarioId) {
return;
}
$solicitud = SolicitudRecarga::pendiente($usuarioId);
if (! $solicitud) {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: solicitud ya no está pendiente, abortando.");
return;
}
$nombreUsuario = $state['data']['nombre_remitente'] ?? null;
$resultado = app(PagoValidadorService::class)->validar(
$this->datosPago, $usuarioId, $nombreUsuario, 'telegram'
);
if ($resultado['estado'] === 'confirmado') {
$this->aplicarYNotificar($bot, $state, $cacheKey, $solicitud, $usuarioId);
return;
}
if ($resultado['estado'] === 'ya_usado') {
$bot->sendWithKeyboard($this->chatId,
"⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
return;
}
// No encontrado — reintentar o agotar
if ($this->intento < self::MAX_INTENTOS) {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intento {$this->intento}/" . self::MAX_INTENTOS . " — reintentando en 20s");
self::dispatch($this->chatId, $this->datosPago, $this->intento + 1)
->delay(now()->addSeconds(20));
} else {
Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intentos agotados, mostrando botón manual");
$bot->sendWithKeyboard(
$this->chatId,
"⚠️ No pudimos confirmar tu pago automáticamente.\nSi ya realizaste la transferencia, presiona Reintentar.",
[
[['text' => '🔄 Reintentar', 'callback_data' => 'pay_retry']],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]
);
}
}
private function aplicarYNotificar(
TelegramBotService $bot,
array $state,
string $cacheKey,
SolicitudRecarga $solicitud,
int $usuarioId
): void {
$user = User::with('saldo')->find($usuarioId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $usuarioId, 'valor' => 0]);
$monto = $solicitud->monto;
$nuevoSaldo = $saldo->valor + $monto;
$saldo->update(['valor' => $nuevoSaldo]);
recarga::create([
'usuario_id' => $usuarioId,
'saldo_id' => $saldo->id,
'monto' => $monto,
'valor_recarga' => $monto,
'status' => 'Confirmado',
'reference' => 'breb-bot-' . $solicitud->id,
]);
$solicitud->confirmar();
$pp = $state['data']['pending_purchase'] ?? null;
unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id'], $state['data']['pending_purchase']);
Cache::put($cacheKey, $state, now()->addSeconds(self::STATE_TTL));
$bot->send($this->chatId,
"✅ *¡Tu recarga de \$" . number_format($monto) . " fue aplicada exitosamente!*\n"
. "Tu nuevo saldo es *\$" . number_format($nuevoSaldo) . "*"
);
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
$bot->sendWithKeyboard(
$this->chatId,
"🛒 Tienes una compra pendiente: *{$pp['servicio']}* — \$" . number_format($pp['valor']) . "\nPresiona para completarla:",
[[['text' => "Comprar {$pp['servicio']}", 'callback_data' => 'buy_start|' . $pp['tarifa_id']]]]
);
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
$bot->sendWithKeyboard(
$this->chatId,
"🛒 Tienes una compra pendiente: *{$pp['nombre']}* — \$" . number_format($pp['valor']) . "\nPresiona para completarla:",
[[['text' => "Comprar {$pp['nombre']}", 'callback_data' => 'promo_saldo|' . $pp['promo_id']]]]
);
} else {
$bot->sendWithKeyboard($this->chatId, "¿En qué más te puedo ayudar?", [
[
['text' => '📺 Ver Servicios', 'callback_data' => 'svc_list'],
['text' => '🎁 Promociones', 'callback_data' => 'promo_list'],
],
[
['text' => '🔑 Mis Credenciales', 'callback_data' => 'creds'],
['text' => '📋 Historial', 'callback_data' => 'history'],
],
]);
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ class SolicitudRecarga extends Model
{
return static::where('user_id', $userId)
->where('estado', 'pendiente')
->where('created_at', '>=', now()->subMinutes(30))
->where('created_at', '>=', now()->startOfDay())
->latest('id')
->first();
}
+92 -46
View File
@@ -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);
}
}
+71 -154
View File
@@ -31,6 +31,7 @@ use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use App\Jobs\ValidarPagoTelegramJob;
use App\Services\GeminiAgentService;
class TelegramBotService
@@ -136,15 +137,12 @@ class TelegramBotService
'svc_view' => $this->viewService($chatId, (int) ($parts[1] ?? 0)),
'buy_start' => $this->startPurchase($chatId, (int) ($parts[1] ?? 0)),
'buy_saldo' => $this->payWithSaldo($chatId),
'buy_mp' => $this->payWithMP($chatId),
'promo_list' => $this->listPromos($chatId),
'promo_view' => $this->viewPromo($chatId, (int) ($parts[1] ?? 0)),
'promo_saldo' => $this->buyPromoSaldo($chatId, (int) ($parts[1] ?? 0)),
'promo_mp' => $this->buyPromoMP($chatId, (int) ($parts[1] ?? 0)),
'rec_init' => $this->showRechargeAmounts($chatId),
'rec_custom' => $this->askCustomAmount($chatId),
'rec_amount' => $this->chooseRechargeMethod($chatId, (int) ($parts[1] ?? 0)),
'rec_mp' => $this->rechargeMP($chatId, (int) ($parts[1] ?? 0)),
'rec_breb' => $this->askRemitenteNombre($chatId, (int) ($parts[1] ?? 0)),
'breb_nom' => $this->seleccionarNombreRemitente($chatId, (string) ($parts[1] ?? 'nuevo')),
'creds' => $this->showCredentials($chatId),
@@ -232,15 +230,18 @@ class TelegramBotService
]);
} elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
// Guardar para reintento manual (por si el job agota sus intentos)
$state['data']['pago_pendiente'] = $datosPago;
$this->setState($chatId, $state);
$this->sendWithKeyboard($chatId,
"⏳ *Por ahora no encontramos tu pago de confirmación.*\n_Puede demorar entre 1 y 2 minutos en llegar. Espera un momento y presiona Reintentar._",
[
[['text' => '🔄 Reintentar', 'callback_data' => 'pay_retry']],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]
$this->send($chatId,
"⏳ *Estamos validando tu pago*, esto puede demorar unos minutos.\n"
. "Cuando terminemos te confirmaremos por este medio. Espera un momento."
);
// Lanzar job que reintenta cada 20s hasta 9 veces (≈3 min)
ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1)
->delay(now()->addSeconds(20));
} else {
$motivoTexto = match ($motivo) {
'correo_deshabilitado' => '📧 Verificación por correo no configurada',
@@ -304,7 +305,7 @@ class TelegramBotService
}
}
private function autoAplicarRecarga(string $chatId, array $state, SolicitudRecarga $solicitud, int $monto): void
public function autoAplicarRecarga(string $chatId, array $state, SolicitudRecarga $solicitud, int $monto): void
{
$usuarioId = $state['user_id'];
$user = User::with('saldo')->find($usuarioId);
@@ -323,14 +324,32 @@ class TelegramBotService
$solicitud->confirmar();
unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id']);
$pp = $state['data']['pending_purchase'] ?? null;
unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id'], $state['data']['pending_purchase']);
$this->setState($chatId, $state);
$this->send($chatId,
"✅ *¡Tu recarga de \$" . number_format($monto) . " fue aplicada exitosamente!*\n"
. "Tu nuevo saldo es *\$" . number_format($nuevoSaldo) . "*"
);
$this->showMainMenu($chatId);
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
// Configurar estado para la compra y ejecutarla directamente
$state = $this->getState($chatId);
$state['data'] = [
'flow' => 'compra',
'tarifa_id' => $pp['tarifa_id'],
'valor' => $pp['valor'],
'servicio' => $pp['servicio'],
'servicio_id' => $pp['servicio_id'],
];
$this->setState($chatId, $state);
$this->payWithSaldo($chatId);
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
$this->buyPromoSaldo($chatId, $pp['promo_id']);
} else {
$this->showMainMenu($chatId);
}
}
public function handleVoice(string $chatId, string $fileId, string $nombre = '', int $duracionSegundos = 0): void
@@ -803,8 +822,19 @@ class TelegramBotService
);
} else {
$falta = $valor - $saldo;
$buttons[] = [['text' => '💰 Recargar saldo', 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]];
// Guardar compra pendiente para ejecutarla automáticamente tras recargar
$state['data']['pending_purchase'] = [
'type' => 'tarifa',
'tarifa_id' => $tarifaId,
'valor' => $valor,
'servicio' => $tarifa->servicio->nombre,
'servicio_id' => $tarifa->servicio_id,
];
$this->setState($chatId, $state);
$buttons[] = [['text' => "💰 Recargar \$" . number_format($falta) . " para comprar {$tarifa->servicio->nombre}", 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]];
$this->sendWithKeyboard(
$chatId,
@@ -884,35 +914,6 @@ class TelegramBotService
$this->showMainMenu($chatId);
}
private function payWithMP(string $chatId): void
{
$state = $this->getState($chatId);
$data = $state['data'] ?? [];
if (($data['flow'] ?? '') !== 'compra') {
$this->showMainMenu($chatId);
return;
}
$valor = (int) ($data['valor'] ?? 0);
$servicio = $data['servicio'] ?? 'Plan streaming';
$ref = 'CHAT-' . strtoupper(Str::random(12));
$url = $this->createMPPreference($servicio, $valor, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo.");
return;
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Pago con MercadoPago*\n\n{$servicio}\$" . number_format($valor) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
$state['data'] = [];
$this->setState($chatId, $state);
}
// ─── Promos ───────────────────────────────────────────────
@@ -995,8 +996,19 @@ class TelegramBotService
);
} else {
$falta = $precio - $saldo;
$buttons[] = [['text' => '💰 Recargar saldo', 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']];
// Guardar compra pendiente para ejecutarla automáticamente tras recargar
$state = $this->getState($chatId);
$state['data']['pending_purchase'] = [
'type' => 'promo',
'promo_id' => $promoId,
'valor' => $precio,
'nombre' => $promo->nombre ?? 'Promoción',
];
$this->setState($chatId, $state);
$buttons[] = [['text' => "💰 Recargar \$" . number_format($falta) . " para comprar " . ($promo->nombre ?? 'Promo'), 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']];
$this->sendWithKeyboard(
$chatId,
@@ -1073,38 +1085,6 @@ class TelegramBotService
$this->showMainMenu($chatId);
}
private function buyPromoMP(string $chatId, int $promoId): void
{
$state = $this->getState($chatId);
$rolId = isset($state['user_id'])
? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id'))
: Role::where('nombre', 'cliente')->value('id');
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
if (! $promo) {
return;
}
$upric = isset($state['user_id'])
? Usuario_promo::where('usuario_id', $state['user_id'])->where('promocion_id', $promoId)->first()
: null;
$tp = $promo->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
$ref = 'PROMO-' . strtoupper(Str::random(10));
$url = $this->createMPPreference($promo->nombre ?? 'Promocion streaming', (int) $precio, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo.");
return;
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Pago con MercadoPago*\n\n{$promo->nombre}\$" . number_format($precio) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
}
// ─── Recharge ─────────────────────────────────────────────
@@ -1160,13 +1140,16 @@ class TelegramBotService
}
$llave = ChatConfig::get('recarga_banco_llave', '');
$buttons = [
[['text' => '🏦 MercadoPago', 'callback_data' => 'rec_mp|' . $monto]],
];
if ($llave) {
$buttons[] = [['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]];
if (! $llave) {
$this->sendWithKeyboard($chatId, "⚠️ La recarga no está disponible en este momento. Contacta a un asesor.", [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
return;
}
$buttons[] = [['text' => '🔙 Volver', 'callback_data' => 'rec_init']];
$buttons = [
[['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]],
[['text' => '🔙 Volver', 'callback_data' => 'rec_init']],
];
$this->sendWithKeyboard(
$chatId,
@@ -1175,41 +1158,6 @@ class TelegramBotService
);
}
private function rechargeMP(string $chatId, int $monto): void
{
$state = $this->getState($chatId);
if ($monto <= 0) {
$this->send($chatId, "Monto inválido.");
return;
}
$ref = 'REC-' . strtoupper(Str::random(12));
$url = $this->createMPPreference('Recarga de saldo', $monto, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de recarga. Intenta de nuevo.");
return;
}
if ($state['user_id'] ?? null) {
$user = User::with('saldo')->find($state['user_id']);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $state['user_id'], 'valor' => 0]);
recarga::create([
'monto' => $monto,
'reference' => $ref,
'status' => 'pendiente_mp',
'usuario_id' => $state['user_id'],
'saldo_id' => $saldo->id,
]);
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Recarga con MercadoPago*\n\n\$" . number_format($monto) . "\nRef: `{$ref}`\n\n[👉 Recargar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
}
private function askRemitenteNombre(string $chatId, int $monto): void
{
@@ -1319,7 +1267,11 @@ class TelegramBotService
if ($titular) {
$text .= "Titular: *{$titular}*\n";
}
$text .= "\n⚠️ Después de transferir, envía la *foto del comprobante* aquí y el sistema lo validará automáticamente."
$text .= "\n📋 *Al enviar el comprobante, asegúrate de que se vea claramente:*\n"
. "✓ Tu nombre completo\n"
. "✓ El valor transferido (\$" . number_format($monto) . ")\n"
. "✓ La fecha y hora de la transacción\n\n"
. "⚠️ Después de transferir, envía la *foto del comprobante* aquí y el sistema lo validará automáticamente."
. "\n_Tienes hasta 15 minutos después de la transferencia para enviar el soporte._";
if ($userId = ($this->getState($chatId)['user_id'] ?? null)) {
@@ -1679,39 +1631,4 @@ class TelegramBotService
return mb_substr($local, 0, $visible) . str_repeat('*', max(0, $len - $visible)) . '@' . $domain;
}
private function createMPPreference(string $titulo, int $valor, string $ref): ?string
{
$accessToken = config('services.mercadopago.token');
if (! $accessToken) {
return null;
}
try {
$response = Http::withToken($accessToken)
->timeout(10)
->post('https://api.mercadopago.com/checkout/preferences', [
'items' => [[
'title' => $titulo,
'quantity' => 1,
'unit_price' => $valor,
'currency_id' => 'COP',
]],
'external_reference' => $ref,
'back_urls' => [
'success' => url('/chat'),
'failure' => url('/chat'),
'pending' => url('/chat'),
],
'auto_return' => 'approved',
'payment_methods' => [
'excluded_payment_types' => [['id' => 'ticket']],
],
]);
return $response->successful() ? $response->json('init_point') : null;
} catch (\Throwable $e) {
Log::warning('[TelegramMP] Error: ' . $e->getMessage());
return null;
}
}
}
@@ -236,6 +236,7 @@
<div class="flex-1 min-h-0 overflow-y-auto px-3 py-4 space-y-3 ios-scroll"
id="pub-messages"
wire:poll.3000ms="pollMensajes"
wire:poll.20000ms="autoReintentarPago"
style="overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;">
@forelse($mensajes as $msg)