Fix: aprovação de documentos não rebaixa filiação com carteira já emitida

TLDR: AdminMembershipsService#admin_approve_membership passa a recusar, antes de qualquer mutação, uma filiação que já está com card_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_picture a partir da foto atual do perfil;
  • força documentation_status = :ok;
  • rebaixa card_status de issued para processing;
  • sobrescreve admin_approved_at e admin_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:

  1. A voltou para processing e reentrou no fluxo de emissão, com a aprovação de 08/09/2025 sobrescrita pela de 31/08/2026;
  2. B foi marcada como renovação automática (auto_issued) pelo FutureApprove;
  3. A apareceu na lista de emissão em 31/08;
  4. 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 por valid_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:

  1. Não reintroduzir a seleção da filiação pendente. A tentativa anterior (f4bd837, PR #223 — User#membership_pending_approval) foi revertida em 1eec152 (PR #225), sem justificativa registrada no PR. A spec 20260903172011_fix_approval_target_and_emission_queue_order.md, que dependia dela, fica em proposed e sem efeito prático até que o motivo do revert seja conhecido.
  2. 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_membership levanta erro quando card_status == "issued", antes de qualquer mutação: nenhum atributo atribuído, card_picture não reanexada, nada salvo.
  • O erro é uma classe própria (CardAlreadyIssuedError), distinguível de StandardError genérico, com mensagem em português orientativa e acionável.
  • A mensagem chega ao atendente pelo rescue => exception já existente nas duas ações do admin (app/admin/user_profiles.rb:208 e :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_id do UserProfile sofrem rollback.
  • Pelo mesmo motivo, fire_approved_user_profile_email e Membership::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 recebe auto_issued indevido.
  • auto_issued continua aceito e rebaixado para processing, 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_membership e a reintrodução de membership_pending_approval ficam intocados. Decisão explícita do usuário.
  • Fila de emissão — Membership.with_profile_approved_card_processing (app/models/membership.rb:95) mantém valid_gte_today e a ordenação por user_profiles.admin_approved_at. Filiação vencida presa em processing continua desaparecendo da fila.
  • Membership::FutureApprove — não ganha guard de issued_definitive_card; a auto-aprovação da próxima filiação segue valendo para qualquer usuário.
  • Marcar issued_definitive_card em admin_approve_membership — previsto na spec 20260811100000 e nunca implementado; segue não implementado.
  • Backfill de issued_definitive_card para 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 enum card_status (app/models/membership.rb:66-71). O enum é declarado sem prefix, e not_issued? já é usado assim em Membership::FutureApprove — o padrão é o do projeto.
  • A guarda cobre exatamente issued. not_issued, processing e auto_issued seguem o caminho atual.
  • A mensagem não interpola card_issued_at: existem filiações issued com card_issued_at nulo (o status é setado em admin_issue_card apenas no ramo first_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ão resource.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

  1. 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.
  2. 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.
  3. Bloquear issued e auto_issued (ISSUED_CARD_STATUSES) — mais protetivo. Recusada: auto_issued é o estado normal de quem tem issued_definitive_card = true e 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, o card_issued_at de uma auto_issued é sintético (carimbado pelo usuário “Automático”), então sobrescrevê-lo não perde histórico real de emissão física.
  4. 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.
  5. 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.md com id: 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ção issued, 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-007 na 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.