feat: add plan enforcement middleware (subscription, CFDI limits, feature gates)
- checkPlanLimits: blocks writes when subscription inactive - checkCfdiLimit: enforces per-plan CFDI count limits - requireFeature: gates reportes/alertas/calendario by plan tier - All cached with 5-min TTL, invalidated via PM2 messaging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { app } from './app.js';
|
import { app } from './app.js';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { tenantDb } from './config/database.js';
|
import { tenantDb } from './config/database.js';
|
||||||
|
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
|
||||||
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||||
|
|
||||||
const PORT = parseInt(env.PORT, 10);
|
const PORT = parseInt(env.PORT, 10);
|
||||||
@@ -32,5 +33,6 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|||||||
process.on('message', (msg: any) => {
|
process.on('message', (msg: any) => {
|
||||||
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
|
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
|
||||||
tenantDb.invalidatePool(msg.tenantId);
|
tenantDb.invalidatePool(msg.tenantId);
|
||||||
|
invalidateTenantCache(msg.tenantId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
@@ -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<string, { plan: string; expires: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
89
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
89
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
@@ -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<string, { data: any; expires: number }>();
|
||||||
|
|
||||||
|
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as alertasController from '../controllers/alertas.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('alertas'));
|
||||||
|
|
||||||
router.get('/', alertasController.getAlertas);
|
router.get('/', alertasController.getAlertas);
|
||||||
router.get('/stats', alertasController.getStats);
|
router.get('/stats', alertasController.getStats);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('calendario'));
|
||||||
|
|
||||||
router.get('/', calendarioController.getEventos);
|
router.get('/', calendarioController.getEventos);
|
||||||
router.get('/proximos', calendarioController.getProximos);
|
router.get('/proximos', calendarioController.getProximos);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as cfdiController from '../controllers/cfdi.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
|
||||||
router.get('/', cfdiController.getCfdis);
|
router.get('/', cfdiController.getCfdis);
|
||||||
router.get('/resumen', cfdiController.getResumen);
|
router.get('/resumen', cfdiController.getResumen);
|
||||||
@@ -14,8 +16,8 @@ router.get('/emisores', cfdiController.getEmisores);
|
|||||||
router.get('/receptores', cfdiController.getReceptores);
|
router.get('/receptores', cfdiController.getReceptores);
|
||||||
router.get('/:id', cfdiController.getCfdiById);
|
router.get('/:id', cfdiController.getCfdiById);
|
||||||
router.get('/:id/xml', cfdiController.getXml);
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
router.post('/', cfdiController.createCfdi);
|
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||||
router.post('/bulk', cfdiController.createManyCfdis);
|
router.post('/bulk', checkCfdiLimit, cfdiController.createManyCfdis);
|
||||||
router.delete('/:id', cfdiController.deleteCfdi);
|
router.delete('/:id', cfdiController.deleteCfdi);
|
||||||
|
|
||||||
export { router as cfdiRoutes };
|
export { router as cfdiRoutes };
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as dashboardController from '../controllers/dashboard.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
|
||||||
router.get('/kpis', dashboardController.getKpis);
|
router.get('/kpis', dashboardController.getKpis);
|
||||||
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
|
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router, type IRouter } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as reportesController from '../controllers/reportes.controller.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('reportes'));
|
||||||
|
|
||||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||||
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||||
|
|||||||
Reference in New Issue
Block a user