All checks were successful
		
		
	
	Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m3s
				
			Se introduce una refactorización completa del sistema de registro de pagos para manejar correctamente los abonos parciales, asegurando que el estado de la factura y el saldo pendiente se reflejen con precisión tanto en el backend como en la interfaz de usuario. ### 🐛 Problema Solucionado - Anteriormente, el sistema no reconocía los pagos parciales. Una factura permanecía en estado "Pendiente" hasta que el monto total era cubierto, y la interfaz de usuario siempre mostraba el 100% del saldo como pendiente, lo cual era incorrecto y confuso. ### ✨ Nuevas Características y Mejoras - **Nuevo Estado de Factura "Pagada Parcialmente":** - Se introduce un nuevo estado para las facturas que han recibido uno o más pagos pero cuyo saldo aún no es cero. - El `PagoService` ahora actualiza el estado de la factura a "Pagada Parcialmente" cuando recibe un abono que no cubre el total. - **Mejoras en la Interfaz de Usuario (`ConsultaFacturasPage`):** - **Nuevas Columnas:** Se han añadido las columnas "Pagado" y "Saldo" a la tabla de detalle de facturas, mostrando explícitamente el monto abonado y el restante. - **Visualización de Estado:** El `Chip` de estado ahora muestra "Pagada Parcialmente" con un color distintivo (azul/primary) para una rápida identificación visual. - **Cálculo de Saldo Correcto:** El saldo pendiente total por suscriptor y el saldo para el modal de pago manual ahora se calculan correctamente, restando el `totalPagado` del `importeFinal`. ### 🔄 Cambios en el Backend - **`PagoService`:** Se actualizó la lógica para establecer el estado de la factura (`Pendiente`, `Pagada Parcialmente`, `Pagada`) basado en el `nuevoTotalPagado` después de registrar un pago. - **`FacturacionService`:** El método `ObtenerResumenesDeCuentaPorPeriodo` ahora calcula correctamente el `SaldoPendienteTotal` y pasa la propiedad `TotalPagado` al DTO del frontend. - **DTOs:** Se actualizó `FacturaConsolidadaDto` para incluir la propiedad `TotalPagado`.
		
			
				
	
	
		
			670 lines
		
	
	
		
			37 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			670 lines
		
	
	
		
			37 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using GestionIntegral.Api.Data;
 | |
| using GestionIntegral.Api.Data.Repositories.Distribucion;
 | |
| using GestionIntegral.Api.Data.Repositories.Suscripciones;
 | |
| using GestionIntegral.Api.Dtos.Suscripciones;
 | |
| using GestionIntegral.Api.Models.Distribucion;
 | |
| using GestionIntegral.Api.Models.Suscripciones;
 | |
| using System.Data;
 | |
| using System.Globalization;
 | |
| using System.Text;
 | |
| using GestionIntegral.Api.Services.Comunicaciones;
 | |
| using GestionIntegral.Api.Data.Repositories.Comunicaciones;
 | |
| using GestionIntegral.Api.Data.Repositories.Usuarios;
 | |
| using GestionIntegral.Api.Dtos.Comunicaciones;
 | |
| using GestionIntegral.Api.Models.Comunicaciones;
 | |
| 
 | |
| namespace GestionIntegral.Api.Services.Suscripciones
 | |
| {
 | |
|     public class FacturacionService : IFacturacionService
 | |
|     {
 | |
|         private readonly ILoteDeEnvioRepository _loteDeEnvioRepository;
 | |
|         private readonly IUsuarioRepository _usuarioRepository;
 | |
|         private readonly ISuscripcionRepository _suscripcionRepository;
 | |
|         private readonly IFacturaRepository _facturaRepository;
 | |
|         private readonly IEmpresaRepository _empresaRepository;
 | |
|         private readonly IFacturaDetalleRepository _facturaDetalleRepository;
 | |
|         private readonly IPrecioRepository _precioRepository;
 | |
|         private readonly IPromocionRepository _promocionRepository;
 | |
|         private readonly ISuscriptorRepository _suscriptorRepository;
 | |
|         private readonly IAjusteRepository _ajusteRepository;
 | |
|         private readonly IEmailService _emailService;
 | |
|         private readonly IPublicacionRepository _publicacionRepository;
 | |
|         private readonly DbConnectionFactory _connectionFactory;
 | |
|         private readonly ILogger<FacturacionService> _logger;
 | |
|         private readonly string _facturasPdfPath;
 | |
|         private const string LogoUrl = "https://www.eldia.com/img/header/eldia.png";
 | |
| 
 | |
|         public FacturacionService(
 | |
|             ISuscripcionRepository suscripcionRepository,
 | |
|             IFacturaRepository facturaRepository,
 | |
|             IEmpresaRepository empresaRepository,
 | |
|             IFacturaDetalleRepository facturaDetalleRepository,
 | |
|             IPrecioRepository precioRepository,
 | |
|             IPromocionRepository promocionRepository,
 | |
|             ISuscriptorRepository suscriptorRepository,
 | |
|             IAjusteRepository ajusteRepository,
 | |
|             IEmailService emailService,
 | |
|             IPublicacionRepository publicacionRepository,
 | |
|             DbConnectionFactory connectionFactory,
 | |
|             ILogger<FacturacionService> logger,
 | |
|             IConfiguration configuration,
 | |
|             ILoteDeEnvioRepository loteDeEnvioRepository,
 | |
|              IUsuarioRepository usuarioRepository)
 | |
|         {
 | |
|             _loteDeEnvioRepository = loteDeEnvioRepository;
 | |
|             _usuarioRepository = usuarioRepository;
 | |
|             _suscripcionRepository = suscripcionRepository;
 | |
|             _facturaRepository = facturaRepository;
 | |
|             _empresaRepository = empresaRepository;
 | |
|             _facturaDetalleRepository = facturaDetalleRepository;
 | |
|             _precioRepository = precioRepository;
 | |
|             _promocionRepository = promocionRepository;
 | |
|             _suscriptorRepository = suscriptorRepository;
 | |
|             _ajusteRepository = ajusteRepository;
 | |
|             _emailService = emailService;
 | |
|             _publicacionRepository = publicacionRepository;
 | |
|             _connectionFactory = connectionFactory;
 | |
|             _logger = logger;
 | |
|             _facturasPdfPath = configuration.GetValue<string>("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF";
 | |
|         }
 | |
| 
 | |
|         public async Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
 | |
|         {
 | |
|             var periodoActual = new DateTime(anio, mes, 1);
 | |
|             var periodoActualStr = periodoActual.ToString("yyyy-MM");
 | |
|             _logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodoActualStr, idUsuario);
 | |
| 
 | |
|             // --- INICIO: Creación del Lote de Envío ---
 | |
|             var lote = await _loteDeEnvioRepository.CreateAsync(new LoteDeEnvio
 | |
|             {
 | |
|                 FechaInicio = DateTime.Now,
 | |
|                 Periodo = periodoActualStr,
 | |
|                 Origen = "FacturacionMensual",
 | |
|                 Estado = "Iniciado",
 | |
|                 IdUsuarioDisparo = idUsuario
 | |
|             });
 | |
|             // --- FIN: Creación del Lote de Envío ---
 | |
| 
 | |
|             var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
 | |
|             if (ultimoPeriodoFacturadoStr != null)
 | |
|             {
 | |
|                 var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
 | |
|                 if (periodoActual != ultimoPeriodo.AddMonths(1))
 | |
|                 {
 | |
|                     var periodoEsperado = ultimoPeriodo.AddMonths(1).ToString("MMMM 'de' yyyy", new CultureInfo("es-ES"));
 | |
|                     return (false, $"Error: No se puede generar la facturación de {periodoActual:MMMM 'de' yyyy}. El siguiente período a generar es {periodoEsperado}.", null);
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             var facturasCreadas = new List<Factura>();
 | |
|             int facturasGeneradas = 0;
 | |
|             int emailsEnviados = 0;
 | |
|             int emailsFallidos = 0;
 | |
|             var erroresDetallados = new List<EmailLogDto>();
 | |
| 
 | |
|             using var connection = _connectionFactory.CreateConnection();
 | |
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync();
 | |
|             using var transaction = connection.BeginTransaction();
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodoActualStr, transaction);
 | |
|                 if (!suscripcionesActivas.Any())
 | |
|                 {
 | |
|                     // Si no hay nada que facturar, consideramos el proceso exitoso pero sin resultados.
 | |
|                     lote.Estado = "Completado";
 | |
|                     lote.FechaFin = DateTime.Now;
 | |
|                     await _loteDeEnvioRepository.UpdateAsync(lote);
 | |
|                     return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", null);
 | |
|                 }
 | |
| 
 | |
|                 var suscripcionesConEmpresa = new List<(Suscripcion Suscripcion, int IdEmpresa)>();
 | |
|                 foreach (var s in suscripcionesActivas)
 | |
|                 {
 | |
|                     var pub = await _publicacionRepository.GetByIdSimpleAsync(s.IdPublicacion);
 | |
|                     if (pub != null)
 | |
|                     {
 | |
|                         suscripcionesConEmpresa.Add((s, pub.IdEmpresa));
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa });
 | |
| 
 | |
|                 foreach (var grupo in gruposParaFacturar)
 | |
|                 {
 | |
|                     int idSuscriptor = grupo.Key.IdSuscriptor;
 | |
|                     int idEmpresa = grupo.Key.IdEmpresa;
 | |
|                     decimal importeBrutoTotal = 0;
 | |
|                     decimal descuentoPromocionesTotal = 0;
 | |
|                     var detallesParaFactura = new List<FacturaDetalle>();
 | |
|                     foreach (var item in grupo)
 | |
|                     {
 | |
|                         var suscripcion = item.Suscripcion;
 | |
|                         decimal importeBrutoSusc = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
 | |
|                         var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, periodoActual, transaction);
 | |
|                         decimal descuentoSusc = CalcularDescuentoPromociones(importeBrutoSusc, promociones);
 | |
|                         importeBrutoTotal += importeBrutoSusc;
 | |
|                         descuentoPromocionesTotal += descuentoSusc;
 | |
|                         var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
 | |
|                         detallesParaFactura.Add(new FacturaDetalle
 | |
|                         {
 | |
|                             IdSuscripcion = suscripcion.IdSuscripcion,
 | |
|                             Descripcion = $"Corresponde a {publicacion?.Nombre ?? "N/A"}",
 | |
|                             ImporteBruto = importeBrutoSusc,
 | |
|                             DescuentoAplicado = descuentoSusc,
 | |
|                             ImporteNeto = importeBrutoSusc - descuentoSusc
 | |
|                         });
 | |
|                     }
 | |
|                     var ultimoDiaDelMes = periodoActual.AddMonths(1).AddDays(-1);
 | |
|                     var ajustesPendientes = await _ajusteRepository.GetAjustesPendientesHastaFechaAsync(idSuscriptor, idEmpresa, ultimoDiaDelMes, transaction);
 | |
|                     decimal totalAjustes = ajustesPendientes.Sum(a => a.TipoAjuste == "Credito" ? -a.Monto : a.Monto);
 | |
|                     var importeFinal = importeBrutoTotal - descuentoPromocionesTotal + totalAjustes;
 | |
|                     if (importeFinal < 0) importeFinal = 0;
 | |
|                     if (importeBrutoTotal <= 0 && descuentoPromocionesTotal <= 0 && totalAjustes == 0) continue;
 | |
|                     var nuevaFactura = new Factura
 | |
|                     {
 | |
|                         IdSuscriptor = idSuscriptor,
 | |
|                         Periodo = periodoActualStr,
 | |
|                         FechaEmision = DateTime.Now.Date,
 | |
|                         FechaVencimiento = new DateTime(anio, mes, 10),
 | |
|                         ImporteBruto = importeBrutoTotal,
 | |
|                         DescuentoAplicado = descuentoPromocionesTotal,
 | |
|                         ImporteFinal = importeFinal,
 | |
|                         EstadoPago = "Pendiente",
 | |
|                         EstadoFacturacion = "Pendiente de Facturar"
 | |
|                     };
 | |
|                     var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
 | |
|                     if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
 | |
|                     facturasCreadas.Add(facturaCreada);
 | |
|                     foreach (var detalle in detallesParaFactura)
 | |
|                     {
 | |
|                         detalle.IdFactura = facturaCreada.IdFactura;
 | |
|                         await _facturaDetalleRepository.CreateAsync(detalle, transaction);
 | |
|                     }
 | |
|                     if (ajustesPendientes.Any())
 | |
|                     {
 | |
|                         await _ajusteRepository.MarcarAjustesComoAplicadosAsync(ajustesPendientes.Select(a => a.IdAjuste), facturaCreada.IdFactura, transaction);
 | |
|                     }
 | |
|                     facturasGeneradas++;
 | |
|                 }
 | |
| 
 | |
|                 transaction.Commit();
 | |
|                 _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr);
 | |
| 
 | |
|                 if (facturasCreadas.Any())
 | |
|                 {
 | |
|                     var suscriptoresAnotificar = facturasCreadas.Select(f => f.IdSuscriptor).Distinct().ToList();
 | |
|                     _logger.LogInformation("Iniciando envío automático de avisos para {Count} suscriptores.", suscriptoresAnotificar.Count);
 | |
| 
 | |
|                     foreach (var idSuscriptor in suscriptoresAnotificar)
 | |
|                     {
 | |
|                         var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); // Necesitamos el objeto suscriptor
 | |
|                         if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
 | |
|                         {
 | |
|                             emailsFallidos++;
 | |
|                             erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor?.NombreCompleto ?? $"ID Suscriptor {idSuscriptor}", Error = "Suscriptor sin email válido." });
 | |
|                             continue;
 | |
|                         }
 | |
| 
 | |
|                         try
 | |
|                         {
 | |
|                             await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor, lote.IdLoteDeEnvio, idUsuario);
 | |
|                             emailsEnviados++;
 | |
|                         }
 | |
|                         catch (Exception exEmail)
 | |
|                         {
 | |
|                             emailsFallidos++;
 | |
|                             erroresDetallados.Add(new EmailLogDto { DestinatarioEmail = suscriptor.Email, Error = exEmail.Message });
 | |
|                             _logger.LogError(exEmail, "Falló el envío automático de email para el suscriptor ID {IdSuscriptor}", idSuscriptor);
 | |
|                         }
 | |
|                     }
 | |
|                     _logger.LogInformation("{EmailsEnviados} avisos de vencimiento enviados automáticamente.", emailsEnviados);
 | |
|                 }
 | |
| 
 | |
|                 lote.Estado = "Completado";
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 try { transaction.Rollback(); } catch { }
 | |
|                 lote.Estado = "Fallido";
 | |
|                 _logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodoActualStr);
 | |
|                 return (false, "Error interno del servidor al generar la facturación.", null);
 | |
|             }
 | |
|             finally
 | |
|             {
 | |
|                 lote.FechaFin = DateTime.Now;
 | |
|                 lote.TotalCorreos = emailsEnviados + emailsFallidos;
 | |
|                 lote.TotalEnviados = emailsEnviados;
 | |
|                 lote.TotalFallidos = emailsFallidos;
 | |
|                 await _loteDeEnvioRepository.UpdateAsync(lote);
 | |
|             }
 | |
| 
 | |
|             var resultadoEnvio = new LoteDeEnvioResumenDto
 | |
|             {
 | |
|                 IdLoteDeEnvio = lote.IdLoteDeEnvio,
 | |
|                 Periodo = periodoActualStr,
 | |
|                 TotalCorreos = lote.TotalCorreos,
 | |
|                 TotalEnviados = lote.TotalEnviados,
 | |
|                 TotalFallidos = lote.TotalFallidos,
 | |
|                 ErroresDetallados = erroresDetallados
 | |
|             };
 | |
| 
 | |
|             return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", resultadoEnvio);
 | |
|         }
 | |
| 
 | |
|         public async Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes)
 | |
|         {
 | |
|             var lotes = await _loteDeEnvioRepository.GetAllAsync(anio, mes);
 | |
|             if (!lotes.Any())
 | |
|             {
 | |
|                 return Enumerable.Empty<LoteDeEnvioHistorialDto>();
 | |
|             }
 | |
| 
 | |
|             var idsUsuarios = lotes.Select(l => l.IdUsuarioDisparo).Distinct();
 | |
|             var usuarios = (await _usuarioRepository.GetByIdsAsync(idsUsuarios)).ToDictionary(u => u.Id);
 | |
| 
 | |
|             return lotes.Select(l => new LoteDeEnvioHistorialDto
 | |
|             {
 | |
|                 IdLoteDeEnvio = l.IdLoteDeEnvio,
 | |
|                 FechaInicio = l.FechaInicio,
 | |
|                 Periodo = l.Periodo,
 | |
|                 Estado = l.Estado,
 | |
|                 TotalCorreos = l.TotalCorreos,
 | |
|                 TotalEnviados = l.TotalEnviados,
 | |
|                 TotalFallidos = l.TotalFallidos,
 | |
|                 NombreUsuarioDisparo = usuarios.TryGetValue(l.IdUsuarioDisparo, out var user)
 | |
|                         ? $"{user.Nombre} {user.Apellido}"
 | |
|                         : "Usuario Desconocido"
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion)
 | |
|         {
 | |
|             var periodo = $"{anio}-{mes:D2}";
 | |
|             var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion);
 | |
|             var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
 | |
|             var empresas = await _empresaRepository.GetAllAsync(null, null);
 | |
| 
 | |
|             var resumenes = facturasData
 | |
|                 .GroupBy(data => data.Factura.IdSuscriptor)
 | |
|                 .Select(grupo =>
 | |
|                 {
 | |
|                     var primerItem = grupo.First();
 | |
|                     var facturasConsolidadas = grupo.Select(itemFactura =>
 | |
|                     {
 | |
|                         var empresa = empresas.FirstOrDefault(e => e.IdEmpresa == itemFactura.IdEmpresa);
 | |
|                         return new FacturaConsolidadaDto
 | |
|                         {
 | |
|                             IdFactura = itemFactura.Factura.IdFactura,
 | |
|                             NombreEmpresa = empresa?.Nombre ?? "N/A",
 | |
|                             ImporteFinal = itemFactura.Factura.ImporteFinal,
 | |
|                             EstadoPago = itemFactura.Factura.EstadoPago,
 | |
|                             EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
 | |
|                             NumeroFactura = itemFactura.Factura.NumeroFactura,
 | |
|                             TotalPagado = itemFactura.TotalPagado,
 | |
|                             Detalles = detallesData
 | |
|                                 .Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
 | |
|                                 .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto })
 | |
|                                 .ToList()
 | |
|                         };
 | |
|                     }).ToList();
 | |
| 
 | |
|                     return new ResumenCuentaSuscriptorDto
 | |
|                     {
 | |
|                         IdSuscriptor = primerItem.Factura.IdSuscriptor,
 | |
|                         NombreSuscriptor = primerItem.NombreSuscriptor,
 | |
|                         Facturas = facturasConsolidadas,
 | |
|                         ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
 | |
|                         SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
 | |
|                     };
 | |
|                 });
 | |
| 
 | |
|             return resumenes.ToList();
 | |
|         }
 | |
| 
 | |
|         public async Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario)
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura);
 | |
|                 if (factura == null) return (false, "Factura no encontrada.", null);
 | |
|                 if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número asignado.", null);
 | |
|                 if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada.", null);
 | |
| 
 | |
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(factura.IdSuscriptor);
 | |
|                 if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email.", null);
 | |
| 
 | |
|                 byte[]? pdfAttachment = null;
 | |
|                 string? pdfFileName = null;
 | |
|                 var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
 | |
| 
 | |
|                 if (File.Exists(rutaCompleta))
 | |
|                 {
 | |
|                     pdfAttachment = await File.ReadAllBytesAsync(rutaCompleta);
 | |
|                     pdfFileName = $"Factura_{factura.NumeroFactura}.pdf";
 | |
|                 }
 | |
|                 else
 | |
|                 {
 | |
|                     _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura}", factura.NumeroFactura);
 | |
|                     return (false, "No se encontró el archivo PDF correspondiente en el servidor.", null);
 | |
|                 }
 | |
| 
 | |
|                 string asunto = $"Factura Electrónica - Período {factura.Periodo}";
 | |
|                 string cuerpoHtml = ConstruirCuerpoEmailFacturaPdf(suscriptor, factura);
 | |
| 
 | |
|                 // Pasamos los nuevos parámetros de contexto al EmailService.
 | |
|                 await _emailService.EnviarEmailAsync(
 | |
|                     destinatarioEmail: suscriptor.Email,
 | |
|                     destinatarioNombre: suscriptor.NombreCompleto,
 | |
|                     asunto: asunto,
 | |
|                     cuerpoHtml: cuerpoHtml,
 | |
|                     attachment: pdfAttachment,
 | |
|                     attachmentName: pdfFileName,
 | |
|                     origen: "EnvioManualPDF",
 | |
|                     referenciaId: $"Factura-{idFactura}",
 | |
|                     idUsuarioDisparo: idUsuario);
 | |
| 
 | |
|                 _logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor);
 | |
| 
 | |
|                 return (true, null, suscriptor.Email);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 _logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura);
 | |
|                 // El error ya será logueado por EmailService, pero lo relanzamos para que el controller lo maneje.
 | |
|                 // En este caso, simplemente devolvemos la tupla de error.
 | |
|                 return (false, "Ocurrió un error al intentar enviar el email con la factura.", null);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         /// <summary>
 | |
|         /// Construye y envía un email consolidado con el resumen de todas las facturas de un suscriptor para un período.
 | |
|         /// Este método está diseñado para ser llamado desde un proceso masivo como la facturación mensual.
 | |
|         /// </summary>
 | |
|         private async Task EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor, int idLoteDeEnvio, int idUsuarioDisparo)
 | |
|         {
 | |
|             var periodo = $"{anio}-{mes:D2}";
 | |
| 
 | |
|             // La lógica de try/catch ahora está en el método llamador (GenerarFacturacionMensual)
 | |
|             // para poder contar los fallos y actualizar el lote de envío.
 | |
| 
 | |
|             var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo);
 | |
|             if (!facturasConEmpresa.Any())
 | |
|             {
 | |
|                 // Si no hay facturas, no hay nada que enviar. Esto no debería ocurrir si se llama desde GenerarFacturacionMensual.
 | |
|                 _logger.LogWarning("Se intentó enviar aviso para Suscriptor ID {IdSuscriptor} en período {Periodo}, pero no se encontraron facturas.", idSuscriptor, periodo);
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor);
 | |
|             // La validación de si el suscriptor tiene email ya se hace en el método llamador.
 | |
|             if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email))
 | |
|             {
 | |
|                 // Lanzamos una excepción para que el método llamador la capture y la cuente como un fallo.
 | |
|                 throw new InvalidOperationException($"El suscriptor ID {idSuscriptor} no es válido o no tiene una dirección de email registrada.");
 | |
|             }
 | |
| 
 | |
|             var resumenHtml = new StringBuilder();
 | |
|             var adjuntos = new List<(byte[] content, string name)>();
 | |
| 
 | |
|             foreach (var item in facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada"))
 | |
|             {
 | |
|                 var factura = item.Factura;
 | |
|                 var nombreEmpresa = item.NombreEmpresa;
 | |
|                 var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura);
 | |
| 
 | |
|                 resumenHtml.Append($"<h4 style='margin-top: 20px; margin-bottom: 10px; color: #34515e;'>Resumen para {nombreEmpresa}</h4>");
 | |
|                 resumenHtml.Append("<table style='width: 100%; border-collapse: collapse; font-size: 0.9em;'>");
 | |
| 
 | |
|                 foreach (var detalle in detalles)
 | |
|                 {
 | |
|                     resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee;'>{detalle.Descripcion}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right;'>${detalle.ImporteNeto:N2}</td></tr>");
 | |
|                 }
 | |
| 
 | |
|                 var ajustes = await _ajusteRepository.GetAjustesPorIdFacturaAsync(factura.IdFactura);
 | |
|                 if (ajustes.Any())
 | |
|                 {
 | |
|                     foreach (var ajuste in ajustes)
 | |
|                     {
 | |
|                         bool esCredito = ajuste.TipoAjuste == "Credito";
 | |
|                         string colorMonto = esCredito ? "#5cb85c" : "#d9534f";
 | |
|                         string signo = esCredito ? "-" : "+";
 | |
|                         resumenHtml.Append($"<tr><td style='padding: 5px; border-bottom: 1px solid #eee; font-style: italic;'>Ajuste: {ajuste.Motivo}</td><td style='padding: 5px; border-bottom: 1px solid #eee; text-align: right; color: {colorMonto}; font-style: italic;'>{signo} ${ajuste.Monto:N2}</td></tr>");
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 resumenHtml.Append($"<tr style='font-weight: bold;'><td style='padding: 5px;'>Subtotal</td><td style='padding: 5px; text-align: right;'>${factura.ImporteFinal:N2}</td></tr>");
 | |
|                 resumenHtml.Append("</table>");
 | |
| 
 | |
|                 if (!string.IsNullOrEmpty(factura.NumeroFactura))
 | |
|                 {
 | |
|                     var rutaCompleta = Path.Combine(_facturasPdfPath, $"{factura.NumeroFactura}.pdf");
 | |
|                     if (File.Exists(rutaCompleta))
 | |
|                     {
 | |
|                         byte[] pdfBytes = await File.ReadAllBytesAsync(rutaCompleta);
 | |
|                         string pdfFileName = $"Factura_{nombreEmpresa.Replace(" ", "")}_{factura.NumeroFactura}.pdf";
 | |
|                         adjuntos.Add((pdfBytes, pdfFileName));
 | |
|                         _logger.LogInformation("PDF adjuntado para envío a Suscriptor ID {IdSuscriptor}: {FileName}", idSuscriptor, pdfFileName);
 | |
|                     }
 | |
|                     else
 | |
|                     {
 | |
|                         _logger.LogWarning("No se encontró el PDF para la factura {NumeroFactura} en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta);
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             var totalGeneral = facturasConEmpresa.Where(f => f.Factura.EstadoPago != "Anulada").Sum(f => f.Factura.ImporteFinal);
 | |
|             string asunto = $"Resumen de Cuenta - Período {periodo}";
 | |
|             string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral);
 | |
| 
 | |
|             await _emailService.EnviarEmailConsolidadoAsync(
 | |
|                 destinatarioEmail: suscriptor.Email,
 | |
|                 destinatarioNombre: suscriptor.NombreCompleto,
 | |
|                 asunto: asunto,
 | |
|                 cuerpoHtml: cuerpoHtml,
 | |
|                 adjuntos: adjuntos,
 | |
|                 origen: "FacturacionMensual",
 | |
|                 referenciaId: $"Suscriptor-{idSuscriptor}",
 | |
|                 idUsuarioDisparo: idUsuarioDisparo, // Se pasa el ID del usuario que inició el cierre
 | |
|                 idLoteDeEnvio: idLoteDeEnvio // Se pasa el ID del lote
 | |
|             );
 | |
| 
 | |
|             // El logging de éxito o fallo ahora lo hace el EmailService, por lo que este log ya no es estrictamente necesario,
 | |
|             // pero lo mantenemos para tener un registro de alto nivel en el log del FacturacionService.
 | |
|             _logger.LogInformation("Llamada a EmailService completada para Suscriptor ID {IdSuscriptor} en el período {Periodo}.", idSuscriptor, periodo);
 | |
|         }
 | |
| 
 | |
|         private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral)
 | |
|         {
 | |
|             return $@"
 | |
|             <div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
 | |
|                 <div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
 | |
|                     <div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
 | |
|                         <img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
 | |
|                         <h2>Resumen de su Cuenta</h2>
 | |
|                     </div>
 | |
|                     <div style='padding: 20px; color: #333;'>
 | |
|                         <h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
 | |
|                         <p>Le enviamos el resumen de su cuenta para el período <strong>{periodo}</strong>.</p>
 | |
|                         
 | |
|                         <!-- Aquí se insertan las tablas de resumen generadas dinámicamente -->
 | |
|                         {resumenHtml}
 | |
|                         
 | |
|                         <hr style='border: none; border-top: 1px solid #eee; margin: 20px 0;'/>
 | |
|                         <table style='width: 100%;'>
 | |
|                             <tr>
 | |
|                                 <td style='font-size: 1.2em; font-weight: bold;'>TOTAL A ABONAR:</td>
 | |
|                                 <td style='font-size: 1.4em; font-weight: bold; text-align: right; color: #34515e;'>${totalGeneral:N2}</td>
 | |
|                             </tr>
 | |
|                         </table>
 | |
|                         <p style='margin-top: 25px;'>Si su pago es por débito automático, los importes se debitarán de su cuenta. Si utiliza otro medio de pago, por favor, regularice su situación.</p>
 | |
|                         <p>Gracias por ser parte de nuestra comunidad de lectores.</p>
 | |
|                     </div>
 | |
|                     <div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
 | |
|                         <p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
 | |
|                         <p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             </div>";
 | |
|         }
 | |
| 
 | |
|         private string ConstruirCuerpoEmailFacturaPdf(Suscriptor suscriptor, Factura factura)
 | |
|         {
 | |
|             return $@"
 | |
|             <div style='font-family: Arial, sans-serif; background-color: #f9f9f9; padding: 20px;'>
 | |
|                 <div style='max-width: 600px; margin: auto; background-color: #ffffff; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;'>
 | |
|                     <div style='background-color: #34515e; color: #ffffff; padding: 20px; text-align: center;'>
 | |
|                         <img src='{LogoUrl}' alt='El Día' style='max-width: 150px; margin-bottom: 10px;'>
 | |
|                         <h2>Factura Electrónica Adjunta</h2>
 | |
|                     </div>
 | |
|                     <div style='padding: 20px; color: #333;'>
 | |
|                         <h3 style='color: #34515e;'>Hola {suscriptor.NombreCompleto},</h3>
 | |
|                         <p>Le enviamos adjunta su factura correspondiente al período <strong>{factura.Periodo}</strong>.</p>
 | |
|                         <h4 style='border-bottom: 2px solid #34515e; padding-bottom: 5px; margin-top: 30px;'>Resumen de la Factura</h4>
 | |
|                         <table style='width: 100%; border-collapse: collapse; margin-top: 15px;'>
 | |
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Número de Factura:</td><td style='padding: 8px; text-align: right;'>{factura.NumeroFactura}</td></tr>
 | |
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Período:</td><td style='padding: 8px; text-align: right;'>{factura.Periodo}</td></tr>
 | |
|                             <tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px; font-weight: bold;'>Fecha de Envío:</td><td style='padding: 8px; text-align: right;'>{factura.FechaEmision:dd/MM/yyyy}</td></tr>
 | |
|                             <tr style='background-color: #f2f2f2;'><td style='padding: 12px; font-weight: bold; font-size: 1.1em;'>IMPORTE TOTAL:</td><td style='padding: 12px; text-align: right; font-weight: bold; font-size: 1.2em; color: #34515e;'>${factura.ImporteFinal:N2}</td></tr>
 | |
|                         </table>
 | |
|                         <p style='margin-top: 30px;'>Puede descargar y guardar el archivo PDF adjunto para sus registros.</p>
 | |
|                         <p>Gracias por ser parte de nuestra comunidad de lectores.</p>
 | |
|                     </div>
 | |
|                     <div style='background-color: #f2f2f2; padding: 15px; text-align: center; font-size: 0.8em; color: #777;'>
 | |
|                         <p>Este es un correo electrónico generado automáticamente. Por favor, no responda a este mensaje.</p>
 | |
|                         <p>© {DateTime.Now.Year} Diario El Día. Todos los derechos reservados.</p>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             </div>";
 | |
|         }
 | |
| 
 | |
|         public async Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario)
 | |
|         {
 | |
|             if (string.IsNullOrWhiteSpace(numeroFactura))
 | |
|             {
 | |
|                 return (false, "El número de factura no puede estar vacío.");
 | |
|             }
 | |
| 
 | |
|             using var connection = _connectionFactory.CreateConnection();
 | |
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync();
 | |
|             using var transaction = connection.BeginTransaction();
 | |
| 
 | |
|             try
 | |
|             {
 | |
|                 var factura = await _facturaRepository.GetByIdAsync(idFactura);
 | |
|                 if (factura == null)
 | |
|                 {
 | |
|                     return (false, "La factura especificada no existe.");
 | |
|                 }
 | |
|                 if (factura.EstadoPago == "Anulada")
 | |
|                 {
 | |
|                     return (false, "No se puede modificar una factura anulada.");
 | |
|                 }
 | |
| 
 | |
|                 var actualizado = await _facturaRepository.UpdateNumeroFacturaAsync(idFactura, numeroFactura, transaction);
 | |
|                 if (!actualizado)
 | |
|                 {
 | |
|                     throw new DataException("La actualización del número de factura falló en el repositorio.");
 | |
|                 }
 | |
| 
 | |
|                 transaction.Commit();
 | |
|                 _logger.LogInformation("Número de factura para Factura ID {IdFactura} actualizado a {NumeroFactura} por Usuario ID {IdUsuario}", idFactura, numeroFactura, idUsuario);
 | |
|                 return (true, null);
 | |
|             }
 | |
|             catch (Exception ex)
 | |
|             {
 | |
|                 try { transaction.Rollback(); } catch { }
 | |
|                 _logger.LogError(ex, "Error al actualizar número de factura para Factura ID {IdFactura}", idFactura);
 | |
|                 return (false, "Error interno al actualizar el número de factura.");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
 | |
|         {
 | |
|             decimal importeTotal = 0;
 | |
|             var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
 | |
|             var fechaActual = new DateTime(anio, mes, 1);
 | |
|             var promociones = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, fechaActual, transaction);
 | |
|             var promocionesDeBonificacion = promociones.Where(p => p.TipoEfecto == "BonificarEntregaDia").ToList();
 | |
| 
 | |
|             while (fechaActual.Month == mes)
 | |
|             {
 | |
|                 if (fechaActual.Date >= suscripcion.FechaInicio.Date && (suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
 | |
|                 {
 | |
|                     var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
 | |
|                     if (diasDeEntrega.Contains(diaSemanaChar))
 | |
|                     {
 | |
|                         decimal precioDelDia = 0;
 | |
|                         var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
 | |
|                         if (precioActivo != null)
 | |
|                         {
 | |
|                             precioDelDia = GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
 | |
|                         }
 | |
|                         else
 | |
|                         {
 | |
|                             _logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date);
 | |
|                         }
 | |
| 
 | |
|                         bool diaBonificado = promocionesDeBonificacion.Any(promo => EvaluarCondicionPromocion(promo, fechaActual));
 | |
|                         if (diaBonificado)
 | |
|                         {
 | |
|                             precioDelDia = 0;
 | |
|                             _logger.LogInformation("Día {Fecha} bonificado para suscripción {IdSuscripcion} por promoción.", fechaActual.ToShortDateString(), suscripcion.IdSuscripcion);
 | |
|                         }
 | |
|                         importeTotal += precioDelDia;
 | |
|                     }
 | |
|                 }
 | |
|                 fechaActual = fechaActual.AddDays(1);
 | |
|             }
 | |
|             return importeTotal;
 | |
|         }
 | |
| 
 | |
|         private bool EvaluarCondicionPromocion(Promocion promocion, DateTime fecha)
 | |
|         {
 | |
|             switch (promocion.TipoCondicion)
 | |
|             {
 | |
|                 case "Siempre": return true;
 | |
|                 case "DiaDeSemana":
 | |
|                     int diaSemanaActual = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
 | |
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActual;
 | |
|                 case "PrimerDiaSemanaDelMes":
 | |
|                     int diaSemanaActualMes = (int)fecha.DayOfWeek == 0 ? 7 : (int)fecha.DayOfWeek;
 | |
|                     return promocion.ValorCondicion.HasValue && promocion.ValorCondicion.Value == diaSemanaActualMes && fecha.Day <= 7;
 | |
|                 default: return false;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         private string GetCharDiaSemana(DayOfWeek dia) => dia switch
 | |
|         {
 | |
|             DayOfWeek.Sunday => "Dom",
 | |
|             DayOfWeek.Monday => "Lun",
 | |
|             DayOfWeek.Tuesday => "Mar",
 | |
|             DayOfWeek.Wednesday => "Mie",
 | |
|             DayOfWeek.Thursday => "Jue",
 | |
|             DayOfWeek.Friday => "Vie",
 | |
|             DayOfWeek.Saturday => "Sab",
 | |
|             _ => ""
 | |
|         };
 | |
| 
 | |
|         private decimal GetPrecioDelDia(Precio precio, DayOfWeek dia) => dia switch
 | |
|         {
 | |
|             DayOfWeek.Sunday => precio.Domingo ?? 0,
 | |
|             DayOfWeek.Monday => precio.Lunes ?? 0,
 | |
|             DayOfWeek.Tuesday => precio.Martes ?? 0,
 | |
|             DayOfWeek.Wednesday => precio.Miercoles ?? 0,
 | |
|             DayOfWeek.Thursday => precio.Jueves ?? 0,
 | |
|             DayOfWeek.Friday => precio.Viernes ?? 0,
 | |
|             DayOfWeek.Saturday => precio.Sabado ?? 0,
 | |
|             _ => 0
 | |
|         };
 | |
| 
 | |
|         private decimal CalcularDescuentoPromociones(decimal importeBruto, IEnumerable<Promocion> promociones)
 | |
|         {
 | |
|             return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p =>
 | |
|                p.TipoEfecto == "DescuentoPorcentajeTotal"
 | |
|                ? (importeBruto * p.ValorEfecto) / 100
 | |
|                : p.ValorEfecto
 | |
|             );
 | |
|         }
 | |
|     }
 | |
| } |