From 98d704a549fa66cfe68c613024bf0d495a81b835 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 25 Jan 2026 02:07:55 +0000 Subject: [PATCH] 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 --- apps/api/package.json | 1 + apps/api/src/services/fiel.service.ts | 21 +- .../src/services/sat/sat-client.service.ts | 203 ++++++++++++++++++ apps/api/src/services/sat/sat.service.ts | 116 +++++----- pnpm-lock.yaml | 34 +++ 5 files changed, 296 insertions(+), 79 deletions(-) create mode 100644 apps/api/src/services/sat/sat-client.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 9a7fd55..b955fb6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,6 +17,7 @@ "@horux/shared": "workspace:*", "@nodecfdi/cfdi-core": "^1.0.1", "@nodecfdi/credentials": "^3.2.0", + "@nodecfdi/sat-ws-descarga-masiva": "^2.0.0", "@prisma/client": "^5.22.0", "adm-zip": "^0.5.16", "bcryptjs": "^2.4.3", diff --git a/apps/api/src/services/fiel.service.ts b/apps/api/src/services/fiel.service.ts index 3afc976..3c917ad 100644 --- a/apps/api/src/services/fiel.service.ts +++ b/apps/api/src/services/fiel.service.ts @@ -179,22 +179,24 @@ export async function deleteFiel(tenantId: string): Promise { * Solo debe usarse internamente por el servicio de SAT */ export async function getDecryptedFiel(tenantId: string): Promise<{ - credential: Credential; + cerContent: string; + keyContent: string; + password: string; rfc: string; } | null> { const fiel = await prisma.fielCredential.findUnique({ where: { tenantId }, }); - + if (!fiel || !fiel.isActive) { return null; } - + // Verificar que no esté vencida if (new Date() > fiel.validUntil) { return null; } - + try { // Desencriptar todas las credenciales juntas const { cerData, keyData, password } = decryptFielCredentials( @@ -205,15 +207,10 @@ export async function getDecryptedFiel(tenantId: string): Promise<{ Buffer.from(fiel.encryptionTag) ); - // Crear credencial - const credential = Credential.create( - cerData.toString('binary'), - keyData.toString('binary'), - password - ); - return { - credential, + cerContent: cerData.toString('binary'), + keyContent: keyData.toString('binary'), + password, rfc: fiel.rfc, }; } catch (error) { diff --git a/apps/api/src/services/sat/sat-client.service.ts b/apps/api/src/services/sat/sat-client.service.ts new file mode 100644 index 0000000..cb62039 --- /dev/null +++ b/apps/api/src/services/sat/sat-client.service.ts @@ -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 { + 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 { + 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 { + 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())}`; +} diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index 7c41e3e..be34242 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -1,44 +1,28 @@ import { prisma } from '../../config/database.js'; import { getDecryptedFiel } from '../fiel.service.js'; -import { authenticate, isTokenValid } from './sat-auth.service.js'; import { - requestDownload, - verifyRequest, - downloadPackage, - isRequestComplete, - isRequestFailed, - isRequestInProgress, - SAT_REQUEST_STATES, -} from './sat-download.service.js'; + createSatService, + querySat, + verifySatRequest, + downloadSatPackage, + type FielData, +} from './sat-client.service.js'; import { processPackage, type CfdiParsed } from './sat-parser.service.js'; 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 MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo const YEARS_TO_SYNC = 10; interface SyncContext { - credential: Credential; - token: string; - tokenExpiresAt: Date; + fielData: FielData; + service: Service; rfc: string; tenantId: string; schemaName: string; } -/** - * Obtiene o renueva el token de autenticación - */ -async function ensureValidToken(ctx: SyncContext): Promise { - 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 */ @@ -202,63 +186,52 @@ async function processDateRange( ): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`); - await ensureValidToken(ctx); - // 1. Solicitar descarga - const requestResponse = await requestDownload({ - credential: ctx.credential, - token: ctx.token, - rfc: ctx.rfc, - fechaInicio, - fechaFin, - tipoSolicitud: 'CFDI', - tipoCfdi, - }); + const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi); - if (requestResponse.codEstatus !== '5000') { - if (requestResponse.codEstatus === '5004') { + if (!queryResult.success) { + // Código 5004 = No hay CFDIs en el rango + if (queryResult.statusCode === '5004') { console.log('[SAT] No se encontraron CFDIs en el rango'); 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; - console.log(`[SAT] Solicitud creada: ${idSolicitud}`); + const requestId = queryResult.requestId!; + console.log(`[SAT] Solicitud creada: ${requestId}`); - await updateJobProgress(jobId, { satRequestId: idSolicitud }); + await updateJobProgress(jobId, { satRequestId: requestId }); // 2. Esperar y verificar solicitud - let verifyResponse; + let verifyResult; let attempts = 0; while (attempts < MAX_POLL_ATTEMPTS) { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); attempts++; - await ensureValidToken(ctx); - verifyResponse = await verifyRequest(ctx.credential, ctx.token, ctx.rfc, idSolicitud); + verifyResult = await verifySatRequest(ctx.service, requestId); + console.log(`[SAT] Estado solicitud: ${verifyResult.status} (intento ${attempts})`); - console.log(`[SAT] Estado solicitud: ${verifyResponse.estadoSolicitud} (intento ${attempts})`); - - if (isRequestComplete(verifyResponse.estadoSolicitud)) { + if (verifyResult.status === 'ready') { break; } - if (isRequestFailed(verifyResponse.estadoSolicitud)) { - throw new Error(`Solicitud fallida: ${verifyResponse.mensaje}`); + if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') { + 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'); } // 3. Descargar paquetes - const packageIds = verifyResponse.paquetes; + const packageIds = verifyResult.packageIds; await updateJobProgress(jobId, { satPackageIds: packageIds, - cfdisFound: verifyResponse.numeroCfdis, + cfdisFound: verifyResult.totalCfdis, }); let totalInserted = 0; @@ -269,11 +242,15 @@ async function processDateRange( const packageId = packageIds[i]; console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`); - await ensureValidToken(ctx); - const packageResponse = await downloadPackage(ctx.credential, ctx.token, ctx.rfc, packageId); + const downloadResult = await downloadSatPackage(ctx.service, packageId); - // 4. Procesar paquete - const cfdis = processPackage(packageResponse.paquete); + if (!downloadResult.success) { + 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; console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`); @@ -293,7 +270,7 @@ async function processDateRange( } return { - found: verifyResponse.numeroCfdis, + found: verifyResult.totalCfdis, downloaded: totalDownloaded, inserted: totalInserted, updated: totalUpdated, @@ -408,11 +385,20 @@ export async function startSync( dateTo?: Date ): Promise { // Obtener credenciales FIEL - const fielData = await getDecryptedFiel(tenantId); - if (!fielData) { + const decryptedFiel = await getDecryptedFiel(tenantId); + if (!decryptedFiel) { 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 const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, @@ -448,14 +434,10 @@ export async function startSync( }, }); - // Autenticar con SAT - const tokenData = await authenticate(fielData.credential); - const ctx: SyncContext = { - credential: fielData.credential, - token: tokenData.token, - tokenExpiresAt: tokenData.expiresAt, - rfc: fielData.rfc, + fielData, + service, + rfc: decryptedFiel.rfc, tenantId, schemaName: tenant.schemaName, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f6e785..1730fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@nodecfdi/credentials': specifier: ^3.2.0 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': specifier: ^5.22.0 version: 5.22.0(prisma@5.22.0) @@ -484,6 +487,23 @@ packages: '@types/luxon': 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': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2545,6 +2565,20 @@ snapshots: luxon: 3.7.2 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': dependencies: '@nodelib/fs.stat': 2.0.5