# Cuota del alumno y tiempo del bot

En el chat conviven dos límites distintos. Si se acaban los dos, corta el
primero que llegue a 0. Los dos avisan para despedirse a los 40 s y cortan 10 s
después de llegar a 0.

| | Cuota del alumno | Tiempo del bot |
|---|---|---|
| Qué limita | El tiempo total que habla un alumno, sumando todos sus auxiliares | Lo que dura cada conversación con ese auxiliar (ver abajo) |
| Quién lo configura | El admin, en Usuarios → Gestionar cuota | Quien edita el bot: admin, o profesor con `canEditBots` |
| A quién se aplica | Solo a alumnos, y solo con la gestión de cuotas activada | A quien tiene asignado el auxiliar (alumnos) |
| Datos | `QuotaGrant` + `UsageLedger` | `Bot.isTimerEnabled` + `Bot.maxUsageSeconds` |
| Mensajes del WebSocket | `quota_update`, `quota_exhausted` | `timer_update`, `time_expired` |

## Cuota del alumno

Desde el 24-oct-2026 (`2b9e5ce`) **no se reinicia cada mes**: dura hasta que se
gasta o hasta que caduca el extra. Los nombres con «Monthly» que quedan en el
código (`enforceUserMonthlyQuota`, `getUserMonthlyQuota`…) son históricos.

### Cómo se calcula

Todo sale de `QuotaService.getRemainingSeconds`
([quotaService.ts](../../backend/src/services/quotaService.ts)). Es el único
cálculo y lo usan el chat, el middleware de las rutas de ElevenLabs y el modal
del admin.

- Cada alumno tiene filas en `QuotaGrant`: `BASE` (la cuota base) y `BONUS`
  (el extra), con sus segundos, su `periodStart` y, normalmente solo el extra,
  un `expiresAt`.
- **Periodo vigente**: el `periodStart` más reciente entre sus cuotas no
  caducadas (`expiresAt` vacío o futuro). Si no tiene ninguna, el saldo es 0.
- **Asignado** = base + extra de ese periodo, sin las caducadas.
- **Consumido** = suma de `UsageLedger` desde el inicio de ese periodo, sin
  fecha de fin.
- **Restante** = asignado − consumido, con mínimo 0.
- `periodEnd` sigue en la tabla porque el esquema lo exige, pero ya no se usa
  para nada.

### Cómo se edita

- Solo ADMIN: `GET` y `POST /api/usage/quota/:userId` (`authorize('ADMIN')`),
  desde Usuarios → Gestionar cuota (`components/admin/UserQuotaManager.vue`).
- `QuotaService.setUserQuota` guarda base y extra en una sola transacción, en
  el periodo vigente, y bloquea la fila del usuario (`FOR UPDATE`) contra
  guardados simultáneos. Por eso editar nunca crea filas nuevas ni reinicia el
  consumo. Si el alumno no tiene ninguna cuota vigente, empieza un periodo
  nuevo en ese momento.
- Extra a 0 → se borra. Fecha del extra vacía → `expiresAt = null`, es decir,
  no caduca. Fecha inválida → error 400.
- **Para recargar**, el campo del extra es el **total acumulado**: si un alumno
  con 120 min compra 2 h más, se pone 240, y el consumo se descuenta aparte.
  Poner solo 120 sustituiría el total.
- Uso real a 24-oct-2026: EWC deja la base a 0 y usa el extra, con su fecha de
  caducidad, como su «paquete de horas hasta tal fecha».

### A quién se aplica

- Solo a STUDENT (`QuotaService.isQuotaSubjectRole`). A ADMIN y TEACHER no se
  les bloquea ni se les anota consumo.
- Interruptor general en Configuración → Cuotas
  (`system_config.quota_management_enabled`). Desactivado, nadie se bloquea
  por cuota, pero **el consumo de los alumnos se sigue anotando** y se
  descuenta cuando se vuelve a activar. Sirve como medida de urgencia para que
  puedan seguir hablando, no para congelar el saldo.

### Cuándo se comprueba

1. **Antes de empezar.** El middleware `enforceUserMonthlyQuota` protege las
   rutas `signed-url` y `conversation-token` de ElevenLabs (responde 403 si no
   queda saldo). El WebSocket también lo comprueba en `init_conversation`:
   envía `quota_exhausted` y no arranca la llamada.
2. **Durante la llamada** (`websocketService.ts`):
   - el saldo se lee **una vez al empezar** y se descuenta con un reloj propio
     (`quotaStartTime`), independiente del tiempo del bot;
   - cada segundo se envía `quota_update`;
   - a los 40 s del final se envía `time_warning`, una sola vez;
   - al llegar a 0 se esperan 10 s (`HARD_STOP_GRACE_SECONDS`), se envía
     `quota_exhausted` y se cierra la conexión.
3. **Al terminar.** `ConversationService.endConversation` suma la duración de
   las sesiones y la anota en `UsageLedger`, en una fila por conversación
   (`relatedConversationId` es único y se escribe con upsert). Lo hace antes de
   generar el feedback, para que quien cuelga y vuelve a llamar enseguida
   empiece con el saldo ya descontado.

### Limitaciones conocidas

Aceptadas en el arreglo del 24-oct-2026. Las resolvería el sistema nuevo de
cuotas, que está pendiente de EWC ([status.md](../status.md)).

- Dos llamadas a la vez del mismo alumno pueden pasarse del saldo, porque cada
  una parte del saldo que había al empezar.
- Una recarga o una caducidad durante una llamada valen desde la siguiente.
- Los 10 s de margen al cortar cuentan como consumo; ese exceso se descuenta de
  la siguiente recarga.
- **Solo puede haber un extra por alumno** (clave única
  `userId + periodStart + type`). Dos compras con caducidades distintas no
  pueden convivir: la fecha nueva se aplica a todo el extra.
- El modal muestra el consumo y el restante en minutos redondeados hacia abajo
  (26 s salen como «0 min»).
- Conversaciones reanudadas: el backend conserva el código, pero desde F1 el
  alumno no puede reanudar una conversación desde la pantalla.

## Tiempo del bot

- Se activa en el propio bot con `isTimerEnabled` y `maxUsageSeconds`.
- Al empezar la llamada, el WebSocket calcula el restante como
  `maxUsageSeconds − StudentBotAccess.totalUsageSeconds` y lo envía en
  `timerSettings`, dentro de `elevenlabs_ready`. Después manda `timer_update`
  cada segundo, `time_warning` a los 40 s del final y `time_expired` 10 s
  después de llegar a 0, y cierra la conexión.
- **En la práctica funciona como límite por conversación.** El acumulado por
  alumno y bot (`StudentBotAccess.totalUsageSeconds`) no lo actualiza ningún
  código actual: las rutas de uso por bot se quitaron (ver el comentario en
  `routes/usage.ts`). Así, cada conversación empieza con el máximo completo,
  salvo que alguna fila antigua conserve un valor (no comprobado).
  `StudentBotAccess.isLocked` tampoco lo escribe nadie ya.
- Solo se aplica si el usuario tiene ese auxiliar asignado; por eso un admin
  que prueba un bot no tiene este límite.
- En ElevenLabs, la duración máxima del agente debe ser este tiempo + 90 s, para
  que ElevenLabs no corte antes que la aplicación ([conversations.md](conversations.md#configuración-de-cada-agente-en-elevenlabs)).
- En pantalla lo muestra `components/student/ChatTimer.vue`.
