erp-core/docs/04-modelado/especificaciones-tecnicas/backend/mgn-015/ET-MGN-015-002-api-suscripciones.md

14 KiB

ET-MGN-015-002: API de Gestión de Suscripciones

Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-002 Tipo: Backend API Estado: Especificado Fecha: 2025-11-24

Descripción Técnica

API REST para la gestión de suscripciones de tenants. Permite a los tenant owners gestionar su suscripción y a los super admins gestionar cualquier suscripción del sistema.

Endpoints

GET /api/v1/billing/subscription

Obtiene la suscripción actual del tenant autenticado.

Autorización: tenant_owner, admin

Response 200:

{
  "success": true,
  "data": {
    "id": "uuid",
    "tenant_id": "uuid",
    "plan": {
      "id": "uuid",
      "code": "professional",
      "name": "Plan Profesional",
      "price_monthly": 499.00,
      "features": { ... }
    },
    "status": "active",
    "billing_cycle": "monthly",
    "current_period_start": "2024-01-01T00:00:00Z",
    "current_period_end": "2024-02-01T00:00:00Z",
    "cancel_at_period_end": false,
    "trial_ends_at": null,
    "usage": {
      "users": { "current": 8, "limit": 25, "percentage": 32 },
      "companies": { "current": 1, "limit": 3, "percentage": 33 },
      "storage_gb": { "current": 5.2, "limit": 50, "percentage": 10 }
    },
    "created_at": "2024-01-01T00:00:00Z"
  }
}

GET /api/v1/billing/subscriptions (Admin)

Lista todas las suscripciones del sistema.

Autorización: super_admin

Query Parameters:

Parámetro Tipo Descripción
status string Filtrar por estado
plan_id UUID Filtrar por plan
page number Página (default: 1)
limit number Registros por página (default: 20)

Response 200:

{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "tenant": {
        "id": "uuid",
        "name": "Demo Company"
      },
      "plan": {
        "id": "uuid",
        "code": "professional",
        "name": "Plan Profesional"
      },
      "status": "active",
      "billing_cycle": "monthly",
      "current_period_end": "2024-02-01T00:00:00Z"
    }
  ],
  "meta": {
    "total": 150,
    "page": 1,
    "limit": 20,
    "pages": 8
  }
}

POST /api/v1/billing/subscription/upgrade

Upgrade a un plan superior.

Autorización: tenant_owner

Request Body:

{
  "plan_id": "uuid",
  "billing_cycle": "yearly",
  "payment_method_id": "uuid"
}

Response 200:

{
  "success": true,
  "data": {
    "subscription": { ... },
    "prorated_amount": 250.00,
    "invoice": {
      "id": "uuid",
      "total": 250.00,
      "status": "paid"
    }
  },
  "message": "Plan actualizado exitosamente"
}

Response 402:

{
  "success": false,
  "error": "El pago no pudo ser procesado",
  "details": {
    "decline_code": "insufficient_funds"
  }
}

POST /api/v1/billing/subscription/downgrade

Programa downgrade para fin del período.

Autorización: tenant_owner

Request Body:

{
  "plan_id": "uuid"
}

Response 200:

{
  "success": true,
  "data": {
    "subscription": { ... },
    "scheduled_plan": {
      "id": "uuid",
      "code": "basic",
      "name": "Plan Básico"
    },
    "effective_date": "2024-02-01T00:00:00Z"
  },
  "message": "Cambio de plan programado para el fin del período actual"
}

Response 400:

{
  "success": false,
  "error": "El uso actual excede los límites del nuevo plan",
  "details": {
    "users": { "current": 15, "new_limit": 10 },
    "companies": { "current": 2, "new_limit": 1 }
  }
}

POST /api/v1/billing/subscription/cancel

Cancela la suscripción al fin del período.

Autorización: tenant_owner

Request Body:

{
  "reason": "too_expensive",
  "feedback": "El precio es muy alto para nuestro presupuesto actual"
}

Response 200:

{
  "success": true,
  "data": {
    "subscription": {
      "status": "pending_cancellation",
      "cancel_at_period_end": true
    },
    "access_until": "2024-02-01T00:00:00Z"
  },
  "message": "Suscripción cancelada. Tendrás acceso hasta el 2024-02-01"
}

POST /api/v1/billing/subscription/reactivate

Reactiva una suscripción cancelada (antes del fin del período).

Autorización: tenant_owner

Response 200:

{
  "success": true,
  "data": {
    "subscription": {
      "status": "active",
      "cancel_at_period_end": false
    }
  },
  "message": "Suscripción reactivada exitosamente"
}

PUT /api/v1/billing/subscription/billing-cycle

Cambia el ciclo de facturación.

Autorización: tenant_owner

Request Body:

{
  "billing_cycle": "yearly"
}

Response 200:

{
  "success": true,
  "data": {
    "subscription": { ... },
    "new_cycle": "yearly",
    "effective_date": "2024-02-01T00:00:00Z",
    "savings": 998.00
  },
  "message": "Ciclo de facturación cambiará a anual en la próxima renovación"
}

GET /api/v1/billing/subscription/history

Historial de cambios de la suscripción.

Autorización: tenant_owner, admin

Response 200:

{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "action": "upgrade",
      "from_plan": "basic",
      "to_plan": "professional",
      "changed_at": "2024-01-15T10:30:00Z",
      "changed_by": {
        "id": "uuid",
        "email": "owner@company.com"
      }
    },
    {
      "id": "uuid",
      "action": "created",
      "to_plan": "basic",
      "changed_at": "2024-01-01T00:00:00Z"
    }
  ]
}

Service Implementation

// subscriptions.service.ts
export class SubscriptionsService {
  async getCurrentSubscription(tenantId: string) {
    const subscription = await db.queryOne(`
      SELECT s.*,
        row_to_json(sp.*) as plan
      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', 'past_due', 'pending_cancellation')
    `, [tenantId]);

    if (!subscription) {
      throw new NotFoundError('Suscripción no encontrada');
    }

    // Agregar uso actual
    subscription.usage = await this.getCurrentUsage(tenantId, subscription.plan);
    return subscription;
  }

  async upgrade(tenantId: string, data: UpgradeDto) {
    const currentSub = await this.getCurrentSubscription(tenantId);
    const newPlan = await plansService.findById(data.plan_id);

    // Validar que sea upgrade (precio mayor)
    if (newPlan.price_monthly <= currentSub.plan.price_monthly) {
      throw new BadRequestError('Para downgrade usa el endpoint correspondiente');
    }

    // Calcular prorrateo
    const daysRemaining = this.getDaysRemaining(currentSub.current_period_end);
    const dailyDiff = (newPlan.price_monthly - currentSub.plan.price_monthly) / 30;
    const proratedAmount = dailyDiff * daysRemaining;

    // Procesar pago
    const payment = await paymentsService.charge(tenantId, {
      amount: proratedAmount,
      payment_method_id: data.payment_method_id,
      description: `Upgrade a ${newPlan.name} (prorrateo)`
    });

    if (!payment.success) {
      throw new PaymentFailedError(payment.error);
    }

    // Actualizar suscripción
    const updated = await db.queryOne(`
      UPDATE billing.subscriptions
      SET plan_id = $1, billing_cycle = $2, updated_at = NOW()
      WHERE tenant_id = $3
      RETURNING *
    `, [data.plan_id, data.billing_cycle || currentSub.billing_cycle, tenantId]);

    // Registrar en historial
    await this.logChange(tenantId, {
      action: 'upgrade',
      from_plan_id: currentSub.plan.id,
      to_plan_id: data.plan_id,
      metadata: { prorated_amount: proratedAmount }
    });

    return { subscription: updated, prorated_amount: proratedAmount, invoice: payment.invoice };
  }

  async downgrade(tenantId: string, data: DowngradeDto) {
    const currentSub = await this.getCurrentSubscription(tenantId);
    const newPlan = await plansService.findById(data.plan_id);

    // Validar límites vs uso actual
    const usage = currentSub.usage;
    const violations = [];

    if (newPlan.max_users && usage.users.current > newPlan.max_users) {
      violations.push({ resource: 'users', current: usage.users.current, new_limit: newPlan.max_users });
    }
    if (newPlan.max_companies && usage.companies.current > newPlan.max_companies) {
      violations.push({ resource: 'companies', current: usage.companies.current, new_limit: newPlan.max_companies });
    }

    if (violations.length > 0) {
      throw new BadRequestError('El uso actual excede los límites del nuevo plan', { violations });
    }

    // Programar downgrade para fin del período
    await db.query(`
      UPDATE billing.subscriptions
      SET scheduled_plan_id = $1, updated_at = NOW()
      WHERE tenant_id = $2
    `, [data.plan_id, tenantId]);

    await this.logChange(tenantId, {
      action: 'downgrade_scheduled',
      from_plan_id: currentSub.plan.id,
      to_plan_id: data.plan_id,
      metadata: { effective_date: currentSub.current_period_end }
    });

    return {
      subscription: currentSub,
      scheduled_plan: newPlan,
      effective_date: currentSub.current_period_end
    };
  }

  async cancel(tenantId: string, data: CancelDto) {
    const subscription = await this.getCurrentSubscription(tenantId);

    await db.query(`
      UPDATE billing.subscriptions
      SET status = 'pending_cancellation',
          cancel_at_period_end = true,
          canceled_at = NOW(),
          cancellation_reason = $1,
          cancellation_feedback = $2,
          updated_at = NOW()
      WHERE tenant_id = $3
    `, [data.reason, data.feedback, tenantId]);

    await this.logChange(tenantId, {
      action: 'cancellation_scheduled',
      metadata: { reason: data.reason, access_until: subscription.current_period_end }
    });

    // Enviar email de confirmación
    await emailService.send({
      to: await this.getTenantOwnerEmail(tenantId),
      template: 'subscription_cancelled',
      data: { access_until: subscription.current_period_end }
    });

    return {
      subscription: { status: 'pending_cancellation', cancel_at_period_end: true },
      access_until: subscription.current_period_end
    };
  }

  private async getCurrentUsage(tenantId: string, plan: any) {
    const [users, companies, storage] = await Promise.all([
      db.queryOne('SELECT COUNT(*) FROM auth.users WHERE tenant_id = $1 AND status = $2', [tenantId, 'active']),
      db.queryOne('SELECT COUNT(*) FROM companies.companies WHERE tenant_id = $1', [tenantId]),
      db.queryOne('SELECT COALESCE(SUM(size_bytes), 0) / 1073741824.0 as gb FROM storage.files WHERE tenant_id = $1', [tenantId])
    ]);

    return {
      users: {
        current: parseInt(users.count),
        limit: plan.max_users,
        percentage: plan.max_users ? Math.round((users.count / plan.max_users) * 100) : 0
      },
      companies: {
        current: parseInt(companies.count),
        limit: plan.max_companies,
        percentage: plan.max_companies ? Math.round((companies.count / plan.max_companies) * 100) : 0
      },
      storage_gb: {
        current: parseFloat(storage.gb.toFixed(2)),
        limit: plan.max_storage_gb,
        percentage: plan.max_storage_gb ? Math.round((storage.gb / plan.max_storage_gb) * 100) : 0
      }
    };
  }

  private async logChange(tenantId: string, data: any) {
    await db.query(`
      INSERT INTO billing.subscription_history
        (tenant_id, subscription_id, action, from_plan_id, to_plan_id, metadata, changed_by)
      SELECT $1, id, $2, $3, $4, $5, $6
      FROM billing.subscriptions WHERE tenant_id = $1
    `, [tenantId, data.action, data.from_plan_id, data.to_plan_id, data.metadata, data.changed_by]);
  }
}

Jobs Programados

// jobs/subscription-renewal.job.ts
export async function processSubscriptionRenewals() {
  // Buscar suscripciones que vencen mañana
  const subscriptions = await db.query(`
    SELECT s.*, t.name as tenant_name, pm.provider_token
    FROM billing.subscriptions s
    JOIN auth.tenants t ON s.tenant_id = t.id
    LEFT JOIN billing.payment_methods pm ON pm.tenant_id = s.tenant_id AND pm.is_default = true
    WHERE s.status = 'active'
      AND s.current_period_end <= NOW() + INTERVAL '1 day'
      AND s.cancel_at_period_end = false
  `);

  for (const sub of subscriptions) {
    try {
      await renewSubscription(sub);
    } catch (error) {
      logger.error('Renewal failed', { tenant_id: sub.tenant_id, error });
      await handleRenewalFailure(sub, error);
    }
  }
}

async function renewSubscription(subscription: any) {
  const plan = await plansService.findById(subscription.plan_id);
  const amount = subscription.billing_cycle === 'yearly' ? plan.price_yearly : plan.price_monthly;

  // Aplicar downgrade programado si existe
  if (subscription.scheduled_plan_id) {
    await db.query(`
      UPDATE billing.subscriptions
      SET plan_id = scheduled_plan_id, scheduled_plan_id = NULL
      WHERE id = $1
    `, [subscription.id]);
  }

  // Crear factura y cobrar
  const invoice = await invoicesService.createForRenewal(subscription, amount);
  const payment = await paymentsService.charge(subscription.tenant_id, {
    amount,
    invoice_id: invoice.id
  });

  if (payment.success) {
    // Extender período
    await db.query(`
      UPDATE billing.subscriptions
      SET current_period_start = current_period_end,
          current_period_end = current_period_end +
            CASE billing_cycle
              WHEN 'monthly' THEN INTERVAL '1 month'
              WHEN 'quarterly' THEN INTERVAL '3 months'
              WHEN 'semi_annual' THEN INTERVAL '6 months'
              WHEN 'yearly' THEN INTERVAL '1 year'
            END,
          updated_at = NOW()
      WHERE id = $1
    `, [subscription.id]);
  }
}

Validaciones Zod

export const upgradeSchema = z.object({
  plan_id: z.string().uuid(),
  billing_cycle: z.enum(['monthly', 'quarterly', 'semi_annual', 'yearly']).optional(),
  payment_method_id: z.string().uuid().optional()
});

export const downgradeSchema = z.object({
  plan_id: z.string().uuid()
});

export const cancelSchema = z.object({
  reason: z.enum([
    'too_expensive',
    'missing_features',
    'switched_to_competitor',
    'not_using',
    'other'
  ]),
  feedback: z.string().max(1000).optional()
});

Notas de Implementación

  1. Transacciones: Upgrades deben ser atómicos (pago + actualización)
  2. Idempotencia: Manejar reintentos de manera segura
  3. Webhooks: Recibir confirmaciones del gateway de pagos
  4. Logs: Registrar todos los cambios en subscription_history
  5. Notificaciones: Enviar emails en cada evento importante