// backend/MotoresArgentinosV2.Infrastructure/Services/MercadoPagoService.cs using MercadoPago.Client; using MercadoPago.Client.Payment; using MercadoPago.Config; using MercadoPago.Resource.Payment; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using MotoresArgentinosV2.Core.DTOs; using MotoresArgentinosV2.Core.Entities; using MotoresArgentinosV2.Core.Interfaces; using Microsoft.EntityFrameworkCore; using MotoresArgentinosV2.Infrastructure.Data; namespace MotoresArgentinosV2.Infrastructure.Services; public class MercadoPagoService : IPaymentService { private readonly IConfiguration _config; private readonly MotoresV2DbContext _context; private readonly ILegacyPaymentService _legacyService; private readonly IAvisosLegacyService _legacyAdsService; private readonly INotificationService _notificationService; private readonly IAdSyncService _syncService; private readonly ILogger _logger; public MercadoPagoService( IConfiguration config, MotoresV2DbContext context, ILegacyPaymentService legacyService, IAvisosLegacyService legacyAdsService, INotificationService notificationService, IAdSyncService syncService, ILogger logger) { _config = config; _context = context; _legacyService = legacyService; _legacyAdsService = legacyAdsService; _notificationService = notificationService; _syncService = syncService; _logger = logger; var token = _config["MercadoPago:AccessToken"] ?? throw new Exception("MP AccessToken no configurado"); MercadoPagoConfig.AccessToken = token; } public async Task ProcessPaymentAsync(CreatePaymentRequestDto request, int userId) { // INTEGRIDAD DE PRECIOS Y PROPIEDAD var ad = await _context.Ads .Include(a => a.User) .Include(a => a.Brand) .FirstOrDefaultAsync(a => a.AdID == request.AdId); if (ad == null) throw new Exception("El aviso no existe."); if (ad.UserID != userId) throw new Exception("No tienes permiso para pagar este aviso."); // VALIDACIÓN DE PRECIO CONTRA LEGACY (Integridad Corregida) // El SP spDatosAvisos usa ("EMOTORES", 1) para destacados y ("EMOTORES", 0) para normales (Autos/Motos) int paquete = ad.IsFeatured ? 1 : 0; var tarifas = await _legacyAdsService.ObtenerDatosAvisosAsync("EMOTORES", paquete); var tarifaOficial = tarifas.FirstOrDefault(); if (tarifaOficial == null) { _logger.LogWarning("No se encontró tarifa en Legacy para (EMOTORES, {Paquete}). Aplicando validación básica.", paquete); if (request.TransactionAmount <= 0) throw new Exception("Monto de transacción inválido."); } else { // Calcular precio final con IVA y Redondear igual que en el Frontend // La lógica de negocio es: (Neto * 1.105) redondeado al entero más cercano. decimal precioCalculado = Math.Round(tarifaOficial.ImporteTotsiniva * 1.105m, 0); // Comparamos el monto que viene del front con nuestro cálculo redondeado if (request.TransactionAmount != precioCalculado) { _logger.LogCritical("¡ALERTA DE SEGURIDAD! Intento de manipulación de precio. AdID: {AdId}, Calculado: {Expected}, Recibido: {Actual}", ad.AdID, precioCalculado, request.TransactionAmount); // Mensaje genérico para el usuario, logueando el detalle real throw new Exception($"Integridad de precio fallida. El monto solicitado no coincide con la tarifa oficial vigente."); } } // 1. Generar ID de Operación Único string operationCode = $"M2-{request.AdId}-{DateTime.Now.Ticks % 10000000}"; // 2. Crear Request var paymentRequest = new PaymentCreateRequest { TransactionAmount = request.TransactionAmount, Token = request.Token, Description = request.Description ?? $"Publicación Aviso #{request.AdId}", Installments = request.Installments, PaymentMethodId = request.PaymentMethodId, IssuerId = request.IssuerId, Payer = new PaymentPayerRequest { Email = request.PayerEmail, FirstName = "Usuario", LastName = "Motores" }, ExternalReference = operationCode, StatementDescriptor = "MOTORESARG" // Aparece en el resumen de la tarjeta }; // 🛡️ SEGURIDAD: IDEMPOTENCIA var requestOptions = new RequestOptions(); requestOptions.CustomHeaders.Add("X-Idempotency-Key", operationCode); var client = new PaymentClient(); Payment payment; try { // 3. Procesar Pago con Idempotencia payment = await client.CreateAsync(paymentRequest, requestOptions); } catch (Exception ex) { _logger.LogError(ex, "Error comunicándose con Mercado Pago"); throw new Exception("Error al procesar el pago con el proveedor. Por favor intente nuevamente."); } // 4. Guardar Transacción en V2 var transaction = new TransactionRecord { AdID = request.AdId, OperationCode = operationCode, Amount = request.TransactionAmount, Status = MapStatus(payment.Status), PaymentMethodID = 1, ProviderPaymentId = payment.Id?.ToString(), ProviderResponse = System.Text.Json.JsonSerializer.Serialize(payment), SnapshotUserEmail = ad.User?.Email ?? request.PayerEmail, SnapshotUserName = ad.User?.UserName ?? "Usuario", SnapshotAdTitle = ad.Brand != null ? $"{ad.Brand.Name} {ad.VersionName}" : ad.VersionName, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _context.Transactions.Add(transaction); // Actualizar estado del aviso si queda PENDIENTE (in_process) // Esto evita que el usuario vea el botón "Continuar Pago" y pague doble. if (payment.Status == PaymentStatus.InProcess || payment.Status == PaymentStatus.Pending) { ad.StatusID = 2; // PaymentPending -> Habilita botón "Verificar Ahora" en el front } await _context.SaveChangesAsync(); // 5. Impactar en Legacy y V2 si está aprobado if (payment.Status == PaymentStatus.Approved) { await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "0", operationCode, request.PaymentMethodId, request.PayerEmail, request.TransactionAmount); } else { // 📝 AUDITORÍA (Pago no aprobado inmediatamente) _context.AuditLogs.Add(new AuditLog { Action = "PAYMENT_INITIATED", Entity = "Transaction", EntityID = transaction.TransactionID, UserID = userId, Details = $"Pago iniciado para AdID {request.AdId}. Estado: {payment.Status}" }); await _context.SaveChangesAsync(); } return new PaymentResponseDto { PaymentId = payment.Id ?? 0, Status = payment.Status, StatusDetail = payment.StatusDetail, OperationCode = operationCode }; } public async Task ProcessWebhookAsync(string topic, string id) { if (topic != "payment") return; var client = new PaymentClient(); var payment = await client.GetAsync(long.Parse(id)); if (payment == null) return; // Buscar la transacción por el ID de pago del proveedor (ProviderPaymentId) var transaction = await _context.Transactions .FirstOrDefaultAsync(t => t.ProviderPaymentId == id); if (transaction == null) { _logger.LogWarning("Webhook recibido para un ID de pago no encontrado en la DB: {PaymentId}", id); return; // No encontramos la transacción, no hay nada que hacer. } // Si ya está aprobada, no hacemos nada para evitar procesar dos veces. if (transaction.Status == "APPROVED") return; // Actualizar estado en V2 con la información REAL de Mercado Pago transaction.Status = MapStatus(payment.Status); transaction.UpdatedAt = DateTime.UtcNow; // 📝 AUDITORÍA (Webhook) _context.AuditLogs.Add(new AuditLog { Action = "PAYMENT_WEBHOOK_RECEIVED", Entity = "Transaction", EntityID = transaction.TransactionID, UserID = 0, // Sistema Details = $"Webhook recibido para MP_ID {id}. Nuevo estado: {transaction.Status}" }); await _context.SaveChangesAsync(); // Si el estado REAL que obtuvimos de MP es "approved", finalizamos el pago. if (payment.Status == PaymentStatus.Approved) { var ad = await _context.Ads.Include(a => a.User).Include(a => a.Brand).FirstOrDefaultAsync(a => a.AdID == transaction.AdID); if (ad != null) { await FinalizeApprovedPayment(ad, id, transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, payment.TransactionAmount ?? 0); } } } private async Task FinalizeApprovedPayment(Ad ad, string mpPaymentId, string operationCode, string paymentMethod, string payerEmail, decimal paidAmount) { // A. Actualizar estado del Aviso y Auditoría en V2 ad.StatusID = (int)AdStatusEnum.ModerationPending; ad.ExpirationWarningSent = false; ad.PaymentReminderSentAt = null; ad.LastPerformanceEmailSentAt = DateTime.UtcNow; _context.AuditLogs.Add(new AuditLog { Action = "PAYMENT_APPROVED", Entity = "Ad", EntityID = ad.AdID, UserID = ad.UserID, Details = $"Pago aprobado para AdID {ad.AdID}. Operación: {operationCode}. MP_ID: {mpPaymentId}" }); // Guardamos los cambios iniciales en la base de datos V2 await _context.SaveChangesAsync(); // B. Impactar Legacy var providerResponseForLegacy = System.Text.Json.JsonSerializer.Serialize(new { tarjeta = paymentMethod, mediopago = "1", titular = "MERCADO PAGO USER", emailcomprador = payerEmail, noperacion = operationCode, mp_id = mpPaymentId }); // Llamamos al servicio legacy y verificamos si tuvo éxito var legacySuccess = await _legacyService.ProcessPaymentResponseAsync(operationCode, "APPROVED", providerResponseForLegacy); // C. Si la sincronización legacy fue exitosa, actualizamos el LegacyAdID if (legacySuccess) { // Volvemos a buscar la transacción para asegurarnos de tener el ID correcto var transaction = await _context.Transactions.FirstOrDefaultAsync(t => t.OperationCode == operationCode); if (transaction != null) { ad.LegacyAdID = transaction.TransactionID; await _context.SaveChangesAsync(); // Guardamos el ID legacy en el aviso } } // Si legacySuccess es false, el error ya fue logueado por el LegacyPaymentService. // D. Notificar al Usuario (RECIBO DE PAGO) - Se ejecuta independientemente del éxito de legacy try { var title = $"{ad.Brand?.Name} {ad.VersionName}"; var userName = ad.User?.FirstName ?? "Usuario"; await _notificationService.SendPaymentReceiptEmailAsync( ad.User?.Email ?? payerEmail, userName, title, paidAmount, operationCode ); } catch (Exception ex) { _logger.LogError(ex, "Falló el envío de email de recibo para Op: {OpCode}", operationCode); /* No bloqueamos el flujo si falla el mail */ } } private string MapStatus(string mpStatus) { return mpStatus switch { PaymentStatus.Approved => "APPROVED", PaymentStatus.Rejected => "REJECTED", PaymentStatus.InProcess => "PENDING", _ => "PENDING" }; } public async Task CheckPaymentStatusAsync(int adId) { // 1. Buscar la última transacción PENDING para este aviso var transaction = await _context.Transactions .Where(t => t.AdID == adId && t.Status == "PENDING") .OrderByDescending(t => t.CreatedAt) .FirstOrDefaultAsync(); if (transaction == null) { // Si no hay pendientes, buscamos la última aprobada para devolver estado OK var approved = await _context.Transactions .Where(t => t.AdID == adId && t.Status == "APPROVED") .FirstOrDefaultAsync(); if (approved != null) return new PaymentResponseDto { Status = "approved", PaymentId = 0 }; throw new Exception("No se encontraron transacciones pendientes para verificar."); } long mpId = 0; if (long.TryParse(transaction.ProviderResponse, out long simpleId)) { mpId = simpleId; } else { try { using var doc = System.Text.Json.JsonDocument.Parse(transaction.ProviderResponse ?? "{}"); if (doc.RootElement.TryGetProperty("mp_id", out var el)) mpId = long.Parse(el.GetString()!); else if (doc.RootElement.TryGetProperty("id", out var el2)) mpId = el2.GetInt64(); } catch { } } if (mpId == 0) throw new Exception("No se pudo recuperar el ID de Mercado Pago."); var client = new PaymentClient(); var payment = await client.GetAsync(mpId); // 3. Actualizar DB Local si cambió el estado var newStatus = MapStatus(payment.Status); if (newStatus != transaction.Status) { transaction.Status = newStatus; transaction.UpdatedAt = DateTime.UtcNow; // Si se aprobó ahora, ejecutamos la lógica de finalización if (newStatus == "APPROVED") { var ad = await _context.Ads.Include(a => a.User).FirstOrDefaultAsync(a => a.AdID == adId); if (ad != null) { await FinalizeApprovedPayment(ad, payment.Id?.ToString() ?? "", transaction.OperationCode, payment.PaymentMethodId, payment.Payer.Email, transaction.Amount); } } // Si se rechazó o canceló if (newStatus == "REJECTED") { // Liberar el aviso para intentar pagar de nuevo var ad = await _context.Ads.FindAsync(adId); if (ad != null) ad.StatusID = 1; // Volver a Borrador } await _context.SaveChangesAsync(); } return new PaymentResponseDto { PaymentId = payment.Id ?? 0, Status = payment.Status, StatusDetail = payment.StatusDetail, OperationCode = transaction.OperationCode }; } }