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
- Caché: Cachear uso actual en Redis (TTL 5 min) para validaciones rápidas
- Triggers: Considerar triggers en tablas para actualizar conteos en tiempo real
- Alertas: Enviar emails cuando se alcance 80% y 100% de límites
- Histórico: Mantener registros de uso por 12 meses para análisis