- 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>
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
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
|
|
);
|
|
}
|