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