Fix: aprovação de documentos não rebaixa filiação com carteira já emitida
TLDR:
AdminMembershipsService#admin_approve_membershippassa a recusar, antes de qualquer mutação, uma filiação que já está comcard_status = issued. A aprovação de documentos falha de forma explícita na tela do atendente em vez de destruir o registro de aprovação e emissão da filiação, jogá-la de volta na fila e disparar auto-aprovação indevida na renovação.
Contexto
O fluxo hoje
A aprovação de documentos tem uma única entrada de UI: o perfil do usuário. Duas ações do ActiveAdmin chamam o mesmo serviço:
| Ação | Arquivo | Quando dispara |
|---|---|---|
approve_user_profile |
app/admin/user_profiles.rb:193 |
Botão “Aprovar” na tela do perfil |
request_pending_documents |
app/admin/user_profiles.rb:212 |
Moderação de documentos; quando todos os itens são marcados OK, cai no fallback da linha 253 e aprova |
Ambas chamam AdminUserProfilesService#admin_approve_user_profile (app/services/admin_user_profiles_service.rb:9), que hoje faz:
```ruby def admin_approve_user_profile(resource, user) membership_service = AdminMembershipsService.new
ActiveRecord::Base.transaction do resource.admin_approved_at = Time.now resource.admin_approved_by_id = user.id resource.save!
@profile_user = resource.user
@membership = @profile_user.current_membership # (1) escolhe o alvo por vigência
raise StandardError, "Este usuário não possui uma filiação vigente, favor criar uma antes de aprovar o perfil" unless @membership
membership_service.admin_approve_membership(@membership, user) # (2) muta o alvo end
fire_approved_user_profile_email Membership::FutureApprove.call(user: @profile_user, current_membership: @membership) # (3) auto-aprova a próxima end ```
E AdminMembershipsService#admin_approve_membership (app/services/admin_memberships_service.rb:32-39):
ruby
def admin_approve_membership(resource, user)
resource.card_picture.attach(resource.user_profile.membership_picture_file.blob)
resource.documentation_status = :ok
resource.card_status = :processing
resource.admin_approved_at = Time.now
resource.admin_approved_by_id = user.id
resource.save!
end
O método não verifica em que ponto do fluxo a filiação está. Ele é escrito assumindo que o alvo é sempre uma filiação aguardando aprovação.
O defeito
O alvo é escolhido em (1) por User#current_membership (app/models/user.rb:85-88) — a filiação cuja vigência cobre hoje, sem considerar se ela já concluiu o fluxo. Quando o terapeuta tem duas filiações consecutivas e a vigente já foi aprovada e emitida, a aprovação recai sobre ela. Em (2), o serviço então:
- reanexa
card_picturea partir da foto atual do perfil; - força
documentation_status = :ok; - rebaixa
card_statusdeissuedparaprocessing; - sobrescreve
admin_approved_ateadmin_approved_by_id, apagando quem aprovou e quando.
O registro da aprovação original é perdido de forma irrecuperável (não há auditoria dessas colunas) e a filiação já emitida volta para a fila de emissão. card_issued_at/card_issued_by_id sobrevivem, mas passam a coexistir com card_status = processing — um estado que não deveria existir: carteira com data de emissão e status “em emissão”.
Em (3), Membership::FutureApprove (app/models/membership/future_approve.rb) compara user.next_membership com a filiação que foi aprovada. Como a aprovada foi a vigente, a próxima é a renovação — que recebe automatic_approve! (app/models/membership.rb:279-288), ou seja card_status = auto_issued + card_issued_at. Uma filiação auto_issued nunca entra em remessa: a carteira física da renovação nunca é produzida.
Caso concreto — Francismare
- Filiação A — vigência 08/2025 a 08/2026; criada em 21/08/2025; documentação aprovada em 08/09/2025; carteira emitida em 11/09/2025, remessa 186. Fluxo integralmente concluído.
- Filiação B — renovação criada em 07/08/2026; vigência 08/2026 a 08/2027.
- Em 31/08/2026 o atendente aprovou a documentação (após recusas anteriores). Como A ainda estava vigente naquele dia, a aprovação foi direcionada a ela.
Consequências observadas, todas explicadas pela cadeia acima:
- A voltou para
processinge reentrou no fluxo de emissão, com a aprovação de 08/09/2025 sobrescrita pela de 31/08/2026; - B foi marcada como renovação automática (
auto_issued) peloFutureApprove; - A apareceu na lista de emissão em 31/08;
- em 01/09, ao encerrar a vigência, A saiu da fila silenciosamente —
Membership.with_profile_approved_card_processing(app/models/membership.rb:95) filtra porvalid_gte_today.
Resultado líquido: nenhuma carteira emitida para a renovação, A com histórico corrompido, e nenhum erro visível para quem operava.
Por que ela caiu no fluxo manual
issued_definitive_card é o gatilho de entrada. A flag só é marcada em admin_issue_card quando Date.current >= DATE_RELEASE_NEW_CITRG (default 28/01/2026) — app/services/admin_memberships_service.rb:81-84. A carteira da Francismare saiu em 11/09/2025, então a flag ficou false.
Com a flag false, WebhookMembershipService#initialize_new_membership (app/services/webhook_membership_service.rb:87-102) cai no else: chama user_profile.manual_approve! e pede reenvio de documentos, em vez de auto-aprovar a renovação na hora (R-004). É esse desvio que traz o caso para a aprovação manual — onde o defeito acima existe.
Nota relacionada: a spec 20260811100000_multiple_pending_memberships_approval.md previa marcar issued_definitive_card = true dentro de admin_approve_membership, o que faria membros antigos saírem desse caminho na renovação seguinte. git log -S "issued_definitive_card" -- app/services/admin_memberships_service.rb mostra apenas o commit 5864961 (marcação em admin_issue_card): essa parte da spec nunca foi implementada. Ela segue fora do escopo aqui.
O recorte desta spec
Esta mudança ataca deliberadamente só a perda de dado, não a escolha do alvo. Decisão tomada com o usuário, em duas partes:
- Não reintroduzir a seleção da filiação pendente. A tentativa anterior (
f4bd837, PR #223 —User#membership_pending_approval) foi revertida em1eec152(PR #225), sem justificativa registrada no PR. A spec20260903172011_fix_approval_target_and_emission_queue_order.md, que dependia dela, fica emproposede sem efeito prático até que o motivo do revert seja conhecido. - Preferir falha explícita a corrupção silenciosa. Com a guarda, o Cenário B deixa de ser aprovável pela tela e passa a exibir erro ao atendente. É pior de UX e melhor de integridade: nada é destruído, e o problema fica visível em vez de aparecer semanas depois como “carteira não chegou”.
Cenários de referência do negócio
| Cenário | Estado da filiação A (vigente) | Comportamento hoje | Comportamento após esta spec |
|---|---|---|---|
| A — fluxo pendente | Sem admin_approved_at, not_issued |
Aprova A corretamente; B recebe auto_issued pelo FutureApprove |
Inalterado — a guarda não dispara |
| B — fluxo concluído | Aprovada e issued |
Corrompe A, joga na fila, auto_issued em B |
Erro na tela; nada é alterado; perfil não fica aprovado |
O direcionamento correto do Cenário B para a filiação B não é entregue aqui. Continua exigindo intervenção técnica.
Objetivos
admin_approve_membershiplevanta erro quandocard_status == "issued", antes de qualquer mutação: nenhum atributo atribuído,card_picturenão reanexada, nada salvo.- O erro é uma classe própria (
CardAlreadyIssuedError), distinguível deStandardErrorgenérico, com mensagem em português orientativa e acionável. - A mensagem chega ao atendente pelo
rescue => exceptionjá existente nas duas ações do admin (app/admin/user_profiles.rb:208e:260), sem nova UI. - Como o raise ocorre dentro da transação de
admin_approve_user_profile, o perfil não fica aprovado:admin_approved_at/admin_approved_by_iddoUserProfilesofrem rollback. - Pelo mesmo motivo,
fire_approved_user_profile_emaileMembership::FutureApprove(linhas 25-26, fora da transação e depois dela) não são alcançados: nenhum e-mail é disparado e a renovação não recebeauto_issuedindevido. auto_issuedcontinua aceito e rebaixado paraprocessing, preservando o fluxo de quem tem carteira definitiva, renovou pelo webhook e reenvia documento para receber a carteira física.- Nenhuma mudança de assinatura:
admin_approve_membership(resource, user)segue igual, e o único chamador de produção (admin_user_profiles_service.rb:22) não muda.
Fora de escopo
- Escolha do alvo da aprovação —
User#current_membership(app/models/user.rb:85-88),User#next_membershipe a reintrodução demembership_pending_approvalficam intocados. Decisão explícita do usuário. - Fila de emissão —
Membership.with_profile_approved_card_processing(app/models/membership.rb:95) mantémvalid_gte_todaye a ordenação poruser_profiles.admin_approved_at. Filiação vencida presa emprocessingcontinua desaparecendo da fila. Membership::FutureApprove— não ganha guard deissued_definitive_card; a auto-aprovação da próxima filiação segue valendo para qualquer usuário.- Marcar
issued_definitive_cardemadmin_approve_membership— previsto na spec20260811100000e nunca implementado; segue não implementado. - Backfill de
issued_definitive_cardpara quem emitiu carteira antes de 28/01/2026 — muda regra de negócio (essas pessoas pararão de reenviar documentos na renovação) e precisa de decisão de produto. - Correção dos dados já corrompidos — restaurar a filiação A da Francismare e demais casos afetados. Tarefa separada; o apêndice traz a query de diagnóstico para dimensionar.
- Bloquear
auto_issued— avaliado e recusado (ver Alternativas, opção 3). - Nova UI para aprovar uma filiação específica — não existe hoje e não é criada aqui.
- Auditoria de
admin_approved_at/card_status— não é introduzida.
Mudanças
app/services/admin_memberships_service.rb
1. Nova classe de erro, junto de MembershipAlreadyInShipmentError (linha 2), seguindo o padrão já existente no arquivo:
ruby
class AdminMembershipsService
class MembershipAlreadyInShipmentError < StandardError; end
class CardAlreadyIssuedError < StandardError; end
2. Guarda no início de admin_approve_membership (linha 32), antes de qualquer atribuição ou anexo:
```ruby def admin_approve_membership(resource, user) if resource.issued? raise CardAlreadyIssuedError, “A filiação ##{resource.register_number} já possui carteira emitida — a aprovação de documentos não pode ser direcionada para ela. Acione o time técnico para direcionar a aprovação à filiação de renovação.” end
resource.card_picture.attach(resource.user_profile.membership_picture_file.blob)
resource.documentation_status = :ok
resource.card_status = :processing
resource.admin_approved_at = Time.now
resource.admin_approved_by_id = user.id
resource.save! end ```
Notas de implementação:
resource.issued?é o predicado gerado pelo enumcard_status(app/models/membership.rb:66-71). O enum é declarado semprefix, enot_issued?já é usado assim emMembership::FutureApprove— o padrão é o do projeto.- A guarda cobre exatamente
issued.not_issued,processingeauto_issuedseguem o caminho atual. - A mensagem não interpola
card_issued_at: existem filiaçõesissuedcomcard_issued_atnulo (o status é setado emadmin_issue_cardapenas no ramofirst_time, e há dados legados/migrados), e a mensagem não deve depender disso. - Usar
resource.register_number(o identificador que o atendente vê na tela e nas remessas), nãoresource.id. - A guarda vale para as duas entradas de UI porque ambas passam por
admin_approve_user_profile.
Nada muda em app/services/admin_user_profiles_service.rb
A propagação do erro já funciona pelo comportamento padrão: exceção dentro de ActiveRecord::Base.transaction faz rollback e sobe. As linhas 25-26 (e-mail e FutureApprove) não são alcançadas. Não é preciso rescue nem tratamento novo.
Nada muda em app/admin/user_profiles.rb
approve_user_profile (linhas 208-210) e request_pending_documents (linhas 260-262) já têm rescue => exception com redirect + flash de erro. A única diferença entre elas é que a primeira usa flash: { error: exception } (objeto) e a segunda exception.message — ambas renderizam a mensagem. Padronizar isso é limpeza opcional, não requisito.
Como verificar
bash
make run.test path=test/services/admin_memberships_service_test.rb
make run.test path=test/services/admin_user_profiles_service_test.rb
make run.test path=test/models/membership_test.rb
bundle exec rubocop app/services/admin_memberships_service.rb
O projeto não tem target de lint no .commons/make (só run.test); o rubocop roda direto via bundler (rubocop-rails-omakase, Gemfile:88).
Casos a cobrir
test/services/admin_memberships_service_test.rb — comportamento da guarda:
| Cenário | Arranjo | Esperado |
|---|---|---|
| Filiação já emitida | card_status: :issued, com admin_approved_at, admin_approved_by_id e card_issued_at preenchidos |
Levanta AdminMembershipsService::CardAlreadyIssuedError |
| Nenhuma mutação parcial | idem acima | Após reload: card_status segue issued, admin_approved_at/admin_approved_by_id/card_issued_at inalterados, documentation_status inalterado, card_picture não anexada (assert_not resource.card_picture.attached?) |
| Mensagem acionável | idem acima | A mensagem contém o register_number da filiação |
| Filiação pendente | card_status: :not_issued |
Aprova: processing, documentation_status == "ok", admin_approved_at presente, admin_approved_by_id == user.id |
| Reenvio de documento | card_status: :processing |
Aprova normalmente; segue processing; admin_approved_at atualizado |
| Carteira automática | card_status: :auto_issued |
Aprova normalmente; vira processing — comportamento atual explicitamente preservado |
test/services/admin_user_profiles_service_test.rb — propagação e rollback:
| Cenário | Arranjo | Esperado |
|---|---|---|
| Cenário B ponta a ponta | Perfil com admin_approved_at: nil; filiação A vigente (valid_since no passado, valid_until no futuro) com card_status: :issued e admin_approved_at antigo; filiação B encadeada (valid_since == A.valid_until), paga, not_issued, sem aprovação |
Levanta CardAlreadyIssuedError |
| Rollback do perfil | idem | profile.reload.admin_approved_at segue nil e admin_approved_by_id segue nil |
| A intacta | idem | A.reload com card_status == "issued" e admin_approved_at original preservado |
| B não é auto-aprovada | idem | B.reload.card_status == "not_issued" e B.admin_approved_at.nil? — prova que FutureApprove não rodou |
| Nenhum e-mail | idem | assert_no_enqueued_emails |
Regressão nos testes existentes: os 8 testes de admin_user_profiles_service_test.rb (linhas 22, 31, 50, 70, 95, 121, 146, 168) devem continuar passando sem alteração. Nenhum deles usa filiação issued: cinco fazem stubs(:admin_approve_membership) e o da linha 121 (“does not auto-approve B when card is not not_issued”) trata do card_status da B, não da A. Confirmar caso a caso na implementação.
Baseline: a suíte completa já falha em main com casos pré-existentes (2 deles em ApoloMembershipControllerTest, por ambiguidade de desempate em current_membership, fora do escopo desta spec). O critério é: o número de failures/errors não aumenta.
Cenário manual (console)
```ruby user = User.find_by(email: “…”) user.memberships.order(:valid_since).map { |m| [m.register_number, m.valid_since, m.valid_until, m.card_status, m.admin_approved_at] }
admin = User.find_by(admin: true) AdminUserProfilesService.new.admin_approve_user_profile(user.user_profile, admin) # esperado: AdminMembershipsService::CardAlreadyIssuedError
user.user_profile.reload.admin_approved_at # esperado: nil user.memberships.reload.map(&:card_status) # esperado: inalterado ```
Verificação na UI: abrir o perfil, clicar em “Aprovar” e confirmar que aparece o flash de erro com a mensagem, e que o perfil permanece não aprovado.
Alternativas consideradas
- Corrigir o alvo (
membership_pending_approval) — resolve os Cenários A e B de verdade, com spec e plano já escritos (20260903172011). Recusada agora: é a linha revertida em PR #225 sem motivo registrado; reintroduzi-la sem saber o que quebrou é arriscado. Continua sendo o caminho correto no médio prazo. - Guarda que “pula” a filiação emitida e mira a próxima pendente — resolveria o Cenário B sem erro na tela. Recusada: é a correção de alvo por outro nome, com a mesma incerteza do revert, e escondida dentro de um serviço que não deveria decidir alvo.
- Bloquear
issuedeauto_issued(ISSUED_CARD_STATUSES) — mais protetivo. Recusada:auto_issuedé o estado normal de quem temissued_definitive_card = truee renovou pelo webhook (webhook_membership_service.rb:87); bloqueá-lo faria toda moderação de documentos dessas pessoas passar a dar erro, quebrando um fluxo em uso. Além disso, ocard_issued_atde umaauto_issuedé sintético (carimbado pelo usuário “Automático”), então sobrescrevê-lo não perde histórico real de emissão física. - Log/alerta em vez de erro — aprovar como hoje e apenas registrar aviso. Recusada: não impede a perda de dado, que é o único objetivo desta mudança.
- Idempotência silenciosa (retornar sem fazer nada quando já emitida) — não corrompe, mas o atendente veria “Perfil aprovado!” sem que a renovação avance. Recusada: repõe a falha silenciosa em outro lugar.
Riscos
| Risco | Impacto | Mitigação |
|---|---|---|
| Atendente fica bloqueado no Cenário B sem rota de saída na UI | Aprovação de documentos desses casos exige console | Mensagem de erro diz explicitamente para acionar o time técnico; a alternativa atual é corromper dados. Quantificar com a query do apêndice antes do deploy |
| Volume de casos maior que o esperado | Fila de suporte técnico | Rodar a query do apêndice antes de subir; se o volume for alto, priorizar a correção de alvo (20260903172011) na sequência |
Filiação legada issued sem admin_approved_at (migrada) sendo aprovada de propósito |
Bloqueada pela guarda | Aceito: filiação issued já teve carteira emitida; reaprovar não é o caminho — reemissão se resolve por remessa |
| Perfil deixa de ser aprovado junto (rollback) | Atendente precisa repetir a ação depois do desbloqueio | Comportamento desejado: aprovar o perfil sem direcionar a filiação é o que gerava inconsistência |
Documentação
- Criar
.project/docs/rules/membership/approval_never_downgrades_issued_card.mdcomid: R-007,scope: membership,certainty: high— a regra: a aprovação de documentos nunca rebaixa uma filiação com carteira já emitida; quando o alvo é uma filiaçãoissued, a operação falha e nada é alterado. Referenciar R-004 (definitive_card_renewal_auto_approval), que explica por que membros com carteira definitiva não passam por este fluxo. - Adicionar a linha
R-007na tabela de regras de.project/docs/README.md(última hoje: R-006, linha 18). - Registrar no doc da regra a limitação conhecida: o direcionamento correto do Cenário B não é resolvido por esta mudança.
Apêndice — query de diagnóstico
Para dimensionar quantas filiações já foram corrompidas por este defeito antes do fix (candidatas: estão em processing, mas já foram para remessa alguma vez):
ruby
Membership.card_in_processing
.joins(:shipment_memberships)
.distinct
.map { |m| [m.id, m.register_number, m.user.email, m.valid_since, m.valid_until, m.admin_approved_at, m.card_issued_at] }
Sinal complementar de sobrescrita: admin_approved_at > card_issued_at — a filiação foi “aprovada” depois de já ter carteira emitida.
ruby
Membership.where("admin_approved_at > card_issued_at").pluck(:id, :register_number, :card_status, :admin_approved_at, :card_issued_at)
Esses resultados alimentam a tarefa de correção de dados, que é separada desta spec.