# Errores de plan

Cómo distinguir un 403 por falta de permiso de un bloqueo por plan (MODULE_NOT_ENABLED y CAPABILITY_NOT_ENABLED) y qué hacer con cada uno desde los SDK.

Delante de cada operación de UTILIA OS hay dos puertas, y las dos responden con un HTTP 403. Esta página explica cómo distinguirlas desde el SDK, porque la reacción correcta es la contraria en cada caso.

1. **El plan de la organización.** ¿Tiene contratado el aplicativo (Marketing, Finanzas, Chat…)? ¿Y la capacidad concreta que hace falta? Es una puerta comercial y vale para toda la organización, sin excepciones por rol.
2. **El permiso.** ¿Esta credencial o esta persona puede hacerlo, y sobre qué datos?

En el SDK los dos llegan con `code` igual a `FORBIDDEN`. Lo que los separa es el `errorCode` del cuerpo de la respuesta. Si no los distingues, acabarás pidiendo permisos que ya están concedidos para desbloquear algo que en realidad no está contratado.

> **Atención: Un bloqueo de plan no se reintenta**
>
> Volver a llamar falla igual, consume cuota y llena tus registros de ruido. No cambia hasta que la organización amplía su plan: trátalo como un estado esperado de tu integración, no como una incidencia.

## Los dos códigos

| `errorCode` | Qué significa |
| - | - |
| `MODULE_NOT_ENABLED` | El aplicativo entero no está en el plan. Ninguna operación de ese aplicativo va a funcionar. |
| `CAPABILITY_NOT_ENABLED` | El aplicativo sí está contratado, pero esta capacidad concreta no. El resto del aplicativo funciona con normalidad. |

Así llega un aplicativo sin contratar:

```json
{
  "statusCode": 403,
  "message": "El aplicativo Marketing no está habilitado para tu organización",
  "error": "FORBIDDEN",
  "timestamp": "2026-08-04T10:00:00.000Z",
  "errorCode": "MODULE_NOT_ENABLED",
  "moduleKey": "marketing"
}
```

Y así, una capacidad sin contratar:

```json
{
  "statusCode": 403,
  "message": "La capacidad Analítica avanzada del CRM no está incluida en el plan de tu organización",
  "error": "FORBIDDEN",
  "timestamp": "2026-08-04T10:00:00.000Z",
  "errorCode": "CAPABILITY_NOT_ENABLED",
  "capabilityKey": "crm.advanced_reports",
  "capabilityLabel": "Analítica avanzada del CRM",
  "requiredPlanKey": "advanced",
  "requiredPlanName": "Avanzado"
}
```

Los campos viajan en la raíz del cuerpo, no anidados. `capabilityLabel` es el nombre comercial de la capacidad, ya en castellano: úsalo tal cual si tienes que enseñárselo a alguien. `requiredPlanKey` y `requiredPlanName` se calculan sobre los planes publicados y pueden llegar a `null` cuando ningún plan visible incluye la capacidad; tu integración tiene que aguantar ese caso.

## Distinguirlos en el SDK

El SDK copia esos campos en `planContext` (`plan_context` en Python) y ofrece las comprobaciones `isPlanError()`, `isModuleNotEnabled()` e `isCapabilityNotEnabled()` (en Python, las propiedades `is_plan_error`, `is_module_not_enabled` e `is_capability_not_enabled`). También exporta el ayudante suelto `isPlanError(error)` (`is_plan_error(error)`), cómodo cuando el error que capturas puede no ser del SDK. El `code` sigue siendo `FORBIDDEN` en los dos casos, para no romper a quien ya lo comprobaba.

El ejemplo usa la lista de clientes, que exige el aplicativo CRM en el plan.

### JavaScript

```typescript
import { ErrorCode, isPlanError, UtiliaSDKError } from '@utilia-os/sdk-js';

try {
  await sdk.externalClients.list();
} catch (error) {
  if (isPlanError(error)) {
    if (error.isModuleNotEnabled()) {
      console.log('Aplicativo sin contratar:', error.planContext?.moduleKey);
    } else {
      console.log(
        'Falta la capacidad',
        error.planContext?.capabilityLabel,
        '- disponible en el plan',
        error.planContext?.requiredPlanName ?? 'sin plan publicado',
      );
    }
    return; // No reintentar.
  }

  if (error instanceof UtiliaSDKError && error.code === ErrorCode.FORBIDDEN) {
    // Aquí sí falta un permiso o un scope.
  }
}
```

### Python

```python
from utilia_sdk import ErrorCode, UtiliaSDKError, is_plan_error

try:
    await sdk.external_clients.list()
except UtiliaSDKError as error:
    if is_plan_error(error) and error.plan_context is not None:
        if error.is_module_not_enabled:
            print("Aplicativo sin contratar:", error.plan_context.module_key)
        else:
            print(
                "Falta la capacidad",
                error.plan_context.capability_label,
                "- disponible en el plan",
                error.plan_context.required_plan_name or "sin plan publicado",
            )
    elif error.code == ErrorCode.FORBIDDEN:
        # Aquí sí falta un permiso o un scope.
        pass
```

## Qué hacer con cada uno

El servidor comprueba las puertas en este orden, y cada salida pide una reacción distinta:

**Qué se comprueba antes de ejecutar una petición**

1. **¿Está el aplicativo en el plan?** (Plan de la organización): Si falta, desactiva en tu integración todo lo que dependa de ese aplicativo y avisa a la organización de que hay que contratarlo. Si no: 403 MODULE_NOT_ENABLED.
2. **¿Está la capacidad en el plan?** (Plan de la organización): Si falta, sigue usando el resto del aplicativo y omite solo esa capacidad; nómbrala con capabilityLabel y el plan con requiredPlanName. Si no: 403 CAPABILITY_NOT_ENABLED.
3. **¿Tiene el scope y el permiso?** (Credencial y persona): Si falta, revisa los scopes de la credencial y los permisos de la persona. Si no: 403 sin errorCode de plan.
4. **Se ejecuta la operación**

_Las tres salidas llegan como 403 con code FORBIDDEN; solo las dos primeras traen un errorCode de plan._

Para no provocar el 403, consulta el plan al arrancar: la herramienta MCP `org_plan`, operación `get`, presente en todos los servidores MCP, devuelve el plan contratado, los aplicativos y capacidades con su estado y el plan que desbloquea cada uno de los que faltan. Exige el permiso «Ver organización» y el scope `org:plan:view`. La herramienta tiene doce operaciones y seis de ellas escriben (pedir que se amplíe el plan o reservar una llamada con UTILIA, entre otras): el detalle está en [Pedir plan y reservar llamada](https://os.utilia.ai/docs/developers/mcp/plan-and-calls).

## Por MCP no llega el código

Por el carril MCP (`POST /api/mcp/{dominio}`, el que usan `sdk.mcp.*` y los copilotos conectados) el servidor no envía hoy ningún `errorCode`:

- Un **aplicativo fuera de plan** llega como error del protocolo con el mensaje «El aplicativo X no está habilitado para tu organización».
- Una **capacidad fuera de plan** llega como resultado de herramienta marcado como error, con un texto en castellano que explica qué falta y dónde se amplía. El SDK te lo entrega tal cual.

Por eso `isPlanError()` responde `false` en las llamadas MCP aunque el corte sea comercial: se apoya en un código que por esa vía no viaja, y deducirlo del texto sería adivinar. Con la API REST funciona sin problema.

La organización consulta y amplía su plan en [Ajustes › Mi plan](https://os.utilia.ai/dashboard/settings/my-plan), con los mismos nombres comerciales que devuelve `capabilityLabel`.

## Funciones que llegan próximamente

Hay un tercer 403 que no es de plan ni de permisos: el de una función en «Próximamente». Desde el 14 de octubre de 2026 lo están Tesorería, Informes (salvo el resumen de Finanzas), Comisiones, Impuestos y Contabilidad. Las estamos validando con especialistas antes de ponerlas a tu disposición, y ningún plan las abre todavía.

```json
{
  "statusCode": 403,
  "message": "El apartado Tesorería estará disponible próximamente.",
  "error": "FORBIDDEN",
  "timestamp": "2026-10-14T10:00:00.000Z",
  "errorCode": "FEATURE_COMING_SOON",
  "featureKey": "finance.treasury",
  "featureLabel": "Tesorería"
}
```

`isPlanError()` responde `false`, porque no se arregla ampliando el plan ni concediendo permisos. Compruébalo con `error.errorCode === 'FEATURE_COMING_SOON'` (en Python, `error.error_code`), no lo reintentes y desactiva lo que dependa de esa función. Por MCP llega como texto, con el mismo mensaje: «El apartado Tesorería estará disponible próximamente.». Qué herramientas afecta está en la [referencia de herramientas MCP](https://os.utilia.ai/docs/developers/mcp/reference#finanzas-servidor-utilia-finance).
