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 GestionIntegral.Api.Services.Comunicaciones; using System.Data; using System.Globalization; using System.Text; namespace GestionIntegral.Api.Services.Suscripciones { public class FacturacionService : IFacturacionService { 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 _logger; private readonly string _facturasPdfPath; 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 logger, IConfiguration configuration) { _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("AppSettings:FacturasPdfPath") ?? "C:\\FacturasPDF"; } public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> 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); 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}.", 0); } } var facturasCreadas = new List(); 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()) { return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0); } // 1. Enriquecer las suscripciones con el IdEmpresa de su publicación 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)); } } // 2. Agrupar por la combinación (Suscriptor, Empresa) var gruposParaFacturar = suscripcionesConEmpresa.GroupBy(s => new { s.Suscripcion.IdSuscriptor, s.IdEmpresa }); int facturasGeneradas = 0; foreach (var grupo in gruposParaFacturar) { int idSuscriptor = grupo.Key.IdSuscriptor; int idEmpresa = grupo.Key.IdEmpresa; // <-- Ya tenemos la empresa del grupo decimal importeBrutoTotal = 0; decimal descuentoPromocionesTotal = 0; var detallesParaFactura = new List(); // 3. Calcular el importe para cada suscripción DENTRO del grupo 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 }); } // 4. Aplicar ajustes. Ahora se buscan por Suscriptor Y por Empresa. 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); // Verificamos si este grupo es el "primero" para este cliente para no aplicar ajustes varias veces bool esPrimerGrupoParaCliente = !facturasCreadas.Any(f => f.IdSuscriptor == idSuscriptor); if (esPrimerGrupoParaCliente) { 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; // 5. Crear UNA factura por cada grupo (Suscriptor + Empresa) 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++; } // --- FIN DE LA LÓGICA DE AGRUPACIÓN --- transaction.Commit(); _logger.LogInformation("Finalizada la generación de {FacturasGeneradas} facturas para {Periodo}.", facturasGeneradas, periodoActualStr); // --- INICIO DE LA LÓGICA DE ENVÍO CONSOLIDADO AUTOMÁTICO --- int emailsEnviados = 0; if (facturasCreadas.Any()) { // Agrupamos las facturas creadas por suscriptor para enviar un solo email 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) { try { var (envioExitoso, _) = await EnviarAvisoCuentaPorEmail(anio, mes, idSuscriptor); if (envioExitoso) emailsEnviados++; } catch (Exception exEmail) { _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); } return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas y se enviaron {emailsEnviados} notificaciones.", facturasGeneradas); } catch (Exception ex) { try { transaction.Rollback(); } catch { } _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.", 0); } } public async Task> 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); // Necesitaremos este nuevo método en el repo 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, 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.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) }; }); return resumenes.ToList(); } public async Task<(bool Exito, string? Error)> EnviarFacturaPdfPorEmail(int idFactura) { try { var factura = await _facturaRepository.GetByIdAsync(idFactura); if (factura == null) return (false, "Factura no encontrada."); if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura no tiene un número oficial asignado para generar el PDF."); if (factura.EstadoPago == "Anulada") return (false, "No se puede enviar email de una factura anulada."); 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."); var detalles = await _facturaDetalleRepository.GetDetallesPorFacturaIdAsync(factura.IdFactura); var primeraSuscripcionId = detalles.FirstOrDefault()?.IdSuscripcion ?? 0; var publicacion = await _publicacionRepository.GetByIdSimpleAsync(primeraSuscripcionId); var empresa = await _empresaRepository.GetByIdAsync(publicacion?.IdEmpresa ?? 0); // --- LÓGICA DE BÚSQUEDA Y ADJUNTO DE PDF --- 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_{empresa?.Nombre?.Replace(" ", "")}_{factura.NumeroFactura}.pdf"; _logger.LogInformation("Adjuntando PDF encontrado en: {Ruta}", rutaCompleta); } else { _logger.LogWarning("Se intentó enviar la factura {NumeroFactura} pero no se encontró el PDF en la ruta: {Ruta}", factura.NumeroFactura, rutaCompleta); return (false, "No se encontró el archivo PDF correspondiente en el servidor. Verifique que el archivo exista y el nombre coincida con el número de factura."); } string asunto = $"Tu Factura Oficial - Diario El Día - Período {factura.Periodo}"; string cuerpoHtml = $"

Hola {suscriptor.NombreCompleto},

Adjuntamos tu factura oficial número {factura.NumeroFactura} correspondiente al período {factura.Periodo}.

Gracias por ser parte de nuestra comunidad de lectores.

Diario El Día

"; await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, pdfAttachment, pdfFileName); _logger.LogInformation("Email con factura PDF ID {IdFactura} enviado para Suscriptor ID {IdSuscriptor}", idFactura, suscriptor.IdSuscriptor); return (true, null); } catch (Exception ex) { _logger.LogError(ex, "Falló el envío de email con PDF para la factura ID {IdFactura}", idFactura); return (false, "Ocurrió un error al intentar enviar el email con la factura."); } } public async Task<(bool Exito, string? Error)> EnviarAvisoCuentaPorEmail(int anio, int mes, int idSuscriptor) { var periodo = $"{anio}-{mes:D2}"; try { var facturasConEmpresa = await _facturaRepository.GetFacturasConEmpresaAsync(idSuscriptor, periodo); if (!facturasConEmpresa.Any()) return (false, "No se encontraron facturas para este suscriptor en el período."); var suscriptor = await _suscriptorRepository.GetByIdAsync(idSuscriptor); if (suscriptor == null || string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no es válido o no tiene email."); 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($"

Resumen para {nombreEmpresa}

"); resumenHtml.Append(""); foreach (var detalle in detalles) { resumenHtml.Append($""); } 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($""); } } resumenHtml.Append($""); resumenHtml.Append("
{detalle.Descripcion}${detalle.ImporteNeto:N2}
Ajuste: {ajuste.Motivo}{signo} ${ajuste.Monto:N2}
Subtotal${factura.ImporteFinal:N2}
"); 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: {FileName}", 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 - Diario El Día - Período {periodo}"; string cuerpoHtml = ConstruirCuerpoEmailConsolidado(suscriptor, periodo, resumenHtml.ToString(), totalGeneral); await _emailService.EnviarEmailConsolidadoAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpoHtml, adjuntos); _logger.LogInformation("Email consolidado para Suscriptor ID {IdSuscriptor} enviado para el período {Periodo}.", idSuscriptor, periodo); return (true, null); } catch (Exception ex) { _logger.LogError(ex, "Falló el envío de email consolidado para el suscriptor ID {IdSuscriptor} y período {Periodo}", idSuscriptor, periodo); return (false, "Ocurrió un error al intentar enviar el email consolidado."); } } private string ConstruirCuerpoEmailConsolidado(Suscriptor suscriptor, string periodo, string resumenHtml, decimal totalGeneral) { return $@"

Hola {suscriptor.NombreCompleto},

Le enviamos el resumen de su cuenta para el período {periodo}.

{resumenHtml}
TOTAL: ${totalGeneral:N2}

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.

Gracias por ser parte de nuestra comunidad de lectores.

Diario El Día

"; } 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 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 promociones) { return promociones.Where(p => p.TipoEfecto.Contains("Descuento")).Sum(p => p.TipoEfecto == "DescuentoPorcentajeTotal" ? (importeBruto * p.ValorEfecto) / 100 : p.ValorEfecto ); } } }