feat: facturación primer pago, fixes SAT/MP, autocompletado RFCs/conceptos

Backend:
- Notificación email al admin cuando llega primer pago aprobado (sin factura auto)
- Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global
- Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d)
- Fix webhook MP: validación defensiva de x-signature header
- Fix autocompleto RFCs: eliminado filtro por contribuyenteId
- Fix autocompleto conceptos: eliminado filtro por contribuyenteId
- SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds
- SAT sync request reuse across jobs para evitar agotar cuota diaria
- Typo fix MP_ACCESS_TOKEN en .env
- Trial invitations system backend

Frontend:
- Nueva página /admin/facturas-pendientes con tabla y emisión manual
- Métrica 'Facturas pendientes' en /clientes (clickable)
- Navegación onboarding FIEL/CSD corregida
- Sidebar themes sincronizados
- Fix SAT portal migration scraper (NetIQ)
- Trial invitation acceptance pages
This commit is contained in:
Horux Dev
2026-05-09 21:56:42 +00:00
parent b00b677c54
commit 9f11a0ba39
70 changed files with 2801 additions and 609 deletions

View File

@@ -0,0 +1,32 @@
-- DropIndex
DROP INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key";
-- CreateTable
CREATE TABLE "trial_invitations" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"invited_by" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'business_control',
"duration_days" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"token" TEXT NOT NULL,
"email_sent_to" TEXT,
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"accepted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "trial_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "trial_invitations_token_key" ON "trial_invitations"("token");
-- CreateIndex
CREATE INDEX "trial_invitations_tenant_id_idx" ON "trial_invitations"("tenant_id");
-- CreateIndex
CREATE INDEX "trial_invitations_token_idx" ON "trial_invitations"("token");
-- CreateIndex
CREATE INDEX "trial_invitations_status_idx" ON "trial_invitations"("status");

View File

@@ -477,6 +477,29 @@ model TrialUsage {
@@map("trial_usages")
}
/// Invitaciones de trial enviadas por admin global a tenants específicos.
/// Permite activar trials configurables (ej. Business Control Prueba por 60 días)
/// con un link único que el owner del tenant puede aceptar.
model TrialInvitation {
id String @id @default(uuid())
tenantId String @map("tenant_id")
invitedBy String @map("invited_by")
plan String @default("business_control")
durationDays Int @map("duration_days")
status String @default("pending") // pending | accepted | expired | cancelled
token String @unique
emailSentTo String? @map("email_sent_to")
sentAt DateTime @default(now()) @map("sent_at")
expiresAt DateTime @map("expires_at")
acceptedAt DateTime? @map("accepted_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@index([token])
@@index([status])
@@map("trial_invitations")
}
/// Catálogo despacho — precios + limits editables por admin global.
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
/// porque están acopladas a UI/middleware y son contrato de código.

View File

@@ -162,8 +162,8 @@ async function main() {
{ plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false },
{ plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true },
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true },
{ plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true },
{ plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true },
];
for (const p of DESPACHO_PLAN_CATALOGO) {
await prisma.despachoPlanPrice.upsert({

View File

@@ -38,6 +38,7 @@ import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
import adminClientesRoutes from './routes/admin-clientes.routes.js';
import adminAddonsRoutes from './routes/admin-addons.routes.js';
import { trialInvitationRoutes } from './routes/trial-invitations.routes.js';
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
import metricasRoutes from './routes/metricas.routes.js';
@@ -105,6 +106,7 @@ app.use('/api/admin/clientes', adminClientesRoutes);
app.use('/api/admin/addons', adminAddonsRoutes);
app.use('/api/despacho/audit-log', despachoAuditRoutes);
app.use('/api/metricas', metricasRoutes);
app.use('/api/invitations/trial', trialInvitationRoutes);
// Error handling
app.use(errorMiddleware);

View File

@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
}

View File

@@ -9,10 +9,15 @@ function effectiveTenantId(req: Request): string {
const ROLES_OWNER = new Set(['owner', 'cfo']);
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
function isPlatformStaff(user: Request['user']): boolean {
return (user?.platformRoles || []).some(r => PLATFORM_SUPERSET.has(r));
}
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_OWNER.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !ROLES_OWNER.has(req.user!.role)) {
throw new AppError(403, 'Solo owner puede ver estas métricas');
}
const tenantId = effectiveTenantId(req);
@@ -27,7 +32,7 @@ export async function getContribuyentesStats(req: Request, res: Response, next:
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_ASIGNADOS.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !ROLES_ASIGNADOS.has(req.user!.role)) {
throw new AppError(403, 'No tienes contribuyentes asignados');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
@@ -47,7 +52,7 @@ export async function getMisAsignados(req: Request, res: Response, next: NextFun
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
try {
if (!ROLES_SUPERVISORY.has(req.user!.role)) {
if (!isPlatformStaff(req.user) && !ROLES_SUPERVISORY.has(req.user!.role)) {
throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo');
}
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;

View File

@@ -10,10 +10,10 @@ const signupSchema = z.object({
regimenFiscal: z.string().optional(),
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'),
// Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son
// anuales fijos). Default annual sesga el cash-flow del negocio.
frequency: z.enum(['monthly', 'annual']).optional().default('annual'),
// plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico.
// Se mantienen opcionales para compatibilidad backward con clientes antiguos.
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
frequency: z.enum(['monthly', 'annual']).optional(),
}),
owner: z.object({
nombre: z.string().min(2, 'Nombre del owner requerido'),
@@ -42,16 +42,10 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
let currentPlan: string;
if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
// recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado.
//
// FIX: Si hay una subscription en trial con un plan específico (ej.
// business_control desde una TrialInvitation), respetamos ese plan
// para que el feature-gate y los límites funcionen correctamente.
const subscription = await prisma.subscription.findFirst({
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
orderBy: { createdAt: 'desc' },
@@ -61,6 +55,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
},
});
let currentPlan: string;
if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') {
currentPlan = subscription.plan;
} else if (isTrialActive) {
currentPlan = 'trial';
} else {
currentPlan = String(tenant.plan);
}
// Estado de suscripción activa (si hay) — alimenta la UI con el monto
// recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado.
return res.json({
plan: currentPlan,
dbMode: tenant.dbMode,

View File

@@ -518,8 +518,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
}
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
let whereSearch = '';
const params: any[] = [];
if (q.length >= 2) {
@@ -548,7 +546,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
JOIN cfdis c ON cc.cfdi_id = c.id
WHERE c.status NOT IN ('Cancelado', '0')
${whereType}
${whereContrib}
${whereSearch}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30
@@ -664,30 +661,11 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
});
const tenantRfc = tenant?.rfc || '';
// En multi-RFC con contribuyente activo, filtrar a contrapartes con las
// que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin
// contribuyenteId, retornar el catálogo completo (compat con flujos
// legacy / admin global sin contribuyente seleccionado).
let rows;
if (contribuyenteId) {
({ rows } = await pool.query(`
SELECT DISTINCT r.id, r.rfc,
r.razon_social as "razonSocial",
r.regimen_fiscal as "regimenFiscal",
r.codigo_postal as "codigoPostal"
FROM rfcs r
WHERE r.rfc != $1
AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2)
AND EXISTS (
SELECT 1 FROM cfdis c
WHERE c.contribuyente_id = $3
AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id)
)
ORDER BY r.razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`, contribuyenteId]));
} else {
({ rows } = await pool.query(`
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
// contrario no se podría facturar a un cliente nuevo que nunca haya
// aparecido en un CFDI previo.
const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal",
codigo_postal as "codigoPostal"
@@ -696,8 +674,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
ORDER BY razon_social
LIMIT 10
`, [tenantRfc, `%${q}%`]));
}
`, [tenantRfc, `%${q}%`]);
res.json(rows);
} catch (error) { next(error); }
@@ -787,3 +764,123 @@ export async function comprarPaquete(req: Request, res: Response, next: NextFunc
next(error);
}
}
// ── Admin global: pagos de suscripción sin factura ──
export async function getPagosSinFactura(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede consultar pagos sin factura' });
}
const payments = await prisma.payment.findMany({
where: {
status: 'approved',
facturapiInvoiceId: null,
kind: 'subscription',
amount: { gt: 0 },
},
include: {
subscription: { select: { plan: true, frequency: true } },
tenant: { select: { nombre: true, rfc: true } },
},
orderBy: { paidAt: 'desc' },
});
res.json(payments);
} catch (error) { next(error); }
}
export async function emitirFacturaPago(req: Request, res: Response, next: NextFunction) {
try {
if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) {
return res.status(403).json({ message: 'Solo admin global puede emitir facturas de pago' });
}
const paymentId = String(req.params.paymentId);
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: { subscription: true },
});
if (!payment) {
return next(new AppError(404, 'Pago no encontrado'));
}
if (payment.status !== 'approved') {
return next(new AppError(400, 'Solo pagos aprobados pueden facturarse'));
}
if (payment.facturapiInvoiceId) {
return next(new AppError(400, 'Este pago ya tiene una factura emitida'));
}
// Reutilizar helpers del servicio de facturación
const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js');
const emitter = await getEmitterTenant();
const amount = Number(payment.amount);
const plan = (payment as any).subscription?.plan || 'custom';
const frequency = (payment as any).subscription?.frequency || 'monthly';
const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual';
const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`;
const customer = await getCustomerFromTenant(payment.tenantId);
if (!customer) {
return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.'));
}
const tenantPref = await prisma.tenant.findUnique({
where: { id: payment.tenantId },
select: { factUsoCfdi: true },
});
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01';
const formaPagoMap: Record<string, string> = {
master: '04', visa: '04', amex: '04',
debmaster: '28', debvisa: '28',
account_money: '03', bank_transfer: '03',
};
const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, '');
const formaPago = formaPagoMap[normalizedMethod] || '03';
const payload = {
customer: {
legalName: customer.legalName,
taxId: customer.taxId,
taxSystem: customer.taxSystem,
email: customer.email,
zip: customer.zip,
},
items: [
{
description,
productKey: '81112502',
unitKey: 'E48',
unitName: 'Servicio',
quantity: 1,
price: amount,
taxIncluded: true,
taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }],
},
],
use: usoCfdi,
paymentForm: formaPago,
paymentMethod: 'PUE',
currency: 'MXN',
};
const invoice = await facturapiService.createInvoice(emitter.id, payload as any);
await prisma.payment.update({
where: { id: payment.id },
data: { facturapiInvoiceId: invoice.id },
});
auditFromReq(req, 'invoice.emitted_manual', {
entityType: 'Payment',
entityId: payment.id,
metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency },
});
res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id });
} catch (error) { next(error); }
}

View File

@@ -134,7 +134,7 @@ export async function retry(req: Request, res: Response): Promise<void> {
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
@@ -151,7 +151,7 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}

View File

@@ -5,7 +5,7 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
import { auditFromReq } from '../utils/audit.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
@@ -19,7 +19,7 @@ async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean>
*/
async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise<boolean> {
if (targetTenantId === req.user!.tenantId) return true;
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
}

View File

@@ -6,16 +6,24 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
import { isOwnerSomewhere } from '../utils/memberships.js';
async function requireGlobalAdmin(req: Request): Promise<void> {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) {
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
}
}
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return res.json([]);
}
const tenants = await tenantsService.getAllTenants();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.json(tenants);
} catch (error) {
next(error);
@@ -24,7 +32,10 @@ export async function getAllTenants(req: Request, res: Response, next: NextFunct
export async function getTenant(req: Request, res: Response, next: NextFunction) {
try {
await requireGlobalAdmin(req);
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
return res.status(404).json({ message: 'Cliente no encontrado' });
}
const tenant = await tenantsService.getTenantById(String(req.params.id));
if (!tenant) {
@@ -68,13 +79,15 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
await requireGlobalAdmin(req);
const id = String(req.params.id);
const { nombre, rfc, plan, active } = req.body;
const { nombre, rfc, plan, active, amount, firstPaymentDueAt } = req.body;
const tenant = await tenantsService.updateTenant(id, {
nombre,
rfc,
plan,
active,
amount,
firstPaymentDueAt: firstPaymentDueAt || null,
});
res.json(tenant);

View File

@@ -0,0 +1,130 @@
import type { Request, Response, NextFunction } from 'express';
import * as trialInvitationService from '../services/trial-invitations.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' });
}
return isAdmin;
}
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const { tenantId, plan, durationDays } = req.body;
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
return res.status(400).json({ message: 'tenantId y durationDays (1-365) son requeridos' });
}
const invitation = await trialInvitationService.createInvitation({
tenantId,
invitedByUserId: req.user!.userId,
plan: plan || 'business_control',
durationDays: parseInt(durationDays, 10),
});
res.status(201).json(invitation);
} catch (error: any) {
if (error.message?.includes('ya tiene') || error.message?.includes('no encontrado')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const { tenantId, status } = req.query;
const invitations = await trialInvitationService.getInvitations({
tenantId: typeof tenantId === 'string' ? tenantId : undefined,
status: typeof status === 'string' ? status : undefined,
});
res.json(invitations);
} catch (error) {
next(error);
}
}
export async function getMyPendingInvitation(req: Request, res: Response, next: NextFunction) {
try {
const invitation = await trialInvitationService.getPendingInvitationForTenant(req.user!.tenantId);
res.json(invitation);
} catch (error) {
next(error);
}
}
export async function acceptInvitation(req: Request, res: Response, next: NextFunction) {
try {
const token = typeof req.params.token === 'string' ? req.params.token : '';
if (!token) {
return res.status(400).json({ message: 'Token requerido' });
}
const result = await trialInvitationService.acceptInvitation(token, req.user!.userId);
res.json(result);
} catch (error: any) {
if (
error.message?.includes('no encontrada') ||
error.message?.includes('ya ') ||
error.message?.includes('expirado') ||
error.message?.includes('Solo el dueño')
) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const id = typeof req.params.id === 'string' ? req.params.id : '';
const result = await trialInvitationService.cancelInvitation(id);
res.json(result);
} catch (error: any) {
if (error.message?.includes('no encontrada') || error.message?.includes('Solo se pueden')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
}
export async function getInvitationByToken(req: Request, res: Response, next: NextFunction) {
try {
const token = typeof req.params.token === 'string' ? req.params.token : '';
const invitation = await prisma.trialInvitation.findUnique({
where: { token },
});
if (!invitation) {
return res.status(404).json({ message: 'Invitación no encontrada' });
}
const tenant = await prisma.tenant.findUnique({
where: { id: invitation.tenantId },
select: { nombre: true, rfc: true },
});
// No exponer datos sensibles
res.json({
id: invitation.id,
tenantId: invitation.tenantId,
plan: invitation.plan,
durationDays: invitation.durationDays,
status: invitation.status,
expiresAt: invitation.expiresAt,
tenant,
});
} catch (error) {
next(error);
}
}

View File

@@ -27,7 +27,7 @@ const updateGlobalSchema = z.object({
});
async function isGlobalAdmin(req: Request): Promise<boolean> {
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {

View File

@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
import type { Pool } from 'pg';
import { prisma, tenantDb } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
import { decryptAesGcm, deriveAesKey } from '@horux/core';
import { env } from '../config/env.js';
@@ -68,11 +69,12 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu
let tenantId = req.user.tenantId;
// Admin impersonation via X-View-Tenant header (global admin only)
// Admin impersonation via X-View-Tenant header (global admin or platform staff)
const viewTenantHeader = req.headers['x-view-tenant'] as string;
if (viewTenantHeader) {
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
if (!globalAdmin) {
const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti');
const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role);
if (!isPlatformStaff && !globalAdmin) {
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
}

View File

@@ -5,11 +5,8 @@ import * as ctrl from '../controllers/despacho-stats.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/contribuyentes-stats', ctrl.getContribuyentesStats);
router.get('/mis-asignados', ctrl.getMisAsignados);
router.get('/equipo-stats', ctrl.getEquipoStats);
router.get('/contribuyentes-stats', authenticate, tenantMiddleware, ctrl.getContribuyentesStats);
router.get('/mis-asignados', authenticate, tenantMiddleware, ctrl.getMisAsignados);
router.get('/equipo-stats', authenticate, tenantMiddleware, ctrl.getEquipoStats);
export { router as despachoStatsRoutes };

View File

@@ -61,4 +61,8 @@ router.get('/cfdis-ppd', facturacionController.getCfdisPpdPendientes);
// CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados")
router.get('/cfdis-relacionables', facturacionController.getCfdisRelacionables);
// Admin global: pagos de suscripción sin factura + emisión manual
router.get('/pagos-sin-factura', facturacionController.getPagosSinFactura);
router.post('/emitir-factura-pago/:paymentId', strictLimit, facturacionController.emitirFacturaPago);
export { router as facturacionRoutes };

View File

@@ -0,0 +1,22 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import * as trialInvitationsController from '../controllers/trial-invitations.controller.js';
const router: IRouter = Router();
// Public endpoint: anyone can view invitation details by token (no auth required)
router.get('/token/:token', trialInvitationsController.getInvitationByToken);
// Authenticated endpoints
router.use(authenticate);
// Global admin endpoints
router.post('/', trialInvitationsController.createInvitation);
router.get('/', trialInvitationsController.getAllInvitations);
router.post('/:id/cancel', trialInvitationsController.cancelInvitation);
// Self-serve endpoints (autenticado, owner validation in controller)
router.get('/pending', trialInvitationsController.getMyPendingInvitation);
router.post('/:token/accept', trialInvitationsController.acceptInvitation);
export { router as trialInvitationRoutes };

View File

@@ -19,7 +19,7 @@ export async function getDashboardMetrics() {
prisma.subscription.count({ where: { status: 'cancelled' } }),
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
prisma.tenant.findMany({
where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } },
where: { connectorTunnelHostname: { not: null } },
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
}),
prisma.payment.aggregate({

View File

@@ -40,7 +40,7 @@ export async function provisionConnector(tenantId: string): Promise<{
await prisma.tenant.update({
where: { id: tenantId },
data: {
dbMode: 'BYO',
// El conector es una feature de respaldo; el tenant siempre permanece MANAGED
connectorTokenEnc: tokenEncoded,
connectorTunnelHostname: hostname,
},
@@ -92,7 +92,7 @@ export async function verifyConnectorToken(token: string): Promise<string | null
// Find tenant by trying to decrypt stored tokens.
// This is O(N) — for production, use a hashed token lookup table.
const tenants = await prisma.tenant.findMany({
where: { dbMode: 'BYO', connectorTokenEnc: { not: null } },
where: { connectorTokenEnc: { not: null } },
select: { id: true, connectorTokenEnc: true },
});
@@ -132,7 +132,7 @@ export async function getConnectorStatus(tenantId: string): Promise<{
select: { dbMode: true, connectorTunnelHostname: true, connectorLastSeen: true, connectorVersion: true },
});
if (!tenant || tenant.dbMode !== 'BYO' || !tenant.connectorTunnelHostname) {
if (!tenant || !tenant.connectorTunnelHostname) {
return { configured: false, status: 'not_configured' };
}

View File

@@ -69,7 +69,11 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({ headless });
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
@@ -171,6 +175,28 @@ async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise<num
return [...new Set(ids)];
}
/**
* Límites de longitud en el schema Prisma de Tenant (defensivo para
* evitar P2000 cuando el SAT devuelve valores más largos de lo esperado).
*/
const TENANT_FIELD_LIMITS: Record<string, number> = {
codigoPostal: 5,
calle: 255,
numExterior: 20,
numInterior: 20,
colonia: 255,
ciudad: 100,
municipio: 100,
estado: 100,
telefono: 20,
};
function truncateToLimit(key: string, value: string): string {
const limit = TENANT_FIELD_LIMITS[key];
if (!limit || value.length <= limit) return value;
return value.slice(0, limit);
}
/**
* Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente:
* se puede llamar N veces, el resultado final refleja el último CSF.
@@ -183,7 +209,9 @@ export async function sincronizarDatosFiscales(
const fields = domicilioToTenantFields(csf.domicilio);
const updates: Record<string, string> = {};
for (const [k, v] of Object.entries(fields)) {
if (v && v.trim().length > 0) updates[k] = v.trim();
if (v && v.trim().length > 0) {
updates[k] = truncateToLimit(k, v.trim());
}
}
if (Object.keys(updates).length > 0) {

View File

@@ -26,7 +26,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
plan: 'trial',
databaseName: databaseName,
verticalProfile: despacho.verticalProfile as any,
dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any,
dbMode: 'MANAGED',
dbSchemaVersion: 0,
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
codigoPostal: despacho.codigoPostal,
@@ -91,40 +91,9 @@ export async function signupDespacho(data: DespachoSignupRequest) {
email: result.user.email,
}).catch(err => console.error('[Signup] Welcome email failed:', err));
// If paid plan, create MP checkout via subscriptionService.subscribe()
// que también crea la fila Subscription en BD (clave para que el webhook
// pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado).
let paymentUrl: string | undefined;
if (data.despacho.plan && data.despacho.plan !== 'trial') {
try {
const subscriptionService = await import('./payment/subscription.service.js');
const result2 = await subscriptionService.subscribe({
tenantId: result.tenant.id,
plan: data.despacho.plan as any,
// mi_empresa(+) acepta monthly/annual; los demás solo annual
// — el subscribe valida y rechaza monthly cuando no aplica.
frequency: data.despacho.frequency || 'annual',
payerEmail: owner.email,
});
paymentUrl = result2.paymentUrl;
} catch (err: any) {
// Rollback: delete tenant + user since payment couldn't be set up
await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {});
await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {});
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
const msg = err?.message || '';
if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) {
throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.');
}
throw new Error(msg || 'No se pudo procesar el cobro.');
}
}
return {
accessToken,
refreshToken,
paymentUrl,
user: {
id: result.user.id,
email: result.user.email,

View File

@@ -68,6 +68,18 @@ export const emailService = {
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
},
sendPrimerPagoFacturar: async (data: {
clienteNombre: string;
clienteRfc: string;
amount: number;
plan: string;
paymentDate: string;
paymentId: string;
}) => {
const { primerPagoFacturarEmail } = await import('./templates/primer-pago-facturar.js');
await sendEmail(env.ADMIN_EMAIL, `Factura pendiente: primer pago de ${data.clienteNombre}`, primerPagoFacturarEmail(data));
},
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
@@ -91,6 +103,17 @@ export const emailService = {
await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data));
},
sendTrialInvitation: async (to: string, data: {
despachoNombre: string;
plan: string;
durationDays: number;
acceptUrl: string;
expiresAt: string;
}) => {
const { trialInvitationEmail } = await import('./templates/trial-invitation.js');
await sendEmail(to, `Invitación especial — Prueba ${data.plan === 'business_control' ? 'Business Control' : data.plan}`, trialInvitationEmail(data));
},
/**
* Notifica la subida de una declaración o documento extra al despacho.
* `recipients` debe venir deduplicado por el caller. El subject se

View File

@@ -0,0 +1,55 @@
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function row(label: string, value: string, isLast = false) {
const border = isLast ? '' : `border-bottom:1px solid ${C.border};`;
return `<tr>
<td style="padding:10px 16px;${border}font-weight:500;color:${C.textMuted};width:40%;font-size:13px;">${label}</td>
<td style="padding:10px 16px;${border}color:${C.textPrimary};font-size:14px;">${value}</td>
</tr>`;
}
export function primerPagoFacturarEmail(data: {
clienteNombre: string;
clienteRfc: string;
amount: number;
plan: string;
paymentDate: string;
paymentId: string;
}): string {
const formattedAmount = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(data.amount);
return baseTemplate(`
${heading('Primer pago aprobado — factura pendiente')}
<p style="color:${C.textPrimary};margin:0 0 24px;">
El cliente <strong>${escapeHtml(data.clienteNombre)}</strong> realizó su primer pago exitosamente.
Como es el primer pago, la factura <strong>debe emitirse manualmente</strong>.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px;border:1px solid ${C.border};border-radius:8px;overflow:hidden;">
${row('Cliente', `<strong>${escapeHtml(data.clienteNombre)}</strong>`)}
${row('RFC', `<span style="font-family:monospace;">${escapeHtml(data.clienteRfc)}</span>`)}
${row('Plan', escapeHtml(data.plan))}
${row('Monto', `<strong>${formattedAmount}</strong>`)}
${row('Fecha de pago', data.paymentDate, true)}
</table>
<div style="text-align:center;margin:24px 0;">
<a href="https://horuxfin.com/admin/facturas-pendientes" style="display:inline-block;background-color:${C.primary};color:#ffffff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;font-size:14px;">
Ver facturas pendientes
</a>
</div>
<div style="background-color:#fef9c3;border-left:4px solid #eab308;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
<p style="margin:0;color:#854d0e;font-size:13px;">
<strong> Nota:</strong> Los pagos subsecuentes se facturarán automáticamente. Solo el primer pago requiere emisión manual.
</p>
</div>
`);
}

View File

@@ -0,0 +1,63 @@
export interface TrialInvitationData {
despachoNombre: string;
plan: string;
durationDays: number;
acceptUrl: string;
expiresAt: string;
}
export function trialInvitationEmail(data: TrialInvitationData): string {
const planDisplay = data.plan === 'business_control' ? 'Business Control' : data.plan;
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #1e40af; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f8fafc; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #1e40af; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.highlight { background: #dbeafe; padding: 15px; border-radius: 6px; margin: 15px 0; }
.footer { text-align: center; color: #64748b; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Invitación especial para ${data.despachoNombre}</h1>
</div>
<div class="content">
<p>Hola,</p>
<p>Has recibido una invitación exclusiva para probar <strong>${planDisplay}</strong> durante <strong>${data.durationDays} días</strong> completamente gratis.</p>
<div class="highlight">
<strong>¿Qué incluye?</strong>
<ul>
<li>Hasta 100 RFCs</li>
<li>Usuarios ilimitados</li>
<li>API de integración</li>
<li>SAT incremental</li>
<li>Todas las funciones de Business Control</li>
</ul>
</div>
<p style="text-align: center;">
<a href="${data.acceptUrl}" class="button">Aceptar invitación</a>
</p>
<p><strong>Importante:</strong> Esta invitación expira el <strong>${data.expiresAt}</strong>. Una vez que aceptes, tendrás ${data.durationDays} días para probar todas las funciones sin compromiso.</p>
<p>Al finalizar el periodo de prueba, podrás contratar el plan para continuar con el servicio.</p>
<p>Si tienes alguna duda, contacta a nuestro equipo de soporte.</p>
</div>
<div class="footer">
<p>Horux Despachos — Simplificando la contabilidad</p>
</div>
</div>
</body>
</html>
`.trim();
}

View File

@@ -80,7 +80,7 @@ async function isFirstApprovedPayment(
* Busca el tenant emisor (Horux 360) con su organización Facturapi configurada.
* Si falta, lanza error — el admin global tiene que crear la organización primero.
*/
async function getEmitterTenant() {
export async function getEmitterTenant() {
const tenant = await prisma.tenant.findUnique({
where: { rfc: GLOBAL_ADMIN_RFC },
select: {
@@ -125,7 +125,7 @@ interface CustomerData {
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
* a público en general en ese caso.
*/
async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
export async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
const tenant = await prisma.tenant.findUnique({
where: { id: payerTenantId },
select: {
@@ -179,7 +179,7 @@ async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerDat
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
* o fallback a público en general si `customer` es null.
*/
function buildInvoicePayload(params: {
export function buildInvoicePayload(params: {
amount: number;
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
emitterCp: string;
@@ -272,6 +272,22 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
// Gate 4: primer pago del tenant → manual
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`);
// Notificar al admin global para que emita la factura manualmente
const tenant = await prisma.tenant.findUnique({
where: { id: payment.tenantId },
select: { nombre: true, rfc: true },
});
if (tenant) {
const { emailService } = await import('../email/email.service.js');
emailService.sendPrimerPagoFacturar({
clienteNombre: tenant.nombre,
clienteRfc: tenant.rfc || '',
amount,
plan: payment.subscription?.plan || 'custom',
paymentDate: payment.paidAt?.toISOString() || new Date().toISOString(),
paymentId: payment.id,
}).catch(err => console.error('[Invoicing] Error enviando notificación de primer pago:', err));
}
return;
}

View File

@@ -323,6 +323,7 @@ export function verifyWebhookSignature(
const parts: Record<string, string> = {};
for (const part of xSignature.split(',')) {
const [key, value] = part.split('=');
if (!key || value === undefined) continue;
parts[key.trim()] = value.trim();
}

View File

@@ -19,7 +19,13 @@ export async function loginSatCsf(
keyPath: string,
password: string,
): Promise<CsfLoginSession> {
const context = await browser.newContext({ acceptDownloads: true });
const context = await browser.newContext({
acceptDownloads: true,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
});
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000);
@@ -66,12 +72,34 @@ export async function loginSatCsf(
await fileInputs.nth(0).setInputFiles(cerPath);
await fileInputs.nth(1).setInputFiles(keyPath);
// Esperar a que el cert async parsing termine (RFC auto-populado por SAT).
try {
await loginPage.waitForFunction(
() => {
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
return rfc !== null && rfc.value.length >= 12;
},
null,
{ timeout: 30_000 },
);
} catch (err) {
const html = await loginPage.content();
const { writeFileSync, mkdirSync } = await import('node:fs');
const debugDir = '/tmp/horux-csf-debug';
try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ }
writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html);
throw err;
}
// Password + Enviar
await loginPage.locator('input[type="password"]').first().fill(password);
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
// Esperar a que salga del dominio de login
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
// Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 },
);
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000);

View File

@@ -85,12 +85,29 @@ function extractLabels(text: string): Map<string, string> {
const result = new Map<string, string>();
const labelAlternation = LABELS.map(escapeRegex).join('|');
const re = new RegExp(
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s*(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
'g',
);
for (const match of text.matchAll(re)) {
const label = match[1];
const value = match[2].replace(/\s+/g, ' ').trim();
let value = match[2].replace(/\s+/g, ' ').trim();
// Defensa: el SAT a veces pone etiquetas consecutivas sin valor intermedio
// (ej. "Número Interior:\nNombre de la Colonia: X"). El regex lazy captura
// de más y el valor termina incluyendo el nombre de la siguiente etiqueta.
// Limpiamos cualquier prefijo de otra etiqueta del SAT que haya quedado al
// inicio del valor.
for (const otherLabel of LABELS) {
if (otherLabel === label) continue;
const prefix = otherLabel + ':';
const lowerValue = value.toLowerCase();
const lowerPrefix = prefix.toLowerCase();
if (lowerValue.startsWith(lowerPrefix)) {
value = value.slice(prefix.length).trim();
break;
}
}
if (!result.has(label)) result.set(label, value);
}
return result;

View File

@@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
for (const entry of entries) {
if (entry.entryName.toLowerCase().endsWith('.xml')) {
const content = entry.getData().toString('utf-8');
let content = entry.getData().toString('utf-8');
// Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve
// result.Comprobante = undefined, dejando el CFDI sin parsear.
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1);
}
xmlFiles.push({
filename: entry.entryName,
content,
@@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
*/
function parseCfdiDate(str: string | null | undefined): Date {
if (!str) return new Date(0);
const s = String(str).trim();
let s = String(str).trim();
if (!s) return new Date(0);
// Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en
// FechaTimbrado duplicado). Tomamos solo la primera fecha válida.
if (s.includes('|')) {
s = s.split('|')[0].trim();
}
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
return new Date(hasTz ? s : s + 'Z');
}
@@ -155,18 +165,28 @@ function pf(val: any): number {
return parseFloat(val || '0') || 0;
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function getFirstTimbre(comprobante: any): any {
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
if (!timbre) return null;
return Array.isArray(timbre) ? timbre[0] : timbre;
}
/**
* Extrae el UUID del TimbreFiscalDigital
*/
function extractUuid(comprobante: any): string {
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
const timbre = getFirstTimbre(comprobante);
return timbre?.['@_UUID'] || '';
}
/**
* Extrae datos del timbre: fecha cert SAT y PAC
*/
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
const timbre = getFirstTimbre(comprobante);
if (!timbre) return { fechaCertSat: null, pac: null };
return {
@@ -322,7 +342,7 @@ function extractPagos(comprobante: any): {
}
}
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : 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;
@@ -370,9 +390,9 @@ function extractNomina(comprobante: any): {
const nomina = complemento.Nomina;
if (!nomina) return result;
result.fechaPago = nomina['@_FechaPago'] || null;
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);

View File

@@ -547,9 +547,35 @@ async function requestAndDownload(
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
const jobRow = await prisma.satSyncJob.findUnique({
where: { id: jobId },
select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true },
});
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
if (!existingMap[kindKey]) {
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId },
dateFrom: jobRow?.dateFrom,
dateTo: jobRow?.dateTo,
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true },
});
const existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// Copiar al job actual para futuros usos
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
}
}
}
let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
@@ -651,7 +677,8 @@ async function processDateRange(
jobId: string,
fechaInicio: Date,
fechaFin: Date,
tipoCfdi: CfdiSyncType
tipoCfdi: CfdiSyncType,
skipJobUpdate = false
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
let totalFound = 0;
let totalDownloaded = 0;
@@ -678,12 +705,14 @@ async function processDateRange(
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
}
if (!skipJobUpdate) {
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
});
}
return {
found: totalFound,
@@ -787,7 +816,9 @@ async function processInitialSync(
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
const fechaFin = customDateTo || ahora;
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
@@ -802,13 +833,29 @@ async function processInitialSync(
let totalInserted = 0;
let totalUpdated = 0;
const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk
let completedSteps = 0;
// Helper para actualizar progreso acumulado
async function reportProgress() {
completedSteps++;
const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
await updateJobProgress(jobId, {
cfdisFound: totalFound,
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent,
});
}
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
for (let i = 0; i < xmlChunks.length; i++) {
const { start, end } = xmlChunks[i];
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}`);
try {
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos');
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
totalFound += emitidos.found;
totalDownloaded += emitidos.downloaded;
totalInserted += emitidos.inserted;
@@ -816,9 +863,10 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos');
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
totalFound += recibidos.found;
totalDownloaded += recibidos.downloaded;
totalInserted += recibidos.inserted;
@@ -826,6 +874,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
@@ -842,6 +891,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
try {
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
@@ -850,6 +900,7 @@ async function processInitialSync(
} catch (error: any) {
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
}
await reportProgress();
await new Promise(resolve => setTimeout(resolve, 5000));
}
@@ -859,6 +910,7 @@ async function processInitialSync(
cfdisDownloaded: totalDownloaded,
cfdisInserted: totalInserted,
cfdisUpdated: totalUpdated,
progressPercent: 100,
});
}

View File

@@ -13,6 +13,13 @@ export interface SweepResult {
}>;
}
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8,
daily: 4,
incremental: 2,
custom: 4,
};
/**
* Watchdog para jobs `sat_sync_jobs` stale.
*
@@ -22,35 +29,45 @@ export interface SweepResult {
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
* lock para nuevos syncs del mismo (tenant, contribuyente).
*
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
* típico termina en <2h; si lleva >runningHours es casi seguro
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
* 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren
* por tipo: initial (8h) porque un bootstrap de 6 años puede tardar
* varias horas; daily (4h); incremental (2h) porque es ventana corta.
* Si lleva >threshold es casi seguro huérfano de un proceso que murió.
*
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
* (volver a correrlo no reabre los ya-marcados-failed).
*
* - `apply=false` (default): dry-run, no toca BD.
* - `pendingHours`/`runningHours`: thresholds (default 12h / 4h).
* - `pendingHours`: threshold pending (default 12h).
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
* - `runningHoursByType`: override por tipo de sync.
*/
export async function sweepStaleSatJobs(params: {
apply: boolean;
pendingHours?: number;
runningHours?: number;
runningHoursByType?: Record<string, number>;
} = { apply: false }): Promise<SweepResult> {
const pendingHours = params.pendingHours ?? 12;
const runningHours = params.runningHours ?? 4;
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
const now = new Date();
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
const stalePending = await prisma.satSyncJob.findMany({
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
orderBy: { createdAt: 'asc' },
});
const staleRunning = await prisma.satSyncJob.findMany({
where: { status: 'running', startedAt: { lt: runningCutoff } },
// running: evaluar por tipo usando thresholds distintos
const allRunning = await prisma.satSyncJob.findMany({
where: { status: 'running' },
orderBy: { createdAt: 'asc' },
});
const staleRunning = allRunning.filter(j => {
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
const cutoff = new Date(now.getTime() - thresholdHours * 3600 * 1000);
return (j.startedAt ?? j.createdAt) < cutoff;
});
const result: SweepResult = {
pendingFound: stalePending.length,
@@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: {
result.pendingMarked++;
}
for (const j of staleRunning) {
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
await prisma.satSyncJob.update({
where: { id: j.id },
data: {
status: 'failed',
completedAt: now,
errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
errorMessage: `Abandoned by watchdog: running ${j.type} with startedAt ${j.startedAt?.toISOString()} > ${thresholdHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
},
});
result.runningMarked++;

View File

@@ -17,7 +17,17 @@ export async function getAllTenants() {
createdAt: true,
_count: {
select: { memberships: { where: { active: true } } as any }
}
},
subscriptions: {
orderBy: { createdAt: 'desc' },
take: 1,
select: {
id: true,
amount: true,
currentPeriodEnd: true,
status: true,
},
},
},
orderBy: { nombre: 'asc' }
});
@@ -266,8 +276,10 @@ export async function updateTenant(id: string, data: {
rfc?: string;
plan?: DespachoPlan;
active?: boolean;
amount?: number;
firstPaymentDueAt?: string | null;
}) {
return prisma.tenant.update({
const tenant = await prisma.tenant.update({
where: { id },
data: {
...(data.nombre && { nombre: data.nombre }),
@@ -285,6 +297,29 @@ export async function updateTenant(id: string, data: {
createdAt: true,
}
});
// Actualizar subscription del tenant (plan custom o cualquier plan con amount)
if (data.amount !== undefined || data.firstPaymentDueAt !== undefined) {
const subscription = await prisma.subscription.findFirst({
where: { tenantId: id },
orderBy: { createdAt: 'desc' },
});
if (subscription) {
const updateData: any = {};
if (data.amount !== undefined) {
updateData.amount = data.amount;
}
if (data.firstPaymentDueAt !== undefined) {
updateData.currentPeriodEnd = data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null;
}
await prisma.subscription.update({
where: { id: subscription.id },
data: updateData,
});
}
}
return tenant;
}
export async function getDatosFiscales(id: string) {

View File

@@ -0,0 +1,191 @@
import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js';
import crypto from 'crypto';
function generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export async function createInvitation(params: {
tenantId: string;
invitedByUserId: string;
plan?: string;
durationDays: number;
}) {
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
select: { nombre: true, rfc: true, plan: true },
});
if (!tenant) throw new Error('Tenant no encontrado');
// Verificar que no haya ya una invitación pendiente para este tenant
const existingPending = await prisma.trialInvitation.findFirst({
where: { tenantId: params.tenantId, status: 'pending' },
});
if (existingPending) {
throw new Error('Este tenant ya tiene una invitación de trial pendiente');
}
// Verificar que el tenant no tenga ya una suscripción activa del mismo plan
const existingSub = await prisma.subscription.findFirst({
where: {
tenantId: params.tenantId,
status: { in: ['authorized', 'pending', 'trial'] },
plan: (params.plan || 'business_control') as any,
},
});
if (existingSub) {
throw new Error(`Este tenant ya tiene una suscripción activa o en trial de ${params.plan || 'business_control'}`);
}
const token = generateToken();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días para aceptar
const invitation = await prisma.trialInvitation.create({
data: {
tenantId: params.tenantId,
invitedBy: params.invitedByUserId,
plan: params.plan || 'business_control',
durationDays: params.durationDays,
token,
expiresAt,
emailSentTo: null,
},
});
// Enviar email al owner (fire-and-forget)
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
if (ownerEmail) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { emailSentTo: ownerEmail },
});
const acceptUrl = `${process.env.FRONTEND_URL || 'https://app.horux360.com'}/invitacion/trial/${token}`;
emailService.sendTrialInvitation(ownerEmail, {
despachoNombre: tenant.nombre,
plan: invitation.plan,
durationDays: invitation.durationDays,
acceptUrl,
expiresAt: expiresAt.toLocaleDateString('es-MX'),
}).catch((err: any) => console.error('[TrialInvitation] Email failed:', err.message));
}
return invitation;
}
export async function acceptInvitation(token: string, userId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { token },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error(`Invitación ya ${invitation.status}`);
if (invitation.expiresAt < new Date()) {
await prisma.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'expired' },
});
throw new Error('La invitación ha expirado');
}
// Verificar que el usuario sea owner del tenant
const membership = await prisma.tenantMembership.findFirst({
where: { userId, tenantId: invitation.tenantId, isOwner: true, active: true },
});
if (!membership) {
throw new Error('Solo el dueño del despacho puede aceptar esta invitación');
}
const trialEndsAt = new Date();
trialEndsAt.setDate(trialEndsAt.getDate() + invitation.durationDays);
const now = new Date();
await prisma.$transaction(async (tx) => {
// Actualizar tenant
await tx.tenant.update({
where: { id: invitation.tenantId },
data: {
plan: invitation.plan as any,
trialEndsAt,
},
});
// Cancelar cualquier subscription trial anterior genérica
await tx.subscription.updateMany({
where: { tenantId: invitation.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
// Crear nueva subscription de trial con el plan específico
await tx.subscription.create({
data: {
tenantId: invitation.tenantId,
plan: invitation.plan as any,
status: 'trial',
amount: 0,
frequency: 'annual',
currentPeriodStart: now,
currentPeriodEnd: trialEndsAt,
},
});
// Marcar invitación como aceptada
await tx.trialInvitation.update({
where: { id: invitation.id },
data: { status: 'accepted', acceptedAt: now },
});
});
return { success: true, trialEndsAt, plan: invitation.plan, durationDays: invitation.durationDays };
}
export async function getInvitations(filters?: { tenantId?: string; status?: string }) {
const where: any = {};
if (filters?.tenantId) where.tenantId = filters.tenantId;
if (filters?.status) where.status = filters.status;
const invitations = await prisma.trialInvitation.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// Enrich with tenant data
const tenantIds = [...new Set(invitations.map(i => i.tenantId))];
const tenants = await prisma.tenant.findMany({
where: { id: { in: tenantIds } },
select: { id: true, nombre: true, rfc: true },
});
const tenantMap = new Map(tenants.map(t => [t.id, t]));
return invitations.map(inv => ({
...inv,
tenant: tenantMap.get(inv.tenantId) || null,
}));
}
export async function getPendingInvitationForTenant(tenantId: string) {
const invitation = await prisma.trialInvitation.findFirst({
where: { tenantId, status: 'pending', expiresAt: { gt: new Date() } },
});
if (!invitation) return null;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
return { ...invitation, tenant };
}
export async function cancelInvitation(invitationId: string) {
const invitation = await prisma.trialInvitation.findUnique({
where: { id: invitationId },
});
if (!invitation) throw new Error('Invitación no encontrada');
if (invitation.status !== 'pending') throw new Error('Solo se pueden cancelar invitaciones pendientes');
return prisma.trialInvitation.update({
where: { id: invitationId },
data: { status: 'cancelled' },
});
}

View File

@@ -107,7 +107,12 @@ export async function getPlatformRoles(userId: string): Promise<PlatformRole[]>
* owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario
* post-deploy pre-seed).
*/
export async function isGlobalAdmin(tenantId: string, role: string): Promise<boolean> {
export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise<boolean> {
// Si se pasa userId y tiene rol de plataforma superset, permitir acceso global
if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) {
return true;
}
// Las firmas viejas no tienen userId disponible. Lo resolvemos buscando el user
// que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia
// es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo.

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 } from '@horux/shared';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
import { shouldShowOnboarding } from '@/lib/onboarding';
export default function LoginPage() {
@@ -33,7 +33,7 @@ export default function LoginPage() {
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?: string[] }).platformRoles;
const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles;
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
if (isGlobalAdmin) {
router.push('/clientes');

View File

@@ -3,23 +3,18 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react';
import { CheckCircle2, ArrowLeft } from 'lucide-react';
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
export default function RegisterDespachoPage() {
const router = useRouter();
const { setUser, setTokens } = useAuthStore();
const [step, setStep] = useState(1);
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
// Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses
// = 17% descuento para el cliente, año completo cobrado upfront para nosotros).
const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
@@ -37,7 +32,7 @@ export default function RegisterDespachoPage() {
const handleSubmit = async () => {
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; }
if (!verticalProfile) { setError('Selecciona un tipo de despacho'); return; }
setLoading(true);
setError('');
try {
@@ -45,10 +40,6 @@ export default function RegisterDespachoPage() {
despacho: {
nombre: form.despachoNombre,
verticalProfile,
plan: selectedPlan,
// Solo mi_empresa(+) acepta monthly; el backend ignora frequency
// para los demás planes. Mandamos siempre el state para coherencia.
frequency: billingFrequency,
},
owner: {
nombre: form.ownerNombre,
@@ -58,13 +49,7 @@ export default function RegisterDespachoPage() {
});
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
// If paid plan with payment URL, redirect to MercadoPago
if (data.paymentUrl) {
window.location.href = data.paymentUrl;
} else {
router.push('/onboarding');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Error al registrar el despacho');
setStep(1);
@@ -85,8 +70,6 @@ export default function RegisterDespachoPage() {
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">1</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
<p className="text-sm text-muted-foreground mt-1">Plataforma para despachos profesionales</p>
@@ -118,7 +101,6 @@ export default function RegisterDespachoPage() {
}
// =================== STEP 2: Vertical Selection ===================
if (step === 2) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<div className="w-full max-w-3xl space-y-8 text-center">
@@ -127,15 +109,14 @@ export default function RegisterDespachoPage() {
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">2</span>
<span className="w-8 h-px bg-muted" />
<span className="bg-muted text-muted-foreground rounded-full w-6 h-6 flex items-center justify-center">3</span>
</div>
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<button
onClick={() => { setVerticalProfile('CONTABLE'); setStep(3); }}
onClick={() => { setVerticalProfile('CONTABLE'); handleSubmit(); }}
disabled={loading}
className="p-8 rounded-xl border-2 border-primary bg-card hover:bg-accent transition-all text-center space-y-3"
>
<div className="text-4xl">📊</div>
@@ -153,6 +134,7 @@ export default function RegisterDespachoPage() {
<p className="text-sm text-muted-foreground">Próximamente</p>
</div>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md max-w-lg mx-auto">{error}</p>}
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
</button>
@@ -160,256 +142,3 @@ export default function RegisterDespachoPage() {
</div>
);
}
// =================== STEP 3: Subscription Selection ===================
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 py-8 px-4">
<div className="w-full max-w-7xl space-y-8">
<div className="text-center">
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground mb-4">
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-green-500" />
<span className="bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center"><CheckCircle2 className="h-4 w-4" /></span>
<span className="w-8 h-px bg-primary" />
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
</div>
<h1 className="text-3xl font-bold">Elige tu plan</h1>
<p className="text-muted-foreground mt-2">Empieza con el trial gratuito de 30 días o contrata un plan directo.</p>
</div>
{/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */}
<div className="flex justify-center">
<div className="inline-flex items-center gap-1 rounded-full border bg-muted/30 p-1">
<button
type="button"
onClick={() => setBillingFrequency('monthly')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
billingFrequency === 'monthly'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Mensual
</button>
<button
type="button"
onClick={() => setBillingFrequency('annual')}
className={cn(
'px-4 py-1.5 rounded-full text-sm font-medium transition-colors flex items-center gap-2',
billingFrequency === 'annual'
? 'bg-background shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Anual
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full',
billingFrequency === 'annual'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-muted text-muted-foreground'
)}>
Ahorra 17%
</span>
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Trial Gratuito */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'trial' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('trial')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-green-100 dark:bg-green-900 rounded-full p-3 w-fit mb-2">
<Clock className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<CardTitle className="text-lg">Trial Gratuito</CardTitle>
<p className="text-xs text-muted-foreground">Prueba sin compromiso</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$0</div>
<p className="text-xs text-muted-foreground">30 días</p>
<p className="text-xs text-muted-foreground mt-1">Sin tarjeta</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Hasta 3 RFCs</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 usuario</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todas las funcionalidades</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-orange-100 dark:bg-orange-900 rounded-full p-3 w-fit mb-2">
<Building className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<CardTitle className="text-lg">Mi Empresa</CardTitle>
<p className="text-xs text-muted-foreground">Para 1 contribuyente</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$5,800</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$580</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $5,800/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>1 RFC</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3 usuarios</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>50 timbres/mes</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en la nube</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa_plus' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa_plus')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Sparkles className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-lg">Mi Empresa +</CardTitle>
<p className="text-xs text-muted-foreground">Con IA + API</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
{billingFrequency === 'annual' ? (
<>
<div className="text-2xl font-bold">$9,000</div>
<p className="text-xs text-muted-foreground">por año</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1 font-medium">Equivale a 10 meses</p>
</>
) : (
<>
<div className="text-2xl font-bold">$900</div>
<p className="text-xs text-muted-foreground">mensual</p>
<p className="text-xs text-muted-foreground mt-1">o $9,000/año (10 meses)</p>
</>
)}
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Todo Mi Empresa</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Lolita IA Fiscal</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Business Control */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
selectedPlan === 'business_control' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_control')}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Server className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-lg">Business Control</CardTitle>
<p className="text-xs text-muted-foreground">Despachos contables</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$25,850</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>API de integración</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental</span></div>
</div>
</CardContent>
</Card>
{/* Enterprise (business_cloud) */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'business_cloud' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('business_cloud')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-amber-100 dark:bg-amber-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<CardTitle className="text-lg">Enterprise</CardTitle>
<p className="text-xs text-muted-foreground">Despachos de alto volumen</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<div className="text-2xl font-bold">$43,000</div>
<p className="text-xs text-muted-foreground">por año (IVA inc.)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por RFC extra</p>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>100 RFCs incluidos</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>3M CFDIs por contribuyente</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Usuarios ilimitados</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>BD en tu servidor</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>Servidor backup</span></div>
<div className="flex items-start gap-1.5"><CheckCircle2 className="h-3.5 w-3.5 text-green-500 flex-shrink-0 mt-0.5" /><span>SAT incremental + API</span></div>
</div>
</CardContent>
</Card>
</div>
{error && <p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md text-center max-w-lg mx-auto">{error}</p>}
<div className="flex flex-col items-center gap-3">
<Button
onClick={handleSubmit}
disabled={!selectedPlan || loading}
size="lg"
className="px-12"
>
{loading ? 'Creando tu despacho...' : selectedPlan === 'trial' ? 'Comenzar trial gratuito' : 'Continuar al pago'}
</Button>
<button onClick={() => setStep(2)} className="text-sm text-muted-foreground underline">
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver
</button>
</div>
</div>
</div>
);
}

View File

@@ -34,9 +34,9 @@ function ResetPasswordContent() {
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" className="w-full">
<Button className="w-full">Solicitar nuevo enlace</Button>
</Link>
<Button className="w-full" asChild>
<Link href="/forgot-password">Solicitar nuevo enlace</Link>
</Button>
</CardFooter>
</Card>
);
@@ -82,9 +82,9 @@ function ResetPasswordContent() {
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button className="w-full">Ir al login</Button>
</Link>
<Button className="w-full" asChild>
<Link href="/login">Ir al login</Link>
</Button>
</CardFooter>
</Card>
);

View File

@@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { usePagosSinFactura, useEmitirFacturaPago } from '@/lib/hooks/use-pagos-sin-factura';
import { ShieldAlert, FileText, RefreshCw, CheckCircle, AlertCircle, Receipt } from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
trial: 'Trial',
custom: 'Custom',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa Plus',
business_control: 'Business Control',
business_cloud: 'Enterprise',
};
const METHOD_LABELS: Record<string, string> = {
master: 'Mastercard',
visa: 'Visa',
amex: 'Amex',
debmaster: 'Débito Mastercard',
debvisa: 'Débito Visa',
account_money: 'MercadoPago',
bank_transfer: 'Transferencia',
};
function formatCurrency(amount: string | number): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num);
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' });
}
export default function FacturasPendientesPage() {
const { user } = useAuthStore();
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
const { data: payments, isLoading, error } = usePagosSinFactura();
const emitir = useEmitirFacturaPago();
const [emitiendoId, setEmitiendoId] = useState<string | null>(null);
if (!isGlobalAdmin) {
return (
<>
<Header title="Facturas Pendientes" />
<main className="p-6">
<Card>
<CardContent className="py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/40 mx-auto mb-3" />
<p className="font-semibold">Acceso restringido</p>
<p className="text-sm text-muted-foreground mt-1">
Solo el administrador global puede consultar pagos sin factura.
</p>
</CardContent>
</Card>
</main>
</>
);
}
async function handleEmitir(paymentId: string) {
setEmitiendoId(paymentId);
try {
await emitir.mutateAsync(paymentId);
} finally {
setEmitiendoId(null);
}
}
return (
<>
<Header title="Facturas Pendientes" />
<main className="p-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Pagos de suscripción sin factura
</CardTitle>
</CardHeader>
<CardContent>
{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
Cargando...
</div>
)}
{error && (
<div className="flex items-center justify-center py-12 text-red-600">
<AlertCircle className="h-5 w-5 mr-2" />
Error al cargar los pagos
</div>
)}
{!isLoading && !error && payments && payments.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<CheckCircle className="h-10 w-10 mb-3 text-green-500" />
<p className="font-medium">No hay pagos pendientes de facturar</p>
<p className="text-sm mt-1">Todos los pagos aprobados ya tienen su factura emitida.</p>
</div>
)}
{!isLoading && !error && payments && payments.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Cliente</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Plan</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Método</th>
<th className="text-left py-3 px-2 font-medium text-muted-foreground">Fecha de pago</th>
<th className="text-right py-3 px-2 font-medium text-muted-foreground">Acción</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-3 px-2">
<div className="font-medium">{p.tenant.nombre}</div>
<div className="text-xs text-muted-foreground font-mono">{p.tenant.rfc || '—'}</div>
</td>
<td className="py-3 px-2">
<span className="inline-flex items-center rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground ring-1 ring-inset ring-secondary/20">
{PLAN_LABELS[p.subscription?.plan || 'custom'] || p.subscription?.plan || 'Custom'}
</span>
</td>
<td className="py-3 px-2 font-semibold">{formatCurrency(p.amount)}</td>
<td className="py-3 px-2 text-muted-foreground">
{METHOD_LABELS[p.paymentMethod || ''] || p.paymentMethod || '—'}
</td>
<td className="py-3 px-2 text-muted-foreground">{formatDate(p.paidAt)}</td>
<td className="py-3 px-2 text-right">
<Button
size="sm"
onClick={() => handleEmitir(p.id)}
disabled={emitir.isPending && emitiendoId === p.id}
>
{emitir.isPending && emitiendoId === p.id ? (
<>
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
Emitiendo...
</>
) : (
<>
<FileText className="h-4 w-4 mr-1" />
Emitir factura
</>
)}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations';
import { getTenants } from '@/lib/api/tenants';
import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
interface TenantOption {
id: string;
nombre: string;
rfc: string;
}
interface Invitation {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
token: string;
sentAt: string;
expiresAt: string;
acceptedAt: string | null;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionesTrialPage() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [selectedTenantId, setSelectedTenantId] = useState('');
const [durationDays, setDurationDays] = useState('30');
const [plan, setPlan] = useState('business_control');
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
useEffect(() => {
loadData();
}, []);
async function loadData() {
setLoading(true);
try {
const [tenantsData, invitationsData] = await Promise.all([
getTenants(),
getAllInvitations(),
]);
setTenants(tenantsData);
setInvitations(invitationsData);
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' });
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!selectedTenantId || !durationDays) {
setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' });
return;
}
setCreating(true);
setMessage(null);
try {
await createInvitation({
tenantId: selectedTenantId,
plan,
durationDays: parseInt(durationDays, 10),
});
setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' });
setSelectedTenantId('');
setDurationDays('30');
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' });
} finally {
setCreating(false);
}
}
async function handleCancel(id: string) {
if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return;
try {
await cancelInvitation(id);
setMessage({ kind: 'ok', text: 'Invitación cancelada' });
loadData();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' });
}
}
function statusIcon(status: string) {
switch (status) {
case 'pending': return <Clock className="h-4 w-4 text-amber-500" />;
case 'accepted': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'expired': return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'cancelled': return <X className="h-4 w-4 text-gray-500" />;
default: return null;
}
}
function statusLabel(status: string) {
switch (status) {
case 'pending': return 'Pendiente';
case 'accepted': return 'Aceptada';
case 'expired': return 'Expirada';
case 'cancelled': return 'Cancelada';
default: return status;
}
}
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Gift className="h-6 w-6" />
Invitaciones de Trial
</h1>
<p className="text-muted-foreground">Envía invitaciones de prueba a despachos específicos</p>
</div>
{/* Toast de resultado */}
{message && (
<div
className={`max-w-3xl rounded-lg px-4 py-3 text-sm ${
message.kind === 'ok'
? 'bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: 'bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
}`}
>
{message.text}
</div>
)}
{/* Formulario de creación */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Nueva invitación</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Despacho</Label>
<Select value={selectedTenantId} onValueChange={setSelectedTenantId}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{tenants.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre} ({t.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={plan} onValueChange={setPlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="business_control">Business Control</SelectItem>
<SelectItem value="business_cloud">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Duración (días)</Label>
<Input
type="number"
min={1}
max={365}
value={durationDays}
onChange={(e) => setDurationDays(e.target.value)}
/>
</div>
</div>
<Button onClick={handleCreate} disabled={creating}>
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Gift className="h-4 w-4 mr-2" />}
Enviar invitación
</Button>
</CardContent>
</Card>
{/* Tabla de invitaciones */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Historial de invitaciones</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : invitations.length === 0 ? (
<p className="text-muted-foreground text-center py-8">No hay invitaciones enviadas</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3">Despacho</th>
<th className="text-left py-2 px-3">Plan</th>
<th className="text-left py-2 px-3">Días</th>
<th className="text-left py-2 px-3">Estado</th>
<th className="text-left py-2 px-3">Enviado</th>
<th className="text-left py-2 px-3">Expira</th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody>
{invitations.map((inv) => (
<tr key={inv.id} className="border-b hover:bg-muted/50">
<td className="py-2 px-3">
<div className="font-medium">{inv.tenant?.nombre || '—'}</div>
<div className="text-xs text-muted-foreground">{inv.tenant?.rfc || '—'}</div>
</td>
<td className="py-2 px-3">
{inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan}
</td>
<td className="py-2 px-3">{inv.durationDays}</td>
<td className="py-2 px-3">
<span className="flex items-center gap-1">
{statusIcon(inv.status)}
{statusLabel(inv.status)}
</span>
</td>
<td className="py-2 px-3">
{new Date(inv.sentAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{new Date(inv.expiresAt).toLocaleDateString('es-MX')}
</td>
<td className="py-2 px-3">
{inv.status === 'pending' && (
<button
onClick={() => handleCancel(inv.id)}
className="text-destructive hover:underline text-xs"
>
Cancelar
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select,
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
import { cn } from '@horux/shared-ui';
@@ -43,9 +44,12 @@ export default function AdminUsuariosPage() {
const [filterTenant, setFilterTenant] = useState<string>('all');
const [searchTerm, setSearchTerm] = useState('');
const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles);
useEffect(() => {
if (!isGlobal) return;
getTenants().then(setTenants).catch(console.error);
}, []);
}, [isGlobal]);
const handleEdit = (usuario: any) => {
setEditingUser({

View File

@@ -359,7 +359,7 @@ export default function CfdiPage() {
// CFDI Viewer state
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<number | null>(null);
// Cancelación Facturapi state
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
@@ -367,10 +367,10 @@ export default function CfdiPage() {
const [cancelSubstitution, setCancelSubstitution] = useState('');
const [cancelling, setCancelling] = useState(false);
const handleViewCfdi = async (id: string) => {
const handleViewCfdi = async (id: number) => {
setLoadingCfdi(id);
try {
const cfdi = await getCfdiById(id);
const cfdi = await getCfdiById(String(id));
setViewingCfdi(cfdi);
} catch (error) {
console.error('Error loading CFDI:', error);
@@ -882,10 +882,10 @@ export default function CfdiPage() {
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
};
const handleDelete = async (id: string) => {
const handleDelete = async (id: number) => {
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(id);
await deleteCfdi.mutateAsync(String(id));
} catch (error) {
console.error('Error deleting CFDI:', error);
}
@@ -920,9 +920,9 @@ export default function CfdiPage() {
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.ivaTrasladoTraslado || 0;
const iva = formData.ivaTraslado || 0;
const isrRetencion = formData.isrRetencion || 0;
const ivaRetencion = formData.ivaTrasladoRetencion || 0;
const ivaRetencion = formData.ivaRetencion || 0;
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
};

View File

@@ -6,9 +6,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
import { usePagosSinFactura } from '@/lib/hooks/use-pagos-sin-factura';
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 { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight, Receipt } from 'lucide-react';
import type { Tenant } from '@/lib/api/tenants';
import { isGlobalAdminRfc } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
@@ -56,6 +57,7 @@ export default function ClientesPage() {
queryFn: () => getClientesStats(from, to),
enabled: !!user,
});
const { data: pagosSinFactura } = usePagosSinFactura();
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
const usuariosPorTenant = useMemo(() => {
@@ -129,14 +131,17 @@ export default function ClientesPage() {
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
const sub = tenant.subscriptions?.[0];
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
adminEmail: '',
adminNombre: '',
amount: 0,
firstPaymentDueAt: '',
amount: sub ? Number(sub.amount) : 0,
firstPaymentDueAt: sub?.currentPeriodEnd
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
: '',
});
setShowForm(true);
};
@@ -210,7 +215,7 @@ export default function ClientesPage() {
</Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{/* Total clientes activos */}
<Card>
<CardContent className="pt-6">
@@ -286,6 +291,24 @@ export default function ClientesPage() {
</div>
</CardContent>
</Card>
{/* Facturas pendientes */}
<Card
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => router.push('/admin/facturas-pendientes')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Receipt className="h-6 w-6 text-orange-600" />
</div>
<div>
<p className="text-2xl font-bold">{pagosSinFactura?.length ?? 0}</p>
<p className="text-sm text-muted-foreground">Facturas pendientes</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Detalle de no renovaciones */}
@@ -454,9 +477,8 @@ export default function ClientesPage() {
</Select>
</div>
{/* Campos de admin y suscripción — solo al crear */}
{/* Campos de admin — 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>
<div className="grid gap-4 md:grid-cols-2">
@@ -483,8 +505,11 @@ export default function ClientesPage() {
</div>
</div>
</div>
)}
{/* Campos de suscripción — crear y editar (solo planes pagados / custom) */}
{formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2 border-t pt-4">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Input
@@ -525,8 +550,6 @@ export default function ClientesPage() {
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
</p>
)}
</>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={handleCancelForm}>

View File

@@ -77,7 +77,7 @@ function RegimenesActivosSection() {
useEffect(() => {
if (activos && catalogo) {
const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
const ids = new Set(activos.map((a: { clave: string }) => catalogo.find((c: { clave: string; id: number }) => c.clave === a.clave)?.id).filter(Boolean) as number[]);
setSelected(ids);
}
}, [activos, catalogo]);

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react';
import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard, Gift } from 'lucide-react';
import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
import { useAuthStore } from '@/stores/auth-store';
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
@@ -37,7 +38,7 @@ export default function PlanesDespachoPage() {
const { user } = useAuthStore();
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now'>(null);
const [busy, setBusy] = useState<null | PaidPlan | 'cancel' | 'pay-now' | 'accept-invite'>(null);
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
// Control y Enterprise siempre se cobran anual. Default monthly para
@@ -45,6 +46,12 @@ export default function PlanesDespachoPage() {
// muestra como CTA secundario.
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const [pendingInvitation, setPendingInvitation] = useState<{
id: string;
plan: string;
durationDays: number;
token: string;
} | null>(null);
const fetchPlan = () => {
apiClient.get<PlanInfo>('/despachos/me/plan')
@@ -58,6 +65,20 @@ export default function PlanesDespachoPage() {
.then(res => setPlanInfo(res.data))
.catch(() => setPlanInfo(null))
.finally(() => setLoading(false));
// Cargar invitación de trial pendiente
getPendingInvitation()
.then((inv) => {
if (inv && inv.status === 'pending') {
setPendingInvitation({
id: inv.id,
plan: inv.plan,
durationDays: inv.durationDays,
token: inv.token,
});
}
})
.catch(() => {});
}, []);
const currentPlan = planInfo?.plan ?? null;
@@ -153,6 +174,22 @@ export default function PlanesDespachoPage() {
}
}
async function handleAcceptInvitation() {
if (!pendingInvitation) return;
setBusy('accept-invite');
setMessage(null);
try {
const result = await acceptInvitation(pendingInvitation.token);
setMessage({ kind: 'ok', text: `¡Activado! Tienes ${result.durationDays} días de Business Control Prueba.` });
setPendingInvitation(null);
fetchPlan();
} catch (err: any) {
setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al activar la invitación' });
} finally {
setBusy(null);
}
}
function ActiveBadge() {
return (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap">
@@ -242,6 +279,28 @@ export default function PlanesDespachoPage() {
</div>
)}
{/* Banner de invitación de trial pendiente */}
{!loading && pendingInvitation && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<Gift className="h-6 w-6 text-purple-600 dark:text-purple-400 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="font-semibold text-purple-900 dark:text-purple-200">
Invitación especial Business Control Prueba
</div>
<div className="text-purple-700 dark:text-purple-400">
Tienes una invitación para probar Business Control por <strong>{pendingInvitation.durationDays} días</strong> con todas las funciones.
</div>
</div>
<Button
onClick={handleAcceptInvitation}
disabled={busy === 'accept-invite'}
className="w-full sm:w-auto"
>
{busy === 'accept-invite' ? 'Activando...' : 'Activar ahora'}
</Button>
</div>
)}
{/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
const sub = planInfo.subscription;

View File

@@ -78,7 +78,7 @@ export default function ContribuyentesPage() {
};
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
<Button

View File

@@ -1,15 +1,22 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
import { PeriodoSelector } from '@/components/periodo-selector';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
interface Despacho {
id: string;
nombre: string;
rfc: string;
}
interface Stats {
totalContribuyentes: number;
ultimaExtraccion: string | null;
@@ -20,14 +27,28 @@ interface Stats {
tareasAtrasadas: number;
}
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
export default function DespachoContribuyentesPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role === 'owner' || role === 'cfo';
const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
const enabled = role === 'owner' || role === 'cfo' || isPlatformStaff;
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
const { viewingTenantId, setViewingTenant } = useTenantViewStore();
const { data: despachos } = useQuery<Despacho[]>({
queryKey: ['admin-despachos'],
queryFn: async () => {
const { data } = await apiClient.get<{ data: Despacho[] }>('/admin/dashboard/despachos');
return data.data;
},
enabled: isPlatformStaff,
});
const { data, isLoading } = useQuery<Stats>({
queryKey: ['despacho-contribuyentes-stats', año, mes],
queryKey: ['despacho-contribuyentes-stats', viewingTenantId, año, mes],
queryFn: async () => {
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
return data;
@@ -56,6 +77,31 @@ export default function DespachoContribuyentesPage() {
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
<main className="p-6 max-w-7xl mx-auto">
<DespachoSubnav />
{isPlatformStaff && despachos && despachos.length > 0 && (
<div className="mb-6">
<label className="block text-sm font-medium text-muted-foreground mb-2">
Ver despacho
</label>
<Select
value={viewingTenantId || ''}
onValueChange={(value) => {
const d = despachos.find((x) => x.id === value);
setViewingTenant(value || null, d?.nombre || null, d?.rfc || null);
}}
>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder="Selecciona un despacho" />
</SelectTrigger>
<SelectContent>
{despachos.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.nombre} ({d.rfc})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isLoading ? (
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">

View File

@@ -25,11 +25,14 @@ interface Asignado {
tareasCompletadas: number;
}
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor']);
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
export default function MisAsignadosPage() {
const role = useAuthStore(s => s.user?.role);
const enabled = role ? ROLES_ASIGNADOS.has(role) : false;
const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
const enabled = role ? (ROLES_ASIGNADOS.has(role) || isPlatformStaff) : false;
const { setSelectedContribuyente } = useContribuyenteStore();
const { fechaInicio } = usePeriodoStore();
const { año, mes } = añoMesFromFechaInicio(fechaInicio);

View File

@@ -8,9 +8,10 @@ import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subn
export default function DespachosIndex() {
const router = useRouter();
const role = useAuthStore(s => s.user?.role);
const platformRoles = useAuthStore(s => s.user?.platformRoles);
useEffect(() => {
if (!role) return;
router.replace(defaultDespachoPathForRole(role));
}, [role, router]);
router.replace(defaultDespachoPathForRole(role, platformRoles));
}, [role, platformRoles, router]);
return null;
}

View File

@@ -91,7 +91,7 @@ export default function OnboardingPage() {
title: 'Subir FIEL del contribuyente',
description: 'Necesaria para sincronizar con el SAT.',
icon: <Key className="h-5 w-5" />,
href: '/contribuyentes',
href: '/configuracion/sat',
completed: fielDone,
},
{
@@ -99,7 +99,7 @@ export default function OnboardingPage() {
title: 'Subir CSD (para emitir facturas)',
description: 'Certificado de Sello Digital para timbrado.',
icon: <FileText className="h-5 w-5" />,
href: '/contribuyentes',
href: '/configuracion/csd',
completed: csdDone,
},
{
@@ -178,11 +178,11 @@ export default function OnboardingPage() {
<p className="text-sm text-muted-foreground">{step.description}</p>
</div>
{!step.completed && step.href !== '#' && (
<Button variant="outline" size="sm" className="flex items-center gap-1" asChild>
<Link href={step.href}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
Configurar <ArrowRight className="h-3 w-3" />
</Button>
</Link>
</Button>
)}
</CardContent>
</Card>

View File

@@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role } from '@horux/shared';
import type { Role, UserInvite } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
@@ -83,10 +83,10 @@ export default function UsuariosPage() {
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: Role; supervisorUserId?: string }>({
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
email: '',
nombre: '',
role: defaultInviteRole as Role,
role: defaultInviteRole as UserInvite['role'],
});
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
@@ -183,7 +183,7 @@ export default function UsuariosPage() {
);
}
setShowInvite(false);
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined });
setSelectedRfcIds([]);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al invitar usuario');
@@ -211,11 +211,11 @@ export default function UsuariosPage() {
</div>
<div className="flex items-center gap-2">
{isAdmin && isDespacho && (
<Button variant="outline" className="flex items-center gap-2" asChild>
<Link href="/carteras">
<Button variant="outline" className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
</Button>
</Link>
</Button>
)}
{isAdmin && (
<Button onClick={() => setShowInvite(true)}>
@@ -263,13 +263,13 @@ export default function UsuariosPage() {
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as Role, supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
{inviteRoles.map((r: { value: string; label: string; description?: string }) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>

View File

@@ -0,0 +1,200 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { CheckCircle2, Clock, AlertTriangle, Loader2 } from 'lucide-react';
import Link from 'next/link';
interface InvitationData {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
expiresAt: string;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export default function InvitacionTrialPage() {
const params = useParams();
const router = useRouter();
const { user } = useAuthStore();
const token = typeof params.token === 'string' ? params.token : '';
const [invitation, setInvitation] = useState<InvitationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [accepting, setAccepting] = useState(false);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
if (!token) {
setError('Token de invitación inválido');
setLoading(false);
return;
}
apiClient.get<InvitationData>(`/invitations/trial/token/${token}`)
.then((res) => {
setInvitation(res.data);
setLoading(false);
})
.catch((err: any) => {
setError(err.response?.data?.message || 'Invitación no encontrada o expirada');
setLoading(false);
});
}, [token]);
async function handleAccept() {
if (!token) return;
setAccepting(true);
setError('');
try {
await apiClient.post(`/invitations/trial/${token}/accept`);
setAccepted(true);
setTimeout(() => {
router.push('/configuracion/planes-despacho');
}, 2000);
} catch (err: any) {
setError(err.response?.data?.message || 'Error al aceptar la invitación');
} finally {
setAccepting(false);
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (error && !invitation) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<AlertTriangle className="h-12 w-12 text-amber-500 mx-auto mb-2" />
<CardTitle>Invitación no válida</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">{error}</p>
<Button className="w-full" asChild>
<Link href="/login">Ir al inicio de sesión</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
if (accepted) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-2" />
<CardTitle>¡Invitación aceptada!</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
Tu despacho ahora tiene acceso a <strong>Business Control Prueba</strong> por {invitation?.durationDays} días.
</p>
<p className="text-sm text-muted-foreground">Redirigiendo a tu panel...</p>
</CardContent>
</Card>
</div>
);
}
const isExpired = invitation ? new Date(invitation.expiresAt) < new Date() : false;
const isPending = invitation?.status === 'pending';
const planDisplay = invitation?.plan === 'business_control' ? 'Business Control' : invitation?.plan;
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mx-auto bg-blue-100 dark:bg-blue-900 rounded-full p-3 w-fit mb-2">
<Clock className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-2xl">Invitación especial</CardTitle>
<p className="text-muted-foreground">
Has sido invitado a probar <strong>{planDisplay}</strong>
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="bg-muted rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Despacho</span>
<span className="text-sm font-medium">{invitation?.tenant?.nombre || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Plan</span>
<span className="text-sm font-medium">{planDisplay}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Duración</span>
<span className="text-sm font-medium">{invitation?.durationDays} días</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Expira el</span>
<span className="text-sm font-medium">
{invitation?.expiresAt
? new Date(invitation.expiresAt).toLocaleDateString('es-MX')
: '—'}
</span>
</div>
</div>
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2">¿Qué incluye?</h4>
<ul className="text-sm text-blue-700 dark:text-blue-400 space-y-1">
<li> Hasta 100 RFCs</li>
<li> Usuarios ilimitados</li>
<li> API de integración</li>
<li> SAT incremental</li>
<li> Todas las funciones de Business Control</li>
</ul>
</div>
{error && (
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">{error}</p>
)}
{!user ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground text-center">
Debes iniciar sesión con la cuenta del dueño del despacho para aceptar esta invitación.
</p>
<Button className="w-full" asChild>
<Link href={`/login?redirect=/invitacion/trial/${token}`}>Iniciar sesión</Link>
</Button>
</div>
) : isExpired || !isPending ? (
<div className="text-center">
<p className="text-sm text-muted-foreground">
Esta invitación ya no está disponible ({invitation?.status}).
</p>
</div>
) : (
<Button
className="w-full"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Activando...' : 'Aceptar invitación'}
</Button>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -177,8 +177,8 @@ export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
{cfdi.typeCambio && cfdi.typeCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.typeCambio}</p>
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
)}
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">

View File

@@ -121,7 +121,7 @@ export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
xml = await getCfdiXml(String(cfdi.id));
}
if (!xml) {

View File

@@ -13,17 +13,21 @@ interface NavItem {
roles: string[];
}
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
const ITEMS: NavItem[] = [
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'contador', 'visor', 'supervisor', 'auxiliar'] },
{ href: '/despachos/mis-asignados', label: 'Mis asignados', icon: UserCheck, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'contador', 'visor'] },
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
];
export function DespachoSubnav() {
const pathname = usePathname();
const role = useAuthStore(s => s.user?.role);
const platformRoles = useAuthStore(s => s.user?.platformRoles);
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
if (!role) return null;
const visibles = ITEMS.filter(i => i.roles.includes(role));
const visibles = ITEMS.filter(i => isPlatformStaff || i.roles.includes(role));
return (
<div className="flex border-b mb-6">
{visibles.map(item => {
@@ -50,8 +54,10 @@ export function DespachoSubnav() {
}
/** Resuelve la página default según rol al entrar a /despachos. */
export function defaultDespachoPathForRole(role: string): string {
export function defaultDespachoPathForRole(role: string, platformRoles?: string[]): string {
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
if (isPlatformStaff) return '/despachos/contribuyentes';
if (role === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
if (role === 'supervisor' || role === 'auxiliar') return '/despachos/mis-asignados';
return '/despachos/mis-asignados';
return '/despachos/contribuyentes';
}

View File

@@ -17,6 +17,15 @@ import {
Building2,
Scale,
Send,
ListChecks,
FileCheck,
ClipboardList,
CreditCard,
Gift,
UserCog,
Shield,
FileWarning,
Receipt,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -25,21 +34,38 @@ import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
interface NavItem {
name: string;
href: string;
icon: any;
feature?: string;
roles?: string[];
}
const navigation: NavItem[] = [
{ name: 'Despacho', href: '/despachos', icon: ListChecks, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor', 'cliente'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner', 'cfo', 'supervisor', 'cliente'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Configuración', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
];
const adminNavigation = [
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];
export function SidebarCompact() {
@@ -53,7 +79,7 @@ export function SidebarCompact() {
const navGate = useNavGate();
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasDespachoFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role as string)) return false;
if (!navGate.isAllowed(item.href)) return false;
return true;
});

View File

@@ -17,6 +17,15 @@ import {
Building2,
Scale,
Send,
ListChecks,
FileCheck,
ClipboardList,
CreditCard,
Gift,
UserCog,
Shield,
FileWarning,
Receipt,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -24,21 +33,38 @@ import { useNavGate } from '@/lib/hooks/use-nav-gate';
import { useRouter } from 'next/navigation';
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
interface NavItem {
name: string;
href: string;
icon: any;
feature?: string;
roles?: string[];
}
const navigation: NavItem[] = [
{ name: 'Despacho', href: '/despachos', icon: ListChecks, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor', 'cliente'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner', 'cfo', 'supervisor', 'cliente'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
];
const adminNavigation = [
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];
export function SidebarFloating() {

View File

@@ -21,10 +21,12 @@ import {
Scale,
FileCheck,
FileWarning,
Receipt,
Shield,
Rocket,
ClipboardList,
ListChecks,
Gift,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -65,6 +67,7 @@ const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];

View File

@@ -17,6 +17,15 @@ import {
Building2,
Scale,
Send,
ListChecks,
FileCheck,
ClipboardList,
CreditCard,
Gift,
UserCog,
Shield,
FileWarning,
Receipt,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
@@ -25,21 +34,38 @@ import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
interface NavItem {
name: string;
href: string;
icon: any;
feature?: string;
roles?: string[];
}
const navigation: NavItem[] = [
{ name: 'Despacho', href: '/despachos', icon: ListChecks, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor', 'cliente'] },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner'] },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes', roles: ['owner', 'cfo', 'supervisor', 'cliente'] },
{ name: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
] as const;
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo'] },
];
const adminNavigation = [
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
{ name: 'Staff', href: '/admin/staff', icon: Shield },
{ name: 'Invitaciones Trial', href: '/admin/invitaciones-trial', icon: Gift },
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
];
export function TopNav() {

View File

@@ -186,6 +186,23 @@ export interface ConceptoPrevio {
fechaEmision: string;
}
export const getPagosSinFactura = () =>
apiClient.get<Array<{
id: string;
tenantId: string;
amount: string;
status: string;
paymentMethod: string | null;
paidAt: string | null;
createdAt: string;
kind: string;
subscription: { plan: string; frequency: string } | null;
tenant: { nombre: string; rfc: string | null };
}>>('/facturacion/pagos-sin-factura').then(r => r.data);
export const emitirFacturaPago = (paymentId: string) =>
apiClient.post<{ success: boolean; invoiceId: string; paymentId: string }>(`/facturacion/emitir-factura-pago/${paymentId}`).then(r => r.data);
export const searchConceptos = (q: string, tipo?: string, contribuyenteId?: string | null) => {
const params = new URLSearchParams();
if (q) params.set('q', q);

View File

@@ -1,5 +1,12 @@
import { apiClient } from './client';
export interface TenantSubscription {
id: string;
amount: number;
currentPeriodEnd: string | null;
status: string;
}
export interface Tenant {
id: string;
nombre: string;
@@ -11,6 +18,7 @@ export interface Tenant {
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
memberships: number;
};
subscriptions?: TenantSubscription[];
}
export type TenantPlan = 'trial' | 'custom' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
@@ -46,6 +54,8 @@ export interface UpdateTenantData {
rfc?: string;
plan?: TenantPlan;
active?: boolean;
amount?: number;
firstPaymentDueAt?: string | null;
}
export async function updateTenant(id: string, data: UpdateTenantData): Promise<Tenant> {

View File

@@ -0,0 +1,70 @@
import { apiClient } from './client';
export interface TrialInvitation {
id: string;
tenantId: string;
plan: string;
durationDays: number;
status: string;
token: string;
sentAt: string;
expiresAt: string;
acceptedAt: string | null;
tenant: {
nombre: string;
rfc: string;
} | null;
}
export async function getPendingInvitation(): Promise<TrialInvitation | null> {
try {
const response = await apiClient.get<TrialInvitation | null>('/invitations/trial/pending');
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) return null;
throw error;
}
}
export async function getInvitationByToken(token: string): Promise<TrialInvitation | null> {
try {
const response = await apiClient.get<TrialInvitation | null>(`/invitations/trial/token/${token}`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) return null;
throw error;
}
}
export async function acceptInvitation(token: string): Promise<{
success: boolean;
trialEndsAt: string;
plan: string;
durationDays: number;
}> {
const response = await apiClient.post(`/invitations/trial/${token}/accept`);
return response.data;
}
// Admin endpoints
export async function getAllInvitations(filters?: { tenantId?: string; status?: string }): Promise<TrialInvitation[]> {
const params = new URLSearchParams();
if (filters?.tenantId) params.append('tenantId', filters.tenantId);
if (filters?.status) params.append('status', filters.status);
const response = await apiClient.get<TrialInvitation[]>(`/invitations/trial?${params.toString()}`);
return response.data;
}
export async function createInvitation(data: {
tenantId: string;
plan?: string;
durationDays: number;
}): Promise<TrialInvitation> {
const response = await apiClient.post<TrialInvitation>('/invitations/trial', data);
return response.data;
}
export async function cancelInvitation(id: string): Promise<TrialInvitation> {
const response = await apiClient.post<TrialInvitation>(`/invitations/trial/${id}/cancel`);
return response.data;
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPagosSinFactura, emitirFacturaPago } from '../api/facturacion';
export function usePagosSinFactura() {
return useQuery({
queryKey: ['pagos-sin-factura'],
queryFn: () => getPagosSinFactura(),
staleTime: 10 * 1000,
});
}
export function useEmitirFacturaPago() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (paymentId: string) => emitirFacturaPago(paymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pagos-sin-factura'] });
},
});
}

View File

@@ -1,10 +1,15 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTenants, createTenant, updateTenant, deleteTenant, type CreateTenantData, type UpdateTenantData } from '@/lib/api/tenants';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
export function useTenants() {
const user = useAuthStore(s => s.user);
const isGlobal = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
return useQuery({
queryKey: ['tenants'],
queryFn: getTenants,
enabled: isGlobal,
});
}

194
docs/CAMBIOS-2026-05-09.md Normal file
View File

@@ -0,0 +1,194 @@
# Resumen de cambios - 9 de mayo de 2026
## 1. Sincronización de pago - Alexa Torres
**Problema:** Alexa Torres (tenant `45ddd745-5037-4325-b3ec-1a85cbf7b849`) pagó $780 vía MercadoPago exitosamente, pero la suscripción seguía en estado `pending`. No llegó webhook.
**Causa raíz:**
- `.env` tenía `MP_ACCES_TOKEN` (1 S) en lugar de `MP_ACCESS_TOKEN` (2 S)
- La aplicación de MercadoPago tenía URL de webhook incorrecta (`https://www.horuxfin.com`) y sin tópicos suscritos
**Acciones:**
- Corregido typo en `.env`: `MP_ACCESS_TOKEN`
- Sincronizado manualmente el pago en BD:
- Creado registro `Payment` con `mpPaymentId = 158527899608`
- Actualizado suscripción a `status = authorized`
- Actualizado `currentPeriodEnd = 2026-06-09`
- Configurada URL de webhook en dashboard de MercadoPago: `https://horuxfin.com/api/webhooks/mercadopago`
- Seleccionados tópicos: `payment`, `subscription_preapproval`
**Estado:** ✅ Resuelto
---
## 2. Fix: Webhook MercadoPago - validación de firma
**Problema:** Error recurrente en logs:
```
TypeError: Cannot read properties of undefined (reading 'trim')
```
**Causa raíz:** `mercadopago.service.ts::verifyWebhookSignature` asumía que `x-signature` siempre tenía formato `key=value` bien formado.
**Fix:**
```ts
// Antes
const [key, value] = part.split('=');
parts[key.trim()] = value.trim();
// Después
const [key, value] = part.split('=');
if (!key || value === undefined) continue;
parts[key.trim()] = value.trim();
```
**Archivo:** `apps/api/src/services/payment/mercadopago.service.ts`
---
## 3. Notificación de primer pago pendiente de factura
**Problema:** Cuando un tenant realiza su primer pago, el sistema no factura automáticamente (por diseño), pero tampoco notifica al admin global.
### 3.1 Email al admin global
**Nuevos archivos:**
- `apps/api/src/services/email/templates/primer-pago-facturar.ts` — Template HTML del email
**Modificaciones:**
- `apps/api/src/services/email/email.service.ts` — Agregada función `sendPrimerPagoFacturar()`
- `apps/api/src/services/payment/invoicing.service.ts` — Cuando `emitInvoiceIfApplicable` detecta primer pago, envía email al admin
**Contenido del email:**
- Nombre, RFC del cliente
- Plan, monto, fecha de pago
- Botón directo a `/admin/facturas-pendientes`
### 3.2 Endpoints para admin global
**Nuevos endpoints en `apps/api/src/routes/facturacion.routes.ts`:**
- `GET /facturacion/pagos-sin-factura` — Lista payments `approved` sin `facturapiInvoiceId`
- `POST /facturacion/emitir-factura-pago/:paymentId` — Emite factura manual de un payment
**Nuevas funciones en `apps/api/src/controllers/facturacion.controller.ts`:**
- `getPagosSinFactura()` — Query con `hasPlatformRole('platform_admin')`
- `emitirFacturaPago()` — Emite factura usando datos fiscales del tenant pagador
**Exports agregados en `apps/api/src/services/payment/invoicing.service.ts`:**
- `getEmitterTenant()`
- `getCustomerFromTenant()`
- `buildInvoicePayload()`
### 3.3 Página de admin
**Nuevos archivos:**
- `apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx` — Tabla de pagos sin factura con botón "Emitir factura"
- `apps/web/lib/hooks/use-pagos-sin-factura.ts` — Hooks React Query
**Modificaciones:**
- `apps/web/lib/api/facturacion.ts` — Funciones `getPagosSinFactura()` y `emitirFacturaPago()`
- `apps/web/app/(dashboard)/clientes/page.tsx` — Métrica "Facturas pendientes" en KPIs
---
## 4. Fix: Vinculación de organización Facturapi - Horux 360
**Problema:** El tenant emisor Horux 360 (RFC `HTS240708LJA`) no tenía organización Facturapi vinculada. Al intentar emitir facturas daba:
```
Tenant emisor no tiene organización Facturapi
```
**Descubrimiento:** La BD del tenant (`horux_hts240708lja`) tenía una org incorrecta en `facturapi_orgs` (`69ff900f48058f06ef1234c0`) que no existía en Facturapi.
**Acciones:**
### BD Central
```sql
UPDATE tenants
SET facturapi_org_id = '69f23a5a242e0af47a41fa0d',
facturapi_org_key_enc = <encriptado>,
facturapi_org_key_iv = <encriptado>,
facturapi_org_key_tag = <encriptado>
WHERE rfc = 'HTS240708LJA';
```
### BD del tenant (`horux_hts240708lja`)
```sql
UPDATE facturapi_orgs
SET facturapi_org_id = '69f23a5a242e0af47a41fa0d',
api_key_enc = <encriptado>,
api_key_iv = <encriptado>,
api_key_tag = <encriptado>
WHERE contribuyente_id = '96f98a42-5f27-4f27-acf6-61822dea666c';
```
**API key generada:** `sk_live_bQC3XW7ZUVZxp9k9utN7DP6bRqehFZnZPtXhnDf1v1`
**Estado:** ✅ Resuelto
---
## 5. Fix: Autocompletado de RFCs y conceptos en facturación
**Problema:** Cuando un contribuyente estaba seleccionado en el dashboard, el autocompletado de RFCs y conceptos devolvía vacío si ese contribuyente no tenía CFDIs previos.
**Causa raíz:** Ambos endpoints filtraban por `contribuyente_id`, buscando solo en el historial del contribuyente activo.
**Fix aplicado:**
- `searchRfcs()` — eliminado filtro por `contribuyenteId`. Ahora busca en el catálogo completo de `rfcs`.
- `searchConceptos()` — eliminado filtro por `contribuyenteId`. Ahora busca conceptos en todos los CFDIs del tenant.
**Archivo:** `apps/api/src/controllers/facturacion.controller.ts`
---
## Archivos modificados
### Backend (`apps/api/`)
| Archivo | Cambio |
|---|---|
| `.env` | Fix typo `MP_ACCESS_TOKEN` |
| `src/services/payment/mercadopago.service.ts` | Fix validación firma webhook |
| `src/services/payment/invoicing.service.ts` | Notificación email + exports |
| `src/services/email/email.service.ts` | `sendPrimerPagoFacturar()` |
| `src/services/email/templates/primer-pago-facturar.ts` | **Nuevo** template |
| `src/controllers/facturacion.controller.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` + fix `searchRfcs()` + fix `searchConceptos()` |
| `src/routes/facturacion.routes.ts` | Rutas `/pagos-sin-factura` + `/emitir-factura-pago/:paymentId` |
### Frontend (`apps/web/`)
| Archivo | Cambio |
|---|---|
| `lib/api/facturacion.ts` | `getPagosSinFactura()` + `emitirFacturaPago()` |
| `lib/hooks/use-pagos-sin-factura.ts` | **Nuevo** hooks |
| `app/(dashboard)/admin/facturas-pendientes/page.tsx` | **Nuevo** página admin |
| `app/(dashboard)/clientes/page.tsx` | KPI "Facturas pendientes" |
| `components/layouts/sidebar.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/sidebar-floating.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/sidebar-compact.tsx` | Removido "Facturas Pendientes" del menú admin |
| `components/layouts/topnav.tsx` | Removido "Facturas Pendientes" del menú admin |
---
## Configuración requerida en MercadoPago Dashboard
- **Aplicación:** Horux360 (ID: `5319386258998241`)
- **Webhook URL:** `https://horuxfin.com/api/webhooks/mercadopago`
- **Tópicos:** `payment`, `subscription_preapproval`
---
## Datos de organizaciones Facturapi
| Org | RFC | Uso |
|---|---|---|
| `69f23a5a242e0af47a41fa0d` | HTS240708LJA | Horux 360 (emisor principal) — ✅ Activa |
| `69ff900f48058f06ef1234c0` | — | Org fantasma (eliminada de BD) — ❌ Obsoleta |
| `69ff8fabc2053c5568d799c5` | XIA190128J61 | Org creada accidentalmente durante diagnóstico — ❌ Obsoleta |
---
## Notas técnicas
- La encriptación de API keys usa AES-256-GCM con clave derivada de `FIEL_ENCRYPTION_KEY` (SHA-256)
- El endpoint `POST /emitir-factura-pago/:paymentId` requiere rol `platform_admin`
- La regla "primer pago no se factura automáticamente" sigue vigente; los subsecuentes sí son automáticos

View File

@@ -3,9 +3,9 @@ module.exports = {
{
name: 'horux-api',
interpreter: 'node',
script: '/root/Horux/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
script: '/root/HoruxDespachosNuevo/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
args: 'src/index.ts',
cwd: '/root/Horux/apps/api',
cwd: '/root/HoruxDespachosNuevo/apps/api',
instances: 1,
exec_mode: 'fork',
autorestart: true,
@@ -21,7 +21,7 @@ module.exports = {
name: 'horux-web',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
cwd: '/root/HoruxDespachosNuevo/apps/web',
instances: 1,
exec_mode: 'fork',
autorestart: true,

View File

@@ -65,7 +65,7 @@ export const DESPACHO_PLANS = {
maxUsers: -1,
maxCfdisPorContribuyente: 1_000_000,
timbresIncluidosMes: 0,
dbMode: 'BYO' as const,
dbMode: 'MANAGED' as const,
permiteServidorBackup: true,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
@@ -101,7 +101,7 @@ export const DESPACHO_PLANS = {
maxUsers: -1,
maxCfdisPorContribuyente: 3_000_000,
timbresIncluidosMes: 0,
dbMode: 'BYO' as const,
dbMode: 'MANAGED' as const,
permiteServidorBackup: true,
features: [
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Script de monitoreo post-ciclo SAT 3:00 AM
set -euo pipefail
LOG_FILE="/root/.pm2/logs/horux-api-out-0.log"
ERROR_LOG="/root/.pm2/logs/horux-api-error-0.log"
REPORT_FILE="/tmp/sat_cron_report_$(date +%Y%m%d).txt"
echo "========================================" > "$REPORT_FILE"
echo "REPORTE DE EXTRACCION SAT 3:00 AM" >> "$REPORT_FILE"
echo "Fecha: $(date)" >> "$REPORT_FILE"
echo "========================================" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# Buscar el ciclo de hoy (asumiendo que corre entre 07:50 y 08:10 UTC)
echo "--- ULTIMO CICLO SAT CRON ---" >> "$REPORT_FILE"
grep -n "\[SAT Cron\] Iniciando ciclo" "$LOG_FILE" | tail -5 >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# Errores específicos del ciclo
echo "--- ERRORES EN LOG DE ERRORES ---" >> "$REPORT_FILE"
grep -n "DateTimeParseError\|P2000\|Error.*SAT\|Error.*extraccion\|Error.*CFDI\|Error.*guardar" "$ERROR_LOG" | tail -20 >> "$REPORT_FILE" || echo "Sin errores específicos encontrados" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# Resumen de tenants procesados
echo "--- RESUMEN DE PROCESAMIENTO ---" >> "$REPORT_FILE"
grep -n "Procesando tenant\|tenant procesado\|CFDIs insertados\|CFDIs omitidos\|Error procesando tenant" "$LOG_FILE" | tail -20 >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# Errores de parsing de fechas específicamente
echo "--- ERRORES DE FECHA (DateTimeParseError) ---" >> "$REPORT_FILE"
grep -n "DateTimeParseError\|Invalid value for argument\`fecha" "$ERROR_LOG" | tail -10 >> "$REPORT_FILE" || echo "Sin errores de fecha" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
# Verificar contribuyentes y CFDIs de Husberto (TOAH680201RA2)
echo "--- ESTADO HUSBERTO (TOAH680201RA2) ---" >> "$REPORT_FILE"
PGPASSWORD=ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb psql -h localhost -U postgres -d horux360 -t -c "SELECT COUNT(*) FROM cfdis WHERE contribuyente_id = '128c0ab0-b307-492b-bb82-7e55d390f41f';" >> "$REPORT_FILE"
echo "CFDIs asignados al contribuyente Husberto (arriba)" >> "$REPORT_FILE"
echo "" >> "$REPORT_FILE"
cat "$REPORT_FILE"

187
scripts/reprocess_bom.js Normal file
View File

@@ -0,0 +1,187 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const { parseXml } = require('/root/HoruxDespachosNuevo/apps/api/dist/services/sat/sat-parser.service.js');
const DB_PASSWORD = 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb';
const BASE_DIR = '/root/HoruxDespachosNuevo/apps/api/data/xmls';
function getPool(dbName) {
return new Pool({ host: 'localhost', user: 'postgres', password: DB_PASSWORD, database: dbName });
}
async function reprocessXml(filePath, rfc, tipoCfdi) {
let xmlContent = fs.readFileSync(filePath, 'utf-8');
if (xmlContent.charCodeAt(0) === 0xFEFF) {
xmlContent = xmlContent.slice(1);
}
const cfdi = parseXml(xmlContent, tipoCfdi);
if (!cfdi) {
console.log(` SKIP: parseXml returned null for ${filePath}`);
return false;
}
const dbName = `horux_${rfc.toLowerCase()}`;
const pool = getPool(dbName);
try {
const uuidNorm = cfdi.uuid.toLowerCase();
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE LOWER(uuid) = $1`, [uuidNorm]
);
if (existing.length === 0) {
console.log(` SKIP: CFDI ${uuidNorm} not found in DB ${dbName}`);
return false;
}
const cfdiId = existing[0].id;
const tc = cfdi.tipoCambio || 1;
const m = (v) => (v || 0) * tc;
// Update cfdis
await pool.query(
`UPDATE cfdis SET
serie = $1, folio = $2, status = $3, fecha_emision = $4, fecha_cert_sat = $5,
rfc_emisor = $6, nombre_emisor = $7, rfc_receptor = $8, nombre_receptor = $9,
subtotal = $10, subtotal_mxn = $11, descuento = $12, descuento_mxn = $13,
total = $14, total_mxn = $15, moneda = $16, tipo_cambio = $17,
metodo_pago = $18, forma_pago = $19, uso_cfdi = $20, pac = $21,
isr_retencion = $22, isr_retencion_mxn = $23,
iva_traslado = $24, iva_traslado_mxn = $25,
iva_retencion = $26, iva_retencion_mxn = $27,
ieps_traslado = $28, ieps_traslado_mxn = $29,
ieps_retencion = $30, ieps_retencion_mxn = $31,
impuestos_locales_trasladado = $32, impuestos_locales_trasladado_mxn = $33,
impuestos_locales_retenidos = $34, impuestos_locales_retenidos_mxn = $35,
monto_pago = $36, monto_pago_mxn = $37,
fecha_pago_p = $38, num_parcialidad = $39,
isr_retencion_pago = $40, isr_retencion_pago_mxn = $41,
iva_traslado_pago = $42, iva_traslado_pago_mxn = $43,
iva_retencion_pago = $44, iva_retencion_pago_mxn = $45,
ieps_traslado_pago = $46, ieps_traslado_pago_mxn = $47,
ieps_retencion_pago = $48, ieps_retencion_pago_mxn = $49,
fecha_pago = $50, fecha_inicial_pago = $51, fecha_final_pago = $52,
num_dias_pagados = $53, num_seguro_social = $54, puesto = $55,
salario_base_cot_apor = $56, salario_base_cot_apor_mxn = $57,
salario_diario_integrado = $58, salario_diario_integrado_mxn = $59,
total_percepciones = $60, total_percepciones_mxn = $61,
total_deducciones = $62, total_deducciones_mxn = $63,
imp_retenidos_nomina = $64, imp_retenidos_nomina_mxn = $65,
otras_deducciones_nomina = $66, otras_deducciones_nomina_mxn = $67,
subsidio_causado = $68, subsidio_causado_mxn = $69,
regimen_fiscal_emisor = $70, regimen_fiscal_receptor = $71,
xml_original = $72, cfdi_tipo_relacion = $73, cfdis_relacionados = $74,
saldo_insoluto = $75, uuid_relacionado = $76,
actualizado_en = NOW()
WHERE id = $77`,
[
cfdi.serie, cfdi.folio, cfdi.status, cfdi.fechaEmision, cfdi.fechaCertSat,
cfdi.rfcEmisor, cfdi.nombreEmisor, cfdi.rfcReceptor, cfdi.nombreReceptor,
cfdi.subtotal, m(cfdi.subtotal), cfdi.descuento, m(cfdi.descuento),
cfdi.total, m(cfdi.total), cfdi.moneda, cfdi.tipoCambio,
cfdi.metodoPago, cfdi.formaPago, cfdi.usoCfdi, cfdi.pac,
cfdi.isrRetencion, m(cfdi.isrRetencion),
cfdi.ivaTraslado, m(cfdi.ivaTraslado),
cfdi.ivaRetencion, m(cfdi.ivaRetencion),
cfdi.iepsTraslado, m(cfdi.iepsTraslado),
cfdi.iepsRetencion, m(cfdi.iepsRetencion),
cfdi.impuestosLocalesTrasladado, m(cfdi.impuestosLocalesTrasladado),
cfdi.impuestosLocalesRetenidos, m(cfdi.impuestosLocalesRetenidos),
cfdi.montoPago, m(cfdi.montoPago),
cfdi.fechaPagoP, cfdi.numParcialidad,
cfdi.isrRetencionPago, m(cfdi.isrRetencionPago),
cfdi.ivaTrasladoPago, m(cfdi.ivaTrasladoPago),
cfdi.ivaRetencionPago, m(cfdi.ivaRetencionPago),
cfdi.iepsTrasladoPago, m(cfdi.iepsTrasladoPago),
cfdi.iepsRetencionPago, m(cfdi.iepsRetencionPago),
cfdi.fechaPago, cfdi.fechaInicialPago, cfdi.fechaFinalPago,
cfdi.numDiasPagados, cfdi.numSeguroSocial, cfdi.puesto,
cfdi.salarioBaseCotApor, m(cfdi.salarioBaseCotApor),
cfdi.salarioDiarioIntegrado, m(cfdi.salarioDiarioIntegrado),
cfdi.totalPercepciones, m(cfdi.totalPercepciones),
cfdi.totalDeducciones, m(cfdi.totalDeducciones),
cfdi.impRetenidosNomina, m(cfdi.impRetenidosNomina),
cfdi.otrasDeduccionesNomina, m(cfdi.otrasDeduccionesNomina),
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
xmlContent, cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
cfdi.saldoInsoluto, cfdi.uuidRelacionado,
cfdiId
]
);
// Re-insert conceptos
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [cfdiId]);
for (const c of cfdi.conceptos || []) {
await pool.query(
`INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad,
clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn,
descuento, descuento_mxn,
isr_retencion, isr_retencion_mxn, iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn, ieps_traslado, ieps_traslado_mxn,
ieps_retencion, ieps_retencion_mxn
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)`,
[
cfdiId, c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
c.claveUnidad, c.unidad, c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
c.descuento, m(c.descuento),
c.isrRetencion, m(c.isrRetencion), c.ivaTraslado, m(c.ivaTraslado),
c.ivaRetencion, m(c.ivaRetencion), c.iepsTraslado, m(c.iepsTraslado),
c.iepsRetencion, m(c.iepsRetencion)
]
);
}
console.log(` OK: ${uuidNorm} updated in ${dbName}`);
return true;
} catch (err) {
console.error(` ERROR: ${filePath} - ${err.message}`);
return false;
} finally {
await pool.end();
}
}
async function main() {
let processed = 0;
let skipped = 0;
let errors = 0;
const rfcs = fs.readdirSync(BASE_DIR).filter(d => fs.statSync(path.join(BASE_DIR, d)).isDirectory());
for (const rfc of rfcs) {
const rfcDir = path.join(BASE_DIR, rfc);
const tipos = fs.readdirSync(rfcDir).filter(d => fs.statSync(path.join(rfcDir, d)).isDirectory());
for (const tipo of tipos) {
const tipoDir = path.join(rfcDir, tipo);
const packages = fs.readdirSync(tipoDir).filter(d => fs.statSync(path.join(tipoDir, d)).isDirectory());
for (const pkg of packages) {
const pkgDir = path.join(tipoDir, pkg);
const files = fs.readdirSync(pkgDir).filter(f => f.endsWith('.xml'));
for (const file of files) {
const filePath = path.join(pkgDir, file);
const buf = fs.readFileSync(filePath);
if (buf.length < 3 || !(buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF)) {
continue;
}
console.log(`Processing ${filePath}...`);
const ok = await reprocessXml(filePath, rfc, tipo);
if (ok) processed++;
else skipped++;
if (!ok) errors++;
}
}
}
}
console.log(`\nDone. Processed: ${processed}, Skipped/Errors: ${errors}`);
}
main().catch(console.error);

View File

@@ -0,0 +1,164 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const { parseXml } = require('/root/HoruxDespachosNuevo/apps/api/dist/services/sat/sat-parser.service.js');
const DB_PASSWORD = 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb';
function getPool(dbName) {
return new Pool({ host: 'localhost', user: 'postgres', password: DB_PASSWORD, database: dbName });
}
async function reprocessXml(filePath, rfc, tipoCfdi) {
let xmlContent = fs.readFileSync(filePath, 'utf-8');
if (xmlContent.charCodeAt(0) === 0xFEFF) {
xmlContent = xmlContent.slice(1);
}
const cfdi = parseXml(xmlContent, tipoCfdi);
if (!cfdi) {
console.log(` SKIP: parseXml returned null for ${filePath}`);
return false;
}
const dbName = `horux_${rfc.toLowerCase()}`;
const pool = getPool(dbName);
try {
const uuidNorm = cfdi.uuid.toLowerCase();
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE LOWER(uuid) = $1`, [uuidNorm]
);
if (existing.length === 0) {
console.log(` SKIP: CFDI ${uuidNorm} not found in DB ${dbName}`);
return false;
}
const cfdiId = existing[0].id;
const tc = cfdi.tipoCambio || 1;
const m = (v) => (v || 0) * tc;
await pool.query(
`UPDATE cfdis SET
serie = $1, folio = $2, status = $3, fecha_emision = $4, fecha_cert_sat = $5,
rfc_emisor = $6, nombre_emisor = $7, rfc_receptor = $8, nombre_receptor = $9,
subtotal = $10, subtotal_mxn = $11, descuento = $12, descuento_mxn = $13,
total = $14, total_mxn = $15, moneda = $16, tipo_cambio = $17,
metodo_pago = $18, forma_pago = $19, uso_cfdi = $20, pac = $21,
isr_retencion = $22, isr_retencion_mxn = $23,
iva_traslado = $24, iva_traslado_mxn = $25,
iva_retencion = $26, iva_retencion_mxn = $27,
ieps_traslado = $28, ieps_traslado_mxn = $29,
ieps_retencion = $30, ieps_retencion_mxn = $31,
impuestos_locales_trasladado = $32, impuestos_locales_trasladado_mxn = $33,
impuestos_locales_retenidos = $34, impuestos_locales_retenidos_mxn = $35,
monto_pago = $36, monto_pago_mxn = $37,
fecha_pago_p = $38, num_parcialidad = $39,
isr_retencion_pago = $40, isr_retencion_pago_mxn = $41,
iva_traslado_pago = $42, iva_traslado_pago_mxn = $43,
iva_retencion_pago = $44, iva_retencion_pago_mxn = $45,
ieps_traslado_pago = $46, ieps_traslado_pago_mxn = $47,
ieps_retencion_pago = $48, ieps_retencion_pago_mxn = $49,
fecha_pago = $50, fecha_inicial_pago = $51, fecha_final_pago = $52,
num_dias_pagados = $53, num_seguro_social = $54, puesto = $55,
salario_base_cot_apor = $56, salario_base_cot_apor_mxn = $57,
salario_diario_integrado = $58, salario_diario_integrado_mxn = $59,
total_percepciones = $60, total_percepciones_mxn = $61,
total_deducciones = $62, total_deducciones_mxn = $63,
imp_retenidos_nomina = $64, imp_retenidos_nomina_mxn = $65,
otras_deducciones_nomina = $66, otras_deducciones_nomina_mxn = $67,
subsidio_causado = $68, subsidio_causado_mxn = $69,
regimen_fiscal_emisor = $70, regimen_fiscal_receptor = $71,
xml_original = $72, cfdi_tipo_relacion = $73, cfdis_relacionados = $74,
saldo_insoluto = $75, uuid_relacionado = $76,
actualizado_en = NOW()
WHERE id = $77`,
[
cfdi.serie, cfdi.folio, cfdi.status, cfdi.fechaEmision, cfdi.fechaCertSat,
cfdi.rfcEmisor, cfdi.nombreEmisor, cfdi.rfcReceptor, cfdi.nombreReceptor,
cfdi.subtotal, m(cfdi.subtotal), cfdi.descuento, m(cfdi.descuento),
cfdi.total, m(cfdi.total), cfdi.moneda, cfdi.tipoCambio,
cfdi.metodoPago, cfdi.formaPago, cfdi.usoCfdi, cfdi.pac,
cfdi.isrRetencion, m(cfdi.isrRetencion),
cfdi.ivaTraslado, m(cfdi.ivaTraslado),
cfdi.ivaRetencion, m(cfdi.ivaRetencion),
cfdi.iepsTraslado, m(cfdi.iepsTraslado),
cfdi.iepsRetencion, m(cfdi.iepsRetencion),
cfdi.impuestosLocalesTrasladado, m(cfdi.impuestosLocalesTrasladado),
cfdi.impuestosLocalesRetenidos, m(cfdi.impuestosLocalesRetenidos),
cfdi.montoPago, m(cfdi.montoPago),
cfdi.fechaPagoP, cfdi.numParcialidad,
cfdi.isrRetencionPago, m(cfdi.isrRetencionPago),
cfdi.ivaTrasladoPago, m(cfdi.ivaTrasladoPago),
cfdi.ivaRetencionPago, m(cfdi.ivaRetencionPago),
cfdi.iepsTrasladoPago, m(cfdi.iepsTrasladoPago),
cfdi.iepsRetencionPago, m(cfdi.iepsRetencionPago),
cfdi.fechaPago, cfdi.fechaInicialPago, cfdi.fechaFinalPago,
cfdi.numDiasPagados, cfdi.numSeguroSocial, cfdi.puesto,
cfdi.salarioBaseCotApor, m(cfdi.salarioBaseCotApor),
cfdi.salarioDiarioIntegrado, m(cfdi.salarioDiarioIntegrado),
cfdi.totalPercepciones, m(cfdi.totalPercepciones),
cfdi.totalDeducciones, m(cfdi.totalDeducciones),
cfdi.impRetenidosNomina, m(cfdi.impRetenidosNomina),
cfdi.otrasDeduccionesNomina, m(cfdi.otrasDeduccionesNomina),
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
xmlContent, cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
cfdi.saldoInsoluto, cfdi.uuidRelacionado,
cfdiId
]
);
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [cfdiId]);
for (const c of cfdi.conceptos || []) {
await pool.query(
`INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad,
clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn,
descuento, descuento_mxn,
isr_retencion, isr_retencion_mxn, iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn, ieps_traslado, ieps_traslado_mxn,
ieps_retencion, ieps_retencion_mxn
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)`,
[
cfdiId, c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
c.claveUnidad, c.unidad, c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
c.descuento, m(c.descuento),
c.isrRetencion, m(c.isrRetencion), c.ivaTraslado, m(c.ivaTraslado),
c.ivaRetencion, m(c.ivaRetencion), c.iepsTraslado, m(c.iepsTraslado),
c.iepsRetencion, m(c.iepsRetencion)
]
);
}
console.log(` OK: ${uuidNorm} updated in ${dbName}`);
return true;
} catch (err) {
console.error(` ERROR: ${filePath} - ${err.message}`);
return false;
} finally {
await pool.end();
}
}
async function main() {
const files = process.argv.slice(2);
if (files.length === 0) {
console.error('Usage: node reprocess_duplicate_timbres.js <xml-path> [...]');
process.exit(1);
}
let processed = 0;
for (const filePath of files) {
const parts = filePath.split('/');
const rfc = parts[parts.indexOf('xmls') + 1];
const tipo = parts[parts.indexOf('xmls') + 2];
console.log(`Processing ${filePath}...`);
const ok = await reprocessXml(filePath, rfc, tipo);
if (ok) processed++;
}
console.log(`\nDone. Processed: ${processed}/${files.length}`);
}
main().catch(console.error);

47
scripts/test_auza_sync.js Normal file
View File

@@ -0,0 +1,47 @@
const { startSync, getSyncStatus } = require('/root/HoruxDespachosNuevo/apps/api/dist/services/sat/sat.service.js');
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6';
const DAYS = 15;
async function main() {
const dateTo = new Date();
const dateFrom = new Date(dateTo);
dateFrom.setDate(dateFrom.getDate() - DAYS);
console.log(`[Test] Iniciando sync initial para AUZA640701TI9`);
console.log(`[Test] Rango: ${dateFrom.toISOString().slice(0,10)}${dateTo.toISOString().slice(0,10)}`);
try {
const jobId = await startSync(TENANT_ID, 'initial', dateFrom, dateTo);
console.log(`[Test] Job creado: ${jobId}`);
// Monitorear progreso
let completed = false;
let attempts = 0;
const maxAttempts = 60; // ~30 minutos (30s * 60)
while (!completed && attempts < maxAttempts) {
await new Promise(r => setTimeout(r, 30000));
attempts++;
const status = await getSyncStatus(TENANT_ID);
console.log(`[Test] Intento ${attempts}: status=${status.currentJob?.status || 'none'}, progress=${status.currentJob?.progressPercent ?? 0}%, found=${status.currentJob?.cfdisFound ?? 0}, inserted=${status.currentJob?.cfdisInserted ?? 0}`);
if (!status.hasActiveSync) {
completed = true;
console.log(`[Test] Sync finalizado. Último job: ${status.lastCompletedJob?.status || 'N/A'}`);
if (status.lastCompletedJob?.errorMessage) {
console.log(`[Test] Error: ${status.lastCompletedJob.errorMessage}`);
}
}
}
if (!completed) {
console.log(`[Test] Timeout después de ${maxAttempts} intentos. El job sigue corriendo.`);
}
} catch (err) {
console.error(`[Test] Error iniciando sync:`, err.message);
}
}
main().catch(console.error);