R-015 — Estado do trial exposto no sign-in e no perfil

TLDR: POST /accounts/sign-in e GET /accounts/profile devolvem um objeto trial descrevendo o trial vigente do usuário (período, dias restantes e funcionalidades liberadas), ou null quando 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 compara subscription_status com User.SUBSCRIPTION_STATUS_TRIAL sem diferenciar maiúsculas. O UserTrial só é 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_at não participa dessa decisão: quando o checkout é aprovado, user.activate() já troca o subscription_status para enabled (ver R-012), e é isso que corta o objeto trial do payload.
  • trial: null significa “não está em trial” (assinante pago, nunca teve trial, ou já converteu). Um objeto com active: false significa “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ão 0. Nunca é negativo — expirado devolve 0. Difere de days_remaining no admin (apps/trials/admin.py), que trunca.
  • id é o Trial.id (qual trial o usuário está usando), não o UserTrial.id do vínculo.
  • total_days vem de Trial.duration_days — é a duração contratada do trial e não muda conforme o prazo corre. Com days_left, permite ao app montar progresso (“faltam 3 de 7 dias”).
  • audio_limit: null significa ilimitado.
  • checkout_url vem de Trial.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 de active: 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/profile também expõe User.expires_at como campo próprio, fora do objeto trial — é 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 no PUT é ignorada.
  • A resolução ignora UserTrial soft-deleted.
  • Este contrato não aplica bloqueio: as funcionalidades listadas em features ainda não são impostas por nenhuma permissão. O gating é escopo separado.
  • GET /accounts/ (AccountSerializer) não foi alterado — apenas sign-in e profile.

Linked test

tests/trials/test_user_trial_state_serializer.py tests/accounts/test_serializer_signin.py tests/accounts/test_serializers_account.py