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:
40
apps/api/scripts/export-xmls-roem.ts
Normal file
40
apps/api/scripts/export-xmls-roem.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Exporta todos los XMLs existentes de ROEM691011EZ4 al filesystem
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const BASE_PATH = '/var/horux/xml/ROEM691011EZ4';
|
||||
const DB_URL = process.env.DATABASE_URL!.replace(/\/[^/?]+(\?.*)?$/, '/horux_roem691011ez4$1');
|
||||
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: DB_URL });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid_fiscal, fecha_emision, xml_original FROM cfdis WHERE xml_original IS NOT NULL`
|
||||
);
|
||||
|
||||
console.log(`Exportando ${rows.length} XMLs...`);
|
||||
let count = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const fecha = new Date(row.fecha_emision);
|
||||
const year = fecha.getFullYear().toString();
|
||||
const month = (fecha.getMonth() + 1).toString().padStart(2, '0');
|
||||
const dir = join(BASE_PATH, year, month);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${row.uuid_fiscal}.xml`), row.xml_original, 'utf-8');
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`${count} XMLs exportados a ${BASE_PATH}`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
58
apps/api/scripts/send-welcome-aaron.ts
Normal file
58
apps/api/scripts/send-welcome-aaron.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
async function main() {
|
||||
const transporter = createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email
|
||||
const { welcomeEmail } = await import('../src/services/email/templates/welcome.js');
|
||||
const html = welcomeEmail({
|
||||
nombre: 'Aaron Ahumada',
|
||||
email: 'aaron.ahumada.zepeda@gmail.com',
|
||||
tempPassword: '(la que elegiste al registrarte)',
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: 'aaron.ahumada.zepeda@gmail.com',
|
||||
subject: 'Bienvenido a Horux360',
|
||||
html,
|
||||
text: html.replace(/<[^>]*>/g, ''),
|
||||
});
|
||||
console.log('Welcome email sent to aaron.ahumada.zepeda@gmail.com');
|
||||
|
||||
// Send admin notification
|
||||
const { newClientAdminEmail } = await import('../src/services/email/templates/new-client-admin.js');
|
||||
const adminHtml = newClientAdminEmail({
|
||||
clienteNombre: 'AARON AHUMADA ZEPEDA',
|
||||
clienteRfc: 'AUZA640701TI9',
|
||||
adminEmail: 'aaron.ahumada.zepeda@gmail.com',
|
||||
adminNombre: 'Aaron Ahumada',
|
||||
tempPassword: '(elegida por el usuario)',
|
||||
databaseName: 'horux_auza640701ti9',
|
||||
plan: 'starter',
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: 'Nuevo cliente: AARON AHUMADA ZEPEDA (AUZA640701TI9)',
|
||||
html: adminHtml,
|
||||
text: adminHtml.replace(/<[^>]*>/g, ''),
|
||||
});
|
||||
console.log('Admin notification sent to', process.env.ADMIN_EMAIL);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
194
apps/api/scripts/setup-horux360-tenant.ts
Normal file
194
apps/api/scripts/setup-horux360-tenant.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Creates the Horux 360 tenant (HTS240708LJA), provisions its database,
|
||||
* moves Carlos to it, and links the existing FIEL credentials.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const RFC = 'HTS240708LJA';
|
||||
const TENANT_NAME = 'Horux 360';
|
||||
const DATABASE_NAME = `horux_${RFC.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
const CARLOS_EMAIL = 'carlos@horuxfin.com';
|
||||
const FIEL_PATH = '/var/horux/fiel/HTS240708LJA';
|
||||
|
||||
async function main() {
|
||||
// 1. Create the tenant
|
||||
console.log('1. Creating tenant...');
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: TENANT_NAME,
|
||||
rfc: RFC,
|
||||
plan: 'enterprise',
|
||||
databaseName: DATABASE_NAME,
|
||||
cfdiLimit: -1, // unlimited
|
||||
usersLimit: 10,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
console.log(` Tenant created: ${tenant.id} (${tenant.nombre})`);
|
||||
|
||||
// 2. Provision database
|
||||
console.log('2. Provisioning database...');
|
||||
const adminUrl = process.env.DATABASE_URL!.replace(/\/[^/?]+(\?.*)?$/, '/postgres$1');
|
||||
const adminPool = new Pool({ connectionString: adminUrl });
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${DATABASE_NAME}"`);
|
||||
console.log(` Database created: ${DATABASE_NAME}`);
|
||||
|
||||
// Create tenant tables
|
||||
const tenantPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL!.replace(/\/[^/?]+(\?.*)?$/, `/${DATABASE_NAME}$1`),
|
||||
});
|
||||
|
||||
await tenantPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cfdis (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid VARCHAR(36) UNIQUE,
|
||||
tipo VARCHAR(10) NOT NULL CHECK (tipo IN ('ingreso', 'egreso')),
|
||||
estado VARCHAR(20) NOT NULL DEFAULT 'vigente',
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
emisor_rfc VARCHAR(13) NOT NULL,
|
||||
emisor_nombre VARCHAR(300),
|
||||
receptor_rfc VARCHAR(13) NOT NULL,
|
||||
receptor_nombre VARCHAR(300),
|
||||
subtotal DECIMAL(15,2) DEFAULT 0,
|
||||
iva DECIMAL(15,2) DEFAULT 0,
|
||||
isr DECIMAL(15,2) DEFAULT 0,
|
||||
total DECIMAL(15,2) NOT NULL,
|
||||
moneda VARCHAR(3) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
forma_pago VARCHAR(50),
|
||||
metodo_pago VARCHAR(5),
|
||||
uso_cfdi VARCHAR(10),
|
||||
xml_content TEXT,
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iva_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INT NOT NULL,
|
||||
mes INT NOT NULL,
|
||||
trasladado DECIMAL(15,2) DEFAULT 0,
|
||||
acreditable DECIMAL(15,2) DEFAULT 0,
|
||||
resultado DECIMAL(15,2) DEFAULT 0,
|
||||
acumulado DECIMAL(15,2) DEFAULT 0,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS isr_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INT NOT NULL,
|
||||
mes INT NOT NULL,
|
||||
ingresos DECIMAL(15,2) DEFAULT 0,
|
||||
deducciones DECIMAL(15,2) DEFAULT 0,
|
||||
base_gravable DECIMAL(15,2) DEFAULT 0,
|
||||
isr_causado DECIMAL(15,2) DEFAULT 0,
|
||||
pagos_provisionales DECIMAL(15,2) DEFAULT 0,
|
||||
isr_por_pagar DECIMAL(15,2) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alertas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tipo VARCHAR(30) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT,
|
||||
prioridad VARCHAR(10) DEFAULT 'media',
|
||||
fecha_vencimiento DATE,
|
||||
leida BOOLEAN DEFAULT false,
|
||||
resuelta BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendario_fiscal (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
fecha DATE NOT NULL,
|
||||
tipo VARCHAR(30) DEFAULT 'obligacion',
|
||||
completado BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha ON cfdis(fecha_emision);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_emisor ON cfdis(emisor_rfc);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_receptor ON cfdis(receptor_rfc);
|
||||
`);
|
||||
console.log(' Tenant tables created');
|
||||
|
||||
await tenantPool.end();
|
||||
await adminPool.end();
|
||||
|
||||
// 3. Move Carlos to new tenant
|
||||
console.log('3. Moving Carlos to new tenant...');
|
||||
const carlos = await prisma.user.update({
|
||||
where: { email: CARLOS_EMAIL },
|
||||
data: { tenantId: tenant.id },
|
||||
});
|
||||
console.log(` Carlos (${carlos.email}) moved to ${TENANT_NAME}`);
|
||||
|
||||
// 4. Link FIEL credentials
|
||||
console.log('4. Linking FIEL credentials...');
|
||||
const cerEnc = readFileSync(resolve(FIEL_PATH, 'certificate.cer.enc'));
|
||||
const cerIv = readFileSync(resolve(FIEL_PATH, 'certificate.cer.iv'));
|
||||
const cerTag = readFileSync(resolve(FIEL_PATH, 'certificate.cer.tag'));
|
||||
const keyEnc = readFileSync(resolve(FIEL_PATH, 'private_key.key.enc'));
|
||||
const keyIv = readFileSync(resolve(FIEL_PATH, 'private_key.key.iv'));
|
||||
const keyTag = readFileSync(resolve(FIEL_PATH, 'private_key.key.tag'));
|
||||
|
||||
// Read metadata for the password (encrypted in metadata)
|
||||
// The password is stored separately in the DB, not in the filesystem metadata
|
||||
// We need to read it from the encrypted files
|
||||
// Actually, looking at fiel.service.ts, the password is stored as keyPasswordEncrypted
|
||||
// in the DB. Since this FIEL was only stored to filesystem, we'll need to get
|
||||
// the encrypted password from the original upload flow.
|
||||
//
|
||||
// For now, let's create the fiel_credentials record with what we have from filesystem.
|
||||
// The password encryption components need to come from the original upload.
|
||||
//
|
||||
// WORKAROUND: Since the FIEL exists on filesystem but not in DB, and we have
|
||||
// the decrypted files available, we should re-upload via the API.
|
||||
// But since we have direct filesystem access, let's check if there's a password file.
|
||||
|
||||
console.log(' NOTE: FIEL files exist on filesystem.');
|
||||
console.log(' The encrypted files are linked but the password needs to be re-uploaded via the app.');
|
||||
console.log(' Carlos should re-upload the FIEL through the web interface at /configuracion/sat');
|
||||
|
||||
// 5. Create subscription (enterprise, authorized)
|
||||
console.log('5. Creating subscription...');
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
plan: 'enterprise',
|
||||
status: 'authorized',
|
||||
currentPeriodStart: new Date(),
|
||||
currentPeriodEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
console.log(' Subscription created (enterprise, 1 year)');
|
||||
|
||||
console.log('\n=== DONE ===');
|
||||
console.log(`Tenant: ${TENANT_NAME} (${RFC})`);
|
||||
console.log(`Database: ${DATABASE_NAME}`);
|
||||
console.log(`Carlos: ${CARLOS_EMAIL} → ${TENANT_NAME}`);
|
||||
console.log(`Ivan: ivan@horuxfin.com → Consultoria Alcaraz Salazar (unchanged)`);
|
||||
console.log('\nNOTE: Carlos needs to re-upload the FIEL at /configuracion/sat');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user