All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m45s
Se introduce una refactorización mayor del ciclo de facturación para manejar correctamente las suscripciones que inician en un período ya cerrado. Esto soluciona el problema de cobrar un mes completo a un nuevo suscriptor, mejorando la transparencia y la experiencia del cliente. ### ✨ Nuevas Características y Lógica de Negocio - **Facturación Pro-rata Automática (Factura de Alta):** - Al crear una nueva suscripción cuya fecha de inicio corresponde a un período de facturación ya cerrado, el sistema ahora calcula automáticamente el costo proporcional por los días restantes de ese mes. - Se genera de forma inmediata una nueva factura de tipo "Alta" por este monto parcial, separándola del ciclo de facturación mensual regular. - **Exclusión del Débito Automático para Facturas de Alta:** - Se implementa una regla de negocio clave: las facturas de tipo "Alta" son **excluidas** del proceso de generación del archivo de débito automático para el banco. - Esto fuerza a que el primer cobro (el proporcional) se gestione a través de un medio de pago manual (efectivo, transferencia, etc.), evitando cargos inesperados en la cuenta bancaria del cliente. - El débito automático comenzará a operar normalmente a partir del primer ciclo de facturación completo. ### 🔄 Cambios en el Backend - **Base de Datos:** - Se ha añadido la columna `TipoFactura` (`varchar(20)`) a la tabla `susc_Facturas`. - Se ha implementado una `CHECK constraint` para permitir únicamente los valores 'Mensual' y 'Alta'. - **Servicios:** - **`SuscripcionService`:** Ahora contiene la lógica para detectar una alta retroactiva, invocar al `FacturacionService` para el cálculo pro-rata y crear la "Factura de Alta" y su detalle correspondiente dentro de la misma transacción. - **`FacturacionService`:** Expone públicamente el método `CalcularImporteParaSuscripcion` y se ha actualizado `ObtenerResumenesDeCuentaPorPeriodo` para que envíe la propiedad `TipoFactura` al frontend. - **`DebitoAutomaticoService`:** El método `GetFacturasParaDebito` ahora filtra y excluye explícitamente las facturas donde `TipoFactura = 'Alta'`. ### 🎨 Mejoras en la Interfaz de Usuario (Frontend) - **`ConsultaFacturasPage.tsx`:** - **Nueva Columna:** Se ha añadido una columna "Tipo Factura" en la tabla de detalle, que muestra un `Chip` distintivo para identificar fácilmente las facturas de "Alta". - **Nuevo Filtro:** Se ha agregado un nuevo menú desplegable para filtrar la vista por "Tipo de Factura" (`Todas`, `Mensual`, `Alta`), permitiendo a los administradores auditar rápidamente los nuevos ingresos.
323 lines
16 KiB
C#
323 lines
16 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.Suscripciones;
|
|
using System.Data;
|
|
using System.Globalization;
|
|
|
|
namespace GestionIntegral.Api.Services.Suscripciones
|
|
{
|
|
public class SuscripcionService : ISuscripcionService
|
|
{
|
|
private readonly ISuscripcionRepository _suscripcionRepository;
|
|
private readonly ISuscriptorRepository _suscriptorRepository;
|
|
private readonly IPublicacionRepository _publicacionRepository;
|
|
private readonly IPromocionRepository _promocionRepository;
|
|
private readonly IFacturaRepository _facturaRepository;
|
|
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
|
|
private readonly IFacturacionService _facturacionService;
|
|
private readonly ILogger<SuscripcionService> _logger;
|
|
private readonly DbConnectionFactory _connectionFactory;
|
|
|
|
public SuscripcionService(
|
|
ISuscripcionRepository suscripcionRepository,
|
|
ISuscriptorRepository suscriptorRepository,
|
|
IPublicacionRepository publicacionRepository,
|
|
IPromocionRepository promocionRepository,
|
|
IFacturaRepository facturaRepository,
|
|
IFacturaDetalleRepository facturaDetalleRepository,
|
|
IFacturacionService facturacionService,
|
|
ILogger<SuscripcionService> logger,
|
|
DbConnectionFactory connectionFactory)
|
|
{
|
|
_suscripcionRepository = suscripcionRepository;
|
|
_suscriptorRepository = suscriptorRepository;
|
|
_publicacionRepository = publicacionRepository;
|
|
_promocionRepository = promocionRepository;
|
|
_facturaRepository = facturaRepository;
|
|
_facturaDetalleRepository = facturaDetalleRepository;
|
|
_facturacionService = facturacionService;
|
|
_logger = logger;
|
|
_connectionFactory = connectionFactory;
|
|
}
|
|
|
|
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
|
|
{
|
|
IdPromocion = promo.IdPromocion,
|
|
Descripcion = promo.Descripcion,
|
|
TipoEfecto = promo.TipoEfecto,
|
|
ValorEfecto = promo.ValorEfecto,
|
|
TipoCondicion = promo.TipoCondicion,
|
|
ValorCondicion = promo.ValorCondicion,
|
|
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
|
|
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
|
|
Activa = promo.Activa
|
|
};
|
|
|
|
private async Task<SuscripcionDto?> MapToDto(Suscripcion suscripcion)
|
|
{
|
|
if (suscripcion == null) return null;
|
|
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(suscripcion.IdPublicacion);
|
|
return new SuscripcionDto
|
|
{
|
|
IdSuscripcion = suscripcion.IdSuscripcion,
|
|
IdSuscriptor = suscripcion.IdSuscriptor,
|
|
IdPublicacion = suscripcion.IdPublicacion,
|
|
NombrePublicacion = publicacion?.Nombre ?? "Desconocida",
|
|
FechaInicio = suscripcion.FechaInicio.ToString("yyyy-MM-dd"),
|
|
FechaFin = suscripcion.FechaFin?.ToString("yyyy-MM-dd"),
|
|
Estado = suscripcion.Estado,
|
|
DiasEntrega = suscripcion.DiasEntrega,
|
|
Observaciones = suscripcion.Observaciones
|
|
};
|
|
}
|
|
|
|
public async Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion)
|
|
{
|
|
var suscripcion = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
|
if (suscripcion == null)
|
|
return null;
|
|
return await MapToDto(suscripcion);
|
|
}
|
|
|
|
public async Task<IEnumerable<SuscripcionDto>> ObtenerPorSuscriptorId(int idSuscriptor)
|
|
{
|
|
var suscripciones = await _suscripcionRepository.GetBySuscriptorIdAsync(idSuscriptor);
|
|
var dtosTasks = suscripciones.Select(s => MapToDto(s));
|
|
var dtos = await Task.WhenAll(dtosTasks);
|
|
return dtos.Where(dto => dto != null)!;
|
|
}
|
|
|
|
public async Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario)
|
|
{
|
|
if (await _suscriptorRepository.GetByIdAsync(createDto.IdSuscriptor) == null)
|
|
return (null, "El suscriptor no existe.");
|
|
if (await _publicacionRepository.GetByIdSimpleAsync(createDto.IdPublicacion) == null)
|
|
return (null, "La publicación no existe.");
|
|
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
|
|
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
|
if ((createDto.Estado == "Cancelada" || createDto.Estado == "Pausada") && !createDto.FechaFin.HasValue)
|
|
{
|
|
return (null, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
|
}
|
|
|
|
if (createDto.Estado == "Activa")
|
|
{
|
|
createDto.FechaFin = null;
|
|
}
|
|
|
|
var nuevaSuscripcion = new Suscripcion
|
|
{
|
|
IdSuscriptor = createDto.IdSuscriptor,
|
|
IdPublicacion = createDto.IdPublicacion,
|
|
FechaInicio = createDto.FechaInicio,
|
|
FechaFin = createDto.FechaFin,
|
|
Estado = createDto.Estado,
|
|
DiasEntrega = string.Join(",", createDto.DiasEntrega),
|
|
Observaciones = createDto.Observaciones,
|
|
IdUsuarioAlta = idUsuario
|
|
};
|
|
|
|
using var connection = _connectionFactory.CreateConnection();
|
|
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
|
using var transaction = connection.BeginTransaction();
|
|
try
|
|
{
|
|
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
|
|
if (creada == null) throw new DataException("Error al crear la suscripción.");
|
|
|
|
var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync();
|
|
if (ultimoPeriodoFacturadoStr != null)
|
|
{
|
|
var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture);
|
|
var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1);
|
|
|
|
if (periodoSuscripcion <= ultimoPeriodo)
|
|
{
|
|
_logger.LogInformation("Suscripción en período ya cerrado detectada para Suscriptor {IdSuscriptor}. Generando factura de alta pro-rata.", creada.IdSuscriptor);
|
|
|
|
decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction);
|
|
|
|
if (importeProporcional > 0)
|
|
{
|
|
var facturaDeAlta = new Factura
|
|
{
|
|
IdSuscriptor = creada.IdSuscriptor,
|
|
Periodo = creada.FechaInicio.ToString("yyyy-MM"),
|
|
FechaEmision = DateTime.Now.Date,
|
|
FechaVencimiento = DateTime.Now.AddDays(10).Date, // Vencimiento corto
|
|
ImporteBruto = importeProporcional,
|
|
ImporteFinal = importeProporcional,
|
|
EstadoPago = "Pendiente",
|
|
EstadoFacturacion = "Pendiente de Facturar",
|
|
TipoFactura = "Alta" // Marcamos la factura como de tipo "Alta"
|
|
};
|
|
|
|
var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction);
|
|
if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta.");
|
|
|
|
var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion);
|
|
var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1);
|
|
|
|
await _facturaDetalleRepository.CreateAsync(new FacturaDetalle
|
|
{
|
|
IdFactura = facturaCreada.IdFactura,
|
|
IdSuscripcion = creada.IdSuscripcion,
|
|
Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})",
|
|
ImporteBruto = importeProporcional,
|
|
ImporteNeto = importeProporcional,
|
|
DescuentoAplicado = 0
|
|
}, transaction);
|
|
|
|
_logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion);
|
|
}
|
|
}
|
|
}
|
|
transaction.Commit();
|
|
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
|
|
return (await MapToDto(creada), null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { transaction.Rollback(); } catch { }
|
|
_logger.LogError(ex, "Error al crear suscripción para suscriptor ID {IdSuscriptor}", createDto.IdSuscriptor);
|
|
return (null, $"Error interno: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public async Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario)
|
|
{
|
|
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
|
|
if (existente == null) return (false, "Suscripción no encontrada.");
|
|
|
|
// Validación de lógica de negocio en el backend
|
|
if ((updateDto.Estado == "Cancelada" || updateDto.Estado == "Pausada") && !updateDto.FechaFin.HasValue)
|
|
{
|
|
return (false, "Se debe especificar una 'Fecha Fin' cuando el estado es 'Cancelada' o 'Pausada'.");
|
|
}
|
|
|
|
// Si el estado es 'Activa', nos aseguramos de que la FechaFin sea nula.
|
|
if (updateDto.Estado == "Activa")
|
|
{
|
|
updateDto.FechaFin = null;
|
|
}
|
|
|
|
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
|
|
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
|
|
|
|
existente.FechaInicio = updateDto.FechaInicio;
|
|
existente.FechaFin = updateDto.FechaFin;
|
|
existente.Estado = updateDto.Estado;
|
|
existente.DiasEntrega = string.Join(",", updateDto.DiasEntrega);
|
|
existente.Observaciones = updateDto.Observaciones;
|
|
existente.IdUsuarioMod = idUsuario;
|
|
existente.FechaMod = DateTime.Now;
|
|
|
|
using var connection = _connectionFactory.CreateConnection();
|
|
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
|
using var transaction = connection.BeginTransaction();
|
|
try
|
|
{
|
|
var actualizado = await _suscripcionRepository.UpdateAsync(existente, transaction);
|
|
if (!actualizado) throw new DataException("Error al actualizar la suscripción.");
|
|
|
|
transaction.Commit();
|
|
_logger.LogInformation("Suscripción ID {Id} actualizada por Usuario ID {UserId}.", idSuscripcion, idUsuario);
|
|
return (true, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { transaction.Rollback(); } catch { }
|
|
_logger.LogError(ex, "Error al actualizar suscripción ID: {IdSuscripcion}", idSuscripcion);
|
|
return (false, $"Error interno: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public async Task<IEnumerable<PromocionAsignadaDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
|
|
{
|
|
var asignaciones = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
|
|
return asignaciones.Select(a => new PromocionAsignadaDto
|
|
{
|
|
IdPromocion = a.Promocion.IdPromocion,
|
|
Descripcion = a.Promocion.Descripcion,
|
|
TipoEfecto = a.Promocion.TipoEfecto,
|
|
ValorEfecto = a.Promocion.ValorEfecto,
|
|
TipoCondicion = a.Promocion.TipoCondicion,
|
|
ValorCondicion = a.Promocion.ValorCondicion,
|
|
FechaInicio = a.Promocion.FechaInicio.ToString("yyyy-MM-dd"),
|
|
FechaFin = a.Promocion.FechaFin?.ToString("yyyy-MM-dd"),
|
|
Activa = a.Promocion.Activa,
|
|
VigenciaDesdeAsignacion = a.Asignacion.VigenciaDesde.ToString("yyyy-MM-dd"),
|
|
VigenciaHastaAsignacion = a.Asignacion.VigenciaHasta?.ToString("yyyy-MM-dd")
|
|
});
|
|
}
|
|
|
|
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion)
|
|
{
|
|
var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true);
|
|
var promosAsignadasData = await _suscripcionRepository.GetPromocionesAsignadasBySuscripcionIdAsync(idSuscripcion);
|
|
var idsAsignadas = promosAsignadasData.Select(p => p.Promocion.IdPromocion).ToHashSet();
|
|
|
|
return todasLasPromosActivas
|
|
.Where(p => !idsAsignadas.Contains(p.IdPromocion))
|
|
.Select(MapPromocionToDto); // Usa el helper que ya creamos
|
|
}
|
|
|
|
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, AsignarPromocionDto dto, int idUsuario)
|
|
{
|
|
using var connection = _connectionFactory.CreateConnection();
|
|
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
|
using var transaction = connection.BeginTransaction();
|
|
try
|
|
{
|
|
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada.");
|
|
if (await _promocionRepository.GetByIdAsync(dto.IdPromocion) == null) return (false, "Promoción no encontrada.");
|
|
|
|
var nuevaAsignacion = new SuscripcionPromocion
|
|
{
|
|
IdSuscripcion = idSuscripcion,
|
|
IdPromocion = dto.IdPromocion,
|
|
IdUsuarioAsigno = idUsuario,
|
|
VigenciaDesde = dto.VigenciaDesde,
|
|
VigenciaHasta = dto.VigenciaHasta
|
|
};
|
|
|
|
await _suscripcionRepository.AsignarPromocionAsync(nuevaAsignacion, transaction);
|
|
transaction.Commit();
|
|
return (true, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (ex.Message.Contains("PRIMARY KEY constraint"))
|
|
{
|
|
return (false, "Esta promoción ya está asignada a la suscripción.");
|
|
}
|
|
try { transaction.Rollback(); } catch { }
|
|
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", dto.IdPromocion, idSuscripcion);
|
|
return (false, "Error interno al asignar la promoción.");
|
|
}
|
|
}
|
|
|
|
public async Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion)
|
|
{
|
|
using var connection = _connectionFactory.CreateConnection();
|
|
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
|
|
using var transaction = connection.BeginTransaction();
|
|
try
|
|
{
|
|
var exito = await _suscripcionRepository.QuitarPromocionAsync(idSuscripcion, idPromocion, transaction);
|
|
if (!exito) return (false, "La promoción no estaba asignada a esta suscripción.");
|
|
|
|
transaction.Commit();
|
|
return (true, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
try { transaction.Rollback(); } catch { }
|
|
_logger.LogError(ex, "Error al quitar promoción {IdPromocion} de suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
|
|
return (false, "Error interno al quitar la promoción.");
|
|
}
|
|
}
|
|
}
|
|
} |