Objeto trial no login e no perfil
TLDR: expõe um objeto
trialna resposta dePOST /accounts/sign-ineGET /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
trialna resposta dePOST /accounts/sign-in. - Expor o mesmo objeto
trialna resposta deGET /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/TrialCourseLessonnão entram no payload. O bloqueio por curso/aula será resolvido no serializer de conteúdo (o campolesson.unlockedjá 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 (
AccountJWTAuthenticationcontinua checando apenasis_active). - Claims de trial no JWT — o token permanece inalterado.
- Renovação de trial (
Trial.allow_renewalnã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
UserTrialdo usuário comconverted_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
Nonese 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 maiorexpires_at; trial expirado mas não convertido → retorna o registro.tests/trials/test_user_trial_state_serializer.py(novo) — cálculo dedays_left(arredondamento para cima, piso em zero),activevigente e expirado,audio_limitnulo.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
make deps.up && python manage.py seed— o seed já cria trials eUserTrialem estados ativo, expirado e convertido (apps/common/management/commands/seed.py:620-763).POST /api/v1/accounts/sign-incom um usuário de trial ativo do seed → resposta contémtrial.active == trueedays_leftcoerente comexpires_at.GET /api/v1/accounts/profilecom o token do passo 2 → mesmo objetotrial.- Repetir com um usuário de trial expirado →
trial.active == false,days_left == 0. - Repetir com um usuário convertido e com um usuário sem trial →
trial == nullnos dois casos. make testpassa.- 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 dedays_left), no formato Given/When/Then com testes vinculados.