feat: SAT sync improvements, XML export, and operational fixes

SAT sync enhancements:
- Filter active (vigente) CFDIs only via DocumentStatus to avoid SAT
  rejecting recibidos with "No se permite descarga de XML cancelados"
- Reclassify CFDIs at save time: tipo='ingreso' received by tenant
  becomes 'egreso' based on RFC (emisor vs receptor)
- Fix pool cleanup bug during long syncs: refresh getPool() on each
  saveCfdis call instead of holding stale reference for 45+ minutes
- Add X-View-Tenant support to SAT controller via viewingTenantId
- Add tenantMiddleware to SAT routes for global admin impersonation

Cron jobs:
- Add separate every-6-hours schedule for specific RFCs
- ROEM691011EZ4 configured for frequent sync (00, 06, 12, 18 MX time)

XML filesystem export:
- Write .xml files to /var/horux/xml/<RFC>/YYYY/MM/UUID.xml
- Activated per-RFC via XML_EXPORT_RFCS allowlist
- Organized by year/month for browsability

Auth improvements:
- Send welcome + admin-notification emails on /auth/register
  (previously only /tenants createTenant flow sent emails)
- Set role='contador' for self-registered users (not admin) to prevent
  new tenants from accessing cross-tenant data

Infrastructure:
- Set express trust proxy=1 to accept X-Forwarded-For from Nginx
  (fixes ERR_ERL_UNEXPECTED_X_FORWARDED_FOR from rate limiter)

Operational scripts:
- setup-horux360-tenant.ts: Provision Horux 360 tenant manually
- send-welcome-aaron.ts: Resend welcome email for Aaron (registered
  before welcome-on-register was added)
- export-xmls-roem.ts: Backfill filesystem XMLs from DB for ROEM

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-04-14 00:35:14 +00:00
parent 351b14a78c
commit 706d9694f1
10 changed files with 432 additions and 16 deletions

View File

@@ -20,6 +20,9 @@ import { subscriptionRoutes } from './routes/subscription.routes.js';
const app: Express = express();
// Trust Nginx reverse proxy (for correct IP in rate limiting)
app.set('trust proxy', 1);
// Security
app.use(helmet());
app.use(cors({

View File

@@ -14,7 +14,7 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
*/
export async function start(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
const jobId = await startSync(
@@ -45,7 +45,7 @@ export async function start(req: Request, res: Response): Promise<void> {
*/
export async function status(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const syncStatus = await getSyncStatus(tenantId);
res.json(syncStatus);
} catch (error: any) {
@@ -59,7 +59,7 @@ export async function status(req: Request, res: Response): Promise<void> {
*/
export async function history(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
@@ -80,7 +80,7 @@ export async function history(req: Request, res: Response): Promise<void> {
*/
export async function jobDetail(req: Request, res: Response): Promise<void> {
try {
const tenantId = req.user!.tenantId;
const tenantId = req.viewingTenantId || req.user!.tenantId;
const { id } = req.params;
const { jobs } = await getSyncHistory(tenantId, 1, 100);
const job = jobs.find(j => j.id === id);

View File

@@ -4,6 +4,8 @@ import { startSync, getSyncStatus } from '../services/sat/sat.service.js';
import { hasFielConfigured } from '../services/fiel.service.js';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const FREQUENT_SYNC_SCHEDULE = '0 */6 * * *'; // Cada 6 horas (00:00, 06:00, 12:00, 18:00)
const FREQUENT_SYNC_RFCS = ['ROEM691011EZ4']; // Tenants con sync frecuente
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
let isRunning = false;
@@ -108,10 +110,40 @@ async function runSyncJob(): Promise<void> {
}
}
/**
* Ejecuta sync frecuente solo para tenants en FREQUENT_SYNC_RFCS
*/
async function runFrequentSyncJob(): Promise<void> {
console.log('[SAT Cron] Iniciando sync frecuente');
try {
const tenants = await prisma.tenant.findMany({
where: {
active: true,
rfc: { in: FREQUENT_SYNC_RFCS },
},
select: { id: true, rfc: true },
});
for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id);
if (hasFiel) {
console.log(`[SAT Cron] Sync frecuente para ${tenant.rfc}`);
await syncTenant(tenant.id);
}
}
console.log('[SAT Cron] Sync frecuente completado');
} catch (error: any) {
console.error('[SAT Cron] Error en sync frecuente:', error.message);
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let frequentTask: ReturnType<typeof cron.schedule> | null = null;
/**
* Inicia el job programado
* Inicia los jobs programados
*/
export function startSatSyncJob(): void {
if (scheduledTask) {
@@ -119,7 +151,7 @@ export function startSatSyncJob(): void {
return;
}
// Validar expresión cron
// Job diario para todos los tenants
if (!cron.validate(SYNC_CRON_SCHEDULE)) {
console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE);
return;
@@ -129,7 +161,16 @@ export function startSatSyncJob(): void {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Job diario programado: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
// Job frecuente para tenants específicos
if (FREQUENT_SYNC_RFCS.length > 0) {
frequentTask = cron.schedule(FREQUENT_SYNC_SCHEDULE, runFrequentSyncJob, {
timezone: 'America/Mexico_City',
});
console.log(`[SAT Cron] Job frecuente programado: ${FREQUENT_SYNC_SCHEDULE} para ${FREQUENT_SYNC_RFCS.join(', ')}`);
}
}
/**
@@ -139,8 +180,12 @@ export function stopSatSyncJob(): void {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
console.log('[SAT Cron] Job detenido');
}
if (frequentTask) {
frequentTask.stop();
frequentTask = null;
}
console.log('[SAT Cron] Jobs detenidos');
}
/**
@@ -153,10 +198,15 @@ export async function runSatSyncJobManually(): Promise<void> {
/**
* Obtiene información del próximo job programado
*/
export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } {
export function getJobInfo() {
return {
scheduled: scheduledTask !== null,
expression: SYNC_CRON_SCHEDULE,
timezone: 'America/Mexico_City',
frequentSync: {
scheduled: frequentTask !== null,
expression: FREQUENT_SYNC_SCHEDULE,
rfcs: FREQUENT_SYNC_RFCS,
},
};
}

View File

@@ -1,11 +1,13 @@
import { Router, type IRouter } from 'express';
import * as satController from '../controllers/sat.controller.js';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
const router: IRouter = Router();
// Todas las rutas requieren autenticación
// Todas las rutas requieren autenticación + tenant resolution
router.use(authenticate);
router.use(tenantMiddleware);
// POST /api/sat/sync - Iniciar sincronización manual
router.post('/sync', satController.start);

View File

@@ -2,6 +2,8 @@ import { prisma, tenantDb } from '../config/database.js';
import { hashPassword, verifyPassword } from '../utils/password.js';
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
import { AppError } from '../middlewares/error.middleware.js';
import { emailService } from './email/email.service.js';
import { env } from '../config/env.js';
import { PLANS } from '@horux/shared';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
@@ -43,7 +45,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
email: data.usuario.email.toLowerCase(),
passwordHash,
nombre: data.usuario.nombre,
role: 'admin',
role: 'contador',
},
});
@@ -65,6 +67,24 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
},
});
// Send welcome email to new user
emailService.sendWelcome(user.email, {
nombre: data.usuario.nombre,
email: user.email,
tempPassword: '(la que elegiste al registrarte)',
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
// Notify admin of new registration
emailService.sendNewClientAdmin({
clienteNombre: data.empresa.nombre,
clienteRfc: tenant.rfc,
adminEmail: user.email,
adminNombre: data.usuario.nombre,
tempPassword: '(elegida por el usuario)',
databaseName,
plan: tenant.plan,
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
return {
accessToken,
refreshToken,

View File

@@ -7,6 +7,7 @@ import {
DateTimePeriod,
DownloadType,
RequestType,
DocumentStatus,
ServiceEndpoints,
} from '@nodecfdi/sat-ws-descarga-masiva';
@@ -79,7 +80,8 @@ export async function querySat(
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
const parameters = QueryParameters.create(period, downloadType, reqType);
const parameters = QueryParameters.create(period, downloadType, reqType)
.withDocumentStatus(new DocumentStatus('active'));
const result = await service.query(parameters);
if (!result.getStatus().isAccepted()) {

View File

@@ -11,6 +11,8 @@ import { processPackage, type CfdiParsed } from './sat-parser.service.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
import type { Pool } from 'pg';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
const POLL_INTERVAL_MS = 30000; // 30 segundos
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
@@ -21,7 +23,39 @@ interface SyncContext {
service: Service;
rfc: string;
tenantId: string;
pool: Pool;
databaseName: string;
}
// RFCs que requieren guardar XMLs en filesystem
const XML_EXPORT_RFCS = ['ROEM691011EZ4'];
const XML_EXPORT_BASE_PATH = '/var/horux/xml';
/**
* Gets a fresh pool reference, refreshing the lastAccess timer
* to prevent cleanup during long-running sync operations.
*/
function getPool(ctx: SyncContext): Pool {
return tenantDb.getPool(ctx.tenantId, ctx.databaseName);
}
/**
* Guarda el XML de un CFDI en el filesystem si el RFC lo requiere
*/
function saveXmlToFilesystem(rfc: string, cfdi: CfdiParsed): void {
if (!XML_EXPORT_RFCS.includes(rfc)) return;
if (!cfdi.xmlOriginal || !cfdi.uuidFiscal) return;
try {
const fecha = cfdi.fechaEmision;
const year = fecha.getFullYear().toString();
const month = (fecha.getMonth() + 1).toString().padStart(2, '0');
const dir = join(XML_EXPORT_BASE_PATH, rfc, year, month);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${cfdi.uuidFiscal}.xml`), cfdi.xmlOriginal, 'utf-8');
} catch (error) {
console.error(`[SAT] Error guardando XML ${cfdi.uuidFiscal} en filesystem:`, error);
}
}
/**
@@ -55,14 +89,24 @@ async function updateJobProgress(
* Guarda los CFDIs en la base de datos del tenant
*/
async function saveCfdis(
pool: Pool,
ctx: SyncContext,
cfdis: CfdiParsed[],
jobId: string
): Promise<{ inserted: number; updated: number }> {
const pool = getPool(ctx);
let inserted = 0;
let updated = 0;
for (const cfdi of cfdis) {
// Reclasificar tipo basado en si el tenant es emisor o receptor
if (cfdi.rfcReceptor === ctx.rfc) {
// Tenant recibió esta factura → gasto para él
if (cfdi.tipo === 'ingreso') cfdi.tipo = 'egreso';
} else if (cfdi.rfcEmisor === ctx.rfc) {
// Tenant emitió esta factura → se mantiene como ingreso
if (cfdi.tipo === 'egreso') cfdi.tipo = 'egreso'; // nota de crédito emitida
}
try {
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
@@ -168,6 +212,9 @@ async function saveCfdis(
);
inserted++;
}
// Guardar XML en filesystem si aplica
saveXmlToFilesystem(ctx.rfc, cfdi);
} catch (error) {
console.error(`[SAT] Error guardando CFDI ${cfdi.uuidFiscal}:`, error);
}
@@ -252,7 +299,7 @@ async function processDateRange(
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
const { inserted, updated } = await saveCfdis(ctx.pool, cfdis, jobId);
const { inserted, updated } = await saveCfdis(ctx, cfdis, jobId);
totalInserted += inserted;
totalUpdated += updated;
@@ -429,7 +476,7 @@ export async function startSync(
service,
rfc: decryptedFiel.rfc,
tenantId,
pool: tenantDb.getPool(tenantId, tenant.databaseName),
databaseName: tenant.databaseName,
};
// Ejecutar sincronización en background