Fix: renovação de filiação ignora vigência pendente de pagamento

TLDR: WebhookMembershipService#set_valid_since_and_until passa a considerar qualquer filiação com vigência em andamento (valid_until >= hoje), independente do status de pagamento — exceto refunded — ao calcular a data de início de uma nova filiação. Hoje o filtro exige payment_status: paid, então uma filiação pending/overdue ainda vigente é tratada como se não existisse, e a nova filiação nasce com vigência a partir de hoje em vez de encadear depois do término da vigência atual.

Contexto

Toda vez que o webhook do gateway de pagamento envia status: "paid" para uma nova compra, WebhookMembershipService#initialize_new_membership cria uma nova Membership e chama set_valid_since_and_until (app/services/webhook_membership_service.rb:62-72) para definir valid_since/valid_until:

```ruby def set_valid_since_and_until last_paid_membership = @user.memberships.paid.valid_gte_today.order_by_newest_valid_until.try(:first)

if last_paid_membership.present? @membership.valid_since = last_paid_membership.valid_until @membership.valid_until = last_paid_membership.valid_until + 1.year else @membership.valid_since = Date.current.beginning_of_day @membership.valid_until = Date.current.end_of_month + 1.year end end ```

A query busca, entre as filiações do usuário, a mais recente que seja paid e ainda vigente (valid_until >= hoje, scope valid_gte_today). Se encontrar, a nova filiação encadeia a partir do valid_until dela. Se não encontrar, assume que não há nada em andamento e usa Date.current como ponto de partida.

Bug relatado: o checkout normal bloqueia compra para quem tem pendência financeira, mas durante campanhas essa restrição é flexibilizada para permitir compra com pendência no IBFT — configuração que não diferencia tipo de produto, então terapeutas com filiação em atraso conseguiram também comprar a renovação da filiação nessa janela. Essa lógica de checkout/campanha é externa a este repositório (confirmado: não há checkout, campanha ou bloqueio de pendência em app/ ou lib/ deste projeto — este serviço só reage ao status que o gateway envia).

Quando isso acontece, o usuário já tem uma filiação com vigência em andamento (ex.: ago/2025 a ago/2026) mas payment_status: pending ou overdue. Como o filtro .paid exige status pago, essa filiação vigente é ignorada pela busca, last_paid_membership vem nil, e a nova filiação é criada a partir de hoje — sobrepondo períodos em vez de continuar depois do término da vigência anterior. Foram identificados 2 casos até agora, corrigidos manualmente via console.

Histórico relacionado: R-002 documenta um fix anterior nesse mesmo método — removeu .admin_approved do encadeamento pelo motivo análogo (filiação paga mas ainda não aprovada pelo admin era ignorada). O padrão adotado ali foi afinar o filtro da query sem criar scope novo; esta correção segue o mesmo padrão.

Objetivos

  • Ao criar uma nova filiação, se o usuário já tem qualquer filiação com vigência em andamento (valid_until >= hoje), a nova filiação deve começar no valid_until dessa filiação — independente do payment_status ser paid, pending ou overdue.
  • Preservar o comportamento atual para os demais casos: filiação refunded (estornada) não conta como vigência em andamento; filiação vencida (valid_until no passado) não conta, mesmo se pending/overdue; ausência de qualquer filiação anterior continua caindo no else (Date.current).

Fora de escopo

  • Não altera a lógica de checkout/campanha externa que permitiu a compra com pendência (fora deste repositório) — fora do escopo deste fix.
  • Não cria scope novo em Membership — troca inline na query, mesmo padrão do fix documentado em R-002.
  • Não corrige retroativamente as duas filiações já identificadas com vigência errada (já corrigidas manualmente via console) nem adiciona uma rotina de correção em massa — não há evidência de mais casos além dos 2 relatados.
  • Não altera admin_approved nem qualquer outra condição já corrigida em R-002.

Mudanças

app/services/webhook_membership_service.rb

Linha 63, dentro de set_valid_since_and_until:

diff - last_paid_membership = @user.memberships.paid.valid_gte_today.order_by_newest_valid_until.try(:first) + last_paid_membership = @user.memberships.where.not(payment_status: :refunded).valid_gte_today.order_by_newest_valid_until.try(:first)

Mantém valid_gte_today e order_by_newest_valid_until como estão. payment_status é uma coluna string simples (db/schema.rb:228), sem enum Rails, com 4 valores em uso: pending, paid, overdue, refunded.

test/services/webhook_membership_service_test.rb

  • Reescrever o teste "sets valid_since from today when existing membership is not paid" (linhas 386-424), que hoje afirma como esperado o comportamento incorreto (filiação pending vigente sendo ignorada). Passa a afirmar que a nova filiação encadeia a partir do valid_until da filiação pending.
  • Adicionar teste equivalente para payment_status: overdue vigente → encadeia normalmente.
  • Adicionar teste para payment_status: refunded vigente (mesmo com valid_until no futuro) → continua caindo no else, valid_since a partir de Date.current.
  • Manter o teste existente de filiação paid vigente (linhas 344-384) sem alteração de expectativa — guarda de não-regressão.

Como verificar

bash make test test=test/services/webhook_membership_service_test.rb make container.server.lint

Manual (console, somente leitura):

ruby user = User.find_by(...) # usuário com filiação pending/overdue vigente user.memberships.where.not(payment_status: :refunded).valid_gte_today.order_by_newest_valid_until.first # deve retornar a filiação pendente vigente, não nil

Documentação