feat(sat): add SAT authentication and download services (Phase 3)
- Add sat-auth.service.ts for SAML token authentication with SAT using FIEL credentials and SOAP protocol - Add sat-download.service.ts with full download workflow: - Request CFDI download (emitted/received) - Verify request status with polling support - Download ZIP packages when ready - Helper functions for status checking - Install fast-xml-parser and adm-zip dependencies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
159
apps/api/src/services/sat/sat-auth.service.ts
Normal file
159
apps/api/src/services/sat/sat-auth.service.ts
Normal file
@@ -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 = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
|
||||
`<u:Created>${timestamp.created}</u:Created>` +
|
||||
`<u:Expires>${timestamp.expires}</u:Expires>` +
|
||||
`</u:Timestamp>`;
|
||||
|
||||
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="#_0">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
// 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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|
||||
<s:Header>
|
||||
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
|
||||
<u:Timestamp u:Id="_0">
|
||||
<u:Created>${timestamp.created}</u:Created>
|
||||
<u:Expires>${timestamp.expires}</u:Expires>
|
||||
</u:Timestamp>
|
||||
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<o:SecurityTokenReference>
|
||||
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
|
||||
</o:SecurityTokenReference>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</o:Security>
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
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<SatToken> {
|
||||
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;
|
||||
}
|
||||
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
@@ -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 = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
|
||||
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
|
||||
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
|
||||
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
|
||||
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
|
||||
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
|
||||
|
||||
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
|
||||
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:SolicitaDescarga>
|
||||
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:SolicitaDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita la descarga de CFDIs al SAT
|
||||
*/
|
||||
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
|
||||
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<SatVerifyResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
|
||||
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:VerificaSolicitudDescarga>
|
||||
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:VerificaSolicitudDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
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<SatPackageResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
|
||||
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:PeticionDescargaMasivaTercerosEntrada>
|
||||
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:peticionDescarga>
|
||||
</des:PeticionDescargaMasivaTercerosEntrada>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user