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,50 @@
using GestionIntegral.Api.Models.Comunicaciones;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
namespace GestionIntegral.Api.Services.Comunicaciones
{
public class EmailService : IEmailService
{
private readonly MailSettings _mailSettings;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<MailSettings> mailSettings, ILogger<EmailService> logger)
{
_mailSettings = mailSettings.Value;
_logger = logger;
}
public async Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
email.From.Add(email.Sender);
email.To.Add(new MailboxAddress(destinatarioNombre, destinatarioEmail));
email.Subject = asunto;
var builder = new BodyBuilder { HtmlBody = cuerpoHtml };
email.Body = builder.ToMessageBody();
using var smtp = new SmtpClient();
try
{
await smtp.ConnectAsync(_mailSettings.SmtpHost, _mailSettings.SmtpPort, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync(_mailSettings.SmtpUser, _mailSettings.SmtpPass);
await smtp.SendAsync(email);
_logger.LogInformation("Email enviado exitosamente a {Destinatario}", destinatarioEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al enviar email a {Destinatario}", destinatarioEmail);
throw; // Relanzar para que el servicio que lo llamó sepa que falló
}
finally
{
await smtp.DisconnectAsync(true);
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace GestionIntegral.Api.Services.Comunicaciones
{
public interface IEmailService
{
Task EnviarEmailAsync(string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml);
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,243 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Distribucion;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Distribucion;
using GestionIntegral.Api.Models.Suscripciones;
using GestionIntegral.Api.Services.Comunicaciones;
using System.Data;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class FacturacionService : IFacturacionService
{
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IPrecioRepository _precioRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly IRecargoZonaRepository _recargoZonaRepository; // Para futura implementación
private readonly ISuscriptorRepository _suscriptorRepository; // Para obtener zona del suscriptor
private readonly DbConnectionFactory _connectionFactory;
private readonly IEmailService _emailService;
private readonly ILogger<FacturacionService> _logger;
public FacturacionService(
ISuscripcionRepository suscripcionRepository,
IFacturaRepository facturaRepository,
IPrecioRepository precioRepository,
IPromocionRepository promocionRepository,
IRecargoZonaRepository recargoZonaRepository,
ISuscriptorRepository suscriptorRepository,
DbConnectionFactory connectionFactory,
IEmailService emailService,
ILogger<FacturacionService> logger)
{
_suscripcionRepository = suscripcionRepository;
_facturaRepository = facturaRepository;
_precioRepository = precioRepository;
_promocionRepository = promocionRepository;
_recargoZonaRepository = recargoZonaRepository;
_suscriptorRepository = suscriptorRepository;
_connectionFactory = connectionFactory;
_emailService = emailService;
_logger = logger;
}
public async Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario)
{
var periodo = $"{anio}-{mes:D2}";
_logger.LogInformation("Iniciando generación de facturación para el período {Periodo} por usuario {IdUsuario}", periodo, idUsuario);
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var suscripcionesActivas = await _suscripcionRepository.GetAllActivasParaFacturacion(periodo, transaction);
if (!suscripcionesActivas.Any())
{
return (true, "No se encontraron suscripciones activas para facturar en el período especificado.", 0);
}
int facturasGeneradas = 0;
foreach (var suscripcion in suscripcionesActivas)
{
var facturaExistente = await _facturaRepository.GetBySuscripcionYPeriodoAsync(suscripcion.IdSuscripcion, periodo, transaction);
if (facturaExistente != null)
{
_logger.LogWarning("Ya existe una factura (ID: {IdFactura}) para la suscripción ID {IdSuscripcion} en el período {Periodo}. Se omite.", facturaExistente.IdFactura, suscripcion.IdSuscripcion, periodo);
continue;
}
// --- LÓGICA DE PROMOCIONES ---
var primerDiaMes = new DateTime(anio, mes, 1);
var promocionesAplicables = await _promocionRepository.GetPromocionesActivasParaSuscripcion(suscripcion.IdSuscripcion, primerDiaMes, transaction);
decimal importeBruto = await CalcularImporteParaSuscripcion(suscripcion, anio, mes, transaction);
decimal descuentoTotal = 0;
// Aplicar promociones de descuento
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "Porcentaje"))
{
descuentoTotal += (importeBruto * promo.Valor) / 100;
}
foreach (var promo in promocionesAplicables.Where(p => p.TipoPromocion == "MontoFijo"))
{
descuentoTotal += promo.Valor;
}
// La bonificación de días se aplicaría idealmente dentro de CalcularImporteParaSuscripcion,
// pero por simplicidad, aquí solo manejamos descuentos sobre el total.
if (importeBruto <= 0)
{
_logger.LogInformation("Suscripción ID {IdSuscripcion} no tiene importe a facturar para el período {Periodo}. Se omite.", suscripcion.IdSuscripcion, periodo);
continue;
}
var importeFinal = importeBruto - descuentoTotal;
if (importeFinal < 0) importeFinal = 0; // El importe no puede ser negativo
var nuevaFactura = new Factura
{
IdSuscripcion = suscripcion.IdSuscripcion,
Periodo = periodo,
FechaEmision = DateTime.Now.Date,
FechaVencimiento = new DateTime(anio, mes, 10).AddMonths(1),
ImporteBruto = importeBruto,
DescuentoAplicado = descuentoTotal,
ImporteFinal = importeFinal,
Estado = "Pendiente de Facturar"
};
var facturaCreada = await _facturaRepository.CreateAsync(nuevaFactura, transaction);
if (facturaCreada == null) throw new DataException($"No se pudo crear el registro de factura para la suscripción ID {suscripcion.IdSuscripcion}");
facturasGeneradas++;
}
transaction.Commit();
_logger.LogInformation("Finalizada la generación de facturación para {Periodo}. Total generadas: {FacturasGeneradas}", periodo, facturasGeneradas);
return (true, $"Proceso completado. Se generaron {facturasGeneradas} facturas.", facturasGeneradas);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error crítico durante la generación de facturación para el período {Periodo}", periodo);
return (false, "Error interno del servidor al generar la facturación.", 0);
}
}
private async Task<decimal> CalcularImporteParaSuscripcion(Suscripcion suscripcion, int anio, int mes, IDbTransaction transaction)
{
decimal importeTotal = 0;
var diasDeEntrega = suscripcion.DiasEntrega.Split(',').ToHashSet();
var fechaActual = new DateTime(anio, mes, 1);
while (fechaActual.Month == mes)
{
// La suscripción debe estar activa en este día
if (fechaActual.Date >= suscripcion.FechaInicio.Date &&
(suscripcion.FechaFin == null || fechaActual.Date <= suscripcion.FechaFin.Value.Date))
{
var diaSemanaChar = GetCharDiaSemana(fechaActual.DayOfWeek);
if (diasDeEntrega.Contains(diaSemanaChar))
{
var precioActivo = await _precioRepository.GetActiveByPublicacionAndDateAsync(suscripcion.IdPublicacion, fechaActual, transaction);
if (precioActivo != null)
{
importeTotal += GetPrecioDelDia(precioActivo, fechaActual.DayOfWeek);
}
else
{
_logger.LogWarning("No se encontró precio para la publicación ID {IdPublicacion} en la fecha {Fecha}", suscripcion.IdPublicacion, fechaActual.Date);
}
}
}
fechaActual = fechaActual.AddDays(1);
}
return importeTotal;
}
public async Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes)
{
var periodo = $"{anio}-{mes:D2}";
var facturasData = await _facturaRepository.GetByPeriodoEnrichedAsync(periodo);
return facturasData.Select(data => new FacturaDto
{
IdFactura = data.Factura.IdFactura,
IdSuscripcion = data.Factura.IdSuscripcion,
Periodo = data.Factura.Periodo,
FechaEmision = data.Factura.FechaEmision.ToString("yyyy-MM-dd"),
FechaVencimiento = data.Factura.FechaVencimiento.ToString("yyyy-MM-dd"),
ImporteFinal = data.Factura.ImporteFinal,
Estado = data.Factura.Estado,
NumeroFactura = data.Factura.NumeroFactura,
NombreSuscriptor = data.NombreSuscriptor,
NombrePublicacion = data.NombrePublicacion
});
}
public async Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura)
{
var factura = await _facturaRepository.GetByIdAsync(idFactura);
if (factura == null) return (false, "Factura no encontrada.");
if (string.IsNullOrEmpty(factura.NumeroFactura)) return (false, "La factura aún no tiene un número asignado por ARCA.");
var suscripcion = await _suscripcionRepository.GetByIdAsync(factura.IdSuscripcion);
if (suscripcion == null) return (false, "Suscripción asociada no encontrada.");
var suscriptor = await _suscriptorRepository.GetByIdAsync(suscripcion.IdSuscriptor);
if (suscriptor == null) return (false, "Suscriptor asociado no encontrado.");
if (string.IsNullOrEmpty(suscriptor.Email)) return (false, "El suscriptor no tiene una dirección de email configurada.");
try
{
var asunto = $"Tu factura del Diario El Día - Período {factura.Periodo}";
var cuerpo = $@"
<h1>Hola {suscriptor.NombreCompleto},</h1>
<p>Te adjuntamos los detalles de tu factura para el período {factura.Periodo}.</p>
<ul>
<li><strong>Número de Factura:</strong> {factura.NumeroFactura}</li>
<li><strong>Importe Total:</strong> ${factura.ImporteFinal:N2}</li>
<li><strong>Fecha de Vencimiento:</strong> {factura.FechaVencimiento:dd/MM/yyyy}</li>
</ul>
<p>Gracias por ser parte de nuestra comunidad de lectores.</p>
<p><em>Diario El Día</em></p>";
await _emailService.EnviarEmailAsync(suscriptor.Email, suscriptor.NombreCompleto, asunto, cuerpo);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falló el envío de email para la factura ID {IdFactura}", idFactura);
return (false, "Ocurrió un error al intentar enviar el email.");
}
}
private string GetCharDiaSemana(DayOfWeek dia) => dia switch
{
DayOfWeek.Sunday => "D",
DayOfWeek.Monday => "L",
DayOfWeek.Tuesday => "M",
DayOfWeek.Wednesday => "X",
DayOfWeek.Thursday => "J",
DayOfWeek.Friday => "V",
DayOfWeek.Saturday => "S",
_ => ""
};
private decimal GetPrecioDelDia(Precio precio, DayOfWeek dia) => dia switch
{
DayOfWeek.Sunday => precio.Domingo ?? 0,
DayOfWeek.Monday => precio.Lunes ?? 0,
DayOfWeek.Tuesday => precio.Martes ?? 0,
DayOfWeek.Wednesday => precio.Miercoles ?? 0,
DayOfWeek.Thursday => precio.Jueves ?? 0,
DayOfWeek.Friday => precio.Viernes ?? 0,
DayOfWeek.Saturday => precio.Sabado ?? 0,
_ => 0
};
}
}

View File

@@ -0,0 +1,10 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IDebitoAutomaticoService
{
Task<(string? ContenidoArchivo, string? NombreArchivo, string? Error)> GenerarArchivoPagoDirecto(int anio, int mes, int idUsuario);
Task<ProcesamientoLoteResponseDto> ProcesarArchivoRespuesta(IFormFile archivo, int idUsuario);
}
}

View File

@@ -0,0 +1,11 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IFacturacionService
{
Task<(bool Exito, string? Mensaje, int FacturasGeneradas)> GenerarFacturacionMensual(int anio, int mes, int idUsuario);
Task<IEnumerable<FacturaDto>> ObtenerFacturasPorPeriodo(int anio, int mes);
Task<(bool Exito, string? Error)> EnviarFacturaPorEmail(int idFactura);
}
}

View File

@@ -0,0 +1,14 @@
// Archivo: GestionIntegral.Api/Services/Suscripciones/IPagoService.cs
using GestionIntegral.Api.Dtos.Suscripciones;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IPagoService
{
Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura);
Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario);
}
}

View File

@@ -0,0 +1,12 @@
using GestionIntegral.Api.Dtos.Suscripciones;
namespace GestionIntegral.Api.Services.Suscripciones
{
public interface IPromocionService
{
Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas);
Task<PromocionDto?> ObtenerPorId(int id);
Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario);
}
}

View File

@@ -8,5 +8,9 @@ namespace GestionIntegral.Api.Services.Suscripciones
Task<SuscripcionDto?> ObtenerPorId(int idSuscripcion);
Task<(SuscripcionDto? Suscripcion, string? Error)> Crear(CreateSuscripcionDto createDto, int idUsuario);
Task<(bool Exito, string? Error)> Actualizar(int idSuscripcion, UpdateSuscripcionDto updateDto, int idUsuario);
Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion);
Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion);
Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario);
Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion);
}
}

View File

@@ -0,0 +1,118 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Data.Repositories.Usuarios;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class PagoService : IPagoService
{
private readonly IPagoRepository _pagoRepository;
private readonly IFacturaRepository _facturaRepository;
private readonly IFormaPagoRepository _formaPagoRepository;
private readonly IUsuarioRepository _usuarioRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PagoService> _logger;
public PagoService(
IPagoRepository pagoRepository,
IFacturaRepository facturaRepository,
IFormaPagoRepository formaPagoRepository,
IUsuarioRepository usuarioRepository,
DbConnectionFactory connectionFactory,
ILogger<PagoService> logger)
{
_pagoRepository = pagoRepository;
_facturaRepository = facturaRepository;
_formaPagoRepository = formaPagoRepository;
_usuarioRepository = usuarioRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private async Task<PagoDto?> MapToDto(Pago pago)
{
if (pago == null) return null;
var formaPago = await _formaPagoRepository.GetByIdAsync(pago.IdFormaPago);
var usuario = await _usuarioRepository.GetByIdAsync(pago.IdUsuarioRegistro);
return new PagoDto
{
IdPago = pago.IdPago,
IdFactura = pago.IdFactura,
FechaPago = pago.FechaPago.ToString("yyyy-MM-dd"),
IdFormaPago = pago.IdFormaPago,
NombreFormaPago = formaPago?.Nombre ?? "Desconocida",
Monto = pago.Monto,
Estado = pago.Estado,
Referencia = pago.Referencia,
Observaciones = pago.Observaciones,
IdUsuarioRegistro = pago.IdUsuarioRegistro,
NombreUsuarioRegistro = usuario != null ? $"{usuario.Nombre} {usuario.Apellido}" : "Usuario Desconocido"
};
}
public async Task<IEnumerable<PagoDto>> ObtenerPagosPorFacturaId(int idFactura)
{
var pagos = await _pagoRepository.GetByFacturaIdAsync(idFactura);
var dtosTasks = pagos.Select(p => MapToDto(p));
var dtos = await Task.WhenAll(dtosTasks);
return dtos.Where(dto => dto != null)!;
}
public async Task<(PagoDto? Pago, string? Error)> RegistrarPagoManual(CreatePagoDto createDto, int idUsuario)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var factura = await _facturaRepository.GetByIdAsync(createDto.IdFactura);
if (factura == null) return (null, "La factura especificada no existe.");
if (factura.Estado == "Pagada") return (null, "La factura ya se encuentra pagada.");
if (factura.Estado == "Anulada") return (null, "No se puede registrar un pago sobre una factura anulada.");
var formaPago = await _formaPagoRepository.GetByIdAsync(createDto.IdFormaPago);
if (formaPago == null || !formaPago.Activo) return (null, "La forma de pago no es válida.");
var nuevoPago = new Pago
{
IdFactura = createDto.IdFactura,
FechaPago = createDto.FechaPago,
IdFormaPago = createDto.IdFormaPago,
Monto = createDto.Monto,
Estado = "Aprobado", // Los pagos manuales se asumen aprobados
Referencia = createDto.Referencia,
Observaciones = createDto.Observaciones,
IdUsuarioRegistro = idUsuario
};
// 1. Crear el registro del pago
var pagoCreado = await _pagoRepository.CreateAsync(nuevoPago, transaction);
if (pagoCreado == null) throw new DataException("No se pudo registrar el pago.");
// 2. Si el monto pagado es igual o mayor al importe de la factura, actualizar la factura
// (Permitimos pago mayor por si hay redondeos, etc.)
if (pagoCreado.Monto >= factura.ImporteFinal)
{
bool actualizado = await _facturaRepository.UpdateEstadoAsync(factura.IdFactura, "Pagada", transaction);
if (!actualizado) throw new DataException("No se pudo actualizar el estado de la factura a 'Pagada'.");
}
transaction.Commit();
_logger.LogInformation("Pago manual ID {IdPago} registrado para Factura ID {IdFactura} por Usuario ID {IdUsuario}", pagoCreado.IdPago, pagoCreado.IdFactura, idUsuario);
var dto = await MapToDto(pagoCreado);
return (dto, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al registrar pago manual para Factura ID {IdFactura}", createDto.IdFactura);
return (null, $"Error interno al registrar el pago: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,127 @@
using GestionIntegral.Api.Data;
using GestionIntegral.Api.Data.Repositories.Suscripciones;
using GestionIntegral.Api.Dtos.Suscripciones;
using GestionIntegral.Api.Models.Suscripciones;
using System.Data;
namespace GestionIntegral.Api.Services.Suscripciones
{
public class PromocionService : IPromocionService
{
private readonly IPromocionRepository _promocionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<PromocionService> _logger;
public PromocionService(
IPromocionRepository promocionRepository,
DbConnectionFactory connectionFactory,
ILogger<PromocionService> logger)
{
_promocionRepository = promocionRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private PromocionDto MapToDto(Promocion promo)
{
return new PromocionDto
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion,
Valor = promo.Valor,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa
};
}
public async Task<IEnumerable<PromocionDto>> ObtenerTodas(bool soloActivas)
{
var promociones = await _promocionRepository.GetAllAsync(soloActivas);
return promociones.Select(MapToDto);
}
public async Task<PromocionDto?> ObtenerPorId(int id)
{
var promocion = await _promocionRepository.GetByIdAsync(id);
return promocion != null ? MapToDto(promocion) : null;
}
public async Task<(PromocionDto? Promocion, string? Error)> Crear(CreatePromocionDto createDto, int idUsuario)
{
if (createDto.FechaFin.HasValue && createDto.FechaFin.Value < createDto.FechaInicio)
{
return (null, "La fecha de fin no puede ser anterior a la fecha de inicio.");
}
var nuevaPromocion = new Promocion
{
Descripcion = createDto.Descripcion,
TipoPromocion = createDto.TipoPromocion,
Valor = createDto.Valor,
FechaInicio = createDto.FechaInicio,
FechaFin = createDto.FechaFin,
Activa = createDto.Activa,
IdUsuarioAlta = idUsuario
};
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var promocionCreada = await _promocionRepository.CreateAsync(nuevaPromocion, transaction);
if (promocionCreada == null) throw new DataException("Error al crear la promoción.");
transaction.Commit();
_logger.LogInformation("Promoción ID {Id} creada por Usuario ID {UserId}.", promocionCreada.IdPromocion, idUsuario);
return (MapToDto(promocionCreada), null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al crear promoción: {Descripcion}", createDto.Descripcion);
return (null, $"Error interno al crear la promoción: {ex.Message}");
}
}
public async Task<(bool Exito, string? Error)> Actualizar(int id, UpdatePromocionDto updateDto, int idUsuario)
{
var existente = await _promocionRepository.GetByIdAsync(id);
if (existente == null) return (false, "Promoción no encontrada.");
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
{
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
}
// Mapeo
existente.Descripcion = updateDto.Descripcion;
existente.TipoPromocion = updateDto.TipoPromocion;
existente.Valor = updateDto.Valor;
existente.FechaInicio = updateDto.FechaInicio;
existente.FechaFin = updateDto.FechaFin;
existente.Activa = updateDto.Activa;
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var actualizado = await _promocionRepository.UpdateAsync(existente, transaction);
if (!actualizado) throw new DataException("Error al actualizar la promoción.");
transaction.Commit();
_logger.LogInformation("Promoción ID {Id} actualizada por Usuario ID {UserId}.", id, idUsuario);
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al actualizar promoción ID: {Id}", id);
return (false, $"Error interno al actualizar: {ex.Message}");
}
}
}
}

View File

@@ -12,6 +12,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
private readonly ISuscripcionRepository _suscripcionRepository;
private readonly ISuscriptorRepository _suscriptorRepository;
private readonly IPublicacionRepository _publicacionRepository;
private readonly IPromocionRepository _promocionRepository;
private readonly DbConnectionFactory _connectionFactory;
private readonly ILogger<SuscripcionService> _logger;
@@ -19,16 +20,29 @@ namespace GestionIntegral.Api.Services.Suscripciones
ISuscripcionRepository suscripcionRepository,
ISuscriptorRepository suscriptorRepository,
IPublicacionRepository publicacionRepository,
IPromocionRepository promocionRepository,
DbConnectionFactory connectionFactory,
ILogger<SuscripcionService> logger)
{
_suscripcionRepository = suscripcionRepository;
_suscriptorRepository = suscriptorRepository;
_publicacionRepository = publicacionRepository;
_promocionRepository = promocionRepository;
_connectionFactory = connectionFactory;
_logger = logger;
}
private PromocionDto MapPromocionToDto(Promocion promo) => new PromocionDto
{
IdPromocion = promo.IdPromocion,
Descripcion = promo.Descripcion,
TipoPromocion = promo.TipoPromocion,
Valor = promo.Valor,
FechaInicio = promo.FechaInicio.ToString("yyyy-MM-dd"),
FechaFin = promo.FechaFin?.ToString("yyyy-MM-dd"),
Activa = promo.Activa
};
private async Task<SuscripcionDto?> MapToDto(Suscripcion suscripcion)
{
if (suscripcion == null) return null;
@@ -91,7 +105,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
var creada = await _suscripcionRepository.CreateAsync(nuevaSuscripcion, transaction);
if (creada == null) throw new DataException("Error al crear la suscripción.");
transaction.Commit();
_logger.LogInformation("Suscripción ID {Id} creada por Usuario ID {UserId}.", creada.IdSuscripcion, idUsuario);
return (await MapToDto(creada), null);
@@ -108,7 +122,7 @@ namespace GestionIntegral.Api.Services.Suscripciones
{
var existente = await _suscripcionRepository.GetByIdAsync(idSuscripcion);
if (existente == null) return (false, "Suscripción no encontrada.");
if (updateDto.FechaFin.HasValue && updateDto.FechaFin.Value < updateDto.FechaInicio)
return (false, "La fecha de fin no puede ser anterior a la fecha de inicio.");
@@ -139,5 +153,71 @@ namespace GestionIntegral.Api.Services.Suscripciones
return (false, $"Error interno: {ex.Message}");
}
}
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesAsignadas(int idSuscripcion)
{
var promociones = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
return promociones.Select(MapPromocionToDto);
}
public async Task<IEnumerable<PromocionDto>> ObtenerPromocionesDisponibles(int idSuscripcion)
{
var todasLasPromosActivas = await _promocionRepository.GetAllAsync(true);
var promosAsignadas = await _suscripcionRepository.GetPromocionesBySuscripcionIdAsync(idSuscripcion);
var idsAsignadas = promosAsignadas.Select(p => p.IdPromocion).ToHashSet();
return todasLasPromosActivas
.Where(p => !idsAsignadas.Contains(p.IdPromocion))
.Select(MapPromocionToDto);
}
public async Task<(bool Exito, string? Error)> AsignarPromocion(int idSuscripcion, int idPromocion, int idUsuario)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
// Validaciones
if (await _suscripcionRepository.GetByIdAsync(idSuscripcion) == null) return (false, "Suscripción no encontrada.");
if (await _promocionRepository.GetByIdAsync(idPromocion) == null) return (false, "Promoción no encontrada.");
await _suscripcionRepository.AsignarPromocionAsync(idSuscripcion, idPromocion, idUsuario, transaction);
transaction.Commit();
return (true, null);
}
catch (Exception ex)
{
// Capturar error de Primary Key duplicada
if (ex.Message.Contains("PRIMARY KEY constraint"))
{
return (false, "Esta promoción ya está asignada a la suscripción.");
}
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al asignar promoción {IdPromocion} a suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
return (false, "Error interno al asignar la promoción.");
}
}
public async Task<(bool Exito, string? Error)> QuitarPromocion(int idSuscripcion, int idPromocion)
{
using var connection = _connectionFactory.CreateConnection();
await (connection as System.Data.Common.DbConnection)!.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
var exito = await _suscripcionRepository.QuitarPromocionAsync(idSuscripcion, idPromocion, transaction);
if (!exito) return (false, "La promoción no estaba asignada a esta suscripción.");
transaction.Commit();
return (true, null);
}
catch (Exception ex)
{
try { transaction.Rollback(); } catch { }
_logger.LogError(ex, "Error al quitar promoción {IdPromocion} de suscripción {IdSuscripcion}", idPromocion, idSuscripcion);
return (false, "Error interno al quitar la promoción.");
}
}
}
}