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:
@@ -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");
|
||||||
@@ -477,6 +477,29 @@ model TrialUsage {
|
|||||||
@@map("trial_usages")
|
@@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.
|
/// Catálogo despacho — precios + limits editables por admin global.
|
||||||
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
/// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`)
|
||||||
/// porque están acopladas a UI/middleware y son contrato de código.
|
/// porque están acopladas a UI/middleware y son contrato de código.
|
||||||
|
|||||||
@@ -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: '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', 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: '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_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: '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: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true },
|
||||||
];
|
];
|
||||||
for (const p of DESPACHO_PLAN_CATALOGO) {
|
for (const p of DESPACHO_PLAN_CATALOGO) {
|
||||||
await prisma.despachoPlanPrice.upsert({
|
await prisma.despachoPlanPrice.upsert({
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
|
|||||||
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
||||||
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||||
import adminAddonsRoutes from './routes/admin-addons.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 despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||||
import metricasRoutes from './routes/metricas.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/admin/addons', adminAddonsRoutes);
|
||||||
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
||||||
app.use('/api/metricas', metricasRoutes);
|
app.use('/api/metricas', metricasRoutes);
|
||||||
|
app.use('/api/invitations/trial', trialInvitationRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
|
|||||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
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) {
|
if (!isAdmin) {
|
||||||
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
|
res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ function effectiveTenantId(req: Request): string {
|
|||||||
const ROLES_OWNER = new Set(['owner', 'cfo']);
|
const ROLES_OWNER = new Set(['owner', 'cfo']);
|
||||||
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
|
const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']);
|
||||||
const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']);
|
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) {
|
export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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');
|
throw new AppError(403, 'Solo owner puede ver estas métricas');
|
||||||
}
|
}
|
||||||
const tenantId = effectiveTenantId(req);
|
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) {
|
export async function getMisAsignados(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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');
|
throw new AppError(403, 'No tienes contribuyentes asignados');
|
||||||
}
|
}
|
||||||
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
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) {
|
export async function getEquipoStats(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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');
|
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;
|
const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined;
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ const signupSchema = z.object({
|
|||||||
regimenFiscal: z.string().optional(),
|
regimenFiscal: z.string().optional(),
|
||||||
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
||||||
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
||||||
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'),
|
// plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico.
|
||||||
// Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son
|
// Se mantienen opcionales para compatibilidad backward con clientes antiguos.
|
||||||
// anuales fijos). Default annual sesga el cash-flow del negocio.
|
plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(),
|
||||||
frequency: z.enum(['monthly', 'annual']).optional().default('annual'),
|
frequency: z.enum(['monthly', 'annual']).optional(),
|
||||||
}),
|
}),
|
||||||
owner: z.object({
|
owner: z.object({
|
||||||
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
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
|
// planes, dbMode también es MANAGED y reportar `business_cloud` daba
|
||||||
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
|
// mapeo equivocado. tenant.plan es la fuente de verdad post-migración
|
||||||
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
|
// 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum).
|
||||||
let currentPlan: string;
|
//
|
||||||
if (isTrialActive) {
|
// FIX: Si hay una subscription en trial con un plan específico (ej.
|
||||||
currentPlan = 'trial';
|
// business_control desde una TrialInvitation), respetamos ese plan
|
||||||
} else {
|
// para que el feature-gate y los límites funcionen correctamente.
|
||||||
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.
|
|
||||||
const subscription = await prisma.subscription.findFirst({
|
const subscription = await prisma.subscription.findFirst({
|
||||||
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } },
|
||||||
orderBy: { createdAt: 'desc' },
|
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({
|
return res.json({
|
||||||
plan: currentPlan,
|
plan: currentPlan,
|
||||||
dbMode: tenant.dbMode,
|
dbMode: tenant.dbMode,
|
||||||
|
|||||||
@@ -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'))`;
|
whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : '';
|
|
||||||
|
|
||||||
let whereSearch = '';
|
let whereSearch = '';
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
if (q.length >= 2) {
|
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
|
JOIN cfdis c ON cc.cfdi_id = c.id
|
||||||
WHERE c.status NOT IN ('Cancelado', '0')
|
WHERE c.status NOT IN ('Cancelado', '0')
|
||||||
${whereType}
|
${whereType}
|
||||||
${whereContrib}
|
|
||||||
${whereSearch}
|
${whereSearch}
|
||||||
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
@@ -664,30 +661,11 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
|
|||||||
});
|
});
|
||||||
const tenantRfc = tenant?.rfc || '';
|
const tenantRfc = tenant?.rfc || '';
|
||||||
|
|
||||||
// En multi-RFC con contribuyente activo, filtrar a contrapartes con las
|
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo
|
||||||
// que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin
|
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo
|
||||||
// contribuyenteId, retornar el catálogo completo (compat con flujos
|
// contrario no se podría facturar a un cliente nuevo que nunca haya
|
||||||
// legacy / admin global sin contribuyente seleccionado).
|
// aparecido en un CFDI previo.
|
||||||
let rows;
|
const { rows } = await pool.query(`
|
||||||
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(`
|
|
||||||
SELECT id, rfc, razon_social as "razonSocial",
|
SELECT id, rfc, razon_social as "razonSocial",
|
||||||
regimen_fiscal as "regimenFiscal",
|
regimen_fiscal as "regimenFiscal",
|
||||||
codigo_postal as "codigoPostal"
|
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)
|
AND (rfc ILIKE $2 OR razon_social ILIKE $2)
|
||||||
ORDER BY razon_social
|
ORDER BY razon_social
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [tenantRfc, `%${q}%`]));
|
`, [tenantRfc, `%${q}%`]);
|
||||||
}
|
|
||||||
|
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
@@ -787,3 +764,123 @@ export async function comprarPaquete(req: Request, res: Response, next: NextFunc
|
|||||||
next(error);
|
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); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export async function retry(req: Request, res: Response): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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' });
|
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
|
||||||
return;
|
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> {
|
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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' });
|
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
|
|||||||
import { auditFromReq } from '../utils/audit.js';
|
import { auditFromReq } from '../utils/audit.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
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) {
|
if (!isAdmin) {
|
||||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
|
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> {
|
async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise<boolean> {
|
||||||
if (targetTenantId === req.user!.tenantId) return true;
|
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) {
|
if (!isAdmin) {
|
||||||
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
|
res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,24 @@ import { isGlobalAdmin } from '../utils/global-admin.js';
|
|||||||
import { isOwnerSomewhere } from '../utils/memberships.js';
|
import { isOwnerSomewhere } from '../utils/memberships.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request): Promise<void> {
|
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');
|
throw new AppError(403, 'Solo el administrador global puede gestionar clientes');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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();
|
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);
|
res.json(tenants);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getTenant(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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));
|
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
@@ -68,13 +79,15 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
await requireGlobalAdmin(req);
|
await requireGlobalAdmin(req);
|
||||||
|
|
||||||
const id = String(req.params.id);
|
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, {
|
const tenant = await tenantsService.updateTenant(id, {
|
||||||
nombre,
|
nombre,
|
||||||
rfc,
|
rfc,
|
||||||
plan,
|
plan,
|
||||||
active,
|
active,
|
||||||
|
amount,
|
||||||
|
firstPaymentDueAt: firstPaymentDueAt || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(tenant);
|
res.json(tenant);
|
||||||
|
|||||||
130
apps/api/src/controllers/trial-invitations.controller.ts
Normal file
130
apps/api/src/controllers/trial-invitations.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ const updateGlobalSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
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) {
|
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
|
|||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
import { prisma, tenantDb } from '../config/database.js';
|
import { prisma, tenantDb } from '../config/database.js';
|
||||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { decryptAesGcm, deriveAesKey } from '@horux/core';
|
import { decryptAesGcm, deriveAesKey } from '@horux/core';
|
||||||
import { env } from '../config/env.js';
|
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;
|
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;
|
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||||
if (viewTenantHeader) {
|
if (viewTenantHeader) {
|
||||||
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
|
const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti');
|
||||||
if (!globalAdmin) {
|
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' });
|
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import * as ctrl from '../controllers/despacho-stats.controller.js';
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.get('/contribuyentes-stats', authenticate, tenantMiddleware, ctrl.getContribuyentesStats);
|
||||||
router.use(tenantMiddleware);
|
router.get('/mis-asignados', authenticate, tenantMiddleware, ctrl.getMisAsignados);
|
||||||
|
router.get('/equipo-stats', authenticate, tenantMiddleware, ctrl.getEquipoStats);
|
||||||
router.get('/contribuyentes-stats', ctrl.getContribuyentesStats);
|
|
||||||
router.get('/mis-asignados', ctrl.getMisAsignados);
|
|
||||||
router.get('/equipo-stats', ctrl.getEquipoStats);
|
|
||||||
|
|
||||||
export { router as despachoStatsRoutes };
|
export { router as despachoStatsRoutes };
|
||||||
|
|||||||
@@ -61,4 +61,8 @@ router.get('/cfdis-ppd', facturacionController.getCfdisPpdPendientes);
|
|||||||
// CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados")
|
// CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados")
|
||||||
router.get('/cfdis-relacionables', facturacionController.getCfdisRelacionables);
|
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 };
|
export { router as facturacionRoutes };
|
||||||
|
|||||||
22
apps/api/src/routes/trial-invitations.routes.ts
Normal file
22
apps/api/src/routes/trial-invitations.routes.ts
Normal 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 };
|
||||||
@@ -19,7 +19,7 @@ export async function getDashboardMetrics() {
|
|||||||
prisma.subscription.count({ where: { status: 'cancelled' } }),
|
prisma.subscription.count({ where: { status: 'cancelled' } }),
|
||||||
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||||
prisma.tenant.findMany({
|
prisma.tenant.findMany({
|
||||||
where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } },
|
where: { connectorTunnelHostname: { not: null } },
|
||||||
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
|
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
|
||||||
}),
|
}),
|
||||||
prisma.payment.aggregate({
|
prisma.payment.aggregate({
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function provisionConnector(tenantId: string): Promise<{
|
|||||||
await prisma.tenant.update({
|
await prisma.tenant.update({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
data: {
|
data: {
|
||||||
dbMode: 'BYO',
|
// El conector es una feature de respaldo; el tenant siempre permanece MANAGED
|
||||||
connectorTokenEnc: tokenEncoded,
|
connectorTokenEnc: tokenEncoded,
|
||||||
connectorTunnelHostname: hostname,
|
connectorTunnelHostname: hostname,
|
||||||
},
|
},
|
||||||
@@ -92,7 +92,7 @@ export async function verifyConnectorToken(token: string): Promise<string | null
|
|||||||
// Find tenant by trying to decrypt stored tokens.
|
// Find tenant by trying to decrypt stored tokens.
|
||||||
// This is O(N) — for production, use a hashed token lookup table.
|
// This is O(N) — for production, use a hashed token lookup table.
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { dbMode: 'BYO', connectorTokenEnc: { not: null } },
|
where: { connectorTokenEnc: { not: null } },
|
||||||
select: { id: true, connectorTokenEnc: true },
|
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 },
|
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' };
|
return { configured: false, status: 'not_configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
||||||
const headless = process.env.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 {
|
try {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
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)];
|
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:
|
* Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente:
|
||||||
* se puede llamar N veces, el resultado final refleja el último CSF.
|
* 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 fields = domicilioToTenantFields(csf.domicilio);
|
||||||
const updates: Record<string, string> = {};
|
const updates: Record<string, string> = {};
|
||||||
for (const [k, v] of Object.entries(fields)) {
|
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) {
|
if (Object.keys(updates).length > 0) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
|||||||
plan: 'trial',
|
plan: 'trial',
|
||||||
databaseName: databaseName,
|
databaseName: databaseName,
|
||||||
verticalProfile: despacho.verticalProfile as any,
|
verticalProfile: despacho.verticalProfile as any,
|
||||||
dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any,
|
dbMode: 'MANAGED',
|
||||||
dbSchemaVersion: 0,
|
dbSchemaVersion: 0,
|
||||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
codigoPostal: despacho.codigoPostal,
|
codigoPostal: despacho.codigoPostal,
|
||||||
@@ -91,40 +91,9 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
|||||||
email: result.user.email,
|
email: result.user.email,
|
||||||
}).catch(err => console.error('[Signup] Welcome email failed:', err));
|
}).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 {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
paymentUrl,
|
|
||||||
user: {
|
user: {
|
||||||
id: result.user.id,
|
id: result.user.id,
|
||||||
email: result.user.email,
|
email: result.user.email,
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ export const emailService = {
|
|||||||
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
|
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) => {
|
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
|
||||||
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
||||||
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
|
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));
|
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.
|
* Notifica la subida de una declaración o documento extra al despacho.
|
||||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal file
63
apps/api/src/services/email/templates/trial-invitation.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ async function isFirstApprovedPayment(
|
|||||||
* Busca el tenant emisor (Horux 360) con su organización Facturapi configurada.
|
* 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.
|
* 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({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { rfc: GLOBAL_ADMIN_RFC },
|
where: { rfc: GLOBAL_ADMIN_RFC },
|
||||||
select: {
|
select: {
|
||||||
@@ -125,7 +125,7 @@ interface CustomerData {
|
|||||||
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
|
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
|
||||||
* a público en general en ese caso.
|
* 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({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: payerTenantId },
|
where: { id: payerTenantId },
|
||||||
select: {
|
select: {
|
||||||
@@ -179,7 +179,7 @@ async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerDat
|
|||||||
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
|
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
|
||||||
* o fallback a público en general si `customer` es null.
|
* o fallback a público en general si `customer` es null.
|
||||||
*/
|
*/
|
||||||
function buildInvoicePayload(params: {
|
export function buildInvoicePayload(params: {
|
||||||
amount: number;
|
amount: number;
|
||||||
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
|
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
|
||||||
emitterCp: string;
|
emitterCp: string;
|
||||||
@@ -272,6 +272,22 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
|
|||||||
// Gate 4: primer pago del tenant → manual
|
// Gate 4: primer pago del tenant → manual
|
||||||
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
|
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
|
||||||
console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ export function verifyWebhookSignature(
|
|||||||
const parts: Record<string, string> = {};
|
const parts: Record<string, string> = {};
|
||||||
for (const part of xSignature.split(',')) {
|
for (const part of xSignature.split(',')) {
|
||||||
const [key, value] = part.split('=');
|
const [key, value] = part.split('=');
|
||||||
|
if (!key || value === undefined) continue;
|
||||||
parts[key.trim()] = value.trim();
|
parts[key.trim()] = value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export async function loginSatCsf(
|
|||||||
keyPath: string,
|
keyPath: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<CsfLoginSession> {
|
): 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();
|
const publicPage = await context.newPage();
|
||||||
publicPage.setDefaultTimeout(60_000);
|
publicPage.setDefaultTimeout(60_000);
|
||||||
|
|
||||||
@@ -66,12 +72,34 @@ export async function loginSatCsf(
|
|||||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
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
|
// Password + Enviar
|
||||||
await loginPage.locator('input[type="password"]').first().fill(password);
|
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
|
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||||
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
|
await loginPage.waitForURL(
|
||||||
|
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||||
await loginPage.waitForTimeout(2000);
|
await loginPage.waitForTimeout(2000);
|
||||||
|
|
||||||
|
|||||||
@@ -85,12 +85,29 @@ function extractLabels(text: string): Map<string, string> {
|
|||||||
const result = new Map<string, string>();
|
const result = new Map<string, string>();
|
||||||
const labelAlternation = LABELS.map(escapeRegex).join('|');
|
const labelAlternation = LABELS.map(escapeRegex).join('|');
|
||||||
const re = new RegExp(
|
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',
|
'g',
|
||||||
);
|
);
|
||||||
for (const match of text.matchAll(re)) {
|
for (const match of text.matchAll(re)) {
|
||||||
const label = match[1];
|
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);
|
if (!result.has(label)) result.set(label, value);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
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({
|
xmlFiles.push({
|
||||||
filename: entry.entryName,
|
filename: entry.entryName,
|
||||||
content,
|
content,
|
||||||
@@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
|||||||
*/
|
*/
|
||||||
function parseCfdiDate(str: string | null | undefined): Date {
|
function parseCfdiDate(str: string | null | undefined): Date {
|
||||||
if (!str) return new Date(0);
|
if (!str) return new Date(0);
|
||||||
const s = String(str).trim();
|
let s = String(str).trim();
|
||||||
if (!s) return new Date(0);
|
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);
|
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
||||||
return new Date(hasTz ? s : s + 'Z');
|
return new Date(hasTz ? s : s + 'Z');
|
||||||
}
|
}
|
||||||
@@ -155,18 +165,28 @@ function pf(val: any): number {
|
|||||||
return parseFloat(val || '0') || 0;
|
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
|
* Extrae el UUID del TimbreFiscalDigital
|
||||||
*/
|
*/
|
||||||
function extractUuid(comprobante: any): string {
|
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
|
* Extrae datos del timbre: fecha cert SAT y PAC
|
||||||
*/
|
*/
|
||||||
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
|
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 };
|
if (!timbre) return { fechaCertSat: null, pac: null };
|
||||||
|
|
||||||
return {
|
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.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
|
||||||
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
||||||
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
||||||
@@ -370,9 +390,9 @@ function extractNomina(comprobante: any): {
|
|||||||
const nomina = complemento.Nomina;
|
const nomina = complemento.Nomina;
|
||||||
if (!nomina) return result;
|
if (!nomina) return result;
|
||||||
|
|
||||||
result.fechaPago = nomina['@_FechaPago'] || null;
|
result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null;
|
||||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
|
result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null;
|
||||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
|
result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null;
|
||||||
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
||||||
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
||||||
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
||||||
|
|||||||
@@ -547,9 +547,35 @@ async function requestAndDownload(
|
|||||||
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
|
// Intentar reusar requestId previo del mismo job/kindKey (caso retry)
|
||||||
const jobRow = await prisma.satSyncJob.findUnique({
|
const jobRow = await prisma.satSyncJob.findUnique({
|
||||||
where: { id: jobId },
|
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 },
|
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 requestId: string | null = existingMap[kindKey] || null;
|
||||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||||
|
|
||||||
@@ -651,7 +677,8 @@ async function processDateRange(
|
|||||||
jobId: string,
|
jobId: string,
|
||||||
fechaInicio: Date,
|
fechaInicio: Date,
|
||||||
fechaFin: Date,
|
fechaFin: Date,
|
||||||
tipoCfdi: CfdiSyncType
|
tipoCfdi: CfdiSyncType,
|
||||||
|
skipJobUpdate = false
|
||||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
@@ -678,12 +705,14 @@ async function processDateRange(
|
|||||||
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!skipJobUpdate) {
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
cfdisFound: totalFound,
|
cfdisFound: totalFound,
|
||||||
cfdisDownloaded: totalDownloaded,
|
cfdisDownloaded: totalDownloaded,
|
||||||
cfdisInserted: totalInserted,
|
cfdisInserted: totalInserted,
|
||||||
cfdisUpdated: totalUpdated,
|
cfdisUpdated: totalUpdated,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
found: totalFound,
|
found: totalFound,
|
||||||
@@ -787,7 +816,9 @@ async function processInitialSync(
|
|||||||
customDateTo?: Date
|
customDateTo?: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ahora = new Date();
|
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;
|
const fechaFin = customDateTo || ahora;
|
||||||
|
|
||||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||||
@@ -802,13 +833,29 @@ async function processInitialSync(
|
|||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 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)
|
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
|
||||||
for (let i = 0; i < xmlChunks.length; i++) {
|
for (let i = 0; i < xmlChunks.length; i++) {
|
||||||
const { start, end } = xmlChunks[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)}`);
|
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos');
|
const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true);
|
||||||
totalFound += emitidos.found;
|
totalFound += emitidos.found;
|
||||||
totalDownloaded += emitidos.downloaded;
|
totalDownloaded += emitidos.downloaded;
|
||||||
totalInserted += emitidos.inserted;
|
totalInserted += emitidos.inserted;
|
||||||
@@ -816,9 +863,10 @@ async function processInitialSync(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
||||||
}
|
}
|
||||||
|
await reportProgress();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos');
|
const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true);
|
||||||
totalFound += recibidos.found;
|
totalFound += recibidos.found;
|
||||||
totalDownloaded += recibidos.downloaded;
|
totalDownloaded += recibidos.downloaded;
|
||||||
totalInserted += recibidos.inserted;
|
totalInserted += recibidos.inserted;
|
||||||
@@ -826,6 +874,7 @@ async function processInitialSync(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
||||||
}
|
}
|
||||||
|
await reportProgress();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
@@ -842,6 +891,7 @@ async function processInitialSync(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
||||||
}
|
}
|
||||||
|
await reportProgress();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
|
const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos');
|
||||||
@@ -850,6 +900,7 @@ async function processInitialSync(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
||||||
}
|
}
|
||||||
|
await reportProgress();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
@@ -859,6 +910,7 @@ async function processInitialSync(
|
|||||||
cfdisDownloaded: totalDownloaded,
|
cfdisDownloaded: totalDownloaded,
|
||||||
cfdisInserted: totalInserted,
|
cfdisInserted: totalInserted,
|
||||||
cfdisUpdated: totalUpdated,
|
cfdisUpdated: totalUpdated,
|
||||||
|
progressPercent: 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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
|
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
|
||||||
* lock para nuevos syncs del mismo (tenant, contribuyente).
|
* lock para nuevos syncs del mismo (tenant, contribuyente).
|
||||||
*
|
*
|
||||||
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
|
* 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren
|
||||||
* típico termina en <2h; si lleva >runningHours es casi seguro
|
* por tipo: initial (8h) porque un bootstrap de 6 años puede tardar
|
||||||
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
|
* 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
|
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
|
||||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||||
*
|
*
|
||||||
* - `apply=false` (default): dry-run, no toca BD.
|
* - `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: {
|
export async function sweepStaleSatJobs(params: {
|
||||||
apply: boolean;
|
apply: boolean;
|
||||||
pendingHours?: number;
|
pendingHours?: number;
|
||||||
runningHours?: number;
|
runningHours?: number;
|
||||||
|
runningHoursByType?: Record<string, number>;
|
||||||
} = { apply: false }): Promise<SweepResult> {
|
} = { apply: false }): Promise<SweepResult> {
|
||||||
const pendingHours = params.pendingHours ?? 12;
|
const pendingHours = params.pendingHours ?? 12;
|
||||||
const runningHours = params.runningHours ?? 4;
|
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||||
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
|
|
||||||
|
|
||||||
const stalePending = await prisma.satSyncJob.findMany({
|
const stalePending = await prisma.satSyncJob.findMany({
|
||||||
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
|
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
|
||||||
orderBy: { createdAt: 'asc' },
|
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' },
|
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 = {
|
const result: SweepResult = {
|
||||||
pendingFound: stalePending.length,
|
pendingFound: stalePending.length,
|
||||||
@@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: {
|
|||||||
result.pendingMarked++;
|
result.pendingMarked++;
|
||||||
}
|
}
|
||||||
for (const j of staleRunning) {
|
for (const j of staleRunning) {
|
||||||
|
const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4;
|
||||||
await prisma.satSyncJob.update({
|
await prisma.satSyncJob.update({
|
||||||
where: { id: j.id },
|
where: { id: j.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
completedAt: now,
|
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++;
|
result.runningMarked++;
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ export async function getAllTenants() {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { memberships: { where: { active: true } } as any }
|
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' }
|
orderBy: { nombre: 'asc' }
|
||||||
});
|
});
|
||||||
@@ -266,8 +276,10 @@ export async function updateTenant(id: string, data: {
|
|||||||
rfc?: string;
|
rfc?: string;
|
||||||
plan?: DespachoPlan;
|
plan?: DespachoPlan;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
amount?: number;
|
||||||
|
firstPaymentDueAt?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return prisma.tenant.update({
|
const tenant = await prisma.tenant.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(data.nombre && { nombre: data.nombre }),
|
...(data.nombre && { nombre: data.nombre }),
|
||||||
@@ -285,6 +297,29 @@ export async function updateTenant(id: string, data: {
|
|||||||
createdAt: true,
|
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) {
|
export async function getDatosFiscales(id: string) {
|
||||||
|
|||||||
191
apps/api/src/services/trial-invitations.service.ts
Normal file
191
apps/api/src/services/trial-invitations.service.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -107,7 +107,12 @@ export async function getPlatformRoles(userId: string): Promise<PlatformRole[]>
|
|||||||
* owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario
|
* owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario
|
||||||
* post-deploy pre-seed).
|
* 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
|
// Las firmas viejas no tienen userId disponible. Lo resolvemos buscando el user
|
||||||
// que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia
|
// que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia
|
||||||
// es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo.
|
// es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Image from 'next/image';
|
|||||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||||
import { login } from '@/lib/api/auth';
|
import { login } from '@/lib/api/auth';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { isGlobalAdminRfc } from '@horux/shared';
|
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
|
||||||
import { shouldShowOnboarding } from '@/lib/onboarding';
|
import { shouldShowOnboarding } from '@/lib/onboarding';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
|||||||
const userRole = response.user?.role;
|
const userRole = response.user?.role;
|
||||||
// Admin global aterriza directo en `/clientes` — su home natural es la
|
// Admin global aterriza directo en `/clientes` — su home natural es la
|
||||||
// gestión de tenants, no el dashboard operativo del despacho.
|
// 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);
|
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
|
||||||
if (isGlobalAdmin) {
|
if (isGlobalAdmin) {
|
||||||
router.push('/clientes');
|
router.push('/clientes');
|
||||||
|
|||||||
@@ -3,23 +3,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 { useAuthStore } from '@/stores/auth-store';
|
||||||
import { apiClient } from '@/lib/api/client';
|
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 VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
|
|
||||||
|
|
||||||
export default function RegisterDespachoPage() {
|
export default function RegisterDespachoPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setUser, setTokens } = useAuthStore();
|
const { setUser, setTokens } = useAuthStore();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
|
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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -37,7 +32,7 @@ export default function RegisterDespachoPage() {
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; }
|
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);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@@ -45,10 +40,6 @@ export default function RegisterDespachoPage() {
|
|||||||
despacho: {
|
despacho: {
|
||||||
nombre: form.despachoNombre,
|
nombre: form.despachoNombre,
|
||||||
verticalProfile,
|
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: {
|
owner: {
|
||||||
nombre: form.ownerNombre,
|
nombre: form.ownerNombre,
|
||||||
@@ -58,13 +49,7 @@ export default function RegisterDespachoPage() {
|
|||||||
});
|
});
|
||||||
setTokens(data.accessToken, data.refreshToken);
|
setTokens(data.accessToken, data.refreshToken);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
|
||||||
// If paid plan with payment URL, redirect to MercadoPago
|
|
||||||
if (data.paymentUrl) {
|
|
||||||
window.location.href = data.paymentUrl;
|
|
||||||
} else {
|
|
||||||
router.push('/onboarding');
|
router.push('/onboarding');
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Error al registrar el despacho');
|
setError(err.response?.data?.message || 'Error al registrar el despacho');
|
||||||
setStep(1);
|
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="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="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="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>
|
</div>
|
||||||
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
|
<CardTitle className="text-2xl font-bold">Crea tu cuenta</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Plataforma para despachos profesionales</p>
|
<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 ===================
|
// =================== STEP 2: Vertical Selection ===================
|
||||||
if (step === 2) {
|
|
||||||
return (
|
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="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">
|
<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="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="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="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>
|
</div>
|
||||||
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
|
<h1 className="text-3xl font-bold">¿Qué tipo de despacho eres?</h1>
|
||||||
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
|
<p className="text-muted-foreground mt-2">Selecciona tu área profesional</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
<button
|
<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"
|
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>
|
<div className="text-4xl">📊</div>
|
||||||
@@ -153,6 +134,7 @@ export default function RegisterDespachoPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Próximamente</p>
|
<p className="text-sm text-muted-foreground">Próximamente</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<button onClick={() => setStep(1)} className="text-sm text-muted-foreground underline">
|
||||||
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
|
<ArrowLeft className="h-3 w-3 inline mr-1" />Volver al formulario
|
||||||
</button>
|
</button>
|
||||||
@@ -160,256 +142,3 @@ export default function RegisterDespachoPage() {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ function ResetPasswordContent() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Link href="/forgot-password" className="w-full">
|
<Button className="w-full" asChild>
|
||||||
<Button className="w-full">Solicitar nuevo enlace</Button>
|
<Link href="/forgot-password">Solicitar nuevo enlace</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -82,9 +82,9 @@ function ResetPasswordContent() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Link href="/login" className="w-full">
|
<Button className="w-full" asChild>
|
||||||
<Button className="w-full">Ir al login</Button>
|
<Link href="/login">Ir al login</Link>
|
||||||
</Link>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal file
169
apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal file
260
apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select,
|
|||||||
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
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 { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
|
|
||||||
@@ -43,9 +44,12 @@ export default function AdminUsuariosPage() {
|
|||||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isGlobal) return;
|
||||||
getTenants().then(setTenants).catch(console.error);
|
getTenants().then(setTenants).catch(console.error);
|
||||||
}, []);
|
}, [isGlobal]);
|
||||||
|
|
||||||
const handleEdit = (usuario: any) => {
|
const handleEdit = (usuario: any) => {
|
||||||
setEditingUser({
|
setEditingUser({
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ export default function CfdiPage() {
|
|||||||
|
|
||||||
// CFDI Viewer state
|
// CFDI Viewer state
|
||||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
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
|
// Cancelación Facturapi state
|
||||||
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
||||||
@@ -367,10 +367,10 @@ export default function CfdiPage() {
|
|||||||
const [cancelSubstitution, setCancelSubstitution] = useState('');
|
const [cancelSubstitution, setCancelSubstitution] = useState('');
|
||||||
const [cancelling, setCancelling] = useState(false);
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
|
||||||
const handleViewCfdi = async (id: string) => {
|
const handleViewCfdi = async (id: number) => {
|
||||||
setLoadingCfdi(id);
|
setLoadingCfdi(id);
|
||||||
try {
|
try {
|
||||||
const cfdi = await getCfdiById(id);
|
const cfdi = await getCfdiById(String(id));
|
||||||
setViewingCfdi(cfdi);
|
setViewingCfdi(cfdi);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading CFDI:', error);
|
console.error('Error loading CFDI:', error);
|
||||||
@@ -882,10 +882,10 @@ export default function CfdiPage() {
|
|||||||
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (confirm('¿Eliminar este CFDI?')) {
|
if (confirm('¿Eliminar este CFDI?')) {
|
||||||
try {
|
try {
|
||||||
await deleteCfdi.mutateAsync(id);
|
await deleteCfdi.mutateAsync(String(id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting CFDI:', error);
|
console.error('Error deleting CFDI:', error);
|
||||||
}
|
}
|
||||||
@@ -920,9 +920,9 @@ export default function CfdiPage() {
|
|||||||
const calculateTotal = () => {
|
const calculateTotal = () => {
|
||||||
const subtotal = formData.subtotal || 0;
|
const subtotal = formData.subtotal || 0;
|
||||||
const descuento = formData.descuento || 0;
|
const descuento = formData.descuento || 0;
|
||||||
const iva = formData.ivaTrasladoTraslado || 0;
|
const iva = formData.ivaTraslado || 0;
|
||||||
const isrRetencion = formData.isrRetencion || 0;
|
const isrRetencion = formData.isrRetencion || 0;
|
||||||
const ivaRetencion = formData.ivaTrasladoRetencion || 0;
|
const ivaRetencion = formData.ivaRetencion || 0;
|
||||||
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
|
return subtotal - descuento + iva - isrRetencion - ivaRetencion;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Header } from '@/components/layouts/header';
|
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 { 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 { 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 { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
import { useAuthStore } from '@/stores/auth-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 type { Tenant } from '@/lib/api/tenants';
|
||||||
import { isGlobalAdminRfc } from '@horux/shared';
|
import { isGlobalAdminRfc } from '@horux/shared';
|
||||||
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
|
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
|
||||||
@@ -56,6 +57,7 @@ export default function ClientesPage() {
|
|||||||
queryFn: () => getClientesStats(from, to),
|
queryFn: () => getClientesStats(from, to),
|
||||||
enabled: !!user,
|
enabled: !!user,
|
||||||
});
|
});
|
||||||
|
const { data: pagosSinFactura } = usePagosSinFactura();
|
||||||
|
|
||||||
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
|
// Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista.
|
||||||
const usuariosPorTenant = useMemo(() => {
|
const usuariosPorTenant = useMemo(() => {
|
||||||
@@ -129,14 +131,17 @@ export default function ClientesPage() {
|
|||||||
|
|
||||||
const handleEdit = (tenant: Tenant) => {
|
const handleEdit = (tenant: Tenant) => {
|
||||||
setEditingTenant(tenant);
|
setEditingTenant(tenant);
|
||||||
|
const sub = tenant.subscriptions?.[0];
|
||||||
setFormData({
|
setFormData({
|
||||||
nombre: tenant.nombre,
|
nombre: tenant.nombre,
|
||||||
rfc: tenant.rfc,
|
rfc: tenant.rfc,
|
||||||
plan: tenant.plan as PlanType,
|
plan: tenant.plan as PlanType,
|
||||||
adminEmail: '',
|
adminEmail: '',
|
||||||
adminNombre: '',
|
adminNombre: '',
|
||||||
amount: 0,
|
amount: sub ? Number(sub.amount) : 0,
|
||||||
firstPaymentDueAt: '',
|
firstPaymentDueAt: sub?.currentPeriodEnd
|
||||||
|
? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10)
|
||||||
|
: '',
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -210,7 +215,7 @@ export default function ClientesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* 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 */}
|
{/* Total clientes activos */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
@@ -286,6 +291,24 @@ export default function ClientesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Detalle de no renovaciones */}
|
{/* Detalle de no renovaciones */}
|
||||||
@@ -454,9 +477,8 @@ export default function ClientesPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campos de admin y suscripción — solo al crear */}
|
{/* Campos de admin — solo al crear */}
|
||||||
{!editingTenant && (
|
{!editingTenant && (
|
||||||
<>
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
|
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -483,8 +505,11 @@ export default function ClientesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campos de suscripción — crear y editar (solo planes pagados / custom) */}
|
||||||
{formData.plan !== 'trial' && (
|
{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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
|
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
|
||||||
<Input
|
<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.
|
Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function RegimenesActivosSection() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activos && catalogo) {
|
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);
|
setSelected(ids);
|
||||||
}
|
}
|
||||||
}, [activos, catalogo]);
|
}, [activos, catalogo]);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui';
|
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 { apiClient } from '@/lib/api/client';
|
||||||
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
|
||||||
|
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
|
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 { user } = useAuthStore();
|
||||||
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
|
const [planInfo, setPlanInfo] = useState<PlanInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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);
|
const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
||||||
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
|
// Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business
|
||||||
// Control y Enterprise siempre se cobran anual. Default monthly para
|
// Control y Enterprise siempre se cobran anual. Default monthly para
|
||||||
@@ -45,6 +46,12 @@ export default function PlanesDespachoPage() {
|
|||||||
// muestra como CTA secundario.
|
// muestra como CTA secundario.
|
||||||
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
|
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
|
||||||
const [mePlusFreq, setMePlusFreq] = 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 = () => {
|
const fetchPlan = () => {
|
||||||
apiClient.get<PlanInfo>('/despachos/me/plan')
|
apiClient.get<PlanInfo>('/despachos/me/plan')
|
||||||
@@ -58,6 +65,20 @@ export default function PlanesDespachoPage() {
|
|||||||
.then(res => setPlanInfo(res.data))
|
.then(res => setPlanInfo(res.data))
|
||||||
.catch(() => setPlanInfo(null))
|
.catch(() => setPlanInfo(null))
|
||||||
.finally(() => setLoading(false));
|
.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;
|
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() {
|
function ActiveBadge() {
|
||||||
return (
|
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">
|
<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>
|
</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 */}
|
{/* Banner de suscripción activa */}
|
||||||
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
{!loading && planInfo?.subscription && hasPaidPlan && (() => {
|
||||||
const sub = planInfo.subscription;
|
const sub = planInfo.subscription;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function ContribuyentesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 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>
|
<div><h1 className="text-2xl font-bold">Contribuyentes</h1><p className="text-sm text-muted-foreground">RFCs que gestiona tu despacho</p></div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { Header } from '@/components/layouts/header';
|
||||||
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
|
import { DespachoSubnav } from '@/components/despachos/despacho-subnav';
|
||||||
import { PeriodoSelector } from '@/components/periodo-selector';
|
import { PeriodoSelector } from '@/components/periodo-selector';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
||||||
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
|
import { Building2, RefreshCw, Loader2, TrendingUp, FileCheck, DollarSign, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Despacho {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
rfc: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
totalContribuyentes: number;
|
totalContribuyentes: number;
|
||||||
ultimaExtraccion: string | null;
|
ultimaExtraccion: string | null;
|
||||||
@@ -20,14 +27,28 @@ interface Stats {
|
|||||||
tareasAtrasadas: number;
|
tareasAtrasadas: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||||
|
|
||||||
export default function DespachoContribuyentesPage() {
|
export default function DespachoContribuyentesPage() {
|
||||||
const role = useAuthStore(s => s.user?.role);
|
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 { fechaInicio } = usePeriodoStore();
|
||||||
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
|
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>({
|
const { data, isLoading } = useQuery<Stats>({
|
||||||
queryKey: ['despacho-contribuyentes-stats', año, mes],
|
queryKey: ['despacho-contribuyentes-stats', viewingTenantId, año, mes],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
|
const { data } = await apiClient.get<Stats>(`/despachos/contribuyentes-stats?año=${año}&mes=${mes}`);
|
||||||
return data;
|
return data;
|
||||||
@@ -56,6 +77,31 @@ export default function DespachoContribuyentesPage() {
|
|||||||
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
|
<Header title="Despacho — Contribuyentes"><PeriodoSelector /></Header>
|
||||||
<main className="p-6 max-w-7xl mx-auto">
|
<main className="p-6 max-w-7xl mx-auto">
|
||||||
<DespachoSubnav />
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ interface Asignado {
|
|||||||
tareasCompletadas: number;
|
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() {
|
export default function MisAsignadosPage() {
|
||||||
const role = useAuthStore(s => s.user?.role);
|
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 { setSelectedContribuyente } = useContribuyenteStore();
|
||||||
const { fechaInicio } = usePeriodoStore();
|
const { fechaInicio } = usePeriodoStore();
|
||||||
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
|
const { año, mes } = añoMesFromFechaInicio(fechaInicio);
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { defaultDespachoPathForRole } from '@/components/despachos/despacho-subn
|
|||||||
export default function DespachosIndex() {
|
export default function DespachosIndex() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const role = useAuthStore(s => s.user?.role);
|
const role = useAuthStore(s => s.user?.role);
|
||||||
|
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!role) return;
|
if (!role) return;
|
||||||
router.replace(defaultDespachoPathForRole(role));
|
router.replace(defaultDespachoPathForRole(role, platformRoles));
|
||||||
}, [role, router]);
|
}, [role, platformRoles, router]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function OnboardingPage() {
|
|||||||
title: 'Subir FIEL del contribuyente',
|
title: 'Subir FIEL del contribuyente',
|
||||||
description: 'Necesaria para sincronizar con el SAT.',
|
description: 'Necesaria para sincronizar con el SAT.',
|
||||||
icon: <Key className="h-5 w-5" />,
|
icon: <Key className="h-5 w-5" />,
|
||||||
href: '/contribuyentes',
|
href: '/configuracion/sat',
|
||||||
completed: fielDone,
|
completed: fielDone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,7 +99,7 @@ export default function OnboardingPage() {
|
|||||||
title: 'Subir CSD (para emitir facturas)',
|
title: 'Subir CSD (para emitir facturas)',
|
||||||
description: 'Certificado de Sello Digital para timbrado.',
|
description: 'Certificado de Sello Digital para timbrado.',
|
||||||
icon: <FileText className="h-5 w-5" />,
|
icon: <FileText className="h-5 w-5" />,
|
||||||
href: '/contribuyentes',
|
href: '/configuracion/csd',
|
||||||
completed: csdDone,
|
completed: csdDone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -178,11 +178,11 @@ export default function OnboardingPage() {
|
|||||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{!step.completed && step.href !== '#' && (
|
{!step.completed && step.href !== '#' && (
|
||||||
|
<Button variant="outline" size="sm" className="flex items-center gap-1" asChild>
|
||||||
<Link href={step.href}>
|
<Link href={step.href}>
|
||||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
|
||||||
Configurar <ArrowRight className="h-3 w-3" />
|
Configurar <ArrowRight className="h-3 w-3" />
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { cn } from '@horux/shared-ui';
|
import { cn } from '@horux/shared-ui';
|
||||||
import { isDespachoTenant } from '@horux/shared';
|
import { isDespachoTenant } from '@horux/shared';
|
||||||
import type { Role } from '@horux/shared';
|
import type { Role, UserInvite } from '@horux/shared';
|
||||||
|
|
||||||
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
|
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
|
||||||
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
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 isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo';
|
||||||
|
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
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: '',
|
email: '',
|
||||||
nombre: '',
|
nombre: '',
|
||||||
role: defaultInviteRole as Role,
|
role: defaultInviteRole as UserInvite['role'],
|
||||||
});
|
});
|
||||||
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
|
const [selectedRfcIds, setSelectedRfcIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ export default function UsuariosPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
setShowInvite(false);
|
setShowInvite(false);
|
||||||
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined });
|
setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined });
|
||||||
setSelectedRfcIds([]);
|
setSelectedRfcIds([]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(error.response?.data?.message || 'Error al invitar usuario');
|
alert(error.response?.data?.message || 'Error al invitar usuario');
|
||||||
@@ -211,11 +211,11 @@ export default function UsuariosPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isAdmin && isDespacho && (
|
{isAdmin && isDespacho && (
|
||||||
|
<Button variant="outline" className="flex items-center gap-2" asChild>
|
||||||
<Link href="/carteras">
|
<Link href="/carteras">
|
||||||
<Button variant="outline" className="flex items-center gap-2">
|
|
||||||
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
|
<FolderOpen className="h-4 w-4" /> Gestionar Carteras
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button onClick={() => setShowInvite(true)}>
|
<Button onClick={() => setShowInvite(true)}>
|
||||||
@@ -263,13 +263,13 @@ export default function UsuariosPage() {
|
|||||||
<Label htmlFor="role">Rol</Label>
|
<Label htmlFor="role">Rol</Label>
|
||||||
<Select
|
<Select
|
||||||
value={inviteForm.role}
|
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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{inviteRoles.map(r => (
|
{inviteRoles.map((r: { value: string; label: string; description?: string }) => (
|
||||||
<SelectItem key={r.value} value={r.value}>
|
<SelectItem key={r.value} value={r.value}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{r.label}</span>
|
<span>{r.label}</span>
|
||||||
|
|||||||
200
apps/web/app/invitacion/trial/[token]/page.tsx
Normal file
200
apps/web/app/invitacion/trial/[token]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -177,8 +177,8 @@ export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
|||||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
<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-xs text-gray-500 uppercase tracking-wide">Moneda</p>
|
||||||
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
|
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
|
||||||
{cfdi.typeCambio && cfdi.typeCambio !== 1 && (
|
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
|
||||||
<p className="text-xs text-gray-500">TC: {cfdi.typeCambio}</p>
|
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
|||||||
let xml = xmlContent;
|
let xml = xmlContent;
|
||||||
|
|
||||||
if (!xml) {
|
if (!xml) {
|
||||||
xml = await getCfdiXml(cfdi.id);
|
xml = await getCfdiXml(String(cfdi.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!xml) {
|
if (!xml) {
|
||||||
|
|||||||
@@ -13,17 +13,21 @@ interface NavItem {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
const ITEMS: NavItem[] = [
|
||||||
{ href: '/despachos/contribuyentes', label: 'Contribuyentes', icon: Building2, roles: ['owner', 'cfo'] },
|
{ 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'] },
|
{ 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'] },
|
{ href: '/despachos/equipo', label: 'Equipo', icon: Users, roles: ['owner', 'cfo', 'supervisor'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DespachoSubnav() {
|
export function DespachoSubnav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const role = useAuthStore(s => s.user?.role);
|
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;
|
if (!role) return null;
|
||||||
const visibles = ITEMS.filter(i => i.roles.includes(role));
|
const visibles = ITEMS.filter(i => isPlatformStaff || i.roles.includes(role));
|
||||||
return (
|
return (
|
||||||
<div className="flex border-b mb-6">
|
<div className="flex border-b mb-6">
|
||||||
{visibles.map(item => {
|
{visibles.map(item => {
|
||||||
@@ -50,8 +54,10 @@ export function DespachoSubnav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resuelve la página default según rol al entrar a /despachos. */
|
/** 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 === 'owner' || role === 'cfo') return '/despachos/contribuyentes';
|
||||||
if (role === 'supervisor' || role === 'auxiliar') return '/despachos/mis-asignados';
|
if (role === 'supervisor' || role === 'auxiliar') return '/despachos/mis-asignados';
|
||||||
return '/despachos/mis-asignados';
|
return '/despachos/contribuyentes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Scale,
|
Scale,
|
||||||
Send,
|
Send,
|
||||||
|
ListChecks,
|
||||||
|
FileCheck,
|
||||||
|
ClipboardList,
|
||||||
|
CreditCard,
|
||||||
|
Gift,
|
||||||
|
UserCog,
|
||||||
|
Shield,
|
||||||
|
FileWarning,
|
||||||
|
Receipt,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
@@ -25,21 +34,38 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
||||||
|
|
||||||
const navigation = [
|
interface NavItem {
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
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: 'CFDI', href: '/cfdi', icon: FileText },
|
||||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
{ 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: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||||
{ name: 'Configuración', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||||
] as const;
|
{ 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: '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() {
|
export function SidebarCompact() {
|
||||||
@@ -53,7 +79,7 @@ export function SidebarCompact() {
|
|||||||
const navGate = useNavGate();
|
const navGate = useNavGate();
|
||||||
const filteredNav = navigation.filter((item) => {
|
const filteredNav = navigation.filter((item) => {
|
||||||
if ('feature' in item && item.feature && !hasDespachoFeature(plan, item.feature)) return false;
|
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;
|
if (!navGate.isAllowed(item.href)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Scale,
|
Scale,
|
||||||
Send,
|
Send,
|
||||||
|
ListChecks,
|
||||||
|
FileCheck,
|
||||||
|
ClipboardList,
|
||||||
|
CreditCard,
|
||||||
|
Gift,
|
||||||
|
UserCog,
|
||||||
|
Shield,
|
||||||
|
FileWarning,
|
||||||
|
Receipt,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
@@ -24,21 +33,38 @@ import { useNavGate } from '@/lib/hooks/use-nav-gate';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
||||||
|
|
||||||
const navigation = [
|
interface NavItem {
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
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: 'CFDI', href: '/cfdi', icon: FileText },
|
||||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
{ 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: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||||
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||||
] as const;
|
{ 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: '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() {
|
export function SidebarFloating() {
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
FileWarning,
|
FileWarning,
|
||||||
|
Receipt,
|
||||||
Shield,
|
Shield,
|
||||||
Rocket,
|
Rocket,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
Gift,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
@@ -65,6 +67,7 @@ const adminNavigation: NavItem[] = [
|
|||||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||||
{ name: 'Staff', href: '/admin/staff', icon: Shield },
|
{ 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 },
|
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Scale,
|
Scale,
|
||||||
Send,
|
Send,
|
||||||
|
ListChecks,
|
||||||
|
FileCheck,
|
||||||
|
ClipboardList,
|
||||||
|
CreditCard,
|
||||||
|
Gift,
|
||||||
|
UserCog,
|
||||||
|
Shield,
|
||||||
|
FileWarning,
|
||||||
|
Receipt,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
@@ -25,21 +34,38 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
import { hasDespachoFeature, isGlobalAdminRfc, type DespachoPlan } from '@horux/shared';
|
||||||
|
|
||||||
const navigation = [
|
interface NavItem {
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, roles: ['owner', 'contador'] },
|
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: 'CFDI', href: '/cfdi', icon: FileText },
|
||||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
{ 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: 'Conciliacion', href: '/conciliacion', icon: Scale, feature: 'conciliacion' },
|
||||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||||
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'contador'] },
|
{ name: 'Facturación', href: '/facturacion', icon: Send, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner'] },
|
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
||||||
{ name: 'Config', href: '/configuracion', icon: Settings, roles: ['owner'] },
|
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
|
||||||
] as const;
|
{ 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: '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() {
|
export function TopNav() {
|
||||||
|
|||||||
@@ -186,6 +186,23 @@ export interface ConceptoPrevio {
|
|||||||
fechaEmision: string;
|
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) => {
|
export const searchConceptos = (q: string, tipo?: string, contribuyenteId?: string | null) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (q) params.set('q', q);
|
if (q) params.set('q', q);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface TenantSubscription {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Tenant {
|
export interface Tenant {
|
||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
@@ -11,6 +18,7 @@ export interface Tenant {
|
|||||||
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
/** Memberships activos (matches el `_count.memberships` que retorna `getAllTenants`). */
|
||||||
memberships: number;
|
memberships: number;
|
||||||
};
|
};
|
||||||
|
subscriptions?: TenantSubscription[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantPlan = 'trial' | 'custom' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
|
export type TenantPlan = 'trial' | 'custom' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud';
|
||||||
@@ -46,6 +54,8 @@ export interface UpdateTenantData {
|
|||||||
rfc?: string;
|
rfc?: string;
|
||||||
plan?: TenantPlan;
|
plan?: TenantPlan;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
amount?: number;
|
||||||
|
firstPaymentDueAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTenant(id: string, data: UpdateTenantData): Promise<Tenant> {
|
export async function updateTenant(id: string, data: UpdateTenantData): Promise<Tenant> {
|
||||||
|
|||||||
70
apps/web/lib/api/trial-invitations.ts
Normal file
70
apps/web/lib/api/trial-invitations.ts
Normal 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;
|
||||||
|
}
|
||||||
22
apps/web/lib/hooks/use-pagos-sin-factura.ts
Normal file
22
apps/web/lib/hooks/use-pagos-sin-factura.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getTenants, createTenant, updateTenant, deleteTenant, type CreateTenantData, type UpdateTenantData } from '@/lib/api/tenants';
|
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() {
|
export function useTenants() {
|
||||||
|
const user = useAuthStore(s => s.user);
|
||||||
|
const isGlobal = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['tenants'],
|
queryKey: ['tenants'],
|
||||||
queryFn: getTenants,
|
queryFn: getTenants,
|
||||||
|
enabled: isGlobal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
docs/CAMBIOS-2026-05-09.md
Normal file
194
docs/CAMBIOS-2026-05-09.md
Normal 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
|
||||||
@@ -3,9 +3,9 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
name: 'horux-api',
|
name: 'horux-api',
|
||||||
interpreter: 'node',
|
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',
|
args: 'src/index.ts',
|
||||||
cwd: '/root/Horux/apps/api',
|
cwd: '/root/HoruxDespachosNuevo/apps/api',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
exec_mode: 'fork',
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
@@ -21,7 +21,7 @@ module.exports = {
|
|||||||
name: 'horux-web',
|
name: 'horux-web',
|
||||||
script: 'node_modules/next/dist/bin/next',
|
script: 'node_modules/next/dist/bin/next',
|
||||||
args: 'start',
|
args: 'start',
|
||||||
cwd: '/root/Horux/apps/web',
|
cwd: '/root/HoruxDespachosNuevo/apps/web',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
exec_mode: 'fork',
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const DESPACHO_PLANS = {
|
|||||||
maxUsers: -1,
|
maxUsers: -1,
|
||||||
maxCfdisPorContribuyente: 1_000_000,
|
maxCfdisPorContribuyente: 1_000_000,
|
||||||
timbresIncluidosMes: 0,
|
timbresIncluidosMes: 0,
|
||||||
dbMode: 'BYO' as const,
|
dbMode: 'MANAGED' as const,
|
||||||
permiteServidorBackup: true,
|
permiteServidorBackup: true,
|
||||||
features: [
|
features: [
|
||||||
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
|
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
|
||||||
@@ -101,7 +101,7 @@ export const DESPACHO_PLANS = {
|
|||||||
maxUsers: -1,
|
maxUsers: -1,
|
||||||
maxCfdisPorContribuyente: 3_000_000,
|
maxCfdisPorContribuyente: 3_000_000,
|
||||||
timbresIncluidosMes: 0,
|
timbresIncluidosMes: 0,
|
||||||
dbMode: 'BYO' as const,
|
dbMode: 'MANAGED' as const,
|
||||||
permiteServidorBackup: true,
|
permiteServidorBackup: true,
|
||||||
features: [
|
features: [
|
||||||
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
|
'dashboard', 'cfdi_basic', 'iva_isr', 'reportes', 'alertas',
|
||||||
|
|||||||
41
scripts/monitoreo_sat_3am.sh
Normal file
41
scripts/monitoreo_sat_3am.sh
Normal 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
187
scripts/reprocess_bom.js
Normal 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);
|
||||||
164
scripts/reprocess_duplicate_timbres.js
Normal file
164
scripts/reprocess_duplicate_timbres.js
Normal 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
47
scripts/test_auza_sync.js
Normal 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);
|
||||||
Reference in New Issue
Block a user