From 84187a66df18a0dbb568939008f28a3fa23090cf Mon Sep 17 00:00:00 2001 From: dmolinari Date: Fri, 1 Aug 2025 12:53:17 -0300 Subject: [PATCH] =?UTF-8?q?Feat:=20Implementa=20flujo=20completo=20de=20fa?= =?UTF-8?q?cturaci=C3=B3n=20y=20promociones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Suscripciones/DebitosController.cs | 93 +++++ .../Suscripciones/FacturacionController.cs | 94 +++++ .../Suscripciones/PagosController.cs | 69 ++++ .../Suscripciones/PromocionesController.cs | 90 +++++ .../Suscripciones/SuscripcionesController.cs | 55 ++- .../Suscripciones/FacturaRepository.cs | 56 ++- .../Suscripciones/IFacturaRepository.cs | 2 + .../Suscripciones/IPromocionRepository.cs | 14 + .../Suscripciones/ISuscripcionRepository.cs | 3 + .../Suscripciones/PromocionRepository.cs | 93 +++++ .../Suscripciones/SuscripcionRepository.cs | 43 ++- .../GestionIntegral.Api.csproj | 1 + .../Models/Comunicaciones/MailSettings.cs | 12 + .../Dtos/Suscripciones/CreatePagoDto.cs | 29 ++ .../Dtos/Suscripciones/CreatePromocionDto.cs | 26 ++ .../Models/Dtos/Suscripciones/FacturaDto.cs | 22 ++ .../Models/Dtos/Suscripciones/PagoDto.cs | 17 + .../ProcesamientoLoteResponseDto.cs | 11 + .../Models/Dtos/Suscripciones/PromocionDto.cs | 13 + .../Models/Suscripciones/Promocion.cs | 15 + .../Suscripciones/SuscripcionPromocion.cs | 10 + Backend/GestionIntegral.Api/Program.cs | 13 +- .../Services/Comunicaciones/EmailService.cs | 50 +++ .../Services/Comunicaciones/IEmailService.cs | 7 + .../Suscripciones/DebitoAutomaticoService.cs | 307 +++++++++++++++++ .../Suscripciones/FacturacionService.cs | 243 +++++++++++++ .../Suscripciones/IDebitoAutomaticoService.cs | 10 + .../Suscripciones/IFacturacionService.cs | 11 + .../Services/Suscripciones/IPagoService.cs | 14 + .../Suscripciones/IPromocionService.cs | 12 + .../Suscripciones/ISuscripcionService.cs | 4 + .../Services/Suscripciones/PagoService.cs | 118 +++++++ .../Suscripciones/PromocionService.cs | 127 +++++++ .../Suscripciones/SuscripcionService.cs | 84 ++++- Backend/GestionIntegral.Api/appsettings.json | 10 +- .../GestionarPromocionesSuscripcionModal.tsx | 111 ++++++ .../Modals/Suscripciones/PagoManualModal.tsx | 141 ++++++++ .../Suscripciones/PromocionFormModal.tsx | 152 +++++++++ .../dtos/Suscripciones/CreatePagoDto.ts | 8 + .../dtos/Suscripciones/CreatePromocionDto.ts | 10 + .../models/dtos/Suscripciones/FacturaDto.ts | 14 + .../GenerarFacturacionResponseDto.ts | 4 + .../src/models/dtos/Suscripciones/PagoDto.ts | 13 + .../ProcesamientoLoteResponseDto.ts | 7 + .../models/dtos/Suscripciones/PromocionDto.ts | 9 + .../pages/Suscripciones/FacturacionPage.tsx | 321 ++++++++++++++++++ .../GestionarPromocionesPage.tsx | 157 +++++++++ .../GestionarSuscripcionesSuscriptorPage.tsx | 68 ++-- .../Suscripciones/SuscripcionesIndexPage.tsx | 4 +- Frontend/src/routes/AppRoutes.tsx | 13 +- .../Suscripciones/facturacionService.ts | 74 ++++ .../Suscripciones/promocionService.ts | 31 ++ .../Suscripciones/suscripcionService.ts | 23 ++ 53 files changed, 2895 insertions(+), 43 deletions(-) create mode 100644 Backend/GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs create mode 100644 Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs create mode 100644 Backend/GestionIntegral.Api/Controllers/Suscripciones/PagosController.cs create mode 100644 Backend/GestionIntegral.Api/Controllers/Suscripciones/PromocionesController.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs create mode 100644 Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs create mode 100644 Backend/GestionIntegral.Api/Models/Comunicaciones/MailSettings.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePagoDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PagoDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ProcesamientoLoteResponseDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs create mode 100644 Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs create mode 100644 Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs create mode 100644 Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/IDebitoAutomaticoService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/IPagoService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/IPromocionService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs create mode 100644 Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs create mode 100644 Frontend/src/components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal.tsx create mode 100644 Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx create mode 100644 Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx create mode 100644 Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/FacturaDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/GenerarFacturacionResponseDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/PagoDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/ProcesamientoLoteResponseDto.ts create mode 100644 Frontend/src/models/dtos/Suscripciones/PromocionDto.ts create mode 100644 Frontend/src/pages/Suscripciones/FacturacionPage.tsx create mode 100644 Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx create mode 100644 Frontend/src/services/Suscripciones/facturacionService.ts create mode 100644 Frontend/src/services/Suscripciones/promocionService.ts diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs new file mode 100644 index 0000000..9122115 --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/DebitosController.cs @@ -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 _logger; + + // Permiso para generar archivos de débito (a crear en BD) + private const string PermisoGenerarDebitos = "SU007"; + + public DebitosController(IDebitoAutomaticoService debitoService, ILogger 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 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 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); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs new file mode 100644 index 0000000..e7e084e --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs @@ -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 _logger; + + // Permiso para generar facturación (a crear en la BD) + private const string PermisoGenerarFacturacion = "SU006"; + + public FacturacionController(IFacturacionService facturacionService, ILogger 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 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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 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." }); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/PagosController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/PagosController.cs new file mode 100644 index 0000000..df654fa --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/PagosController.cs @@ -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 _logger; + + // Permiso para registrar pagos manuales (a crear en BD) + private const string PermisoRegistrarPago = "SU008"; + + public PagosController(IPagoService pagoService, ILogger 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task 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 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); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/PromocionesController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/PromocionesController.cs new file mode 100644 index 0000000..ca8f0cb --- /dev/null +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/PromocionesController.cs @@ -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 _logger; + + // Permiso a crear en BD + private const string PermisoGestionarPromociones = "SU010"; + + public PromocionesController(IPromocionService promocionService, ILogger 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 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 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 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 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(); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs index 24beb70..8745cb6 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/SuscripcionesController.cs @@ -17,7 +17,7 @@ namespace GestionIntegral.Api.Controllers.Suscripciones private readonly ILogger _logger; // Permisos (nuevos, a crear en la BD) - private const string PermisoGestionarSuscripciones = "SU005"; + private const string PermisoGestionarSuscripciones = "SU005"; public SuscripcionesController(ISuscripcionService suscripcionService, ILogger logger) { @@ -26,7 +26,7 @@ namespace GestionIntegral.Api.Controllers.Suscripciones } 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; @@ -41,7 +41,7 @@ namespace GestionIntegral.Api.Controllers.Suscripciones { // Se podría usar el permiso de ver suscriptores (SU001) o el de gestionar suscripciones (SU005) if (!TienePermiso("SU001")) return Forbid(); - + var suscripciones = await _suscripcionService.ObtenerPorSuscriptorId(idSuscriptor); return Ok(suscripciones); } @@ -62,15 +62,15 @@ namespace GestionIntegral.Api.Controllers.Suscripciones { if (!TienePermiso(PermisoGestionarSuscripciones)) return Forbid(); if (!ModelState.IsValid) return BadRequest(ModelState); - + var userId = GetCurrentUserId(); if (userId == null) return Unauthorized(); var (dto, error) = await _suscripcionService.Crear(createDto, userId.Value); - + if (error != null) return BadRequest(new { message = error }); if (dto == null) return StatusCode(StatusCodes.Status500InternalServerError, "Error al crear la suscripción."); - + return CreatedAtRoute("GetSuscripcionById", new { id = dto.IdSuscripcion }, dto); } @@ -85,7 +85,7 @@ namespace GestionIntegral.Api.Controllers.Suscripciones if (userId == null) return Unauthorized(); var (exito, error) = await _suscripcionService.Actualizar(id, updateDto, userId.Value); - + if (!exito) { if (error != null && error.Contains("no encontrada")) return NotFound(new { message = error }); @@ -93,5 +93,46 @@ namespace GestionIntegral.Api.Controllers.Suscripciones } return NoContent(); } + + // GET: api/suscripciones/{idSuscripcion}/promociones + [HttpGet("{idSuscripcion:int}/promociones")] + public async Task 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 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 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 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(); + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs index 15ef425..ff65cd3 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs @@ -34,7 +34,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones public async Task 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."); } @@ -55,7 +55,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones VALUES (@IdSuscripcion, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @Estado);"; - + return await transaction.Connection.QuerySingleAsync(sqlInsert, nuevaFactura, transaction); } @@ -80,7 +80,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones var rowsAffected = await transaction.Connection.ExecuteAsync(sql, new { NumeroFactura = numeroFactura, IdFactura = idFactura }, transaction); return rowsAffected == 1; } - + public async Task UpdateLoteDebitoAsync(IEnumerable idsFacturas, int idLoteDebito, IDbTransaction transaction) { if (transaction == null || transaction.Connection == null) @@ -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> 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( + 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 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; + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs index 1c4a28b..b8d331a 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs @@ -12,5 +12,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones Task UpdateEstadoAsync(int idFactura, string nuevoEstado, IDbTransaction transaction); Task UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); Task UpdateLoteDebitoAsync(IEnumerable idsFacturas, int idLoteDebito, IDbTransaction transaction); + Task> GetByPeriodoEnrichedAsync(string periodo); + Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstado, string? motivoRechazo, IDbTransaction transaction); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs new file mode 100644 index 0000000..74b800b --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IPromocionRepository.cs @@ -0,0 +1,14 @@ +using GestionIntegral.Api.Models.Suscripciones; +using System.Data; + +namespace GestionIntegral.Api.Data.Repositories.Suscripciones +{ + public interface IPromocionRepository + { + Task> GetAllAsync(bool soloActivas); + Task GetByIdAsync(int id); + Task CreateAsync(Promocion nuevaPromocion, IDbTransaction transaction); + Task UpdateAsync(Promocion promocion, IDbTransaction transaction); + Task> GetPromocionesActivasParaSuscripcion(int idSuscripcion, DateTime fechaPeriodo, IDbTransaction transaction); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs index 7e421b2..0c007b2 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/ISuscripcionRepository.cs @@ -10,5 +10,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones Task CreateAsync(Suscripcion nuevaSuscripcion, IDbTransaction transaction); Task UpdateAsync(Suscripcion suscripcionAActualizar, IDbTransaction transaction); Task> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction); + Task> GetPromocionesBySuscripcionIdAsync(int idSuscripcion); + Task AsignarPromocionAsync(int idSuscripcion, int idPromocion, int idUsuario, IDbTransaction transaction); + Task QuitarPromocionAsync(int idSuscripcion, int idPromocion, IDbTransaction transaction); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs new file mode 100644 index 0000000..de9d644 --- /dev/null +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/PromocionRepository.cs @@ -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 _logger; + + public PromocionRepository(DbConnectionFactory factory, ILogger logger) + { + _connectionFactory = factory; + _logger = logger; + } + + public async Task> 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(sql.ToString()); + } + + public async Task GetByIdAsync(int id) + { + const string sql = "SELECT * FROM dbo.susc_Promociones WHERE IdPromocion = @Id;"; + using var connection = _connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + } + + public async Task 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(sql, nuevaPromocion, transaction); + } + + public async Task 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> 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(sql, new { IdSuscripcion = idSuscripcion, FechaPeriodo = fechaPeriodo }, transaction); + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs index 7f98165..e27ebba 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/SuscripcionRepository.cs @@ -44,7 +44,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return Enumerable.Empty(); } } - + public async Task> GetAllActivasParaFacturacion(string periodo, IDbTransaction transaction) { // Lógica para determinar el rango del período (ej. '2023-11') @@ -61,7 +61,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones AND su.Activo = 1 AND s.FechaInicio <= @UltimoDiaMes AND (s.FechaFin IS NULL OR s.FechaFin >= @PrimerDiaMes);"; - + if (transaction == null || transaction.Connection == null) { throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); @@ -85,7 +85,7 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones VALUES (@IdSuscriptor, @IdPublicacion, @FechaInicio, @FechaFin, @Estado, @DiasEntrega, @Observaciones, @IdUsuarioAlta, GETDATE());"; - + return await transaction.Connection.QuerySingleAsync(sqlInsert, nuevaSuscripcion, 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> 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(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 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; + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/GestionIntegral.Api.csproj b/Backend/GestionIntegral.Api/GestionIntegral.Api.csproj index 91aad60..808f888 100644 --- a/Backend/GestionIntegral.Api/GestionIntegral.Api.csproj +++ b/Backend/GestionIntegral.Api/GestionIntegral.Api.csproj @@ -9,6 +9,7 @@ + diff --git a/Backend/GestionIntegral.Api/Models/Comunicaciones/MailSettings.cs b/Backend/GestionIntegral.Api/Models/Comunicaciones/MailSettings.cs new file mode 100644 index 0000000..3ead8d8 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Comunicaciones/MailSettings.cs @@ -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; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePagoDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePagoDto.cs new file mode 100644 index 0000000..44a7b76 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePagoDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs new file mode 100644 index 0000000..f468b86 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/CreatePromocionDto.cs @@ -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; \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs new file mode 100644 index 0000000..dd0296f --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaDto.cs @@ -0,0 +1,22 @@ +namespace GestionIntegral.Api.Dtos.Suscripciones +{ + /// + /// 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. + /// + 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; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PagoDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PagoDto.cs new file mode 100644 index 0000000..5859101 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PagoDto.cs @@ -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 + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ProcesamientoLoteResponseDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ProcesamientoLoteResponseDto.cs new file mode 100644 index 0000000..066c51e --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/ProcesamientoLoteResponseDto.cs @@ -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 Errores { get; set; } = new List(); + public string MensajeResumen { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs new file mode 100644 index 0000000..0e1ed5a --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/PromocionDto.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs new file mode 100644 index 0000000..7251c16 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Promocion.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs new file mode 100644 index 0000000..0a25357 --- /dev/null +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/SuscripcionPromocion.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Program.cs b/Backend/GestionIntegral.Api/Program.cs index da98088..fe0c8e1 100644 --- a/Backend/GestionIntegral.Api/Program.cs +++ b/Backend/GestionIntegral.Api/Program.cs @@ -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(); builder.Services.AddScoped(); // --- Suscripciones --- -// Repositorios builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); -// Servicios builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// --- Comunicaciones --- +builder.Services.Configure(builder.Configuration.GetSection("MailSettings")); +builder.Services.AddTransient(); // --- SERVICIO DE HEALTH CHECKS --- // Añadimos una comprobación específica para SQL Server. diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs new file mode 100644 index 0000000..7010442 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/EmailService.cs @@ -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 _logger; + + public EmailService(IOptions mailSettings, ILogger 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); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs new file mode 100644 index 0000000..502ce01 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Comunicaciones/IEmailService.cs @@ -0,0 +1,7 @@ +namespace GestionIntegral.Api.Services.Comunicaciones +{ + public interface IEmailService + { + Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs new file mode 100644 index 0000000..e8b28b1 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs @@ -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 _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 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> 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 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; + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs new file mode 100644 index 0000000..0dcc5ef --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -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 _logger; + + public FacturacionService( + ISuscripcionRepository suscripcionRepository, + IFacturaRepository facturaRepository, + IPrecioRepository precioRepository, + IPromocionRepository promocionRepository, + IRecargoZonaRepository recargoZonaRepository, + ISuscriptorRepository suscriptorRepository, + DbConnectionFactory connectionFactory, + IEmailService emailService, + ILogger 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 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> 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 = $@" +

Hola {suscriptor.NombreCompleto},

+

Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.

+
    +
  • Número de Factura: {factura.NumeroFactura}
  • +
  • Importe Total: ${factura.ImporteFinal:N2}
  • +
  • Fecha de Vencimiento: {factura.FechaVencimiento:dd/MM/yyyy}
  • +
+

Gracias por ser parte de nuestra comunidad de lectores.

+

Diario El Día

"; + + 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 + }; + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IDebitoAutomaticoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IDebitoAutomaticoService.cs new file mode 100644 index 0000000..e2b93b4 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IDebitoAutomaticoService.cs @@ -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 ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs new file mode 100644 index 0000000..9411cd3 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs @@ -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> ObtenerFacturasPorPeriodo(int anio, int mes); + Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IPagoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IPagoService.cs new file mode 100644 index 0000000..42ed624 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IPagoService.cs @@ -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> ObtenerPagosPorFacturaId(int idFactura); + Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IPromocionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IPromocionService.cs new file mode 100644 index 0000000..c733bbf --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IPromocionService.cs @@ -0,0 +1,12 @@ +using GestionIntegral.Api.Dtos.Suscripciones; + +namespace GestionIntegral.Api.Services.Suscripciones +{ + public interface IPromocionService + { + Task> ObtenerTodas(bool soloActivas); + Task 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); + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs index 925666e..36c1d08 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/ISuscripcionService.cs @@ -8,5 +8,9 @@ namespace GestionIntegral.Api.Services.Suscripciones Task 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> ObtenerPromocionesAsignadas(int idSuscripcion); + Task> 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); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs new file mode 100644 index 0000000..3314b90 --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/PagoService.cs @@ -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 _logger; + + public PagoService( + IPagoRepository pagoRepository, + IFacturaRepository facturaRepository, + IFormaPagoRepository formaPagoRepository, + IUsuarioRepository usuarioRepository, + DbConnectionFactory connectionFactory, + ILogger logger) + { + _pagoRepository = pagoRepository; + _facturaRepository = facturaRepository; + _formaPagoRepository = formaPagoRepository; + _usuarioRepository = usuarioRepository; + _connectionFactory = connectionFactory; + _logger = logger; + } + + private async Task 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> 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}"); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs new file mode 100644 index 0000000..aed3abb --- /dev/null +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/PromocionService.cs @@ -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 _logger; + + public PromocionService( + IPromocionRepository promocionRepository, + DbConnectionFactory connectionFactory, + ILogger 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> ObtenerTodas(bool soloActivas) + { + var promociones = await _promocionRepository.GetAllAsync(soloActivas); + return promociones.Select(MapToDto); + } + + public async Task 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}"); + } + } + } +} \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs index 158e6a5..110566d 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs @@ -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 _logger; @@ -19,16 +20,29 @@ namespace GestionIntegral.Api.Services.Suscripciones ISuscripcionRepository suscripcionRepository, ISuscriptorRepository suscriptorRepository, IPublicacionRepository publicacionRepository, + IPromocionRepository promocionRepository, DbConnectionFactory connectionFactory, ILogger 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 MapToDto(Suscripcion suscripcion) { if (suscripcion == null) return null; @@ -91,7 +105,7 @@ namespace GestionIntegral.Api.Services.Suscripciones { var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); if (creada == null) throw new DataException("Error al crear la suscripción."); - + transaction.Commit(); _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); return (await MapToDto(creada), null); @@ -108,7 +122,7 @@ namespace GestionIntegral.Api.Services.Suscripciones { var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion); if (existente == null) return (false, "Suscripció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."); @@ -139,5 +153,71 @@ namespace GestionIntegral.Api.Services.Suscripciones return (false, $"Error interno: {ex.Message}"); } } + + public async Task> ObtenerPromocionesAsignadas(int idSuscripcion) + { + var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion); + return promociones.Select(MapPromocionToDto); + } + + public async Task> 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."); + } + } } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/appsettings.json b/Backend/GestionIntegral.Api/appsettings.json index 8b8cdb8..6536e1c 100644 --- a/Backend/GestionIntegral.Api/appsettings.json +++ b/Backend/GestionIntegral.Api/appsettings.json @@ -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" +} } \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal.tsx b/Frontend/src/components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal.tsx new file mode 100644 index 0000000..f01622e --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/GestionarPromocionesSuscripcionModal.tsx @@ -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 = ({ open, onClose, suscripcion }) => { + const [asignadas, setAsignadas] = useState([]); + const [disponibles, setDisponibles] = useState([]); + const [selectedPromo, setSelectedPromo] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + Gestionar Promociones + + Suscripción a: {suscripcion.nombrePublicacion} + + {error && {error}} + {loading ? : ( + <> + Promociones Asignadas + + {asignadas.length === 0 && } + {asignadas.map(p => ( + handleQuitar(p.idPromocion)}>}> + + + ))} + + + Asignar Nueva Promoción + + + Promociones Disponibles + + + + + + )} + + + + + + ); +}; + +export default GestionarPromocionesSuscripcionModal; \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx new file mode 100644 index 0000000..25630b6 --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx @@ -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; + factura: FacturaDto | null; + errorMessage?: string | null; + clearErrorMessage: () => void; +} + +const PagoManualModal: React.FC = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { + const [formData, setFormData] = useState>({}); + const [formasDePago, setFormasDePago] = useState([]); + 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) => { + 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) => { + 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) => { + 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 ( + + + Registrar Pago Manual + + Factura #{factura.idFactura} para {factura.nombreSuscriptor} + + + + + Forma de Pago + + + $ }} inputProps={{ step: "0.01" }} /> + + + + {errorMessage && {errorMessage}} + + + + + + + + + ); +}; + +export default PagoManualModal; \ No newline at end of file diff --git a/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx b/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx new file mode 100644 index 0000000..d7d6ce6 --- /dev/null +++ b/Frontend/src/components/Modals/Suscripciones/PromocionFormModal.tsx @@ -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; + initialData?: PromocionDto | null; + errorMessage?: string | null; + clearErrorMessage: () => void; +} + +const PromocionFormModal: React.FC = ({ open, onClose, onSubmit, initialData, errorMessage, clearErrorMessage }) => { + const [formData, setFormData] = useState>({}); + 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) => { + 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) => { + 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) => { + 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 ( + + + {isEditing ? 'Editar Promoción' : 'Nueva Promoción'} + + + + + + Tipo + + + {formData.tipoPromocion === 'Porcentaje' ? '%' : '$'} }} + inputProps={{ step: "0.01" }} + /> + + + + + + + + } label="Promoción Activa" sx={{mt: 1}} /> + + {errorMessage && {errorMessage}} + + + + + + + + + ); +}; + +export default PromocionFormModal; \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts b/Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts new file mode 100644 index 0000000..965c3bd --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/CreatePagoDto.ts @@ -0,0 +1,8 @@ +export interface CreatePagoDto { + idFactura: number; + fechaPago: string; // "yyyy-MM-dd" + idFormaPago: number; + monto: number; + referencia?: string | null; + observaciones?: string | null; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts b/Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts new file mode 100644 index 0000000..d83951a --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/CreatePromocionDto.ts @@ -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; \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/FacturaDto.ts b/Frontend/src/models/dtos/Suscripciones/FacturaDto.ts new file mode 100644 index 0000000..8d207a0 --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/FacturaDto.ts @@ -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; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/GenerarFacturacionResponseDto.ts b/Frontend/src/models/dtos/Suscripciones/GenerarFacturacionResponseDto.ts new file mode 100644 index 0000000..de9dd37 --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/GenerarFacturacionResponseDto.ts @@ -0,0 +1,4 @@ +export interface GenerarFacturacionResponseDto { + message: string; + facturasGeneradas: number; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/PagoDto.ts b/Frontend/src/models/dtos/Suscripciones/PagoDto.ts new file mode 100644 index 0000000..5efdf49 --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/PagoDto.ts @@ -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; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/ProcesamientoLoteResponseDto.ts b/Frontend/src/models/dtos/Suscripciones/ProcesamientoLoteResponseDto.ts new file mode 100644 index 0000000..f1f0cb1 --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/ProcesamientoLoteResponseDto.ts @@ -0,0 +1,7 @@ +export interface ProcesamientoLoteResponseDto { + totalRegistrosLeidos: number; + pagosAprobados: number; + pagosRechazados: number; + errores: string[]; + mensajeResumen: string; +} \ No newline at end of file diff --git a/Frontend/src/models/dtos/Suscripciones/PromocionDto.ts b/Frontend/src/models/dtos/Suscripciones/PromocionDto.ts new file mode 100644 index 0000000..ac59f4a --- /dev/null +++ b/Frontend/src/models/dtos/Suscripciones/PromocionDto.ts @@ -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; +} \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/FacturacionPage.tsx b/Frontend/src/pages/Suscripciones/FacturacionPage.tsx new file mode 100644 index 0000000..3839c9b --- /dev/null +++ b/Frontend/src/pages/Suscripciones/FacturacionPage.tsx @@ -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(new Date().getFullYear()); + const [selectedMes, setSelectedMes] = useState(new Date().getMonth() + 1); + const [loading, setLoading] = useState(false); + const [loadingArchivo, setLoadingArchivo] = useState(false); + const [loadingProceso, setLoadingProceso] = useState(false); + const [apiMessage, setApiMessage] = useState(null); + const [apiError, setApiError] = useState(null); + const [facturas, setFacturas] = useState([]); + 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(null); + const [anchorEl, setAnchorEl] = useState(null); + const [archivoSeleccionado, setArchivoSeleccionado] = useState(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, 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) => { + 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 No tiene permiso para acceder a esta sección.; + } + + return ( + + Facturación y Débito Automático + + 1. Generación de Facturación + + Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado. + + + + Mes + + + + Año + + + + + + + 2. Generación de Archivo para Banco + 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. + + + + + 3. Procesar Respuesta del Banco + + Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada". + + + + {archivoSeleccionado && {archivoSeleccionado.name}} + + {archivoSeleccionado && ( + + )} + + + {apiError && {apiError}} + {apiMessage && {apiMessage}} + + Facturas del Período + + + + + IDSuscriptorPublicación + ImporteEstadoNro. Factura + Acciones + + + + {loading ? () + : facturas.length === 0 ? (No hay facturas para el período seleccionado.) + : (facturas.map(f => ( + + {f.idFactura} + {f.nombreSuscriptor} + {f.nombrePublicacion} + ${f.importeFinal.toFixed(2)} + + {f.numeroFactura || '-'} + + handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}> + + + + + )))} + +
+
+ + {selectedFactura && puedeRegistrarPago && ( + + + Registrar Pago Manual + + )} + {selectedFactura && puedeEnviarEmail && ( + handleSendEmail(selectedFactura.idFactura)} + disabled={!selectedFactura.numeroFactura} + > + + Enviar Email + + )} + + setApiError(null)} /> +
+ ); +}; + +export default FacturacionPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx b/Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx new file mode 100644 index 0000000..00d66ac --- /dev/null +++ b/Frontend/src/pages/Suscripciones/GestionarPromocionesPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filtroSoloActivas, setFiltroSoloActivas] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editingPromocion, setEditingPromocion] = useState(null); + const [apiErrorMessage, setApiErrorMessage] = useState(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 ; + if (error) return {error}; + if (!puedeGestionar) return Acceso Denegado.; + + return ( + + Gestionar Promociones + + setFiltroSoloActivas(e.target.checked)} />} label="Ver Solo Activas" /> + + + + {apiErrorMessage && {apiErrorMessage}} + + + + + + Descripción + Tipo + Valor + Inicio + Fin + Estado + Acciones + + + + {promociones.length === 0 ? ( + No se encontraron promociones. + ) : ( + promociones.map(p => ( + + {p.descripcion} + {formatTipo(p.tipoPromocion)} + {p.tipoPromocion === 'Porcentaje' ? `${p.valor}%` : `$${p.valor.toFixed(2)}`} + {formatDate(p.fechaInicio)} + {formatDate(p.fechaFin)} + + + + + + handleOpenModal(p)}> + + + + + + )) + )} + +
+
+ + setApiErrorMessage(null)} + /> +
+ ); +}; + +export default GestionarPromocionesPage; \ No newline at end of file diff --git a/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx b/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx index 261c60f..caa7a95 100644 --- a/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx +++ b/Frontend/src/pages/Suscripciones/GestionarSuscripcionesSuscriptorPage.tsx @@ -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(null); const [apiErrorMessage, setApiErrorMessage] = useState(null); + const [promocionesModalOpen, setPromocionesModalOpen] = useState(false); + const [selectedSuscripcion, setSelectedSuscripcion] = useState(null); + const { tienePermiso, isSuperAdmin } = usePermissions(); const puedeVer = isSuperAdmin || tienePermiso("SU001"); const puedeGestionar = isSuperAdmin || tienePermiso("SU005"); @@ -54,11 +59,11 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { }, [idSuscriptor, puedeVer]); 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 ; - if (error) return {error}; - if (!puedeVer) return Acceso Denegado. + if (error) return {error}; + if (!puedeVer) return Acceso Denegado. return ( @@ -102,22 +112,22 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { Documento: {suscriptor?.tipoDocumento} {suscriptor?.nroDocumento} | Dirección: {suscriptor?.direccion} - + {puedeGestionar && } - - {apiErrorMessage && {apiErrorMessage}} + + {apiErrorMessage && {apiErrorMessage}} - Publicación - Estado - Días Entrega - Inicio - Fin - Observaciones - Acciones + Publicación + Estado + Días Entrega + Inicio + Fin + Observaciones + Acciones @@ -128,10 +138,10 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { {s.nombrePublicacion} - {s.diasEntrega.split(',').join(', ')} @@ -147,14 +157,23 @@ const GestionarSuscripcionesSuscriptorPage: React.FC = () => { + + + handleOpenModal(s)} disabled={!puedeGestionar}> + + + handleOpenPromocionesModal(s)} disabled={!puedeGestionar}> + + + )) )}
- {idSuscriptor && + {idSuscriptor && { clearErrorMessage={() => setApiErrorMessage(null)} /> } + setPromocionesModalOpen(false)} + suscripcion={selectedSuscripcion} + />
); }; diff --git a/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx b/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx index 1ff9279..a200b51 100644 --- a/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx +++ b/Frontend/src/pages/Suscripciones/SuscripcionesIndexPage.tsx @@ -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 = () => { diff --git a/Frontend/src/routes/AppRoutes.tsx b/Frontend/src/routes/AppRoutes.tsx index e8f4239..c696f70 100644 --- a/Frontend/src/routes/AppRoutes.tsx +++ b/Frontend/src/routes/AppRoutes.tsx @@ -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 = () => { } /> - {/* Aquí irán las otras sub-rutas como 'facturacion', etc. */} + + + + } /> + + + + } /> {/* Módulo Contable (anidado) */} diff --git a/Frontend/src/services/Suscripciones/facturacionService.ts b/Frontend/src/services/Suscripciones/facturacionService.ts new file mode 100644 index 0000000..578da0a --- /dev/null +++ b/Frontend/src/services/Suscripciones/facturacionService.ts @@ -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 => { + const formData = new FormData(); + formData.append('archivo', archivo); + + const response = await apiClient.post(`${DEBITOS_URL}/procesar-respuesta`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +}; + +const getFacturasPorPeriodo = async (anio: number, mes: number): Promise => { + const response = await apiClient.get(`${API_URL}/${anio}/${mes}`); + return response.data; +}; + +const generarFacturacionMensual = async (anio: number, mes: number): Promise => { + const response = await apiClient.post(`${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 => { + const response = await apiClient.get(`${FACTURAS_URL}/${idFactura}/pagos`); + return response.data; +}; + +const registrarPagoManual = async (data: CreatePagoDto): Promise => { + const response = await apiClient.post(PAGOS_URL, data); + return response.data; +}; + +const enviarFacturaPorEmail = async (idFactura: number): Promise => { + await apiClient.post(`${API_URL}/${idFactura}/enviar-email`); +}; + +export default { + procesarArchivoRespuesta, + getFacturasPorPeriodo, + generarFacturacionMensual, + generarArchivoDebito, + getPagosPorFactura, + registrarPagoManual, + enviarFacturaPorEmail, +}; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/promocionService.ts b/Frontend/src/services/Suscripciones/promocionService.ts new file mode 100644 index 0000000..cefcbb9 --- /dev/null +++ b/Frontend/src/services/Suscripciones/promocionService.ts @@ -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 => { + const response = await apiClient.get(`${API_URL}?soloActivas=${soloActivas}`); + return response.data; +}; + +const getPromocionById = async (id: number): Promise => { + const response = await apiClient.get(`${API_URL}/${id}`); + return response.data; +}; + +const createPromocion = async (data: CreatePromocionDto): Promise => { + const response = await apiClient.post(API_URL, data); + return response.data; +}; + +const updatePromocion = async (id: number, data: UpdatePromocionDto): Promise => { + await apiClient.put(`${API_URL}/${id}`, data); +}; + +export default { + getAllPromociones, + getPromocionById, + createPromocion, + updatePromocion +}; \ No newline at end of file diff --git a/Frontend/src/services/Suscripciones/suscripcionService.ts b/Frontend/src/services/Suscripciones/suscripcionService.ts index 01d2adb..71c6f77 100644 --- a/Frontend/src/services/Suscripciones/suscripcionService.ts +++ b/Frontend/src/services/Suscripciones/suscripcionService.ts @@ -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 => { + const response = await apiClient.get(`${API_URL_BASE}/${idSuscripcion}/promociones`); + return response.data; +}; + +const getPromocionesDisponibles = async (idSuscripcion: number): Promise => { + const response = await apiClient.get(`${API_URL_BASE}/${idSuscripcion}/promociones-disponibles`); + return response.data; +}; + +const asignarPromocion = async (idSuscripcion: number, idPromocion: number): Promise => { + await apiClient.post(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); +}; + +const quitarPromocion = async (idSuscripcion: number, idPromocion: number): Promise => { + await apiClient.delete(`${API_URL_BASE}/${idSuscripcion}/promociones/${idPromocion}`); +}; + export default { getSuscripcionesPorSuscriptor, getSuscripcionById, createSuscripcion, updateSuscripcion, + getPromocionesAsignadas, + getPromocionesDisponibles, + asignarPromocion, + quitarPromocion }; \ No newline at end of file