Init Commit
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
// 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user