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