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

TLDR: torna User#membership_pending_approval determinístico no dia de virada de vigência e faz Membership.with_profile_approved_card_processing priorizar vencimento do mês corrente sem derrubar da fila filiação vencida presa em processing.

Spec: .project/docs/specs/20260903172011_fix_approval_target_and_emission_queue_order.md Branch: fix/approval-target-and-emission-queue-order

Arquitetura: duas mudanças independentes, em arquivos diferentes, sem dependência entre si — podem ser feitas em paralelo. User#membership_pending_approval passa a fazer uma busca direta (paga, não arquivada, sem admin_approved_at, não vencida, ordenada por valid_until, id) em vez de depender da janela de data ambígua de current_membership. Membership.with_profile_approved_card_processing ganha uma ordenação de dois critérios (vencimento no mês corrente primeiro, depois admin_approved_at da própria filiação) e perde o filtro que removia filiação vencida da fila.

Stack: Ruby on Rails 8, Minitest, fixtures existentes (users(:paulo), users(:joao_silva), membership_groups(:test_group)).

Restrições globais

  • Nenhuma mudança em User#current_membership / User#next_membership / Membership::FutureApprove — confirmado com o usuário, inclusive a ambiguidade de empate que hoje já quebra 2 testes de ApoloMembershipControllerTest (pré-existente em main, fora deste plano).
  • Critério de “já concluiu o fluxo” em membership_pending_approval continua sendo só admin_approved_at presente/ausente — não considerar card_status.
  • Membership.with_profile_approved_not_issued não é usada em lugar nenhum do código — não é alterada.
  • Commits: uma linha, sem menção a IA, prefixos test:/fix:/docs: conforme a fase.
  • Rodar make run.lint e make run.test path=<arquivo> (não make container.server.lint/make test — esses targets não existem neste projeto; os reais são run.lint/run.test).
  • Baseline atual (main, antes deste plano): make run.test já falha com 4 failures + 1 error + 1 skip pré-existentes, nenhum deles introduzido por este plano — 2 são causados por mudanças locais não commitadas de outra tarefa (mock de login do Apolo) e 3 são falhas conhecidas fora de escopo. Depois deste plano, o número de failures/errors não deve aumentar.

Task 1: User#membership_pending_approval determinístico

Files: - Modify: app/models/user.rb - Test: test/models/user_test.rb - Create: .project/docs/rules/membership/documentation_approval_targets_pending_membership.md

Interfaces: - Produces: User#membership_pending_approval(date = Date.current) -> Membership | nil, mesma assinatura de hoje — consumida por AdminUserProfilesService#admin_approve_user_profile (app/services/admin_user_profiles_service.rb:18), que não muda.

  • [ ] Step 1: Escrever o teste que expõe a ambiguidade

Adicionar em test/models/user_test.rb, logo antes do end final da classe (depois do teste “membership_pending_approval returns nil when the user has no paid membership”, linha 883):

```ruby test “membership_pending_approval returns the membership expiring today, not the one starting today, deterministically” do # arrange — B é criada antes de A de propósito: sem ORDER BY, uma busca por # janela de data pode devolver a linha inserida primeiro nesse empate. user = users(:paulo) next_membership = Membership.create!( user: user, membership_group_id: 1, register_number: 50009, valid_since: Date.parse(“2026-01-01”), valid_until: Date.parse(“2027-01-01”), payment_status: “paid”, payment_reference: “PAY_REF_50009”, card_status: “not_issued”, documentation_status: “pending”, admin_approved_at: nil, admin_approved_by_id: nil ) expiring_today = Membership.create!( user: user, membership_group_id: 1, register_number: 50010, valid_since: Date.parse(“2025-01-01”), valid_until: Date.parse(“2026-01-01”), payment_status: “paid”, payment_reference: “PAY_REF_50010”, card_status: “not_issued”, documentation_status: “pending”, admin_approved_at: nil, admin_approved_by_id: nil )

# act — repete a chamada: se ainda houver ambiguidade, o resultado varia entre chamadas
results = 5.times.map { travel_to(Date.parse("2026-01-01")) { user.membership_pending_approval } }

# assert
assert results.all? { |r| r&.id == expiring_today.id },
  "esperado sempre a filiação vencendo hoje (#{expiring_today.id}), obtido: #{results.map { |r| r&.id }}"
assert_not_equal next_membership.id, results.first&.id   end ```
  • [ ] Step 2: Rodar e observar a falha

bash make run.test path=test/models/user_test.rb

Esperado: FAIL — com a implementação atual (current = current_membership(date); return current if ... blank?), current_membership monta a busca sem ORDER BY num empate de janela de data; o .first pode devolver next_membership em vez de expiring_today (não é garantido qual, é exatamente a não determinismo que este teste elimina).

  • [ ] Step 3: Implementação mínima

Em app/models/user.rb, substituir o corpo do método (linhas 101–111 hoje):

ruby # The membership that should receive the next documentation approval: the # paid, unarchived membership with no admin_approved_at yet that expires # soonest. Falls back to current_membership when everything is already # approved — covers resending documents on the same, already-approved # membership (no new one pending). 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

  • [ ] Step 4: Rodar e confirmar que passam

bash make run.test path=test/models/user_test.rb

Esperado: PASS — inclusive os 6 testes já existentes de membership_pending_approval (linhas 763–883), sem alteração neles.

  • [ ] Step 5: Commit

bash git add app/models/user.rb test/models/user_test.rb git commit -m "fix: torna membership_pending_approval determinístico"

  • [ ] Step 6: Registrar a regra de negócio

Criar .project/docs/rules/membership/documentation_approval_targets_pending_membership.md:

```markdown

id: R-007 title: Aprovação de documentação mira a filiação sem fluxo concluído scope: membership certainty: high created: 2026-09-03 updated: 2026-09-03 —

R-007 — Aprovação de documentação mira a filiação sem fluxo concluído

TLDR: User#membership_pending_approval escolhe a filiação paga, não arquivada, ainda sem admin_approved_at, que vence mais cedo — não a filiação vigente por data. Só cai de volta para current_membership (a vigente por data) quando não existe nenhuma filiação pendente, cobrindo o reenvio de documentos sobre uma filiação que já tinha sido aprovada.

Given / When / Then

Dado um usuário com uma filiação A vigente ainda sem admin_approved_at e, opcionalmente, uma filiação B futura (renovação) Quando admin_approve_user_profile aprova a documentação do perfil Então a aprovação (AdminMembershipsService#admin_approve_membership) é aplicada em A, mesmo que B já exista — B segue o fluxo de auto-aprovação de Membership::FutureApprove normalmente

Dado um usuário com uma filiação A vigente já aprovada (admin_approved_at presente) e uma filiação B futura sem aprovação Quando a documentação do perfil é aprovada de novo Então a aprovação é direcionada para B — A não é reprocessada nem volta para processing

Dado um usuário com uma única filiação, já aprovada e emitida, cujos documentos foram reenviados Quando a documentação do perfil é aprovada de novo Então a mesma filiação é reaprovada (card_status volta para processing) — não existe filiação “pendente” nesse caso, então o fallback usa a vigente por data

Restrições

  • O critério de “já concluiu o fluxo” é só admin_approved_at presente — não considera card_status. Uma filiação aprovada mas travada em processing sem nunca ter sido emitida não é considerada “pendente” de novo por este método (mas continua na fila de emissão — ver R-008).
  • A ordenação order(:valid_until, :id) existe para resolver de forma determinística o dia em que uma filiação vence e a próxima começa (B.valid_since == A.valid_until, padrão de encadeamento de renovação) — nesse dia, A.valid_until é sempre menor que B.valid_until (que vence um ano depois), então não há empate real.
  • User#current_membership e User#next_membership não mudam — continuam com o significado “vigente/próxima por data”, usados por outros fluxos (acesso Apolo, encadeamento de renovação no webhook). A ambiguidade de empate que existe dentro de current_membership continua lá; este método simplesmente não depende mais dela para encontrar a filiação pendente.
  • Membership::FutureApprove não muda — continua comparando user.next_membership com a filiação recebida por admin_approve_user_profile.

Código

app/models/user.rb — membership_pending_approval

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

Teste vinculado

test/models/user_test.rb: - “membership_pending_approval returns the current membership when it has no approval” - “membership_pending_approval returns the current membership even with a future one, when current has no approval” - “membership_pending_approval returns the next membership when the current one is already approved” - “membership_pending_approval returns the current membership when it is approved and there is no next one” - “membership_pending_approval ignores an unpaid next membership and falls back to the current one” - “membership_pending_approval returns nil when the user has no paid membership” - “membership_pending_approval returns the membership expiring today, not the one starting today, deterministically”

test/services/admin_user_profiles_service_test.rb — todos os testes de admin_approve_user_profile, em especial “does not reset the already approved membership (A) back into the queue” e “re-approves the only membership when it is already approved”.

Relacionados

bash git add .project/docs/rules/membership/documentation_approval_targets_pending_membership.md git commit -m "docs: rule R-007 documentation approval targets pending membership"


Task 2: fila de emissão prioriza vencimento do mês corrente

Files: - Modify: app/models/membership.rb - Test: test/models/membership_test.rb - Create: .project/docs/rules/membership/emission_queue_prioritizes_current_month_due_date.md

Interfaces: - Produces: Membership.with_profile_approved_card_processing (scope), mesmo nome/aridade de hoje — consumida por app/admin/memberships.rb:50 (scope "Em Emissão", :with_profile_approved_card_processing), que não muda.

  • [ ] Step 1: Escrever os testes que falham

Adicionar em test/models/membership_test.rb, logo antes do end final da classe:

```ruby test “with_profile_approved_card_processing keeps an expired membership stuck in processing” do # arrange expired_processing = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 2.years, valid_until: Date.current - 1.year, payment_reference: “PAY_REF_EXPIRED_PROCESSING” )

# act
result = Membership.with_profile_approved_card_processing

# assert
assert_includes result, expired_processing   end

test “with_profile_approved_card_processing prioritizes memberships due this month over earlier approvals” do # arrange due_this_month = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 1.year, valid_until: Date.current, admin_approved_at: 1.day.ago, payment_reference: “PAY_REF_DUE_THIS_MONTH” ) due_later_approved_earlier = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 6.months, valid_until: Date.current + 6.months, admin_approved_at: 1.year.ago, payment_reference: “PAY_REF_DUE_LATER” )

# act
result = Membership.with_profile_approved_card_processing.to_a

# assert
assert_operator result.index(due_this_month), :<, result.index(due_later_approved_earlier)   end

test “with_profile_approved_card_processing orders memberships due this month by valid_until, and the rest by admin_approved_at” do # arrange due_this_month_early = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 1.year, valid_until: Date.current.beginning_of_month + 1.day, payment_reference: “PAY_REF_DUE_EARLY” ) due_this_month_late = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 1.year, valid_until: Date.current.beginning_of_month + 2.days, payment_reference: “PAY_REF_DUE_LATE” ) not_due_older_approval = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 1.year, valid_until: Date.current + 6.months, admin_approved_at: 1.year.ago, payment_reference: “PAY_REF_NOT_DUE_OLDER” ) not_due_newer_approval = Membership.create!( user: @user, membership_group: @membership_group, payment_status: :paid, card_status: :processing, valid_since: Date.current - 1.year, valid_until: Date.current + 7.months, admin_approved_at: 1.month.ago, payment_reference: “PAY_REF_NOT_DUE_NEWER” )

# act
result = Membership.with_profile_approved_card_processing.to_a

# assert
assert_equal [ due_this_month_early.id, due_this_month_late.id, not_due_older_approval.id, not_due_newer_approval.id ],
  result.map(&:id)   end ```

@user (users(:joao_silva)) e @membership_group (membership_groups(:test_group)) já vêm do setup da classe (test/models/membership_test.rb:48-51); o user_profile fixture de joao_silva (test/fixtures/user_profiles.yml:105-106) já tem admin_approved_at/admin_approved_by_id preenchidos, então with_profile_approved já passa sem setup extra.

  • [ ] Step 2: Rodar e confirmar que falham

bash make run.test path=test/models/membership_test.rb

Esperado: - “keeps an expired membership stuck in processing” — FAIL, assert_includes falha porque valid_gte_today remove a filiação vencida do resultado. - “prioritizes memberships due this month…” — FAIL, hoje a ordenação é só por user_profiles.admin_approved_at, então due_later_approved_earlier (aprovada há mais tempo) vem antes de due_this_month. - “orders memberships due this month by valid_until…” — FAIL, mesma causa.

  • [ ] Step 3: Implementação mínima

Em app/models/membership.rb, linha 95:

diff - scope :with_profile_approved_card_processing, -> { paid.card_in_processing.with_profile_approved.valid_gte_today.reorder("user_profiles.admin_approved_at ASC NULLS LAST") } + 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")) + }

  • [ ] Step 4: Rodar e confirmar que passam

bash make run.test path=test/models/membership_test.rb

Esperado: PASS — inclusive os testes já existentes no arquivo (nenhum outro teste depende de valid_gte_today ou da ordenação antiga deste scope específico).

  • [ ] Step 5: Commit

bash git add app/models/membership.rb test/models/membership_test.rb git commit -m "fix: fila de emissão prioriza vencimento do mês"

  • [ ] Step 6: Registrar a regra de negócio

Criar .project/docs/rules/membership/emission_queue_prioritizes_current_month_due_date.md:

```markdown

id: R-008 title: Fila de emissão prioriza vencimento do mês corrente scope: membership certainty: high created: 2026-09-03 updated: 2026-09-03 —

R-008 — Fila de emissão prioriza vencimento do mês corrente

TLDR: A aba “Em Emissão” do admin (Membership.with_profile_approved_card_processing) ordena primeiro pelas filiações que vencem no mês corrente (por valid_until crescente entre elas), depois pelas demais em ordem de admin_approved_at da própria filiação. Filiação vencida presa em processing continua na fila até ser emitida — não é mais removida por estar vencida.

Tabela de decisão

Situação da filiação em processing Aparece na fila? Posição
Vence no mês corrente Sim Primeiro grupo, ordenada por valid_until crescente
Não vence no mês corrente, aprovada há mais tempo Sim Segundo grupo, antes das aprovadas mais recentemente
Vencida (valid_until no passado), presa em processing Sim Segundo grupo (não vence este mês), por admin_approved_at
admin_approved_at nulo na própria filiação Sim Vai por último dentro do seu grupo (NULLS LAST)

Restrições

  • A ordenação usa memberships.admin_approved_at (da própria filiação), não user_profiles.admin_approved_at (do perfil) — o perfil é único por usuário e zera a cada recusa de documento, não reflete a aprovação da filiação específica que está na fila.
  • date_trunc('month', ...) exige Arel.sql(...) para passar pela checagem de SQL cru do Rails, porque a expressão não é uma referência simples de coluna — não é uma escolha de estilo, é exigência do disallow_raw_sql!.
  • Membership.with_profile_approved_not_issued (outro scope, aba “Pagas e não emitidas” não usa) não foi alterada — continua com valid_gte_today e ordenação pelo perfil.
  • Efeito colateral esperado no primeiro deploy: filiações antigas represadas em processing, já vencidas, aparecem de uma vez na aba “Em Emissão”.

Código

app/models/membership.rb — with_profile_approved_card_processing

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")) }

Teste vinculado

test/models/membership_test.rb: - “with_profile_approved_card_processing keeps an expired membership stuck in processing” - “with_profile_approved_card_processing prioritizes memberships due this month over earlier approvals” - “with_profile_approved_card_processing orders memberships due this month by valid_until, and the rest by admin_approved_at”

Relacionados

bash git add .project/docs/rules/membership/emission_queue_prioritizes_current_month_due_date.md git commit -m "docs: rule R-008 emission queue prioritizes current month due date"


Verificação final

bash make run.lint make run.test path=test/models/user_test.rb make run.test path=test/models/membership_test.rb make run.test path=test/services/admin_user_profiles_service_test.rb make run.test

A última chamada roda a suíte inteira — comparar a contagem de failures/errors com a baseline registrada nas Restrições globais (4 failures + 1 error + 1 skip, nenhum deles nestes arquivos). O número não deve aumentar.

Cobertura da spec: Objetivos 1 e 2 (resolução determinística + fallback preservado) cobertos pela Task 1; Objetivos 3 e 4 (priorização por vencimento do mês + retenção de filiação vencida em processing) cobertos pela Task 2. Ambas as tasks são independentes (arquivos diferentes, sem interseção) — podem ser executadas em paralelo.