Feat: Implementa flujo completo de facturación y promociones

Este commit introduce la funcionalidad completa para la facturación mensual,
la gestión de promociones y la comunicación con el cliente en el módulo
de suscripciones.

Backend:
- Se añade el servicio de Facturación que calcula automáticamente los importes
  mensuales basándose en las suscripciones activas, días de entrega y precios.
- Se implementa el servicio DebitoAutomaticoService, capaz de generar el
  archivo de texto plano para "Pago Directo Galicia" y de procesar el
  archivo de respuesta para la conciliación de pagos.
- Se desarrolla el ABM completo para Promociones (Servicio, Repositorio,
  Controlador y DTOs), permitiendo la creación de descuentos por porcentaje
  o monto fijo.
- Se implementa la lógica para asignar y desasignar promociones a suscripciones
  específicas.
- Se añade un servicio de envío de email (EmailService) integrado con MailKit
  y un endpoint para notificar facturas a los clientes.
- Se crea la lógica para registrar pagos manuales (efectivo, tarjeta, etc.)
  y actualizar el estado de las facturas.
- Se añaden todos los permisos necesarios a la base de datos para
  segmentar el acceso a las nuevas funcionalidades.

Frontend:
- Se crea la página de Facturación, que permite al usuario seleccionar un
  período, generar la facturación, listar los resultados y generar el archivo
  de débito para el banco.
- Se implementa la funcionalidad para subir y procesar el archivo de
  respuesta del banco, actualizando la UI en consecuencia.
- Se añade la página completa para el ABM de Promociones.
- Se integra un modal en la gestión de suscripciones para asignar y
  desasignar promociones a un cliente.
- Se añade la opción "Enviar Email" en el menú de acciones de las facturas,
  conectada al nuevo endpoint del backend.
- Se completan y corrigen los componentes `PagoManualModal` y `FacturacionPage`
  para incluir la lógica de registro de pagos y solucionar errores de TypeScript.
This commit is contained in:
2025-08-01 12:53:17 -03:00
parent b14c5de1b4
commit 84187a66df
53 changed files with 2895 additions and 43 deletions

View File

@@ -0,0 +1,321 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Typography, Button, Paper, CircularProgress, Alert, Select, MenuItem, FormControl, InputLabel, Table, TableContainer, TableHead, TableRow, TableCell, TableBody, Chip, IconButton, Menu, ListItemIcon, ListItemText } from '@mui/material';
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
import DownloadIcon from '@mui/icons-material/Download';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PaymentIcon from '@mui/icons-material/Payment';
import EmailIcon from '@mui/icons-material/Email';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { styled } from '@mui/material/styles';
import facturacionService from '../../services/Suscripciones/facturacionService';
import { usePermissions } from '../../hooks/usePermissions';
import axios from 'axios';
import type { FacturaDto } from '../../models/dtos/Suscripciones/FacturaDto';
import PagoManualModal from '../../components/Modals/Suscripciones/PagoManualModal';
import type { CreatePagoDto } from '../../models/dtos/Suscripciones/CreatePagoDto';
const anios = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i);
const meses = [
{ value: 1, label: 'Enero' }, { value: 2, label: 'Febrero' }, { value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' }, { value: 5, label: 'Mayo' }, { value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' }, { value: 8, label: 'Agosto' }, { value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' }, { value: 11, label: 'Noviembre' }, { value: 12, label: 'Diciembre' }
];
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)', clipPath: 'inset(50%)', height: 1, overflow: 'hidden',
position: 'absolute', bottom: 0, left: 0, whiteSpace: 'nowrap', width: 1,
});
const FacturacionPage: React.FC = () => {
const [selectedAnio, setSelectedAnio] = useState<number>(new Date().getFullYear());
const [selectedMes, setSelectedMes] = useState<number>(new Date().getMonth() + 1);
const [loading, setLoading] = useState(false);
const [loadingArchivo, setLoadingArchivo] = useState(false);
const [loadingProceso, setLoadingProceso] = useState(false);
const [apiMessage, setApiMessage] = useState<string | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [facturas, setFacturas] = useState<FacturaDto[]>([]);
const { tienePermiso, isSuperAdmin } = usePermissions();
const puedeGenerarFacturacion = isSuperAdmin || tienePermiso("SU006");
const puedeGenerarArchivo = isSuperAdmin || tienePermiso("SU007");
const puedeRegistrarPago = isSuperAdmin || tienePermiso("SU008");
const puedeEnviarEmail = isSuperAdmin || tienePermiso("SU009");
const [pagoModalOpen, setPagoModalOpen] = useState(false);
const [selectedFactura, setSelectedFactura] = useState<FacturaDto | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [archivoSeleccionado, setArchivoSeleccionado] = useState<File | null>(null);
const cargarFacturasDelPeriodo = useCallback(async () => {
if (!puedeGenerarFacturacion) return;
setLoading(true);
try {
const data = await facturacionService.getFacturasPorPeriodo(selectedAnio, selectedMes);
setFacturas(data);
} catch (err) {
setFacturas([]);
console.error(err);
} finally {
setLoading(false);
}
}, [selectedAnio, selectedMes, puedeGenerarFacturacion]);
useEffect(() => {
cargarFacturasDelPeriodo();
}, [cargarFacturasDelPeriodo]);
const handleGenerarFacturacion = async () => {
if (!window.confirm(`¿Está seguro de que desea generar la facturación para ${meses.find(m => m.value === selectedMes)?.label} de ${selectedAnio}? Este proceso creará registros de cobro para todas las suscripciones activas.`)) {
return;
}
setLoading(true);
setApiMessage(null);
setApiError(null);
try {
const response = await facturacionService.generarFacturacionMensual(selectedAnio, selectedMes);
setApiMessage(`${response.message}. Se generaron ${response.facturasGeneradas} facturas.`);
await cargarFacturasDelPeriodo();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message
? err.response.data.message
: 'Ocurrió un error al generar la facturación.';
setApiError(message);
} finally {
setLoading(false);
}
};
const handleGenerarArchivo = async () => {
if (!window.confirm(`Se generará el archivo de débito para las facturas del período ${meses.find(m => m.value === selectedMes)?.label}/${selectedAnio} que estén en estado 'Pendiente de Cobro'. ¿Continuar?`)) {
return;
}
setLoadingArchivo(true);
setApiMessage(null);
setApiError(null);
try {
const { fileContent, fileName } = await facturacionService.generarArchivoDebito(selectedAnio, selectedMes);
const url = window.URL.createObjectURL(new Blob([fileContent]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);
setApiMessage(`Archivo "${fileName}" generado y descargado exitosamente.`);
cargarFacturasDelPeriodo();
} catch (err: any) {
let message = 'Ocurrió un error al generar el archivo.';
if (axios.isAxiosError(err) && err.response) {
const errorText = await err.response.data.text();
try {
const errorJson = JSON.parse(errorText);
message = errorJson.message || message;
} catch { message = errorText || message; }
}
setApiError(message);
} finally {
setLoadingArchivo(false);
}
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>, factura: FacturaDto) => {
setAnchorEl(event.currentTarget);
setSelectedFactura(factura);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedFactura(null);
};
const handleOpenPagoModal = () => {
setPagoModalOpen(true);
handleMenuClose();
};
const handleClosePagoModal = () => {
setPagoModalOpen(false);
};
const handleSubmitPagoModal = async (data: CreatePagoDto) => {
setApiError(null);
try {
await facturacionService.registrarPagoManual(data);
setApiMessage(`Pago para la factura #${data.idFactura} registrado exitosamente.`);
cargarFacturasDelPeriodo();
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : 'Error al registrar el pago.';
setApiError(message);
throw err;
}
};
const handleSendEmail = async (idFactura: number) => {
if (!window.confirm(`¿Está seguro de enviar la notificación de la factura #${idFactura} por email?`)) return;
setApiMessage(null);
setApiError(null);
try {
await facturacionService.enviarFacturaPorEmail(idFactura);
setApiMessage(`El email para la factura #${idFactura} ha sido enviado a la cola de procesamiento.`);
} catch (err: any) {
setApiError(err.response?.data?.message || 'Error al intentar enviar el email.');
} finally {
handleMenuClose();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setArchivoSeleccionado(event.target.files[0]);
setApiMessage(null);
setApiError(null);
}
};
const handleProcesarArchivo = async () => {
if (!archivoSeleccionado) {
setApiError("Por favor, seleccione un archivo de respuesta para procesar.");
return;
}
setLoadingProceso(true);
setApiMessage(null);
setApiError(null);
try {
const response = await facturacionService.procesarArchivoRespuesta(archivoSeleccionado);
setApiMessage(response.mensajeResumen);
if (response.errores?.length > 0) {
setApiError(`Se encontraron los siguientes problemas durante el proceso:\n${response.errores.join('\n')}`);
}
cargarFacturasDelPeriodo(); // Recargar para ver los estados finales
} catch (err: any) {
const message = axios.isAxiosError(err) && err.response?.data?.mensajeResumen
? err.response.data.mensajeResumen
: 'Ocurrió un error crítico al procesar el archivo.';
setApiError(message);
} finally {
setLoadingProceso(false);
setArchivoSeleccionado(null);
}
};
if (!puedeGenerarFacturacion) {
return <Alert severity="error">No tiene permiso para acceder a esta sección.</Alert>;
}
return (
<Box sx={{ p: 1 }}>
<Typography variant="h5" gutterBottom>Facturación y Débito Automático</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">1. Generación de Facturación</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Este proceso calcula los importes a cobrar para todas las suscripciones activas en el período seleccionado.
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Mes</InputLabel>
<Select value={selectedMes} label="Mes" onChange={(e) => setSelectedMes(e.target.value as number)}>{meses.map(m => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}</Select>
</FormControl>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel>Año</InputLabel>
<Select value={selectedAnio} label="Año" onChange={(e) => setSelectedAnio(e.target.value as number)}>{anios.map(a => <MenuItem key={a} value={a}>{a}</MenuItem>)}</Select>
</FormControl>
</Box>
<Button variant="contained" color="primary" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />} onClick={handleGenerarFacturacion} disabled={loading || loadingArchivo}>Generar Facturación del Período</Button>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">2. Generación de Archivo para Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>Crea el archivo de texto para enviar a "Pago Directo Galicia" con todas las facturas del período que estén listas para el cobro.</Typography>
<Button variant="contained" color="secondary" startIcon={loadingArchivo ? <CircularProgress size={20} color="inherit" /> : <DownloadIcon />} onClick={handleGenerarArchivo} disabled={loading || loadingArchivo || !puedeGenerarArchivo}>Generar Archivo de Débito</Button>
</Paper>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6">3. Procesar Respuesta del Banco</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Suba aquí el archivo de respuesta de Galicia para actualizar automáticamente el estado de las facturas a "Pagada" o "Rechazada".
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
component="label"
role={undefined}
variant="outlined"
tabIndex={-1}
startIcon={<UploadFileIcon />}
disabled={loadingProceso}
>
Seleccionar Archivo
<VisuallyHiddenInput type="file" onChange={handleFileChange} accept=".txt, text/plain" />
</Button>
{archivoSeleccionado && <Typography variant="body2">{archivoSeleccionado.name}</Typography>}
</Box>
{archivoSeleccionado && (
<Button
variant="contained"
color="success"
sx={{ mt: 2 }}
onClick={handleProcesarArchivo}
disabled={loadingProceso}
startIcon={loadingProceso ? <CircularProgress size={20} color="inherit" /> : <PlayCircleOutlineIcon />}
>
Procesar Archivo de Respuesta
</Button>
)}
</Paper>
{apiError && <Alert severity="error" sx={{ my: 2 }}>{apiError}</Alert>}
{apiMessage && <Alert severity="success" sx={{ my: 2 }}>{apiMessage}</Alert>}
<Typography variant="h6" sx={{ mt: 4 }}>Facturas del Período</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell><TableCell>Suscriptor</TableCell><TableCell>Publicación</TableCell>
<TableCell align="right">Importe</TableCell><TableCell>Estado</TableCell><TableCell>Nro. Factura</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (<TableRow><TableCell colSpan={7} align="center"><CircularProgress /></TableCell></TableRow>)
: facturas.length === 0 ? (<TableRow><TableCell colSpan={7} align="center">No hay facturas para el período seleccionado.</TableCell></TableRow>)
: (facturas.map(f => (
<TableRow key={f.idFactura} hover>
<TableCell>{f.idFactura}</TableCell>
<TableCell>{f.nombreSuscriptor}</TableCell>
<TableCell>{f.nombrePublicacion}</TableCell>
<TableCell align="right">${f.importeFinal.toFixed(2)}</TableCell>
<TableCell><Chip label={f.estado} size="small" color={f.estado === 'Pagada' ? 'success' : (f.estado === 'Rechazada' ? 'error' : 'default')} /></TableCell>
<TableCell>{f.numeroFactura || '-'}</TableCell>
<TableCell align="right">
<IconButton onClick={(e) => handleMenuOpen(e, f)} disabled={f.estado === 'Pagada' || f.estado === 'Anulada'}>
<MoreVertIcon />
</IconButton>
</TableCell>
</TableRow>
)))}
</TableBody>
</Table>
</TableContainer>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
{selectedFactura && puedeRegistrarPago && (
<MenuItem onClick={handleOpenPagoModal}>
<ListItemIcon><PaymentIcon fontSize="small" /></ListItemIcon>
<ListItemText>Registrar Pago Manual</ListItemText>
</MenuItem>
)}
{selectedFactura && puedeEnviarEmail && (
<MenuItem
onClick={() => handleSendEmail(selectedFactura.idFactura)}
disabled={!selectedFactura.numeroFactura}
>
<ListItemIcon><EmailIcon fontSize="small" /></ListItemIcon>
<ListItemText>Enviar Email</ListItemText>
</MenuItem>
)}
</Menu>
<PagoManualModal open={pagoModalOpen} onClose={handleClosePagoModal} onSubmit={handleSubmitPagoModal} factura={selectedFactura} errorMessage={apiError} clearErrorMessage={() => setApiError(null)} />
</Box>
);
};
export default FacturacionPage;