R-015 — Estado do trial exposto no sign-in e no perfil
TLDR:
POST /accounts/sign-ineGET /accounts/profiledevolvem um objetotrialdescrevendo o trial vigente do usuário (período, dias restantes e funcionalidades liberadas), ounullquando o usuário não está em trial.
Given / When / Then
Dado um usuário com subscription_status = "trial" e um UserTrial registrado
Quando ele faz sign-in ou consulta o perfil
Então a resposta traz o objeto trial com id, active, started_at, expires_at, days_left, total_days, features, access_library_audios, audio_limit e checkout_url
Dado um usuário trial cujo UserTrial já expirou (expires_at no passado)
Quando ele faz sign-in ou consulta o perfil
Então o objeto trial é devolvido com active: false e days_left: 0 — e não null
Dado um usuário cujo subscription_status é diferente de "trial" (assinante, convertido, ou vazio)
Quando ele faz sign-in ou consulta o perfil
Então o campo trial vem como null, mesmo que ele tenha um UserTrial registrado
Dado um usuário com subscription_status = "trial" mas sem nenhum UserTrial
Quando ele faz sign-in ou consulta o perfil
Então o campo trial vem como null
Dado um usuário trial com mais de um UserTrial
Quando o estado do trial é resolvido
Então vale o de maior expires_at
Constraints
- Quem define que o usuário é trial é
User.is_trial, property que comparasubscription_statuscomUser.SUBSCRIPTION_STATUS_TRIALsem diferenciar maiúsculas. OUserTrialsó é lido depois, para carregar prazo e permissões — ele nunca decide sozinho se o usuário é trial. A mesma property decide o desbloqueio de aulas em R-016. UserTrial.converted_atnão participa dessa decisão: quando o checkout é aprovado,user.activate()já troca osubscription_statusparaenabled(ver R-012), e é isso que corta o objetotrialdo payload.trial: nullsignifica “não está em trial” (assinante pago, nunca teve trial, ou já converteu). Um objeto comactive: falsesignifica “o trial acabou” — a distinção existe para o app decidir entre acesso normal e paywall.days_lefté arredondado para cima: faltando 30 minutos, o valor é1, não0. Nunca é negativo — expirado devolve0. Difere dedays_remainingno admin (apps/trials/admin.py), que trunca.idé oTrial.id(qual trial o usuário está usando), não oUserTrial.iddo vínculo.total_daysvem deTrial.duration_days— é a duração contratada do trial e não muda conforme o prazo corre. Comdays_left, permite ao app montar progresso (“faltam 3 de 7 dias”).audio_limit: nullsignifica ilimitado.checkout_urlvem deTrial.checkout_url— é o link de assinatura daquele trial (cada oferta tem o seu), obrigatório no cadastro do admin. É o destino que o app abre quando o usuário decide assinar, inclusive no paywall deactive: false. Trials criados antes do campo existir foram preenchidos pela migração com o checkout externo padrão (https://checkouts.ibft.app/2404/onion_externo/aff/370e61ef0113) — o campo nunca vem vazio.GET /accounts/profiletambém expõeUser.expires_atcomo campo próprio, fora do objetotrial— é a expiração de acesso do usuário (assinatura ou trial), preenchida tanto pelo checkout quanto pelo cadastro de trial via campanha. Read-only: uma tentativa de escrita noPUTé ignorada.- A resolução ignora
UserTrialsoft-deleted. - Este contrato não aplica bloqueio: as funcionalidades listadas em
featuresainda não são impostas por nenhuma permissão. O gating é escopo separado. GET /accounts/(AccountSerializer) não foi alterado — apenassign-ineprofile.
Linked test
tests/trials/test_user_trial_state_serializer.py
tests/accounts/test_serializer_signin.py
tests/accounts/test_serializers_account.py