Fix: renovação antecipada remove indevidamente o acesso estendido no Apolo

TLDR: GET /api/v1/apolo_membership passa a montar a resposta a partir de duas filiações: os campos de situação (apolo_access_status, card_status, card_picture) vêm da filiação vigente elegível, e a data final (valid_until/valid_until_timestamp) continua vindo da filiação paga mais distante. Destrava 192 membros que renovaram antecipadamente, sem que nenhum usuário perca acesso e sem alterar um único campo lido pelo trg-club-api.

Contexto

Toda renovação (WebhookMembershipService#initialize_new_membership) cria uma nova Membership, e set_valid_since_and_until faz essa linha começar exatamente no valid_until da filiação atual. Numa renovação antecipada o usuário fica com duas filiações pagas simultâneas: a vigente (aprovada) e uma futura, que nasce sem admin_approved_at/admin_approved_by_id a menos que user.issued_definitive_card == true.

O endpoint seleciona por order_by_newest_valid_until (unarchived.reorder("valid_until DESC, id DESC")), sem filtro de vigência. Como a filiação nova sempre tem o maior valid_until, ela é sempre a escolhida — mesmo estando no futuro. Quando ainda não foi aprovada, Membership#apolo_access_permitted? retorna false e o Apolo grava apolo_access_enabled = false.

No Apolo, User#compliant_for? (apolo/app/models/user.rb:321) usa esse booleano para decidir se aplica o filtro not_expired sobre enrollments.expires_at:

ruby courses_ids = apolo_access_enabled ? courses.enabled.with_valid_access(cbtrg_expires_at).ids : courses.enabled.not_expired.with_valid_access(cbtrg_expires_at).ids

Com false, matrículas de validade vencida deixam de contar e os cursos comuns são bloqueados. Os cursos is_cbtrg (Sala Secreta, Biologia da Crença, Mentoria Avançada) continuam liberados porque dependem de cbtrg_expires_at, que recebe o valid_until da filiação nova — data ainda mais distante. Isso explica a assimetria do ticket.

Regressão introduzida

Até 219deb5 (PR #203, 2026-07-17) a seleção era memberships.admin_approved.order_by_newest_valid_until — o filtro admin_approved descartava naturalmente a renovação pendente. O PR trocou para .paid (para corrigir valid_until_timestamp: null, que rebaixava o PRO no trg-club) e passou a considerar filiações futuras.

Por que a permissão não é o lugar do ajuste

A primeira versão desta spec propunha tornar apolo_access_status user-level (User#apolo_access_permitted? sobre o par vigente+próxima). Foi descartada: exige método novo em User, método novo em Membership (covers?) e merge no payload, quando o problema é apenas qual linha o endpoint escolhe. A correção certa é na seleção.

Também foi considerado um ApoloMembershipSerializer autônomo recebendo user, no padrão de MembershipSerializer (que já compõe payload a partir de duas filiações). Descartado: duplicaria a forma inteira do public_serialize, com risco de drift silencioso quando alguém adicionar campo lá, para um endpoint de uma action só. Vale extrair se o payload do Apolo passar a divergir mais do público.

Consumidores do endpoint — verificado

Apolo (apolo/app/services/apolo_service.rb:44-58) não replica regra de elegibilidade; só persiste apolo_access_enabled, cbtrg_expires_at, cbtrg_status e cbtrg_register_number. Grep por valid_since|renewal|renovac em app/+lib/ do Apolo: zero resultados. cbtrg_status foi varrido no repo inteiro (5 ocorrências) e é display puro — manager/v2/user_serializer.rb:47, manager/components/users/UserCard.jsx:33-57, views/manager/users/show.html.erb:29-31. Não gateia acesso, não filtra query.

trg-club-api lê dois campos com regra de negócio (app/use_cases/subscriptions/pro/validate_eligibility.rb:21,28): valid_until_timestamp (define pro_eligible) e register_number (headline). O card Avo exibe card_picture/card_status/valid_until. apolo_access_status não é lido em lugar nenhum.

Os dois caminhos que rebaixam terapeuta para regular: 1. Subscriptions::Pro::ExpireWhenIneligible#reactivate_regular_subscription — dispara só com pro_eligible == false, derivado exclusivamente de valid_until_timestamp. 2. Webhook POST citrg/memberships/expired → ExpireByCITRGExpiry (pro_eligible: false hardcoded), disparado pelo ExpiredYesterdayJob. Já protegido pela guarda renweal_exists em BaseExpirationJob#perform, que pula quem tem qualquer filiação paga com valid_until maior, sem exigir aprovação.

Validação em produção (2026-07-29)

Simulação read-only da mudança sobre todos os 18.262 usuários com filiação paga:

Categoria Usuários
unchanged_same_row 17.849
changed_row_same_outcome 221
fixed (apolo_access_status false → true) 192
REGRESSION_ACCESS (true → false) 0
REGRESSION_PRO (pro_eligible true → false) 0

Uma variante mais simples — “a mais antiga ainda válida”, sem checar o gate — foi testada primeiro e reprovada: causava 4 regressões de acesso. As 4 tinham a mesma assinatura (vigente card=not_issued appr_by=nil, renovação card=issued appr_by=19705): pessoas cuja filiação vigente nunca foi aprovada mas cuja renovação foi auto-aprovada. Hoje têm acesso pela renovação, e a variante simples tiraria. O detect(&:apolo_access_permitted?) resolve porque pula a vigente reprovada e cai na renovação aprovada — a mesma linha de hoje.

Casos do ticket confirmados como corrigidos: marciaandradaz86@gmail.com, juniaribei@hotmail.com, pcecke4@gmail.com.

Objetivos

  • Separar os campos por natureza: situação (a pessoa está em conformidade agora?) vem da filiação vigente elegível; data final (até quando ela pagou?) vem da filiação paga mais distante.
  • Manter o diff mínimo: reaproveitar public_serialize e sobrescrever só as duas chaves de data.
  • Garantir que a mudança seja estritamente aditiva: nenhum usuário sai de apolo_access_status: true para false. Isso é obtido pelo fallback para a seleção atual quando nenhuma candidata vigente passa no gate — validado com 0 regressões em 18.262 usuários.
  • Preservar valid_until_timestamp idêntico ao de hoje para todos os usuários, por construção: o valor continua saindo de newest_paid_membership, que é exatamente a seleção atual. Isso torna impossível qualquer rebaixamento de PRO no trg-club, sem depender de medição.
  • Preservar o 404 para usuário sem nenhuma filiação paga.
  • Preservar a revogação automática: quando a vigente vencer e a nova continuar sem aprovação, o acesso volta a ser negado sem intervenção.
  • Manter Membership#apolo_access_permitted? como única fonte da verdade do gate — a seleção o consome, não o reimplementa em SQL.

Fora de escopo

  • Não altera as 4 condições de apolo_access_permitted?. Única mudança no método: admin_approved_by.present? → admin_approved_by_id.present?, equivalente por construção (existe FK admin_approved_by_id => users.id), para que o detect não faça uma query por candidata.
  • Não altera Membership#public_serialize, usado pelo diretório público (MembershipsController#index), pelo me e pelo user_profiles.
  • Não toca em profile.status / profile_status, que continuam vindo de user_profile.documentation_status (user-level, zerado por manual_approve! na renovação). Atualização (2026-09-01): a afirmação “o trg-club nem lê” abaixo envelheceu. Era verdade em 29/07/2026 e deixou de ser em 04/08/2026, quando o #700 do trgclub-api passou a exigir profile.status == "ok" para elegibilidade PRO — o campo virou gate. Ver R-006. Não é necessário para o fix: esse campo vira cbtrg_status no Apolo, e cbtrg_status foi varrido no repo inteiro — 5 ocorrências, todas display — então não gateia nada. O trg-club nem lê. Além disso, pending ali é informação verdadeira e acionável (“a documentação da renovação está pendente”); forçar "ok" apagaria esse sinal justamente na janela em que a pessoa precisa ser cobrada. Consequência aceita: o card do manager do Apolo mostra “Perfil: Não aprovado” para quem tem acesso liberado — o status real segue visível no admin do CITRG (app/admin/memberships.rb:168), onde a equipe de documentação trabalha.
  • Não cria scope novo. Uma versão anterior desta spec introduzia currently_valid e order_by_last_valid_until, sob a hipótese de que valid_gte_today excluiria uma filiação vencendo hoje (ele compara a coluna date com Date.today.end_of_day). A hipótese foi testada no banco e é falsa: o literal vai sem tipo explícito, o Postgres resolve o operador como date >= date e faz cast truncando a hora, então valid_gte_today inclui quem vence hoje. Confirmado pelo teste valid_gte_today includes a membership expiring today. Os dois scopes novos foram descartados e valid_gte_today é reusado.
  • Não mexe em nada do lado Apolo, nem no ExpiredYesterdayJob.
  • Não faz backfill de Enrollment no Apolo: enroll_available_courses depende de is_cbtrg_valid → cbtrg_expires_at, que durante a janela do bug ficou com a data nova (mais distante). O bug bloqueou leitura, não impediu matrícula.

Mudanças

app/models/membership.rb

Uma linha, sem scope novo:

diff - return false unless admin_approved_by.present? || ISSUED_CARD_STATUSES.include?(card_status.try(:to_sym)) + return false unless admin_approved_by_id.present? || ISSUED_CARD_STATUSES.include?(card_status.try(:to_sym))

admin_approved_by é belongs_to, então .present? carrega a associação — um SELECT em users por candidata avaliada no detect. A coluna já vem carregada, e a FK admin_approved_by_id => users.id (sem on_delete, portanto não há id órfão possível) garante equivalência.

app/controllers/api/v1/apolo_membership_controller.rb

```diff def show - @membership = @user.memberships.paid.order_by_newest_valid_until.try(:first) - - unless @membership + if membership.nil? render json: { error: I18n.t(“api.errors.membership.not_found”) }, status: :not_found and return end

  • render json: @membership.public_serialize
  • render json: apolo_payload end +
  • def membership
  • @membership   = current_eligible_membership   newest_paid_membership
  • end +
  • def current_eligible_membership
  • @user.memberships.paid.valid_gte_today.order_by_newest_valid_until.detect(&:apolo_access_permitted?)
  • end +
  • def newest_paid_membership
  • @newest_paid_membership   = @user.memberships.paid.order_by_newest_valid_until.first
  • end +
  • def apolo_payload
  • membership.public_serialize.merge(
  • valid_until: newest_paid_membership.valid_until.strftime(“%m/%Y”),
  • valid_until_timestamp: newest_paid_membership.valid_until
  • )
  • end ```

public_serialize é reaproveitado inteiro e só as duas chaves de data são sobrescritas. strftime("%m/%Y") em vez de I18n.l é deliberado: mantém valid_until byte-idêntico ao que public_serialize produz hoje.

Duas queries, memoizadas.

O fallback || newest_paid_membership é o que garante que a mudança seja aditiva. Sem ele, quando nenhuma candidata passa no gate o endpoint devolve 404 em vez de 200 com apolo_access_status: false — e 404 faz o trg-club cair em citrg_unavailable = false e executar reactivate_regular_subscription, rebaixando quem está pago. É exatamente o bug que o PR #203 corrigiu. Essa variação foi testada e derruba o teste returns the newest paid membership even when it has not been re-approved.

newest_paid_membership nunca aponta para data anterior à da escolhida, porque é o de maior valid_until entre as pagas: o override nunca move a data para trás.

Como a ordenação é valid_until DESC, o detect escolhe a mais nova que passa no gate, não a mais antiga. O resultado de acesso é idêntico ao de escolher a mais antiga — se existe alguma que passa, apolo_access_status é true de qualquer forma, e as datas vêm de newest_paid_membership nos dois casos. A diferença aparece apenas em card_status/card_picture de quem tem vigente e renovação ambas aprovadas.

O detect é o “pega o primeiro da lista que serve”. Índice posicional ([0]/[1]) foi descartado: sem filtro de vigência o [0] é a filiação mais antiga de todas — vencida, para quem tem 4 a 6 renovações — e [1] estoura em nil para os 17.849 usuários que têm uma filiação só.

Alternativa descartada: serializer autônomo

Considerado um ApoloMembershipSerializer recebendo user e reproduzindo a forma inteira do public_serialize, no padrão de MembershipSerializer. Descartado por ora: duplica a forma do hash (risco de drift silencioso quando alguém adicionar campo ao public_serialize) para um endpoint de uma action só. Vale extrair se o payload do Apolo passar a divergir mais do público.

Comportamento resultante

Cenário Base da resposta apolo_access_status valid_until_timestamp
Vigente aprovada + renovação pendente (os 192) vigente false → true inalterado
Vigente pendente + renovação aprovada (os 4 casos) renovação inalterado inalterado
Nenhuma filiação aprovada fallback: mais recente paga inalterado inalterado
Todas vencidas fallback: mais recente paga inalterado inalterado
Vigente venceu e nova segue pendente nova false inalterado
Nenhuma filiação paga — 404 404

Com o override das datas, valid_until_timestamp é idêntico ao de hoje em 100% dos casos — o efeito colateral de 413 usuários com data mais próxima, que existia na versão anterior desta spec, deixa de existir. O cbtrg_expires_at no Apolo e o card Avo do trg-club continuam mostrando a validade da renovação.

Campos que mudam para alguém:

Campo Mudança Quem consome
apolo_access_status false → true (192 usuários) Apolo → apolo_access_enabled, libera os cursos. É o fix.
card_status / card_picture passam a refletir a carteira que a pessoa de fato tem (issued) em vez da renovação não emitida (not_issued), nos 413 cuja linha-base troca card Avo do trg-club ("issued" ? "Sim" : "Não"), só display. O Apolo não persiste card_status.

Nenhum dos dois é lido por regra de negócio do trg-club, cujos dois únicos campos (valid_until_timestamp e register_number) permanecem idênticos.

Plano de implementação

  1. test: caso da Paola (pcecke4@gmail.com): vigente 2025-09-10..2026-09-30 aprovada/card issued + nova 2026-09-30..2027-09-30 paga/pendente → apolo_access_status é true e o card_status retornado é o da vigente (issued). Deve falhar antes do fix. Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  2. test: guarda de não-regressão dos 4 casos: vigente pendente (card=not_issued, admin_approved_by_id nil) + renovação aprovada → retorna a renovação e apolo_access_status é true. Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  3. test: override das datas: no caso da Paola, valid_until_timestamp é 2027-09-30 (da renovação) e valid_until é "09/2027", mesmo com a resposta baseada na vigente. Guarda de regressão do trg-club; deve passar antes e depois do fix. Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  4. test: borda de vigência: no dia em que vigente.valid_until == Date.current (e nova.valid_since == Date.current), a base ainda é a vigente; no dia seguinte passa a ser a nova. Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  5. test: fallbacks: nenhuma filiação aprovada → mais recente paga; todas vencidas → mais recente paga; nenhuma filiação paga → 404. Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  6. test: os 3 testes existentes seguem verdes (mais recente paga sem reaprovação, aprovada mais recente, 404 sem filiação paga). Files: test/controllers/api/v1/apolo_membership_controller_test.rb
  7. test: valid_gte_today inclui filiação vencendo hoje e exclui a vencida ontem — comportamento de borda do qual a seleção depende. Files: test/models/membership_test.rb
  8. fix: troca para admin_approved_by_id. Files: app/models/membership.rb
  9. fix: seleção e apolo_payload no controller. Files: app/controllers/api/v1/apolo_membership_controller.rb
  10. chore: remover lib/console/diagnose_early_renewal.rb e lib/console/diagnose_apolo_selection_change.rb (diagnósticos descartáveis; resultados registrados nesta spec).
  11. docs: reescrever R-001 e criar as learnings.

Como verificar

bash make test test=test/controllers/api/v1/apolo_membership_controller_test.rb make test test=test/models/membership_test.rb make container.server.lint

O teste 1 deve falhar antes do fix. O teste 3 deve passar antes e depois — é justamente a garantia de que a data não mudou.

Manual (console de produção, somente leitura):

```ruby user = User.find_by(email: “pcecke4@gmail.com”) newest = user.memberships.paid.order_by_newest_valid_until.first newest.id # 37284 — base de hoje, e fonte da data no payload novo newest.apolo_access_permitted? # false — o bug

current = user.memberships.paid.valid_gte_today.order_by_newest_valid_until.detect(&:apolo_access_permitted?) current.id # 29234 — nova base da resposta current.apolo_access_permitted? # true — o fix newest.valid_until # 2027-09-30 — o que continua indo em valid_until_timestamp ```

Pós-deploy a recuperação é automática: o front do aluno dispara POST /api/v1/apolo_process ao montar (apolo/app/javascript/frontend/components/LMS.jsx:65, courses/CourseContainer.jsx:20, lessons/LessonContainer.jsx:58), respeitado o intervalo de CBTRG_USER_PROCESS_INTERVAL (default 60 min). O manager v2 também tem a ação (apolo/app/javascript/manager/services/usersApi.js:104) para forçar caso a caso.

Documentação