Initial commit - Horux Despachos NL
This commit is contained in:
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
160
apps/api/src/services/sat/sat-auth.service.ts
Normal 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;
|
||||
}
|
||||
248
apps/api/src/services/sat/sat-client.service.ts
Normal file
248
apps/api/src/services/sat/sat-client.service.ts
Normal 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())}`;
|
||||
}
|
||||
94
apps/api/src/services/sat/sat-crypto.service.ts
Normal file
94
apps/api/src/services/sat/sat-crypto.service.ts
Normal 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 };
|
||||
}
|
||||
84
apps/api/src/services/sat/sat-csf-login.ts
Normal file
84
apps/api/src/services/sat/sat-csf-login.ts
Normal 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 };
|
||||
}
|
||||
246
apps/api/src/services/sat/sat-csf-parser.ts
Normal file
246
apps/api/src/services/sat/sat-csf-parser.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
121
apps/api/src/services/sat/sat-csf-scraper.ts
Normal file
121
apps/api/src/services/sat/sat-csf-scraper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import type { Credential } from '@nodecfdi/credentials/node';
|
||||
import type {
|
||||
SatDownloadRequestResponse,
|
||||
SatVerifyResponse,
|
||||
SatPackageResponse,
|
||||
CfdiSyncType
|
||||
} from '@horux/shared';
|
||||
|
||||
const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc';
|
||||
const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc';
|
||||
const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc';
|
||||
|
||||
type TipoSolicitud = 'CFDI' | 'Metadata';
|
||||
|
||||
interface RequestDownloadParams {
|
||||
credential: Credential;
|
||||
token: string;
|
||||
rfc: string;
|
||||
fechaInicio: Date;
|
||||
fechaFin: Date;
|
||||
tipoSolicitud: TipoSolicitud;
|
||||
tipoCfdi: CfdiSyncType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS)
|
||||
*/
|
||||
function formatSatDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el XML de solicitud de descarga
|
||||
*/
|
||||
function buildDownloadRequest(params: RequestDownloadParams): string {
|
||||
const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params;
|
||||
const uuid = randomUUID();
|
||||
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
// Construir el elemento de solicitud
|
||||
const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined;
|
||||
const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined;
|
||||
|
||||
const solicitudContent = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
|
||||
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
|
||||
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
|
||||
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
|
||||
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
|
||||
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
|
||||
|
||||
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
|
||||
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:SolicitaDescarga>
|
||||
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:SolicitaDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita la descarga de CFDIs al SAT
|
||||
*/
|
||||
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
|
||||
const soapRequest = buildDownloadRequest(params);
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_SOLICITUD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
|
||||
'Authorization': `WRAP access_token="${params.token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadRequestResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Request Error]', error);
|
||||
throw new Error(`Error al solicitar descarga: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de solicitud de descarga
|
||||
*/
|
||||
function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta inválida del SAT');
|
||||
}
|
||||
|
||||
return {
|
||||
idSolicitud: respuesta['@_IdSolicitud'] || '',
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de una solicitud de descarga
|
||||
*/
|
||||
export async function verifyRequest(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idSolicitud: string
|
||||
): Promise<SatVerifyResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
|
||||
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:VerificaSolicitudDescarga>
|
||||
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:VerificaSolicitudDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_VERIFICA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseVerifyResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error);
|
||||
throw new Error(`Error al verificar solicitud: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de verificación
|
||||
*/
|
||||
function parseVerifyResponse(responseXml: string): SatVerifyResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta de verificación inválida');
|
||||
}
|
||||
|
||||
// Extraer paquetes
|
||||
let paquetes: string[] = [];
|
||||
const paquetesNode = respuesta.IdsPaquetes;
|
||||
if (paquetesNode) {
|
||||
if (Array.isArray(paquetesNode)) {
|
||||
paquetes = paquetesNode;
|
||||
} else if (typeof paquetesNode === 'string') {
|
||||
paquetes = [paquetesNode];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10),
|
||||
codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '',
|
||||
numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10),
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
paquetes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga un paquete de CFDIs
|
||||
*/
|
||||
export async function downloadPackage(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idPaquete: string
|
||||
): Promise<SatPackageResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
|
||||
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:PeticionDescargaMasivaTercerosEntrada>
|
||||
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:peticionDescarga>
|
||||
</des:PeticionDescargaMasivaTercerosEntrada>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_DESCARGA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Package Error]', error);
|
||||
throw new Error(`Error al descargar paquete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de descarga de paquete
|
||||
*/
|
||||
function parseDownloadResponse(responseXml: string): SatPackageResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('No se pudo obtener el paquete');
|
||||
}
|
||||
|
||||
return {
|
||||
paquete: respuesta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estados de solicitud del SAT
|
||||
*/
|
||||
export const SAT_REQUEST_STATES = {
|
||||
ACCEPTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
COMPLETED: 3,
|
||||
ERROR: 4,
|
||||
REJECTED: 5,
|
||||
EXPIRED: 6,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está completa
|
||||
*/
|
||||
export function isRequestComplete(estadoSolicitud: number): boolean {
|
||||
return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud falló
|
||||
*/
|
||||
export function isRequestFailed(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ERROR ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.REJECTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está en progreso
|
||||
*/
|
||||
export function isRequestInProgress(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS
|
||||
);
|
||||
}
|
||||
92
apps/api/src/services/sat/sat-opinion-login.ts
Normal file
92
apps/api/src/services/sat/sat-opinion-login.ts
Normal 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;
|
||||
}
|
||||
76
apps/api/src/services/sat/sat-opinion-parser.ts
Normal file
76
apps/api/src/services/sat/sat-opinion-parser.ts
Normal 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';
|
||||
}
|
||||
84
apps/api/src/services/sat/sat-opinion-scraper.ts
Normal file
84
apps/api/src/services/sat/sat-opinion-scraper.ts
Normal 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');
|
||||
}
|
||||
735
apps/api/src/services/sat/sat-parser.service.ts
Normal file
735
apps/api/src/services/sat/sat-parser.service.ts
Normal 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 };
|
||||
1463
apps/api/src/services/sat/sat.service.ts
Normal file
1463
apps/api/src/services/sat/sat.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
apps/api/src/services/sat/sweep-stale-jobs.service.ts
Normal file
98
apps/api/src/services/sat/sweep-stale-jobs.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user