Fix: direcionamento de aprovação e ordenação da fila de emissão

TLDR: User#membership_pending_approval passa a escolher, de forma determinística, a filiação paga sem admin_approved_at que vence mais cedo (voltando ao fallback atual só quando nenhuma estiver pendente); a fila de emissão (Membership.with_profile_approved_card_processing) passa a priorizar filiações vencendo no mês corrente e deixa de remover da fila filiações vencidas presas em processing.

Contexto

Existe uma regra de negócio para a fila de emissão de carteirinhas com dois critérios: filiações vencendo no mês corrente vão para o início da fila, independente da data de aprovação; as demais seguem a ordem de aprovação. Essa regra nunca chegou a ser implementada em Membership.with_profile_approved_card_processing (app/models/membership.rb:95) — a fila hoje ordena só por user_profiles.admin_approved_at ASC NULLS LAST (data do perfil, não da filiação) e usa valid_gte_today, que remove da fila qualquer filiação vencida, mesmo presa em processing sem nunca ter sido emitida.

Esse segundo ponto (valid_gte_today) se combina com um problema já parcialmente corrigido (f4bd837, #223): User#membership_pending_approval (app/models/user.rb:106) resolve qual filiação recebe a próxima aprovação de documentação, com fallback de current_membership para next_membership quando a vigente já foi aprovada antes. Essa implementação cobre corretamente os casos comuns, mas current_membership (app/models/user.rb:85) monta a busca sem ORDER BY:

ruby def current_membership(date = Date.current) @current_membership = memberships.paid.where("?::date between valid_since and valid_until", date).first @current_membership || memberships.paid.order(created_at: :desc).first end

Quando a filiação A vence no mesmo dia em que a filiação B (renovação) começa — o encadeamento padrão de renovação (valid_since de B = valid_until de A) —, a condição BETWEEN bate nas duas ao mesmo tempo nesse dia, e .first sem ordenação não garante qual das duas volta. Se a aprovação cair em B por acidente nesse dia, A (que estava vencendo naquele mês e ainda sem aprovação) nunca entra em processing — e mesmo que entrasse, seria removida da fila ao vencer, por causa do valid_gte_today.

Cenário A (documentação aprovada ainda dentro da vigência de A, A sem aprovação): a aprovação deve ir para A; A entra na fila priorizada pelo vencimento do mês.

Cenário B (A já aprovada e com carteira emitida): a aprovação deve ir para B; B entra na fila normal, ordenada por data de aprovação.

Objetivos

  • User#membership_pending_approval escolhe, de forma determinística, a filiação paga (não arquivada) sem admin_approved_at que vence mais cedo — sem depender da ambiguidade de current_membership no dia de virada de vigência.
  • Quando não existir nenhuma filiação paga sem admin_approved_at, o comportamento atual é preservado: reaprova a filiação vigente por data (fallback existente via current_membership, usado hoje no fluxo de reenvio de documentos sobre a mesma filiação).
  • Membership.with_profile_approved_card_processing prioriza filiações que vencem no mês corrente (ordenadas por valid_until dentro desse grupo) e, fora desse grupo, ordena pela data de aprovação da própria filiação — não mais a do perfil do usuário.
  • Filiação vencida presa em processing permanece na fila até ser emitida, em vez de desaparecer silenciosamente.

Fora de escopo

  • Mudanças em User#current_membership / User#next_membership — continuam com o significado atual (“vigente/próxima por data”), usados por outros fluxos (acesso Apolo, encadeamento de renovação no webhook).
  • Mudanças em Membership::FutureApprove — já compara corretamente next_membership com a filiação recebida.
  • Considerar card_status no critério de “já concluiu o fluxo” em membership_pending_approval — o critério continua sendo só admin_approved_at presente/ausente, como hoje.
  • A ambiguidade de desempate remanescente no fallback current_membership (usado só quando nenhuma filiação está com admin_approved_at em branco — ex.: reenvio de documento em que a única filiação, ou todas, já foram aprovadas antes) não é resolvida aqui: nesse caso as filiações envolvidas já concluíram o fluxo, então qual delas é reaprovada tem baixo impacto prático.
  • Membership.with_profile_approved_not_issued (app/models/membership.rb:94) — não é usada em nenhum lugar do código hoje; não será alterada.

Mudanças

app/models/user.rb

Substitui o corpo de membership_pending_approval por uma busca direta e determinística, sem depender de current_membership para encontrar a filiação pendente — current_membership continua sendo chamado só como fallback final, exatamente como hoje:

ruby def membership_pending_approval(date = Date.current) memberships.paid.unarchived.where(admin_approved_at: nil) .where("valid_until >= ?", date) .order(:valid_until, :id).first || current_membership(date) end

  • order(:valid_until, :id) elimina a ambiguidade do dia de virada: a filiação que está vencendo sempre tem valid_until menor que a próxima (que vence um ano depois), então a ordenação nunca empata de verdade nesse caso; :id como segundo critério só cobre um empate de datas fora do padrão normal de encadeamento.
  • unarchived evita que uma filiação arquivada apareça como candidata a receber aprovação.
  • Sem candidata pendente (todas já aprovadas), cai no fallback atual current_membership(date) — preserva o reenvio de documentos sobre a mesma filiação (test/services/admin_user_profiles_service_test.rb:229, “re-approves the only membership when it is already approved”).

admin_approve_user_profile (app/services/admin_user_profiles_service.rb:18) não muda — já usa membership_pending_approval.

app/models/membership.rb

ruby scope :with_profile_approved_card_processing, -> { paid.card_in_processing.with_profile_approved .reorder(Arel.sql("(date_trunc('month', memberships.valid_until) = date_trunc('month', current_date)) DESC, memberships.valid_until ASC, memberships.admin_approved_at ASC NULLS LAST")) }

  • Remove valid_gte_today: filiação vencida presa em processing continua na fila até ser emitida.
  • Troca a ordenação de user_profiles.admin_approved_at (do perfil, único por usuário, zerado a cada recusa de documento) para memberships.admin_approved_at (da própria filiação em processamento).
  • Arel.sql(...) é necessário porque a expressão não é uma referência simples de coluna (tem date_trunc) — o Rails recusa passar isso como string crua sem o wrapper. Fora isso, segue o padrão já usado nos outros scopes do arquivo (string única, sem heredoc).
  • Efeito colateral esperado ao subir: filiações antigas represadas em processing (aprovadas há tempos, nunca emitidas, já vencidas) voltam a aparecer de uma vez na aba “Em Emissão” do admin — é o comportamento correto, mas vale avisar quem for revisar a fila depois do deploy.

Como verificar

bash make test test=test/models/user_test.rb make test test=test/services/admin_user_profiles_service_test.rb make test test=test/models/membership_test.rb make container.server.lint

Casos novos a cobrir:

Teste Cenário Esperado
User#membership_pending_approval A vence hoje (sem aprovação) e B começa hoje (B.valid_since == A.valid_until), nenhuma aprovada Retorna A, de forma determinística (repetir o teste não pode ser flaky)
User#membership_pending_approval Todos os testes já existentes em test/models/user_test.rb:763-883 Continuam passando sem alteração (a nova implementação foi verificada contra cada um manualmente)
Membership.with_profile_approved_card_processing Filiação X vence no mês corrente, filiação Y vence em outro mês mas foi aprovada antes X vem antes de Y na fila
Membership.with_profile_approved_card_processing Duas filiações vencendo no mês corrente, com valid_until diferentes Ordenadas por valid_until crescente entre si
Membership.with_profile_approved_card_processing Filiação aprovada, em processing, com valid_until no passado Continua aparecendo na fila (antes era removida por valid_gte_today)

Cenário manual (console):

ruby user = User.find_by(email: "...") user.memberships.order(:valid_since).map { |m| [m.valid_since, m.valid_until, m.admin_approved_at, m.card_status] } user.membership_pending_approval

Documentação

  • Criar .project/docs/rules/membership/documentation_approval_targets_pending_membership.md (R-007): regra de qual filiação recebe a próxima aprovação de documentação.
  • Criar .project/docs/rules/membership/emission_queue_prioritizes_current_month_due_date.md (R-008): regra dos dois critérios de ordenação da fila de emissão.