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
// 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. Create subscription (only for paid plans)
if (!isDespacho || (plan !== 'trial' && plan !== 'custom')) {
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan,
plan: plan as any,
status: 'pending',
amount: data.amount,
frequency: 'monthly',
frequency: data.frequency || 'monthly',
},
});
}
// 5. Send welcome email to client (non-blocking)
// 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: {

View File

@@ -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 {

View File

@@ -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<string, string> = {
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<string, string> = {
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() {
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
{editingTenant ? 'Editar Despacho' : 'Nuevo Despacho'}
</CardTitle>
<CardDescription>
{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'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
@@ -410,54 +427,112 @@ export default function ClientesPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre de la Empresa</Label>
<Label htmlFor="nombre">Nombre del Despacho</Label>
<Input
id="nombre"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
placeholder="Empresa SA de CV"
placeholder="Despacho Pérez y Asociados"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rfc">RFC</Label>
<Label htmlFor="rfc">RFC del Despacho</Label>
<Input
id="rfc"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={14}
placeholder="ABC010101ABC"
maxLength={13}
required
disabled={!!editingTenant} // Can't change RFC after creation
disabled={!!editingTenant}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Label htmlFor="verticalProfile">Perfil Profesional</Label>
<Select
value={formData.plan}
value={formData.verticalProfile}
onValueChange={(value) =>
setFormData({ ...formData, plan: value as PlanType })
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTABLE">📊 Contable Fiscal, CFDI, IVA/ISR</SelectItem>
<SelectItem value="JURIDICO"> Jurídico Próximamente</SelectItem>
<SelectItem value="ARQUITECTURA">🏗 Arquitectura Próximamente</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value) => {
const plan = value as PlanType;
const isCustom = plan === 'custom';
const isTrial = plan === 'trial';
let amount = 0;
if (!isCustom && !isTrial) {
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[plan];
amount = priceInfo?.firstYear ?? 0;
}
setFormData({ ...formData, plan, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trial">Trial Gratuito 30 días, 3 RFCs, 20 timbres</SelectItem>
<SelectItem value="mi_empresa">Mi Empresa 1 RFC, 3 usuarios, 50 timbres/mes</SelectItem>
<SelectItem value="mi_empresa_plus">Mi Empresa + Con API y Lolita IA</SelectItem>
<SelectItem value="business_control">Business Control 100 RFCs, BYO server</SelectItem>
<SelectItem value="business_cloud">Enterprise 100 RFCs, 3M CFDIs, BYO</SelectItem>
<SelectItem value="custom">Custom Sin cobro, asignado por admin</SelectItem>
<hr className="my-1" />
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitados</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Frequency selector for plans that allow monthly */}
{formData.plan !== 'custom' && formData.plan !== 'trial' && permiteFrecuenciaMensual(formData.plan) && (
<div className="space-y-2">
<Label htmlFor="frequency">Frecuencia de Pago</Label>
<Select
value={formData.frequency}
onValueChange={(value) => {
const freq = value as 'monthly' | 'annual';
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[formData.plan];
const amount = freq === 'monthly' ? (priceInfo?.monthly ?? 0) : (priceInfo?.firstYear ?? 0);
setFormData({ ...formData, frequency: freq, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Mensual</SelectItem>
<SelectItem value="annual">Anual (ahorra ~17%)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Campos de admin y suscripción — solo al crear */}
{!editingTenant && (
<>
<div className="border-t pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Despacho</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
@@ -476,16 +551,16 @@ export default function ClientesPage() {
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
placeholder="admin@despacho.com"
required
/>
</div>
</div>
</div>
{formData.plan !== 'custom' && (
{formData.plan !== 'custom' && formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Label htmlFor="amount">Monto (MXN)</Label>
<Input
id="amount"
type="number"
@@ -495,12 +570,17 @@ export default function ClientesPage() {
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
Precio sugerido según catálogo. Puedes ajustarlo para descuentos especiales.
</p>
</div>
</div>
)}
{formData.plan === 'custom' && (
{(formData.plan === 'custom' || formData.plan === 'trial') && (
<p className="text-xs text-muted-foreground">
Plan Custom no genera cobro ni suscripción. Vigencia indefinida.
{formData.plan === 'custom'
? 'Plan Custom no genera cobro ni suscripción. Vigencia indefinida.'
: 'Trial gratuito por 30 días. No requiere tarjeta.'}
</p>
)}
</>

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
@@ -9,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
@@ -44,14 +43,7 @@ function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number)
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
// Admin global no opera sobre datos de despacho — su home natural es
// `/clientes` (gestión de tenants). Redirige al primer render.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin) router.replace('/clientes');
}, [isGlobalAdmin, router]);
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);

View File

@@ -16,9 +16,9 @@ export interface Tenant {
export interface CreateTenantData {
nombre: string;
rfc: string;
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency?: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount?: number;
@@ -42,9 +42,8 @@ export async function createTenant(data: CreateTenantData): Promise<Tenant> {
export interface UpdateTenantData {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
active?: boolean;
}