Objeto trial no login e no perfil

TLDR: expõe um objeto trial na resposta de POST /accounts/sign-in e GET /accounts/profile, para que o app saiba se o usuário está em trial, quando expira e quais funcionalidades estão liberadas.

Status: proposed Created: 2026-08-17 Owner: @matheusscfr Asana: [Trial] Criar logica para liberação das features


Context

Os modelos Trial, TrialCourse, TrialCourseLesson e UserTrial já existem (apps/trials/), assim como o admin e os dashboards de acompanhamento. Porém a configuração de permissões do trial (features, access_library_audios, audio_limit) é hoje configuração morta: nenhum endpoint da API lê esses campos.

Do lado do app (onion-app), não existe nenhuma noção de trial. A resposta de POST /accounts/sign-in devolve apenas id, code, name, email, phone, accept_terms_at, refresh e token; GET /accounts/profile devolve apenas dados cadastrais. Os campos User.subscription_status e User.expires_at existem no modelo mas nunca são serializados.

Resultado: o app não tem como saber se o usuário está em trial, quanto tempo resta, nem o que deveria estar liberado. Sem esse contrato, nenhuma tela de trial pode ser construída.

Esta spec cobre apenas a exposição do estado do trial na API. É o primeiro passo de uma sequência: o bloqueio efetivo de conteúdo e funcionalidades é escopo separado.

Objectives

  • Criar um serviço que resolve o trial vigente de um usuário a partir de UserTrial + Trial.
  • Expor o objeto trial na resposta de POST /accounts/sign-in.
  • Expor o mesmo objeto trial na resposta de GET /accounts/profile, permitindo que o app revalide o estado sem precisar deslogar.
  • Distinguir três situações no payload: usuário sem trial, trial vigente e trial expirado.

Non-goals

  • Criação/ativação de UserTrial — nenhum fluxo novo de cadastro, atribuição por campanha ou endpoint de ativação. Escopo de outra task.
  • Gating/bloqueio real de áudios, cursos, aulas, livros, lives, hábitos, sonhos ou chat. Nenhuma permission class, nenhum filtro de queryset, nenhuma alteração em Lesson.is_unlocked(). Escopo de task separada.
  • TrialCourse / TrialCourseLesson não entram no payload. O bloqueio por curso/aula será resolvido no serializer de conteúdo (o campo lesson.unlocked já existe e o app já o respeita), não no payload de autenticação.
  • Alterações em GET /accounts/ (AccountView / AccountSerializer) — o app não consome esse endpoint.
  • Validação de expiração em runtime (AccountJWTAuthentication continua checando apenas is_active).
  • Claims de trial no JWT — o token permanece inalterado.
  • Renovação de trial (Trial.allow_renewal não é lido).

Changes

apps/trials/services/__init__.py (novo)

Reexporta resolve_user_trial.

apps/trials/services/user_trial_access.py (novo)

Função resolve_user_trial(user) que retorna o UserTrial relevante ou None.

Regra de seleção:

  • Considera apenas UserTrial do usuário com converted_at__isnull=True (quem converteu virou assinante e não está mais em trial).
  • Considera apenas registros cujo trial__status == Trial.Status.ACTIVE.
  • Havendo mais de um, usa o de maior expires_at.
  • Retorna None se nada casar.

A distinção entre vigente e expirado (expires_at vs. timezone.now()) fica no serializer, não aqui — o serviço devolve o registro, o serializer descreve o estado.

apps/trials/serializers/__init__.py (novo)

Reexporta UserTrialStateSerializer.

apps/trials/serializers/user_trial_state.py (novo)

UserTrialStateSerializer(serializers.Serializer) — somente leitura, recebe uma instância de UserTrial:

Campo Tipo Origem
active bool expires_at > now
started_at datetime UserTrial.started_at
expires_at datetime UserTrial.expires_at
days_left int ceil((expires_at - now).total_seconds() / 86400), com piso em 0
features list[str] UserTrial.trial.features
access_library_audios bool UserTrial.trial.access_library_audios
audio_limit int \| null UserTrial.trial.audio_limit (null = ilimitado)

days_left usa arredondamento para cima: faltando 30 minutos, o app mostra “1 dia”, não “0 dias”.

apps/accounts/serializers/authetication.py

SignInSerializer.validate() passa a incluir a chave trial no dicionário de retorno, resolvida via resolve_user_trial(self.user). None quando não há trial.

apps/accounts/serializers/account.py

ProfileSerializer ganha trial = serializers.SerializerMethodField(read_only=True) e o campo entra em Meta.fields. get_trial() usa o mesmo serviço. Nenhum campo existente é alterado ou removido.

Contrato resultante

Trial vigente:

json "trial": { "active": true, "started_at": "2026-08-15T10:00:00-03:00", "expires_at": "2026-08-22T10:00:00-03:00", "days_left": 5, "features": ["audios", "dreams", "books"], "access_library_audios": false, "audio_limit": 3 }

Trial expirado (mesma forma, active: false e days_left: 0) — o app precisa diferenciar “trial acabou” de “nunca teve trial” para decidir entre paywall e acesso normal.

Sem trial (assinante pago, usuário já convertido, ou trial inativo/expirado no cadastro do Trial):

json "trial": null

apps/trials/apps.py

Nenhuma alteração — o app já está em INSTALLED_APPS.

Testes

  • tests/trials/test_user_trial_access.py (novo) — resolve_user_trial: sem trial → None; trial convertido → None; Trial.status != active → None; múltiplos trials → retorna o de maior expires_at; trial expirado mas não convertido → retorna o registro.
  • tests/trials/test_user_trial_state_serializer.py (novo) — cálculo de days_left (arredondamento para cima, piso em zero), active vigente e expirado, audio_limit nulo.
  • tests/accounts/ — sign-in e profile com trial vigente, com trial expirado e sem trial; garantir que os campos pré-existentes das duas respostas continuam intactos.

Sem migrations: nenhum modelo é alterado.

How to verify

  1. make deps.up && python manage.py seed — o seed já cria trials e UserTrial em estados ativo, expirado e convertido (apps/common/management/commands/seed.py:620-763).
  2. POST /api/v1/accounts/sign-in com um usuário de trial ativo do seed → resposta contém trial.active == true e days_left coerente com expires_at.
  3. GET /api/v1/accounts/profile com o token do passo 2 → mesmo objeto trial.
  4. Repetir com um usuário de trial expirado → trial.active == false, days_left == 0.
  5. Repetir com um usuário convertido e com um usuário sem trial → trial == null nos dois casos.
  6. make test passa.
  7. Conferir que nenhum campo existente sumiu das duas respostas (regressão de contrato com o app em produção).

Documentation

  • Criar .project/docs/rules/trials/trial_state_in_auth_endpoints.md — regra de negócio da resolução do trial vigente (critérios de seleção, os três estados do payload, cálculo de days_left), no formato Given/When/Then com testes vinculados.