diff --git a/apps/api/src/controllers/tenants.controller.ts b/apps/api/src/controllers/tenants.controller.ts index 5e68579..047aba8 100644 --- a/apps/api/src/controllers/tenants.controller.ts +++ b/apps/api/src/controllers/tenants.controller.ts @@ -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, }); diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts index 0ad94b5..489b524 100644 --- a/apps/api/src/jobs/sat-sync.job.ts +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -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 { 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 { 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 { 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 { diff --git a/apps/api/src/services/constancia.service.ts b/apps/api/src/services/constancia.service.ts index d49fbed..115f8b8 100644 --- a/apps/api/src/services/constancia.service.ts +++ b/apps/api/src/services/constancia.service.ts @@ -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(); } diff --git a/apps/api/src/services/sat/sat-parser.service.ts b/apps/api/src/services/sat/sat-parser.service.ts index b7d1056..5f3ac5f 100644 --- a/apps/api/src/services/sat/sat-parser.service.ts +++ b/apps/api/src/services/sat/sat-parser.service.ts @@ -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; diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index 567aeb0..083a174 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -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 { 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) { diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts index 583700f..3174180 100644 --- a/apps/api/src/services/tenants.service.ts +++ b/apps/api/src/services/tenants.service.ts @@ -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)[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: { diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 3c880c8..c2bb837 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -7,7 +7,7 @@ import Image from 'next/image'; import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; import { login } from '@/lib/api/auth'; import { useAuthStore } from '@/stores/auth-store'; -import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared'; + export default function LoginPage() { const router = useRouter(); @@ -30,13 +30,7 @@ export default function LoginPage() { setUser(response.user); const userRole = response.user?.role; - // Admin global aterriza directo en `/clientes` — su home natural es la - // gestión de tenants, no el dashboard operativo del despacho. - const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles; - const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles); - if (isGlobalAdmin) { - router.push('/clientes'); - } else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') { + if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') { // Clients and roles without onboarding go straight to dashboard router.push('/dashboard'); } else { diff --git a/apps/web/app/(dashboard)/clientes/page.tsx b/apps/web/app/(dashboard)/clientes/page.tsx index d506fcc..40eca78 100644 --- a/apps/web/app/(dashboard)/clientes/page.tsx +++ b/apps/web/app/(dashboard)/clientes/page.tsx @@ -10,22 +10,24 @@ import { useTenantViewStore } from '@/stores/tenant-view-store'; import { useAuthStore } from '@/stores/auth-store'; import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react'; import type { Tenant } from '@/lib/api/tenants'; -import { isGlobalAdminRfc } from '@horux/shared'; +import { isGlobalAdminRfc, DESPACHO_PLAN_PRICES, permiteFrecuenciaMensual } from '@horux/shared'; import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes'; const PLAN_LABELS: Record = { - starter: 'Starter', - business: 'Business', - business_ia: 'Business + IA', - enterprise: 'Enterprise', - custom: 'Custom', + trial: 'Trial Gratuito', mi_empresa: 'Mi Empresa', mi_empresa_plus: 'Mi Empresa +', business_control: 'Business Control', business_cloud: 'Enterprise (Despacho)', + custom: 'Custom', + // Legacy labels kept for display + starter: 'Starter (legacy)', + business: 'Business (legacy)', + business_ia: 'Business + IA (legacy)', + enterprise: 'Enterprise (legacy)', }; -type PlanType = 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom'; +type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud' | 'custom' | 'starter' | 'business' | 'business_ia' | 'enterprise'; export default function ClientesPage() { const { user } = useAuthStore(); @@ -81,13 +83,17 @@ export default function ClientesPage() { nombre: string; rfc: string; plan: PlanType; + verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'; + frequency: 'monthly' | 'annual'; adminEmail: string; adminNombre: string; amount: number; }>({ nombre: '', rfc: '', - plan: 'starter', + plan: 'trial', + verticalProfile: 'CONTABLE', + frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0, @@ -116,12 +122,20 @@ export default function ClientesPage() { try { if (editingTenant) { - await updateTenant.mutateAsync({ id: editingTenant.id, data: formData }); + await updateTenant.mutateAsync({ + id: editingTenant.id, + data: { + nombre: formData.nombre, + rfc: formData.rfc, + plan: formData.plan, + verticalProfile: formData.verticalProfile, + }, + }); setEditingTenant(null); } else { await createTenant.mutateAsync(formData); } - setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 }); + setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 }); setShowForm(false); } catch (error) { console.error('Error:', error); @@ -133,7 +147,9 @@ export default function ClientesPage() { setFormData({ nombre: tenant.nombre, rfc: tenant.rfc, - plan: tenant.plan as PlanType, + plan: (tenant.plan as PlanType) || 'trial', + verticalProfile: 'CONTABLE', + frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0, @@ -154,7 +170,7 @@ export default function ClientesPage() { const handleCancelForm = () => { setShowForm(false); setEditingTenant(null); - setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 }); + setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 }); }; const handleViewClient = (tenantId: string, tenantName: string) => { @@ -175,15 +191,16 @@ export default function ClientesPage() { // los planes — legacy + despacho + custom. El planColors local se mantiene // chico con un fallback genérico para planes nuevos. const planColors: Record = { - starter: 'bg-muted text-muted-foreground', - business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100', - business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100', - enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', + trial: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100', mi_empresa: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100', mi_empresa_plus: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100', business_control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100', business_cloud: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', custom: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-100', + starter: 'bg-muted text-muted-foreground', + business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100', + business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100', + enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', }; return ( @@ -393,12 +410,12 @@ export default function ClientesPage() {
- {editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'} + {editingTenant ? 'Editar Despacho' : 'Nuevo Despacho'} {editingTenant - ? 'Modifica los datos del cliente' - : 'Registra un nuevo cliente para gestionar su facturación'} + ? 'Modifica los datos del despacho' + : 'Registra un nuevo despacho en Horux'}