# ET-MGN-015-005: API de Uso y Métricas **Módulo:** MGN-015 - Billing y Suscripciones **RF Relacionado:** [RF-MGN-015-005](../../../requerimientos-funcionales/mgn-015/RF-MGN-015-005-registro-uso-metricas.md) **Tipo:** Backend API **Estado:** Especificado **Fecha:** 2025-11-24 ## Descripción Técnica API REST para consulta de métricas de uso y sistema de validación de límites según el plan contratado. Incluye funciones de base de datos para validación en tiempo real y endpoints para dashboard de uso. ## Endpoints ### GET /api/v1/billing/usage Obtiene el uso actual del tenant. **Autorización:** `tenant_owner`, `admin` **Response 200:** ```json { "success": true, "data": { "plan": { "code": "professional", "name": "Plan Profesional" }, "metrics": { "users": { "current": 8, "limit": 25, "percentage": 32, "status": "ok" }, "companies": { "current": 1, "limit": 3, "percentage": 33, "status": "ok" }, "storage_gb": { "current": 12.5, "limit": 50, "percentage": 25, "status": "ok" }, "api_calls_month": { "current": 15420, "limit": 100000, "percentage": 15, "status": "ok" } }, "alerts": [] } } ``` **Response con alertas:** ```json { "success": true, "data": { "plan": { ... }, "metrics": { "users": { "current": 24, "limit": 25, "percentage": 96, "status": "warning" }, "storage_gb": { "current": 52.3, "limit": 50, "percentage": 105, "status": "exceeded" } }, "alerts": [ { "type": "warning", "metric": "users", "message": "Estás cerca del límite de usuarios (24/25)" }, { "type": "error", "metric": "storage_gb", "message": "Has excedido el límite de almacenamiento. Libera espacio o actualiza tu plan." } ] } } ``` ### GET /api/v1/billing/usage/history Historial de uso por período. **Autorización:** `tenant_owner`, `admin` **Query Parameters:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | metric | string | Tipo de métrica (users, storage, api_calls) | | period | string | monthly, weekly (default: monthly) | | months | number | Meses hacia atrás (default: 12) | **Response 200:** ```json { "success": true, "data": { "metric": "users", "period": "monthly", "history": [ { "period": "2024-01", "value": 5 }, { "period": "2024-02", "value": 6 }, { "period": "2024-03", "value": 8 } ] } } ``` ### GET /api/v1/billing/usage/check Verifica si una acción específica está permitida. **Autorización:** Cualquier usuario autenticado **Query Parameters:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | action | string | Acción a verificar (add_user, add_company, upload_file) | | size | number | Para upload_file, tamaño en bytes | **Response 200:** ```json { "success": true, "data": { "allowed": true, "current": 8, "limit": 25, "remaining": 17 } } ``` **Response cuando no permitido:** ```json { "success": true, "data": { "allowed": false, "current": 25, "limit": 25, "remaining": 0, "message": "Has alcanzado el límite de usuarios de tu plan", "upgrade_url": "/settings/billing/upgrade" } } ``` ### GET /api/v1/billing/features Lista las features habilitadas para el tenant. **Autorización:** Cualquier usuario autenticado **Response 200:** ```json { "success": true, "data": { "inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": false, "hr": false, "manufacturing": false, "reports_basic": true, "reports_advanced": true, "api_access": true, "white_label": false, "custom_domain": false, "sso": false } } ``` ### GET /api/v1/billing/features/:feature Verifica si una feature específica está habilitada. **Autorización:** Cualquier usuario autenticado **Response 200:** ```json { "success": true, "data": { "feature": "crm", "enabled": true } } ``` ### GET /api/v1/admin/billing/usage (Admin Global) Métricas de uso de todos los tenants. **Autorización:** `super_admin` **Query Parameters:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | plan_id | UUID | Filtrar por plan | | over_limit | boolean | Solo tenants que exceden límites | **Response 200:** ```json { "success": true, "data": { "summary": { "total_tenants": 150, "active_subscriptions": 142, "over_limit_count": 3, "total_revenue_mrr": 45000.00 }, "by_plan": [ { "plan": "free", "count": 50, "revenue": 0 }, { "plan": "basic", "count": 60, "revenue": 11940 }, { "plan": "professional", "count": 35, "revenue": 17465 }, { "plan": "enterprise", "count": 5, "revenue": 15000 } ], "tenants_over_limit": [ { "tenant_id": "uuid", "name": "Acme Corp", "plan": "basic", "exceeded_metrics": ["users", "storage"] } ] } } ``` ## Funciones de Base de Datos ```sql -- billing.can_add_user(tenant_id) → boolean -- Verifica si el tenant puede agregar un nuevo usuario CREATE OR REPLACE FUNCTION billing.can_add_user(p_tenant_id UUID) RETURNS BOOLEAN AS $$ DECLARE v_max_users INTEGER; v_current_users INTEGER; BEGIN -- Obtener límite del plan SELECT sp.max_users INTO v_max_users FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); -- NULL significa ilimitado IF v_max_users IS NULL THEN RETURN TRUE; END IF; -- Contar usuarios activos SELECT COUNT(*) INTO v_current_users FROM auth.users WHERE tenant_id = p_tenant_id AND status = 'active'; RETURN v_current_users < v_max_users; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- billing.can_add_company(tenant_id) → boolean CREATE OR REPLACE FUNCTION billing.can_add_company(p_tenant_id UUID) RETURNS BOOLEAN AS $$ DECLARE v_max_companies INTEGER; v_current_companies INTEGER; BEGIN SELECT sp.max_companies INTO v_max_companies FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); IF v_max_companies IS NULL THEN RETURN TRUE; END IF; SELECT COUNT(*) INTO v_current_companies FROM companies.companies WHERE tenant_id = p_tenant_id AND deleted_at IS NULL; RETURN v_current_companies < v_max_companies; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- billing.has_feature(tenant_id, feature_name) → boolean CREATE OR REPLACE FUNCTION billing.has_feature(p_tenant_id UUID, p_feature VARCHAR) RETURNS BOOLEAN AS $$ DECLARE v_features JSONB; BEGIN SELECT sp.features INTO v_features FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); IF v_features IS NULL THEN RETURN FALSE; END IF; RETURN COALESCE((v_features ->> p_feature)::boolean, FALSE); END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- billing.get_current_usage(tenant_id) → jsonb CREATE OR REPLACE FUNCTION billing.get_current_usage(p_tenant_id UUID) RETURNS JSONB AS $$ DECLARE v_result JSONB; v_plan RECORD; v_users INTEGER; v_companies INTEGER; v_storage_gb NUMERIC; BEGIN -- Obtener plan SELECT sp.* INTO v_plan FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); -- Contar recursos SELECT COUNT(*) INTO v_users FROM auth.users WHERE tenant_id = p_tenant_id AND status = 'active'; SELECT COUNT(*) INTO v_companies FROM companies.companies WHERE tenant_id = p_tenant_id AND deleted_at IS NULL; SELECT COALESCE(SUM(size_bytes), 0) / 1073741824.0 INTO v_storage_gb FROM storage.files WHERE tenant_id = p_tenant_id; v_result := jsonb_build_object( 'users', jsonb_build_object( 'current', v_users, 'limit', v_plan.max_users, 'percentage', CASE WHEN v_plan.max_users IS NULL THEN 0 ELSE ROUND((v_users::numeric / v_plan.max_users) * 100) END ), 'companies', jsonb_build_object( 'current', v_companies, 'limit', v_plan.max_companies, 'percentage', CASE WHEN v_plan.max_companies IS NULL THEN 0 ELSE ROUND((v_companies::numeric / v_plan.max_companies) * 100) END ), 'storage_gb', jsonb_build_object( 'current', ROUND(v_storage_gb, 2), 'limit', v_plan.max_storage_gb, 'percentage', CASE WHEN v_plan.max_storage_gb IS NULL THEN 0 ELSE ROUND((v_storage_gb / v_plan.max_storage_gb) * 100) END ) ); RETURN v_result; END; $$ LANGUAGE plpgsql SECURITY DEFINER; ``` ## Service Implementation ```typescript // usage.service.ts export class UsageService { async getCurrentUsage(tenantId: string) { const plan = await this.getTenantPlan(tenantId); const usage = await db.queryOne( 'SELECT billing.get_current_usage($1) as usage', [tenantId] ); const metrics = usage.usage; const alerts: Alert[] = []; // Generar alertas for (const [key, metric] of Object.entries(metrics) as [string, any][]) { if (metric.limit !== null) { if (metric.percentage >= 100) { metric.status = 'exceeded'; alerts.push({ type: 'error', metric: key, message: this.getExceededMessage(key) }); } else if (metric.percentage >= 80) { metric.status = 'warning'; alerts.push({ type: 'warning', metric: key, message: this.getWarningMessage(key, metric.current, metric.limit) }); } else { metric.status = 'ok'; } } else { metric.status = 'unlimited'; } } return { plan, metrics, alerts }; } async checkAction(tenantId: string, action: string, params?: any) { switch (action) { case 'add_user': return this.checkAddUser(tenantId); case 'add_company': return this.checkAddCompany(tenantId); case 'upload_file': return this.checkUploadFile(tenantId, params?.size || 0); default: throw new BadRequestError(`Acción desconocida: ${action}`); } } async checkAddUser(tenantId: string) { const result = await db.queryOne( 'SELECT billing.can_add_user($1) as allowed', [tenantId] ); const usage = await db.queryOne(` SELECT (SELECT COUNT(*) FROM auth.users WHERE tenant_id = $1 AND status = 'active') as current, sp.max_users as limit FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = $1 AND s.status IN ('active', 'trialing') `, [tenantId]); return { allowed: result.allowed, current: usage.current, limit: usage.limit, remaining: usage.limit ? Math.max(0, usage.limit - usage.current) : null, message: result.allowed ? null : 'Has alcanzado el límite de usuarios de tu plan', upgrade_url: result.allowed ? null : '/settings/billing/upgrade' }; } async checkAddCompany(tenantId: string) { const result = await db.queryOne( 'SELECT billing.can_add_company($1) as allowed', [tenantId] ); const usage = await db.queryOne(` SELECT (SELECT COUNT(*) FROM companies.companies WHERE tenant_id = $1 AND deleted_at IS NULL) as current, sp.max_companies as limit FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = $1 AND s.status IN ('active', 'trialing') `, [tenantId]); return { allowed: result.allowed, current: usage.current, limit: usage.limit, remaining: usage.limit ? Math.max(0, usage.limit - usage.current) : null, message: result.allowed ? null : 'Has alcanzado el límite de empresas de tu plan' }; } async getFeatures(tenantId: string) { const plan = await db.queryOne(` SELECT sp.features FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = $1 AND s.status IN ('active', 'trialing') `, [tenantId]); return plan?.features || {}; } async hasFeature(tenantId: string, feature: string): Promise { const result = await db.queryOne( 'SELECT billing.has_feature($1, $2) as enabled', [tenantId, feature] ); return result?.enabled || false; } async getUsageHistory(tenantId: string, metric: string, months: number = 12) { const records = await db.query(` SELECT TO_CHAR(period_start, 'YYYY-MM') as period, quantity as value FROM billing.usage_records WHERE tenant_id = $1 AND metric_type = $2 AND period_start >= NOW() - INTERVAL '${months} months' ORDER BY period_start `, [tenantId, metric]); return { metric, period: 'monthly', history: records }; } private getWarningMessage(metric: string, current: number, limit: number): string { const messages: Record = { users: `Estás cerca del límite de usuarios (${current}/${limit})`, companies: `Estás cerca del límite de empresas (${current}/${limit})`, storage_gb: `Estás cerca del límite de almacenamiento (${current}GB/${limit}GB)` }; return messages[metric] || `Cerca del límite de ${metric}`; } private getExceededMessage(metric: string): string { const messages: Record = { users: 'Has alcanzado el límite de usuarios. Actualiza tu plan para agregar más.', companies: 'Has alcanzado el límite de empresas. Actualiza tu plan para agregar más.', storage_gb: 'Has excedido el límite de almacenamiento. Libera espacio o actualiza tu plan.' }; return messages[metric] || `Límite de ${metric} excedido`; } } ``` ## Middleware de Validación ```typescript // middleware/usage-limit.middleware.ts export function checkUserLimit(req: Request, res: Response, next: NextFunction) { const tenantId = req.user!.tenantId; usageService.checkAction(tenantId, 'add_user') .then(result => { if (!result.allowed) { return res.status(403).json({ success: false, error: result.message, data: { current: result.current, limit: result.limit } }); } next(); }) .catch(next); } export function checkCompanyLimit(req: Request, res: Response, next: NextFunction) { const tenantId = req.user!.tenantId; usageService.checkAction(tenantId, 'add_company') .then(result => { if (!result.allowed) { return res.status(403).json({ success: false, error: result.message, data: { current: result.current, limit: result.limit } }); } next(); }) .catch(next); } export function requireFeature(feature: string) { return async (req: Request, res: Response, next: NextFunction) => { const tenantId = req.user!.tenantId; const hasFeature = await usageService.hasFeature(tenantId, feature); if (!hasFeature) { return res.status(403).json({ success: false, error: `La funcionalidad "${feature}" no está disponible en tu plan`, upgrade_url: '/settings/billing/upgrade' }); } next(); }; } // Uso en rutas router.post('/users', authenticate, checkUserLimit, usersController.create); router.post('/companies', authenticate, checkCompanyLimit, companiesController.create); router.get('/crm/leads', authenticate, requireFeature('crm'), crmController.getLeads); ``` ## Jobs para Registro de Métricas ```typescript // jobs/record-usage.job.ts // Ejecutar cada 6 horas export async function recordUsageMetrics() { const tenants = await db.query(` SELECT t.id, s.id as subscription_id FROM auth.tenants t JOIN billing.subscriptions s ON s.tenant_id = t.id WHERE s.status IN ('active', 'trialing') `); for (const tenant of tenants) { const periodStart = new Date(); periodStart.setDate(1); periodStart.setHours(0, 0, 0, 0); const periodEnd = new Date(periodStart); periodEnd.setMonth(periodEnd.getMonth() + 1); // Registrar usuarios activos const users = await db.queryOne( 'SELECT COUNT(*) FROM auth.users WHERE tenant_id = $1 AND status = $2', [tenant.id, 'active'] ); await db.query(` INSERT INTO billing.usage_records (tenant_id, subscription_id, metric_type, quantity, period_start, period_end) VALUES ($1, $2, 'users', $3, $4, $5) ON CONFLICT (tenant_id, metric_type, period_start) DO UPDATE SET quantity = $3, recorded_at = NOW() `, [tenant.id, tenant.subscription_id, users.count, periodStart, periodEnd]); // Registrar almacenamiento const storage = await db.queryOne( 'SELECT COALESCE(SUM(size_bytes), 0) / 1073741824.0 as gb FROM storage.files WHERE tenant_id = $1', [tenant.id] ); await db.query(` INSERT INTO billing.usage_records (tenant_id, subscription_id, metric_type, quantity, period_start, period_end) VALUES ($1, $2, 'storage_gb', $3, $4, $5) ON CONFLICT (tenant_id, metric_type, period_start) DO UPDATE SET quantity = $3, recorded_at = NOW() `, [tenant.id, tenant.subscription_id, storage.gb, periodStart, periodEnd]); } } ``` ## Notas de Implementación 1. **Caché:** Cachear uso actual en Redis (TTL 5 min) para validaciones rápidas 2. **Triggers:** Considerar triggers en tablas para actualizar conteos en tiempo real 3. **Alertas:** Enviar emails cuando se alcance 80% y 100% de límites 4. **Histórico:** Mantener registros de uso por 12 meses para análisis