Files
GestionIntegralWeb/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs
dmolinari 2e7d1e36be
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m3s
Feat(suscripciones): Implementa manejo de pagos parciales en facturas
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`.
2025-08-11 15:15:08 -03:00

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>&copy; {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>&copy; {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
);
}
}
}