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

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

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

View 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());