Fix: renovação antecipada remove indevidamente o acesso estendido no Apolo
TLDR:
GET /api/v1/apolo_membershippassa 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 pelotrg-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_serializee sobrescrever só as duas chaves de data. - Garantir que a mudança seja estritamente aditiva: nenhum usuário sai de
apolo_access_status: trueparafalse. 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_timestampidêntico ao de hoje para todos os usuários, por construção: o valor continua saindo denewest_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
404para 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 FKadmin_approved_by_id => users.id), para que odetectnão faça uma query por candidata. - Não altera
Membership#public_serialize, usado pelo diretório público (MembershipsController#index), pelomee pelouser_profiles. - Não toca em
profile.status/profile_status, que continuam vindo deuser_profile.documentation_status(user-level, zerado pormanual_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#700dotrgclub-apipassou a exigirprofile.status == "ok"para elegibilidade PRO — o campo virou gate. Ver R-006. Não é necessário para o fix: esse campo viracbtrg_statusno Apolo, ecbtrg_statusfoi varrido no repo inteiro — 5 ocorrências, todas display — então não gateia nada. O trg-club nem lê. Além disso,pendingali é 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_valideorder_by_last_valid_until, sob a hipótese de quevalid_gte_todayexcluiria uma filiação vencendo hoje (ele compara a colunadatecomDate.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 comodate >= datee faz cast truncando a hora, entãovalid_gte_todayinclui quem vence hoje. Confirmado pelo testevalid_gte_today includes a membership expiring today. Os dois scopes novos foram descartados evalid_gte_todayé reusado. - Não mexe em nada do lado Apolo, nem no
ExpiredYesterdayJob. - Não faz backfill de
Enrollmentno Apolo:enroll_available_coursesdepende deis_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
- test: caso da Paola (
pcecke4@gmail.com): vigente2025-09-10..2026-09-30aprovada/card issued+ nova2026-09-30..2027-09-30paga/pendente →apolo_access_statusétruee ocard_statusretornado é o da vigente (issued). Deve falhar antes do fix. Files:test/controllers/api/v1/apolo_membership_controller_test.rb - test: guarda de não-regressão dos 4 casos: vigente pendente (
card=not_issued,admin_approved_by_idnil) + renovação aprovada → retorna a renovação eapolo_access_statusétrue. Files:test/controllers/api/v1/apolo_membership_controller_test.rb - test: override das datas: no caso da Paola,
valid_until_timestampé2027-09-30(da renovação) evalid_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 - test: borda de vigência: no dia em que
vigente.valid_until == Date.current(enova.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 - 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 - test: os 3 testes existentes seguem verdes (mais recente paga sem reaprovação, aprovada mais recente,
404sem filiação paga). Files:test/controllers/api/v1/apolo_membership_controller_test.rb - test:
valid_gte_todayinclui filiação vencendo hoje e exclui a vencida ontem — comportamento de borda do qual a seleção depende. Files:test/models/membership_test.rb - fix: troca para
admin_approved_by_id. Files:app/models/membership.rb - fix: seleção e
apolo_payloadno controller. Files:app/controllers/api/v1/apolo_membership_controller.rb - chore: remover
lib/console/diagnose_early_renewal.rbelib/console/diagnose_apolo_selection_change.rb(diagnósticos descartáveis; resultados registrados nesta spec). - 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
- R-001 — Resposta do apolo_membership é composta por duas filiações — reescrita. A regra anterior (“seleciona a filiação paga mais recente”) e sua restrição “nunca voltar a considerar uma filiação mais antiga quando existir uma mais nova e paga” estavam erradas para os campos de situação. O arquivo foi renomeado e o
RULES.mdatualizado. - Aprendizado: escolher “o registro mais recente” quebra quando existe registro futuro
- O comportamento de borda de
valid_gte_today(inclui a filiação que vence hoje) está registrado nas restrições de R-001 e coberto pelo testevalid_gte_today includes a membership expiring today.