diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8841a44..1fa9208 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,6 +1,7 @@ import { app } from './app.js'; import { env } from './config/env.js'; import { tenantDb } from './config/database.js'; +import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js'; import { startSatSyncJob } from './jobs/sat-sync.job.js'; const PORT = parseInt(env.PORT, 10); @@ -32,5 +33,6 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('message', (msg: any) => { if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) { tenantDb.invalidatePool(msg.tenantId); + invalidateTenantCache(msg.tenantId); } }); diff --git a/apps/api/src/middlewares/feature-gate.middleware.ts b/apps/api/src/middlewares/feature-gate.middleware.ts new file mode 100644 index 0000000..fde3f20 --- /dev/null +++ b/apps/api/src/middlewares/feature-gate.middleware.ts @@ -0,0 +1,37 @@ +import type { Request, Response, NextFunction } from 'express'; +import { hasFeature, type Plan } from '@horux/shared'; +import { prisma } from '../config/database.js'; + +const planCache = new Map(); + +/** + * Middleware factory that gates routes based on tenant plan features. + * Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature. + */ +export function requireFeature(feature: string) { + return async (req: Request, res: Response, next: NextFunction) => { + if (!req.user) return res.status(401).json({ message: 'No autenticado' }); + + let plan: string; + const cached = planCache.get(req.user.tenantId); + if (cached && cached.expires > Date.now()) { + plan = cached.plan; + } else { + const tenant = await prisma.tenant.findUnique({ + where: { id: req.user.tenantId }, + select: { plan: true }, + }); + if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' }); + plan = tenant.plan; + planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 }); + } + + if (!hasFeature(plan as Plan, feature)) { + return res.status(403).json({ + message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.', + }); + } + + next(); + }; +} diff --git a/apps/api/src/middlewares/plan-limits.middleware.ts b/apps/api/src/middlewares/plan-limits.middleware.ts new file mode 100644 index 0000000..d79ebe8 --- /dev/null +++ b/apps/api/src/middlewares/plan-limits.middleware.ts @@ -0,0 +1,89 @@ +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../config/database.js'; + +// Simple in-memory cache with TTL +const cache = new Map(); + +async function getCached(key: string, ttlMs: number, fetcher: () => Promise): Promise { + const entry = cache.get(key); + if (entry && entry.expires > Date.now()) return entry.data; + const data = await fetcher(); + cache.set(key, { data, expires: Date.now() + ttlMs }); + return data; +} + +export function invalidateTenantCache(tenantId: string) { + for (const key of cache.keys()) { + if (key.includes(tenantId)) cache.delete(key); + } +} + +/** + * Checks if tenant has an active subscription before allowing write operations + */ +export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) { + if (!req.user) return next(); + + // Admin impersonation bypasses subscription check + if (req.headers['x-view-tenant'] && req.user.role === 'admin') { + return next(); + } + + const subscription = await getCached( + `sub:${req.user.tenantId}`, + 5 * 60 * 1000, + () => prisma.subscription.findFirst({ + where: { tenantId: req.user!.tenantId }, + orderBy: { createdAt: 'desc' }, + }) + ); + + const allowedStatuses = ['authorized', 'pending']; + + if (!subscription || !allowedStatuses.includes(subscription.status)) { + // Allow GET requests even with inactive subscription (read-only access) + if (req.method !== 'GET') { + return res.status(403).json({ + message: 'Suscripción inactiva. Contacta soporte para reactivar.', + }); + } + } + + next(); +} + +/** + * Checks if tenant has room for more CFDIs before allowing CFDI creation + */ +export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) { + if (!req.user || !req.tenantPool) return next(); + + const tenant = await getCached( + `tenant:${req.user.tenantId}`, + 5 * 60 * 1000, + () => prisma.tenant.findUnique({ + where: { id: req.user!.tenantId }, + select: { cfdiLimit: true }, + }) + ); + + if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited + + const cfdiCount = await getCached( + `cfdi-count:${req.user.tenantId}`, + 5 * 60 * 1000, + async () => { + const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis'); + return parseInt(result.rows[0].count); + } + ); + + const newCount = Array.isArray(req.body) ? req.body.length : 1; + if (cfdiCount + newCount > tenant.cfdiLimit) { + return res.status(403).json({ + message: `Límite de CFDIs alcanzado (${cfdiCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`, + }); + } + + next(); +} diff --git a/apps/api/src/routes/alertas.routes.ts b/apps/api/src/routes/alertas.routes.ts index 0d1e35c..9d8c801 100644 --- a/apps/api/src/routes/alertas.routes.ts +++ b/apps/api/src/routes/alertas.routes.ts @@ -1,12 +1,16 @@ import { Router, type IRouter } from 'express'; import { authenticate } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; import * as alertasController from '../controllers/alertas.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.use(checkPlanLimits); +router.use(requireFeature('alertas')); router.get('/', alertasController.getAlertas); router.get('/stats', alertasController.getStats); diff --git a/apps/api/src/routes/calendario.routes.ts b/apps/api/src/routes/calendario.routes.ts index 90ae3ee..6c1488b 100644 --- a/apps/api/src/routes/calendario.routes.ts +++ b/apps/api/src/routes/calendario.routes.ts @@ -1,12 +1,16 @@ import { Router, type IRouter } from 'express'; import { authenticate } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; import * as calendarioController from '../controllers/calendario.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.use(checkPlanLimits); +router.use(requireFeature('calendario')); router.get('/', calendarioController.getEventos); router.get('/proximos', calendarioController.getProximos); diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts index 988b1b6..42ab545 100644 --- a/apps/api/src/routes/cfdi.routes.ts +++ b/apps/api/src/routes/cfdi.routes.ts @@ -1,12 +1,14 @@ import { Router, type IRouter } from 'express'; import { authenticate } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js'; import * as cfdiController from '../controllers/cfdi.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.use(checkPlanLimits); router.get('/', cfdiController.getCfdis); router.get('/resumen', cfdiController.getResumen); @@ -14,8 +16,8 @@ router.get('/emisores', cfdiController.getEmisores); router.get('/receptores', cfdiController.getReceptores); router.get('/:id', cfdiController.getCfdiById); router.get('/:id/xml', cfdiController.getXml); -router.post('/', cfdiController.createCfdi); -router.post('/bulk', cfdiController.createManyCfdis); +router.post('/', checkCfdiLimit, cfdiController.createCfdi); +router.post('/bulk', checkCfdiLimit, cfdiController.createManyCfdis); router.delete('/:id', cfdiController.deleteCfdi); export { router as cfdiRoutes }; diff --git a/apps/api/src/routes/dashboard.routes.ts b/apps/api/src/routes/dashboard.routes.ts index 26d9f63..70548ee 100644 --- a/apps/api/src/routes/dashboard.routes.ts +++ b/apps/api/src/routes/dashboard.routes.ts @@ -1,12 +1,14 @@ import { Router, type IRouter } from 'express'; import { authenticate } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; import * as dashboardController from '../controllers/dashboard.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.use(checkPlanLimits); router.get('/kpis', dashboardController.getKpis); router.get('/ingresos-egresos', dashboardController.getIngresosEgresos); diff --git a/apps/api/src/routes/reportes.routes.ts b/apps/api/src/routes/reportes.routes.ts index 455a9cb..5bb2368 100644 --- a/apps/api/src/routes/reportes.routes.ts +++ b/apps/api/src/routes/reportes.routes.ts @@ -1,12 +1,16 @@ import { Router, type IRouter } from 'express'; import { authenticate } from '../middlewares/auth.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js'; +import { requireFeature } from '../middlewares/feature-gate.middleware.js'; import * as reportesController from '../controllers/reportes.controller.js'; const router: IRouter = Router(); router.use(authenticate); router.use(tenantMiddleware); +router.use(checkPlanLimits); +router.use(requireFeature('reportes')); router.get('/estado-resultados', reportesController.getEstadoResultados); router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);