Feat: Implementa flujo completo de facturación y promociones

Este commit introduce la funcionalidad completa para la facturación mensual,
la gestión de promociones y la comunicación con el cliente en el módulo
de suscripciones.

Backend:
- Se añade el servicio de Facturación que calcula automáticamente los importes
  mensuales basándose en las suscripciones activas, días de entrega y precios.
- Se implementa el servicio DebitoAutomaticoService, capaz de generar el
  archivo de texto plano para "Pago Directo Galicia" y de procesar el
  archivo de respuesta para la conciliación de pagos.
- Se desarrolla el ABM completo para Promociones (Servicio, Repositorio,
  Controlador y DTOs), permitiendo la creación de descuentos por porcentaje
  o monto fijo.
- Se implementa la lógica para asignar y desasignar promociones a suscripciones
  específicas.
- Se añade un servicio de envío de email (EmailService) integrado con MailKit
  y un endpoint para notificar facturas a los clientes.
- Se crea la lógica para registrar pagos manuales (efectivo, tarjeta, etc.)
  y actualizar el estado de las facturas.
- Se añaden todos los permisos necesarios a la base de datos para
  segmentar el acceso a las nuevas funcionalidades.

Frontend:
- Se crea la página de Facturación, que permite al usuario seleccionar un
  período, generar la facturación, listar los resultados y generar el archivo
  de débito para el banco.
- Se implementa la funcionalidad para subir y procesar el archivo de
  respuesta del banco, actualizando la UI en consecuencia.
- Se añade la página completa para el ABM de Promociones.
- Se integra un modal en la gestión de suscripciones para asignar y
  desasignar promociones a un cliente.
- Se añade la opción "Enviar Email" en el menú de acciones de las facturas,
  conectada al nuevo endpoint del backend.
- Se completan y corrigen los componentes `PagoManualModal` y `FacturacionPage`
  para incluir la lógica de registro de pagos y solucionar errores de TypeScript.
This commit is contained in:
2025-08-01 12:53:17 -03:00
parent b14c5de1b4
commit 84187a66df
53 changed files with 2895 additions and 43 deletions

View File

@@ -0,0 +1,243 @@
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;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class FacturacionService : IFacturacionService
{
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IPrecioRepository _precioRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
private readonly DbConnectionFactory _connectionFactory;
private readonly IEmailService _emailService;
private readonly ILogger<FacturacionService> _logger;
public FacturacionService(
ISuscripcionRepository suscripcionRepository,
IFacturaRepository facturaRepository,
IPrecioRepository precioRepository,
IPromocionRepository promocionRepository,
IRecargoZonaRepository recargoZonaRepository,
ISuscriptorRepository suscriptorRepository,
DbConnectionFactory connectionFactory,
IEmailService emailService,
ILogger<FacturacionService> logger)
{
_suscripcionRepository = suscripcionRepository;
_facturaRepository = facturaRepository;
_precioRepository = precioRepository;
_promocionRepository = promocionRepository;
_recargoZonaRepository = recargoZonaRepository;
_suscriptorRepository = suscriptorRepository;
_connectionFactory = connectionFactory;
_emailService = emailService;
_logger = logger;
}
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
{
var periodo = $"{anio}-{mes:D2}";
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
if (!suscripcionesActivas.Any())
{
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
}
int facturasGeneradas = 0;
foreach (var suscripcion in suscripcionesActivas)
{
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
if (facturaExistente != null)
{
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
continue;
}
// --- LÓGICA DE PROMOCIONES ---
var primerDiaMes = new DateTime(anio, mes, 1);
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
decimal descuentoTotal = 0;
// Aplicar promociones de descuento
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
{
descuentoTotal += (importeBruto * promo.Valor) / 100;
}
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
{
descuentoTotal += promo.Valor;
}
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
if (importeBruto <= 0)
{
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
continue;
}
var importeFinal = importeBruto - descuentoTotal;
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
var nuevaFactura = new Factura
{
IdSuscripcion = suscripcion.IdSuscripcion,
Periodo = periodo,
FechaEmision = DateTime.Now.Date,
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
ImporteBruto = importeBruto,
DescuentoAplicado = descuentoTotal,
ImporteFinal = importeFinal,
Estado = "Pendiente de Facturar"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
facturasGeneradas++;
}
transaction.Commit();
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", 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}", periodo);
return (false, "Error interno del servidor al generar la facturación.", 0);
}
}
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);
while (fechaActual.Month == mes)
{
// La suscripción debe estar activa en este día
if (fechaActual.Date >= suscripcion.FechaInicio.Date &&
(suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
{
var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
if (diasDeEntrega.Contains(diaSemanaChar))
{
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
if (precioActivo != null)
{
importeTotal += 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);
}
}
}
fechaActual = fechaActual.AddDays(1);
}
return importeTotal;
}
public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo);
return facturasData.Select(data => new FacturaDto
{
IdFactura = data.Factura.IdFactura,
IdSuscripcion = data.Factura.IdSuscripcion,
Periodo = data.Factura.Periodo,
FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"),
FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"),
ImporteFinal = data.Factura.ImporteFinal,
Estado = data.Factura.Estado,
NumeroFactura = data.Factura.NumeroFactura,
NombreSuscriptor = data.NombreSuscriptor,
NombrePublicacion = data.NombrePublicacion
});
}
public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura)
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.");
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA.");
var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion);
if (suscripcion == null) return (false, "Suscripción asociada no encontrada.");
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
if (suscriptor == null) return (false, "Suscriptor asociado no encontrado.");
if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada.");
try
{
var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}";
var cuerpo = $@"
<h1>Hola {suscriptor.NombreCompleto},</h1>
<p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p>
<ul>
<li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li>
<li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li>
<li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li>
</ul>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
<p><em>Diario El Día</em></p>";
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura);
return (false, "Ocurrió un error al intentar enviar el email.");
}
}
private string GetCharDiaSemana(DayOfWeek dia) => dia switch
{
DayOfWeek.Sunday => "D",
DayOfWeek.Monday => "L",
DayOfWeek.Tuesday => "M",
DayOfWeek.Wednesday => "X",
DayOfWeek.Thursday => "J",
DayOfWeek.Friday => "V",
DayOfWeek.Saturday => "S",
_ => ""
};
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
};
}
}