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:
		| @@ -0,0 +1,307 @@ | ||||
| // Archivo: GestionIntegral.Api/Services/Suscripciones/DebitoAutomaticoService.cs | ||||
|  | ||||
| using GestionIntegral.Api.Data; | ||||
| using GestionIntegral.Api.Data.Repositories.Suscripciones; | ||||
| using GestionIntegral.Api.Models.Suscripciones; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using System; | ||||
| using System.Data; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using System.Collections.Generic; | ||||
| using GestionIntegral.Api.Dtos.Suscripciones; | ||||
|  | ||||
| namespace GestionIntegral.Api.Services.Suscripciones | ||||
| { | ||||
|     public class DebitoAutomaticoService : IDebitoAutomaticoService | ||||
|     { | ||||
|         private readonly IFacturaRepository _facturaRepository; | ||||
|         private readonly ISuscriptorRepository _suscriptorRepository; | ||||
|         private readonly ISuscripcionRepository _suscripcionRepository; | ||||
|         private readonly ILoteDebitoRepository _loteDebitoRepository; | ||||
|         private readonly IFormaPagoRepository _formaPagoRepository; | ||||
|         private readonly IPagoRepository _pagoRepository; | ||||
|         private readonly DbConnectionFactory _connectionFactory; | ||||
|         private readonly ILogger<DebitoAutomaticoService> _logger; | ||||
|  | ||||
|         // --- CONSTANTES DEL BANCO (Mover a appsettings.json si es necesario) --- | ||||
|         private const string NRO_PRESTACION = "123456"; // Nro. de prestación asignado por el banco | ||||
|         private const string ORIGEN_EMPRESA = "ELDIA";   // Nombre de la empresa (7 chars) | ||||
|  | ||||
|         public DebitoAutomaticoService( | ||||
|             IFacturaRepository facturaRepository, | ||||
|             ISuscriptorRepository suscriptorRepository, | ||||
|             ISuscripcionRepository suscripcionRepository, | ||||
|             ILoteDebitoRepository loteDebitoRepository, | ||||
|             IFormaPagoRepository formaPagoRepository, | ||||
|             IPagoRepository pagoRepository, | ||||
|             DbConnectionFactory connectionFactory, | ||||
|             ILogger<DebitoAutomaticoService> logger) | ||||
|         { | ||||
|             _facturaRepository = facturaRepository; | ||||
|             _suscriptorRepository = suscriptorRepository; | ||||
|             _suscripcionRepository = suscripcionRepository; | ||||
|             _loteDebitoRepository = loteDebitoRepository; | ||||
|             _formaPagoRepository = formaPagoRepository; | ||||
|             _pagoRepository = pagoRepository; | ||||
|             _connectionFactory = connectionFactory; | ||||
|             _logger = logger; | ||||
|         } | ||||
|  | ||||
|         public async Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario) | ||||
|         { | ||||
|             var periodo = $"{anio}-{mes:D2}"; | ||||
|             var fechaGeneracion = DateTime.Now; | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 // Buscamos facturas que están listas para ser enviadas al cobro. | ||||
|                 var facturasParaDebito = await GetFacturasParaDebito(periodo, transaction); | ||||
|  | ||||
|                 if (!facturasParaDebito.Any()) | ||||
|                 { | ||||
|                     return (null, null, "No se encontraron facturas pendientes de cobro por débito automático para el período seleccionado."); | ||||
|                 } | ||||
|  | ||||
|                 var importeTotal = facturasParaDebito.Sum(f => f.Factura.ImporteFinal); | ||||
|                 var cantidadRegistros = facturasParaDebito.Count(); | ||||
|                 var nombreArchivo = $"PD_{ORIGEN_EMPRESA.Trim()}_{fechaGeneracion:yyyyMMdd}.txt"; | ||||
|  | ||||
|                 // 1. Crear el Lote de Débito | ||||
|                 var nuevoLote = new LoteDebito | ||||
|                 { | ||||
|                     Periodo = periodo, | ||||
|                     NombreArchivo = nombreArchivo, | ||||
|                     ImporteTotal = importeTotal, | ||||
|                     CantidadRegistros = cantidadRegistros, | ||||
|                     IdUsuarioGeneracion = idUsuario | ||||
|                 }; | ||||
|                 var loteCreado = await _loteDebitoRepository.CreateAsync(nuevoLote, transaction); | ||||
|                 if (loteCreado == null) throw new DataException("No se pudo crear el registro del lote de débito."); | ||||
|  | ||||
|                 // 2. Generar el contenido del archivo | ||||
|                 var sb = new StringBuilder(); | ||||
|                 sb.Append(CrearRegistroHeader(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|  | ||||
|                 foreach (var item in facturasParaDebito) | ||||
|                 { | ||||
|                     sb.Append(CrearRegistroDetalle(item.Factura, item.Suscriptor)); | ||||
|                 } | ||||
|  | ||||
|                 sb.Append(CrearRegistroTrailer(fechaGeneracion, importeTotal, cantidadRegistros)); | ||||
|  | ||||
|                 // 3. Actualizar las facturas con el ID del lote | ||||
|                 var idsFacturas = facturasParaDebito.Select(f => f.Factura.IdFactura); | ||||
|                 bool actualizadas = await _facturaRepository.UpdateLoteDebitoAsync(idsFacturas, loteCreado.IdLoteDebito, transaction); | ||||
|                 if (!actualizadas) throw new DataException("No se pudieron actualizar las facturas con la información del lote."); | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 _logger.LogInformation("Archivo de débito {NombreArchivo} generado exitosamente para el período {Periodo}.", nombreArchivo, periodo); | ||||
|                 return (sb.ToString(), nombreArchivo, null); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al generar el archivo de débito para el período {Periodo}", periodo); | ||||
|                 return (null, null, $"Error interno: {ex.Message}"); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private async Task<List<(Factura Factura, Suscriptor Suscriptor)>> GetFacturasParaDebito(string periodo, IDbTransaction transaction) | ||||
|         { | ||||
|             // Idealmente, esto debería estar en el repositorio para optimizar la consulta. | ||||
|             // Por simplicidad del ejemplo, lo hacemos aquí. | ||||
|             var facturas = await _facturaRepository.GetByPeriodoAsync(periodo); | ||||
|             var resultado = new List<(Factura, Suscriptor)>(); | ||||
|  | ||||
|             foreach (var f in facturas.Where(fa => fa.Estado == "Pendiente de Cobro")) | ||||
|             { | ||||
|                 var suscripcion = await _suscripcionRepository.GetByIdAsync(f.IdSuscripcion); | ||||
|                 if (suscripcion == null) continue; | ||||
|  | ||||
|                 var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor); | ||||
|                 if (suscriptor == null || string.IsNullOrWhiteSpace(suscriptor.CBU)) continue; | ||||
|  | ||||
|                 var formaPago = await _formaPagoRepository.GetByIdAsync(suscriptor.IdFormaPagoPreferida); | ||||
|                 if (formaPago != null && formaPago.RequiereCBU) | ||||
|                 { | ||||
|                     resultado.Add((f, suscriptor)); | ||||
|                 } | ||||
|             } | ||||
|             return resultado; | ||||
|         } | ||||
|  | ||||
|         // --- Métodos de Formateo de Campos --- | ||||
|         private string FormatString(string? value, int length) => (value ?? "").PadRight(length).Substring(0, length); | ||||
|         private string FormatNumeric(long value, int length) => value.ToString().PadLeft(length, '0'); | ||||
|  | ||||
|         private string CrearRegistroHeader(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("00"); // Tipo de Registro | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo (ej. '1' para el primer envío del día) | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); // 12 enteros + 2 decimales | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroDetalle(Factura factura, Suscriptor suscriptor) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("0101"); // Tipo de Registro | ||||
|             sb.Append(FormatString(suscriptor.IdSuscriptor.ToString(), 22)); // Identificación Cliente | ||||
|             sb.Append(FormatString(suscriptor.CBU, 26)); // CBU | ||||
|  | ||||
|             // Referencia Unívoca: Usaremos ID Factura para asegurar unicidad | ||||
|             sb.Append(FormatString($"SUSC-{factura.IdFactura}", 15)); | ||||
|  | ||||
|             sb.Append(factura.FechaVencimiento.ToString("yyyyMMdd")); // Fecha 1er Vto | ||||
|             sb.Append(FormatNumeric((long)(factura.ImporteFinal * 100), 14)); // Importe 1er Vto | ||||
|  | ||||
|             // Campos opcionales o con valores fijos | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 2do Vto | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha 3er Vto | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe 3er Vto | ||||
|             sb.Append("0"); // Moneda (0 = Pesos) | ||||
|             sb.Append(FormatString("", 3)); // Motivo Rechazo | ||||
|             sb.Append(FormatString(suscriptor.TipoDocumento, 4)); | ||||
|             sb.Append(FormatString(suscriptor.NroDocumento, 11)); | ||||
|  | ||||
|             // El resto son campos opcionales que rellenamos con espacios/ceros | ||||
|             sb.Append(FormatString("", 22)); // Nueva ID Cliente | ||||
|             sb.Append(FormatNumeric(0, 26)); // Nuevo CBU | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Mínimo | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Próximo Vto | ||||
|             sb.Append(FormatString("", 22)); // ID Cuenta Anterior | ||||
|             sb.Append(FormatString("", 40)); // Mensaje ATM | ||||
|             sb.Append(FormatString($"Suscripcion {factura.Periodo}", 10)); // Concepto Factura | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha de Cobro | ||||
|             sb.Append(FormatNumeric(0, 14)); // Importe Cobrado | ||||
|             sb.Append(FormatNumeric(0, 8)); // Fecha Acreditación | ||||
|             sb.Append(FormatString("", 26)); // Libre | ||||
|             sb.Append("\r\n"); | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         private string CrearRegistroTrailer(DateTime fechaGeneracion, decimal importeTotal, int cantidadRegistros) | ||||
|         { | ||||
|             var sb = new StringBuilder(); | ||||
|             sb.Append("99"); // Tipo de Registro | ||||
|             sb.Append(FormatString(NRO_PRESTACION, 6)); | ||||
|             sb.Append("C"); // Servicio | ||||
|             sb.Append(fechaGeneracion.ToString("yyyyMMdd")); | ||||
|             sb.Append("1"); // Identificación de Archivo | ||||
|             sb.Append(FormatString(ORIGEN_EMPRESA, 7)); | ||||
|             sb.Append(FormatNumeric((long)(importeTotal * 100), 14)); | ||||
|             sb.Append(FormatNumeric(cantidadRegistros, 7)); | ||||
|             sb.Append(FormatString("", 304)); // Libre | ||||
|             // No se añade \r\n al final del último registro | ||||
|             return sb.ToString(); | ||||
|         } | ||||
|  | ||||
|         public async Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario) | ||||
|         { | ||||
|             var respuesta = new ProcesamientoLoteResponseDto(); | ||||
|             if (archivo == null || archivo.Length == 0) | ||||
|             { | ||||
|                 respuesta.Errores.Add("No se proporcionó ningún archivo o el archivo está vacío."); | ||||
|                 return respuesta; | ||||
|             } | ||||
|  | ||||
|             using var connection = _connectionFactory.CreateConnection(); | ||||
|             await (connection as System.Data.Common.DbConnection)!.OpenAsync(); | ||||
|             using var transaction = connection.BeginTransaction(); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 using var reader = new StreamReader(archivo.OpenReadStream()); | ||||
|                 string? linea; | ||||
|                 while ((linea = await reader.ReadLineAsync()) != null) | ||||
|                 { | ||||
|                     // Ignorar header/trailer si los hubiera (basado en el formato real) | ||||
|                     if (linea.Length < 20) continue; | ||||
|  | ||||
|                     respuesta.TotalRegistrosLeidos++; | ||||
|  | ||||
|                     // ================================================================= | ||||
|                     // === ESTA ES LA LÓGICA DE PARSEO QUE SE DEBE AJUSTAR === | ||||
|                     // === CON EL FORMATO REAL DEL ARCHIVO DE RESPUESTA    === | ||||
|                     // ================================================================= | ||||
|                     // Asunción: Pos 1-15: Referencia, Pos 16-17: Estado, Pos 18-20: Rechazo | ||||
|                     var referencia = linea.Substring(0, 15).Trim(); | ||||
|                     var estadoProceso = linea.Substring(15, 2).Trim(); | ||||
|                     var motivoRechazo = linea.Substring(17, 3).Trim(); | ||||
|                     // Asumimos que podemos extraer el IdFactura de la referencia | ||||
|                     if (!int.TryParse(referencia.Replace("SUSC-", ""), out int idFactura)) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: No se pudo extraer un ID de factura válido de la referencia '{referencia}'."); | ||||
|                         continue; | ||||
|                     } | ||||
|                     // ================================================================= | ||||
|                     // === FIN DE LA LÓGICA DE PARSEO                         === | ||||
|                     // ================================================================= | ||||
|  | ||||
|                     var factura = await _facturaRepository.GetByIdAsync(idFactura); | ||||
|                     if (factura == null) | ||||
|                     { | ||||
|                         respuesta.Errores.Add($"Línea #{respuesta.TotalRegistrosLeidos}: La factura con ID {idFactura} no fue encontrada en el sistema."); | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var nuevoPago = new Pago | ||||
|                     { | ||||
|                         IdFactura = idFactura, | ||||
|                         FechaPago = DateTime.Now.Date, // O la fecha que venga en el archivo | ||||
|                         IdFormaPago = 1, // 1 = Débito Automático | ||||
|                         Monto = factura.ImporteFinal, | ||||
|                         IdUsuarioRegistro = idUsuario, | ||||
|                         Referencia = $"Lote {factura.IdLoteDebito} - Banco" | ||||
|                     }; | ||||
|  | ||||
|                     if (estadoProceso == "AP") // "AP" = Aprobado (Asunción) | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Aprobado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         await _facturaRepository.UpdateEstadoAsync(idFactura, "Pagada", transaction); | ||||
|                         respuesta.PagosAprobados++; | ||||
|                     } | ||||
|                     else // Asumimos que cualquier otra cosa es Rechazado | ||||
|                     { | ||||
|                         nuevoPago.Estado = "Rechazado"; | ||||
|                         await _pagoRepository.CreateAsync(nuevoPago, transaction); | ||||
|                         factura.Estado = "Rechazada"; | ||||
|                         factura.MotivoRechazo = motivoRechazo; | ||||
|                         // Necesitamos un método en el repo para actualizar estado y motivo | ||||
|                         await _facturaRepository.UpdateEstadoYMotivoAsync(idFactura, "Rechazada", motivoRechazo, transaction); | ||||
|                         respuesta.PagosRechazados++; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 transaction.Commit(); | ||||
|                 respuesta.MensajeResumen = $"Archivo procesado. Leídos: {respuesta.TotalRegistrosLeidos}, Aprobados: {respuesta.PagosAprobados}, Rechazados: {respuesta.PagosRechazados}."; | ||||
|                 _logger.LogInformation(respuesta.MensajeResumen); | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 try { transaction.Rollback(); } catch { } | ||||
|                 _logger.LogError(ex, "Error crítico al procesar archivo de respuesta de débito."); | ||||
|                 respuesta.Errores.Add($"Error fatal en el procesamiento: {ex.Message}"); | ||||
|             } | ||||
|  | ||||
|             return respuesta; | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user