fix: fechaPagoP pipe timestamp, admin redirects, despacho plans, CSF parsing
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user