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.
152 lines
6.6 KiB
TypeScript
152 lines
6.6 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Modal, Box, Typography, TextField, Button, CircularProgress, Alert, FormControl, InputLabel, Select, MenuItem, type SelectChangeEvent, InputAdornment } from '@mui/material';
|
|
import type { FacturaConsolidadaDto } from '../../../models/dtos/Suscripciones/ResumenCuentaSuscriptorDto';
|
|
import type { CreatePagoDto } from '../../../models/dtos/Suscripciones/CreatePagoDto';
|
|
import type { FormaPagoDto } from '../../../models/dtos/Suscripciones/FormaPagoDto';
|
|
import formaPagoService from '../../../services/Suscripciones/formaPagoService';
|
|
|
|
const modalStyle = {
|
|
position: 'absolute' as 'absolute',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: { xs: '95%', sm: '80%', md: '500px' },
|
|
bgcolor: 'background.paper',
|
|
border: '2px solid #000',
|
|
boxShadow: 24,
|
|
p: 4,
|
|
maxHeight: '90vh',
|
|
overflowY: 'auto'
|
|
};
|
|
|
|
interface PagoManualModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (data: CreatePagoDto) => Promise<void>;
|
|
factura: FacturaConsolidadaDto | null;
|
|
nombreSuscriptor: string; // Se pasa el nombre del suscriptor como prop-
|
|
errorMessage?: string | null;
|
|
clearErrorMessage: () => void;
|
|
}
|
|
|
|
const PagoManualModal: React.FC<PagoManualModalProps> = ({ open, onClose, onSubmit, factura, nombreSuscriptor, errorMessage, clearErrorMessage }) => {
|
|
const [formData, setFormData] = useState<Partial<CreatePagoDto>>({});
|
|
const [formasDePago, setFormasDePago] = useState<FormaPagoDto[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadingFormasPago, setLoadingFormasPago] = useState(false);
|
|
const [localErrors, setLocalErrors] = useState<{ [key: string]: string | null }>({});
|
|
const saldoPendiente = factura ? factura.importeFinal - factura.totalPagado : 0;
|
|
|
|
useEffect(() => {
|
|
const fetchFormasDePago = async () => {
|
|
setLoadingFormasPago(true);
|
|
try {
|
|
const data = await formaPagoService.getAllFormasDePago();
|
|
setFormasDePago(data.filter(fp => !fp.requiereCBU));
|
|
} catch (error) {
|
|
setLocalErrors(prev => ({ ...prev, formasDePago: 'Error al cargar formas de pago.' }));
|
|
} finally {
|
|
setLoadingFormasPago(false);
|
|
}
|
|
};
|
|
|
|
if (open && factura) {
|
|
fetchFormasDePago();
|
|
setFormData({
|
|
idFactura: factura.idFactura,
|
|
monto: saldoPendiente,
|
|
fechaPago: new Date().toISOString().split('T')[0]
|
|
});
|
|
setLocalErrors({});
|
|
}
|
|
}, [open, factura, saldoPendiente]);
|
|
|
|
const validate = (): boolean => {
|
|
const errors: { [key: string]: string | null } = {};
|
|
if (!formData.idFormaPago) errors.idFormaPago = "Seleccione una forma de pago.";
|
|
if (!formData.fechaPago) errors.fechaPago = "La fecha de pago es obligatoria.";
|
|
|
|
const monto = formData.monto ?? 0;
|
|
|
|
if (monto <= 0) {
|
|
errors.monto = "El monto debe ser mayor a cero.";
|
|
} else if (monto > saldoPendiente) {
|
|
errors.monto = `El monto no puede superar el saldo pendiente de $${saldoPendiente.toFixed(2)}.`;
|
|
}
|
|
|
|
setLocalErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
const finalValue = name === 'monto' && value !== '' ? parseFloat(value) : value;
|
|
setFormData(prev => ({ ...prev, [name]: finalValue }));
|
|
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
|
if (errorMessage) clearErrorMessage();
|
|
};
|
|
|
|
const handleSelectChange = (e: SelectChangeEvent<any>) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({ ...prev, [name]: value }));
|
|
if (localErrors[name]) setLocalErrors(prev => ({ ...prev, [name]: null }));
|
|
if (errorMessage) clearErrorMessage();
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
clearErrorMessage();
|
|
if (!validate()) return;
|
|
|
|
setLoading(true);
|
|
let success = false;
|
|
try {
|
|
await onSubmit(formData as CreatePagoDto);
|
|
success = true;
|
|
} catch (error) {
|
|
success = false;
|
|
} finally {
|
|
setLoading(false);
|
|
if (success) onClose();
|
|
}
|
|
};
|
|
|
|
if (!factura) return null;
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose}>
|
|
<Box sx={modalStyle}>
|
|
<Typography variant="h6">Registrar Pago Manual</Typography>
|
|
<Typography variant="body1" color="text.secondary" gutterBottom>
|
|
Para: {nombreSuscriptor}
|
|
</Typography>
|
|
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
|
|
Saldo Pendiente: ${saldoPendiente.toFixed(2)}
|
|
</Typography>
|
|
<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} />
|
|
<FormControl fullWidth margin="dense" error={!!localErrors.idFormaPago}>
|
|
<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}>
|
|
{formasDePago.map(fp => <MenuItem key={fp.idFormaPago} value={fp.idFormaPago}>{fp.nombre}</MenuItem>)}
|
|
</Select>
|
|
</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="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} />
|
|
|
|
{errorMessage && <Alert severity="error" sx={{ mt: 2 }}>{errorMessage}</Alert>}
|
|
|
|
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
<Button onClick={onClose} color="secondary" disabled={loading}>Cancelar</Button>
|
|
<Button type="submit" variant="contained" disabled={loading || loadingFormasPago}>
|
|
{loading ? <CircularProgress size={24} /> : 'Registrar Pago'}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default PagoManualModal; |