Feat(suscripciones): Implementa manejo de pagos parciales en facturas
All checks were successful
Optimized Build and Deploy / remote-build-and-deploy (push) Successful in 8m3s

Se introduce una refactorización completa del sistema de registro de pagos para manejar correctamente los abonos parciales, asegurando que el estado de la factura y el saldo pendiente se reflejen con precisión tanto en el backend como en la interfaz de usuario.

### 🐛 Problema Solucionado

- Anteriormente, el sistema no reconocía los pagos parciales. Una factura permanecía en estado "Pendiente" hasta que el monto total era cubierto, y la interfaz de usuario siempre mostraba el 100% del saldo como pendiente, lo cual era incorrecto y confuso.

###  Nuevas Características y Mejoras

- **Nuevo Estado de Factura "Pagada Parcialmente":**
    - Se introduce un nuevo estado para las facturas que han recibido uno o más pagos pero cuyo saldo aún no es cero.
    - El `PagoService` ahora actualiza el estado de la factura a "Pagada Parcialmente" cuando recibe un abono que no cubre el total.

- **Mejoras en la Interfaz de Usuario (`ConsultaFacturasPage`):**
    - **Nuevas Columnas:** Se han añadido las columnas "Pagado" y "Saldo" a la tabla de detalle de facturas, mostrando explícitamente el monto abonado y el restante.
    - **Visualización de Estado:** El `Chip` de estado ahora muestra "Pagada Parcialmente" con un color distintivo (azul/primary) para una rápida identificación visual.
    - **Cálculo de Saldo Correcto:** El saldo pendiente total por suscriptor y el saldo para el modal de pago manual ahora se calculan correctamente, restando el `totalPagado` del `importeFinal`.

### 🔄 Cambios en el Backend

- **`PagoService`:** Se actualizó la lógica para establecer el estado de la factura (`Pendiente`, `Pagada Parcialmente`, `Pagada`) basado en el `nuevoTotalPagado` después de registrar un pago.
- **`FacturacionService`:** El método `ObtenerResumenesDeCuentaPorPeriodo` ahora calcula correctamente el `SaldoPendienteTotal` y pasa la propiedad `TotalPagado` al DTO del frontend.
- **DTOs:** Se actualizó `FacturaConsolidadaDto` para incluir la propiedad `TotalPagado`.
This commit is contained in:
2025-08-11 15:15:08 -03:00
parent dd2277fce2
commit 2e7d1e36be
5 changed files with 90 additions and 63 deletions

View File

@@ -8,6 +8,7 @@ namespace GestionIntegral.Api.Dtos.Suscripciones
public string EstadoPago { get; set; } = string.Empty; public string EstadoPago { get; set; } = string.Empty;
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 List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>(); public List<FacturaDetalleDto> Detalles { get; set; } = new List<FacturaDetalleDto>();
} }
} }

View File

@@ -282,7 +282,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
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);
var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo); // Necesitaremos este nuevo método en el repo var detallesData = await _facturaDetalleRepository.GetDetallesPorPeriodoAsync(periodo);
var empresas = await _empresaRepository.GetAllAsync(null, null); var empresas = await _empresaRepository.GetAllAsync(null, null);
var resumenes = facturasData var resumenes = facturasData
@@ -301,6 +301,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
EstadoPago = itemFactura.Factura.EstadoPago, EstadoPago = itemFactura.Factura.EstadoPago,
EstadoFacturacion = itemFactura.Factura.EstadoFacturacion, EstadoFacturacion = itemFactura.Factura.EstadoFacturacion,
NumeroFactura = itemFactura.Factura.NumeroFactura, NumeroFactura = itemFactura.Factura.NumeroFactura,
TotalPagado = itemFactura.TotalPagado,
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 })
@@ -314,7 +315,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
NombreSuscriptor = primerItem.NombreSuscriptor, NombreSuscriptor = primerItem.NombreSuscriptor,
Facturas = facturasConsolidadas, Facturas = facturasConsolidadas,
ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal), ImporteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal),
SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.EstadoPago == "Pagada" ? 0 : f.ImporteFinal) SaldoPendienteTotal = facturasConsolidadas.Sum(f => f.ImporteFinal - f.TotalPagado)
}; };
}); });

View File

@@ -70,14 +70,11 @@ namespace GestionIntegral.Api.Services.Suscripciones
{ {
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura); var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe."); if (factura == null) return (null, "La factura especificada no existe.");
// Usar EstadoPago para la validación
if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada."); if (factura.EstadoPago == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago); var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida."); if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
// Obtenemos la suma de pagos ANTERIORES
var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction); var totalPagadoAnteriormente = await _pagoRepository.GetTotalPagadoAprobadoAsync(createDto.IdFactura, transaction);
var nuevoPago = new Pago var nuevoPago = new Pago
@@ -96,37 +93,31 @@ namespace GestionIntegral.Api.Services.Suscripciones
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction); var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago."); if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
// Calculamos el nuevo total EN MEMORIA
var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto; var nuevoTotalPagado = totalPagadoAnteriormente + pagoCreado.Monto;
// Comparamos y actualizamos el estado si es necesario // Nueva lógica para manejar todos los estados de pago
// CORRECCIÓN: Usar EstadoPago y el método correcto del repositorio string nuevoEstadoPago = factura.EstadoPago;
if (factura.EstadoPago != "Pagada" && nuevoTotalPagado >= factura.ImporteFinal) if (nuevoTotalPagado >= factura.ImporteFinal)
{ {
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, "Pagada", transaction); nuevoEstadoPago = "Pagada";
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'."); }
else if (nuevoTotalPagado > 0)
{
nuevoEstadoPago = "Pagada Parcialmente";
}
// Si nuevoTotalPagado es 0, el estado no cambia.
// Solo actualizamos si el estado calculado es diferente al actual.
if (nuevoEstadoPago != factura.EstadoPago)
{
bool actualizado = await _facturaRepository.UpdateEstadoPagoAsync(factura.IdFactura, nuevoEstadoPago, transaction);
if (!actualizado) throw new DataException($"No se pudo actualizar el estado de la factura a '{nuevoEstadoPago}'.");
} }
transaction.Commit(); transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario); _logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
// Construimos el DTO de respuesta SIN volver a consultar la base de datos var dto = await MapToDto(pagoCreado); // MapToDto ahora es más simple
var usuario = await _usuarioRepository.GetByIdAsync(idUsuario);
var dto = new PagoDto
{
IdPago = pagoCreado.IdPago,
IdFactura = pagoCreado.IdFactura,
FechaPago = pagoCreado.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pagoCreado.IdFormaPago,
NombreFormaPago = formaPago.Nombre,
Monto = pagoCreado.Monto,
Estado = pagoCreado.Estado,
Referencia = pagoCreado.Referencia,
Observaciones = pagoCreado.Observaciones,
IdUsuarioRegistro = pagoCreado.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "N/A"
};
return (dto, null); return (dto, null);
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -12,6 +12,7 @@ export interface FacturaConsolidadaDto {
estadoPago: string; estadoPago: string;
estadoFacturacion: string; estadoFacturacion: string;
numeroFactura?: string | null; numeroFactura?: string | null;
totalPagado: number;
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

@@ -24,7 +24,7 @@ const meses = [
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' } { value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
]; ];
const estadosPago = ['Pendiente', 'Pagada', 'Rechazada', 'Anulada']; const estadosPago = ['Pendiente', 'Pagada', 'Pagada Parcialmente', 'Rechazada', 'Anulada'];
const estadosFacturacion = ['Pendiente de Facturar', 'Facturado']; const estadosFacturacion = ['Pendiente de Facturar', 'Facturado'];
const SuscriptorRow: React.FC<{ const SuscriptorRow: React.FC<{
@@ -33,50 +33,83 @@ 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)}`;
return ( return (
<React.Fragment> <React.Fragment>
<TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover> <TableRow sx={{ '& > *': { borderBottom: 'unset' } }} hover>
<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' }}>${resumen.saldoPendienteTotal.toFixed(2)}</Typography> <Typography variant="body2" sx={{ fontWeight: 'bold', color: resumen.saldoPendienteTotal > 0 ? 'error.main' : 'success.main' }}>
<Typography variant="caption" color="text.secondary">de ${resumen.importeTotal.toFixed(2)}</Typography> {formatCurrency(resumen.saldoPendienteTotal)}
</Typography>
<Typography variant="caption" color="text.secondary">de {formatCurrency(resumen.importeTotal)}</Typography>
</TableCell> </TableCell>
<TableCell colSpan={5}></TableCell> <TableCell colSpan={7}></TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={10}>
<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' }}>Facturas del Período para {resumen.nombreSuscriptor}</Typography> <Typography variant="h6" gutterBottom component="div" sx={{ fontSize: '1rem', fontWeight: 'bold' }}>
Facturas del Período para {resumen.nombreSuscriptor}
</Typography>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Empresa</TableCell><TableCell align="right">Importe</TableCell> <TableCell>Empresa</TableCell>
<TableCell>Estado Pago</TableCell><TableCell>Estado Facturación</TableCell> <TableCell align="right">Importe Total</TableCell>
<TableCell>Nro. Factura</TableCell><TableCell align="right">Acciones</TableCell> <TableCell align="right">Pagado</TableCell>
<TableCell align="right">Saldo</TableCell>
<TableCell>Estado Pago</TableCell>
<TableCell>Estado Facturación</TableCell>
<TableCell>Nro. Factura</TableCell>
<TableCell align="right">Acciones</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{resumen.facturas.map((factura) => ( {resumen.facturas.map((factura) => {
<TableRow key={factura.idFactura}> const saldo = factura.importeFinal - factura.totalPagado;
<TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell> return (
<TableCell align="right">${factura.importeFinal.toFixed(2)}</TableCell> <TableRow key={factura.idFactura}>
<TableCell><Chip label={factura.estadoPago} size="small" color={factura.estadoPago === 'Pagada' ? 'success' : (factura.estadoPago === 'Rechazada' ? 'error' : 'default')} /></TableCell> <TableCell sx={{ fontWeight: 'medium' }}>{factura.nombreEmpresa}</TableCell>
<TableCell><Chip label={factura.estadoFacturacion} size="small" color={factura.estadoFacturacion === 'Facturado' ? 'info' : 'warning'} /></TableCell> <TableCell align="right">{formatCurrency(factura.importeFinal)}</TableCell>
<TableCell>{factura.numeroFactura || '-'}</TableCell> <TableCell align="right" sx={{ color: 'success.dark' }}>
<TableCell align="right"> {formatCurrency(factura.totalPagado)}
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}> </TableCell>
<MoreVertIcon /> <TableCell align="right" sx={{ fontWeight: 'bold', color: saldo > 0 ? 'error.main' : 'inherit' }}>
</IconButton> {formatCurrency(saldo)}
<Tooltip title="Ver Historial de Envíos"> </TableCell>
<IconButton onClick={() => handleOpenHistorial(factura)}> <TableCell>
<MailOutlineIcon /> <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>{factura.numeroFactura || '-'}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, factura)} disabled={factura.estadoPago === 'Anulada'}>
<MoreVertIcon />
</IconButton> </IconButton>
</Tooltip> <Tooltip title="Ver Historial de Envíos">
</TableCell> <IconButton onClick={() => handleOpenHistorial(factura)}>
</TableRow> <MailOutlineIcon />
))} </IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</Box> </Box>
@@ -162,7 +195,7 @@ const ConsultaFacturasPage: React.FC = () => {
setLoadingLogs(false); setLoadingLogs(false);
} }
}; };
const handleSubmitPagoModal = async (data: CreatePagoDto) => { const handleSubmitPagoModal = async (data: CreatePagoDto) => {
setApiError(null); setApiError(null);
try { try {
@@ -231,10 +264,10 @@ const ConsultaFacturasPage: React.FC = () => {
{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>)
: (resumenes.map(resumen => ( : (resumenes.map(resumen => (
<SuscriptorRow <SuscriptorRow
key={resumen.idSuscriptor} key={resumen.idSuscriptor}
resumen={resumen} resumen={resumen}
handleMenuOpen={handleMenuOpen} handleMenuOpen={handleMenuOpen}
handleOpenHistorial={handleOpenHistorial} handleOpenHistorial={handleOpenHistorial}
/> />
)))} )))}
@@ -257,12 +290,12 @@ const ConsultaFacturasPage: React.FC = () => {
idFactura: selectedFactura.idFactura, idFactura: selectedFactura.idFactura,
nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '', nombreSuscriptor: resumenes.find(r => r.idSuscriptor === resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor)?.nombreSuscriptor || '',
importeFinal: selectedFactura.importeFinal, importeFinal: selectedFactura.importeFinal,
saldoPendiente: selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal, saldoPendiente: selectedFactura.importeFinal - selectedFactura.totalPagado,
totalPagado: selectedFactura.totalPagado,
idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0, idSuscriptor: resumenes.find(res => res.facturas.some(f => f.idFactura === selectedFactura.idFactura))?.idSuscriptor || 0,
periodo: '', periodo: '',
fechaEmision: '', fechaEmision: '',
fechaVencimiento: '', fechaVencimiento: '',
totalPagado: selectedFactura.importeFinal - (selectedFactura.estadoPago === 'Pagada' ? 0 : selectedFactura.importeFinal),
estadoPago: selectedFactura.estadoPago, estadoPago: selectedFactura.estadoPago,
estadoFacturacion: selectedFactura.estadoFacturacion, estadoFacturacion: selectedFactura.estadoFacturacion,
numeroFactura: selectedFactura.numeroFactura, numeroFactura: selectedFactura.numeroFactura,
@@ -272,7 +305,7 @@ const ConsultaFacturasPage: React.FC = () => {
errorMessage={apiError} errorMessage={apiError}
clearErrorMessage={() => setApiError(null)} clearErrorMessage={() => setApiError(null)}
/> />
<HistorialEnviosModal <HistorialEnviosModal
open={historialModalOpen} open={historialModalOpen}
onClose={() => setHistorialModalOpen(false)} onClose={() => setHistorialModalOpen(false)}