11 KiB
ET-MGN-015-001: API de Gestión de Planes de Suscripción
Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-001 Tipo: Backend API Estado: Especificado Fecha: 2025-11-24
Descripción Técnica
API REST para la administración de planes de suscripción del sistema. Solo accesible para usuarios con rol super_admin o platform_admin. Los planes son entidades globales que no pertenecen a ningún tenant específico.
Endpoints
GET /api/v1/billing/plans
Lista todos los planes de suscripción.
Autorización: super_admin, platform_admin
Query Parameters:
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
| is_active | boolean | No | Filtrar por estado activo |
| is_public | boolean | No | Filtrar por visibilidad pública |
Response 200:
{
"success": true,
"data": [
{
"id": "uuid",
"code": "professional",
"name": "Plan Profesional",
"description": "Para empresas en crecimiento",
"price_monthly": 499.00,
"price_yearly": 4990.00,
"currency": "MXN",
"max_users": 25,
"max_companies": 3,
"max_storage_gb": 50,
"features": {
"inventory": true,
"sales": true,
"financial": true,
"crm": true,
"reports_advanced": true,
"api_access": true
},
"trial_days": 14,
"is_active": true,
"is_public": true,
"sort_order": 2,
"created_at": "2024-01-01T00:00:00Z"
}
],
"meta": {
"total": 4
}
}
GET /api/v1/billing/plans/:id
Obtiene un plan específico por ID.
Autorización: super_admin, platform_admin
Path Parameters:
| Parámetro | Tipo | Descripción |
|---|---|---|
| id | UUID | ID del plan |
Response 200:
{
"success": true,
"data": {
"id": "uuid",
"code": "professional",
"name": "Plan Profesional",
"description": "Para empresas en crecimiento",
"price_monthly": 499.00,
"price_yearly": 4990.00,
"currency": "MXN",
"max_users": 25,
"max_companies": 3,
"max_storage_gb": 50,
"features": { ... },
"trial_days": 14,
"is_active": true,
"is_public": true,
"sort_order": 2,
"active_subscriptions_count": 42,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T00:00:00Z"
}
}
Response 404:
{
"success": false,
"error": "Plan no encontrado"
}
POST /api/v1/billing/plans
Crea un nuevo plan de suscripción.
Autorización: super_admin
Request Body:
{
"code": "startup",
"name": "Plan Startup",
"description": "Ideal para emprendedores",
"price_monthly": 199.00,
"price_yearly": 1990.00,
"currency": "MXN",
"max_users": 5,
"max_companies": 1,
"max_storage_gb": 10,
"features": {
"inventory": true,
"sales": true,
"financial": true,
"crm": false
},
"trial_days": 14,
"is_active": true,
"is_public": true,
"sort_order": 1
}
Validaciones:
code: requerido, único, max 50 caracteres, solo alfanumérico y guiones bajosname: requerido, max 100 caracteresprice_monthly: requerido, >= 0price_yearly: requerido, >= 0features: objeto JSON válido
Response 201:
{
"success": true,
"data": { ... },
"message": "Plan creado exitosamente"
}
Response 400:
{
"success": false,
"error": "El código de plan ya existe"
}
PUT /api/v1/billing/plans/:id
Actualiza un plan existente.
Autorización: super_admin
Request Body: (campos opcionales)
{
"name": "Plan Startup Plus",
"price_monthly": 249.00,
"max_users": 10,
"is_active": true
}
Response 200:
{
"success": true,
"data": { ... },
"message": "Plan actualizado exitosamente"
}
DELETE /api/v1/billing/plans/:id
Elimina un plan (solo si no tiene suscripciones activas).
Autorización: super_admin
Response 200:
{
"success": true,
"message": "Plan eliminado exitosamente"
}
Response 400:
{
"success": false,
"error": "No se puede eliminar plan con suscripciones activas"
}
PATCH /api/v1/billing/plans/:id/toggle
Activa o desactiva un plan.
Autorización: super_admin
Response 200:
{
"success": true,
"data": {
"id": "uuid",
"is_active": false
},
"message": "Plan desactivado exitosamente"
}
Estructura de Archivos
src/modules/billing/
├── plans/
│ ├── plans.controller.ts
│ ├── plans.service.ts
│ ├── plans.routes.ts
│ └── plans.validation.ts
└── index.ts
Service Implementation
// plans.service.ts
export class PlansService {
async findAll(filters?: { is_active?: boolean; is_public?: boolean }) {
let query = `
SELECT sp.*,
(SELECT COUNT(*) FROM billing.subscriptions s
WHERE s.plan_id = sp.id AND s.status = 'active') as active_subscriptions_count
FROM billing.subscription_plans sp
WHERE 1=1
`;
const params: any[] = [];
if (filters?.is_active !== undefined) {
params.push(filters.is_active);
query += ` AND sp.is_active = $${params.length}`;
}
if (filters?.is_public !== undefined) {
params.push(filters.is_public);
query += ` AND sp.is_public = $${params.length}`;
}
query += ' ORDER BY sp.sort_order ASC';
return db.query(query, params);
}
async findById(id: string) {
return db.queryOne(`
SELECT sp.*,
(SELECT COUNT(*) FROM billing.subscriptions s
WHERE s.plan_id = sp.id AND s.status = 'active') as active_subscriptions_count
FROM billing.subscription_plans sp
WHERE sp.id = $1
`, [id]);
}
async create(data: CreatePlanDto) {
// Validar código único
const existing = await db.queryOne(
'SELECT id FROM billing.subscription_plans WHERE code = $1',
[data.code]
);
if (existing) {
throw new BadRequestError('El código de plan ya existe');
}
return db.queryOne(`
INSERT INTO billing.subscription_plans (
code, name, description, price_monthly, price_yearly,
currency, max_users, max_companies, max_storage_gb,
features, trial_days, is_active, is_public, sort_order
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *
`, [...Object.values(data)]);
}
async update(id: string, data: UpdatePlanDto) {
// Build dynamic update query
const fields = Object.keys(data);
const values = Object.values(data);
const setClause = fields.map((f, i) => `${f} = $${i + 2}`).join(', ');
return db.queryOne(`
UPDATE billing.subscription_plans
SET ${setClause}, updated_at = NOW()
WHERE id = $1
RETURNING *
`, [id, ...values]);
}
async delete(id: string) {
// Verificar suscripciones activas
const count = await db.queryOne(`
SELECT COUNT(*) as count
FROM billing.subscriptions
WHERE plan_id = $1 AND status IN ('active', 'trialing')
`, [id]);
if (count.count > 0) {
throw new BadRequestError('No se puede eliminar plan con suscripciones activas');
}
await db.query('DELETE FROM billing.subscription_plans WHERE id = $1', [id]);
}
async toggle(id: string) {
return db.queryOne(`
UPDATE billing.subscription_plans
SET is_active = NOT is_active, updated_at = NOW()
WHERE id = $1
RETURNING id, is_active
`, [id]);
}
}
Validación con Zod
// plans.validation.ts
import { z } from 'zod';
export const createPlanSchema = z.object({
code: z.string()
.min(2).max(50)
.regex(/^[a-z0-9_]+$/, 'Solo letras minúsculas, números y guiones bajos'),
name: z.string().min(2).max(100),
description: z.string().max(500).optional(),
price_monthly: z.number().min(0),
price_yearly: z.number().min(0),
currency: z.string().default('MXN'),
max_users: z.number().int().positive().nullable().optional(),
max_companies: z.number().int().positive().nullable().optional(),
max_storage_gb: z.number().positive().nullable().optional(),
features: z.record(z.boolean()).default({}),
trial_days: z.number().int().min(0).default(0),
is_active: z.boolean().default(true),
is_public: z.boolean().default(true),
sort_order: z.number().int().default(0)
});
export const updatePlanSchema = createPlanSchema.partial().omit({ code: true });
Middleware de Autorización
// Solo super_admin puede gestionar planes
router.use('/plans', authenticate, requireRoles('super_admin', 'platform_admin'));
// Solo super_admin puede crear/modificar/eliminar
router.post('/plans', requireRoles('super_admin'), plansController.create);
router.put('/plans/:id', requireRoles('super_admin'), plansController.update);
router.delete('/plans/:id', requireRoles('super_admin'), plansController.delete);
Tests
describe('Plans API', () => {
describe('GET /api/v1/billing/plans', () => {
it('returns all plans for super_admin', async () => {
const res = await request(app)
.get('/api/v1/billing/plans')
.set('Authorization', `Bearer ${superAdminToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toBeInstanceOf(Array);
});
it('returns 403 for regular users', async () => {
const res = await request(app)
.get('/api/v1/billing/plans')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
});
});
describe('POST /api/v1/billing/plans', () => {
it('creates a new plan', async () => {
const res = await request(app)
.post('/api/v1/billing/plans')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'test_plan',
name: 'Test Plan',
price_monthly: 99,
price_yearly: 990
});
expect(res.status).toBe(201);
expect(res.body.data.code).toBe('test_plan');
});
it('rejects duplicate codes', async () => {
const res = await request(app)
.post('/api/v1/billing/plans')
.set('Authorization', `Bearer ${superAdminToken}`)
.send({
code: 'basic', // ya existe
name: 'Another Basic',
price_monthly: 99,
price_yearly: 990
});
expect(res.status).toBe(400);
});
});
});
Notas de Implementación
- Sin tenant_id: Esta tabla es global, no pertenece a ningún tenant
- Soft delete: Considerar implementar soft delete para mantener histórico
- Caché: Planes pueden cachearse ya que cambian poco
- Auditoría: Registrar todos los cambios en tabla de auditoría