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:*",
|
"@horux/shared": "workspace:*",
|
||||||
"@nodecfdi/credentials": "^3.2.0",
|
"@nodecfdi/credentials": "^3.2.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@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
|
||||||
|
);
|
||||||
|
}
|
||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@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)
|
||||||
|
adm-zip:
|
||||||
|
specifier: ^0.5.16
|
||||||
|
version: 0.5.16
|
||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: ^2.4.3
|
specifier: ^2.4.3
|
||||||
version: 2.4.3
|
version: 2.4.3
|
||||||
@@ -41,6 +44,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.22.1
|
version: 4.22.1
|
||||||
|
fast-xml-parser:
|
||||||
|
specifier: ^5.3.3
|
||||||
|
version: 5.3.3
|
||||||
helmet:
|
helmet:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.1.0
|
version: 8.1.0
|
||||||
@@ -54,6 +60,9 @@ importers:
|
|||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/adm-zip':
|
||||||
|
specifier: ^0.5.7
|
||||||
|
version: 0.5.7
|
||||||
'@types/bcryptjs':
|
'@types/bcryptjs':
|
||||||
specifier: ^2.4.6
|
specifier: ^2.4.6
|
||||||
version: 2.4.6
|
version: 2.4.6
|
||||||
@@ -958,6 +967,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@types/adm-zip@0.5.7':
|
||||||
|
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
|
||||||
|
|
||||||
'@types/bcryptjs@2.4.6':
|
'@types/bcryptjs@2.4.6':
|
||||||
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||||
|
|
||||||
@@ -1052,6 +1064,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
adm-zip@0.5.16:
|
||||||
|
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||||
|
engines: {node: '>=12.0'}
|
||||||
|
|
||||||
any-promise@1.3.0:
|
any-promise@1.3.0:
|
||||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||||
|
|
||||||
@@ -1424,6 +1440,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
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:
|
fastq@1.20.1:
|
||||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
@@ -2112,6 +2132,9 @@ packages:
|
|||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
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:
|
styled-jsx@5.1.1:
|
||||||
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2977,6 +3000,10 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
|
'@types/adm-zip@0.5.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
'@types/bcryptjs@2.4.6': {}
|
'@types/bcryptjs@2.4.6': {}
|
||||||
|
|
||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
@@ -3079,6 +3106,8 @@ snapshots:
|
|||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
adm-zip@0.5.16: {}
|
||||||
|
|
||||||
any-promise@1.3.0: {}
|
any-promise@1.3.0: {}
|
||||||
|
|
||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
@@ -3520,6 +3549,10 @@ snapshots:
|
|||||||
merge2: 1.4.1
|
merge2: 1.4.1
|
||||||
micromatch: 4.0.8
|
micromatch: 4.0.8
|
||||||
|
|
||||||
|
fast-xml-parser@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
strnum: 2.1.2
|
||||||
|
|
||||||
fastq@1.20.1:
|
fastq@1.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
@@ -4185,6 +4218,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
strnum@2.1.2: {}
|
||||||
|
|
||||||
styled-jsx@5.1.1(react@18.3.1):
|
styled-jsx@5.1.1(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user