From e95c851e5b381c90f0b87d0ade3d75c16bdc7bbf Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 13 Aug 2025 14:55:24 -0300 Subject: [PATCH] =?UTF-8?q?Feat(suscripciones):=20Implementa=20facturaci?= =?UTF-8?q?=C3=B3n=20pro-rata=20para=20altas=20y=20excluye=20del=20d=C3=A9?= =?UTF-8?q?bito=20autom=C3=A1tico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se introduce una refactorización mayor del ciclo de facturación para manejar correctamente las suscripciones que inician en un período ya cerrado. Esto soluciona el problema de cobrar un mes completo a un nuevo suscriptor, mejorando la transparencia y la experiencia del cliente. ### ✨ Nuevas Características y Lógica de Negocio - **Facturación Pro-rata Automática (Factura de Alta):** - Al crear una nueva suscripción cuya fecha de inicio corresponde a un período de facturación ya cerrado, el sistema ahora calcula automáticamente el costo proporcional por los días restantes de ese mes. - Se genera de forma inmediata una nueva factura de tipo "Alta" por este monto parcial, separándola del ciclo de facturación mensual regular. - **Exclusión del Débito Automático para Facturas de Alta:** - Se implementa una regla de negocio clave: las facturas de tipo "Alta" son **excluidas** del proceso de generación del archivo de débito automático para el banco. - Esto fuerza a que el primer cobro (el proporcional) se gestione a través de un medio de pago manual (efectivo, transferencia, etc.), evitando cargos inesperados en la cuenta bancaria del cliente. - El débito automático comenzará a operar normalmente a partir del primer ciclo de facturación completo. ### 🔄 Cambios en el Backend - **Base de Datos:** - Se ha añadido la columna `TipoFactura` (`varchar(20)`) a la tabla `susc_Facturas`. - Se ha implementado una `CHECK constraint` para permitir únicamente los valores 'Mensual' y 'Alta'. - **Servicios:** - **`SuscripcionService`:** Ahora contiene la lógica para detectar una alta retroactiva, invocar al `FacturacionService` para el cálculo pro-rata y crear la "Factura de Alta" y su detalle correspondiente dentro de la misma transacción. - **`FacturacionService`:** Expone públicamente el método `CalcularImporteParaSuscripcion` y se ha actualizado `ObtenerResumenesDeCuentaPorPeriodo` para que envíe la propiedad `TipoFactura` al frontend. - **`DebitoAutomaticoService`:** El método `GetFacturasParaDebito` ahora filtra y excluye explícitamente las facturas donde `TipoFactura = 'Alta'`. ### 🎨 Mejoras en la Interfaz de Usuario (Frontend) - **`ConsultaFacturasPage.tsx`:** - **Nueva Columna:** Se ha añadido una columna "Tipo Factura" en la tabla de detalle, que muestra un `Chip` distintivo para identificar fácilmente las facturas de "Alta". - **Nuevo Filtro:** Se ha agregado un nuevo menú desplegable para filtrar la vista por "Tipo de Factura" (`Todas`, `Mensual`, `Alta`), permitiendo a los administradores auditar rápidamente los nuevos ingresos. --- .../Suscripciones/FacturacionController.cs | 11 +-- .../Suscripciones/FacturaRepository.cs | 9 +- .../Suscripciones/IFacturaRepository.cs | 3 +- .../Suscripciones/FacturaConsolidadaDto.cs | 2 + .../Models/Suscripciones/Factura.cs | 1 + .../Suscripciones/DebitoAutomaticoService.cs | 17 +++- .../Suscripciones/FacturacionService.cs | 16 +++- .../Suscripciones/IFacturacionService.cs | 6 +- .../Suscripciones/SuscripcionService.cs | 76 ++++++++++++++--- .../Modals/Suscripciones/PagoManualModal.tsx | 65 +++++++------- .../ResumenCuentaSuscriptorDto.ts | 1 + .../Suscripciones/ConsultaFacturasPage.tsx | 84 +++++++------------ .../Suscripciones/facturacionService.ts | 11 ++- 13 files changed, 187 insertions(+), 115 deletions(-) diff --git a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs index a8315c2..3b4c2de 100644 --- a/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs +++ b/Backend/GestionIntegral.Api/Controllers/Suscripciones/FacturacionController.cs @@ -72,15 +72,16 @@ namespace GestionIntegral.Api.Controllers.Suscripciones [HttpGet("{anio:int}/{mes:int}")] public async Task GetFacturas( - int anio, int mes, - [FromQuery] string? nombreSuscriptor, - [FromQuery] string? estadoPago, - [FromQuery] string? estadoFacturacion) + int anio, int mes, + [FromQuery] string? nombreSuscriptor, + [FromQuery] string? estadoPago, + [FromQuery] string? estadoFacturacion, + [FromQuery] string? tipoFactura) { if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); - var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion); + var resumenes = await _facturacionService.ObtenerResumenesDeCuentaPorPeriodo(anio, mes, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); return Ok(resumenes); } diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs index 6e6a174..f62c7f6 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/FacturaRepository.cs @@ -104,7 +104,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones return rowsAffected == idsFacturas.Count(); } - public async Task> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) + public async Task> GetByPeriodoEnrichedAsync( + string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) { var sqlBuilder = new StringBuilder(@" WITH FacturaConEmpresa AS ( @@ -149,6 +150,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones parameters.Add("EstadoFacturacion", estadoFacturacion); } + if (!string.IsNullOrWhiteSpace(tipoFactura)) + { + sqlBuilder.Append(" AND f.TipoFactura = @TipoFactura"); + parameters.Add("TipoFactura", tipoFactura); + } + sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;"); try diff --git a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs index 53328c3..1572d4a 100644 --- a/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs +++ b/Backend/GestionIntegral.Api/Data/Repositories/Suscripciones/IFacturaRepository.cs @@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones Task UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); Task UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); Task UpdateLoteDebitoAsync(IEnumerable idsFacturas, int idLoteDebito, IDbTransaction transaction); - Task> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); + Task> GetByPeriodoEnrichedAsync( + string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); Task UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction); Task GetUltimoPeriodoFacturadoAsync(); Task> GetFacturasPagadasPendientesDeFacturar(string periodo); diff --git a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs index e02bd06..0494c17 100644 --- a/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs +++ b/Backend/GestionIntegral.Api/Models/Dtos/Suscripciones/FacturaConsolidadaDto.cs @@ -9,6 +9,8 @@ namespace GestionIntegral.Api.Dtos.Suscripciones public string EstadoFacturacion { get; set; } = string.Empty; public string? NumeroFactura { get; set; } public decimal TotalPagado { get; set; } + public string TipoFactura { get; set; } = string.Empty; + public int IdSuscriptor { get; set; } public List Detalles { get; set; } = new List(); } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs b/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs index 73940fe..f70229a 100644 --- a/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs +++ b/Backend/GestionIntegral.Api/Models/Suscripciones/Factura.cs @@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones public string? NumeroFactura { get; set; } public int? IdLoteDebito { get; set; } public string? MotivoRechazo { get; set; } + public string TipoFactura { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs index dfe8abe..0dbecd0 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs @@ -42,7 +42,7 @@ namespace GestionIntegral.Api.Services.Suscripciones { // Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1. const int identificacionArchivo = 1; - + var periodo = $"{anio}-{mes:D2}"; var fechaGeneracion = DateTime.Now; @@ -102,7 +102,11 @@ namespace GestionIntegral.Api.Services.Suscripciones var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); var resultado = new List<(Factura, Suscriptor)>(); - foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada")) + // Filtramos por estado Y POR TIPO DE FACTURA + foreach (var f in facturas.Where(fa => + (fa.EstadoPago == "Pendiente" || fa.EstadoPago == "Pagada Parcialmente" || fa.EstadoPago == "Rechazada") && + fa.TipoFactura == "Mensual" + )) { var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor); if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) @@ -141,7 +145,12 @@ namespace GestionIntegral.Api.Services.Suscripciones private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0'); private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch { - "DNI" => "0096", "CUIT" => "0080", "CUIL" => "0086", "LE" => "0089", "LC" => "0090", _ => "0000" + "DNI" => "0096", + "CUIT" => "0080", + "CUIL" => "0086", + "LE" => "0089", + "LC" => "0090", + _ => "0000" }; private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) @@ -212,7 +221,7 @@ namespace GestionIntegral.Api.Services.Suscripciones public async Task ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) { // Se mantiene la lógica original para procesar el archivo de respuesta del banco. - + var respuesta = new ProcesamientoLoteResponseDto(); if (archivo == null || archivo.Length == 0) { diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs index 41cbdd9..baaf2e5 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/FacturacionService.cs @@ -278,10 +278,11 @@ namespace GestionIntegral.Api.Services.Suscripciones }); } - public async Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) + public async Task> ObtenerResumenesDeCuentaPorPeriodo( + int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura) { var periodo = $"{anio}-{mes:D2}"; - var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion); + var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo, nombreSuscriptor, estadoPago, estadoFacturacion, tipoFactura); var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); var empresas = await _empresaRepository.GetAllAsync(null, null); @@ -302,10 +303,17 @@ namespace GestionIntegral.Api.Services.Suscripciones EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, NumeroFactura = itemFactura.Factura.NumeroFactura, TotalPagado = itemFactura.TotalPagado, + + // Faltaba esta línea para pasar el tipo de factura al frontend. + TipoFactura = itemFactura.Factura.TipoFactura, + Detalles = detallesData .Where(d => d.IdFactura == itemFactura.Factura.IdFactura) .Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) - .ToList() + .ToList(), + + // Pasamos el id del suscriptor para facilitar las cosas en el frontend + IdSuscriptor = itemFactura.Factura.IdSuscriptor }; }).ToList(); @@ -579,7 +587,7 @@ namespace GestionIntegral.Api.Services.Suscripciones } } - private async Task CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) + public async Task CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) { decimal importeTotal = 0; var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); diff --git a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs index fc57241..e8e2ee9 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/IFacturacionService.cs @@ -1,5 +1,7 @@ +using System.Data; using GestionIntegral.Api.Dtos.Comunicaciones; using GestionIntegral.Api.Dtos.Suscripciones; +using GestionIntegral.Api.Models.Suscripciones; namespace GestionIntegral.Api.Services.Suscripciones { @@ -7,8 +9,10 @@ namespace GestionIntegral.Api.Services.Suscripciones { Task<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario); Task> ObtenerHistorialLotesEnvio(int? anio, int? mes); - Task> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); + Task> ObtenerResumenesDeCuentaPorPeriodo( + int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura); Task<(bool Exito, string? Error, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario); Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); + Task CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction); } } \ 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 4fd6e4f..32a2874 100644 --- a/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs +++ b/Backend/GestionIntegral.Api/Services/Suscripciones/SuscripcionService.cs @@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion; using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Models.Suscripciones; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; using System.Data; -using System.Linq; -using System.Threading.Tasks; +using System.Globalization; namespace GestionIntegral.Api.Services.Suscripciones { @@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones private readonly ISuscriptorRepository _suscriptorRepository; private readonly IPublicacionRepository _publicacionRepository; private readonly IPromocionRepository _promocionRepository; - private readonly DbConnectionFactory _connectionFactory; + private readonly IFacturaRepository _facturaRepository; + private readonly IFacturaDetalleRepository _facturaDetalleRepository; + private readonly IFacturacionService _facturacionService; private readonly ILogger _logger; + private readonly DbConnectionFactory _connectionFactory; public SuscripcionService( ISuscripcionRepository suscripcionRepository, - ISuscriptorRepository suscriptorRepository, - IPublicacionRepository publicacionRepository, - IPromocionRepository promocionRepository, - DbConnectionFactory connectionFactory, - ILogger logger) + ISuscriptorRepository suscriptorRepository, + IPublicacionRepository publicacionRepository, + IPromocionRepository promocionRepository, + IFacturaRepository facturaRepository, + IFacturaDetalleRepository facturaDetalleRepository, + IFacturacionService facturacionService, + ILogger logger, + DbConnectionFactory connectionFactory) { _suscripcionRepository = suscripcionRepository; _suscriptorRepository = suscriptorRepository; _publicacionRepository = publicacionRepository; _promocionRepository = promocionRepository; - _connectionFactory = connectionFactory; + _facturaRepository = facturaRepository; + _facturaDetalleRepository = facturaDetalleRepository; + _facturacionService = facturacionService; _logger = logger; + _connectionFactory = connectionFactory; } private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto @@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); if (creada == null) throw new DataException("Error al crear la suscripción."); + var ultimoPeriodoFacturadoStr = await _facturaRepository.GetUltimoPeriodoFacturadoAsync(); + if (ultimoPeriodoFacturadoStr != null) + { + var ultimoPeriodo = DateTime.ParseExact(ultimoPeriodoFacturadoStr, "yyyy-MM", CultureInfo.InvariantCulture); + var periodoSuscripcion = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1); + + if (periodoSuscripcion <= ultimoPeriodo) + { + _logger.LogInformation("Suscripción en período ya cerrado detectada para Suscriptor {IdSuscriptor}. Generando factura de alta pro-rata.", creada.IdSuscriptor); + + decimal importeProporcional = await _facturacionService.CalcularImporteParaSuscripcion(creada, creada.FechaInicio.Year, creada.FechaInicio.Month, transaction); + + if (importeProporcional > 0) + { + var facturaDeAlta = new Factura + { + IdSuscriptor = creada.IdSuscriptor, + Periodo = creada.FechaInicio.ToString("yyyy-MM"), + FechaEmision = DateTime.Now.Date, + FechaVencimiento = DateTime.Now.AddDays(10).Date, // Vencimiento corto + ImporteBruto = importeProporcional, + ImporteFinal = importeProporcional, + EstadoPago = "Pendiente", + EstadoFacturacion = "Pendiente de Facturar", + TipoFactura = "Alta" // Marcamos la factura como de tipo "Alta" + }; + + var facturaCreada = await _facturaRepository.CreateAsync(facturaDeAlta, transaction); + if (facturaCreada == null) throw new DataException("No se pudo crear la factura de alta."); + + var publicacion = await _publicacionRepository.GetByIdSimpleAsync(creada.IdPublicacion); + var finDeMes = new DateTime(creada.FechaInicio.Year, creada.FechaInicio.Month, 1).AddMonths(1).AddDays(-1); + + await _facturaDetalleRepository.CreateAsync(new FacturaDetalle + { + IdFactura = facturaCreada.IdFactura, + IdSuscripcion = creada.IdSuscripcion, + Descripcion = $"Suscripción proporcional {publicacion?.Nombre} ({creada.FechaInicio:dd/MM} al {finDeMes:dd/MM})", + ImporteBruto = importeProporcional, + ImporteNeto = importeProporcional, + DescuentoAplicado = 0 + }, transaction); + + _logger.LogInformation("Factura de alta #{IdFactura} por ${Importe} generada para la nueva suscripción #{IdSuscripcion}.", facturaCreada.IdFactura, importeProporcional, creada.IdSuscripcion); + } + } + } transaction.Commit(); _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); return (await MapToDto(creada), null); diff --git a/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx index 2068d8b..8f4cc96 100644 --- a/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx +++ b/Frontend/src/components/Modals/Suscripciones/PagoManualModal.tsx @@ -1,6 +1,6 @@ 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 { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto'; import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto'; import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; import formaPagoService from '../../../services/Suscripciones/formaPagoService'; @@ -23,17 +23,19 @@ interface PagoManualModalProps { open: boolean; onClose: () => void; onSubmit: (data: CreatePagoDto) => Promise; - factura: FacturaDto | null; + factura: FacturaConsolidadaDto | null; + nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop- errorMessage?: string | null; clearErrorMessage: () => void; } -const PagoManualModal: React.FC = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { +const PagoManualModal: React.FC = ({ open, onClose, onSubmit, factura, nombreSuscriptor, 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 }>({}); + const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0; useEffect(() => { const fetchFormasDePago = async () => { @@ -52,26 +54,24 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm fetchFormasDePago(); setFormData({ idFactura: factura.idFactura, - monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto + monto: saldoPendiente, fechaPago: new Date().toISOString().split('T')[0] }); setLocalErrors({}); } - }, [open, factura]); + }, [open, factura, saldoPendiente]); const validate = (): boolean => { const errors: { [key: string]: string | null } = {}; if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago."; if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; - + const monto = formData.monto ?? 0; - const saldo = factura?.saldoPendiente ?? 0; if (monto <= 0) { - errors.monto = "El monto debe ser mayor a cero."; - } else if (monto > saldo) { - // Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje - errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`; + errors.monto = "El monto debe ser mayor a cero."; + } else if (monto > saldoPendiente) { + errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`; } setLocalErrors(errors); @@ -85,7 +85,7 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm 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 })); @@ -117,29 +117,32 @@ const PagoManualModal: React.FC = ({ open, onClose, onSubm Registrar Pago Manual - - Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} + + Para: {nombreSuscriptor} + + + Saldo Pendiente: ${saldoPendiente.toFixed(2)} - - - Forma de Pago - - - $ }} inputProps={{ step: "0.01" }} /> - - + + + Forma de Pago + + + $ }} inputProps={{ step: "0.01" }} /> + + - {errorMessage && {errorMessage}} + {errorMessage && {errorMessage}} - - - - + + + + diff --git a/Frontend/src/models/dtos/Suscripciones/ResumenCuentaSuscriptorDto.ts b/Frontend/src/models/dtos/Suscripciones/ResumenCuentaSuscriptorDto.ts index 045205a..98f56f6 100644 --- a/Frontend/src/models/dtos/Suscripciones/ResumenCuentaSuscriptorDto.ts +++ b/Frontend/src/models/dtos/Suscripciones/ResumenCuentaSuscriptorDto.ts @@ -13,6 +13,7 @@ export interface FacturaConsolidadaDto { estadoFacturacion: string; numeroFactura?: string | null; totalPagado: number; + tipoFactura: 'Mensual' | 'Alta'; detalles: FacturaDetalleDto[]; // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers idSuscriptor: number; diff --git a/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx index 69e0425..96711b1 100644 --- a/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx +++ b/Frontend/src/pages/Suscripciones/ConsultaFacturasPage.tsx @@ -26,6 +26,7 @@ const meses = [ const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada']; const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; +const tiposFactura = ['Mensual', 'Alta']; const SuscriptorRow: React.FC<{ resumen: ResumenCuentaSuscriptorDto; @@ -33,8 +34,6 @@ const SuscriptorRow: React.FC<{ handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { const [open, setOpen] = useState(false); - - // Función para formatear moneda const formatCurrency = (value: number) => `$${value.toFixed(2)}`; return ( @@ -43,20 +42,16 @@ const SuscriptorRow: React.FC<{ setOpen(!open)}>{open ? : } {resumen.nombreSuscriptor} - 0 ? 'error.main' : 'success.main' }}> - {formatCurrency(resumen.saldoPendienteTotal)} - + 0 ? 'error.main' : 'success.main' }}>{formatCurrency(resumen.saldoPendienteTotal)} de {formatCurrency(resumen.importeTotal)} - + - + {/* <-- Ajustado para la nueva columna */} - - Facturas del Período para {resumen.nombreSuscriptor} - + Facturas del Período para {resumen.nombreSuscriptor} @@ -64,6 +59,7 @@ const SuscriptorRow: React.FC<{ Importe Total Pagado Saldo + Tipo Factura Estado Pago Estado Facturación Nro. Factura @@ -77,35 +73,17 @@ const SuscriptorRow: React.FC<{ {factura.nombreEmpresa} {formatCurrency(factura.importeFinal)} - - {formatCurrency(factura.totalPagado)} - - 0 ? 'error.main' : 'inherit' }}> - {formatCurrency(saldo)} - + {formatCurrency(factura.totalPagado)} + 0 ? 'error.main' : 'inherit' }}>{formatCurrency(saldo)} - + + {factura.numeroFactura || '-'} - handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> - - - - handleOpenHistorial(factura)}> - - - + handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> + handleOpenHistorial(factura)}> ); @@ -135,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => { const [filtroNombre, setFiltroNombre] = useState(''); const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); + const [filtroTipoFactura, setFiltroTipoFactura] = useState(''); const [selectedFactura, setSelectedFactura] = useState(null); const [anchorEl, setAnchorEl] = useState(null); @@ -154,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => { selectedMes, filtroNombre || undefined, filtroEstadoPago || undefined, - filtroEstadoFacturacion || undefined + filtroEstadoFacturacion || undefined, + filtroTipoFactura || undefined ); setResumenes(data); } catch (err) { @@ -163,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => { } finally { setLoading(false); } - }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); + }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]); useEffect(() => { const timer = setTimeout(() => { @@ -251,6 +231,17 @@ const ConsultaFacturasPage: React.FC = () => { setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> Estado de Pago Estado de Facturación + + Tipo de Factura + + @@ -259,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => {
- SuscriptorSaldo Total / Importe Total + SuscriptorSaldo Total / Importe Total {loading ? () : resumenes.length === 0 ? (No hay facturas para el período seleccionado.) @@ -285,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => { open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} - factura={ - selectedFactura ? { - idFactura: selectedFactura.idFactura, - nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '', - importeFinal: selectedFactura.importeFinal, - saldoPendiente: selectedFactura.importeFinal - selectedFactura.totalPagado, - totalPagado: selectedFactura.totalPagado, - idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, - periodo: '', - fechaEmision: '', - fechaVencimiento: '', - estadoPago: selectedFactura.estadoPago, - estadoFacturacion: selectedFactura.estadoFacturacion, - numeroFactura: selectedFactura.numeroFactura, - detalles: selectedFactura.detalles, - } : null + factura={selectedFactura} + nombreSuscriptor={ + resumenes.find(r => r.idSuscriptor === selectedFactura?.idSuscriptor)?.nombreSuscriptor || '' } errorMessage={apiError} clearErrorMessage={() => setApiError(null)} diff --git a/Frontend/src/services/Suscripciones/facturacionService.ts b/Frontend/src/services/Suscripciones/facturacionService.ts index 97e0d76..b21ea0d 100644 --- a/Frontend/src/services/Suscripciones/facturacionService.ts +++ b/Frontend/src/services/Suscripciones/facturacionService.ts @@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise => { +const getResumenesDeCuentaPorPeriodo = async ( + anio: number, mes: number, + nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string, + tipoFactura?: string +): Promise => { const params = new URLSearchParams(); if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); if (estadoPago) params.append('estadoPago', estadoPago); if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); + if (tipoFactura) params.append('tipoFactura', tipoFactura); const queryString = params.toString(); const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; @@ -81,10 +86,10 @@ const getHistorialLotesEnvio = async (anio?: number, mes?: number): Promise(url); return response.data; };