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 { 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);
|
||||
}
|
||||
});
|
||||
|
||||
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 { 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user