Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
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();
// El PEM ya contiene el certificado en base64, solo quitamos headers y newlines
const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, '');
// 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;
}

View File

@@ -0,0 +1,248 @@
import {
Fiel,
HttpsWebClient,
FielRequestBuilder,
Service,
QueryParameters,
DateTimePeriod,
DownloadType,
RequestType,
DocumentStatus,
ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva';
export interface FielData {
cerContent: string;
keyContent: string;
password: string;
}
/**
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
*/
export function createSatService(fielData: FielData): Service {
// Crear FIEL usando el método estático create
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
// Verificar que la FIEL sea válida
if (!fiel.isValid()) {
throw new Error('La FIEL no es válida o está vencida');
}
// Crear cliente HTTP
const webClient = new HttpsWebClient();
// Crear request builder con la FIEL
const requestBuilder = new FielRequestBuilder(fiel);
// Crear y retornar el servicio
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
}
export interface QueryResult {
success: boolean;
requestId?: string;
message: string;
statusCode?: string;
}
export interface VerifyResult {
success: boolean;
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
packageIds: string[];
totalCfdis: number;
message: string;
statusCode?: string;
}
export interface DownloadResult {
success: boolean;
packageContent: string; // Base64 encoded ZIP
message: string;
}
/**
* Realiza una consulta al SAT para solicitar CFDIs
*/
export async function querySat(
service: Service,
fechaInicio: Date,
fechaFin: Date,
tipo: 'emitidos' | 'recibidos',
requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> {
try {
const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio),
formatDateForSat(fechaFin)
);
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
// XMLs: solo vigentes (active). Metadata: todos (undefined = sin filtro).
let parameters = QueryParameters.create(period, downloadType, reqType);
if (requestType === 'cfdi') {
parameters = parameters.withDocumentStatus(new DocumentStatus('active'));
}
const result = await service.query(parameters);
if (!result.getStatus().isAccepted()) {
return {
success: false,
message: result.getStatus().getMessage(),
statusCode: result.getStatus().getCode().toString(),
};
}
return {
success: true,
requestId: result.getRequestId(),
message: 'Solicitud aceptada',
statusCode: result.getStatus().getCode().toString(),
};
} catch (error: any) {
console.error('[SAT Query Error]', error);
return {
success: false,
message: error.message || 'Error al realizar consulta',
};
}
}
/**
* Verifica el estado de una solicitud
*/
export async function verifySatRequest(
service: Service,
requestId: string
): Promise<VerifyResult> {
try {
const result = await service.verify(requestId);
const statusRequest = result.getStatusRequest();
// `codeRequest` es el código SAT específico del estado de la solicitud
// (5000 Accepted, 5002 Exhausted, 5003 MaximumLimit, 5004 EmptyResult,
// 5005 Duplicated) y su mensaje explica POR QUÉ el SAT rechazó. Es la
// pieza clave para diagnosticar rejections — el `getStatus().getCode()`
// solo devuelve el wrapper HTTP (5000 genérico "Aceptada").
//
// Fuente: docs phpcfdi + lib @nodecfdi/sat-ws-descarga-masiva (`CodeRequest`).
const codeReqObj = typeof (result as any).getCodeRequest === 'function'
? (result as any).getCodeRequest()
: null;
const codeRequestValue = codeReqObj ? codeReqObj.getValue() : null;
const codeRequestMessage = codeReqObj ? codeReqObj.getMessage() : null;
const codeRequestEntry = codeReqObj ? codeReqObj.getEntryId() : null;
// Debug logging
console.log('[SAT Verify Debug]', {
statusRequestValue: statusRequest.getValue(),
statusRequestEntryId: statusRequest.getEntryId(),
cfdis: result.getNumberCfdis(),
packages: result.getPackageIds(),
statusCode: result.getStatus().getCode(),
statusMsg: result.getStatus().getMessage(),
codeRequestValue,
codeRequestEntry,
codeRequestMessage,
});
// Usar isTypeOf para determinar el estado
let status: VerifyResult['status'];
if (statusRequest.isTypeOf('Finished')) {
status = 'ready';
} else if (statusRequest.isTypeOf('InProgress')) {
status = 'processing';
} else if (statusRequest.isTypeOf('Accepted')) {
status = 'pending';
} else if (statusRequest.isTypeOf('Failure')) {
status = 'failed';
} else if (statusRequest.isTypeOf('Rejected')) {
status = 'rejected';
} else {
// Default: check by entryId
const entryId = statusRequest.getEntryId();
if (entryId === 'Finished') status = 'ready';
else if (entryId === 'InProgress') status = 'processing';
else if (entryId === 'Accepted') status = 'pending';
else status = 'pending';
}
// Para estados terminales no-felices, construir mensaje informativo.
// `codeRequest` (si está disponible) es la razón SAT real del rechazo.
const statusCode = result.getStatus().getCode().toString();
const statusMsg = result.getStatus().getMessage();
const reqValue = statusRequest.getValue();
const reqEntry = statusRequest.getEntryId();
let message = statusMsg;
if (status === 'rejected' || status === 'failed') {
const codeReqStr = codeRequestValue
? ` codeRequest=${codeRequestEntry}(${codeRequestValue}) — ${codeRequestMessage}`
: '';
message = `SAT request=${reqEntry}(${reqValue})${codeReqStr} wrapperCode=${statusCode} wrapperMsg="${statusMsg}"`;
}
return {
success: result.getStatus().isAccepted(),
status,
packageIds: result.getPackageIds(),
totalCfdis: result.getNumberCfdis(),
message,
statusCode,
};
} catch (error: any) {
console.error('[SAT Verify Error]', error.message || error);
// Errores de la librería (ej. webError.getResponse is not a function)
// no son fallos del SAT — devolver 'pending' para reintentar polling
return {
success: false,
status: 'pending',
packageIds: [],
totalCfdis: 0,
message: error.message || 'Error al verificar solicitud',
};
}
}
/**
* Descarga un paquete de CFDIs
*/
export async function downloadSatPackage(
service: Service,
packageId: string
): Promise<DownloadResult> {
try {
const result = await service.download(packageId);
if (!result.getStatus().isAccepted()) {
return {
success: false,
packageContent: '',
message: result.getStatus().getMessage(),
};
}
return {
success: true,
packageContent: result.getPackageContent(),
message: 'Paquete descargado',
};
} catch (error: any) {
console.error('[SAT Download Error]', error);
return {
success: false,
packageContent: '',
message: error.message || 'Error al descargar paquete',
};
}
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}

View File

@@ -0,0 +1,94 @@
import { encryptAesGcm, decryptAesGcm, deriveAesKey } from '@horux/core';
import { env } from '../../config/env.js';
function getKey(): Buffer {
return deriveAesKey(env.FIEL_ENCRYPTION_KEY);
}
/**
* Encripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY
*/
export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
return encryptAesGcm(data, getKey());
}
/**
* Desencripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY
*/
export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
return decryptAesGcm(encrypted, iv, tag, getKey());
}
/**
* Encripta un string y retorna los componentes
*/
export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
return encrypt(Buffer.from(text, 'utf-8'));
}
/**
* Desencripta a string
*/
export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string {
return decrypt(encrypted, iv, tag).toString('utf-8');
}
/**
* Encripta credenciales FIEL con IV/tag independiente por componente
*/
export function encryptFielCredentials(
cerData: Buffer,
keyData: Buffer,
password: string
): {
encryptedCer: Buffer;
encryptedKey: Buffer;
encryptedPassword: Buffer;
cerIv: Buffer;
cerTag: Buffer;
keyIv: Buffer;
keyTag: Buffer;
passwordIv: Buffer;
passwordTag: Buffer;
} {
const cer = encrypt(cerData);
const key = encrypt(keyData);
const pwd = encrypt(Buffer.from(password, 'utf-8'));
return {
encryptedCer: cer.encrypted,
encryptedKey: key.encrypted,
encryptedPassword: pwd.encrypted,
cerIv: cer.iv,
cerTag: cer.tag,
keyIv: key.iv,
keyTag: key.tag,
passwordIv: pwd.iv,
passwordTag: pwd.tag,
};
}
/**
* Desencripta credenciales FIEL (per-component IV/tag)
*/
export function decryptFielCredentials(
encryptedCer: Buffer,
encryptedKey: Buffer,
encryptedPassword: Buffer,
cerIv: Buffer,
cerTag: Buffer,
keyIv: Buffer,
keyTag: Buffer,
passwordIv: Buffer,
passwordTag: Buffer
): {
cerData: Buffer;
keyData: Buffer;
password: string;
} {
const cerData = decrypt(encryptedCer, cerIv, cerTag);
const keyData = decrypt(encryptedKey, keyIv, keyTag);
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
return { cerData, keyData, password };
}

View File

@@ -0,0 +1,84 @@
import type { Browser, BrowserContext, Page } from 'playwright';
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
export interface CsfLoginSession {
context: BrowserContext;
appPage: Page;
}
/**
* Navigates from the public CSF page → "SERVICIO" popup → FIEL login →
* returns the post-login app page (popup that became the SPA).
* Ver referencia_sat_portal_csf: el botón "Generar" vive en un iframe JSF
* dentro de esta appPage, por eso la retornamos tal cual.
*/
export async function loginSatCsf(
browser: Browser,
cerPath: string,
keyPath: string,
password: string,
): Promise<CsfLoginSession> {
const context = await browser.newContext({ acceptDownloads: true });
const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000);
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
await publicPage.waitForTimeout(2000);
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
const obtenerLocator = publicPage.locator(
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
).first();
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
await obtenerLocator.scrollIntoViewIfNeeded();
await obtenerLocator.click();
await publicPage.waitForTimeout(1500);
// Click "SERVICIO" → popup
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
const loginPage = await popupPromise;
await loginPage.waitForLoadState('domcontentloaded');
loginPage.setDefaultTimeout(60_000);
// Click "e.firma" (NO "e.firma portable"). El SAT a veces aterriza en la
// pestaña de contraseña: el botón cambia a la vista FIEL. El click sintético
// de Playwright a veces no dispara el handler — afirmamos el efecto (aparece
// el file input) y reintentamos con dispatchEvent si hace falta.
const efirmaBtn = loginPage
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
.first();
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
await efirmaBtn.scrollIntoViewIfNeeded();
await efirmaBtn.click();
const fileInputs = loginPage.locator('input[type="file"]');
try {
await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 });
} catch {
// Retry: el click sintético no disparó el handler — forzamos dispatchEvent
await efirmaBtn.dispatchEvent('click');
await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 });
}
// Upload .cer (primer input) y .key (segundo)
await fileInputs.nth(0).setInputFiles(cerPath);
await fileInputs.nth(1).setInputFiles(keyPath);
// Password + Enviar
await loginPage.locator('input[type="password"]').first().fill(password);
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
// Esperar a que salga del dominio de login
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000);
const bodyText = await loginPage.locator('body').innerText().catch(() => '');
if (/contrase[nñ]a\s+incorrecta|usuario.*no.*v[aá]lido|firma\s+inv[aá]lida/i.test(bodyText)) {
throw new Error('FIEL inválida o contraseña incorrecta');
}
return { context, appPage: loginPage };
}

View File

@@ -0,0 +1,246 @@
import { PDFParse } from 'pdf-parse';
export interface Domicilio {
codigoPostal?: string;
tipoVialidad?: string;
nombreVialidad?: string;
numeroExterior?: string;
numeroInterior?: string;
colonia?: string;
localidad?: string;
municipio?: string;
entidadFederativa?: string;
entreCalle?: string;
yCalle?: string;
}
export interface ActividadEconomica {
orden: number;
descripcion: string;
porcentaje: number;
fechaInicio: string;
fechaFin?: string;
}
export interface RegimenCsf {
nombre: string;
fechaInicio: string;
fechaFin?: string;
}
export interface Obligacion {
descripcion: string;
descripcionVencimiento: string;
fechaInicio: string;
fechaFin?: string;
}
export interface ConstanciaSituacionFiscal {
rfc: string;
curp?: string;
idCIF: string;
nombre?: string;
primerApellido?: string;
segundoApellido?: string;
razonSocial?: string;
nombreComercial?: string;
fechaInicioOperaciones: string;
estatusPadron: string;
fechaUltimoCambioEstado?: string;
lugarFechaEmision: string;
domicilio: Domicilio;
actividadesEconomicas: ActividadEconomica[];
regimenes: RegimenCsf[];
obligaciones: Obligacion[];
cadenaOriginalSello: string;
selloDigital: string;
}
async function extractPdfText(pdfBuffer: Buffer): Promise<string> {
const parser = new PDFParse({ data: pdfBuffer });
try {
const result = await parser.getText();
return result.text;
} finally {
await parser.destroy();
}
}
const LABELS = [
'RFC', 'CURP', 'Nombre (s)', 'Primer Apellido', 'Segundo Apellido',
'Denominación o Razón Social', 'Denominación/Razón Social',
'Régimen Capital', 'Fecha inicio de operaciones', 'Estatus en el padrón',
'Fecha de último cambio de estado', 'Nombre Comercial',
'Código Postal', 'Tipo de Vialidad', 'Nombre de Vialidad',
'Número Exterior', 'Número Interior', 'Nombre de la Colonia',
'Nombre de la Localidad', 'Nombre del Municipio o Demarcación Territorial',
'Nombre de la Entidad Federativa', 'Entre Calle', 'Y Calle',
] as const;
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractLabels(text: string): Map<string, string> {
const result = new Map<string, string>();
const labelAlternation = LABELS.map(escapeRegex).join('|');
const re = new RegExp(
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
'g',
);
for (const match of text.matchAll(re)) {
const label = match[1];
const value = match[2].replace(/\s+/g, ' ').trim();
if (!result.has(label)) result.set(label, value);
}
return result;
}
function extractIdCIF(text: string): string {
const m = text.match(/idCIF\s*:?\s*(\d+)/i);
if (!m) throw new Error('idCIF no encontrado en PDF');
return m[1];
}
function extractLugarFechaEmision(text: string): string {
const m = text.match(/Lugar y Fecha de Emisión\s*\n?\s*([^\n]+?)\s*(?=\n|TORC|HTS|[A-Z]{4}\d{6})/);
if (m) return m[1].replace(/\s+/g, ' ').trim();
const m2 = text.match(/([A-ZÁÉÍÓÚÑ ]+,\s*[A-ZÁÉÍÓÚÑ ]+\s+A\s+\d{1,2}\s+DE\s+[A-ZÁÉÍÓÚÑ]+\s+DE\s+\d{4})/i);
if (m2) return m2[1].replace(/\s+/g, ' ').trim();
throw new Error('Lugar y Fecha de Emisión no encontrado');
}
const PAGE_NOISE_RE = /^\s*(?:--\s*\d+\s+of\s+\d+\s*--|Página\s*\[\d+\]\s*de\s*\[\d+\])\s*$/;
function sliceSection(text: string, header: string, nextHeaders: string[]): string {
const start = text.indexOf(header);
if (start === -1) return '';
const after = start + header.length;
let end = text.length;
for (const h of nextHeaders) {
const idx = text.indexOf(h, after);
if (idx !== -1 && idx < end) end = idx;
}
return text.slice(after, end);
}
function groupRowChunks(body: string, headerRowRegex: RegExp): string[] {
const lines = body.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0 && !PAGE_NOISE_RE.test(l));
if (lines.length > 0 && headerRowRegex.test(lines[0])) lines.shift();
const chunks: string[] = [];
let current: string[] = [];
for (const line of lines) {
current.push(line);
if (/\d{2}\/\d{2}\/\d{4}\s*$/.test(line)) {
chunks.push(current.join(' ').replace(/\s+/g, ' ').trim());
current = [];
}
}
return chunks;
}
function extractActividades(text: string): ActividadEconomica[] {
const section = sliceSection(text, 'Actividades Económicas:', ['Regímenes:', 'Obligaciones:', 'Cadena Original']);
if (!section) return [];
const chunks = groupRowChunks(section, /^\s*Orden\s+Actividad\s+Económica\s+Porcentaje\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i);
const result: ActividadEconomica[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^(\d+)\s+(.+?)\s+(\d+)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
if (!m) continue;
result.push({
orden: Number(m[1]),
descripcion: m[2].replace(/\s+/g, ' ').trim(),
porcentaje: Number(m[3]),
fechaInicio: m[4],
fechaFin: m[5],
});
}
return result;
}
function extractRegimenes(text: string): RegimenCsf[] {
const section = sliceSection(text, 'Regímenes:', ['Obligaciones:', 'Cadena Original']);
if (!section) return [];
const chunks = groupRowChunks(section, /^\s*Régimen\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i);
const result: RegimenCsf[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^(.+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
if (!m) continue;
result.push({ nombre: m[1].replace(/\s+/g, ' ').trim(), fechaInicio: m[2], fechaFin: m[3] });
}
return result;
}
function extractObligaciones(text: string): Obligacion[] {
const section = sliceSection(text, 'Obligaciones:', ['Sus datos personales', 'Cadena Original']);
if (!section) return [];
const chunks = groupRowChunks(section, /^\s*Descripción de la Obligación\s+Descripción Vencimiento\s+Fecha Inicio\s+Fecha Fin\s*$/i);
const result: Obligacion[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^(.+?)\s+((?:A\s+m[aá]s\s+tardar|Dentro\s+de|Mensualmente|Bimestralmente|Trimestralmente|Anualmente|En\s+los|Cuando\s+)[\s\S]+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
if (!m) continue;
result.push({ descripcion: m[1].trim(), descripcionVencimiento: m[2].trim(), fechaInicio: m[3], fechaFin: m[4] });
}
return result;
}
function extractCadenaOriginalSello(text: string): string {
const m = text.match(/Cadena Original Sello\s*:\s*(\|\|[\s\S]+?\|\|)\s*(?:Sello Digital|$)/);
if (!m) throw new Error('Cadena Original Sello no encontrada');
return m[1].replace(/\s+/g, '');
}
function extractSelloDigital(text: string): string {
const m = text.match(/Sello Digital\s*:\s*([A-Za-z0-9+/=\s]+?)(?:\n\s*\n|Página|$)/);
if (!m) throw new Error('Sello Digital no encontrado');
return m[1].replace(/\s+/g, '');
}
export async function parseCsfPdf(pdfBuffer: Buffer): Promise<ConstanciaSituacionFiscal> {
const text = await extractPdfText(pdfBuffer);
const labels = extractLabels(text);
const idCIF = extractIdCIF(text);
const lugarFechaEmision = extractLugarFechaEmision(text);
const rfc = labels.get('RFC');
if (!rfc) throw new Error('RFC no encontrado en PDF');
const fechaInicioOperaciones = labels.get('Fecha inicio de operaciones');
if (!fechaInicioOperaciones) throw new Error('Fecha inicio de operaciones no encontrada');
const estatusPadron = labels.get('Estatus en el padrón');
if (!estatusPadron) throw new Error('Estatus en el padrón no encontrado');
return {
rfc,
curp: labels.get('CURP'),
idCIF,
nombre: labels.get('Nombre (s)'),
primerApellido: labels.get('Primer Apellido'),
segundoApellido: labels.get('Segundo Apellido'),
razonSocial: labels.get('Denominación o Razón Social') ?? labels.get('Denominación/Razón Social'),
nombreComercial: labels.get('Nombre Comercial') || undefined,
fechaInicioOperaciones,
estatusPadron,
fechaUltimoCambioEstado: labels.get('Fecha de último cambio de estado'),
lugarFechaEmision,
domicilio: {
codigoPostal: labels.get('Código Postal'),
tipoVialidad: labels.get('Tipo de Vialidad'),
nombreVialidad: labels.get('Nombre de Vialidad'),
numeroExterior: labels.get('Número Exterior'),
numeroInterior: labels.get('Número Interior'),
colonia: labels.get('Nombre de la Colonia'),
localidad: labels.get('Nombre de la Localidad'),
municipio: labels.get('Nombre del Municipio o Demarcación Territorial'),
entidadFederativa: labels.get('Nombre de la Entidad Federativa'),
entreCalle: labels.get('Entre Calle'),
yCalle: labels.get('Y Calle'),
},
actividadesEconomicas: extractActividades(text),
regimenes: extractRegimenes(text),
obligaciones: extractObligaciones(text),
cadenaOriginalSello: extractCadenaOriginalSello(text),
selloDigital: extractSelloDigital(text),
};
}

View File

@@ -0,0 +1,121 @@
import type { Page, Locator, Frame, Response } from 'playwright';
import type { CsfLoginSession } from './sat-csf-login.js';
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
if (url.startsWith('blob:') || url.startsWith('data:')) {
const arr = await page.evaluate(async (u) => {
const r = await fetch(u);
const buf = await r.arrayBuffer();
return Array.from(new Uint8Array(buf));
}, url);
return Buffer.from(arr);
}
if (url.startsWith('http')) {
const response = await page.context().request.get(url);
if (!response.ok()) return null;
return Buffer.from(await response.body());
}
return null;
}
/**
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
* Intenta 3 rutas: download event, popup con viewer, response interception.
*/
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
const { context, appPage } = session;
let interceptedPdf: Buffer | null = null;
const responseListener = async (response: Response) => {
const ct = response.headers()['content-type'] ?? '';
if (ct.includes('application/pdf')) {
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
}
};
context.on('response', responseListener);
const GENERAR_SELECTORS = [
'button:has-text("Generar Constancia")',
'button:has-text("Generar constancia")',
'input[type="button"][value*="Generar" i]',
'input[type="submit"][value*="Generar" i]',
'a:has-text("Generar Constancia")',
'a:has-text("Generar constancia")',
].join(', ');
let generarLocator: Locator | null = null;
let foundFrame: Frame | null = null;
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
for (const frame of appPage.frames()) {
const loc = frame.locator(GENERAR_SELECTORS).first();
const count = await loc.count().catch(() => 0);
if (count > 0 && await loc.isVisible().catch(() => false)) {
generarLocator = loc;
foundFrame = frame;
break;
}
}
if (generarLocator) break;
await appPage.waitForTimeout(1000);
}
if (!generarLocator || !foundFrame) {
context.off('response', responseListener);
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
}
await generarLocator.scrollIntoViewIfNeeded();
await appPage.waitForTimeout(500);
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
await generarLocator.click();
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
try {
// Path 1: download event
if (download) {
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
const pdf = Buffer.concat(chunks);
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
throw new Error('El archivo descargado no es un PDF válido');
}
return pdf;
}
// Path 2: viewer popup
if (popup) {
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
await popup.waitForTimeout(2000);
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
if (!pdf) {
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
if (embedSrc) {
const absolute = new URL(embedSrc, popup.url()).toString();
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
}
}
if (!pdf && interceptedPdf) pdf = interceptedPdf;
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
return pdf;
}
// Path 3: inline response (no popup, no download)
await appPage.waitForTimeout(3000);
if (interceptedPdf) {
const pdf = interceptedPdf as Buffer;
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
return pdf;
}
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
} finally {
context.off('response', responseListener);
}
}

View 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
);
}

View File

@@ -0,0 +1,92 @@
import { type Page } from 'playwright';
const TIMEOUT = 60_000;
export async function loginToSatOpinion(
page: Page,
cerPath: string,
keyPath: string,
password: string,
): Promise<Page> {
// Step 1: Navigate to SAT public page
const publicUrl = 'https://www.sat.gob.mx/portal/public/tramites/opinion-del-cumplimiento';
console.log('[SAT Opinion] Navigating to SAT public page...');
await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
await page.waitForTimeout(2000);
// Step 2: Click "Obtén la Opinión del cumplimiento" tab
console.log('[SAT Opinion] Clicking "Obtén la Opinión del cumplimiento"...');
const obtenerOpcion = page.locator('text=Obt').first();
await obtenerOpcion.waitFor({ state: 'visible', timeout: TIMEOUT });
await obtenerOpcion.click();
await page.waitForTimeout(2000);
// Step 3: Expand "De tu empresa" accordion
console.log('[SAT Opinion] Expanding "De tu empresa" section...');
const empresaOption = page.locator('text=De tu empresa').first();
await empresaOption.waitFor({ state: 'visible', timeout: TIMEOUT });
await empresaOption.click();
await page.waitForTimeout(2000);
// Step 4: Click "Ingresa" link — opens new tab (target=_blank)
console.log('[SAT Opinion] Clicking "Ingresa" (opens new tab)...');
const ingresaLink = page.locator('a:has-text("Ingresa")').first();
await ingresaLink.waitFor({ state: 'visible', timeout: TIMEOUT });
const [loginPage] = await Promise.all([
page.context().waitForEvent('page', { timeout: TIMEOUT }),
ingresaLink.click(),
]);
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
await loginPage.waitForTimeout(2000);
// Step 5: Switch to e.firma login
console.log('[SAT Opinion] Clicking e.firma button...');
const efirmaButton = loginPage.locator('button:has-text("e.firma"), input[value*="firma"], a:has-text("e.firma")').first();
await efirmaButton.waitFor({ state: 'visible', timeout: TIMEOUT });
await efirmaButton.click();
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
await loginPage.waitForTimeout(2000);
// Step 6: Upload .cer
console.log('[SAT Opinion] Uploading .cer...');
const cerInput = loginPage.locator('input[type="file"]').first();
await cerInput.waitFor({ state: 'attached', timeout: TIMEOUT });
await cerInput.setInputFiles(cerPath);
await loginPage.waitForTimeout(500);
// Step 7: Upload .key
console.log('[SAT Opinion] Uploading .key...');
const keyInput = loginPage.locator('input[type="file"]').nth(1);
await keyInput.waitFor({ state: 'attached', timeout: TIMEOUT });
await keyInput.setInputFiles(keyPath);
await loginPage.waitForTimeout(500);
// Step 8: Enter password
console.log('[SAT Opinion] Entering password...');
const passwordInput = loginPage.locator('input[type="password"]').first();
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUT });
await passwordInput.fill(password);
// Step 9: Submit
console.log('[SAT Opinion] Submitting login...');
const submitButton = loginPage.locator('button:has-text("Enviar"), input[value="Enviar"], a:has-text("Enviar"), input[type="submit"], button[type="submit"]').first();
await submitButton.waitFor({ state: 'visible', timeout: TIMEOUT });
await submitButton.click();
// Step 10: Wait for auth + redirect to report
console.log('[SAT Opinion] Waiting for authentication...');
await loginPage.waitForTimeout(8000);
const currentUrl = loginPage.url();
if (!currentUrl.includes('reporteOpinion32DContribuyente')) {
const baseUrl = currentUrl.replace(/#.*$/, '').replace(/\?.*$/, '');
const reporteUrl = baseUrl + '#/reporteOpinion32DContribuyente';
console.log(`[SAT Opinion] Navigating to report: ${reporteUrl}`);
await loginPage.goto(reporteUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
await loginPage.waitForTimeout(5000);
}
console.log('[SAT Opinion] Login completed.');
return loginPage;
}

View File

@@ -0,0 +1,76 @@
import { PDFParse } from 'pdf-parse';
export interface ParsedOpinion {
rfc: string;
razonSocial: string;
estatus: string;
folio: string;
cadenaOriginal: string;
fechaConsulta: string;
}
export async function parseOpinionPdf(pdfBuffer: Buffer): Promise<ParsedOpinion> {
const pdfParse = new PDFParse({ data: new Uint8Array(pdfBuffer) });
try {
const textResult = await pdfParse.getText();
const text = textResult.text;
return {
rfc: extractRfc(text),
razonSocial: extractRazonSocial(text),
estatus: extractEstatus(text),
folio: extractFolio(text),
cadenaOriginal: extractCadenaOriginal(text),
fechaConsulta: extractFecha(text),
};
} finally {
await pdfParse.destroy();
}
}
function extractRfc(text: string): string {
const match = text.match(/RFC\s+Folio\s*\n\s*([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})/i);
if (match) return match[1].trim();
const fallback = text.match(/\b([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})\b/);
return fallback ? fallback[1] : 'NO_ENCONTRADO';
}
function extractRazonSocial(text: string): string {
const match = text.match(/(?:Nombre|denominaci[oó]n|raz[oó]n social)\s+Sentido\s*\n\s*(.+)/i);
if (match) {
return match[1].trim().replace(/\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/i, '').trim();
}
return 'NO_ENCONTRADO';
}
function extractEstatus(text: string): string {
const match = text.match(/Sentido\s*\n\s*.+\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/im);
if (match) {
const raw = match[1].trim().toUpperCase();
if (raw === 'POSITIVO') return 'Positiva';
if (raw === 'NEGATIVO') return 'Negativa';
if (raw.includes('SUSPENSI')) return 'En suspensión';
if (raw.includes('NO INSCRITO')) return 'No inscrito';
if (raw.includes('SIN OBLIGACIONES')) return 'Inscrito sin obligaciones';
}
if (/POSITIVO/i.test(text)) return 'Positiva';
if (/NEGATIVO/i.test(text)) return 'Negativa';
return 'NO_DETERMINADO';
}
function extractFolio(text: string): string {
const match = text.match(/RFC\s+Folio\s*\n\s*[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\s+(\S+)/i);
return match ? match[1].trim() : 'NO_ENCONTRADO';
}
function extractCadenaOriginal(text: string): string {
const match = text.match(/Cadena Original\s*\n\s*(\|\|.+\|\|)/i);
return match ? match[1].trim() : 'NO_ENCONTRADO';
}
function extractFecha(text: string): string {
const match = text.match(/Fecha\s+y\s+hora\s+de\s+emisi[oó]n\s*\n\s*(.+)/i);
if (match) return match[1].trim();
const fallback = text.match(/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4}\s+a\s+las\s+[\d:]+\s+horas)/i);
return fallback ? fallback[1].trim() : 'NO_ENCONTRADO';
}

View File

@@ -0,0 +1,84 @@
import { type Page } from 'playwright';
export async function extractOpinionPdf(page: Page): Promise<Buffer> {
const TIMEOUT = 120_000;
const POLL_INTERVAL = 3_000;
console.log('[SAT Opinion Scraper] Waiting for PDF to appear...');
let interceptedPdf: Buffer | null = null;
page.on('response', async (response) => {
try {
const contentType = response.headers()['content-type'] || '';
if (contentType.includes('application/pdf') || response.url().endsWith('.pdf')) {
const body = await response.body();
if (body.length > 100) {
interceptedPdf = body;
console.log(`[SAT Opinion Scraper] PDF intercepted via network: ${body.length} bytes`);
}
}
} catch { /* response body may not be available */ }
});
const startTime = Date.now();
while (Date.now() - startTime < TIMEOUT) {
if (interceptedPdf) return interceptedPdf;
// Strategy 1: <embed> or <object> with PDF data URI
const embedData = await page.evaluate(() => {
for (const el of document.querySelectorAll('embed, object')) {
const src = el.getAttribute('src') || el.getAttribute('data') || '';
if (src.startsWith('data:application/pdf;base64,')) return src;
}
return null;
}).catch(() => null);
if (embedData) {
console.log('[SAT Opinion Scraper] PDF found via <embed>/<object>');
return decodeDataUri(embedData);
}
// Strategy 2: Scan full HTML for base64 PDF
const html = await page.content().catch(() => '');
const match = html.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
if (match) {
console.log('[SAT Opinion Scraper] PDF found via page content scan');
return decodeDataUri(`data:application/pdf;base64,${match[1]}`);
}
// Strategy 3: Check iframes
for (const frame of page.frames()) {
try {
const frameUrl = frame.url();
if (frameUrl.startsWith('data:application/pdf;base64,')) {
console.log('[SAT Opinion Scraper] PDF found via iframe URL');
return decodeDataUri(frameUrl);
}
const frameHtml = await frame.content();
const frameMatch = frameHtml.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
if (frameMatch) {
console.log('[SAT Opinion Scraper] PDF found via iframe content');
return decodeDataUri(`data:application/pdf;base64,${frameMatch[1]}`);
}
} catch { /* cross-origin frame */ }
}
// Strategy 4: Page URL itself
if (page.url().startsWith('data:application/pdf;base64,')) {
console.log('[SAT Opinion Scraper] PDF found via page URL');
return decodeDataUri(page.url());
}
console.log(`[SAT Opinion Scraper] PDF not found, retrying in ${POLL_INTERVAL / 1000}s...`);
await page.waitForTimeout(POLL_INTERVAL);
}
throw new Error(`PDF not found after ${TIMEOUT / 1000}s`);
}
function decodeDataUri(dataUri: string): Buffer {
const prefix = 'data:application/pdf;base64,';
const base64 = dataUri.substring(prefix.length).replace(/\s/g, '');
return Buffer.from(base64, 'base64');
}

View File

@@ -0,0 +1,735 @@
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
interface CfdiParsed {
uuid: string;
type: TipoCfdi;
tipoComprobante: string;
serie: string | null;
folio: string | null;
status: EstadoCfdi;
fechaEmision: Date;
fechaCertSat: Date | null;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
descuento: number;
total: number;
moneda: string;
tipoCambio: number;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
pac: string | null;
// Impuestos del comprobante
ivaTraslado: number;
isrRetencion: number;
ivaRetencion: number;
iepsTraslado: number;
iepsRetencion: number;
// Impuestos locales
impuestosLocalesTrasladado: number;
impuestosLocalesRetenidos: number;
// Complemento de pagos
montoPago: number;
fechaPagoP: string | null;
numParcialidad: string | null;
uuidRelacionado: string | null;
saldoInsoluto: string | null;
isrRetencionPago: number;
ivaTrasladoPago: number;
ivaRetencionPago: number;
iepsTrasladoPago: number;
iepsRetencionPago: number;
// Nómina
fechaPago: string | null;
fechaInicialPago: string | null;
fechaFinalPago: string | null;
numDiasPagados: number;
numSeguroSocial: string | null;
puesto: string | null;
salarioBaseCotApor: number;
salarioDiarioIntegrado: number;
totalPercepciones: number;
totalDeducciones: number;
impRetenidosNomina: number;
otrasDeduccionesNomina: number;
subsidioCausado: number;
regimenFiscalEmisor: string | null;
regimenFiscalReceptor: string | null;
// CfdiRelacionados a nivel raíz del comprobante (CFDI 4.0).
// `cfdiTipoRelacion` — clave SAT (01..07). NULL si no hay relación.
// `cfdisRelacionados` — UUIDs pipe-separated.
cfdiTipoRelacion: string | null;
cfdisRelacionados: string | null;
conceptos: ConceptoParsed[];
xmlOriginal: string;
}
interface ConceptoParsed {
claveProdServ: string | null;
noIdentificacion: string | null;
descripcion: string;
cantidad: number;
claveUnidad: string | null;
unidad: string | null;
valorUnitario: number;
importe: number;
descuento: number;
// Impuestos por concepto
isrRetencion: number;
ivaTraslado: number;
ivaRetencion: number;
iepsTraslado: number;
iepsRetencion: number;
}
interface ExtractedXml {
filename: string;
content: string;
}
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
});
/**
* Extrae archivos XML de un paquete ZIP en base64
*/
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
const zipBuffer = Buffer.from(zipBase64, 'base64');
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
const xmlFiles: ExtractedXml[] = [];
for (const entry of entries) {
if (entry.entryName.toLowerCase().endsWith('.xml')) {
const content = entry.getData().toString('utf-8');
xmlFiles.push({
filename: entry.entryName,
content,
});
}
}
return xmlFiles;
}
/**
* Parsea una fecha del XML/CSV del SAT preservando la hora **literal**.
*
* Problema: el CFDI 4.0 define `Fecha` y `FechaTimbrado` como ISO-8601 sin
* zona horaria (hora local del contribuyente = México). Si se pasa tal cual
* a `new Date(str)`, Node lo interpreta según la timezone de la máquina:
* en CDMX (UTC-6), "2025-12-31T18:37:51" se convierte a UTC
* "2026-01-01T00:37:51Z", cambiando la fecha efectiva y desalineando el
* mes/año del CFDI. Postgres guarda ese valor UTC, y los filtros por rango
* lo sacan del mes correcto.
*
* Solución: forzar 'Z' si el string no trae TZ indicator. Esto hace que
* Node interprete el texto como UTC literal y preserve la hora tal cual.
* El valor queda naive pero consistente: todo el sistema filtra con
* fechas naive (sin TZ), así que el resultado es correcto.
*/
function parseCfdiDate(str: string | null | undefined): Date {
if (!str) return new Date(0);
const s = String(str).trim();
if (!s) return new Date(0);
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
return new Date(hasTz ? s : s + 'Z');
}
function toArray(val: any): any[] {
if (!val) return [];
return Array.isArray(val) ? val : [val];
}
function pf(val: any): number {
return parseFloat(val || '0') || 0;
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function extractUuid(comprobante: any): string {
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
}
/**
* Extrae datos del timbre: fecha cert SAT y PAC
*/
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
if (!timbre) return { fechaCertSat: null, pac: null };
return {
fechaCertSat: timbre['@_FechaTimbrado'] ? parseCfdiDate(timbre['@_FechaTimbrado']) : null,
pac: timbre['@_RfcProvCertif'] || null,
};
}
/**
* Extrae impuestos trasladados (IVA 002, IEPS 003)
*/
function extractTraslados(comprobante: any): { iva: number; ieps: number } {
const traslados = toArray(comprobante.Impuestos?.Traslados?.Traslado);
let iva = 0, ieps = 0;
for (const t of traslados) {
const importe = pf(t['@_Importe']);
if (t['@_Impuesto'] === '002') iva += importe;
else if (t['@_Impuesto'] === '003') ieps += importe;
}
return { iva, ieps };
}
/**
* Extrae impuestos retenidos (ISR 001, IVA 002, IEPS 003)
*/
function extractRetenciones(comprobante: any): { isr: number; iva: number; ieps: number } {
const retenciones = toArray(comprobante.Impuestos?.Retenciones?.Retencion);
let isr = 0, iva = 0, ieps = 0;
for (const r of retenciones) {
const importe = pf(r['@_Importe']);
if (r['@_Impuesto'] === '001') isr += importe;
else if (r['@_Impuesto'] === '002') iva += importe;
else if (r['@_Impuesto'] === '003') ieps += importe;
}
return { isr, iva, ieps };
}
/**
* Extrae impuestos locales
*/
function extractImpuestosLocales(comprobante: any): { trasladado: number; retenido: number } {
const complemento = comprobante.Complemento;
if (!complemento) return { trasladado: 0, retenido: 0 };
const impLocales = complemento.ImpuestosLocales;
if (!impLocales) return { trasladado: 0, retenido: 0 };
return {
trasladado: pf(impLocales['@_TotaldeTraslados']),
retenido: pf(impLocales['@_TotaldeRetenciones']),
};
}
/**
* Extrae CfdiRelacionados a nivel raíz del Comprobante. Puede haber 1+
* nodos `cfdi:CfdiRelacionados` (cada uno con un `TipoRelacion`), y dentro
* de cada uno 1+ `cfdi:CfdiRelacionado` con UUID. Retorna el primer
* TipoRelacion encontrado (lo más común) y todos los UUIDs pipe-separated.
*/
function extractCfdiRelacionados(comprobante: any): {
tipoRelacion: string | null;
uuids: string | null;
} {
const nodes = toArray(comprobante.CfdiRelacionados);
if (nodes.length === 0) return { tipoRelacion: null, uuids: null };
let tipoRelacion: string | null = null;
const allUuids: string[] = [];
for (const node of nodes) {
if (!tipoRelacion && node['@_TipoRelacion']) {
tipoRelacion = String(node['@_TipoRelacion']);
}
const rels = toArray(node.CfdiRelacionado);
for (const r of rels) {
if (r['@_UUID']) allUuids.push(String(r['@_UUID']));
}
}
return {
tipoRelacion,
uuids: allUuids.length > 0 ? allUuids.join('|') : null,
};
}
/**
* Extrae datos del complemento de pagos (pago20)
*/
function extractPagos(comprobante: any): {
montoPago: number;
fechaPagoP: string | null;
numParcialidad: string | null;
uuidRelacionado: string | null;
saldoInsoluto: string | null;
isrRetencion: number;
ivaTraslado: number;
ivaRetencion: number;
iepsTraslado: number;
iepsRetencion: number;
} {
const result = {
montoPago: 0, fechaPagoP: null as string | null,
numParcialidad: null as string | null,
uuidRelacionado: null as string | null,
saldoInsoluto: null as string | null,
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
iepsTraslado: 0, iepsRetencion: 0,
};
const complemento = comprobante.Complemento;
if (!complemento) return result;
// Try pago20:Pagos or just Pagos
const pagosNode = complemento.Pagos;
if (!pagosNode) return result;
const pagos = toArray(pagosNode.Pago);
const fechas: string[] = [];
const parcialidades: string[] = [];
const uuids: string[] = [];
const saldos: string[] = [];
for (const pago of pagos) {
result.montoPago += pf(pago['@_Monto']);
if (pago['@_FechaPago']) fechas.push(pago['@_FechaPago']);
// Impuestos del pago
const retPago = toArray(pago.ImpuestosP?.RetencionesPP?.RetencionP || pago.ImpuestosP?.RetencionesPP);
for (const r of retPago) {
const importe = pf(r['@_ImporteP']);
if (r['@_ImpuestoP'] === '001') result.isrRetencion += importe;
else if (r['@_ImpuestoP'] === '002') result.ivaRetencion += importe;
else if (r['@_ImpuestoP'] === '003') result.iepsRetencion += importe;
}
const trasPago = toArray(pago.ImpuestosP?.TrasladosP?.TrasladoP || pago.ImpuestosP?.TrasladosP);
for (const t of trasPago) {
const importe = pf(t['@_ImporteP']);
if (t['@_ImpuestoP'] === '002') result.ivaTraslado += importe;
else if (t['@_ImpuestoP'] === '003') result.iepsTraslado += importe;
}
// Documentos relacionados
const doctos = toArray(pago.DoctoRelacionado);
for (const d of doctos) {
if (d['@_IdDocumento']) uuids.push(d['@_IdDocumento']);
if (d['@_NumParcialidad']) parcialidades.push(d['@_NumParcialidad']);
if (d['@_ImpSaldoInsoluto'] !== undefined) saldos.push(d['@_ImpSaldoInsoluto']);
}
}
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
return result;
}
/**
* Extrae datos del complemento de nómina (nomina12)
*/
function extractNomina(comprobante: any): {
fechaPago: string | null;
fechaInicialPago: string | null;
fechaFinalPago: string | null;
numDiasPagados: number;
numSeguroSocial: string | null;
puesto: string | null;
salarioBaseCotApor: number;
salarioDiarioIntegrado: number;
totalPercepciones: number;
totalDeducciones: number;
impRetenidosNomina: number;
otrasDeduccionesNomina: number;
subsidioCausado: number;
} {
const result = {
fechaPago: null as string | null,
fechaInicialPago: null as string | null,
fechaFinalPago: null as string | null,
numDiasPagados: 0,
numSeguroSocial: null as string | null,
puesto: null as string | null,
salarioBaseCotApor: 0,
salarioDiarioIntegrado: 0,
totalPercepciones: 0,
totalDeducciones: 0,
impRetenidosNomina: 0,
otrasDeduccionesNomina: 0,
subsidioCausado: 0,
};
const complemento = comprobante.Complemento;
if (!complemento) return result;
const nomina = complemento.Nomina;
if (!nomina) return result;
result.fechaPago = nomina['@_FechaPago'] || null;
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
// Receptor de nómina
const receptor = nomina.Receptor;
if (receptor) {
result.numSeguroSocial = receptor['@_NumSeguridadSocial'] || null;
result.puesto = receptor['@_Puesto'] || null;
result.salarioBaseCotApor = pf(receptor['@_SalarioBaseCotApor']);
result.salarioDiarioIntegrado = pf(receptor['@_SalarioDiarioIntegrado']);
}
// Deducciones
const deducciones = nomina.Deducciones;
if (deducciones) {
result.impRetenidosNomina = pf(deducciones['@_TotalImpuestosRetenidos']);
result.otrasDeduccionesNomina = pf(deducciones['@_TotalOtrasDeducciones']);
}
// Subsidio causado (OtrosPagos/OtroPago[@TipoOtroPago='002'])
const otrosPagos = toArray(nomina.OtrosPagos?.OtroPago);
for (const op of otrosPagos) {
if (op['@_TipoOtroPago'] === '002') {
result.subsidioCausado = pf(op.SubsidioAlEmpleo?.['@_SubsidioCausado']);
}
}
return result;
}
/**
* Extrae los conceptos del comprobante
*/
function extractConceptos(comprobante: any): ConceptoParsed[] {
const conceptosNode = comprobante.Conceptos?.Concepto;
if (!conceptosNode) return [];
const conceptos = toArray(conceptosNode);
return conceptos.map((c: any) => {
// Impuestos por concepto
const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado);
const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion);
let ivaTraslado = 0, iepsTraslado = 0;
for (const t of trasladosC) {
const importe = pf(t['@_Importe']);
if (t['@_Impuesto'] === '002') ivaTraslado += importe;
else if (t['@_Impuesto'] === '003') iepsTraslado += importe;
}
let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0;
for (const r of retencionesC) {
const importe = pf(r['@_Importe']);
if (r['@_Impuesto'] === '001') isrRetencion += importe;
else if (r['@_Impuesto'] === '002') ivaRetencion += importe;
else if (r['@_Impuesto'] === '003') iepsRetencion += importe;
}
return {
claveProdServ: c['@_ClaveProdServ'] || null,
noIdentificacion: c['@_NoIdentificacion'] || null,
descripcion: c['@_Descripcion'] || '',
cantidad: pf(c['@_Cantidad']) || 1,
claveUnidad: c['@_ClaveUnidad'] || null,
unidad: c['@_Unidad'] || null,
valorUnitario: pf(c['@_ValorUnitario']),
importe: pf(c['@_Importe']),
descuento: pf(c['@_Descuento']),
isrRetencion,
ivaTraslado,
ivaRetencion,
iepsTraslado,
iepsRetencion,
};
});
}
/**
* Parsea un XML de CFDI y extrae los datos relevantes
* @param downloadType - 'emitidos' o 'recibidos' para determinar el type (EMITIDO/RECIBIDO)
*/
export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed | null {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) {
console.error('[Parser] No se encontró el nodo Comprobante');
return null;
}
const emisor = comprobante.Emisor || {};
const receptor = comprobante.Receptor || {};
const retenciones = extractRetenciones(comprobante);
const traslados = extractTraslados(comprobante);
const timbreData = extractTimbreData(comprobante);
const impLocales = extractImpuestosLocales(comprobante);
const tipoComprobante = comprobante['@_TipoDeComprobante'] || 'I';
// Complemento de pagos (solo tipo P)
const pagosData = tipoComprobante === 'P' ? extractPagos(comprobante) : {
montoPago: 0, fechaPagoP: null, numParcialidad: null,
uuidRelacionado: null, saldoInsoluto: null,
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
iepsTraslado: 0, iepsRetencion: 0,
};
// CfdiRelacionados a nivel raíz. CFDI 4.0 permite 1+ nodos
// `cfdi:CfdiRelacionados` cada uno con un TipoRelacion y múltiples UUIDs.
// Aquí capturamos el PRIMER TipoRelacion (lo más común es que haya uno
// solo, especialmente en NC tipo E). Los UUIDs de todos los bloques se
// concatenan con `|`.
const relacionesData = extractCfdiRelacionados(comprobante);
// Complemento de nómina (solo tipo N)
const nominaData = tipoComprobante === 'N' ? extractNomina(comprobante) : {
fechaPago: null, fechaInicialPago: null, fechaFinalPago: null,
numDiasPagados: 0, numSeguroSocial: null, puesto: null,
salarioBaseCotApor: 0, salarioDiarioIntegrado: 0,
totalPercepciones: 0, totalDeducciones: 0,
impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0,
};
const cfdi: CfdiParsed = {
uuid: extractUuid(comprobante),
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
tipoComprobante,
serie: comprobante['@_Serie'] || null,
folio: comprobante['@_Folio'] || null,
status: 'Vigente',
fechaEmision: parseCfdiDate(comprobante['@_Fecha']),
fechaCertSat: timbreData.fechaCertSat,
rfcEmisor: emisor['@_Rfc'] || '',
nombreEmisor: emisor['@_Nombre'] || '',
rfcReceptor: receptor['@_Rfc'] || '',
nombreReceptor: receptor['@_Nombre'] || '',
subtotal: pf(comprobante['@_SubTotal']),
descuento: pf(comprobante['@_Descuento']),
total: pf(comprobante['@_Total']),
moneda: comprobante['@_Moneda'] || 'MXN',
tipoCambio: pf(comprobante['@_TipoCambio']) || 1,
metodoPago: comprobante['@_MetodoPago'] || null,
formaPago: comprobante['@_FormaPago'] || null,
usoCfdi: receptor['@_UsoCFDI'] || null,
pac: timbreData.pac,
regimenFiscalEmisor: emisor['@_RegimenFiscal'] || null,
regimenFiscalReceptor: receptor['@_RegimenFiscalReceptor'] || receptor['@_RegimenFiscal'] || null,
cfdiTipoRelacion: relacionesData.tipoRelacion,
cfdisRelacionados: relacionesData.uuids,
// Impuestos comprobante
ivaTraslado: traslados.iva,
isrRetencion: retenciones.isr,
ivaRetencion: retenciones.iva,
iepsTraslado: traslados.ieps,
iepsRetencion: retenciones.ieps,
// Impuestos locales
impuestosLocalesTrasladado: impLocales.trasladado,
impuestosLocalesRetenidos: impLocales.retenido,
// Complemento de pagos
montoPago: pagosData.montoPago,
fechaPagoP: pagosData.fechaPagoP,
numParcialidad: pagosData.numParcialidad,
uuidRelacionado: pagosData.uuidRelacionado,
saldoInsoluto: pagosData.saldoInsoluto,
isrRetencionPago: pagosData.isrRetencion,
ivaTrasladoPago: pagosData.ivaTraslado,
ivaRetencionPago: pagosData.ivaRetencion,
iepsTrasladoPago: pagosData.iepsTraslado,
iepsRetencionPago: pagosData.iepsRetencion,
// Nómina
...nominaData,
conceptos: extractConceptos(comprobante),
xmlOriginal: xmlContent,
};
if (!cfdi.uuid) {
console.error('[Parser] CFDI sin UUID');
return null;
}
return cfdi;
} catch (error) {
console.error('[Parser Error]', error);
return null;
}
}
/**
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
* @param downloadType - 'emitidos' o 'recibidos'
*/
export function processPackage(zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed[] {
const xmlFiles = extractXmlsFromZip(zipBase64);
const cfdis: CfdiParsed[] = [];
for (const { content } of xmlFiles) {
const cfdi = parseXml(content, downloadType);
if (cfdi) {
cfdis.push(cfdi);
}
}
return cfdis;
}
/**
* Datos parseados de un registro de metadata del SAT
*/
interface CfdiMetadata {
uuid: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
rfcPac: string | null;
fechaEmision: Date;
fechaCertSat: Date | null;
fechaCancelacion: Date | null;
monto: number;
tipoComprobante: string;
status: string; // 'Vigente' | 'Cancelado'
type: 'EMITIDO' | 'RECIBIDO';
}
/**
* Extrae archivos CSV de un paquete ZIP de metadata en base64.
* Usa AdmZip directamente para evitar problemas de archivos temporales en Windows.
*/
function extractCsvsFromZip(zipBase64: string): string[] {
const zipBuffer = Buffer.from(zipBase64, 'base64');
const zip = new AdmZip(zipBuffer);
const entries = zip.getEntries();
const csvContents: string[] = [];
for (const entry of entries) {
const name = entry.entryName.toLowerCase();
if (name.endsWith('.csv') || name.endsWith('.txt')) {
csvContents.push(entry.getData().toString('utf-8'));
}
}
return csvContents;
}
/**
* Parsea una línea CSV respetando campos entrecomillados
*/
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === '~' && !inQuotes) {
fields.push(current.trim());
current = '';
} else {
current += ch;
}
}
fields.push(current.trim());
return fields;
}
/**
* Procesa un paquete de metadata del SAT (ZIP con CSV) y retorna los registros.
* Usa AdmZip directo en vez de MetadataPackageReader para compatibilidad Windows.
*/
export function processMetadataPackage(
zipBase64: string,
downloadType: 'emitidos' | 'recibidos' = 'emitidos'
): CfdiMetadata[] {
const csvContents = extractCsvsFromZip(zipBase64);
const results: CfdiMetadata[] = [];
const tipoMap: Record<string, string> = {
'Ingreso': 'I', 'Egreso': 'E', 'Traslado': 'T', 'Nómina': 'N', 'Nomina': 'N', 'Pago': 'P',
};
for (const csv of csvContents) {
const lines = csv.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) continue;
// Header line — SAT uses ~ as delimiter
const headers = parseCsvLine(lines[0]);
// Find column indices (case-insensitive)
const idx = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase());
const iUuid = idx('Uuid');
const iRfcEmisor = idx('RfcEmisor');
const iNombreEmisor = idx('NombreEmisor');
const iRfcReceptor = idx('RfcReceptor');
const iNombreReceptor = idx('NombreReceptor');
const iRfcPac = idx('RfcPac');
const iFechaEmision = idx('FechaEmision');
const iFechaCert = idx('FechaCertificacionSat');
const iFechaCancel = idx('FechaCancelacion');
const iMonto = idx('Monto');
const iEfecto = idx('EfectoComprobante');
const iEstatus = idx('Estatus');
// Fallback column names
const iEstado = iEstatus >= 0 ? iEstatus : idx('Estado');
if (iUuid < 0) continue; // No UUID column = invalid CSV
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
const uuid = (fields[iUuid] || '').trim();
if (!uuid) continue;
const estatus = (fields[iEstado] || 'Vigente').trim();
const fechaCancelStr = iFechaCancel >= 0 ? (fields[iFechaCancel] || '').trim() : '';
const fechaEmisionStr = iFechaEmision >= 0 ? (fields[iFechaEmision] || '').trim() : '';
const fechaCertStr = iFechaCert >= 0 ? (fields[iFechaCert] || '').trim() : '';
const efecto = iEfecto >= 0 ? (fields[iEfecto] || 'Ingreso').trim() : 'Ingreso';
results.push({
uuid: uuid.toUpperCase(),
rfcEmisor: iRfcEmisor >= 0 ? (fields[iRfcEmisor] || '').trim() : '',
nombreEmisor: iNombreEmisor >= 0 ? (fields[iNombreEmisor] || '').trim() : '',
rfcReceptor: iRfcReceptor >= 0 ? (fields[iRfcReceptor] || '').trim() : '',
nombreReceptor: iNombreReceptor >= 0 ? (fields[iNombreReceptor] || '').trim() : '',
rfcPac: iRfcPac >= 0 ? (fields[iRfcPac] || '').trim() || null : null,
fechaEmision: fechaEmisionStr ? parseCfdiDate(fechaEmisionStr) : new Date(),
fechaCertSat: fechaCertStr ? parseCfdiDate(fechaCertStr) : null,
fechaCancelacion: fechaCancelStr ? parseCfdiDate(fechaCancelStr) : null,
monto: parseFloat(iMonto >= 0 ? fields[iMonto] || '0' : '0') || 0,
tipoComprobante: tipoMap[efecto] || efecto.charAt(0) || 'I',
status: estatus === '0' || estatus.toLowerCase().includes('cancel') ? 'Cancelado' : 'Vigente',
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
});
}
}
return results;
}
/**
* Valida que un XML sea un CFDI válido
*/
export function isValidCfdi(xmlContent: string): boolean {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) return false;
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
if (!extractUuid(comprobante)) return false;
return true;
} catch {
return false;
}
}
export type { CfdiParsed, CfdiMetadata, ConceptoParsed, ExtractedXml };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import { prisma } from '../../config/database.js';
export interface SweepResult {
pendingFound: number;
runningFound: number;
pendingMarked: number;
runningMarked: number;
entries: Array<{
id: string;
tenantId: string;
kind: 'pending-stale' | 'running-stale';
ageHours: number;
}>;
}
/**
* Watchdog para jobs `sat_sync_jobs` stale.
*
* Categorías:
* 1. `pending` con `nextRetryAt` > pendingHours atrás. El cron horario
* `retryTimedOutJobs` normalmente los retoma, pero si no arranca
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
* lock para nuevos syncs del mismo (tenant, contribuyente).
*
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
* típico termina en <2h; si lleva >runningHours es casi seguro
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
*
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
* (volver a correrlo no reabre los ya-marcados-failed).
*
* - `apply=false` (default): dry-run, no toca BD.
* - `pendingHours`/`runningHours`: thresholds (default 12h / 4h).
*/
export async function sweepStaleSatJobs(params: {
apply: boolean;
pendingHours?: number;
runningHours?: number;
} = { apply: false }): Promise<SweepResult> {
const pendingHours = params.pendingHours ?? 12;
const runningHours = params.runningHours ?? 4;
const now = new Date();
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
const stalePending = await prisma.satSyncJob.findMany({
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
orderBy: { createdAt: 'asc' },
});
const staleRunning = await prisma.satSyncJob.findMany({
where: { status: 'running', startedAt: { lt: runningCutoff } },
orderBy: { createdAt: 'asc' },
});
const result: SweepResult = {
pendingFound: stalePending.length,
runningFound: staleRunning.length,
pendingMarked: 0,
runningMarked: 0,
entries: [],
};
for (const j of stalePending) {
const ageHours = Math.round((now.getTime() - (j.nextRetryAt ?? j.createdAt).getTime()) / 3_600_000);
result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'pending-stale', ageHours });
}
for (const j of staleRunning) {
const ageHours = Math.round((now.getTime() - (j.startedAt ?? j.createdAt).getTime()) / 3_600_000);
result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'running-stale', ageHours });
}
if (!params.apply) return result;
for (const j of stalePending) {
await prisma.satSyncJob.update({
where: { id: j.id },
data: {
status: 'failed',
completedAt: now,
errorMessage: `Abandoned by watchdog: pending with nextRetryAt ${j.nextRetryAt?.toISOString()} > ${pendingHours}h in the past. Retry cron didn't pick it up.`,
},
});
result.pendingMarked++;
}
for (const j of staleRunning) {
await prisma.satSyncJob.update({
where: { id: j.id },
data: {
status: 'failed',
completedAt: now,
errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
},
});
result.runningMarked++;
}
return result;
}