feat: use @nodecfdi/sat-ws-descarga-masiva for SAT sync

Replace manual SOAP authentication with the official nodecfdi library
which properly handles WS-Security signatures for SAT web services.

- Add sat-client.service.ts using Fiel.create() for authentication
- Update sat.service.ts to use new client
- Update fiel.service.ts to return raw certificate data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-25 02:07:55 +00:00
parent c52548a2bb
commit 98d704a549
5 changed files with 296 additions and 79 deletions

View File

@@ -17,6 +17,7 @@
"@horux/shared": "workspace:*", "@horux/shared": "workspace:*",
"@nodecfdi/cfdi-core": "^1.0.1", "@nodecfdi/cfdi-core": "^1.0.1",
"@nodecfdi/credentials": "^3.2.0", "@nodecfdi/credentials": "^3.2.0",
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

View File

@@ -179,7 +179,9 @@ export async function deleteFiel(tenantId: string): Promise<boolean> {
* Solo debe usarse internamente por el servicio de SAT * Solo debe usarse internamente por el servicio de SAT
*/ */
export async function getDecryptedFiel(tenantId: string): Promise<{ export async function getDecryptedFiel(tenantId: string): Promise<{
credential: Credential; cerContent: string;
keyContent: string;
password: string;
rfc: string; rfc: string;
} | null> { } | null> {
const fiel = await prisma.fielCredential.findUnique({ const fiel = await prisma.fielCredential.findUnique({
@@ -205,15 +207,10 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
Buffer.from(fiel.encryptionTag) Buffer.from(fiel.encryptionTag)
); );
// Crear credencial
const credential = Credential.create(
cerData.toString('binary'),
keyData.toString('binary'),
password
);
return { return {
credential, cerContent: cerData.toString('binary'),
keyContent: keyData.toString('binary'),
password,
rfc: fiel.rfc, rfc: fiel.rfc,
}; };
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,203 @@
import {
Fiel,
HttpsWebClient,
FielRequestBuilder,
Service,
QueryParameters,
DateTimePeriod,
DownloadType,
RequestType,
ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva';
export interface FielData {
cerContent: string;
keyContent: string;
password: string;
}
/**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/
export function createSatService(fielData: FielData): Service {
// Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
// Verificar que la FIEL sea válida
if (!fiel.isValid()) {
throw new Error('La FIEL no es válida o está vencida');
}
// Crear cliente HTTP
const webClient = new HttpsWebClient();
// Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
}
export interface QueryResult {
success: boolean;
requestId?: string;
message: string;
statusCode?: string;
}
export interface VerifyResult {
success: boolean;
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
packageIds: string[];
totalCfdis: number;
message: string;
statusCode?: string;
}
export interface DownloadResult {
success: boolean;
packageContent: string; // Base64 encoded ZIP
message: string;
}
/**
* Realiza una consulta al SAT para solicitar CFDIs
*/
export async function querySat(
service: Service,
fechaInicio: Date,
fechaFin: Date,
tipo: 'emitidos' | 'recibidos',
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
try {
const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio),
formatDateForSat(fechaFin)
);
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
const parameters = QueryParameters.create(period, downloadType, reqType);
const result = await service.query(parameters);
if (!result.getStatus().isAccepted()) {
return {
success: false,
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
}
return {
success: true,
requestId: result.getRequestId(),
message: 'Solicitud aceptada',
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Query Error]', error);
return {
success: false,
message: error.message || 'Error al realizar consulta',
};
}
}
/**
* Verifica el estado de una solicitud
*/
export async function verifySatRequest(
service: Service,
requestId: string
): Promise<VerifyResult> {
try {
const result = await service.verify(requestId);
const statusCode = result.getStatusRequest().getValue();
let status: VerifyResult['status'];
// Los valores del SAT para estado de solicitud (getValue retorna número o string)
const codeNum = typeof statusCode === 'string' ? parseInt(statusCode, 10) : statusCode;
switch (codeNum) {
case 1:
status = 'pending';
break;
case 2:
status = 'processing';
break;
case 3:
status = 'ready';
break;
case 4:
status = 'failed';
break;
case 5:
status = 'rejected';
break;
default:
status = 'pending';
}
return {
success: result.getStatus().isAccepted(),
status,
packageIds: result.getPackageIds(),
totalCfdis: result.getNumberCfdis(),
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Verify Error]', error);
return {
success: false,
status: 'failed',
packageIds: [],
totalCfdis: 0,
message: error.message || 'Error al verificar solicitud',
};
}
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadSatPackage(
service: Service,
packageId: string
): Promise<DownloadResult> {
try {
const result = await service.download(packageId);
if (!result.getStatus().isAccepted()) {
return {
success: false,
packageContent: '',
message: result.getStatus().getMessage(),
};
}
return {
success: true,
packageContent: result.getPackageContent(),
message: 'Paquete descargado',
};
} catch (error: any) {
console.error('[SAT Download Error]', error);
return {
success: false,
packageContent: '',
message: error.message || 'Error al descargar paquete',
};
}
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}

View File

@@ -1,44 +1,28 @@
import { prisma } from '../../config/database.js'; import { prisma } from '../../config/database.js';
import { getDecryptedFiel } from '../fiel.service.js'; import { getDecryptedFiel } from '../fiel.service.js';
import { authenticate, isTokenValid } from './sat-auth.service.js';
import { import {
requestDownload, createSatService,
verifyRequest, querySat,
downloadPackage, verifySatRequest,
isRequestComplete, downloadSatPackage,
isRequestFailed, type FielData,
isRequestInProgress, } from './sat-client.service.js';
SAT_REQUEST_STATES,
} from './sat-download.service.js';
import { processPackage, type CfdiParsed } from './sat-parser.service.js'; import { processPackage, type CfdiParsed } from './sat-parser.service.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared'; import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Credential } from '@nodecfdi/credentials/node'; import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
const POLL_INTERVAL_MS = 30000; // 30 segundos const POLL_INTERVAL_MS = 30000; // 30 segundos
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
const YEARS_TO_SYNC = 10; const YEARS_TO_SYNC = 10;
interface SyncContext { interface SyncContext {
credential: Credential; fielData: FielData;
token: string; service: Service;
tokenExpiresAt: Date;
rfc: string; rfc: string;
tenantId: string; tenantId: string;
schemaName: string; schemaName: string;
} }
/**
* Obtiene o renueva el token de autenticación
*/
async function ensureValidToken(ctx: SyncContext): Promise<void> {
if (!isTokenValid({ token: ctx.token, expiresAt: ctx.tokenExpiresAt })) {
console.log('[SAT] Renovando token...');
const newToken = await authenticate(ctx.credential);
ctx.token = newToken.token;
ctx.tokenExpiresAt = newToken.expiresAt;
}
}
/** /**
* Actualiza el progreso de un job * Actualiza el progreso de un job
*/ */
@@ -202,63 +186,52 @@ async function processDateRange(
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { ): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`); console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
await ensureValidToken(ctx);
// 1. Solicitar descarga // 1. Solicitar descarga
const requestResponse = await requestDownload({ const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
credential: ctx.credential,
token: ctx.token,
rfc: ctx.rfc,
fechaInicio,
fechaFin,
tipoSolicitud: 'CFDI',
tipoCfdi,
});
if (requestResponse.codEstatus !== '5000') { if (!queryResult.success) {
if (requestResponse.codEstatus === '5004') { // Código 5004 = No hay CFDIs en el rango
if (queryResult.statusCode === '5004') {
console.log('[SAT] No se encontraron CFDIs en el rango'); console.log('[SAT] No se encontraron CFDIs en el rango');
return { found: 0, downloaded: 0, inserted: 0, updated: 0 }; return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
} }
throw new Error(`Error SAT: ${requestResponse.codEstatus} - ${requestResponse.mensaje}`); throw new Error(`Error SAT: ${queryResult.message}`);
} }
const idSolicitud = requestResponse.idSolicitud; const requestId = queryResult.requestId!;
console.log(`[SAT] Solicitud creada: ${idSolicitud}`); console.log(`[SAT] Solicitud creada: ${requestId}`);
await updateJobProgress(jobId, { satRequestId: idSolicitud }); await updateJobProgress(jobId, { satRequestId: requestId });
// 2. Esperar y verificar solicitud // 2. Esperar y verificar solicitud
let verifyResponse; let verifyResult;
let attempts = 0; let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) { while (attempts < MAX_POLL_ATTEMPTS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
attempts++; attempts++;
await ensureValidToken(ctx); verifyResult = await verifySatRequest(ctx.service, requestId);
verifyResponse = await verifyRequest(ctx.credential, ctx.token, ctx.rfc, idSolicitud); console.log(`[SAT] Estado solicitud: ${verifyResult.status} (intento ${attempts})`);
console.log(`[SAT] Estado solicitud: ${verifyResponse.estadoSolicitud} (intento ${attempts})`); if (verifyResult.status === 'ready') {
if (isRequestComplete(verifyResponse.estadoSolicitud)) {
break; break;
} }
if (isRequestFailed(verifyResponse.estadoSolicitud)) { if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
throw new Error(`Solicitud fallida: ${verifyResponse.mensaje}`); throw new Error(`Solicitud fallida: ${verifyResult.message}`);
} }
} }
if (!verifyResponse || !isRequestComplete(verifyResponse.estadoSolicitud)) { if (!verifyResult || verifyResult.status !== 'ready') {
throw new Error('Timeout esperando respuesta del SAT'); throw new Error('Timeout esperando respuesta del SAT');
} }
// 3. Descargar paquetes // 3. Descargar paquetes
const packageIds = verifyResponse.paquetes; const packageIds = verifyResult.packageIds;
await updateJobProgress(jobId, { await updateJobProgress(jobId, {
satPackageIds: packageIds, satPackageIds: packageIds,
cfdisFound: verifyResponse.numeroCfdis, cfdisFound: verifyResult.totalCfdis,
}); });
let totalInserted = 0; let totalInserted = 0;
@@ -269,11 +242,15 @@ async function processDateRange(
const packageId = packageIds[i]; const packageId = packageIds[i];
console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`); console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`);
await ensureValidToken(ctx); const downloadResult = await downloadSatPackage(ctx.service, packageId);
const packageResponse = await downloadPackage(ctx.credential, ctx.token, ctx.rfc, packageId);
// 4. Procesar paquete if (!downloadResult.success) {
const cfdis = processPackage(packageResponse.paquete); console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
continue;
}
// 4. Procesar paquete (el contenido viene en base64)
const cfdis = processPackage(downloadResult.packageContent);
totalDownloaded += cfdis.length; totalDownloaded += cfdis.length;
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`); console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
@@ -293,7 +270,7 @@ async function processDateRange(
} }
return { return {
found: verifyResponse.numeroCfdis, found: verifyResult.totalCfdis,
downloaded: totalDownloaded, downloaded: totalDownloaded,
inserted: totalInserted, inserted: totalInserted,
updated: totalUpdated, updated: totalUpdated,
@@ -408,11 +385,20 @@ export async function startSync(
dateTo?: Date dateTo?: Date
): Promise<string> { ): Promise<string> {
// Obtener credenciales FIEL // Obtener credenciales FIEL
const fielData = await getDecryptedFiel(tenantId); const decryptedFiel = await getDecryptedFiel(tenantId);
if (!fielData) { if (!decryptedFiel) {
throw new Error('No hay FIEL configurada o está vencida'); throw new Error('No hay FIEL configurada o está vencida');
} }
const fielData: FielData = {
cerContent: decryptedFiel.cerContent,
keyContent: decryptedFiel.keyContent,
password: decryptedFiel.password,
};
// Crear servicio SAT
const service = createSatService(fielData);
// Obtener datos del tenant // Obtener datos del tenant
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
@@ -448,14 +434,10 @@ export async function startSync(
}, },
}); });
// Autenticar con SAT
const tokenData = await authenticate(fielData.credential);
const ctx: SyncContext = { const ctx: SyncContext = {
credential: fielData.credential, fielData,
token: tokenData.token, service,
tokenExpiresAt: tokenData.expiresAt, rfc: decryptedFiel.rfc,
rfc: fielData.rfc,
tenantId, tenantId,
schemaName: tenant.schemaName, schemaName: tenant.schemaName,
}; };

34
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@nodecfdi/credentials': '@nodecfdi/credentials':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0(luxon@3.7.2) version: 3.2.0(luxon@3.7.2)
'@nodecfdi/sat-ws-descarga-masiva':
specifier: ^2.0.0
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
'@prisma/client': '@prisma/client':
specifier: ^5.22.0 specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0) version: 5.22.0(prisma@5.22.0)
@@ -484,6 +487,23 @@ packages:
'@types/luxon': '@types/luxon':
optional: true optional: true
'@nodecfdi/rfc@2.0.6':
resolution: {integrity: sha512-DiNC6j/mubbci8D9Qj9tdCm4/T/Q3ST92qpQ+AuHKJFVZ+/98F6ap8QFKeYK2ECu71wQGqAgkbmgQmVONAI5gg==}
engines: {node: '>=18 <=22 || ^16'}
peerDependencies:
'@types/luxon': 3.4.2
luxon: ^3.4.4
peerDependenciesMeta:
'@types/luxon':
optional: true
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
resolution: {integrity: sha512-FAmypqJfilOd29bf2bgMdysUkQKsu6ZirgljRfH4VFClXXtDHKmjOKahX0AbegUFc1GhtLjxhQgM+PJX3zhOdA==}
engines: {node: '>=18'}
peerDependencies:
'@nodecfdi/cfdi-core': ^1.0.0
luxon: ^3.6.1
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2545,6 +2565,20 @@ snapshots:
luxon: 3.7.2 luxon: 3.7.2
ts-mixer: 6.0.4 ts-mixer: 6.0.4
'@nodecfdi/rfc@2.0.6(luxon@3.7.2)':
dependencies:
luxon: 3.7.2
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
dependencies:
'@nodecfdi/cfdi-core': 1.0.1
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
'@nodecfdi/rfc': 2.0.6(luxon@3.7.2)
jszip: 3.10.1
luxon: 3.7.2
transitivePeerDependencies:
- '@types/luxon'
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5