erp-construccion/docs/02-definicion-modulos/MAI-007-rrhh-asistencias/especificaciones/ET-HR-005-integracion-infonavit.md

424 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<Employee>,
@InjectRepository(INFONAVITCredit)
private creditRepo: Repository<INFONAVITCredit>,
@InjectRepository(INFONAVITPaymentFile)
private paymentFileRepo: Repository<INFONAVITPaymentFile>,
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<void> {
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<number> {
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<INFONAVITPaymentFile> {
// 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<any> {
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<Employee[]> {
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