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
|
||||
// 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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user