Fix: direcionamento de aprovação e ordenação da fila de emissão — Plano de implementação
TLDR: torna
User#membership_pending_approvaldeterminístico no dia de virada de vigência e fazMembership.with_profile_approved_card_processingpriorizar vencimento do mês corrente sem derrubar da fila filiação vencida presa emprocessing.
Spec:
.project/docs/specs/20260903172011_fix_approval_target_and_emission_queue_order.mdBranch: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 deApoloMembershipControllerTest(pré-existente emmain, fora deste plano). - Critério de “já concluiu o fluxo” em
membership_pending_approvalcontinua sendo sóadmin_approved_atpresente/ausente — não considerarcard_status. Membership.with_profile_approved_not_issuednã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.lintemake run.test path=<arquivo>(nãomake container.server.lint/make test— esses targets não existem neste projeto; os reais sãorun.lint/run.test). - Baseline atual (
main, antes deste plano):make run.testjá 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_approvalescolhe a filiação paga, não arquivada, ainda semadmin_approved_at, que vence mais cedo — não a filiação vigente por data. Só cai de volta paracurrent_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_atpresente — não consideracard_status. Uma filiação aprovada mas travada emprocessingsem 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 queB.valid_until(que vence um ano depois), então não há empate real. User#current_membershipeUser#next_membershipnã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 decurrent_membershipcontinua lá; este método simplesmente não depende mais dela para encontrar a filiação pendente.Membership::FutureApprovenão muda — continua comparandouser.next_membershipcom a filiação recebida poradmin_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
- R-008 — Fila de emissão prioriza vencimento do mês corrente
- R-002 — Renovação encadeia a partir de qualquer vigência em andamento não estornada ```
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 (porvalid_untilcrescente entre elas), depois pelas demais em ordem deadmin_approved_atda própria filiação. Filiação vencida presa emprocessingcontinua 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ãouser_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', ...)exigeArel.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 dodisallow_raw_sql!.Membership.with_profile_approved_not_issued(outro scope, aba “Pagas e não emitidas” não usa) não foi alterada — continua comvalid_gte_todaye 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.