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
+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;
}
}
}