Suspensão deixa de ser derivada para o perfil do usuário
TLDR:
UserProfile#calculate_documentation_statuspara de resolver:suspendeda partir de qualquer filiação suspensa do usuário. A suspensão passa a viver só na filiação que foi suspensa, exposta pelo camposuspendeddo payload — que é o que o Apolo e o trg-club já leem.
Contexto
Uma cliente teve a filiação suspensa em dezembro/2025 por chargeback. Em julho/2026 comprou uma nova filiação, passou pelo fluxo de compra e teve a documentação aprovada. Mesmo assim o Apolo continua reconhecendo a pessoa como suspensa e os benefícios da filiação nova seguem bloqueados.
A suspensão em si está correta e é por filiação: memberships.suspended / suspended_at, escrita por Membership#suspend! a partir do PAD. O problema é que esse estado é derivado para o nível do usuário por uma regra grosseira em UserProfile#calculate_documentation_status (app/models/user_profile.rb:123-134):
ruby
return :suspended if has_any_suspended_membership? # user.memberships.suspended.any?
Qualquer filiação suspensa serve — vencida, arquivada, de qualquer época. E o resultado não é calculado na leitura: fica gravado em user_profiles.documentation_status, reescrito pelo before_save :update_cached_fields (app/models/user_profile.rb:59) e pelo after_commit { update_cached_information } de Membership (app/models/membership.rb:110,296). Como o return :suspended vem antes do return :ok if admin_approved_at, aprovar a documentação pelo admin não adianta: o próprio sistema desfaz no salvamento seguinte. Não existe hoje forma de tirar a marca sem mexer na filiação antiga.
O vazamento chega ao consumidor. O endpoint apolo_membership seleciona corretamente a filiação nova (detect(&:apolo_access_permitted?) pula as suspensas), mas o payload sai contraditório: apolo_access_status: true, suspended: false e profile.status: "suspended" — este último vindo de user_profile.documentation_status.
E profile.status é gate. O trgclub-api exige documentação aprovada desde d1937f01 feat: PRO exige filiação válida e documentação aprovada (#700) (04/08/2026):
ruby
# trgclub-api — app/use_cases/subscriptions/pro/validate_eligibility.rb
context.pro_eligible = !suspended? && membership_valid? && documentation_approved?
def documentation_approved? = membership.dig(:profile, :status).to_s == "ok"
E Subscriptions::Pro::ExpireWhenIneligible, na mesma flow, desativa a assinatura PRO e rebaixa para a regular. É esse o caminho concreto pelo qual a cliente perdeu os benefícios.
Isso invalida uma premissa registrada na spec de 29/07 (20260729145634_fix_apolo_access_early_renewal.md), que afirmava “o trg-club nem lê” profile.status. Era verdade quando foi escrita, deixou de ser 6 dias depois.
Objetivos
- Suspensão passa a valer apenas para a filiação que recebeu o status, sem transbordar para filiações posteriores do mesmo usuário.
user_profiles.documentation_statusvolta a falar só de documentação:pending,analysis,ok.- Aprovar documentação pelo admin volta a funcionar para quem tem filiação suspensa no histórico — hoje é sobrescrito no save seguinte.
- Nenhum usuário sai de liberado para bloqueado. A mudança só afrouxa.
- O bloqueio real continua funcionando: quem só tem a filiação suspensa segue bloqueado, pelo campo
suspendeddo payload.
Fora de escopo
- Não faz backfill. Decisão explícita do usuário. Os perfis hoje gravados como
suspendedcontinuam assim até o próximo save do perfil ou o próximo commit de qualquer filiação do usuário, quando oupdate_cached_fieldsrecalcula sozinho. Para a cliente do ticket, um save no admin passa a resolver — hoje não resolve. - Não remove o valor
suspendeddo enumdocumentation_statusdeUserProfile, nem o métodoUserProfile#suspended?, nem a chave de localeuser_profile_documentation_statuses.SUSPENDED. Sem backfill existem linhas com o valor gravado; tirar do enum faria o reader devolvernile quebrarapp/admin/user_profiles.rb:79(documentation_status.upcase). - Não altera
Membership:suspended,suspended_at,suspend!,unsuspend!,Membership#status,apolo_access_permitted?eapolo_access_denial_reasonficam como estão. A suspensão por filiação é o comportamento correto e é o que passa a ser a única fonte. - Não altera o
trgclub-apinem nada do lado do Apolo. O trg-club reavalia a elegibilidade a cada login (API::V1::Terapeuta::BaseController#sign_in_user→AutoSubscribeJob.perform_now→CreateFlow), então recria o PRO sozinho quando o payload voltar elegível. No Apolo o campo viracbtrg_status, que é display. - Não altera a seleção de filiação do
apolo_membership(R-001) nem os campos de data. - Não mexe no
CreateNewOnboardingJob, que trata o bucket"suspended": é job de backfill sem chamador nem agendamento no repo.
Mudanças
app/models/user_profile.rb
calculate_documentation_status: remover a primeira linha,return :suspended if has_any_suspended_membership?. O cálculo passa a começar emreturn :ok if admin_approved_at.- Remover o método
has_any_suspended_membership?, que fica sem chamador. - Remover
scope :suspended(linha 65), que fica sem chamador depois da mudança no admin. - Manter o enum com o valor
suspended, o métodosuspended?epublic_serializecom a chavesuspended:— leem a coluna, não o cálculo, e o contrato do/mee douser_profilesnão muda.
app/models/user.rb
active_memberships: removerreturn [] if user_profile.suspended?(linha 78). É redundante — o scopeMembership.active(app/models/membership.rb:79) já filtrawhere(suspended: false)— e é justamente o que zerava as filiações válidas de quem teve uma suspensão no passado. O método não tem chamador no repo hoje; a linha sai junto porque carrega o mesmo bug.
app/admin/user_profiles.rb
- Remover
scope "Suspensos", :suspended(linha 62). Ficaria sempre vazio para dados novos. A visão certa já existe emapp/admin/memberships.rb:58, que lista filiações suspensas — que é o que a equipe precisa ver.
test/models/user_profile_test.rb
- Reescrever
"calculate_documentation_status returns :suspended when user has suspended membership"(linha 50), que fixa o comportamento a ser removido. Vira o inverso: filiação suspensa não torna o perfil suspenso. - Novo caso, o do ticket: usuário com filiação antiga suspensa e vencida + filiação nova paga e com perfil aprovado →
calculate_documentation_statusresolve:ok. - Novo caso: usuário com filiação suspensa e documentação não aprovada → resolve
:pending(e não:suspended), provando que a suspensão sumiu do cálculo sem virar aprovação automática.
test/models/user_test.rb
- Caso para
active_memberships: usuário com filiação antiga suspensa e filiação nova ativa retorna a nova. Hoje retorna[].
test/serializers/membership_apolo_serializer_test.rb
- Caso de ponta a ponta do payload: filiação antiga suspensa + nova aprovada →
suspended: false,apolo_access_status: trueeprofile[:status] == "ok". É a asserção que representa o gate do trg-club. - Guarda do caso oposto: usuário cuja única filiação paga é a suspensa →
suspended: trueeapolo_access_status: false, provando que o bloqueio legítimo sobrevive.
Como verificar
make test test=test/models/user_profile_test.rb,test/models/user_test.rbetest/serializers/membership_apolo_serializer_test.rbpassando.make testcompleto — o comportamento removido pode estar assumido em testes de controller e de onboarding.make container.server.lint.- No console de produção, para o e-mail da cliente:
user.user_profile.calculate_documentation_statusdeve devolver:ok(ou:pending, seadmin_approved_atestiver vazio — ver a nota abaixo). GET /api/v1/apolo_membership?email=<e-mail da cliente>deve devolverapolo_access_status: true,suspended: falseeprofile.status: "ok".- Confirmar no trg-club que a assinatura PRO volta a ficar ativa no próximo login dela.
Nota de dado, não de código: o gate do trg-club exige profile.status == "ok", e o cálculo só chega em :ok com user_profiles.admin_approved_at preenchido. Se a aprovação de julho tiver ficado só na filiação (memberships.admin_approved_at) e não no perfil, ela sai de "suspended" para "pending" e segue bloqueada — por um motivo verdadeiro e resolvível pela equipe de documentação no admin, o que depois desta mudança passa a funcionar. Conferir esse campo antes de fechar o ticket.
Documentação
- Criar
.project/docs/rules/membership/suspension_scoped_to_membership.md(R-006): a suspensão vale só para a filiação suspensa e o perfil não a herda. Registrar queprofile.statusé gate de PRO no trg-club esuspendedé o sinal de suspensão para consumidores externos. Indexar em.project/docs/RULES.mde.project/docs/README.md. - Atualizar
.project/docs/rules/membership/apolo_membership_composed_response.md: a restrição sobreprofile.status/profile_statusprecisa registrar que o campo virou gate no trg-club a partir de #700, e apontar para R-006. - Adicionar nota de atualização em
.project/docs/specs/20260729145634_fix_apolo_access_early_renewal.md, no item “Fora de escopo” que afirma “o trg-club nem lê” — era verdade em 29/07/2026, deixou de ser em 04/08/2026. Não reescrever a spec, só anotar, seguindo o padrão de20260709122104_fix_status_after_definitive_card_renewal.md. - Criar
.project/docs/learnings/derived_user_level_state_leaks_across_records.md: estado derivado de um registro filho e gravado no pai vaza para os irmãos e não tem como ser corrigido pela interface, porque o callback reescreve. Somar aos aprendizados irmãos já registrados sobre “o registro mais recente” e sobre condicionar campos de fato a gates de aprovação.