# 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](../../../requerimientos-funcionales/mgn-015/RF-MGN-015-001-gestion-planes-suscripcion.md) **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:** ```json { "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:** ```json { "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:** ```json { "success": false, "error": "Plan no encontrado" } ``` ### POST /api/v1/billing/plans Crea un nuevo plan de suscripción. **Autorización:** `super_admin` **Request Body:** ```json { "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 bajos - `name`: requerido, max 100 caracteres - `price_monthly`: requerido, >= 0 - `price_yearly`: requerido, >= 0 - `features`: objeto JSON válido **Response 201:** ```json { "success": true, "data": { ... }, "message": "Plan creado exitosamente" } ``` **Response 400:** ```json { "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) ```json { "name": "Plan Startup Plus", "price_monthly": 249.00, "max_users": 10, "is_active": true } ``` **Response 200:** ```json { "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:** ```json { "success": true, "message": "Plan eliminado exitosamente" } ``` **Response 400:** ```json { "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:** ```json { "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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript 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 1. **Sin tenant_id:** Esta tabla es global, no pertenece a ningún tenant 2. **Soft delete:** Considerar implementar soft delete para mantener histórico 3. **Caché:** Planes pueden cachearse ya que cambian poco 4. **Auditoría:** Registrar todos los cambios en tabla de auditoría