# ET-HR-005: Implementación Integración INFONAVIT **Epic:** MAI-007 - RRHH, Asistencias y Nómina **RF:** RF-HR-005 **Tipo:** Especificación Técnica **Prioridad:** Crítica (Cumplimiento Legal) **Estado:** 🚧 En Implementación **Última actualización:** 2025-11-17 --- ## 🔧 Implementación Backend ### 1. INFONAVITIntegrationService **Archivo:** `apps/backend/src/modules/hr/integrations/infonavit/infonavit-integration.service.ts` ```typescript import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { Employee } from '../../employees/entities/employee.entity'; import { INFONAVITCredit } from './entities/infonavit-credit.entity'; import { INFONAVITPaymentFile } from './entities/infonavit-payment-file.entity'; import { firstValueFrom } from 'rxjs'; @Injectable() export class INFONAVITIntegrationService { private readonly apiUrl: string; private readonly registroPatronal: string; private readonly apiKey: string; private readonly accessToken: string; constructor( @InjectRepository(Employee) private employeeRepo: Repository, @InjectRepository(INFONAVITCredit) private creditRepo: Repository, @InjectRepository(INFONAVITPaymentFile) private paymentFileRepo: Repository, private httpService: HttpService, private configService: ConfigService, ) { this.apiUrl = configService.get('INFONAVIT_API_URL'); this.registroPatronal = configService.get('INFONAVIT_REGISTRO_PATRONAL'); this.apiKey = configService.get('INFONAVIT_API_KEY'); this.accessToken = configService.get('INFONAVIT_ACCESS_TOKEN'); } /** * Calcular aportaciones bimestrales (5% del SBC) */ async calcularAportaciones( constructoraId: string, periodNumber: number, // 1-6 (bimestre) periodYear: number, ): Promise<{ totalAportaciones: number; empleados: any[] }> { // Determinar fechas del bimestre const { startDate, endDate } = this.getBimestreDates(periodNumber, periodYear); // Obtener empleados activos en el bimestre const employees = await this.getActiveEmployeesForPeriod( constructoraId, startDate, endDate, ); let totalAportaciones = 0; const detalles = []; for (const employee of employees) { // Días cotizados en el bimestre (simplificado: 60 días por bimestre) const diasCotizados = 60; // Aportación INFONAVIT = SBC × días × 5% const aportacion = employee.currentSalary * diasCotizados * 0.05; totalAportaciones += aportacion; detalles.push({ employeeId: employee.id, nss: employee.nss, nombre: employee.fullName, sbc: employee.currentSalary, diasCotizados, aportacion, }); } return { totalAportaciones, empleados: detalles, }; } /** * Consultar trabajadores con crédito INFONAVIT */ async consultarAcreditados(constructoraId: string): Promise { const payload = { registro_patronal: this.registroPatronal, }; try { const response = await this.makeINFONAVITRequest('/acreditados/consulta', payload); // Procesar lista de acreditados for (const acreditado of response.acreditados || []) { // Buscar empleado por NSS const employee = await this.employeeRepo.findOne({ where: { nss: acreditado.nss, constructoraId, }, }); if (!employee) continue; // Actualizar o crear crédito let credit = await this.creditRepo.findOne({ where: { employeeId: employee.id, creditNumber: acreditado.numero_credito, }, }); if (credit) { // Actualizar existente credit.discountValue = acreditado.descuento_mensual; credit.outstandingBalance = acreditado.saldo_pendiente; } else { // Crear nuevo credit = this.creditRepo.create({ employeeId: employee.id, creditNumber: acreditado.numero_credito, discountType: acreditado.tipo_descuento || 'VSM', discountValue: acreditado.descuento_mensual, outstandingBalance: acreditado.saldo_pendiente, startDate: new Date(acreditado.fecha_inicio), isActive: true, }); } await this.creditRepo.save(credit); } } catch (error) { console.error('Error consultando acreditados INFONAVIT:', error); throw error; } } /** * Calcular descuento por crédito INFONAVIT para un empleado */ async calcularDescuentoCredito( employeeId: string, salarioBruto: number, ): Promise { const credit = await this.creditRepo.findOne({ where: { employeeId, isActive: true, }, }); if (!credit) { return 0; } let descuento = 0; switch (credit.discountType) { case 'VSM': { // Veces Salario Mínimo const salarioMinimo = 248.93; // 2025 const diasMes = 30; descuento = salarioMinimo * credit.discountValue * diasMes; break; } case 'percentage': { // Porcentaje del salario bruto descuento = salarioBruto * (credit.discountValue / 100); break; } case 'fixed': { // Cuota fija descuento = credit.discountValue; break; } } // Validar que no exceda 30% del salario bruto const maxDescuento = salarioBruto * 0.3; if (descuento > maxDescuento) { descuento = maxDescuento; } return descuento; } /** * Generar archivo de pago bimestral */ async generarArchivoPago( constructoraId: string, periodNumber: number, periodYear: number, generatedBy: string, ): Promise { // Calcular aportaciones const { totalAportaciones, empleados } = await this.calcularAportaciones( constructoraId, periodNumber, periodYear, ); // Obtener descuentos por créditos const creditos = await this.creditRepo.find({ where: { isActive: true }, relations: ['employee'], }); let totalDescuentos = 0; const descuentosDetalle = []; for (const credito of creditos) { // Calcular descuento mensual × 2 (bimestre) const descuentoBimestral = (await this.calcularDescuentoCredito( credito.employeeId, credito.employee.currentSalary * 30, // Salario mensual estimado )) * 2; totalDescuentos += descuentoBimestral; descuentosDetalle.push({ employeeId: credito.employeeId, nss: credito.employee.nss, creditoNumero: credito.creditNumber, descuento: descuentoBimestral, }); } // Construir archivo de pago let fileContent = ''; // Header fileContent += `HEADER|${this.registroPatronal}|BIMESTRE ${periodNumber}-${periodYear}\n`; // Aportaciones empleados.forEach((emp) => { fileContent += `APORTACION|${emp.nss}|${emp.aportacion.toFixed(2)}|${emp.diasCotizados}\n`; }); // Descuentos descuentosDetalle.forEach((desc) => { fileContent += `DESCUENTO|${desc.nss}|${desc.creditoNumero}|${desc.descuento.toFixed(2)}\n`; }); // Total fileContent += `TOTAL|${empleados.length}|${totalAportaciones.toFixed(2)}|${totalDescuentos.toFixed(2)}\n`; // Calcular hash const crypto = require('crypto'); const fileHash = crypto.createHash('sha256').update(fileContent).digest('hex'); // Generar línea de captura (simulado) const lineaCaptura = this.generarLineaCaptura( totalAportaciones + totalDescuentos, periodNumber, periodYear, ); // Guardar en BD const paymentFile = this.paymentFileRepo.create({ constructoraId, periodNumber, periodYear, fileContent, fileHash, totalEmployees: empleados.length, totalContributions: totalAportaciones, totalDiscounts: totalDescuentos, paymentReference: lineaCaptura, paymentStatus: 'pending', generatedBy, }); return await this.paymentFileRepo.save(paymentFile); } /** * Request HTTP a API INFONAVIT con OAuth 2.0 */ private async makeINFONAVITRequest(endpoint: string, payload: any): Promise { const config = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.accessToken}`, 'X-API-Key': this.apiKey, }, }; const response = await firstValueFrom( this.httpService.post(this.apiUrl + endpoint, payload, config), ); return response.data; } /** * Generar línea de captura bancaria */ private generarLineaCaptura(monto: number, bimestre: number, year: number): string { // Formato simplificado: INFONAVIT + REGISTRO + BIMESTRE + MONTO const registro = this.registroPatronal.padEnd(10, '0'); const periodo = `${year}${bimestre.toString().padStart(2, '0')}`; const montoStr = Math.round(monto).toString().padStart(12, '0'); return `INFONAVIT${registro}${periodo}${montoStr}`; } /** * Obtener fechas de bimestre */ private getBimestreDates(bimestre: number, year: number): { startDate: Date; endDate: Date } { const bimestreMap = { 1: { start: new Date(year, 0, 1), end: new Date(year, 1, 28) }, // Ene-Feb 2: { start: new Date(year, 2, 1), end: new Date(year, 3, 30) }, // Mar-Abr 3: { start: new Date(year, 4, 1), end: new Date(year, 5, 30) }, // May-Jun 4: { start: new Date(year, 6, 1), end: new Date(year, 7, 31) }, // Jul-Ago 5: { start: new Date(year, 8, 1), end: new Date(year, 9, 31) }, // Sep-Oct 6: { start: new Date(year, 10, 1), end: new Date(year, 11, 31) }, // Nov-Dic }; return { startDate: bimestreMap[bimestre].start, endDate: bimestreMap[bimestre].end, }; } private async getActiveEmployeesForPeriod( constructoraId: string, startDate: Date, endDate: Date, ): Promise { return await this.employeeRepo .createQueryBuilder('emp') .where('emp.constructoraId = :constructoraId', { constructoraId }) .andWhere('emp.hireDate <= :endDate', { endDate }) .andWhere('(emp.terminationDate IS NULL OR emp.terminationDate >= :startDate)', { startDate, }) .andWhere('emp.status = :status', { status: 'active' }) .getMany(); } } ``` --- ## 🔐 Configuración ### Variables de Entorno ```bash # .env.production INFONAVIT_API_URL=https://api.infonavit.org.mx/v1 INFONAVIT_REGISTRO_PATRONAL=1234567890 INFONAVIT_API_KEY=your-api-key-here INFONAVIT_ACCESS_TOKEN=your-oauth-token-here ``` ### OAuth 2.0 Flow ```typescript // Obtener access token async function refreshAccessToken() { const response = await axios.post('https://api.infonavit.org.mx/oauth/token', { grant_type: 'client_credentials', client_id: process.env.INFONAVIT_CLIENT_ID, client_secret: process.env.INFONAVIT_CLIENT_SECRET, }); return response.data.access_token; } ``` --- ## 🧪 Tests ```typescript describe('INFONAVITIntegrationService', () => { it('should calculate 5% contributions correctly', async () => { const result = await service.calcularAportaciones('constructora-id', 1, 2025); // Empleado con SBC $350/día × 60 días × 5% = $1,050 expect(result.totalAportaciones).toBeGreaterThan(0); expect(result.empleados).toHaveLength(5); expect(result.empleados[0].aportacion).toBe(350 * 60 * 0.05); }); it('should calculate VSM discount correctly', async () => { const descuento = await service.calcularDescuentoCredito('emp-id', 10500); // 2.5 VSM = $248.93 × 2.5 × 30 = $18,669.75 expect(descuento).toBeCloseTo(18669.75, 2); }); it('should not exceed 30% of gross salary', async () => { const descuento = await service.calcularDescuentoCredito('emp-id', 5000); expect(descuento).toBeLessThanOrEqual(5000 * 0.3); }); }); ``` --- **Fecha de creación:** 2025-11-17 **Versión:** 1.0