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:
		| @@ -0,0 +1,93 @@ | ||||
| // Archivo: GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs | ||||
|  | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/debitos")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class DebitosController : ControllerBase | ||||
|     { | ||||
|         private readonly IDebitoAutomaticoService _debitoService; | ||||
|         private readonly ILogger<DebitosController> _logger; | ||||
|  | ||||
|         // Permiso para generar archivos de débito (a crear en BD) | ||||
|         private const string PermisoGenerarDebitos = "SU007"; | ||||
|  | ||||
|         public DebitosController(IDebitoAutomaticoService debitoService, ILogger<DebitosController> logger) | ||||
|         { | ||||
|             _debitoService = debitoService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // POST: api/debitos/{anio}/{mes}/generar-archivo | ||||
|         [HttpPost("{anio:int}/{mes:int}/generar-archivo")] | ||||
|         [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|         public async Task<IActionResult> GenerarArchivo(int anio, int mes) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGenerarDebitos)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (contenido, nombreArchivo, error) = await _debitoService.GenerarArchivoPagoDirecto(anio, mes, userId.Value); | ||||
|  | ||||
|             if (error != null) | ||||
|             { | ||||
|                 // Si el error es "No se encontraron facturas", es un 404. Otros son 400. | ||||
|                 if (error.Contains("No se encontraron")) | ||||
|                 { | ||||
|                     return NotFound(new { message = error }); | ||||
|                 } | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|  | ||||
|             if (string.IsNullOrEmpty(contenido) || string.IsNullOrEmpty(nombreArchivo)) | ||||
|             { | ||||
|                 return StatusCode(500, new { message = "El servicio no pudo generar el contenido del archivo correctamente." }); | ||||
|             } | ||||
|  | ||||
|             // Devolver el archivo para descarga | ||||
|             var fileBytes = Encoding.UTF8.GetBytes(contenido); | ||||
|             return File(fileBytes, "text/plain", nombreArchivo); | ||||
|         } | ||||
|  | ||||
|         // POST: api/debitos/procesar-respuesta | ||||
|         [HttpPost("procesar-respuesta")] | ||||
|         [ProducesResponseType(typeof(ProcesamientoLoteResponseDto), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         public async Task<IActionResult> ProcesarArchivoRespuesta(IFormFile archivo) | ||||
|         { | ||||
|             // Usamos el mismo permiso de generar débitos para procesar la respuesta. | ||||
|             if (!TienePermiso(PermisoGenerarDebitos)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var resultado = await _debitoService.ProcesarArchivoRespuesta(archivo, userId.Value); | ||||
|  | ||||
|             if (resultado.Errores.Any() && resultado.PagosAprobados == 0 && resultado.PagosRechazados == 0) | ||||
|             { | ||||
|                 return BadRequest(resultado); | ||||
|             } | ||||
|  | ||||
|             return Ok(resultado); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,94 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/facturacion")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class FacturacionController : ControllerBase | ||||
|     { | ||||
|         private readonly IFacturacionService _facturacionService; | ||||
|         private readonly ILogger<FacturacionController> _logger; | ||||
|  | ||||
|         // Permiso para generar facturación (a crear en la BD) | ||||
|         private const string PermisoGenerarFacturacion = "SU006"; | ||||
|  | ||||
|         public FacturacionController(IFacturacionService facturacionService, ILogger<FacturacionController> logger) | ||||
|         { | ||||
|             _facturacionService = facturacionService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|  | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // POST: api/facturacion/{anio}/{mes} | ||||
|         [HttpPost("{anio:int}/{mes:int}")] | ||||
|         public async Task<IActionResult> GenerarFacturacion(int anio, int mes) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             if (anio < 2020 || mes < 1 || mes > 12) | ||||
|             { | ||||
|                 return BadRequest(new { message = "El año y el mes proporcionados no son válidos." }); | ||||
|             } | ||||
|  | ||||
|             var (exito, mensaje, facturasGeneradas) = await _facturacionService.GenerarFacturacionMensual(anio, mes, userId.Value); | ||||
|  | ||||
|             if (!exito) | ||||
|             { | ||||
|                 return StatusCode(StatusCodes.Status500InternalServerError, new { message = mensaje }); | ||||
|             } | ||||
|  | ||||
|             return Ok(new { message = mensaje, facturasGeneradas }); | ||||
|         } | ||||
|  | ||||
|         // GET: api/facturacion/{anio}/{mes} | ||||
|         [HttpGet("{anio:int}/{mes:int}")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<FacturaDto>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> GetFacturas(int anio, int mes) | ||||
|         { | ||||
|             // Usamos el permiso de generar facturación también para verlas. | ||||
|             if (!TienePermiso(PermisoGenerarFacturacion)) return Forbid(); | ||||
|  | ||||
|             if (anio < 2020 || mes < 1 || mes > 12) | ||||
|             { | ||||
|                 return BadRequest(new { message = "El período no es válido." }); | ||||
|             } | ||||
|  | ||||
|             var facturas = await _facturacionService.ObtenerFacturasPorPeriodo(anio, mes); | ||||
|             return Ok(facturas); | ||||
|         } | ||||
|  | ||||
|         // POST: api/facturacion/{idFactura}/enviar-email | ||||
|         [HttpPost("{idFactura:int}/enviar-email")] | ||||
|         [ProducesResponseType(StatusCodes.Status200OK)] | ||||
|         public async Task<IActionResult> EnviarEmail(int idFactura) | ||||
|         { | ||||
|             // Usaremos un nuevo permiso para esta acción | ||||
|             if (!TienePermiso("SU009")) return Forbid(); | ||||
|  | ||||
|             var (exito, error) = await _facturacionService.EnviarFacturaPorEmail(idFactura); | ||||
|  | ||||
|             if (!exito) | ||||
|             { | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|  | ||||
|             return Ok(new { message = "Email enviado a la cola de procesamiento." }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/pagos")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class PagosController : ControllerBase | ||||
|     { | ||||
|         private readonly IPagoService _pagoService; | ||||
|         private readonly ILogger<PagosController> _logger; | ||||
|  | ||||
|         // Permiso para registrar pagos manuales (a crear en BD) | ||||
|         private const string PermisoRegistrarPago = "SU008"; | ||||
|  | ||||
|         public PagosController(IPagoService pagoService, ILogger<PagosController> logger) | ||||
|         { | ||||
|             _pagoService = pagoService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|          | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/facturas/{idFactura}/pagos | ||||
|         [HttpGet("~/api/facturas/{idFactura:int}/pagos")] | ||||
|         [ProducesResponseType(typeof(IEnumerable<PagoDto>), StatusCodes.Status200OK)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> GetPagosPorFactura(int idFactura) | ||||
|         { | ||||
|             // Se podría usar un permiso de "Ver Facturación" | ||||
|             if (!TienePermiso("SU006")) return Forbid(); | ||||
|  | ||||
|             var pagos = await _pagoService.ObtenerPagosPorFacturaId(idFactura); | ||||
|             return Ok(pagos); | ||||
|         } | ||||
|  | ||||
|         // POST: api/pagos | ||||
|         [HttpPost] | ||||
|         [ProducesResponseType(typeof(PagoDto), StatusCodes.Status201Created)] | ||||
|         [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|         [ProducesResponseType(StatusCodes.Status403Forbidden)] | ||||
|         public async Task<IActionResult> RegistrarPago([FromBody] CreatePagoDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoRegistrarPago)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _pagoService.RegistrarPagoManual(createDto, userId.Value); | ||||
|  | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al registrar el pago."); | ||||
|  | ||||
|             // No tenemos un "GetById" para pagos, así que devolvemos el objeto con un 201. | ||||
|             return StatusCode(201, dto); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,90 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using System.Security.Claims; | ||||
|  | ||||
| namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
| { | ||||
|     [Route("api/promociones")] | ||||
|     [ApiController] | ||||
|     [Authorize] | ||||
|     public class PromocionesController : ControllerBase | ||||
|     { | ||||
|         private readonly IPromocionService _promocionService; | ||||
|         private readonly ILogger<PromocionesController> _logger; | ||||
|  | ||||
|         // Permiso a crear en BD | ||||
|         private const string PermisoGestionarPromociones = "SU010"; | ||||
|  | ||||
|         public PromocionesController(IPromocionService promocionService, ILogger<PromocionesController> logger) | ||||
|         { | ||||
|             _promocionService = promocionService; | ||||
|             _logger = logger; | ||||
|         } | ||||
|          | ||||
|         private bool TienePermiso(string codAcc) => User.IsInRole("SuperAdmin") || User.HasClaim(c => c.Type == "permission" && c.Value == codAcc); | ||||
|         private int? GetCurrentUserId() | ||||
|         { | ||||
|             if (int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"), out int userId)) return userId; | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         // GET: api/promociones | ||||
|         [HttpGet] | ||||
|         public async Task<IActionResult> GetAll([FromQuery] bool soloActivas = true) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             var promociones = await _promocionService.ObtenerTodas(soloActivas); | ||||
|             return Ok(promociones); | ||||
|         } | ||||
|  | ||||
|         // GET: api/promociones/{id} | ||||
|         [HttpGet("{id:int}", Name = "GetPromocionById")] | ||||
|         public async Task<IActionResult> GetById(int id) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             var promocion = await _promocionService.ObtenerPorId(id); | ||||
|             if (promocion == null) return NotFound(); | ||||
|             return Ok(promocion); | ||||
|         } | ||||
|  | ||||
|         // POST: api/promociones | ||||
|         [HttpPost] | ||||
|         public async Task<IActionResult> Create([FromBody] CreatePromocionDto createDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|              | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (dto, error) = await _promocionService.Crear(createDto, userId.Value); | ||||
|              | ||||
|             if (error != null) return BadRequest(new { message = error }); | ||||
|             if (dto == null) return StatusCode(500, "Error al crear la promoción."); | ||||
|              | ||||
|             return CreatedAtRoute("GetPromocionById", new { id = dto.IdPromocion }, dto); | ||||
|         } | ||||
|  | ||||
|         // PUT: api/promociones/{id} | ||||
|         [HttpPut("{id:int}")] | ||||
|         public async Task<IActionResult> Update(int id, [FromBody] UpdatePromocionDto updateDto) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarPromociones)) return Forbid(); | ||||
|             if (!ModelState.IsValid) return BadRequest(ModelState); | ||||
|  | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _promocionService.Actualizar(id, updateDto, userId.Value); | ||||
|              | ||||
|             if (!exito) | ||||
|             { | ||||
|                 if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error }); | ||||
|                 return BadRequest(new { message = error }); | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -93,5 +93,46 @@ namespace GestionIntegral.Api.Controllers.Suscripciones | ||||
|             } | ||||
|             return NoContent(); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscripciones/{idSuscripcion}/promociones | ||||
|         [HttpGet("{idSuscripcion:int}/promociones")] | ||||
|         public async Task<IActionResult> GetPromocionesAsignadas(int idSuscripcion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var promos = await _suscripcionService.ObtenerPromocionesAsignadas(idSuscripcion); | ||||
|             return Ok(promos); | ||||
|         } | ||||
|  | ||||
|         // GET: api/suscripciones/{idSuscripcion}/promociones-disponibles | ||||
|         [HttpGet("{idSuscripcion:int}/promociones-disponibles")] | ||||
|         public async Task<IActionResult> GetPromocionesDisponibles(int idSuscripcion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var promos = await _suscripcionService.ObtenerPromocionesDisponibles(idSuscripcion); | ||||
|             return Ok(promos); | ||||
|         } | ||||
|  | ||||
|         // POST: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} | ||||
|         [HttpPost("{idSuscripcion:int}/promociones/{idPromocion:int}")] | ||||
|         public async Task<IActionResult> AsignarPromocion(int idSuscripcion, int idPromocion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var userId = GetCurrentUserId(); | ||||
|             if (userId == null) return Unauthorized(); | ||||
|  | ||||
|             var (exito, error) = await _suscripcionService.AsignarPromocion(idSuscripcion, idPromocion, userId.Value); | ||||
|             if (!exito) return BadRequest(new { message = error }); | ||||
|             return Ok(); | ||||
|         } | ||||
|  | ||||
|         // DELETE: api/suscripciones/{idSuscripcion}/promociones/{idPromocion} | ||||
|         [HttpDelete("{idSuscripcion:int}/promociones/{idPromocion:int}")] | ||||
|         public async Task<IActionResult> QuitarPromocion(int idSuscripcion, int idPromocion) | ||||
|         { | ||||
|             if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); | ||||
|             var (exito, error) = await _suscripcionService.QuitarPromocion(idSuscripcion, idPromocion); | ||||
|             if (!exito) return BadRequest(new { message = error }); | ||||
|             return NoContent(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -34,7 +34,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|         public async Task<Factura?> GetBySuscripcionYPeriodoAsync(int idSuscripcion, string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = "SELECT TOP 1 * FROM dbo.susc_Facturas WHERE IdSuscripcion = @IdSuscripcion AND Periodo = @Periodo;"; | ||||
|              if (transaction == null || transaction.Connection == null) | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
| @@ -91,5 +91,55 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { IdLoteDebito = idLoteDebito, IdsFacturas = idsFacturas }, transaction); | ||||
|             return rowsAffected == idsFacturas.Count(); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                         SELECT  f.*, s.NombreCompleto AS NombreSuscriptor, p.Nombre AS NombrePublicacion | ||||
|                         FROM dbo.susc_Facturas f | ||||
|                         JOIN dbo.susc_Suscripciones sc ON f.IdSuscripcion = sc.IdSuscripcion | ||||
|                         JOIN dbo.susc_Suscriptores s ON sc.IdSuscriptor = s.IdSuscriptor | ||||
|                         JOIN dbo.dist_dtPublicaciones p ON sc.IdPublicacion = p.Id_Publicacion | ||||
|                         WHERE f.Periodo = @Periodo | ||||
|                         ORDER BY s.NombreCompleto; | ||||
|                     "; | ||||
|             try | ||||
|             { | ||||
|                 using var connection = _connectionFactory.CreateConnection(); | ||||
|                 var result = await connection.QueryAsync<Factura, string, string, (Factura, string, string)>( | ||||
|                     sql, | ||||
|                     (factura, suscriptor, publicacion) => (factura, suscriptor, publicacion), | ||||
|                     new { Periodo = periodo }, | ||||
|                     splitOn: "NombreSuscriptor,NombrePublicacion" | ||||
|                 ); | ||||
|                 return result; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al obtener facturas enriquecidas para el período {Periodo}", periodo); | ||||
|                 return Enumerable.Empty<(Factura, string, string)>(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|  | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Facturas SET | ||||
|                     Estado = @NuevoEstado, | ||||
|                     MotivoRechazo = @MotivoRechazo | ||||
|                 WHERE IdFactura = @IdFactura;"; | ||||
|  | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync( | ||||
|                 sql, | ||||
|                 new { NuevoEstado = nuevoEstado, MotivoRechazo = motivoRechazo, IdFactura = idFactura }, | ||||
|                 transaction | ||||
|             ); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -12,5 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|         Task<bool> UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction); | ||||
|         Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); | ||||
|         Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); | ||||
|         Task<IEnumerable<(Factura Factura, string NombreSuscriptor, string NombrePublicacion)>> GetByPeriodoEnrichedAsync(string periodo); | ||||
|         Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public interface IPromocionRepository | ||||
|     { | ||||
|         Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas); | ||||
|         Task<Promocion?> GetByIdAsync(int id); | ||||
|         Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction); | ||||
|     } | ||||
| } | ||||
| @@ -10,5 +10,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|         Task<Suscripcion?> CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction); | ||||
|         Task<bool> UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Suscripcion>> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); | ||||
|         Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); | ||||
|         Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction); | ||||
|         Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| using Dapper; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
| using System.Text; | ||||
|  | ||||
| namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
| { | ||||
|     public class PromocionRepository : IPromocionRepository | ||||
|     { | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<PromocionRepository> _logger; | ||||
|  | ||||
|         public PromocionRepository(DbConnectionFactory factory, ILogger<PromocionRepository> logger) | ||||
|         { | ||||
|             _connectionFactory = factory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Promocion>> GetAllAsync(bool soloActivas) | ||||
|         { | ||||
|             var sql = new StringBuilder("SELECT * FROM dbo.susc_Promociones"); | ||||
|             if(soloActivas) | ||||
|             { | ||||
|                 sql.Append(" WHERE Activa = 1"); | ||||
|             } | ||||
|             sql.Append(" ORDER BY FechaInicio DESC;"); | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Promocion>(sql.ToString()); | ||||
|         } | ||||
|  | ||||
|         public async Task<Promocion?> GetByIdAsync(int id) | ||||
|         { | ||||
|             const string sql = "SELECT * FROM dbo.susc_Promociones WHERE IdPromocion = @Id;"; | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QuerySingleOrDefaultAsync<Promocion>(sql, new { Id = id }); | ||||
|         } | ||||
|  | ||||
|         public async Task<Promocion?> CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 INSERT INTO dbo.susc_Promociones (Descripcion, TipoPromocion, Valor, FechaInicio, FechaFin, Activa, IdUsuarioAlta, FechaAlta) | ||||
|                 OUTPUT INSERTED.* | ||||
|                 VALUES (@Descripcion, @TipoPromocion, @Valor, @FechaInicio, @FechaFin, @Activa, @IdUsuarioAlta, GETDATE());"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             return await transaction.Connection.QuerySingleAsync<Promocion>(sql, nuevaPromocion, transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> UpdateAsync(Promocion promocion, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 UPDATE dbo.susc_Promociones SET | ||||
|                     Descripcion = @Descripcion, | ||||
|                     TipoPromocion = @TipoPromocion, | ||||
|                     Valor = @Valor, | ||||
|                     FechaInicio = @FechaInicio, | ||||
|                     FechaFin = @FechaFin, | ||||
|                     Activa = @Activa | ||||
|                 WHERE IdPromocion = @IdPromocion;"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, promocion, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction) | ||||
|         { | ||||
|             const string sql = @" | ||||
|                 SELECT p.* FROM dbo.susc_Promociones p | ||||
|                 JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion | ||||
|                 WHERE sp.IdSuscripcion = @IdSuscripcion | ||||
|                   AND p.Activa = 1 | ||||
|                   AND p.FechaInicio <= @FechaPeriodo | ||||
|                   AND (p.FechaFin IS NULL OR p.FechaFin >= @FechaPeriodo);"; | ||||
|  | ||||
|             if (transaction?.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction.Connection), "La conexión de la transacción no puede ser nula."); | ||||
|             } | ||||
|  | ||||
|             return await transaction.Connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -111,5 +111,42 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones | ||||
|             var rowsAffected = await transaction.Connection.ExecuteAsync(sqlUpdate, suscripcion, transaction); | ||||
|             return rowsAffected == 1; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<Promocion>> GetPromocionesBySuscripcionIdAsync(int idSuscripcion) | ||||
|         { | ||||
|             const string sql = @" | ||||
|         SELECT p.* FROM dbo.susc_Promociones p | ||||
|         JOIN dbo.susc_SuscripcionPromociones sp ON p.IdPromocion = sp.IdPromocion | ||||
|         WHERE sp.IdSuscripcion = @IdSuscripcion;"; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             return await connection.QueryAsync<Promocion>(sql, new { IdSuscripcion = idSuscripcion }); | ||||
|         } | ||||
|  | ||||
|         public async Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = @" | ||||
|         INSERT INTO dbo.susc_SuscripcionPromociones (IdSuscripcion, IdPromocion, IdUsuarioAsigno) | ||||
|         VALUES (@IdSuscripcion, @IdPromocion, @IdUsuario);"; | ||||
|  | ||||
|             await transaction.Connection.ExecuteAsync(sql, | ||||
|                 new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion, IdUsuario = idUsuario }, | ||||
|                 transaction); | ||||
|         } | ||||
|  | ||||
|         public async Task<bool> QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction) | ||||
|         { | ||||
|             if (transaction == null || transaction.Connection == null) | ||||
|             { | ||||
|                 throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); | ||||
|             } | ||||
|             const string sql = "DELETE FROM dbo.susc_SuscripcionPromociones WHERE IdSuscripcion = @IdSuscripcion AND IdPromocion = @IdPromocion;"; | ||||
|             var rows = await transaction.Connection.ExecuteAsync(sql, new { IdSuscripcion = idSuscripcion, IdPromocion = idPromocion }, transaction); | ||||
|             return rows == 1; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -9,6 +9,7 @@ | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> | ||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> | ||||
|     <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> | ||||
|   | ||||
| @@ -0,0 +1,12 @@ | ||||
| namespace GestionIntegral.Api.Models.Comunicaciones | ||||
| { | ||||
|     public class MailSettings | ||||
|     { | ||||
|         public string SmtpHost { get; set; } = string.Empty; | ||||
|         public int SmtpPort { get; set; } | ||||
|         public string SenderName { get; set; } = string.Empty; | ||||
|         public string SenderEmail { get; set; } = string.Empty; | ||||
|         public string SmtpUser { get; set; } = string.Empty; | ||||
|         public string SmtpPass { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,29 @@ | ||||
| // Archivo: GestionIntegral.Api/Dtos/Suscripciones/CreatePagoDto.cs | ||||
|  | ||||
| using System; | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreatePagoDto | ||||
|     { | ||||
|         [Required] | ||||
|         public int IdFactura { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaPago { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "Debe seleccionar una forma de pago.")] | ||||
|         public int IdFormaPago { get; set; } | ||||
|  | ||||
|         [Required(ErrorMessage = "El monto es obligatorio.")] | ||||
|         [Range(0.01, 99999999.99, ErrorMessage = "El monto debe ser un valor positivo.")] | ||||
|         public decimal Monto { get; set; } | ||||
|  | ||||
|         [StringLength(100)] | ||||
|         public string? Referencia { get; set; } // Nro. de comprobante, etc. | ||||
|  | ||||
|         [StringLength(250)] | ||||
|         public string? Observaciones { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
|  | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class CreatePromocionDto | ||||
|     { | ||||
|         [Required] | ||||
|         [StringLength(200)] | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] | ||||
|         public string TipoPromocion { get; set; } = string.Empty; | ||||
|  | ||||
|         [Required] | ||||
|         [Range(0.01, 99999999.99)] | ||||
|         public decimal Valor { get; set; } | ||||
|  | ||||
|         [Required] | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } = true; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // UpdatePromocionDto puede ser idéntico al de creación por ahora | ||||
| using UpdatePromocionDto = CreatePromocionDto; | ||||
| @@ -0,0 +1,22 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     /// <summary> | ||||
|     /// DTO para enviar la información de una factura generada al frontend. | ||||
|     /// Incluye datos enriquecidos como nombres para facilitar su visualización en la UI. | ||||
|     /// </summary> | ||||
|     public class FacturaDto | ||||
|     { | ||||
|         public int IdFactura { get; set; } | ||||
|         public int IdSuscripcion { get; set; } | ||||
|         public string Periodo { get; set; } = string.Empty; // Formato "YYYY-MM" | ||||
|         public string FechaEmision { get; set; } = string.Empty; // Formato "yyyy-MM-dd" | ||||
|         public string FechaVencimiento { get; set; } = string.Empty; // Formato "yyyy-MM-dd" | ||||
|         public decimal ImporteFinal { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; | ||||
|         public string? NumeroFactura { get; set; } | ||||
|  | ||||
|         // Datos enriquecidos para la UI, poblados por el servicio | ||||
|         public string NombreSuscriptor { get; set; } = string.Empty; | ||||
|         public string NombrePublicacion { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class PagoDto | ||||
|     { | ||||
|         public int IdPago { get; set; } | ||||
|         public int IdFactura { get; set; } | ||||
|         public string FechaPago { get; set; } = string.Empty; // "yyyy-MM-dd" | ||||
|         public int IdFormaPago { get; set; } | ||||
|         public string NombreFormaPago { get; set; } = string.Empty; // Enriquecido | ||||
|         public decimal Monto { get; set; } | ||||
|         public string Estado { get; set; } = string.Empty; // "Aprobado", "Rechazado" | ||||
|         public string? Referencia { get; set; } | ||||
|         public string? Observaciones { get; set; } | ||||
|         public int IdUsuarioRegistro { get; set; } | ||||
|         public string NombreUsuarioRegistro { get; set; } = string.Empty; // Enriquecido | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class ProcesamientoLoteResponseDto | ||||
|     { | ||||
|         public int TotalRegistrosLeidos { get; set; } | ||||
|         public int PagosAprobados { get; set; } | ||||
|         public int PagosRechazados { get; set; } | ||||
|         public List<string> Errores { get; set; } = new List<string>(); | ||||
|         public string MensajeResumen { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| namespace GestionIntegral.Api.Dtos.Suscripciones | ||||
| { | ||||
|     public class PromocionDto | ||||
|     { | ||||
|         public int IdPromocion { get; set; } | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|         public string TipoPromocion { get; set; } = string.Empty; | ||||
|         public decimal Valor { get; set; } | ||||
|         public string FechaInicio { get; set; } = string.Empty; // yyyy-MM-dd | ||||
|         public string? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class Promocion | ||||
|     { | ||||
|         public int IdPromocion { get; set; } | ||||
|         public string Descripcion { get; set; } = string.Empty; | ||||
|         public string TipoPromocion { get; set; } = string.Empty; | ||||
|         public decimal Valor { get; set; } | ||||
|         public DateTime FechaInicio { get; set; } | ||||
|         public DateTime? FechaFin { get; set; } | ||||
|         public bool Activa { get; set; } | ||||
|         public int IdUsuarioAlta { get; set; } | ||||
|         public DateTime FechaAlta { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| namespace GestionIntegral.Api.Models.Suscripciones | ||||
| { | ||||
|     public class SuscripcionPromocion | ||||
|     { | ||||
|         public int IdSuscripcion { get; set; } | ||||
|         public int IdPromocion { get; set; } | ||||
|         public DateTime FechaAsignacion { get; set; } | ||||
|         public int IdUsuarioAsigno { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -20,6 +20,8 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; | ||||
| using GestionIntegral.Api.Services.Anomalia; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Services.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
| using GestionIntegral.Api.Services.Comunicaciones; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -103,18 +105,25 @@ builder.Services.AddScoped<IQuestPdfGenerator, QuestPdfGenerator>(); | ||||
| builder.Services.AddScoped<IAlertaService, AlertaService>(); | ||||
|  | ||||
| // --- Suscripciones --- | ||||
| // Repositorios | ||||
| builder.Services.AddScoped<IFormaPagoRepository, FormaPagoRepository>(); | ||||
| builder.Services.AddScoped<ISuscriptorRepository, SuscriptorRepository>(); | ||||
| builder.Services.AddScoped<ISuscripcionRepository, SuscripcionRepository>(); | ||||
| builder.Services.AddScoped<IFacturaRepository, FacturaRepository>(); | ||||
| builder.Services.AddScoped<ILoteDebitoRepository, LoteDebitoRepository>(); | ||||
| builder.Services.AddScoped<IPagoRepository, PagoRepository>(); | ||||
| builder.Services.AddScoped<IPromocionRepository, PromocionRepository>(); | ||||
|  | ||||
| // Servicios | ||||
| builder.Services.AddScoped<IFormaPagoService, FormaPagoService>(); | ||||
| builder.Services.AddScoped<ISuscriptorService, SuscriptorService>(); | ||||
| builder.Services.AddScoped<ISuscripcionService, SuscripcionService>(); | ||||
| builder.Services.AddScoped<IFacturacionService, FacturacionService>(); | ||||
| builder.Services.AddScoped<IDebitoAutomaticoService, DebitoAutomaticoService>(); | ||||
| builder.Services.AddScoped<IPagoService, PagoService>(); | ||||
| builder.Services.AddScoped<IPromocionService, PromocionService>(); | ||||
|  | ||||
| // --- Comunicaciones --- | ||||
| builder.Services.Configure<MailSettings>(builder.Configuration.GetSection("MailSettings")); | ||||
| builder.Services.AddTransient<IEmailService, EmailService>(); | ||||
|  | ||||
| // --- SERVICIO DE HEALTH CHECKS --- | ||||
| // Añadimos una comprobación específica para SQL Server. | ||||
|   | ||||
| @@ -0,0 +1,50 @@ | ||||
| using GestionIntegral.Api.Models.Comunicaciones; | ||||
| using MailKit.Net.Smtp; | ||||
| using MailKit.Security; | ||||
| using Microsoft.Extensions.Options; | ||||
| using MimeKit; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|     public class EmailService : IEmailService | ||||
|     { | ||||
|         private readonly MailSettings _mailSettings; | ||||
|         private readonly ILogger<EmailService> _logger; | ||||
|  | ||||
|         public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger) | ||||
|         { | ||||
|             _mailSettings = mailSettings.Value; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml) | ||||
|         { | ||||
|             var email = new MimeMessage(); | ||||
|             email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail); | ||||
|             email.From.Add(email.Sender); | ||||
|             email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail)); | ||||
|             email.Subject = asunto; | ||||
|  | ||||
|             var builder = new BodyBuilder { HtmlBody = cuerpoHtml }; | ||||
|             email.Body = builder.ToMessageBody(); | ||||
|  | ||||
|             using var smtp = new SmtpClient(); | ||||
|             try | ||||
|             { | ||||
|                 await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); | ||||
|                 await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); | ||||
|                 await smtp.SendAsync(email); | ||||
|                 _logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 _logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail); | ||||
|                 throw; // Relanzar para que el servicio que lo llamó sepa que falló | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 await smtp.DisconnectAsync(true); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| namespace GestionIntegral.Api.Services.Comunicaciones | ||||
| { | ||||
|     public interface IEmailService | ||||
|     { | ||||
|         Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,307 @@ | ||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs | ||||
|  | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Data; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using System.Collections.Generic; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class DebitoAutomaticoService : IDebitoAutomaticoService | ||||
|     { | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly ISuscripcionRepository _suscripcionRepository; | ||||
|         private readonly ILoteDebitoRepository _loteDebitoRepository; | ||||
|         private readonly IFormaPagoRepository _formaPagoRepository; | ||||
|         private readonly IPagoRepository _pagoRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||
|  | ||||
|         // --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) --- | ||||
|         private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA";   // Nombre de la empresa (7 chars) | ||||
|  | ||||
|         public DebitoAutomaticoService( | ||||
|             IFacturaRepository facturaRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|             ILoteDebitoRepository loteDebitoRepository, | ||||
|             IFormaPagoRepository formaPagoRepository, | ||||
|             IPagoRepository pagoRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<DebitoAutomaticoService> logger) | ||||
|         { | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _loteDebitoRepository = loteDebitoRepository; | ||||
|             _formaPagoRepository = formaPagoRepository; | ||||
|             _pagoRepository = pagoRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var fechaGeneracion = DateTime.Now; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // Buscamos facturas que están listas para ser enviadas al cobro. | ||||
|                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); | ||||
|  | ||||
|                 if (!facturasParaDebito.Any()) | ||||
|                 { | ||||
|                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); | ||||
|                 } | ||||
|  | ||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||
|                 var cantidadRegistros = facturasParaDebito.Count(); | ||||
|                 var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; | ||||
|  | ||||
|                 // 1. Crear el Lote de Débito | ||||
|                 var nuevoLote = new LoteDebito | ||||
|                 { | ||||
|                     Periodo = periodo, | ||||
|                     NombreArchivo = nombreArchivo, | ||||
|                     ImporteTotal = importeTotal, | ||||
|                     CantidadRegistros = cantidadRegistros, | ||||
|                     IdUsuarioGeneracion = idUsuario | ||||
|                 }; | ||||
|                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); | ||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||
|  | ||||
|                 // 2. Generar el contenido del archivo | ||||
|                 var sb = new StringBuilder(); | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|  | ||||
|                 foreach (var item in facturasParaDebito) | ||||
|                 { | ||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||
|                 } | ||||
|  | ||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|  | ||||
|                 // 3. Actualizar las facturas con el ID del lote | ||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||
|                 bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); | ||||
|                 if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Archivo de débito {NombreArchivo} generado exitosamente para el período {Periodo}.", nombreArchivo, periodo); | ||||
|                 return (sb.ToString(), nombreArchivo, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al generar el archivo de débito para el período {Periodo}", periodo); | ||||
|                 return (null, null, $"Error interno: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             // Idealmente, esto debería estar en el repositorio para optimizar la consulta. | ||||
|             // Por simplicidad del ejemplo, lo hacemos aquí. | ||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             var resultado = new List<(Factura, Suscriptor)>(); | ||||
|  | ||||
|             foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro")) | ||||
|             { | ||||
|                 var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion); | ||||
|                 if (suscripcion == null) continue; | ||||
|  | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); | ||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||
|                 if (formaPago != null && formaPago.RequiereCBU) | ||||
|                 { | ||||
|                     resultado.Add((f, suscriptor)); | ||||
|                 } | ||||
|             } | ||||
|             return resultado; | ||||
|         } | ||||
|  | ||||
|         // --- Métodos de Formateo de Campos --- | ||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length); | ||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||
|  | ||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("00"); // Tipo de Registro | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día) | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("0101"); // Tipo de Registro | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente | ||||
|             sb.Append(FormatString(suscriptor.CBU, 26)); // CBU | ||||
|  | ||||
|             // Referencia Unívoca: Usaremos ID Factura para asegurar unicidad | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||
|  | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto | ||||
|  | ||||
|             // Campos opcionales o con valores fijos | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto | ||||
|             sb.Append("0"); // Moneda (0 = Pesos) | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo | ||||
|             sb.Append(FormatString(suscriptor.TipoDocumento, 4)); | ||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||
|  | ||||
|             // El resto son campos opcionales que rellenamos con espacios/ceros | ||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||
|             sb.Append(FormatNumeric(0, 26)); // Nuevo CBU | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto | ||||
|             sb.Append(FormatString("", 22)); // ID Cuenta Anterior | ||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||
|             sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación | ||||
|             sb.Append(FormatString("", 26)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("99"); // Tipo de Registro | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             // No se añade \r\n al final del último registro | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) | ||||
|         { | ||||
|             var respuesta = new ProcesamientoLoteResponseDto(); | ||||
|             if (archivo == null || archivo.Length == 0) | ||||
|             { | ||||
|                 respuesta.Errores.Add("No se proporcionó ningún archivo o el archivo está vacío."); | ||||
|                 return respuesta; | ||||
|             } | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var reader = new StreamReader(archivo.OpenReadStream()); | ||||
|                 string? linea; | ||||
|                 while ((linea = await reader.ReadLineAsync()) != null) | ||||
|                 { | ||||
|                     // Ignorar header/trailer si los hubiera (basado en el formato real) | ||||
|                     if (linea.Length < 20) continue; | ||||
|  | ||||
|                     respuesta.TotalRegistrosLeidos++; | ||||
|  | ||||
|                     // ================================================================= | ||||
|                     // === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR === | ||||
|                     // === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA    === | ||||
|                     // ================================================================= | ||||
|                     // Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo | ||||
|                     var referencia = linea.Substring(0, 15).Trim(); | ||||
|                     var estadoProceso = linea.Substring(15, 2).Trim(); | ||||
|                     var motivoRechazo = linea.Substring(17, 3).Trim(); | ||||
|                     // Asumimos que podemos extraer el IdFactura de la referencia | ||||
|                     if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura)) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'."); | ||||
|                         continue; | ||||
|                     } | ||||
|                     // ================================================================= | ||||
|                     // === FIN DE LA LÓGICA DE PARSEO                         === | ||||
|                     // ================================================================= | ||||
|  | ||||
|                     var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                     if (factura == null) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: La factura con ID {idFactura} no fue encontrada en el sistema."); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var nuevoPago = new Pago | ||||
|                     { | ||||
|                         IdFactura = idFactura, | ||||
|                         FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo | ||||
|                         IdFormaPago = 1, // 1 = Débito Automático | ||||
|                         Monto = factura.ImporteFinal, | ||||
|                         IdUsuarioRegistro = idUsuario, | ||||
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" | ||||
|                     }; | ||||
|  | ||||
|                     if (estadoProceso == "AP") // "AP" = Aprobado (Asunción) | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Aprobado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction); | ||||
|                         respuesta.PagosAprobados++; | ||||
|                     } | ||||
|                     else // Asumimos que cualquier otra cosa es Rechazado | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Rechazado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         factura.Estado = "Rechazada"; | ||||
|                         factura.MotivoRechazo = motivoRechazo; | ||||
|                         // Necesitamos un método en el repo para actualizar estado y motivo | ||||
|                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction); | ||||
|                         respuesta.PagosRechazados++; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 respuesta.MensajeResumen = $"Archivo procesado. Leídos: {respuesta.TotalRegistrosLeidos}, Aprobados: {respuesta.PagosAprobados}, Rechazados: {respuesta.PagosRechazados}."; | ||||
|                 _logger.LogInformation(respuesta.MensajeResumen); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al procesar archivo de respuesta de débito."); | ||||
|                 respuesta.Errores.Add($"Error fatal en el procesamiento: {ex.Message}"); | ||||
|             } | ||||
|  | ||||
|             return respuesta; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IDebitoAutomaticoService | ||||
|     { | ||||
|         Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario); | ||||
|         Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IFacturacionService | ||||
|     { | ||||
|         Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); | ||||
|         Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes); | ||||
|         Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/IPagoService.cs | ||||
|  | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading.Tasks; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IPagoService | ||||
|     { | ||||
|         Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura); | ||||
|         Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public interface IPromocionService | ||||
|     { | ||||
|         Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas); | ||||
|         Task<PromocionDto?> ObtenerPorId(int id); | ||||
|         Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario); | ||||
|     } | ||||
| } | ||||
| @@ -8,5 +8,9 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|         Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion); | ||||
|         Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario); | ||||
|         Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion); | ||||
|         Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion); | ||||
|         Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario); | ||||
|         Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,118 @@ | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Data.Repositories.Usuarios; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class PagoService : IPagoService | ||||
|     { | ||||
|         private readonly IPagoRepository _pagoRepository; | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly IFormaPagoRepository _formaPagoRepository; | ||||
|         private readonly IUsuarioRepository _usuarioRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<PagoService> _logger; | ||||
|  | ||||
|         public PagoService( | ||||
|             IPagoRepository pagoRepository, | ||||
|             IFacturaRepository facturaRepository, | ||||
|             IFormaPagoRepository formaPagoRepository, | ||||
|             IUsuarioRepository usuarioRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<PagoService> logger) | ||||
|         { | ||||
|             _pagoRepository = pagoRepository; | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _formaPagoRepository = formaPagoRepository; | ||||
|             _usuarioRepository = usuarioRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private async Task<PagoDto?> MapToDto(Pago pago) | ||||
|         { | ||||
|             if (pago == null) return null; | ||||
|             var formaPago = await _formaPagoRepository.GetByIdAsync(pago.IdFormaPago); | ||||
|             var usuario = await _usuarioRepository.GetByIdAsync(pago.IdUsuarioRegistro); | ||||
|             return new PagoDto | ||||
|             { | ||||
|                 IdPago = pago.IdPago, | ||||
|                 IdFactura = pago.IdFactura, | ||||
|                 FechaPago = pago.FechaPago.ToString("yyyy-MM-dd"), | ||||
|                 IdFormaPago = pago.IdFormaPago, | ||||
|                 NombreFormaPago = formaPago?.Nombre ?? "Desconocida", | ||||
|                 Monto = pago.Monto, | ||||
|                 Estado = pago.Estado, | ||||
|                 Referencia = pago.Referencia, | ||||
|                 Observaciones = pago.Observaciones, | ||||
|                 IdUsuarioRegistro = pago.IdUsuarioRegistro, | ||||
|                 NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "Usuario Desconocido" | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura) | ||||
|         { | ||||
|             var pagos = await _pagoRepository.GetByFacturaIdAsync(idFactura); | ||||
|             var dtosTasks = pagos.Select(p => MapToDto(p)); | ||||
|             var dtos = await Task.WhenAll(dtosTasks); | ||||
|             return dtos.Where(dto => dto != null)!; | ||||
|         } | ||||
|  | ||||
|         public async Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario) | ||||
|         { | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); | ||||
|                 if (factura == null) return (null, "La factura especificada no existe."); | ||||
|                 if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada."); | ||||
|                 if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); | ||||
|                 if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); | ||||
|  | ||||
|                 var nuevoPago = new Pago | ||||
|                 { | ||||
|                     IdFactura = createDto.IdFactura, | ||||
|                     FechaPago = createDto.FechaPago, | ||||
|                     IdFormaPago = createDto.IdFormaPago, | ||||
|                     Monto = createDto.Monto, | ||||
|                     Estado = "Aprobado", // Los pagos manuales se asumen aprobados | ||||
|                     Referencia = createDto.Referencia, | ||||
|                     Observaciones = createDto.Observaciones, | ||||
|                     IdUsuarioRegistro = idUsuario | ||||
|                 }; | ||||
|  | ||||
|                 // 1. Crear el registro del pago | ||||
|                 var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                 if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); | ||||
|  | ||||
|                 // 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura | ||||
|                 // (Permitimos pago mayor por si hay redondeos, etc.) | ||||
|                 if (pagoCreado.Monto >= factura.ImporteFinal) | ||||
|                 { | ||||
|                     bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction); | ||||
|                     if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); | ||||
|                 } | ||||
|                  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); | ||||
|                  | ||||
|                 var dto = await MapToDto(pagoCreado); | ||||
|                 return (dto, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al registrar pago manual para Factura ID {IdFactura}", createDto.IdFactura); | ||||
|                 return (null, $"Error interno al registrar el pago: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,127 @@ | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using System.Data; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class PromocionService : IPromocionService | ||||
|     { | ||||
|         private readonly IPromocionRepository _promocionRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<PromocionService> _logger; | ||||
|  | ||||
|         public PromocionService( | ||||
|             IPromocionRepository promocionRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<PromocionService> logger) | ||||
|         { | ||||
|             _promocionRepository = promocionRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private PromocionDto MapToDto(Promocion promo) | ||||
|         { | ||||
|             return new PromocionDto | ||||
|             { | ||||
|                 IdPromocion = promo.IdPromocion, | ||||
|                 Descripcion = promo.Descripcion, | ||||
|                 TipoPromocion = promo.TipoPromocion, | ||||
|                 Valor = promo.Valor, | ||||
|                 FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"), | ||||
|                 FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"), | ||||
|                 Activa = promo.Activa | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas) | ||||
|         { | ||||
|             var promociones = await _promocionRepository.GetAllAsync(soloActivas); | ||||
|             return promociones.Select(MapToDto); | ||||
|         } | ||||
|  | ||||
|         public async Task<PromocionDto?> ObtenerPorId(int id) | ||||
|         { | ||||
|             var promocion = await _promocionRepository.GetByIdAsync(id); | ||||
|             return promocion != null ? MapToDto(promocion) : null; | ||||
|         } | ||||
|  | ||||
|         public async Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario) | ||||
|         { | ||||
|             if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio) | ||||
|             { | ||||
|                 return (null, "La fecha de fin no puede ser anterior a la fecha de inicio."); | ||||
|             } | ||||
|  | ||||
|             var nuevaPromocion = new Promocion | ||||
|             { | ||||
|                 Descripcion = createDto.Descripcion, | ||||
|                 TipoPromocion = createDto.TipoPromocion, | ||||
|                 Valor = createDto.Valor, | ||||
|                 FechaInicio = createDto.FechaInicio, | ||||
|                 FechaFin = createDto.FechaFin, | ||||
|                 Activa = createDto.Activa, | ||||
|                 IdUsuarioAlta = idUsuario | ||||
|             }; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 var promocionCreada = await _promocionRepository.CreateAsync(nuevaPromocion, transaction); | ||||
|                 if (promocionCreada == null) throw new DataException("Error al crear la promoción."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Promoción ID {Id} creada por Usuario ID {UserId}.", promocionCreada.IdPromocion, idUsuario); | ||||
|                 return (MapToDto(promocionCreada), null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al crear promoción: {Descripcion}", createDto.Descripcion); | ||||
|                 return (null, $"Error interno al crear la promoción: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario) | ||||
|         { | ||||
|             var existente = await _promocionRepository.GetByIdAsync(id); | ||||
|             if (existente == null) return (false, "Promoción no encontrada."); | ||||
|  | ||||
|             if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio) | ||||
|             { | ||||
|                 return (false, "La fecha de fin no puede ser anterior a la fecha de inicio."); | ||||
|             } | ||||
|  | ||||
|             // Mapeo | ||||
|             existente.Descripcion = updateDto.Descripcion; | ||||
|             existente.TipoPromocion = updateDto.TipoPromocion; | ||||
|             existente.Valor = updateDto.Valor; | ||||
|             existente.FechaInicio = updateDto.FechaInicio; | ||||
|             existente.FechaFin = updateDto.FechaFin; | ||||
|             existente.Activa = updateDto.Activa; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 var actualizado = await _promocionRepository.UpdateAsync(existente, transaction); | ||||
|                 if (!actualizado) throw new DataException("Error al actualizar la promoción."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Promoción ID {Id} actualizada por Usuario ID {UserId}.", id, idUsuario); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error al actualizar promoción ID: {Id}", id); | ||||
|                 return (false, $"Error interno al actualizar: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -12,6 +12,7 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|         private readonly ISuscripcionRepository _suscripcionRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly IPublicacionRepository _publicacionRepository; | ||||
|         private readonly IPromocionRepository _promocionRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<SuscripcionService> _logger; | ||||
|  | ||||
| @@ -19,16 +20,29 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             IPublicacionRepository publicacionRepository, | ||||
|             IPromocionRepository promocionRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<SuscripcionService> logger) | ||||
|         { | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _publicacionRepository = publicacionRepository; | ||||
|             _promocionRepository = promocionRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto | ||||
|         { | ||||
|             IdPromocion = promo.IdPromocion, | ||||
|             Descripcion = promo.Descripcion, | ||||
|             TipoPromocion = promo.TipoPromocion, | ||||
|             Valor = promo.Valor, | ||||
|             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; | ||||
| @@ -139,5 +153,71 @@ namespace GestionIntegral.Api.Services.Suscripciones | ||||
|                 return (false, $"Error interno: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion) | ||||
|         { | ||||
|             var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); | ||||
|             return promociones.Select(MapPromocionToDto); | ||||
|         } | ||||
|  | ||||
|         public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion) | ||||
|         { | ||||
|             var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true); | ||||
|             var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); | ||||
|             var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet(); | ||||
|  | ||||
|             return todasLasPromosActivas | ||||
|                 .Where(p => !idsAsignadas.Contains(p.IdPromocion)) | ||||
|                 .Select(MapPromocionToDto); | ||||
|         } | ||||
|  | ||||
|         public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario) | ||||
|         { | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|             try | ||||
|             { | ||||
|                 // Validaciones | ||||
|                 if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada."); | ||||
|                 if (await _promocionRepository.GetByIdAsync(idPromocion) == null) return (false, "Promoción no encontrada."); | ||||
|  | ||||
|                 await _suscripcionRepository.AsignarPromocionAsync(idSuscripcion, idPromocion, idUsuario, transaction); | ||||
|                 transaction.Commit(); | ||||
|                 return (true, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 // Capturar error de Primary Key duplicada | ||||
|                 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}", 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."); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -11,5 +11,13 @@ | ||||
|     "Audience": "GestionIntegralClient", | ||||
|     "DurationInHours": 8 | ||||
|   }, | ||||
|   "AllowedHosts": "*" | ||||
|   "AllowedHosts": "*", | ||||
|   "MailSettings": { | ||||
|   "SmtpHost": "smtp.yourprovider.com", | ||||
|   "SmtpPort": 587, | ||||
|   "SenderName": "Diario El Día - Suscripciones", | ||||
|   "SenderEmail": "suscripciones@eldia.com", | ||||
|   "SmtpUser": "your-smtp-username", | ||||
|   "SmtpPass": "your-smtp-password" | ||||
| } | ||||
| } | ||||
| @@ -0,0 +1,111 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Modal, Box, Typography, Button, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'; | ||||
| import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||||
| import type { SuscripcionDto } from '../../../models/dtos/Suscripciones/SuscripcionDto'; | ||||
| import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import suscripcionService from '../../../services/Suscripciones/suscripcionService'; | ||||
|  | ||||
| const modalStyle = { /* ... */ }; | ||||
|  | ||||
| interface GestionarPromocionesSuscripcionModalProps { | ||||
|     open: boolean; | ||||
|     onClose: () => void; | ||||
|     suscripcion: SuscripcionDto | null; | ||||
| } | ||||
|  | ||||
| const GestionarPromocionesSuscripcionModal: React.FC<GestionarPromocionesSuscripcionModalProps> = ({ open, onClose, suscripcion }) => { | ||||
|     const [asignadas, setAsignadas] = useState<PromocionDto[]>([]); | ||||
|     const [disponibles, setDisponibles] = useState<PromocionDto[]>([]); | ||||
|     const [selectedPromo, setSelectedPromo] = useState<number | string>(''); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         if (!suscripcion) return; | ||||
|         setLoading(true); | ||||
|         setError(null); | ||||
|         try { | ||||
|             const [asignadasData, disponiblesData] = await Promise.all([ | ||||
|                 suscripcionService.getPromocionesAsignadas(suscripcion.idSuscripcion), | ||||
|                 suscripcionService.getPromocionesDisponibles(suscripcion.idSuscripcion) | ||||
|             ]); | ||||
|             setAsignadas(asignadasData); | ||||
|             setDisponibles(disponiblesData); | ||||
|         } catch (err) { | ||||
|             setError("Error al cargar las promociones."); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [suscripcion]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (open) { | ||||
|             cargarDatos(); | ||||
|         } | ||||
|     }, [open, cargarDatos]); | ||||
|  | ||||
|     const handleAsignar = async () => { | ||||
|         if (!suscripcion || !selectedPromo) return; | ||||
|         try { | ||||
|             await suscripcionService.asignarPromocion(suscripcion.idSuscripcion, Number(selectedPromo)); | ||||
|             setSelectedPromo(''); | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             setError(err.response?.data?.message || "Error al asignar la promoción."); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleQuitar = async (idPromocion: number) => { | ||||
|         if (!suscripcion) return; | ||||
|         try { | ||||
|             await suscripcionService.quitarPromocion(suscripcion.idSuscripcion, idPromocion); | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             setError(err.response?.data?.message || "Error al quitar la promoción."); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!suscripcion) return null; | ||||
|  | ||||
|     return ( | ||||
|         <Modal open={open} onClose={onClose}> | ||||
|             <Box sx={modalStyle}> | ||||
|                 <Typography variant="h6">Gestionar Promociones</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" gutterBottom> | ||||
|                     Suscripción a: {suscripcion.nombrePublicacion} | ||||
|                 </Typography> | ||||
|                 {error && <Alert severity="error" sx={{ my: 2 }}>{error}</Alert>} | ||||
|                 {loading ? <CircularProgress /> : ( | ||||
|                     <> | ||||
|                         <Typography sx={{ mt: 2 }}>Promociones Asignadas</Typography> | ||||
|                         <List dense> | ||||
|                             {asignadas.length === 0 && <ListItem><ListItemText primary="No hay promociones asignadas." /></ListItem>} | ||||
|                             {asignadas.map(p => ( | ||||
|                                 <ListItem key={p.idPromocion} secondaryAction={<IconButton edge="end" onClick={() => handleQuitar(p.idPromocion)}><DeleteIcon /></IconButton>}> | ||||
|                                     <ListItemText primary={p.descripcion} secondary={`Tipo: ${p.tipoPromocion}, Valor: ${p.valor}`} /> | ||||
|                                 </ListItem> | ||||
|                             ))} | ||||
|                         </List> | ||||
|                         <Divider sx={{ my: 2 }} /> | ||||
|                         <Typography>Asignar Nueva Promoción</Typography> | ||||
|                         <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mt: 1 }}> | ||||
|                             <FormControl fullWidth size="small"> | ||||
|                                 <InputLabel>Promociones Disponibles</InputLabel> | ||||
|                                 <Select value={selectedPromo} label="Promociones Disponibles" onChange={(e) => setSelectedPromo(e.target.value)}> | ||||
|                                     {disponibles.map(p => <MenuItem key={p.idPromocion} value={p.idPromocion}>{p.descripcion}</MenuItem>)} | ||||
|                                 </Select> | ||||
|                             </FormControl> | ||||
|                             <Button variant="contained" onClick={handleAsignar} disabled={!selectedPromo}><AddCircleOutlineIcon /></Button> | ||||
|                         </Box> | ||||
|                     </> | ||||
|                 )} | ||||
|                 <Box sx={{ mt: 3, textAlign: 'right' }}> | ||||
|                     <Button onClick={onClose}>Cerrar</Button> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </Modal> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default GestionarPromocionesSuscripcionModal; | ||||
							
								
								
									
										141
									
								
								Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| // Archivo: Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx | ||||
|  | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||
| import type { FacturaDto } from '../../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
| import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; | ||||
| import formaPagoService from '../../../services/Suscripciones/formaPagoService'; | ||||
|  | ||||
| const modalStyle = { | ||||
|   position: 'absolute' as 'absolute', | ||||
|   top: '50%', | ||||
|   left: '50%', | ||||
|   transform: 'translate(-50%, -50%)', | ||||
|   width: { xs: '95%', sm: '80%', md: '500px' }, | ||||
|   bgcolor: 'background.paper', | ||||
|   border: '2px solid #000', | ||||
|   boxShadow: 24, | ||||
|   p: 4, | ||||
|   maxHeight: '90vh', | ||||
|   overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| interface PagoManualModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePagoDto) => Promise<void>; | ||||
|   factura: FacturaDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { | ||||
|   const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); | ||||
|   const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loadingFormasPago, setLoadingFormasPago] = useState(false); | ||||
|   const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchFormasDePago = async () => { | ||||
|       setLoadingFormasPago(true); | ||||
|       try { | ||||
|         const data = await formaPagoService.getAllFormasDePago(); | ||||
|         setFormasDePago(data.filter(fp => !fp.requiereCBU)); | ||||
|       } catch (error) { | ||||
|         setLocalErrors(prev => ({ ...prev, formasDePago: 'Error al cargar formas de pago.' })); | ||||
|       } finally { | ||||
|         setLoadingFormasPago(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     if (open && factura) { | ||||
|       fetchFormasDePago(); | ||||
|       setFormData({ | ||||
|         idFactura: factura.idFactura, | ||||
|         monto: factura.importeFinal, | ||||
|         fechaPago: new Date().toISOString().split('T')[0] | ||||
|       }); | ||||
|       setLocalErrors({}); | ||||
|     } | ||||
|   }, [open, factura]); | ||||
|  | ||||
|   const validate = (): boolean => { | ||||
|     const errors: { [key: string]: string | null } = {}; | ||||
|     if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago."; | ||||
|     if (!formData.monto || formData.monto <= 0) errors.monto = "El monto debe ser mayor a cero."; | ||||
|     if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; | ||||
|     setLocalErrors(errors); | ||||
|     return Object.keys(errors).length === 0; | ||||
|   }; | ||||
|  | ||||
|   const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|     const { name, value } = e.target; | ||||
|     const finalValue = name === 'monto' && value !== '' ? parseFloat(value) : value; | ||||
|     setFormData(prev => ({ ...prev, [name]: finalValue })); | ||||
|     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||
|     if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|    | ||||
|   const handleSelectChange = (e: SelectChangeEvent<any>) => { | ||||
|     const { name, value } = e.target; | ||||
|     setFormData(prev => ({ ...prev, [name]: value })); | ||||
|     if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||
|     if (errorMessage) clearErrorMessage(); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||||
|     e.preventDefault(); | ||||
|     clearErrorMessage(); | ||||
|     if (!validate()) return; | ||||
|  | ||||
|     setLoading(true); | ||||
|     let success = false; | ||||
|     try { | ||||
|       await onSubmit(formData as CreatePagoDto); | ||||
|       success = true; | ||||
|     } catch (error) { | ||||
|       success = false; | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|       if (success) onClose(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!factura) return null; | ||||
|  | ||||
|   return ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={modalStyle}> | ||||
|         <Typography variant="h6">Registrar Pago Manual</Typography> | ||||
|         <Typography variant="body2" color="text.secondary" gutterBottom> | ||||
|           Factura #{factura.idFactura} para {factura.nombreSuscriptor} | ||||
|         </Typography> | ||||
|         <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|             <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> | ||||
|             <FormControl fullWidth margin="dense" error={!!localErrors.idFormaPago}> | ||||
|                 <InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel> | ||||
|                 <Select name="idFormaPago" labelId="forma-pago-label" value={formData.idFormaPago || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loadingFormasPago}> | ||||
|                     {formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)} | ||||
|                 </Select> | ||||
|             </FormControl> | ||||
|             <TextField name="monto" label="Monto Pagado" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} /> | ||||
|             <TextField name="referencia" label="Referencia (Opcional)" value={formData.referencia || ''} onChange={handleInputChange} fullWidth margin="dense" /> | ||||
|             <TextField name="observaciones" label="Observaciones (Opcional)" value={formData.observaciones || ''} onChange={handleInputChange} fullWidth margin="dense" multiline rows={2} /> | ||||
|  | ||||
|             {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} | ||||
|  | ||||
|             <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|               <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|               <Button type="submit" variant="contained" disabled={loading || loadingFormasPago}> | ||||
|                 {loading ? <CircularProgress size={24} /> : 'Registrar Pago'} | ||||
|               </Button> | ||||
|             </Box> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default PagoManualModal; | ||||
| @@ -0,0 +1,152 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, | ||||
|         FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox, | ||||
|         type SelectChangeEvent, InputAdornment } from '@mui/material'; | ||||
| import type { PromocionDto } from '../../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import type { CreatePromocionDto, UpdatePromocionDto } from '../../../models/dtos/Suscripciones/CreatePromocionDto'; | ||||
|  | ||||
| const modalStyle = { | ||||
|   position: 'absolute' as 'absolute', | ||||
|   top: '50%', | ||||
|   left: '50%', | ||||
|   transform: 'translate(-50%, -50%)', | ||||
|   width: { xs: '95%', sm: '80%', md: '600px' }, | ||||
|   bgcolor: 'background.paper', | ||||
|   border: '2px solid #000', | ||||
|   boxShadow: 24, | ||||
|   p: 4, | ||||
|   maxHeight: '90vh', | ||||
|   overflowY: 'auto' | ||||
| }; | ||||
|  | ||||
| const tiposPromocion = [ | ||||
|     { value: 'Porcentaje', label: 'Descuento Porcentual (%)' }, | ||||
|     { value: 'MontoFijo', label: 'Descuento de Monto Fijo ($)' }, | ||||
|     // { value: 'BonificacionDias', label: 'Bonificación de Días' }, // Descomentar para futuras implementaciones | ||||
| ]; | ||||
|  | ||||
| interface PromocionFormModalProps { | ||||
|   open: boolean; | ||||
|   onClose: () => void; | ||||
|   onSubmit: (data: CreatePromocionDto | UpdatePromocionDto, id?: number) => Promise<void>; | ||||
|   initialData?: PromocionDto | null; | ||||
|   errorMessage?: string | null; | ||||
|   clearErrorMessage: () => void; | ||||
| } | ||||
|  | ||||
| const PromocionFormModal: React.FC<PromocionFormModalProps> = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => { | ||||
|     const [formData, setFormData] = useState<Partial<CreatePromocionDto>>({}); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); | ||||
|      | ||||
|     const isEditing = Boolean(initialData); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (open) { | ||||
|             setFormData(initialData || { | ||||
|                 descripcion: '', | ||||
|                 tipoPromocion: 'Porcentaje', | ||||
|                 valor: 0, | ||||
|                 fechaInicio: new Date().toISOString().split('T')[0], | ||||
|                 activa: true | ||||
|             }); | ||||
|             setLocalErrors({}); | ||||
|         } | ||||
|     }, [open, initialData]); | ||||
|      | ||||
|     const validate = (): boolean => { | ||||
|         const errors: { [key: string]: string | null } = {}; | ||||
|         if (!formData.descripcion?.trim()) errors.descripcion = 'La descripción es obligatoria.'; | ||||
|         if (!formData.tipoPromocion) errors.tipoPromocion = 'El tipo de promoción es obligatorio.'; | ||||
|         if (!formData.valor || formData.valor <= 0) errors.valor = 'El valor debe ser mayor a cero.'; | ||||
|         if (formData.tipoPromocion === 'Porcentaje' && (formData.valor ?? 0) > 100) { | ||||
|             errors.valor = 'El valor para porcentaje no puede ser mayor a 100.'; | ||||
|         } | ||||
|         if (!formData.fechaInicio) errors.fechaInicio = 'La fecha de inicio es obligatoria.'; | ||||
|         if (formData.fechaFin && formData.fechaInicio && new Date(formData.fechaFin) < new Date(formData.fechaInicio)) { | ||||
|             errors.fechaFin = 'La fecha de fin no puede ser anterior a la de inicio.'; | ||||
|         } | ||||
|         setLocalErrors(errors); | ||||
|         return Object.keys(errors).length === 0; | ||||
|     }; | ||||
|  | ||||
|     const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         const { name, value, type, checked } = e.target; | ||||
|         const finalValue = type === 'checkbox' ? checked : (type === 'number' ? parseFloat(value) : value); | ||||
|         setFormData(prev => ({ ...prev, [name]: finalValue })); | ||||
|         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||
|         if (errorMessage) clearErrorMessage(); | ||||
|     }; | ||||
|      | ||||
|     const handleSelectChange = (e: SelectChangeEvent<any>) => { | ||||
|         const { name, value } = e.target; | ||||
|         setFormData(prev => ({ ...prev, [name]: value })); | ||||
|         if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null })); | ||||
|         if (errorMessage) clearErrorMessage(); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { | ||||
|         e.preventDefault(); | ||||
|         clearErrorMessage(); | ||||
|         if (!validate()) return; | ||||
|  | ||||
|         setLoading(true); | ||||
|         let success = false; | ||||
|         try { | ||||
|             const dataToSubmit = { | ||||
|                 ...formData, | ||||
|                 fechaFin: formData.fechaFin || null | ||||
|             } as CreatePromocionDto | UpdatePromocionDto; | ||||
|              | ||||
|             await onSubmit(dataToSubmit, initialData?.idPromocion); | ||||
|             success = true; | ||||
|         } catch (error) { | ||||
|             success = false; | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|             if (success) onClose(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <Modal open={open} onClose={onClose}> | ||||
|             <Box sx={modalStyle}> | ||||
|                 <Typography variant="h6" component="h2">{isEditing ? 'Editar Promoción' : 'Nueva Promoción'}</Typography> | ||||
|                 <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> | ||||
|                     <TextField name="descripcion" label="Descripción" value={formData.descripcion || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.descripcion} helperText={localErrors.descripcion} disabled={loading} autoFocus /> | ||||
|                      | ||||
|                     <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> | ||||
|                         <FormControl fullWidth margin="dense" sx={{flex: 2}} error={!!localErrors.tipoPromocion}> | ||||
|                             <InputLabel id="tipo-promo-label" required>Tipo</InputLabel> | ||||
|                             <Select name="tipoPromocion" labelId="tipo-promo-label" value={formData.tipoPromocion || ''} onChange={handleSelectChange} label="Tipo" disabled={loading}> | ||||
|                                 {tiposPromocion.map(t => <MenuItem key={t.value} value={t.value}>{t.label}</MenuItem>)} | ||||
|                             </Select> | ||||
|                         </FormControl> | ||||
|                         <TextField name="valor" label="Valor" type="number" value={formData.valor || ''} onChange={handleInputChange} required fullWidth margin="dense" sx={{flex: 1}} error={!!localErrors.valor} helperText={localErrors.valor} disabled={loading}  | ||||
|                             InputProps={{ startAdornment: <InputAdornment position="start">{formData.tipoPromocion === 'Porcentaje' ? '%' : '$'}</InputAdornment> }}  | ||||
|                             inputProps={{ step: "0.01" }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                      | ||||
|                     <Box sx={{ display: 'flex', gap: 2, mt: 1 }}> | ||||
|                         <TextField name="fechaInicio" label="Fecha Inicio" type="date" value={formData.fechaInicio || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaInicio} helperText={localErrors.fechaInicio} disabled={loading} /> | ||||
|                         <TextField name="fechaFin" label="Fecha Fin (Opcional)" type="date" value={formData.fechaFin || ''} onChange={handleInputChange} fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaFin} helperText={localErrors.fechaFin} disabled={loading} /> | ||||
|                     </Box> | ||||
|  | ||||
|                     <FormControlLabel control={<Checkbox name="activa" checked={formData.activa ?? true} onChange={handleInputChange} disabled={loading}/>} label="Promoción Activa" sx={{mt: 1}} /> | ||||
|  | ||||
|                     {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} | ||||
|                      | ||||
|                     <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> | ||||
|                         <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> | ||||
|                         <Button type="submit" variant="contained" disabled={loading}> | ||||
|                             {loading ? <CircularProgress size={24} /> : 'Guardar'} | ||||
|                         </Button> | ||||
|                     </Box> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|         </Modal> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default PromocionFormModal; | ||||
							
								
								
									
										8
									
								
								Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| export interface CreatePagoDto { | ||||
|   idFactura: number; | ||||
|   fechaPago: string; // "yyyy-MM-dd" | ||||
|   idFormaPago: number; | ||||
|   monto: number; | ||||
|   referencia?: string | null; | ||||
|   observaciones?: string | null; | ||||
| } | ||||
							
								
								
									
										10
									
								
								Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| export interface CreatePromocionDto { | ||||
|   descripcion: string; | ||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; | ||||
|   valor: number; | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   activa: boolean; | ||||
| } | ||||
| // UpdatePromocionDto es igual | ||||
| export type UpdatePromocionDto = CreatePromocionDto; | ||||
							
								
								
									
										14
									
								
								Frontend/src/models/dtos/Suscripciones/FacturaDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Frontend/src/models/dtos/Suscripciones/FacturaDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| export interface FacturaDto { | ||||
|   idFactura: number; | ||||
|   idSuscripcion: number; | ||||
|   periodo: string; // "YYYY-MM" | ||||
|   fechaEmision: string; // "yyyy-MM-dd" | ||||
|   fechaVencimiento: string; // "yyyy-MM-dd" | ||||
|   importeFinal: number; | ||||
|   estado: string; | ||||
|   numeroFactura?: string | null; | ||||
|    | ||||
|   // Datos enriquecidos para la UI | ||||
|   nombreSuscriptor: string; | ||||
|   nombrePublicacion: string; | ||||
| } | ||||
| @@ -0,0 +1,4 @@ | ||||
| export interface GenerarFacturacionResponseDto { | ||||
|     message: string; | ||||
|     facturasGeneradas: number; | ||||
| } | ||||
							
								
								
									
										13
									
								
								Frontend/src/models/dtos/Suscripciones/PagoDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								Frontend/src/models/dtos/Suscripciones/PagoDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| export interface PagoDto { | ||||
|   idPago: number; | ||||
|   idFactura: number; | ||||
|   fechaPago: string; // "yyyy-MM-dd" | ||||
|   idFormaPago: number; | ||||
|   nombreFormaPago: string; | ||||
|   monto: number; | ||||
|   estado: string; | ||||
|   referencia?: string | null; | ||||
|   observaciones?: string | null; | ||||
|   idUsuarioRegistro: number; | ||||
|   nombreUsuarioRegistro: string; | ||||
| } | ||||
| @@ -0,0 +1,7 @@ | ||||
| export interface ProcesamientoLoteResponseDto { | ||||
|     totalRegistrosLeidos: number; | ||||
|     pagosAprobados: number; | ||||
|     pagosRechazados: number; | ||||
|     errores: string[]; | ||||
|     mensajeResumen: string; | ||||
| } | ||||
							
								
								
									
										9
									
								
								Frontend/src/models/dtos/Suscripciones/PromocionDto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Frontend/src/models/dtos/Suscripciones/PromocionDto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| export interface PromocionDto { | ||||
|   idPromocion: number; | ||||
|   descripcion: string; | ||||
|   tipoPromocion: 'Porcentaje' | 'MontoFijo' | 'BonificacionDias'; | ||||
|   valor: number; | ||||
|   fechaInicio: string; // "yyyy-MM-dd" | ||||
|   fechaFin?: string | null; | ||||
|   activa: boolean; | ||||
| } | ||||
							
								
								
									
										321
									
								
								Frontend/src/pages/Suscripciones/FacturacionPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										321
									
								
								Frontend/src/pages/Suscripciones/FacturacionPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,321 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material'; | ||||
| import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import MoreVertIcon from '@mui/icons-material/MoreVert'; | ||||
| import PaymentIcon from '@mui/icons-material/Payment'; | ||||
| import EmailIcon from '@mui/icons-material/Email'; | ||||
| import UploadFileIcon from '@mui/icons-material/UploadFile'; | ||||
| import { styled } from '@mui/material/styles'; | ||||
| import facturacionService from '../../services/Suscripciones/facturacionService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal'; | ||||
| import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
|  | ||||
| const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i); | ||||
| const meses = [ | ||||
|     { value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' }, | ||||
|     { value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' }, | ||||
|     { value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' }, | ||||
|     { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } | ||||
| ]; | ||||
|  | ||||
| const VisuallyHiddenInput = styled('input')({ | ||||
|     clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', height: 1, overflow: 'hidden', | ||||
|     position: 'absolute', bottom: 0, left: 0, whiteSpace: 'nowrap', width: 1, | ||||
| }); | ||||
|  | ||||
| const FacturacionPage: React.FC = () => { | ||||
|     const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear()); | ||||
|     const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [loadingArchivo, setLoadingArchivo] = useState(false); | ||||
|     const [loadingProceso, setLoadingProceso] = useState(false); | ||||
|     const [apiMessage, setApiMessage] = useState<string | null>(null); | ||||
|     const [apiError, setApiError] = useState<string | null>(null); | ||||
|     const [facturas, setFacturas] = useState<FacturaDto[]>([]); | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006"); | ||||
|     const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007"); | ||||
|     const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008"); | ||||
|     const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009"); | ||||
|     const [pagoModalOpen, setPagoModalOpen] = useState(false); | ||||
|     const [selectedFactura, setSelectedFactura] = useState<FacturaDto | null>(null); | ||||
|     const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
|     const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null); | ||||
|  | ||||
|     const cargarFacturasDelPeriodo = useCallback(async () => { | ||||
|         if (!puedeGenerarFacturacion) return; | ||||
|         setLoading(true); | ||||
|         try { | ||||
|             const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes); | ||||
|             setFacturas(data); | ||||
|         } catch (err) { | ||||
|             setFacturas([]); | ||||
|             console.error(err); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }, [selectedAnio, selectedMes, puedeGenerarFacturacion]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarFacturasDelPeriodo(); | ||||
|     }, [cargarFacturasDelPeriodo]); | ||||
|  | ||||
|     const handleGenerarFacturacion = async () => { | ||||
|         if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) { | ||||
|             return; | ||||
|         } | ||||
|         setLoading(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes); | ||||
|             setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`); | ||||
|             await cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message | ||||
|                 ? err.response.data.message | ||||
|                 : 'Ocurrió un error al generar la facturación.'; | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoading(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleGenerarArchivo = async () => { | ||||
|         if (!window.confirm(`Se generará el archivo de débito para las facturas del período ${meses.find(m => m.value === selectedMes)?.label}/${selectedAnio} que estén en estado 'Pendiente de Cobro'. ¿Continuar?`)) { | ||||
|             return; | ||||
|         } | ||||
|         setLoadingArchivo(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes); | ||||
|             const url = window.URL.createObjectURL(new Blob([fileContent])); | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = url; | ||||
|             link.setAttribute('download', fileName); | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             link.parentNode?.removeChild(link); | ||||
|             window.URL.revokeObjectURL(url); | ||||
|             setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`); | ||||
|             cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             let message = 'Ocurrió un error al generar el archivo.'; | ||||
|             if (axios.isAxiosError(err) && err.response) { | ||||
|                 const errorText = await err.response.data.text(); | ||||
|                 try { | ||||
|                     const errorJson = JSON.parse(errorText); | ||||
|                     message = errorJson.message || message; | ||||
|                 } catch { message = errorText || message; } | ||||
|             } | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoadingArchivo(false); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaDto) => { | ||||
|         setAnchorEl(event.currentTarget); | ||||
|         setSelectedFactura(factura); | ||||
|     }; | ||||
|  | ||||
|     const handleMenuClose = () => { | ||||
|         setAnchorEl(null); | ||||
|         setSelectedFactura(null); | ||||
|     }; | ||||
|  | ||||
|     const handleOpenPagoModal = () => { | ||||
|         setPagoModalOpen(true); | ||||
|         handleMenuClose(); | ||||
|     }; | ||||
|  | ||||
|     const handleClosePagoModal = () => { | ||||
|         setPagoModalOpen(false); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitPagoModal = async (data: CreatePagoDto) => { | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.registrarPagoManual(data); | ||||
|             setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`); | ||||
|             cargarFacturasDelPeriodo(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.'; | ||||
|             setApiError(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleSendEmail = async (idFactura: number) => { | ||||
|         if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return; | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             await facturacionService.enviarFacturaPorEmail(idFactura); | ||||
|             setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`); | ||||
|         } catch (err: any) { | ||||
|             setApiError(err.response?.data?.message || 'Error al intentar enviar el email.'); | ||||
|         } finally { | ||||
|             handleMenuClose(); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
|         if (event.target.files && event.target.files.length > 0) { | ||||
|             setArchivoSeleccionado(event.target.files[0]); | ||||
|             setApiMessage(null); | ||||
|             setApiError(null); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleProcesarArchivo = async () => { | ||||
|         if (!archivoSeleccionado) { | ||||
|             setApiError("Por favor, seleccione un archivo de respuesta para procesar."); | ||||
|             return; | ||||
|         } | ||||
|         setLoadingProceso(true); | ||||
|         setApiMessage(null); | ||||
|         setApiError(null); | ||||
|         try { | ||||
|             const response = await facturacionService.procesarArchivoRespuesta(archivoSeleccionado); | ||||
|             setApiMessage(response.mensajeResumen); | ||||
|             if (response.errores?.length > 0) { | ||||
|                 setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`); | ||||
|             } | ||||
|             cargarFacturasDelPeriodo(); // Recargar para ver los estados finales | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen | ||||
|                 ? err.response.data.mensajeResumen | ||||
|                 : 'Ocurrió un error crítico al procesar el archivo.'; | ||||
|             setApiError(message); | ||||
|         } finally { | ||||
|             setLoadingProceso(false); | ||||
|             setArchivoSeleccionado(null); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if (!puedeGenerarFacturacion) { | ||||
|         return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Typography variant="h6">1. Generación de Facturación</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                     Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado. | ||||
|                 </Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}> | ||||
|                     <FormControl sx={{ minWidth: 120 }} size="small"> | ||||
|                         <InputLabel>Mes</InputLabel> | ||||
|                         <Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select> | ||||
|                     </FormControl> | ||||
|                     <FormControl sx={{ minWidth: 120 }} size="small"> | ||||
|                         <InputLabel>Año</InputLabel> | ||||
|                         <Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select> | ||||
|                     </FormControl> | ||||
|                 </Box> | ||||
|                 <Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo}>Generar Facturación del Período</Button> | ||||
|             </Paper> | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Typography variant="h6">2. Generación de Archivo para Banco</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography> | ||||
|                 <Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button> | ||||
|             </Paper> | ||||
|  | ||||
|             <Paper sx={{ p: 2, mb: 2 }}> | ||||
|                 <Typography variant="h6">3. Procesar Respuesta del Banco</Typography> | ||||
|                 <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
|                     Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada". | ||||
|                 </Typography> | ||||
|                 <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> | ||||
|                     <Button | ||||
|                         component="label" | ||||
|                         role={undefined} | ||||
|                         variant="outlined" | ||||
|                         tabIndex={-1} | ||||
|                         startIcon={<UploadFileIcon />} | ||||
|                         disabled={loadingProceso} | ||||
|                     > | ||||
|                         Seleccionar Archivo | ||||
|                         <VisuallyHiddenInput type="file" onChange={handleFileChange} accept=".txt, text/plain" /> | ||||
|                     </Button> | ||||
|                     {archivoSeleccionado && <Typography variant="body2">{archivoSeleccionado.name}</Typography>} | ||||
|                 </Box> | ||||
|                 {archivoSeleccionado && ( | ||||
|                     <Button | ||||
|                         variant="contained" | ||||
|                         color="success" | ||||
|                         sx={{ mt: 2 }} | ||||
|                         onClick={handleProcesarArchivo} | ||||
|                         disabled={loadingProceso} | ||||
|                         startIcon={loadingProceso ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} | ||||
|                     > | ||||
|                         Procesar Archivo de Respuesta | ||||
|                     </Button> | ||||
|                 )} | ||||
|             </Paper> | ||||
|  | ||||
|             {apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>} | ||||
|             {apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>} | ||||
|  | ||||
|             <Typography variant="h6" sx={{ mt: 4 }}>Facturas del Período</Typography> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>ID</TableCell><TableCell>Suscriptor</TableCell><TableCell>Publicación</TableCell> | ||||
|                             <TableCell align="right">Importe</TableCell><TableCell>Estado</TableCell><TableCell>Nro. Factura</TableCell> | ||||
|                             <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {loading ? (<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>) | ||||
|                             : facturas.length === 0 ? (<TableRow><TableCell colSpan={7} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) | ||||
|                                 : (facturas.map(f => ( | ||||
|                                     <TableRow key={f.idFactura} hover> | ||||
|                                         <TableCell>{f.idFactura}</TableCell> | ||||
|                                         <TableCell>{f.nombreSuscriptor}</TableCell> | ||||
|                                         <TableCell>{f.nombrePublicacion}</TableCell> | ||||
|                                         <TableCell align="right">${f.importeFinal.toFixed(2)}</TableCell> | ||||
|                                         <TableCell><Chip label={f.estado} size="small" color={f.estado === 'Pagada' ? 'success' : (f.estado === 'Rechazada' ? 'error' : 'default')} /></TableCell> | ||||
|                                         <TableCell>{f.numeroFactura || '-'}</TableCell> | ||||
|                                         <TableCell align="right"> | ||||
|                                             <IconButton onClick={(e) => handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}> | ||||
|                                                 <MoreVertIcon /> | ||||
|                                             </IconButton> | ||||
|                                         </TableCell> | ||||
|                                     </TableRow> | ||||
|                                 )))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|             <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> | ||||
|                 {selectedFactura && puedeRegistrarPago && ( | ||||
|                     <MenuItem onClick={handleOpenPagoModal}> | ||||
|                         <ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Registrar Pago Manual</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|                 {selectedFactura && puedeEnviarEmail && ( | ||||
|                     <MenuItem | ||||
|                         onClick={() => handleSendEmail(selectedFactura.idFactura)} | ||||
|                         disabled={!selectedFactura.numeroFactura} | ||||
|                     > | ||||
|                         <ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon> | ||||
|                         <ListItemText>Enviar Email</ListItemText> | ||||
|                     </MenuItem> | ||||
|                 )} | ||||
|             </Menu> | ||||
|             <PagoManualModal open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} factura={selectedFactura} errorMessage={apiError} clearErrorMessage={() => setApiError(null)} /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default FacturacionPage; | ||||
							
								
								
									
										157
									
								
								Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| import React, { useState, useEffect, useCallback } from 'react'; | ||||
| import { Box, Typography, Button, Paper, CircularProgress, Alert, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Switch, FormControlLabel, Tooltip } from '@mui/material'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
| import EditIcon from '@mui/icons-material/Edit'; | ||||
| import promocionService from '../../services/Suscripciones/promocionService'; | ||||
| import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import type { CreatePromocionDto, UpdatePromocionDto } from '../../models/dtos/Suscripciones/CreatePromocionDto'; | ||||
| import PromocionFormModal from '../../components/Modals/Suscripciones/PromocionFormModal'; | ||||
|  | ||||
| const GestionarPromocionesPage: React.FC = () => { | ||||
|     const [promociones, setPromociones] = useState<PromocionDto[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|     const [filtroSoloActivas, setFiltroSoloActivas] = useState(true); | ||||
|     const [modalOpen, setModalOpen] = useState(false); | ||||
|     const [editingPromocion, setEditingPromocion] = useState<PromocionDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|      | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU010"); | ||||
|  | ||||
|     const cargarDatos = useCallback(async () => { | ||||
|         if (!puedeGestionar) {  | ||||
|             setError("No tiene permiso para gestionar promociones.");  | ||||
|             setLoading(false);  | ||||
|             return;  | ||||
|         } | ||||
|         setLoading(true); | ||||
|         setError(null); | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             const data = await promocionService.getAllPromociones(filtroSoloActivas); | ||||
|             setPromociones(data); | ||||
|         } catch (err) {  | ||||
|             setError("Error al cargar las promociones.");  | ||||
|         } finally {  | ||||
|             setLoading(false);  | ||||
|         } | ||||
|     }, [filtroSoloActivas, puedeGestionar]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         cargarDatos(); | ||||
|     }, [cargarDatos]); | ||||
|  | ||||
|     const handleOpenModal = (promocion?: PromocionDto) => { | ||||
|         setEditingPromocion(promocion || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
|         setModalOpen(false); | ||||
|         setEditingPromocion(null); | ||||
|     }; | ||||
|  | ||||
|     const handleSubmitModal = async (data: CreatePromocionDto | UpdatePromocionDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if (id && editingPromocion) { | ||||
|                 await promocionService.updatePromocion(id, data as UpdatePromocionDto); | ||||
|             } else { | ||||
|                 await promocionService.createPromocion(data as CreatePromocionDto); | ||||
|             } | ||||
|             cargarDatos(); | ||||
|         } catch (err: any) { | ||||
|             const message = axios.isAxiosError(err) && err.response?.data?.message | ||||
|                 ? err.response.data.message | ||||
|                 : 'Error al guardar la promoción.'; | ||||
|             setApiErrorMessage(message); | ||||
|             throw err; | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     const formatDate = (dateString?: string | null) => { | ||||
|         if (!dateString) return 'Indefinido'; | ||||
|         const parts = dateString.split('-'); | ||||
|         return `${parts[2]}/${parts[1]}/${parts[0]}`; | ||||
|     }; | ||||
|  | ||||
|     const formatTipo = (tipo: string) => { | ||||
|         if (tipo === 'MontoFijo') return 'Monto Fijo'; | ||||
|         if (tipo === 'Porcentaje') return 'Porcentaje'; | ||||
|         return tipo; | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>; | ||||
|     if (!puedeGestionar) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 1 }}> | ||||
|             <Typography variant="h5" gutterBottom>Gestionar Promociones</Typography> | ||||
|             <Paper sx={{ p: 2, mb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||
|                 <FormControlLabel control={<Switch checked={filtroSoloActivas} onChange={(e) => setFiltroSoloActivas(e.target.checked)} />} label="Ver Solo Activas" /> | ||||
|                 <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()}> | ||||
|                     Nueva Promoción | ||||
|                 </Button> | ||||
|             </Paper> | ||||
|              | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Descripción</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Tipo</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Valor</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> | ||||
|                             <TableCell align="center" sx={{fontWeight: 'bold'}}>Estado</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {promociones.length === 0 ? ( | ||||
|                             <TableRow><TableCell colSpan={7} align="center">No se encontraron promociones.</TableCell></TableRow> | ||||
|                         ) : ( | ||||
|                             promociones.map(p => ( | ||||
|                                 <TableRow key={p.idPromocion} hover> | ||||
|                                     <TableCell>{p.descripcion}</TableCell> | ||||
|                                     <TableCell>{formatTipo(p.tipoPromocion)}</TableCell> | ||||
|                                     <TableCell align="right">{p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`}</TableCell> | ||||
|                                     <TableCell>{formatDate(p.fechaInicio)}</TableCell> | ||||
|                                     <TableCell>{formatDate(p.fechaFin)}</TableCell> | ||||
|                                     <TableCell align="center"> | ||||
|                                         <Chip label={p.activa ? 'Activa' : 'Inactiva'} color={p.activa ? 'success' : 'default'} size="small" /> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         <Tooltip title="Editar Promoción"> | ||||
|                                             <IconButton onClick={() => handleOpenModal(p)}> | ||||
|                                                 <EditIcon /> | ||||
|                                             </IconButton> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|                             )) | ||||
|                         )} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|              | ||||
|             <PromocionFormModal  | ||||
|                 open={modalOpen} | ||||
|                 onClose={handleCloseModal} | ||||
|                 onSubmit={handleSubmitModal} | ||||
|                 initialData={editingPromocion} | ||||
|                 errorMessage={apiErrorMessage} | ||||
|                 clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default GestionarPromocionesPage; | ||||
| @@ -13,6 +13,8 @@ import { usePermissions } from '../../hooks/usePermissions'; | ||||
| import axios from 'axios'; | ||||
| import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; | ||||
| import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; | ||||
| import LoyaltyIcon from '@mui/icons-material/Loyalty'; | ||||
| import GestionarPromocionesSuscripcionModal from '../../components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal'; | ||||
|  | ||||
| const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const { idSuscriptor: idSuscriptorStr } = useParams<{ idSuscriptor: string }>(); | ||||
| @@ -27,6 +29,9 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const [editingSuscripcion, setEditingSuscripcion] = useState<SuscripcionDto | null>(null); | ||||
|     const [apiErrorMessage, setApiErrorMessage] = useState<string | null>(null); | ||||
|  | ||||
|     const [promocionesModalOpen, setPromocionesModalOpen] = useState(false); | ||||
|     const [selectedSuscripcion, setSelectedSuscripcion] = useState<SuscripcionDto | null>(null); | ||||
|  | ||||
|     const { tienePermiso, isSuperAdmin } = usePermissions(); | ||||
|     const puedeVer = isSuperAdmin || tienePermiso("SU001"); | ||||
|     const puedeGestionar = isSuperAdmin || tienePermiso("SU005"); | ||||
| @@ -56,9 +61,9 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     useEffect(() => { cargarDatos(); }, [cargarDatos]); | ||||
|  | ||||
|     const handleOpenModal = (suscripcion?: SuscripcionDto) => { | ||||
|       setEditingSuscripcion(suscripcion || null); | ||||
|       setApiErrorMessage(null); | ||||
|       setModalOpen(true); | ||||
|         setEditingSuscripcion(suscripcion || null); | ||||
|         setApiErrorMessage(null); | ||||
|         setModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseModal = () => { | ||||
| @@ -69,7 +74,7 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     const handleSubmitModal = async (data: CreateSuscripcionDto | UpdateSuscripcionDto, id?: number) => { | ||||
|         setApiErrorMessage(null); | ||||
|         try { | ||||
|             if(id && editingSuscripcion) { | ||||
|             if (id && editingSuscripcion) { | ||||
|                 await suscripcionService.updateSuscripcion(id, data as UpdateSuscripcionDto); | ||||
|             } else { | ||||
|                 await suscripcionService.createSuscripcion(data as CreateSuscripcionDto); | ||||
| @@ -82,6 +87,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const handleOpenPromocionesModal = (suscripcion: SuscripcionDto) => { | ||||
|         setSelectedSuscripcion(suscripcion); | ||||
|         setPromocionesModalOpen(true); | ||||
|     }; | ||||
|  | ||||
|     const formatDate = (dateString?: string | null) => { | ||||
|         if (!dateString) return 'Indefinido'; | ||||
|         // Asume que la fecha viene como "yyyy-MM-dd" | ||||
| @@ -90,8 +100,8 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     if (error) return <Alert severity="error" sx={{m: 2}}>{error}</Alert>; | ||||
|     if (!puedeVer) return <Alert severity="error" sx={{m: 2}}>Acceso Denegado.</Alert> | ||||
|     if (error) return <Alert severity="error" sx={{ m: 2 }}>{error}</Alert>; | ||||
|     if (!puedeVer) return <Alert severity="error" sx={{ m: 2 }}>Acceso Denegado.</Alert> | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ p: 2 }}> | ||||
| @@ -105,19 +115,19 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|  | ||||
|             {puedeGestionar && <Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpenModal()} sx={{ my: 2 }}>Nueva Suscripción</Button>} | ||||
|  | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{my: 2}}>{apiErrorMessage}</Alert>} | ||||
|             {apiErrorMessage && <Alert severity="error" sx={{ my: 2 }}>{apiErrorMessage}</Alert>} | ||||
|  | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Publicación</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Estado</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Días Entrega</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Inicio</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Fin</TableCell> | ||||
|                             <TableCell sx={{fontWeight: 'bold'}}>Observaciones</TableCell> | ||||
|                             <TableCell align="right" sx={{fontWeight: 'bold'}}>Acciones</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Publicación</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Estado</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Días Entrega</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Inicio</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Fin</TableCell> | ||||
|                             <TableCell sx={{ fontWeight: 'bold' }}>Observaciones</TableCell> | ||||
|                             <TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
| @@ -147,7 +157,16 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|                                             </span> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                     <TableCell align="right"> | ||||
|                                         <Tooltip title="Editar Suscripción"> | ||||
|                                             <span><IconButton onClick={() => handleOpenModal(s)} disabled={!puedeGestionar}><EditIcon /></IconButton></span> | ||||
|                                         </Tooltip> | ||||
|                                         <Tooltip title="Gestionar Promociones"> | ||||
|                                             <span><IconButton onClick={() => handleOpenPromocionesModal(s)} disabled={!puedeGestionar}><LoyaltyIcon /></IconButton></span> | ||||
|                                         </Tooltip> | ||||
|                                     </TableCell> | ||||
|                                 </TableRow> | ||||
|  | ||||
|                             )) | ||||
|                         )} | ||||
|                     </TableBody> | ||||
| @@ -165,6 +184,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { | ||||
|                     clearErrorMessage={() => setApiErrorMessage(null)} | ||||
|                 /> | ||||
|             } | ||||
|             <GestionarPromocionesSuscripcionModal | ||||
|                 open={promocionesModalOpen} | ||||
|                 onClose={() => setPromocionesModalOpen(false)} | ||||
|                 suscripcion={selectedSuscripcion} | ||||
|             /> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|   | ||||
| @@ -6,8 +6,8 @@ import { usePermissions } from '../../hooks/usePermissions'; | ||||
| // Define las pestañas del módulo. Ajusta los permisos según sea necesario. | ||||
| const suscripcionesSubModules = [ | ||||
|   { label: 'Suscriptores', path: 'suscriptores', requiredPermission: 'SU001' }, | ||||
|   // { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU005' }, | ||||
|   // { label: 'Promociones', path: 'promociones', requiredPermission: 'SU006' }, | ||||
|   { label: 'Facturación', path: 'facturacion', requiredPermission: 'SU006' }, | ||||
|   { label: 'Promociones', path: 'promociones', requiredPermission: 'SU010' }, | ||||
| ]; | ||||
|  | ||||
| const SuscripcionesIndexPage: React.FC = () => { | ||||
|   | ||||
| @@ -80,6 +80,8 @@ import ReporteListadoDistMensualPage from '../pages/Reportes/ReporteListadoDistM | ||||
| import SuscripcionesIndexPage from '../pages/Suscripciones/SuscripcionesIndexPage'; | ||||
| import GestionarSuscriptoresPage from '../pages/Suscripciones/GestionarSuscriptoresPage'; | ||||
| import GestionarSuscripcionesSuscriptorPage from '../pages/Suscripciones/GestionarSuscripcionesSuscriptorPage'; | ||||
| import FacturacionPage from '../pages/Suscripciones/FacturacionPage'; | ||||
| import GestionarPromocionesPage from '../pages/Suscripciones/GestionarPromocionesPage'; | ||||
|  | ||||
| // Anonalías | ||||
| import AlertasPage from '../pages/Anomalia/AlertasPage'; | ||||
| @@ -203,7 +205,16 @@ const AppRoutes = () => { | ||||
|                 <GestionarSuscripcionesSuscriptorPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             {/* Aquí irán las otras sub-rutas como 'facturacion', etc. */} | ||||
|             <Route path="facturacion" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU006" sectionName="Facturación de Suscripciones"> | ||||
|                 <FacturacionPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|             <Route path="promociones" element={ | ||||
|               <SectionProtectedRoute requiredPermission="SU010" sectionName="Promociones"> | ||||
|                 <GestionarPromocionesPage /> | ||||
|               </SectionProtectedRoute> | ||||
|             } /> | ||||
|           </Route> | ||||
|  | ||||
|           {/* Módulo Contable (anidado) */} | ||||
|   | ||||
							
								
								
									
										74
									
								
								Frontend/src/services/Suscripciones/facturacionService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								Frontend/src/services/Suscripciones/facturacionService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| import apiClient from '../apiClient'; | ||||
| import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto'; | ||||
| import type { GenerarFacturacionResponseDto } from '../../models/dtos/Suscripciones/GenerarFacturacionResponseDto'; | ||||
| import type { PagoDto } from '../../models/dtos/Suscripciones/PagoDto'; | ||||
| import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto'; | ||||
| import type { ProcesamientoLoteResponseDto } from '../../models/dtos/Suscripciones/ProcesamientoLoteResponseDto'; | ||||
|  | ||||
| const API_URL = '/facturacion'; | ||||
| const DEBITOS_URL = '/debitos'; | ||||
| const PAGOS_URL = '/pagos'; | ||||
| const FACTURAS_URL = '/facturas'; | ||||
|  | ||||
| const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLoteResponseDto> => { | ||||
|     const formData = new FormData(); | ||||
|     formData.append('archivo', archivo); | ||||
|  | ||||
|     const response = await apiClient.post<ProcesamientoLoteResponseDto>(`${DEBITOS_URL}/procesar-respuesta`, formData, { | ||||
|         headers: { | ||||
|             'Content-Type': 'multipart/form-data', | ||||
|         }, | ||||
|     }); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const getFacturasPorPeriodo = async (anio: number, mes: number): Promise<FacturaDto[]> => { | ||||
|     const response = await apiClient.get<FacturaDto[]>(`${API_URL}/${anio}/${mes}`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const generarFacturacionMensual = async (anio: number, mes: number): Promise<GenerarFacturacionResponseDto> => { | ||||
|     const response = await apiClient.post<GenerarFacturacionResponseDto>(`${API_URL}/${anio}/${mes}`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const generarArchivoDebito = async (anio: number, mes: number): Promise<{ fileContent: Blob, fileName: string }> => { | ||||
|     const response = await apiClient.post(`${DEBITOS_URL}/${anio}/${mes}/generar-archivo`, {}, { | ||||
|         responseType: 'blob', | ||||
|     }); | ||||
|  | ||||
|     const contentDisposition = response.headers['content-disposition']; | ||||
|     let fileName = `debito_${anio}_${mes}.txt`; | ||||
|     if (contentDisposition) { | ||||
|         const fileNameMatch = contentDisposition.match(/filename="(.+)"/); | ||||
|         if (fileNameMatch && fileNameMatch.length > 1) { | ||||
|             fileName = fileNameMatch[1]; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     return { fileContent: response.data, fileName: fileName }; | ||||
| }; | ||||
|  | ||||
| const getPagosPorFactura = async (idFactura: number): Promise<PagoDto[]> => { | ||||
|     const response = await apiClient.get<PagoDto[]>(`${FACTURAS_URL}/${idFactura}/pagos`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const registrarPagoManual = async (data: CreatePagoDto): Promise<PagoDto> => { | ||||
|     const response = await apiClient.post<PagoDto>(PAGOS_URL, data); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const enviarFacturaPorEmail = async (idFactura: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL}/${idFactura}/enviar-email`); | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     procesarArchivoRespuesta, | ||||
|     getFacturasPorPeriodo, | ||||
|     generarFacturacionMensual, | ||||
|     generarArchivoDebito, | ||||
|     getPagosPorFactura, | ||||
|     registrarPagoManual, | ||||
|     enviarFacturaPorEmail, | ||||
| }; | ||||
							
								
								
									
										31
									
								
								Frontend/src/services/Suscripciones/promocionService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								Frontend/src/services/Suscripciones/promocionService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import apiClient from '../apiClient'; | ||||
| import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; | ||||
| import type { CreatePromocionDto, UpdatePromocionDto } from '../../models/dtos/Suscripciones/CreatePromocionDto'; | ||||
|  | ||||
| const API_URL = '/promociones'; | ||||
|  | ||||
| const getAllPromociones = async (soloActivas: boolean = true): Promise<PromocionDto[]> => { | ||||
|     const response = await apiClient.get<PromocionDto[]>(`${API_URL}?soloActivas=${soloActivas}`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const getPromocionById = async (id: number): Promise<PromocionDto> => { | ||||
|     const response = await apiClient.get<PromocionDto>(`${API_URL}/${id}`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const createPromocion = async (data: CreatePromocionDto): Promise<PromocionDto> => { | ||||
|     const response = await apiClient.post<PromocionDto>(API_URL, data); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const updatePromocion = async (id: number, data: UpdatePromocionDto): Promise<void> => { | ||||
|     await apiClient.put(`${API_URL}/${id}`, data); | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     getAllPromociones, | ||||
|     getPromocionById, | ||||
|     createPromocion, | ||||
|     updatePromocion | ||||
| }; | ||||
| @@ -2,6 +2,7 @@ import apiClient from '../apiClient'; | ||||
| import type { SuscripcionDto } from '../../models/dtos/Suscripciones/SuscripcionDto'; | ||||
| import type { CreateSuscripcionDto } from '../../models/dtos/Suscripciones/CreateSuscripcionDto'; | ||||
| import type { UpdateSuscripcionDto } from '../../models/dtos/Suscripciones/UpdateSuscripcionDto'; | ||||
| import type { PromocionDto } from '../../models/dtos/Suscripciones/PromocionDto'; | ||||
|  | ||||
| const API_URL_BASE = '/suscripciones'; | ||||
| const API_URL_BY_SUSCRIPTOR = '/suscriptores'; // Para la ruta anidada | ||||
| @@ -25,9 +26,31 @@ const updateSuscripcion = async (id: number, data: UpdateSuscripcionDto): Promis | ||||
|     await apiClient.put(`${API_URL_BASE}/${id}`, data); | ||||
| }; | ||||
|  | ||||
| const getPromocionesAsignadas = async (idSuscripcion: number): Promise<PromocionDto[]> => { | ||||
|     const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const getPromocionesDisponibles = async (idSuscripcion: number): Promise<PromocionDto[]> => { | ||||
|     const response = await apiClient.get<PromocionDto[]>(`${API_URL_BASE}/${idSuscripcion}/promociones-disponibles`); | ||||
|     return response.data; | ||||
| }; | ||||
|  | ||||
| const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { | ||||
|     await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); | ||||
| }; | ||||
|  | ||||
| const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise<void> => { | ||||
|     await apiClient.delete(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     getSuscripcionesPorSuscriptor, | ||||
|     getSuscripcionById, | ||||
|     createSuscripcion, | ||||
|     updateSuscripcion, | ||||
|     getPromocionesAsignadas, | ||||
|     getPromocionesDisponibles, | ||||
|     asignarPromocion, | ||||
|     quitarPromocion | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user