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:
Consultoria AS
2026-03-15 23:41:20 +00:00
parent 69d7590834
commit b977f92141
8 changed files with 146 additions and 2 deletions

View File

@@ -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);
}
});

View 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();
};
}

View 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();
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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);