erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-015/ET-MGN-015-005-api-uso-metricas.md

18 KiB

ET-MGN-015-005: API de Uso y Métricas

Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-005 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:

{
  "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:

{
  "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:

{
  "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:

{
  "success": true,
  "data": {
    "allowed": true,
    "current": 8,
    "limit": 25,
    "remaining": 17
  }
}

Response cuando no permitido:

{
  "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:

{
  "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:

{
  "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:

{
  "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

-- 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

// 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<boolean> {
    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<string, string> = {
      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<string, string> = {
      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

// 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

// 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