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