feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos
Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages
This commit is contained in:
@@ -19,7 +19,13 @@ export async function loginSatCsf(
|
||||
keyPath: string,
|
||||
password: string,
|
||||
): Promise<CsfLoginSession> {
|
||||
const context = await browser.newContext({ acceptDownloads: true });
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(60_000);
|
||||
|
||||
@@ -66,12 +72,34 @@ export async function loginSatCsf(
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
|
||||
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
|
||||
try {
|
||||
await loginPage.waitForFunction(
|
||||
() => {
|
||||
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
const html = await loginPage.content();
|
||||
const { writeFileSync, mkdirSync } = await import('node:fs');
|
||||
const debugDir = '/tmp/horux-csf-debug';
|
||||
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
|
||||
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Password + Enviar
|
||||
await loginPage.locator('input[type="password"]').first().fill(password);
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
||||
|
||||
// Esperar a que salga del dominio de login
|
||||
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
|
||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||
await loginPage.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
|
||||
@@ -85,12 +85,29 @@ 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+|$)`,
|
||||
`(${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();
|
||||
let value = match[2].replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Defensa: el SAT a veces pone etiquetas consecutivas sin valor intermedio
|
||||
// (ej. "Número Interior:\nNombre de la Colonia: X"). El regex lazy captura
|
||||
// de más y el valor termina incluyendo el nombre de la siguiente etiqueta.
|
||||
// Limpiamos cualquier prefijo de otra etiqueta del SAT que haya quedado al
|
||||
// inicio del valor.
|
||||
for (const otherLabel of LABELS) {
|
||||
if (otherLabel === label) continue;
|
||||
const prefix = otherLabel + ':';
|
||||
const lowerValue = value.toLowerCase();
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
if (lowerValue.startsWith(lowerPrefix)) {
|
||||
value = value.slice(prefix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.has(label)) result.set(label, value);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
||||
const content = entry.getData().toString('utf-8');
|
||||
let content = entry.getData().toString('utf-8');
|
||||
// Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve
|
||||
// result.Comprobante = undefined, dejando el CFDI sin parsear.
|
||||
if (content.charCodeAt(0) === 0xFEFF) {
|
||||
content = content.slice(1);
|
||||
}
|
||||
xmlFiles.push({
|
||||
filename: entry.entryName,
|
||||
content,
|
||||
@@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
*/
|
||||
function parseCfdiDate(str: string | null | undefined): Date {
|
||||
if (!str) return new Date(0);
|
||||
const s = String(str).trim();
|
||||
let s = String(str).trim();
|
||||
if (!s) return new Date(0);
|
||||
// Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en
|
||||
// FechaTimbrado duplicado). Tomamos solo la primera fecha válida.
|
||||
if (s.includes('|')) {
|
||||
s = s.split('|')[0].trim();
|
||||
}
|
||||
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
||||
return new Date(hasTz ? s : s + 'Z');
|
||||
}
|
||||
@@ -155,18 +165,28 @@ function pf(val: any): number {
|
||||
return parseFloat(val || '0') || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function getFirstTimbre(comprobante: any): any {
|
||||
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
||||
if (!timbre) return null;
|
||||
return Array.isArray(timbre) ? timbre[0] : timbre;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function extractUuid(comprobante: any): string {
|
||||
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
|
||||
const timbre = getFirstTimbre(comprobante);
|
||||
return timbre?.['@_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;
|
||||
const timbre = getFirstTimbre(comprobante);
|
||||
if (!timbre) return { fechaCertSat: null, pac: null };
|
||||
|
||||
return {
|
||||
@@ -322,7 +342,7 @@ function extractPagos(comprobante: any): {
|
||||
}
|
||||
}
|
||||
|
||||
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
|
||||
result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : 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;
|
||||
@@ -370,9 +390,9 @@ function extractNomina(comprobante: any): {
|
||||
const nomina = complemento.Nomina;
|
||||
if (!nomina) return result;
|
||||
|
||||
result.fechaPago = nomina['@_FechaPago'] || null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
|
||||
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
|
||||
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
||||
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
||||
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
||||
|
||||
@@ -547,9 +547,35 @@ async function requestAndDownload(
|
||||
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
|
||||
const jobRow = await prisma.satSyncJob.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { satRequestIds: true },
|
||||
select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true },
|
||||
});
|
||||
const existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||
|
||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
||||
if (!existingMap[kindKey]) {
|
||||
const previousJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: jobRow?.tenantId,
|
||||
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||
id: { not: jobId },
|
||||
dateFrom: jobRow?.dateFrom,
|
||||
dateTo: jobRow?.dateTo,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { satRequestIds: true },
|
||||
});
|
||||
if (previousJob?.satRequestIds) {
|
||||
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||
if (prevMap[kindKey]) {
|
||||
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||
// Copiar al job actual para futuros usos
|
||||
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let requestId: string | null = existingMap[kindKey] || null;
|
||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||
|
||||
@@ -651,7 +677,8 @@ async function processDateRange(
|
||||
jobId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType
|
||||
tipoCfdi: CfdiSyncType,
|
||||
skipJobUpdate = false
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
@@ -678,12 +705,14 @@ async function processDateRange(
|
||||
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
if (!skipJobUpdate) {
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
found: totalFound,
|
||||
@@ -787,7 +816,9 @@ async function processInitialSync(
|
||||
customDateTo?: Date
|
||||
): Promise<void> {
|
||||
const ahora = new Date();
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
|
||||
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
|
||||
const fechaFin = customDateTo || ahora;
|
||||
|
||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||
@@ -802,13 +833,29 @@ async function processInitialSync(
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk
|
||||
let completedSteps = 0;
|
||||
|
||||
// Helper para actualizar progreso acumulado
|
||||
async function reportProgress() {
|
||||
completedSteps++;
|
||||
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent,
|
||||
});
|
||||
}
|
||||
|
||||
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
|
||||
for (let i = 0; i < xmlChunks.length; i++) {
|
||||
const { start, end } = xmlChunks[i];
|
||||
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
||||
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos');
|
||||
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
|
||||
totalFound += emitidos.found;
|
||||
totalDownloaded += emitidos.downloaded;
|
||||
totalInserted += emitidos.inserted;
|
||||
@@ -816,9 +863,10 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos');
|
||||
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
|
||||
totalFound += recibidos.found;
|
||||
totalDownloaded += recibidos.downloaded;
|
||||
totalInserted += recibidos.inserted;
|
||||
@@ -826,6 +874,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
@@ -842,6 +891,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
try {
|
||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
|
||||
@@ -850,6 +900,7 @@ async function processInitialSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
||||
}
|
||||
await reportProgress();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
@@ -859,6 +910,7 @@ async function processInitialSync(
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent: 100,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ export interface SweepResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
initial: 8,
|
||||
daily: 4,
|
||||
incremental: 2,
|
||||
custom: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Watchdog para jobs `sat_sync_jobs` stale.
|
||||
*
|
||||
@@ -22,35 +29,45 @@ export interface SweepResult {
|
||||
* (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ó.
|
||||
* 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren
|
||||
* por tipo: initial (8h) porque un bootstrap de 6 años puede tardar
|
||||
* varias horas; daily (4h); incremental (2h) porque es ventana corta.
|
||||
* Si lleva >threshold es casi seguro huérfano de un proceso que murió.
|
||||
*
|
||||
* 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).
|
||||
* - `pendingHours`: threshold pending (default 12h).
|
||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
|
||||
* - `runningHoursByType`: override por tipo de sync.
|
||||
*/
|
||||
export async function sweepStaleSatJobs(params: {
|
||||
apply: boolean;
|
||||
pendingHours?: number;
|
||||
runningHours?: number;
|
||||
runningHoursByType?: Record<string, number>;
|
||||
} = { apply: false }): Promise<SweepResult> {
|
||||
const pendingHours = params.pendingHours ?? 12;
|
||||
const runningHours = params.runningHours ?? 4;
|
||||
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||
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 } },
|
||||
|
||||
// running: evaluar por tipo usando thresholds distintos
|
||||
const allRunning = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'running' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const staleRunning = allRunning.filter(j => {
|
||||
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
|
||||
const cutoff = new Date(now.getTime() - thresholdHours * 3600 * 1000);
|
||||
return (j.startedAt ?? j.createdAt) < cutoff;
|
||||
});
|
||||
|
||||
const result: SweepResult = {
|
||||
pendingFound: stalePending.length,
|
||||
@@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: {
|
||||
result.pendingMarked++;
|
||||
}
|
||||
for (const j of staleRunning) {
|
||||
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
|
||||
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.`,
|
||||
errorMessage: `Abandoned by watchdog: running ${j.type} with startedAt ${j.startedAt?.toISOString()} > ${thresholdHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
|
||||
},
|
||||
});
|
||||
result.runningMarked++;
|
||||
|
||||
Reference in New Issue
Block a user