erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-015/ET-MGN-015-001-api-planes-suscripcion.md

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 bajos
  • name: requerido, max 100 caracteres
  • price_monthly: requerido, >= 0
  • price_yearly: requerido, >= 0
  • features: 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

  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