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.
		
			
				
	
	
		
			307 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			307 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| // 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;
 | |
|         }
 | |
|     }
 | |
| } |