diff --git a/apps/api/package.json b/apps/api/package.json index e657cf6..42b3d77 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,17 +17,20 @@ "@horux/shared": "workspace:*", "@nodecfdi/credentials": "^3.2.0", "@prisma/client": "^5.22.0", + "adm-zip": "^0.5.16", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^17.2.3", "exceljs": "^4.4.0", "express": "^4.21.0", + "fast-xml-parser": "^5.3.3", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "node-forge": "^1.3.3", "zod": "^3.23.0" }, "devDependencies": { + "@types/adm-zip": "^0.5.7", "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", diff --git a/apps/api/src/services/sat/sat-auth.service.ts b/apps/api/src/services/sat/sat-auth.service.ts new file mode 100644 index 0000000..02a7ced --- /dev/null +++ b/apps/api/src/services/sat/sat-auth.service.ts @@ -0,0 +1,159 @@ +import { XMLParser, XMLBuilder } from 'fast-xml-parser'; +import { createHash, randomUUID } from 'crypto'; +import type { Credential } from '@nodecfdi/credentials/node'; + +const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc'; + +interface SatToken { + token: string; + expiresAt: Date; +} + +/** + * Genera el timestamp para la solicitud SOAP + */ +function createTimestamp(): { created: string; expires: string } { + const now = new Date(); + const created = now.toISOString(); + const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos + return { created, expires }; +} + +/** + * Construye el XML de solicitud de autenticación + */ +function buildAuthRequest(credential: Credential): string { + const timestamp = createTimestamp(); + const uuid = randomUUID(); + + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + // Canonicalizar y firmar + const toDigestXml = `` + + `${timestamp.created}` + + `${timestamp.expires}` + + ``; + + const digestValue = createHash('sha1').update(toDigestXml).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + // Firmar con la llave privada (sign retorna binary string, convertir a base64) + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapEnvelope = ` + + + + + ${timestamp.created} + ${timestamp.expires} + + ${cerB64} + + ${signedInfoXml} + ${signatureValue} + + + + + + + + + + + +`; + + return soapEnvelope; +} + +/** + * Extrae el token de la respuesta SOAP + */ +function parseAuthResponse(responseXml: string): SatToken { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + }); + + const result = parser.parse(responseXml); + + // Navegar la estructura de respuesta SOAP + const envelope = result.Envelope || result['s:Envelope']; + if (!envelope) { + throw new Error('Respuesta SOAP inválida'); + } + + const body = envelope.Body || envelope['s:Body']; + if (!body) { + throw new Error('No se encontró el cuerpo de la respuesta'); + } + + const autenticaResponse = body.AutenticaResponse; + if (!autenticaResponse) { + throw new Error('No se encontró AutenticaResponse'); + } + + const autenticaResult = autenticaResponse.AutenticaResult; + if (!autenticaResult) { + throw new Error('No se obtuvo token de autenticación'); + } + + // El token es un SAML assertion en base64 + const token = autenticaResult; + + // El token expira en 5 minutos según documentación SAT + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + + return { token, expiresAt }; +} + +/** + * Autentica con el SAT usando la FIEL y obtiene un token + */ +export async function authenticate(credential: Credential): Promise { + const soapRequest = buildAuthRequest(credential); + + try { + const response = await fetch(SAT_AUTH_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica', + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseAuthResponse(responseXml); + } catch (error: any) { + console.error('[SAT Auth Error]', error); + throw new Error(`Error al autenticar con el SAT: ${error.message}`); + } +} + +/** + * Verifica si un token está vigente + */ +export function isTokenValid(token: SatToken): boolean { + return new Date() < token.expiresAt; +} diff --git a/apps/api/src/services/sat/sat-download.service.ts b/apps/api/src/services/sat/sat-download.service.ts new file mode 100644 index 0000000..4489246 --- /dev/null +++ b/apps/api/src/services/sat/sat-download.service.ts @@ -0,0 +1,408 @@ +import { XMLParser } from 'fast-xml-parser'; +import { createHash, randomUUID } from 'crypto'; +import type { Credential } from '@nodecfdi/credentials/node'; +import type { + SatDownloadRequestResponse, + SatVerifyResponse, + SatPackageResponse, + CfdiSyncType +} from '@horux/shared'; + +const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc'; +const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc'; +const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc'; + +type TipoSolicitud = 'CFDI' | 'Metadata'; + +interface RequestDownloadParams { + credential: Credential; + token: string; + rfc: string; + fechaInicio: Date; + fechaFin: Date; + tipoSolicitud: TipoSolicitud; + tipoCfdi: CfdiSyncType; +} + +/** + * Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS) + */ +function formatSatDate(date: Date): string { + return date.toISOString().slice(0, 19); +} + +/** + * Construye el XML de solicitud de descarga + */ +function buildDownloadRequest(params: RequestDownloadParams): string { + const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params; + const uuid = randomUUID(); + + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + // Construir el elemento de solicitud + const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined; + const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined; + + const solicitudContent = `${rfc}` + + `${formatSatDate(fechaInicio)}` + + `${formatSatDate(fechaFin)}` + + `${tipoSolicitud}` + + (rfcEmisor ? `${rfcEmisor}` : '') + + (rfcReceptor ? `${rfcReceptor}` : ''); + + const solicitudToSign = `${solicitudContent}`; + const digestValue = createHash('sha1').update(solicitudToSign).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + return ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; +} + +/** + * Solicita la descarga de CFDIs al SAT + */ +export async function requestDownload(params: RequestDownloadParams): Promise { + const soapRequest = buildDownloadRequest(params); + + try { + const response = await fetch(SAT_SOLICITUD_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga', + 'Authorization': `WRAP access_token="${params.token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseDownloadRequestResponse(responseXml); + } catch (error: any) { + console.error('[SAT Download Request Error]', error); + throw new Error(`Error al solicitar descarga: ${error.message}`); + } +} + +/** + * Parsea la respuesta de solicitud de descarga + */ +function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult; + + if (!respuesta) { + throw new Error('Respuesta inválida del SAT'); + } + + return { + idSolicitud: respuesta['@_IdSolicitud'] || '', + codEstatus: respuesta['@_CodEstatus'] || '', + mensaje: respuesta['@_Mensaje'] || '', + }; +} + +/** + * Verifica el estado de una solicitud de descarga + */ +export async function verifyRequest( + credential: Credential, + token: string, + rfc: string, + idSolicitud: string +): Promise { + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + const verificaContent = ``; + const digestValue = createHash('sha1').update(verificaContent).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapRequest = ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; + + try { + const response = await fetch(SAT_VERIFICA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga', + 'Authorization': `WRAP access_token="${token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseVerifyResponse(responseXml); + } catch (error: any) { + console.error('[SAT Verify Error]', error); + throw new Error(`Error al verificar solicitud: ${error.message}`); + } +} + +/** + * Parsea la respuesta de verificación + */ +function parseVerifyResponse(responseXml: string): SatVerifyResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult; + + if (!respuesta) { + throw new Error('Respuesta de verificación inválida'); + } + + // Extraer paquetes + let paquetes: string[] = []; + const paquetesNode = respuesta.IdsPaquetes; + if (paquetesNode) { + if (Array.isArray(paquetesNode)) { + paquetes = paquetesNode; + } else if (typeof paquetesNode === 'string') { + paquetes = [paquetesNode]; + } + } + + return { + codEstatus: respuesta['@_CodEstatus'] || '', + estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10), + codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '', + numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10), + mensaje: respuesta['@_Mensaje'] || '', + paquetes, + }; +} + +/** + * Descarga un paquete de CFDIs + */ +export async function downloadPackage( + credential: Credential, + token: string, + rfc: string, + idPaquete: string +): Promise { + const certificate = credential.certificate(); + const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64'); + + const descargaContent = ``; + const digestValue = createHash('sha1').update(descargaContent).digest('base64'); + + const signedInfoXml = `` + + `` + + `` + + `` + + `` + + `` + + `${digestValue}` + + `` + + ``; + + const signatureBinary = credential.sign(signedInfoXml, 'sha1'); + const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64'); + + const soapRequest = ` + + + + + + + ${signedInfoXml} + ${signatureValue} + + + + ${certificate.issuerAsRfc4514()} + ${certificate.serialNumber().bytes()} + + ${cerB64} + + + + + + +`; + + try { + const response = await fetch(SAT_DESCARGA_URL, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml;charset=UTF-8', + 'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar', + 'Authorization': `WRAP access_token="${token}"`, + }, + body: soapRequest, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Error HTTP ${response.status}: ${errorText}`); + } + + const responseXml = await response.text(); + return parseDownloadResponse(responseXml); + } catch (error: any) { + console.error('[SAT Download Package Error]', error); + throw new Error(`Error al descargar paquete: ${error.message}`); + } +} + +/** + * Parsea la respuesta de descarga de paquete + */ +function parseDownloadResponse(responseXml: string): SatPackageResponse { + const parser = new XMLParser({ + ignoreAttributes: false, + removeNSPrefix: true, + attributeNamePrefix: '@_', + }); + + const result = parser.parse(responseXml); + const envelope = result.Envelope || result['s:Envelope']; + const body = envelope?.Body || envelope?.['s:Body']; + const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete; + + if (!respuesta) { + throw new Error('No se pudo obtener el paquete'); + } + + return { + paquete: respuesta, + }; +} + +/** + * Estados de solicitud del SAT + */ +export const SAT_REQUEST_STATES = { + ACCEPTED: 1, + IN_PROGRESS: 2, + COMPLETED: 3, + ERROR: 4, + REJECTED: 5, + EXPIRED: 6, +} as const; + +/** + * Verifica si la solicitud está completa + */ +export function isRequestComplete(estadoSolicitud: number): boolean { + return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED; +} + +/** + * Verifica si la solicitud falló + */ +export function isRequestFailed(estadoSolicitud: number): boolean { + return ( + estadoSolicitud === SAT_REQUEST_STATES.ERROR || + estadoSolicitud === SAT_REQUEST_STATES.REJECTED || + estadoSolicitud === SAT_REQUEST_STATES.EXPIRED + ); +} + +/** + * Verifica si la solicitud está en progreso + */ +export function isRequestInProgress(estadoSolicitud: number): boolean { + return ( + estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED || + estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a359ed..f3653db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@prisma/client': specifier: ^5.22.0 version: 5.22.0(prisma@5.22.0) + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -41,6 +44,9 @@ importers: express: specifier: ^4.21.0 version: 4.22.1 + fast-xml-parser: + specifier: ^5.3.3 + version: 5.3.3 helmet: specifier: ^8.0.0 version: 8.1.0 @@ -54,6 +60,9 @@ importers: specifier: ^3.23.0 version: 3.25.76 devDependencies: + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 @@ -958,6 +967,9 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + '@types/bcryptjs@2.4.6': resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} @@ -1052,6 +1064,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1424,6 +1440,10 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-xml-parser@5.3.3: + resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2112,6 +2132,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -2977,6 +3000,10 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 22.19.7 + '@types/bcryptjs@2.4.6': {} '@types/body-parser@1.19.6': @@ -3079,6 +3106,8 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + adm-zip@0.5.16: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3520,6 +3549,10 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-xml-parser@5.3.3: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4185,6 +4218,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + strnum@2.1.2: {} + styled-jsx@5.1.1(react@18.3.1): dependencies: client-only: 0.0.1