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:
Horux Dev
2026-05-09 21:56:42 +00:00
parent b00b677c54
commit 9f11a0ba39
70 changed files with 2801 additions and 609 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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,
});
}

View File

@@ -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++;