Suspensão deixa de ser derivada para o perfil do usuário

TLDR: UserProfile#calculate_documentation_status para de resolver :suspended a partir de qualquer filiação suspensa do usuário. A suspensão passa a viver só na filiação que foi suspensa, exposta pelo campo suspended do 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_status volta 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 suspended do payload.

Fora de escopo

  • Não faz backfill. Decisão explícita do usuário. Os perfis hoje gravados como suspended continuam assim até o próximo save do perfil ou o próximo commit de qualquer filiação do usuário, quando o update_cached_fields recalcula sozinho. Para a cliente do ticket, um save no admin passa a resolver — hoje não resolve.
  • Não remove o valor suspended do enum documentation_status de UserProfile, nem o método UserProfile#suspended?, nem a chave de locale user_profile_documentation_statuses.SUSPENDED. Sem backfill existem linhas com o valor gravado; tirar do enum faria o reader devolver nil e quebrar app/admin/user_profiles.rb:79 (documentation_status.upcase).
  • Não altera Membership: suspended, suspended_at, suspend!, unsuspend!, Membership#status, apolo_access_permitted? e apolo_access_denial_reason ficam 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-api nem 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 vira cbtrg_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 em return :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étodo suspended? e public_serialize com a chave suspended: — leem a coluna, não o cálculo, e o contrato do /me e do user_profiles não muda.

app/models/user.rb

  • active_memberships: remover return [] if user_profile.suspended? (linha 78). É redundante — o scope Membership.active (app/models/membership.rb:79) já filtra where(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 em app/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_status resolve :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: true e profile[: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: true e apolo_access_status: false, provando que o bloqueio legítimo sobrevive.

Como verificar

  1. make test test=test/models/user_profile_test.rb, test/models/user_test.rb e test/serializers/membership_apolo_serializer_test.rb passando.
  2. make test completo — o comportamento removido pode estar assumido em testes de controller e de onboarding.
  3. make container.server.lint.
  4. No console de produção, para o e-mail da cliente: user.user_profile.calculate_documentation_status deve devolver :ok (ou :pending, se admin_approved_at estiver vazio — ver a nota abaixo).
  5. GET /api/v1/apolo_membership?email=<e-mail da cliente> deve devolver apolo_access_status: true, suspended: false e profile.status: "ok".
  6. 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 que profile.status é gate de PRO no trg-club e suspended é o sinal de suspensão para consumidores externos. Indexar em .project/docs/RULES.md e .project/docs/README.md.
  • Atualizar .project/docs/rules/membership/apolo_membership_composed_response.md: a restrição sobre profile.status/profile_status precisa 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 de 20260709122104_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.