fix: fechaPagoP pipe timestamp, admin redirects, despacho plans, CSF parsing

This commit is contained in:
Horux Dev
2026-04-28 22:24:30 +00:00
parent 3eb0f33f3b
commit 066ba7deda
10 changed files with 322 additions and 118 deletions

View File

@@ -41,7 +41,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
try {
await requireGlobalAdmin(req);
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
const { nombre, rfc, plan, verticalProfile, frequency, adminEmail, adminNombre, amount } = req.body;
if (!nombre || !rfc || !adminEmail || !adminNombre) {
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
@@ -51,8 +51,8 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
verticalProfile,
frequency,
adminEmail,
adminNombre,
amount: amount || 0,
@@ -69,14 +69,13 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
await requireGlobalAdmin(req);
const id = String(req.params.id);
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const { nombre, rfc, plan, verticalProfile, active } = req.body;
const tenant = await tenantsService.updateTenant(id, {
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
verticalProfile,
active,
});

View File

@@ -3,12 +3,13 @@ import { prisma } from '../config/database.js';
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
import { hasFielConfigured } from '../services/fiel.service.js';
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
import { consultarOpinion, consultarOpinionContribuyente, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
import { applyPendingChanges, expireTrials } from '../services/payment/subscription.service.js';
import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { consultarConstancia, consultarConstanciaContribuyente, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js';
import { isDespachoTenant } from '@horux/shared';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
@@ -216,6 +217,47 @@ async function runOpinionJob(): Promise<void> {
let skipped = 0;
for (const tenant of tenants) {
const isDespacho = isDespachoTenant(tenant.rfc);
if (isDespacho) {
// Modo despacho: iterar contribuyentes con FIEL
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query(`
SELECT c.entidad_id as id, c.rfc
FROM contribuyentes c
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
WHERE f.is_active = true
`);
if (contribuyentes.length === 0) {
skipped++;
continue;
}
for (const contrib of contribuyentes) {
try {
console.log(`[Opinion Cron] Consultando opinión para contribuyente ${contrib.rfc} (tenant ${tenant.rfc})...`);
await consultarOpinionContribuyente(pool, contrib.id);
success++;
} catch (err: any) {
console.error(`[Opinion Cron] Error para contribuyente ${contrib.rfc}:`, err.message);
failed++;
}
}
const deleted = await limpiarOpinionesAntiguas(pool);
if (deleted > 0) {
console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`);
}
} catch (error: any) {
console.error(`[Opinion Cron] Error despacho ${tenant.rfc}:`, error.message);
failed++;
}
continue;
}
// Modo legacy (Horux 360)
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) {
skipped++;
@@ -247,7 +289,7 @@ async function runCsfJob(): Promise<void> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
@@ -255,6 +297,42 @@ async function runCsfJob(): Promise<void> {
let skipped = 0;
for (const tenant of tenants) {
const isDespacho = isDespachoTenant(tenant.rfc);
if (isDespacho) {
// Modo despacho: iterar contribuyentes con FIEL
try {
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query(`
SELECT c.entidad_id as id, c.rfc
FROM contribuyentes c
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
WHERE f.is_active = true
`);
if (contribuyentes.length === 0) {
skipped++;
continue;
}
for (const contrib of contribuyentes) {
try {
console.log(`[CSF Cron] Consultando CSF para contribuyente ${contrib.rfc} (tenant ${tenant.rfc})...`);
await consultarConstanciaContribuyente(pool, contrib.id);
success++;
} catch (err: any) {
console.error(`[CSF Cron] Error para contribuyente ${contrib.rfc}:`, err.message);
failed++;
}
}
} catch (error: any) {
console.error(`[CSF Cron] Error despacho ${tenant.rfc}:`, error.message);
failed++;
}
continue;
}
// Modo legacy (Horux 360)
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) { skipped++; continue; }
try {

View File

@@ -299,8 +299,9 @@ export async function consultarConstanciaContribuyente(
function cleanDomField(val: string | undefined): string {
if (!val) return '';
// Remove embedded label prefixes like "Nombre de la Colonia: "
// Labels must be ordered from longest to shortest to avoid partial matches.
return val
.replace(/^.*(?:Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Nombre de la Entidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '')
.replace(/^.*(?:Nombre del Municipio o Demarcación Territorial|Nombre de la Entidad Federativa|Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '')
.trim();
}

View File

@@ -322,7 +322,7 @@ function extractPagos(comprobante: any): {
}
}
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
result.fechaPagoP = fechas.length > 0 ? fechas[0] : 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;

View File

@@ -22,6 +22,7 @@ const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
const MAX_RETRIES = 3; // Máximo de reintentos tras timeout
const RETRY_DELAY_HOURS = 6; // Horas entre reintentos
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
const SAT_DATE_SAFETY_MARGIN_DAYS = 7; // Margen de seguridad para evitar rechazo por límite de 6 años
interface SyncContext {
fielData: FielData;
@@ -666,7 +667,11 @@ async function processInitialSync(
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
// Calcular límite histórico respetando exactamente 6 años + margen de seguridad
const maxHistorical = new Date(ahora);
maxHistorical.setFullYear(maxHistorical.getFullYear() - YEARS_TO_SYNC);
maxHistorical.setDate(maxHistorical.getDate() + SAT_DATE_SAFETY_MARGIN_DAYS);
const inicioHistorico = customDateFrom || new Date(maxHistorical.getFullYear(), maxHistorical.getMonth(), 1);
const fechaFin = customDateTo || ahora;
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
@@ -983,13 +988,19 @@ export async function startSync(
}
const now = new Date();
// Calcular dateFrom por defecto respetando el límite de 6 años del SAT
const defaultDateFrom = new Date(now);
defaultDateFrom.setFullYear(defaultDateFrom.getFullYear() - YEARS_TO_SYNC);
defaultDateFrom.setDate(defaultDateFrom.getDate() + SAT_DATE_SAFETY_MARGIN_DAYS);
defaultDateFrom.setDate(1); // Truncar al primer día del mes
const job = await prisma.satSyncJob.create({
data: {
tenantId,
contribuyenteId: contribuyenteId || null,
type,
status: 'running',
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
dateFrom: dateFrom || defaultDateFrom,
dateTo: dateTo || now,
startedAt: now,
},
@@ -1009,7 +1020,7 @@ export async function startSync(
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id, dateFrom, dateTo);
await processInitialSync(ctx, job.id, job.dateFrom, job.dateTo);
} else if (type === 'incremental') {
await processIncrementalSync(ctx, job.id);
} else if (dateFrom && dateTo) {

View File

@@ -1,5 +1,5 @@
import { prisma, tenantDb } from '../config/database.js';
import { PLANS } from '@horux/shared';
import { PLANS, DESPACHO_PLANS } from '@horux/shared';
import { emailService } from './email/email.service.js';
import * as metabaseService from './metabase.service.js';
import { randomBytes } from 'crypto';
@@ -42,18 +42,47 @@ export async function getTenantById(id: string) {
export async function createTenant(data: {
nombre: string;
rfc: string;
plan?: 'starter' | 'business' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency?: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount: number;
}) {
const plan = data.plan || 'starter';
const planConfig = PLANS[plan];
const plan = data.plan || 'trial';
const despachoPlans = ['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud', 'custom'];
const isDespacho = despachoPlans.includes(plan);
let rfc = data.rfc.toUpperCase();
let cfdiLimit: number;
let usersLimit: number;
let dbMode: 'BYO' | 'MANAGED' | undefined;
let trialEndsAt: Date | undefined;
let timbresIncluidos = 0;
if (isDespacho) {
// Normalizar RFC con prefijo DESPACHO_
if (!rfc.startsWith('DESPACHO_')) {
rfc = `DESPACHO_${rfc}`;
}
const despachoConfig = (DESPACHO_PLANS as Record<string, { maxRfcs: number; maxUsers: number; dbMode: 'BYO' | 'MANAGED'; timbresIncluidosMes: number }>)[plan];
if (!despachoConfig) throw new Error(`Plan despacho desconocido: ${plan}`);
cfdiLimit = -1;
usersLimit = -1;
dbMode = despachoConfig.dbMode;
timbresIncluidos = despachoConfig.timbresIncluidosMes;
if (plan === 'trial') {
trialEndsAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
}
} else {
const planConfig = PLANS[plan as keyof typeof PLANS];
if (!planConfig) throw new Error(`Plan desconocido: ${plan}`);
cfdiLimit = planConfig.cfdiLimit;
usersLimit = planConfig.usersLimit;
}
// 1. Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.rfc);
const databaseName = await tenantDb.provisionDatabase(rfc);
// 1b. Register tenant database in Metabase (non-blocking, logs errors only)
metabaseService.registerDatabase({
@@ -65,11 +94,14 @@ export async function createTenant(data: {
const tenant = await prisma.tenant.create({
data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan,
rfc,
plan: plan as any,
databaseName,
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
usersLimit: data.usersLimit || planConfig.usersLimit,
cfdiLimit,
usersLimit,
...(data.verticalProfile && { verticalProfile: data.verticalProfile as any }),
...(dbMode && { dbMode: dbMode as any }),
...(trialEndsAt && { trialEndsAt }),
}
});
@@ -101,28 +133,48 @@ export async function createTenant(data: {
},
});
// 4. Create initial subscription
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan,
status: 'pending',
amount: data.amount,
frequency: 'monthly',
},
});
// 4. Create timbre subscription for despacho plans (if applicable)
if (isDespacho && timbresIncluidos > 0) {
const inicio = new Date();
const fin = new Date(inicio);
fin.setMonth(fin.getMonth() + 1);
fin.setDate(fin.getDate() - 1);
await prisma.timbreSuscripcion.create({
data: {
tenantId: tenant.id,
tipo: 'mensual',
timbresLimite: timbresIncluidos,
timbresUsados: 0,
periodoInicio: inicio,
periodoFin: fin,
},
});
}
// 5. Send welcome email to client (non-blocking)
// 5. Create subscription (only for paid plans)
if (!isDespacho || (plan !== 'trial' && plan !== 'custom')) {
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan: plan as any,
status: 'pending',
amount: data.amount,
frequency: data.frequency || 'monthly',
},
});
}
// 6. Send welcome email to client (non-blocking)
emailService.sendWelcome(data.adminEmail, {
nombre: data.adminNombre,
email: data.adminEmail,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
// 6. Send new client notification to admin with DB credentials
// 7. Send new client notification to admin with DB credentials
emailService.sendNewClientAdmin({
clienteNombre: data.nombre,
clienteRfc: data.rfc.toUpperCase(),
clienteRfc: rfc,
adminEmail: data.adminEmail,
adminNombre: data.adminNombre,
tempPassword,
@@ -265,9 +317,8 @@ export async function getMyTenantsDetailed(userId: string, onlyOwner = true) {
export async function updateTenant(id: string, data: {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
active?: boolean;
}) {
return prisma.tenant.update({
@@ -275,9 +326,8 @@ export async function updateTenant(id: string, data: {
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
...(data.plan && { plan: data.plan }),
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
...(data.plan && { plan: data.plan as any }),
...(data.verticalProfile && { verticalProfile: data.verticalProfile as any }),
...(data.active !== undefined && { active: data.active }),
},
select: {