Compare commits

...

8 Commits

Author SHA1 Message Date
c27dc2a0ba Fix deploy
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 6m15s
2025-09-10 12:30:07 -03:00
24b1c07342 Test Up
Some checks failed
Optimized Build and Deploy / remote-build-and-deploy (push) Failing after 11s
2025-09-10 11:29:25 -03:00
cb64bbc1f5 Actualizar README.md
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m36s
2025-08-14 13:15:23 +00:00
057310ca47 Fix(Suscripciones): Insert en db arreglado y muestra en UI tipo factura
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m25s
- Se arregla error al insertar en la db el registro de factura "Alta"
- Se arregla UI por falla en la visualización del tipo de factura en la tabla de gestión de facturas.
2025-08-13 15:53:34 -03:00
e95c851e5b Feat(suscripciones): Implementa facturación pro-rata para altas y excluye del débito automático
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 3m45s
Se introduce una refactorización mayor del ciclo de facturación para manejar correctamente las suscripciones que inician en un período ya cerrado. Esto soluciona el problema de cobrar un mes completo a un nuevo suscriptor, mejorando la transparencia y la experiencia del cliente.

###  Nuevas Características y Lógica de Negocio

- **Facturación Pro-rata Automática (Factura de Alta):**
    - Al crear una nueva suscripción cuya fecha de inicio corresponde a un período de facturación ya cerrado, el sistema ahora calcula automáticamente el costo proporcional por los días restantes de ese mes.
    - Se genera de forma inmediata una nueva factura de tipo "Alta" por este monto parcial, separándola del ciclo de facturación mensual regular.

- **Exclusión del Débito Automático para Facturas de Alta:**
    - Se implementa una regla de negocio clave: las facturas de tipo "Alta" son **excluidas** del proceso de generación del archivo de débito automático para el banco.
    - Esto fuerza a que el primer cobro (el proporcional) se gestione a través de un medio de pago manual (efectivo, transferencia, etc.), evitando cargos inesperados en la cuenta bancaria del cliente.
    - El débito automático comenzará a operar normalmente a partir del primer ciclo de facturación completo.

### 🔄 Cambios en el Backend

- **Base de Datos:**
    - Se ha añadido la columna `TipoFactura` (`varchar(20)`) a la tabla `susc_Facturas`.
    - Se ha implementado una `CHECK constraint` para permitir únicamente los valores 'Mensual' y 'Alta'.

- **Servicios:**
    - **`SuscripcionService`:** Ahora contiene la lógica para detectar una alta retroactiva, invocar al `FacturacionService` para el cálculo pro-rata y crear la "Factura de Alta" y su detalle correspondiente dentro de la misma transacción.
    - **`FacturacionService`:** Expone públicamente el método `CalcularImporteParaSuscripcion` y se ha actualizado `ObtenerResumenesDeCuentaPorPeriodo` para que envíe la propiedad `TipoFactura` al frontend.
    - **`DebitoAutomaticoService`:** El método `GetFacturasParaDebito` ahora filtra y excluye explícitamente las facturas donde `TipoFactura = 'Alta'`.

### 🎨 Mejoras en la Interfaz de Usuario (Frontend)

- **`ConsultaFacturasPage.tsx`:**
    - **Nueva Columna:** Se ha añadido una columna "Tipo Factura" en la tabla de detalle, que muestra un `Chip` distintivo para identificar fácilmente las facturas de "Alta".
    - **Nuevo Filtro:** Se ha agregado un nuevo menú desplegable para filtrar la vista por "Tipo de Factura" (`Todas`, `Mensual`, `Alta`), permitiendo a los administradores auditar rápidamente los nuevos ingresos.
2025-08-13 14:55:24 -03:00
038faefd35 Fix: Formato de Archivo de Débito Modificado
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 4m23s
2025-08-12 12:49:56 -03:00
da50c052f1 Fix: Configuración SMTP
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 6m46s
- No se toma la configuración SMTP del .env por falla de lecturas.
- Se incluyen las configuraciones en appsettings.json
2025-08-12 11:18:30 -03:00
5781713b13 Fix: Menú Reportes
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 7m12s
- Fix del menú de reportes que impedía el recorrido del mismo.
- Se quita la apertura predeterminada de una opción del menú de Reportes.
2025-08-12 10:33:36 -03:00
24 changed files with 327 additions and 317 deletions

View File

@@ -26,12 +26,6 @@ jobs:
set -e set -e
echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---" echo "--- INICIO DEL DESPLIEGUE OPTIMIZADO ---"
# --- Asegurar que el Stack de la Base de Datos esté corriendo ---
echo "Asegurando que el stack de la base de datos esté activo..."
cd /opt/shared-services/database
# El comando 'up -d' es idempotente. Si ya está corriendo, no hace nada.
docker compose up -d
# 1. Preparar entorno # 1. Preparar entorno
TEMP_DIR=$(mktemp -d) TEMP_DIR=$(mktemp -d)
REPO_OWNER="dmolinari" REPO_OWNER="dmolinari"

View File

@@ -1,12 +0,0 @@
# ================================================
# VARIABLES DE ENTORNO PARA LA CONFIGURACIÓN DE CORREO
# ================================================
# El separador de doble guion bajo (__) se usa para mapear la jerarquía del JSON.
# MailSettings:SmtpHost se convierte en MailSettings__SmtpHost
MailSettings__SmtpHost="192.168.5.201"
MailSettings__SmtpPort=587
MailSettings__SenderName="Club - Diario El Día"
MailSettings__SenderEmail="alertas@eldia.com"
MailSettings__SmtpUser="alertas@eldia.com"
MailSettings__SmtpPass="@Alertas713550@"

View File

@@ -72,15 +72,16 @@ namespace GestionIntegral.Api.Controllers.Suscripciones
[HttpGet("{anio:int}/{mes:int}")] [HttpGet("{anio:int}/{mes:int}")]
public async Task<IActionResult> GetFacturas( public async Task<IActionResult> GetFacturas(
int anio, int mes, int anio, int mes,
[FromQuery] string? nombreSuscriptor, [FromQuery] string? nombreSuscriptor,
[FromQuery] string? estadoPago, [FromQuery] string? estadoPago,
[FromQuery] string? estadoFacturacion) [FromQuery] string? estadoFacturacion,
[FromQuery] string? tipoFactura)
{ {
if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid(); if (!TienePermiso(PermisoGestionarFacturacion)) return Forbid();
if (anio < 2020 || mes < 1 || mes > 12) return BadRequest(new { message = "El período no es válido." }); 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); return Ok(resumenes);
} }

View File

@@ -59,10 +59,15 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
{ {
throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas."); throw new ArgumentNullException(nameof(transaction), "La transacción o su conexión no pueden ser nulas.");
} }
const string sqlInsert = @" const string sqlInsert = @"
INSERT INTO dbo.susc_Facturas (IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto, DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion) INSERT INTO dbo.susc_Facturas
(IdSuscriptor, Periodo, FechaEmision, FechaVencimiento, ImporteBruto,
DescuentoAplicado, ImporteFinal, EstadoPago, EstadoFacturacion, TipoFactura)
OUTPUT INSERTED.* OUTPUT INSERTED.*
VALUES (@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto, @DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion);"; VALUES
(@IdSuscriptor, @Periodo, @FechaEmision, @FechaVencimiento, @ImporteBruto,
@DescuentoAplicado, @ImporteFinal, @EstadoPago, @EstadoFacturacion, @TipoFactura);";
return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction); return await transaction.Connection.QuerySingleAsync<Factura>(sqlInsert, nuevaFactura, transaction);
} }
@@ -104,7 +109,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
return rowsAffected == idsFacturas.Count(); return rowsAffected == idsFacturas.Count();
} }
public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) public async Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{ {
var sqlBuilder = new StringBuilder(@" var sqlBuilder = new StringBuilder(@"
WITH FacturaConEmpresa AS ( WITH FacturaConEmpresa AS (
@@ -149,6 +155,12 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
parameters.Add("EstadoFacturacion", estadoFacturacion); 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;"); sqlBuilder.Append(" ORDER BY s.NombreCompleto, f.IdFactura;");
try try

View File

@@ -15,7 +15,8 @@ namespace GestionIntegral.Api.Data.Repositories.Suscripciones
Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction); Task<bool> UpdateEstadoPagoAsync(int idFactura, string nuevoEstadoPago, IDbTransaction transaction);
Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction); Task<bool> UpdateNumeroFacturaAsync(int idFactura, string numeroFactura, IDbTransaction transaction);
Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction); Task<bool> UpdateLoteDebitoAsync(IEnumerable<int> idsFacturas, int idLoteDebito, IDbTransaction transaction);
Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); Task<IEnumerable<(Factura Factura, string NombreSuscriptor, int IdEmpresa, decimal TotalPagado)>> GetByPeriodoEnrichedAsync(
string periodo, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura);
Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction); Task<bool> UpdateEstadoYMotivoAsync(int idFactura, string nuevoEstadoPago, string? motivoRechazo, IDbTransaction transaction);
Task<string?> GetUltimoPeriodoFacturadoAsync(); Task<string?> GetUltimoPeriodoFacturadoAsync();
Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo); Task<IEnumerable<Factura>> GetFacturasPagadasPendientesDeFacturar(string periodo);

View File

@@ -9,7 +9,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="MailKit" Version="4.13.0" /> <PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />

View File

@@ -9,6 +9,8 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string EstadoFacturacion { get; set; } = string.Empty; public string EstadoFacturacion { get; set; } = string.Empty;
public string? NumeroFactura { get; set; } public string? NumeroFactura { get; set; }
public decimal TotalPagado { get; set; } public decimal TotalPagado { get; set; }
public string TipoFactura { get; set; } = string.Empty;
public int IdSuscriptor { get; set; }
public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
} }
} }

View File

@@ -15,5 +15,6 @@ namespace GestionIntegral.Api.Models.Suscripciones
public string? NumeroFactura { get; set; } public string? NumeroFactura { get; set; }
public int? IdLoteDebito { get; set; } public int? IdLoteDebito { get; set; }
public string? MotivoRechazo { get; set; } public string? MotivoRechazo { get; set; }
public string TipoFactura { get; set; } = string.Empty;
} }
} }

View File

@@ -24,10 +24,6 @@ using GestionIntegral.Api.Models.Comunicaciones;
using GestionIntegral.Api.Services.Comunicaciones; using GestionIntegral.Api.Services.Comunicaciones;
using GestionIntegral.Api.Data.Repositories.Comunicaciones; using GestionIntegral.Api.Data.Repositories.Comunicaciones;
// Carga las variables de entorno desde el archivo .env al inicio de la aplicación.
// Debe ser la primera línea para que la configuración esté disponible para el 'builder'.
DotNetEnv.Env.Load();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- Registros de Servicios --- // --- Registros de Servicios ---

View File

@@ -4,6 +4,8 @@ using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MimeKit; using MimeKit;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace GestionIntegral.Api.Services.Comunicaciones namespace GestionIntegral.Api.Services.Comunicaciones
{ {
@@ -88,6 +90,30 @@ namespace GestionIntegral.Api.Services.Comunicaciones
using var smtp = new SmtpClient(); using var smtp = new SmtpClient();
try try
{ {
// Se añade una política de validación de certificado personalizada.
// Esto es necesario para entornos de desarrollo o redes internas donde
// el nombre del host al que nos conectamos (ej. una IP) no coincide
// con el nombre en el certificado SSL (ej. mail.eldia.com).
smtp.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
{
// Si no hay errores, el certificado es válido.
if (sslPolicyErrors == SslPolicyErrors.None)
return true;
// Si el único error es que el nombre no coincide (RemoteCertificateNameMismatch)
// Y el certificado es el que esperamos (emitido para "mail.eldia.com"),
// entonces lo aceptamos como válido.
if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch) && certificate != null && certificate.Subject.Contains("CN=mail.eldia.com"))
{
_logger.LogWarning("Se aceptó un certificado SSL con 'Name Mismatch' para el host de confianza 'mail.eldia.com'.");
return true;
}
// Para cualquier otro error, rechazamos el certificado.
_logger.LogError("Error de validación de certificado SSL: {Errors}", sslPolicyErrors);
return false;
};
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls); await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass); await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(emailMessage); await smtp.SendAsync(emailMessage);
@@ -95,20 +121,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
log.Estado = "Enviado"; log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); _logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
} }
catch (SmtpCommandException scEx)
{
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
log.Estado = "Fallido";
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
throw;
}
catch (AuthenticationException authEx)
{
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
log.Estado = "Fallido";
log.Error = "Error de autenticación. Revise las credenciales de correo.";
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject); _logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);

View File

@@ -17,8 +17,8 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly DbConnectionFactory _connectionFactory; private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<DebitoAutomaticoService> _logger; private readonly ILogger<DebitoAutomaticoService> _logger;
private const string NRO_PRESTACION = "123456"; private const string NRO_PRESTACION = "26435"; // Reemplazar por el número real
private const string ORIGEN_EMPRESA = "ELDIA"; private const string ORIGEN_EMPRESA = "EMPRESA";
public DebitoAutomaticoService( public DebitoAutomaticoService(
IFacturaRepository facturaRepository, IFacturaRepository facturaRepository,
@@ -40,9 +40,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario)
{ {
// Se define la identificación del archivo. // Este número debe ser gestionado para no repetirse. Por ahora, lo mantenemos como 1.
// Este número debe ser gestionado para no repetirse en archivos generados
// para la misma prestación y fecha.
const int identificacionArchivo = 1; const int identificacionArchivo = 1;
var periodo = $"{anio}-{mes:D2}"; var periodo = $"{anio}-{mes:D2}";
@@ -62,8 +60,6 @@ namespace GestionIntegral.Api.Services.Suscripciones
var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal);
var cantidadRegistros = facturasParaDebito.Count(); var cantidadRegistros = facturasParaDebito.Count();
// Se utiliza la variable 'identificacionArchivo' para nombrar el archivo.
var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt"; var nombreArchivo = $"{NRO_PRESTACION}{fechaGeneracion:yyyyMMdd}{identificacionArchivo}.txt";
var nuevoLote = new LoteDebito var nuevoLote = new LoteDebito
@@ -78,13 +74,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito.");
var sb = new StringBuilder(); var sb = new StringBuilder();
// Se pasa la 'identificacionArchivo' al método que crea el Header.
sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
foreach (var item in facturasParaDebito) foreach (var item in facturasParaDebito)
{ {
sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor));
} }
// Se pasa la 'identificacionArchivo' al método que crea el Trailer.
sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo)); sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros, identificacionArchivo));
var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura);
@@ -108,17 +102,18 @@ namespace GestionIntegral.Api.Services.Suscripciones
var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); var facturas = await _facturaRepository.GetByPeriodoAsync(periodo);
var resultado = new List<(Factura, Suscriptor)>(); var resultado = new List<(Factura, Suscriptor)>();
foreach (var f in facturas.Where(fa => fa.EstadoPago == "Pendiente")) // 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); var suscriptor = await _suscriptorRepository.GetByIdAsync(f.IdSuscriptor);
// Se valida que el CBU de Banelco (22 caracteres) exista antes de intentar la conversión.
if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22) if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU) || suscriptor.CBU.Length != 22)
{ {
_logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", suscriptor?.IdSuscriptor); _logger.LogWarning("Suscriptor ID {IdSuscriptor} omitido del lote de débito por CBU inválido o ausente (se esperan 22 dígitos).", f.IdSuscriptor);
continue; continue;
} }
var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida);
if (formaPago != null && formaPago.RequiereCBU) if (formaPago != null && formaPago.RequiereCBU)
{ {
@@ -128,26 +123,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
return resultado; return resultado;
} }
// Lógica de conversión de CBU.
private string ConvertirCbuBanelcoASnp(string cbu22) private string ConvertirCbuBanelcoASnp(string cbu22)
{ {
if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) if (string.IsNullOrEmpty(cbu22) || cbu22.Length != 22) return "".PadRight(26);
{
_logger.LogError("Se intentó convertir un CBU inválido de {Length} caracteres. Se devolverá un campo vacío.", cbu22?.Length ?? 0);
// Devolver un string de 26 espacios/ceros según la preferencia del banco para campos erróneos.
return "".PadRight(26);
}
// El formato SNP de 26 se obtiene insertando un "0" al inicio y "000" después del 8vo caracter del CBU de 22.
// Formato Banelco (22): [BBBSSSSX] [T....Y]
// Posiciones: (0-7) (8-21)
// Formato SNP (26): 0[BBBSSSSX]000[T....Y]
try try
{ {
string bloque1 = cbu22.Substring(0, 8); // Contiene código de banco, sucursal y DV del bloque 1. string bloque1 = cbu22.Substring(0, 8);
string bloque2 = cbu22.Substring(8); // Contiene el resto de la cadena. string bloque2 = cbu22.Substring(8);
// Reconstruir en formato SNP de 26 dígitos según el instructivo.
return $"0{bloque1}000{bloque2}"; return $"0{bloque1}000{bloque2}";
} }
catch (Exception ex) catch (Exception ex)
@@ -157,9 +139,10 @@ namespace GestionIntegral.Api.Services.Suscripciones
} }
} }
// --- Métodos de Formateo y Mapeo --- // --- Helpers de Formateo ---
private string FormatString(string? value, int length) => (value ?? "").PadRight(length); private string FormatString(string? value, int length) => (value ?? "").PadRight(length);
private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0');
private string FormatNumericString(string? value, int length) => (value ?? "").PadLeft(length, '0');
private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch private string MapTipoDocumento(string tipo) => tipo.ToUpper() switch
{ {
"DNI" => "0096", "DNI" => "0096",
@@ -167,17 +150,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
"CUIL" => "0086", "CUIL" => "0086",
"LE" => "0089", "LE" => "0089",
"LC" => "0090", "LC" => "0090",
_ => "0000" // Tipo no especificado o C.I. Policía Federal según anexo. _ => "0000"
}; };
private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("00"); // Tipo de Registro Header sb.Append("00");
sb.Append(FormatString(NRO_PRESTACION, 6)); sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio: Sistema Nacional de Pagos sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd")); sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7)); sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7)); sb.Append(FormatNumeric(cantidadRegistros, 7));
@@ -188,35 +171,33 @@ namespace GestionIntegral.Api.Services.Suscripciones
private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor)
{ {
// Convertimos el CBU de 22 (Banelco) a 26 (SNP) antes de usarlo.
string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!); string cbu26 = ConvertirCbuBanelcoASnp(suscriptor.CBU!);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("0370"); // Tipo de Registro Detalle (Orden de Débito) sb.Append("0370");
sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación de Cliente sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22));
sb.Append(FormatString(cbu26, 26)); // CBU en formato SNP de 26 caracteres. sb.Append(cbu26);
sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); // Referencia Unívoca de la factura. sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15));
sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd"));
sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14));
sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento sb.Append(FormatNumeric(0, 14)); // Importe 2do Vencimiento
sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vencimiento
sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento sb.Append(FormatNumeric(0, 14)); // Importe 3er Vencimiento
sb.Append("0"); // Moneda (0 = Pesos) sb.Append("0");
sb.Append(FormatString("", 3)); // Motivo Rechazo (vacío en el envío) sb.Append(FormatString("", 3));
sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4)); sb.Append(FormatString(MapTipoDocumento(suscriptor.TipoDocumento), 4));
sb.Append(FormatString(suscriptor.NroDocumento, 11)); sb.Append(FormatNumericString(suscriptor.NroDocumento, 11));
sb.Append(FormatString("", 22)); // Nueva ID Cliente sb.Append(FormatString("", 22));
sb.Append(FormatString("", 26)); // Nueva CBU sb.Append(FormatString("", 26));
sb.Append(FormatNumeric(0, 14)); // Importe Mínimo sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vencimiento sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 22)); // Identificación Cuenta Anterior sb.Append(FormatString("", 22));
sb.Append(FormatString("", 40)); // Mensaje ATM sb.Append(FormatString("", 40));
sb.Append(FormatString($"Susc.{factura.Periodo}", 10)); // Concepto Factura sb.Append(FormatString($"Susc.{factura.Periodo}", 10));
sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro sb.Append(FormatNumeric(0, 8));
sb.Append(FormatNumeric(0, 14)); // Importe Cobrado sb.Append(FormatNumeric(0, 14));
sb.Append(FormatNumeric(0, 8)); // Fecha de Acreditamiento sb.Append(FormatNumeric(0, 8));
sb.Append(FormatString("", 26)); // Libre sb.Append(FormatString("", 26));
sb.Append("\r\n"); sb.Append("\r\n");
return sb.ToString(); return sb.ToString();
} }
@@ -224,16 +205,16 @@ namespace GestionIntegral.Api.Services.Suscripciones
private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo) private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros, int identificacionArchivo)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("99"); // Tipo de Registro Trailer sb.Append("99");
sb.Append(FormatString(NRO_PRESTACION, 6)); sb.Append(FormatNumericString(NRO_PRESTACION, 6));
sb.Append("C"); // Servicio: Sistema Nacional de Pagos sb.Append("C");
sb.Append(fechaGeneracion.ToString("yyyyMMdd")); sb.Append(fechaGeneracion.ToString("yyyyMMdd"));
sb.Append(FormatString(identificacionArchivo.ToString(), 1)); // Identificación de Archivo sb.Append(FormatString(identificacionArchivo.ToString(), 1));
sb.Append(FormatString(ORIGEN_EMPRESA, 7)); sb.Append(FormatString(ORIGEN_EMPRESA, 7));
sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); sb.Append(FormatNumeric((long)(importeTotal * 100), 14));
sb.Append(FormatNumeric(cantidadRegistros, 7)); sb.Append(FormatNumeric(cantidadRegistros, 7));
sb.Append(FormatString("", 304)); sb.Append(FormatString("", 304));
// La última línea del archivo no lleva salto de línea (\r\n). sb.Append("\r\n");
return sb.ToString(); return sb.ToString();
} }

View File

@@ -171,10 +171,13 @@ namespace GestionIntegral.Api.Services.Suscripciones
DescuentoAplicado = descuentoPromocionesTotal, DescuentoAplicado = descuentoPromocionesTotal,
ImporteFinal = importeFinal, ImporteFinal = importeFinal,
EstadoPago = "Pendiente", EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar" EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "Mensual"
}; };
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction); var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}"); if (facturaCreada == null) throw new DataException($"No se pudo crear la factura para suscriptor ID {idSuscriptor} y empresa ID {idEmpresa}");
facturasCreadas.Add(facturaCreada); facturasCreadas.Add(facturaCreada);
foreach (var detalle in detallesParaFactura) foreach (var detalle in detallesParaFactura)
{ {
@@ -278,10 +281,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
}); });
} }
public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion) public async Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(
int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion, string? tipoFactura)
{ {
var periodo = $"{anio}-{mes:D2}"; 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 detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
var empresas = await _empresaRepository.GetAllAsync(null, null); var empresas = await _empresaRepository.GetAllAsync(null, null);
@@ -302,10 +306,17 @@ namespace GestionIntegral.Api.Services.Suscripciones
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura, NumeroFactura = itemFactura.Factura.NumeroFactura,
TotalPagado = itemFactura.TotalPagado, TotalPagado = itemFactura.TotalPagado,
// Faltaba esta línea para pasar el tipo de factura al frontend.
TipoFactura = itemFactura.Factura.TipoFactura,
Detalles = detallesData Detalles = detallesData
.Where(d => d.IdFactura == itemFactura.Factura.IdFactura) .Where(d => d.IdFactura == itemFactura.Factura.IdFactura)
.Select(d => new FacturaDetalleDto { Descripcion = d.Descripcion, ImporteNeto = d.ImporteNeto }) .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(); }).ToList();
@@ -579,7 +590,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
} }
} }
private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction) public async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
{ {
decimal importeTotal = 0; decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet(); var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();

View File

@@ -1,5 +1,7 @@
using System.Data;
using GestionIntegral.Api.Dtos.Comunicaciones; using GestionIntegral.Api.Dtos.Comunicaciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
namespace GestionIntegral.Api.Services.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<(bool Exito, string? Mensaje, LoteDeEnvioResumenDto? ResultadoEnvio)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes); Task<IEnumerable<LoteDeEnvioHistorialDto>> ObtenerHistorialLotesEnvio(int? anio, int? mes);
Task<IEnumerable<ResumenCuentaSuscriptorDto>> ObtenerResumenesDeCuentaPorPeriodo(int anio, int mes, string? nombreSuscriptor, string? estadoPago, string? estadoFacturacion); Task<IEnumerable<ResumenCuentaSuscriptorDto>> 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, string? EmailDestino)> EnviarFacturaPdfPorEmail(int idFactura, int idUsuario);
Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario); Task<(bool Exito, string? Error)> ActualizarNumeroFactura(int idFactura, string numeroFactura, int idUsuario);
Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction);
} }
} }

View File

@@ -3,12 +3,8 @@ using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Suscripciones; using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones; using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones; using GestionIntegral.Api.Models.Suscripciones;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq; using System.Globalization;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones namespace GestionIntegral.Api.Services.Suscripciones
{ {
@@ -18,23 +14,32 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly ISuscriptorRepository _suscriptorRepository; private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IPublicacionRepository _publicacionRepository; private readonly IPublicacionRepository _publicacionRepository;
private readonly IPromocionRepository _promocionRepository; private readonly IPromocionRepository _promocionRepository;
private readonly DbConnectionFactory _connectionFactory; private readonly IFacturaRepository _facturaRepository;
private readonly IFacturaDetalleRepository _facturaDetalleRepository;
private readonly IFacturacionService _facturacionService;
private readonly ILogger<SuscripcionService> _logger; private readonly ILogger<SuscripcionService> _logger;
private readonly DbConnectionFactory _connectionFactory;
public SuscripcionService( public SuscripcionService(
ISuscripcionRepository suscripcionRepository, ISuscripcionRepository suscripcionRepository,
ISuscriptorRepository suscriptorRepository, ISuscriptorRepository suscriptorRepository,
IPublicacionRepository publicacionRepository, IPublicacionRepository publicacionRepository,
IPromocionRepository promocionRepository, IPromocionRepository promocionRepository,
DbConnectionFactory connectionFactory, IFacturaRepository facturaRepository,
ILogger<SuscripcionService> logger) IFacturaDetalleRepository facturaDetalleRepository,
IFacturacionService facturacionService,
ILogger<SuscripcionService> logger,
DbConnectionFactory connectionFactory)
{ {
_suscripcionRepository = suscripcionRepository; _suscripcionRepository = suscripcionRepository;
_suscriptorRepository = suscriptorRepository; _suscriptorRepository = suscriptorRepository;
_publicacionRepository = publicacionRepository; _publicacionRepository = publicacionRepository;
_promocionRepository = promocionRepository; _promocionRepository = promocionRepository;
_connectionFactory = connectionFactory; _facturaRepository = facturaRepository;
_facturaDetalleRepository = facturaDetalleRepository;
_facturacionService = facturacionService;
_logger = logger; _logger = logger;
_connectionFactory = connectionFactory;
} }
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
@@ -122,6 +127,53 @@ namespace GestionIntegral.Api.Services.Suscripciones
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction); var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
if (creada == null) throw new DataException("Error al crear la suscripción."); 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. Generando factura de alta pro-rata.");
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,
ImporteBruto = importeProporcional,
ImporteFinal = importeProporcional,
EstadoPago = "Pendiente",
EstadoFacturacion = "Pendiente de Facturar",
TipoFactura = "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(); transaction.Commit();
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario); _logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
return (await MapToDto(creada), null); return (await MapToDto(creada), null);

View File

@@ -16,11 +16,11 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"MailSettings": { "MailSettings": {
"SmtpHost": "", "SmtpHost": "192.168.5.201",
"SmtpPort": 0, "SmtpPort": 587,
"SenderName": "", "SenderName": "Club - Diario El Día",
"SenderEmail": "", "SenderEmail": "alertas@eldia.com",
"SmtpUser": "", "SmtpUser": "alertas@eldia.com",
"SmtpPass": "" "SmtpPass": "@Alertas713550@"
} }
} }

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; 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 { 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 { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto';
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto'; import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
import formaPagoService from '../../../services/Suscripciones/formaPagoService'; import formaPagoService from '../../../services/Suscripciones/formaPagoService';
@@ -23,17 +23,19 @@ interface PagoManualModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (data: CreatePagoDto) => Promise<void>; onSubmit: (data: CreatePagoDto) => Promise<void>;
factura: FacturaDto | null; factura: FacturaConsolidadaDto | null;
nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop-
errorMessage?: string | null; errorMessage?: string | null;
clearErrorMessage: () => void; clearErrorMessage: () => void;
} }
const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, errorMessage, clearErrorMessage }) => { const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => {
const [formData, setFormData] = useState<Partial<CreatePagoDto>>({}); const [formData, setFormData] = useState<Partial<CreatePagoDto>>({});
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]); const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingFormasPago, setLoadingFormasPago] = useState(false); const [loadingFormasPago, setLoadingFormasPago] = useState(false);
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({}); const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0;
useEffect(() => { useEffect(() => {
const fetchFormasDePago = async () => { const fetchFormasDePago = async () => {
@@ -52,12 +54,12 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
fetchFormasDePago(); fetchFormasDePago();
setFormData({ setFormData({
idFactura: factura.idFactura, idFactura: factura.idFactura,
monto: factura.saldoPendiente, // Usar el saldo pendiente como valor por defecto monto: saldoPendiente,
fechaPago: new Date().toISOString().split('T')[0] fechaPago: new Date().toISOString().split('T')[0]
}); });
setLocalErrors({}); setLocalErrors({});
} }
}, [open, factura]); }, [open, factura, saldoPendiente]);
const validate = (): boolean => { const validate = (): boolean => {
const errors: { [key: string]: string | null } = {}; const errors: { [key: string]: string | null } = {};
@@ -65,13 +67,11 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria."; if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
const monto = formData.monto ?? 0; const monto = formData.monto ?? 0;
const saldo = factura?.saldoPendiente ?? 0;
if (monto <= 0) { if (monto <= 0) {
errors.monto = "El monto debe ser mayor a cero."; errors.monto = "El monto debe ser mayor a cero.";
} else if (monto > saldo) { } else if (monto > saldoPendiente) {
// Usamos toFixed(2) para mostrar el formato de moneda correcto en el mensaje errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`;
errors.monto = `El monto no puede superar el saldo pendiente de $${saldo.toFixed(2)}.`;
} }
setLocalErrors(errors); setLocalErrors(errors);
@@ -117,29 +117,32 @@ const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubm
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<Box sx={modalStyle}> <Box sx={modalStyle}>
<Typography variant="h6">Registrar Pago Manual</Typography> <Typography variant="h6">Registrar Pago Manual</Typography>
<Typography variant="subtitle1" gutterBottom sx={{fontWeight: 'bold'}}> <Typography variant="body1" color="text.secondary" gutterBottom>
Saldo Pendiente: ${factura.saldoPendiente.toFixed(2)} Para: {nombreSuscriptor}
</Typography>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Saldo Pendiente: ${saldoPendiente.toFixed(2)}
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}> <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2 }}>
<TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} /> <TextField name="fechaPago" label="Fecha de Pago" type="date" value={formData.fechaPago || ''} onChange={handleInputChange} required fullWidth margin="dense" InputLabelProps={{ shrink: true }} error={!!localErrors.fechaPago} helperText={localErrors.fechaPago} />
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPago}> <FormControl fullWidth margin="dense" error={!!localErrors.idFormaPago}>
<InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel> <InputLabel id="forma-pago-label" required>Forma de Pago</InputLabel>
<Select name="idFormaPago" labelId="forma-pago-label" value={formData.idFormaPago || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loadingFormasPago}> <Select name="idFormaPago" labelId="forma-pago-label" value={formData.idFormaPago || ''} onChange={handleSelectChange} label="Forma de Pago" disabled={loadingFormasPago}>
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)} {formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
</Select> </Select>
</FormControl> </FormControl>
<TextField name="monto" label="Monto Pagado" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} /> <TextField name="monto" label="Monto Pagado" type="number" value={formData.monto || ''} onChange={handleInputChange} required fullWidth margin="dense" error={!!localErrors.monto} helperText={localErrors.monto} InputProps={{ startAdornment: <InputAdornment position="start">$</InputAdornment> }} inputProps={{ step: "0.01" }} />
<TextField name="referencia" label="Referencia (Opcional)" value={formData.referencia || ''} onChange={handleInputChange} fullWidth margin="dense" /> <TextField name="referencia" label="Referencia (Opcional)" value={formData.referencia || ''} onChange={handleInputChange} fullWidth margin="dense" />
<TextField name="observaciones" label="Observaciones (Opcional)" value={formData.observaciones || ''} onChange={handleInputChange} fullWidth margin="dense" multiline rows={2} /> <TextField name="observaciones" label="Observaciones (Opcional)" value={formData.observaciones || ''} onChange={handleInputChange} fullWidth margin="dense" multiline rows={2} />
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>} {errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}> <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button> <Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
<Button type="submit" variant="contained" disabled={loading || loadingFormasPago}> <Button type="submit" variant="contained" disabled={loading || loadingFormasPago}>
{loading ? <CircularProgress size={24} /> : 'Registrar Pago'} {loading ? <CircularProgress size={24} /> : 'Registrar Pago'}
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Modal> </Modal>

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
Modal, Box, Typography, TextField, Button, CircularProgress, Alert,
FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox FormControl, InputLabel, Select, MenuItem, FormControlLabel, Checkbox
} from '@mui/material'; } from '@mui/material';
import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto'; import type { UsuarioDto } from '../../../models/dtos/Usuarios/UsuarioDto';

View File

@@ -1,22 +1,22 @@
// src/hooks/usePermissions.ts
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useCallback } from 'react';
export const usePermissions = () => { export const usePermissions = () => {
const { user } = useAuth(); // user aquí es de tipo UserContextData | null const { user } = useAuth();
const tienePermiso = (codigoPermisoRequerido: string): boolean => { // Envolvemos la función en useCallback.
if (!user) { // Si no hay usuario logueado // Su dependencia es [user], por lo que la función solo se
// volverá a crear si el objeto 'user' cambia (ej. al iniciar/cerrar sesión).
const tienePermiso = useCallback((codigoPermisoRequerido: string): boolean => {
if (!user) {
return false; return false;
} }
if (user.esSuperAdmin) { // SuperAdmin tiene todos los permisos if (user.esSuperAdmin) {
return true; return true;
} }
// Verificar si la lista de permisos del usuario incluye el código requerido
return user.permissions?.includes(codigoPermisoRequerido) ?? false; return user.permissions?.includes(codigoPermisoRequerido) ?? false;
}; }, [user]);
// También puede exportar el objeto user completo si se necesita en otros lugares
// o propiedades específicas como idPerfil, esSuperAdmin.
return { return {
tienePermiso, tienePermiso,
isSuperAdmin: user?.esSuperAdmin ?? false, isSuperAdmin: user?.esSuperAdmin ?? false,

View File

@@ -13,6 +13,7 @@ export interface FacturaConsolidadaDto {
estadoFacturacion: string; estadoFacturacion: string;
numeroFactura?: string | null; numeroFactura?: string | null;
totalPagado: number; totalPagado: number;
tipoFactura: 'Mensual' | 'Alta';
detalles: FacturaDetalleDto[]; detalles: FacturaDetalleDto[];
// Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers // Añadimos el id del suscriptor para que sea fácil pasarlo a los handlers
idSuscriptor: number; idSuscriptor: number;

View File

@@ -40,14 +40,12 @@ const ReportesIndexPage: React.FC = () => {
const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true); const [isLoadingInitialNavigation, setIsLoadingInitialNavigation] = useState(true);
const { tienePermiso, isSuperAdmin } = usePermissions(); const { tienePermiso, isSuperAdmin } = usePermissions();
// 1. Creamos una lista memoizada de reportes a los que el usuario SÍ tiene acceso.
const accessibleReportModules = useMemo(() => { const accessibleReportModules = useMemo(() => {
return allReportModules.filter(module => return allReportModules.filter(module =>
isSuperAdmin || tienePermiso(module.requiredPermission) isSuperAdmin || tienePermiso(module.requiredPermission)
); );
}, [isSuperAdmin, tienePermiso]); }, [isSuperAdmin, tienePermiso]);
// 2. Creamos una lista de categorías que SÍ tienen al menos un reporte accesible.
const accessibleCategories = useMemo(() => { const accessibleCategories = useMemo(() => {
const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category)); const categoriesWithAccess = new Set(accessibleReportModules.map(r => r.category));
return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category)); return predefinedCategoryOrder.filter(category => categoriesWithAccess.has(category));
@@ -62,18 +60,23 @@ const ReportesIndexPage: React.FC = () => {
const activeReport = accessibleReportModules.find(module => module.path === subPathSegment); const activeReport = accessibleReportModules.find(module => module.path === subPathSegment);
if (activeReport) { if (activeReport) {
setExpandedCategory(activeReport.category); setExpandedCategory(activeReport.category);
} else {
// Si la URL apunta a un reporte que no es accesible, no expandimos nada
setExpandedCategory(false);
}
} else {
// Si estamos en la página base (/reportes), expandimos la primera categoría disponible.
if (accessibleCategories.length > 0) {
setExpandedCategory(accessibleCategories[0]);
} else {
setExpandedCategory(false);
} }
} }
// 4. Navegamos al PRIMER REPORTE ACCESIBLE si estamos en la ruta base. // No hay navegación automática, solo manejamos el estado de carga.
if (location.pathname === currentBasePath && accessibleReportModules.length > 0 && isLoadingInitialNavigation) {
const firstReportToNavigate = accessibleReportModules[0];
navigate(firstReportToNavigate.path, { replace: true });
}
setIsLoadingInitialNavigation(false); setIsLoadingInitialNavigation(false);
}, [location.pathname, navigate, accessibleReportModules, isLoadingInitialNavigation]); }, [location.pathname, accessibleReportModules, accessibleCategories]);
const handleCategoryClick = (categoryName: string) => { const handleCategoryClick = (categoryName: string) => {
setExpandedCategory(prev => (prev === categoryName ? false : categoryName)); setExpandedCategory(prev => (prev === categoryName ? false : categoryName));
@@ -84,12 +87,10 @@ const ReportesIndexPage: React.FC = () => {
}; };
const isReportActive = (reportPath: string) => { const isReportActive = (reportPath: string) => {
return location.pathname === `/reportes/${reportPath}` || location.pathname.startsWith(`/reportes/${reportPath}/`); return location.pathname.startsWith(`/reportes/${reportPath}`);
}; };
// Si isLoadingInitialNavigation es true Y estamos en /reportes, mostrar loader if (isLoadingInitialNavigation) {
// Esto evita mostrar el loader si se navega directamente a un sub-reporte.
if (isLoadingInitialNavigation && (location.pathname === '/reportes' || location.pathname === '/reportes/')) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}>
<CircularProgress /> <CircularProgress />
@@ -98,48 +99,25 @@ const ReportesIndexPage: React.FC = () => {
} }
return ( return (
// Contenedor principal que se adaptará a su padre
// Eliminamos 'height: calc(100vh - 64px)' y cualquier margen/padding que controle el espacio exterior
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}> <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
{/* Panel Lateral para Navegación */} <Paper elevation={0} square sx={{
<Paper width: { xs: 220, sm: 250, md: 280 },
elevation={0} // Sin elevación para que se sienta más integrado si el fondo es el mismo
square // Bordes rectos
sx={{
width: { xs: 220, sm: 250, md: 280 }, // Ancho responsivo del panel lateral
minWidth: { xs: 200, sm: 220 }, minWidth: { xs: 200, sm: 220 },
height: '100%', // Ocupa toda la altura del Box padre height: '100%',
borderRight: (theme) => `1px solid ${theme.palette.divider}`, borderRight: (theme) => `1px solid ${theme.palette.divider}`,
overflowY: 'auto', overflowY: 'auto',
bgcolor: 'background.paper', // O el color que desees para el menú bgcolor: 'background.paper',
// display: 'flex', flexDirection: 'column' // Para que el título y la lista usen el espacio vertical }}>
}} <Box sx={{ p: 1.5 }}>
> <Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 }}>
{/* Título del Menú Lateral */}
<Box
sx={{
p: 1.5, // Padding interno para el título
// borderBottom: (theme) => `1px solid ${theme.palette.divider}`, // Opcional: separador
// position: 'sticky', // Si quieres que el título quede fijo al hacer scroll en la lista
// top: 0,
// zIndex: 1,
// bgcolor: 'background.paper' // Necesario si es sticky y tiene scroll la lista
}}
>
<Typography variant="h6" component="div" sx={{ fontWeight: 'medium', ml:1 /* Pequeño margen para alinear con items */ }}>
Reportes Reportes
</Typography> </Typography>
</Box> </Box>
{/* 5. Renderizamos el menú usando la lista de categorías ACCESIBLES. */}
{accessibleCategories.length > 0 ? ( {accessibleCategories.length > 0 ? (
<List component="nav" dense sx={{ pt: 0 }}> <List component="nav" dense sx={{ pt: 0 }}>
{accessibleCategories.map((category) => { {accessibleCategories.map((category) => {
// 6. Obtenemos los reportes de esta categoría de la lista ACCESIBLE.
const reportsInCategory = accessibleReportModules.filter(r => r.category === category); const reportsInCategory = accessibleReportModules.filter(r => r.category === category);
// Ya no es necesario el `if (reportsInCategory.length === 0) return null;` porque `accessibleCategories` ya está filtrado.
const isExpanded = expandedCategory === category; const isExpanded = expandedCategory === category;
return ( return (
@@ -147,15 +125,10 @@ const ReportesIndexPage: React.FC = () => {
<ListItemButton <ListItemButton
onClick={() => handleCategoryClick(category)} onClick={() => handleCategoryClick(category)}
sx={{ sx={{
// py: 1.2, // Ajustar padding vertical de items de categoría
// backgroundColor: isExpanded ? 'action.selected' : 'transparent',
borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent', borderLeft: isExpanded ? (theme) => `4px solid ${theme.palette.primary.main}` : '4px solid transparent',
pr: 1, // Menos padding a la derecha para dar espacio al ícono expander pr: 1,
'&:hover': { '&:hover': { backgroundColor: 'action.hover' }
backgroundColor: 'action.hover' }}>
}
}}
>
<ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/> <ListItemText primary={category} primaryTypographyProps={{ fontWeight: isExpanded ? 'bold' : 'normal' }}/>
{isExpanded ? <ExpandLess /> : <ExpandMore />} {isExpanded ? <ExpandLess /> : <ExpandMore />}
</ListItemButton> </ListItemButton>
@@ -167,21 +140,14 @@ const ReportesIndexPage: React.FC = () => {
selected={isReportActive(report.path)} selected={isReportActive(report.path)}
onClick={() => handleReportClick(report.path)} onClick={() => handleReportClick(report.path)}
sx={{ sx={{
pl: 3.5, // Indentación para los reportes (ajustar si se cambió el padding del título) pl: 3.5, py: 0.8,
py: 0.8, // Padding vertical de items de reporte
...(isReportActive(report.path) && { ...(isReportActive(report.path) && {
backgroundColor: (theme) => theme.palette.action.selected, // Un color de fondo sutil backgroundColor: (theme) => theme.palette.action.selected,
borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`, // Un borde para el activo borderLeft: (theme) => `4px solid ${theme.palette.primary.light}`,
'& .MuiListItemText-primary': { '& .MuiListItemText-primary': { fontWeight: 'medium' },
fontWeight: 'medium', // O 'bold'
// color: 'primary.main'
},
}), }),
'&:hover': { '&:hover': { backgroundColor: (theme) => theme.palette.action.hover }
backgroundColor: (theme) => theme.palette.action.hover }}>
}
}}
>
<ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/> <ListItemText primary={report.label} primaryTypographyProps={{ variant: 'body2' }}/>
</ListItemButton> </ListItemButton>
))} ))}
@@ -196,26 +162,17 @@ const ReportesIndexPage: React.FC = () => {
)} )}
</Paper> </Paper>
{/* Área Principal para el Contenido del Reporte */} <Box component="main" sx={{
<Box flexGrow: 1, p: { xs: 1, sm: 2, md: 3 },
component="main" overflowY: 'auto', height: '100%', bgcolor: 'grey.100'
sx={{ }}>
flexGrow: 1, // Ocupa el espacio restante {/* Lógica para mostrar el mensaje de bienvenida */}
p: { xs: 1, sm: 2, md: 3 }, // Padding interno para el contenido, responsivo {location.pathname === '/reportes' && !isLoadingInitialNavigation && (
overflowY: 'auto',
height: '100%', // Ocupa toda la altura del Box padre
bgcolor: 'grey.100' // Un color de fondo diferente para distinguir el área de contenido
}}
>
{/* El Outlet renderiza el componente del reporte específico */}
{(!location.pathname.startsWith('/reportes/') || !allReportModules.some(r => isReportActive(r.path))) && location.pathname !== '/reportes/' && location.pathname !== '/reportes' && !isLoadingInitialNavigation && (
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
El reporte solicitado no existe o la ruta no es válida.
</Typography>
)}
{(location.pathname === '/reportes/' || location.pathname === '/reportes') && !allReportModules.some(r => isReportActive(r.path)) && !isLoadingInitialNavigation && (
<Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}> <Typography sx={{p:2, textAlign:'center', mt: 4, color: 'text.secondary'}}>
{allReportModules.length > 0 ? "Seleccione una categoría y un reporte del menú lateral." : "No hay reportes configurados."} {accessibleReportModules.length > 0
? "Seleccione una categoría y un reporte del menú lateral."
: "No tiene acceso a ningún reporte."
}
</Typography> </Typography>
)} )}
<Outlet /> <Outlet />

View File

@@ -26,6 +26,7 @@ const meses = [
const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada']; const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada'];
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
const tiposFactura = ['Mensual', 'Alta'];
const SuscriptorRow: React.FC<{ const SuscriptorRow: React.FC<{
resumen: ResumenCuentaSuscriptorDto; resumen: ResumenCuentaSuscriptorDto;
@@ -33,8 +34,6 @@ const SuscriptorRow: React.FC<{
handleOpenHistorial: (factura: FacturaConsolidadaDto) => void; handleOpenHistorial: (factura: FacturaConsolidadaDto) => void;
}> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => { }> = ({ resumen, handleMenuOpen, handleOpenHistorial }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Función para formatear moneda
const formatCurrency = (value: number) => `$${value.toFixed(2)}`; const formatCurrency = (value: number) => `$${value.toFixed(2)}`;
return ( return (
@@ -43,20 +42,16 @@ const SuscriptorRow: React.FC<{
<TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell> <TableCell><IconButton size="small" onClick={() => setOpen(!open)}>{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}</IconButton></TableCell>
<TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell> <TableCell component="th" scope="row">{resumen.nombreSuscriptor}</TableCell>
<TableCell align="right"> <TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}> <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>{formatCurrency(resumen.saldoPendienteTotal)}</Typography>
{formatCurrency(resumen.saldoPendienteTotal)}
</Typography>
<Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography> <Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography>
</TableCell> </TableCell>
<TableCell colSpan={7}></TableCell> <TableCell colSpan={6}></TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={10}> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}> {/* <-- Ajustado para la nueva columna */}
<Collapse in={open} timeout="auto" unmountOnExit> <Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> <Box sx={{ margin: 1, padding: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
<Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}> <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography>
Facturas del Período para {resumen.nombreSuscriptor}
</Typography>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
@@ -64,6 +59,7 @@ const SuscriptorRow: React.FC<{
<TableCell align="right">Importe Total</TableCell> <TableCell align="right">Importe Total</TableCell>
<TableCell align="right">Pagado</TableCell> <TableCell align="right">Pagado</TableCell>
<TableCell align="right">Saldo</TableCell> <TableCell align="right">Saldo</TableCell>
<TableCell>Tipo Factura</TableCell>
<TableCell>Estado Pago</TableCell> <TableCell>Estado Pago</TableCell>
<TableCell>Estado Facturación</TableCell> <TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell> <TableCell>Nro. Factura</TableCell>
@@ -77,35 +73,17 @@ const SuscriptorRow: React.FC<{
<TableRow key={factura.idFactura}> <TableRow key={factura.idFactura}>
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
<TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell> <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell>
<TableCell align="right" sx={{ color: 'success.dark' }}> <TableCell align="right" sx={{ color: 'success.dark' }}>{formatCurrency(factura.totalPagado)}</TableCell>
{formatCurrency(factura.totalPagado)} <TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>{formatCurrency(saldo)}</TableCell>
</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>
{formatCurrency(saldo)}
</TableCell>
<TableCell> <TableCell>
<Chip <Chip label={factura.tipoFactura} size="small" color={factura.tipoFactura === 'Alta' ? 'secondary' : 'default'} />
label={factura.estadoPago}
size="small"
color={
factura.estadoPago === 'Pagada' ? 'success' :
factura.estadoPago === 'Pagada Parcialmente' ? 'primary' :
factura.estadoPago === 'Rechazada' ? 'error' :
'default'
}
/>
</TableCell> </TableCell>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Pagada Parcialmente' ? 'primary' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default'))} /></TableCell>
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> <TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell>
<TableCell>{factura.numeroFactura || '-'}</TableCell> <TableCell>{factura.numeroFactura || '-'}</TableCell>
<TableCell align="right"> <TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> <IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}><MoreVertIcon /></IconButton>
<MoreVertIcon /> <Tooltip title="Ver Historial de Envíos"><IconButton onClick={() => handleOpenHistorial(factura)}><MailOutlineIcon /></IconButton></Tooltip>
</IconButton>
<Tooltip title="Ver Historial de Envíos">
<IconButton onClick={() => handleOpenHistorial(factura)}>
<MailOutlineIcon />
</IconButton>
</Tooltip>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -135,6 +113,7 @@ const ConsultaFacturasPage: React.FC = () => {
const [filtroNombre, setFiltroNombre] = useState(''); const [filtroNombre, setFiltroNombre] = useState('');
const [filtroEstadoPago, setFiltroEstadoPago] = useState(''); const [filtroEstadoPago, setFiltroEstadoPago] = useState('');
const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState(''); const [filtroEstadoFacturacion, setFiltroEstadoFacturacion] = useState('');
const [filtroTipoFactura, setFiltroTipoFactura] = useState('');
const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null); const [selectedFactura, setSelectedFactura] = useState<FacturaConsolidadaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -154,7 +133,8 @@ const ConsultaFacturasPage: React.FC = () => {
selectedMes, selectedMes,
filtroNombre || undefined, filtroNombre || undefined,
filtroEstadoPago || undefined, filtroEstadoPago || undefined,
filtroEstadoFacturacion || undefined filtroEstadoFacturacion || undefined,
filtroTipoFactura || undefined
); );
setResumenes(data); setResumenes(data);
} catch (err) { } catch (err) {
@@ -163,7 +143,7 @@ const ConsultaFacturasPage: React.FC = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion]); }, [selectedAnio, selectedMes, puedeConsultar, filtroNombre, filtroEstadoPago, filtroEstadoFacturacion, filtroTipoFactura]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -251,6 +231,17 @@ const ConsultaFacturasPage: React.FC = () => {
<TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} /> <TextField label="Buscar por Suscriptor" size="small" value={filtroNombre} onChange={(e) => setFiltroNombre(e.target.value)} sx={{ flexGrow: 1, minWidth: '200px' }} />
<FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Pago</InputLabel><Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Pago</InputLabel><Select value={filtroEstadoPago} label="Estado de Pago" onChange={(e) => setFiltroEstadoPago(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosPago.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Facturación</InputLabel><Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl> <FormControl sx={{ minWidth: 200 }} size="small"><InputLabel>Estado de Facturación</InputLabel><Select value={filtroEstadoFacturacion} label="Estado de Facturación" onChange={(e) => setFiltroEstadoFacturacion(e.target.value)}><MenuItem value=""><em>Todos</em></MenuItem>{estadosFacturacion.map(e => <MenuItem key={e} value={e}>{e}</MenuItem>)}</Select></FormControl>
<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel>Tipo de Factura</InputLabel>
<Select
value={filtroTipoFactura}
label="Tipo de Factura"
onChange={(e) => setFiltroTipoFactura(e.target.value)}
>
<MenuItem value=""><em>Todos</em></MenuItem>
{tiposFactura.map(t => <MenuItem key={t} value={t}>{t}</MenuItem>)}
</Select>
</FormControl>
</Box> </Box>
</Paper> </Paper>
@@ -259,7 +250,7 @@ const ConsultaFacturasPage: React.FC = () => {
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table aria-label="collapsible table"> <Table aria-label="collapsible table">
<TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={5}></TableCell></TableRow></TableHead> <TableHead><TableRow><TableCell /><TableCell>Suscriptor</TableCell><TableCell align="right">Saldo Total / Importe Total</TableCell><TableCell colSpan={6}></TableCell></TableRow></TableHead>
<TableBody> <TableBody>
{loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>) {loading ? (<TableRow><TableCell colSpan={8} align="center"><CircularProgress /></TableCell></TableRow>)
: resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>) : resumenes.length === 0 ? (<TableRow><TableCell colSpan={8} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
@@ -285,22 +276,9 @@ const ConsultaFacturasPage: React.FC = () => {
open={pagoModalOpen} open={pagoModalOpen}
onClose={handleClosePagoModal} onClose={handleClosePagoModal}
onSubmit={handleSubmitPagoModal} onSubmit={handleSubmitPagoModal}
factura={ factura={selectedFactura}
selectedFactura ? { nombreSuscriptor={
idFactura: selectedFactura.idFactura, resumenes.find(r => r.idSuscriptor === selectedFactura?.idSuscriptor)?.nombreSuscriptor || ''
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
} }
errorMessage={apiError} errorMessage={apiError}
clearErrorMessage={() => setApiError(null)} clearErrorMessage={() => setApiError(null)}

View File

@@ -1,11 +1,9 @@
// src/routes/AppRoutes.tsx
import React, { type JSX } from 'react'; import React, { type JSX } from 'react';
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import LoginPage from '../pages/LoginPage'; import LoginPage from '../pages/LoginPage';
import HomePage from '../pages/HomePage'; import HomePage from '../pages/HomePage';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import MainLayout from '../layouts/MainLayout'; import MainLayout from '../layouts/MainLayout';
import { Typography } from '@mui/material';
import SectionProtectedRoute from './SectionProtectedRoute'; import SectionProtectedRoute from './SectionProtectedRoute';
// Distribución // Distribución
@@ -267,7 +265,6 @@ const AppRoutes = () => {
<ReportesIndexPage /> <ReportesIndexPage />
</SectionProtectedRoute>} </SectionProtectedRoute>}
> >
<Route index element={<Typography sx={{ p: 2 }}>Seleccione un reporte del menú lateral.</Typography>} /> {/* Placeholder */}
<Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} /> <Route path="existencia-papel" element={<ReporteExistenciaPapelPage />} />
<Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} /> <Route path="movimiento-bobinas" element={<ReporteMovimientoBobinasPage />} />
<Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} /> <Route path="movimiento-bobinas-estado" element={<ReporteMovimientoBobinasEstadoPage />} />

View File

@@ -19,11 +19,16 @@ const procesarArchivoRespuesta = async (archivo: File): Promise<ProcesamientoLot
return response.data; return response.data;
}; };
const getResumenesDeCuentaPorPeriodo = async (anio: number, mes: number, nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string): Promise<ResumenCuentaSuscriptorDto[]> => { const getResumenesDeCuentaPorPeriodo = async (
anio: number, mes: number,
nombreSuscriptor?: string, estadoPago?: string, estadoFacturacion?: string,
tipoFactura?: string
): Promise<ResumenCuentaSuscriptorDto[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor); if (nombreSuscriptor) params.append('nombreSuscriptor', nombreSuscriptor);
if (estadoPago) params.append('estadoPago', estadoPago); if (estadoPago) params.append('estadoPago', estadoPago);
if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion); if (estadoFacturacion) params.append('estadoFacturacion', estadoFacturacion);
if (tipoFactura) params.append('tipoFactura', tipoFactura);
const queryString = params.toString(); const queryString = params.toString();
const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`; const url = `${API_URL}/${anio}/${mes}${queryString ? `?${queryString}` : ''}`;

View File

@@ -60,6 +60,22 @@ El sistema está organizado en varios módulos clave para cubrir todas las área
- **Autenticación Segura:** Mediante JSON Web Tokens (JWT). - **Autenticación Segura:** Mediante JSON Web Tokens (JWT).
- **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`). - **Auditoría:** Todas las modificaciones a los datos maestros y transacciones importantes se registran en tablas de historial (`_H`).
### 📨 Suscripciones
- **Gestión de Suscriptores:** ABM completo de clientes, incluyendo datos de contacto, dirección de entrega y forma de pago preferida.
- **Ciclo de Vida de la Suscripción:** Creación y administración de suscripciones por cliente y publicación, con fechas de inicio, fin, días de entrega y estados (`Activa`, `Pausada`, `Cancelada`).
- **Facturación Proporcional (Pro-rata):** El sistema genera automáticamente una "Factura de Alta" por el monto proporcional cuando un cliente se suscribe en un período ya cerrado, evitando cobros excesivos en la primera factura.
- **Gestión de Promociones:** ABM de promociones (ej. descuentos porcentuales, bonificación de días) y asignación a suscripciones específicas con vigencia definida.
- **Cuenta Corriente del Suscriptor:** Administración de ajustes manuales (`Crédito`/`Débito`) para manejar situaciones excepcionales como notas de crédito, devoluciones o cargos especiales.
- **Procesos de Cierre Mensual:**
- **Generación de Cierre:** Proceso masivo que calcula y genera todas las facturas del período, aplicando promociones y ajustes.
- **Notificaciones Automáticas:** Envío automático de resúmenes de cuenta por email a cada suscriptor al generar el cierre.
- **Gestión de Débito Automático:**
- **Generación de Archivo:** Creación del archivo de texto plano en formato "Pago Directo" para el Banco Galicia. Las "Facturas de Alta" se excluyen automáticamente de este proceso.
- **Procesamiento de Respuesta:** Herramienta para procesar el archivo de respuesta del banco, actualizando los estados de pago (`Pagada`/`Rechazada`) de forma masiva.
- **Auditoría de Comunicaciones:**
- **Log de Envíos:** Registro detallado de cada correo electrónico enviado (individual o masivo), incluyendo estado (`Enviado`/`Fallido`) y mensajes de error.
- **Historial de Envíos:** Interfaz para consultar el historial de notificaciones enviadas por cada factura o por cada lote de cierre mensual.
--- ---
## 🛠️ Stack Tecnológico ## 🛠️ Stack Tecnológico