Files
MotoresArgentinosV2/Backend/MotoresArgentinosV2.Infrastructure/Services/MercadoPagoService.cs

392 lines
14 KiB
C#
Raw Normal View History

2026-01-29 13:43:44 -03:00
// 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<MercadoPagoService> _logger;
public MercadoPagoService(
IConfiguration config,
MotoresV2DbContext context,
ILegacyPaymentService legacyService,
IAvisosLegacyService legacyAdsService,
INotificationService notificationService,
IAdSyncService syncService,
ILogger<MercadoPagoService> 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<PaymentResponseDto> 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<PaymentResponseDto> 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
};
}
}