Corrigir ExpireWhenIneligible para expirar PRO com expires_at já no passado

TLDR: Subscriptions::Pro::ExpireWhenIneligible busca a subscription PRO a expirar usando o scope .non_expired; se expires_at já passou, a busca retorna nil e o use case nunca marca a subscription como inactive nem loga o evento — deixando-a presa em status: active para sempre e bloqueando qualquer nova tentativa de assinatura PRO.

Contexto

Caso real em produção (usuária alessapena04@gmail.com, user_id 4891): a subscription PRO (id 767) está com status: active e expires_at: 2026-08-20 17:40:53 (no passado). A filiação no CITRG (CITRG::MembershipByEmail) está válida, aprovada e não suspensa (valid_until_timestamp: 2027-04-30, profile.status: "ok") — ou seja, ValidateEligibility calcularia pro_eligible: true hoje. Mesmo assim, user.active_pro? retorna false, porque user.subscription (has_one -> non_expired) ignora a linha 767 por causa do expires_at vencido e passa a apontar para a regular-terapeuta.

O problema é o finder em app/use_cases/subscriptions/pro/expire_when_ineligible.rb:21-24:

ruby def pro_subscription @pro_subscription ||= context.user.subscriptions.non_expired.find_by( subscription_plan: SubscriptionPlan.pro, status: [:pending, :active] ) end

Se, no momento em que este use case roda (login, POST /api/v1/subscriptions/pros ou webhook do CITRG), o expires_at da PRO já estiver no passado, .non_expired exclui essa linha da busca. Consequência, em call:

```ruby def call return if context.pro_eligible return if context.citrg_unavailable

ActiveRecord::Base.transaction do pro_subscription.update!(status: :inactive, expires_at: Time.current) if pro_subscription.present? reactivate_regular_subscription end

return if pro_subscription.blank?

info(message: “Pro subscription expired due to ineligible CITRG membership”, metadata: {user_id: context.user.id}) end ```

  • pro_subscription.present? é false → o update!(status: :inactive, ...) nunca roda → o status fica active para sempre, mesmo com expires_at vencido.
  • return if pro_subscription.blank? suprime o log informativo → nenhum rastro do evento (Subscription/SubscriptionPlan não usam a gem audited, então também não há histórico em audits).
  • reactivate_regular_subscription roda de qualquer forma (sem guarda), reativando a regular-terapeuta — o app já reflete “usuário não é mais PRO” no dia a dia (active_pro?, user.plan), mas o registro da PRO em si fica inconsistente.

Esse estado inconsistente (status: active + expires_at no passado) é então enxergado por app/use_cases/subscriptions/pro/validate_existing_subscription.rb:5, que busca a PRO sem .non_expired:

ruby subscription = context.user.subscriptions.where(subscription_plan:, status: [:pending, :active]).first context.fail!(status: :unprocessable_entity, message: "Você já possui um plano PRO ativo") if subscription.present?

Essa linha ainda é encontrada (o .non_expired não é usado aqui) e bloqueia qualquer nova tentativa de assinar/renovar o PRO com "Você já possui um plano PRO ativo" — mesmo o usuário, na prática, não sendo mais PRO. O ciclo de repromoção descrito em R-003 (“Creation::CreateSubscription limpa expires_at ao reativar uma PRO existente”) depende de ExpireWhenIneligible ter conseguido marcar a PRO como inactive antes — o que não acontece nesse caso, então o usuário fica travado indefinidamente, mesmo depois de regularizar a filiação no CITRG.

Este spec é incremental sobre o trabalho já registrado em expire_pro_when_citrg_ineligible e fix_non_expired_scope_invert_where. O bug do invert_where no scope non_expired já foi corrigido em app/models/subscription.rb:37; este spec não mexe na definição do scope, apenas para de usá-lo neste finder específico.

Objetivos

  • ExpireWhenIneligible marca como inactive (e loga) qualquer subscription PRO pending/active do usuário quando pro_eligible é false, independentemente de expires_at já estar no passado
  • Contas presas nesse estado hoje (como a da usuária do caso real) se autocorrigem na próxima execução do use case (login, POST /api/v1/subscriptions/pros ou webhook do CITRG), sem depender de backfill manual

Fora de escopo

  • Remediação manual/pontual da conta da usuária em produção (id 767) — resolve sozinha na próxima execução do use case, uma vez o fix aplicado
  • Qualquer mudança em ValidateExistingSubscription, Creation::CreateSubscription ou na definição do scope Subscription.non_expired
  • Auditoria/histórico de mudanças de Subscription/SubscriptionPlan (ex.: adicionar audited) — mencionado como lacuna observada, mas não faz parte deste fix
  • Constraint/validação no model Subscription impedindo o estado status: active + expires_at no passado — esse estado é usado de propósito por expiry_other_active_plans (em Pro::Creation::CreateSubscription e RegularTerapeuta::Creation::CreateSubscription) para marcar planos substituídos sem tocar status; mudar isso é uma decisão de modelo maior, fora do escopo deste bug

Mudanças

  • app/use_cases/subscriptions/pro/expire_when_ineligible.rb — remover .non_expired do finder pro_subscription, deixando-o igual ao já usado em validate_existing_subscription.rb:5: ruby def pro_subscription @pro_subscription ||= context.user.subscriptions.find_by( subscription_plan: SubscriptionPlan.pro, status: [:pending, :active] ) end
  • spec/use_cases/subscriptions/pro/expire_when_ineligible_spec.rb — novo teste cobrindo o cenário real: PRO com status: active e expires_at no passado + pro_eligible: false → deve ficar inactive e o use case deve ter sucesso (log disparado)

Plano de implementação (TDD)

  1. test (RED): adicionar o cenário — usuário com pro_subscription status: active, expires_at: 1.day.ago, chamado com pro_eligible: false → hoje falha porque a subscription permanece active
  2. fix (GREEN): remover .non_expired do finder pro_subscription
  3. Rodar a suíte completa de expire_when_ineligible_spec.rb e dos specs relacionados (lista abaixo) para confirmar que nada regride

Como verificar

  • make test test=spec/use_cases/subscriptions/pro/expire_when_ineligible_spec.rb
  • make test test=spec/use_cases/subscriptions/pro/creation/create_flow_spec.rb spec/use_cases/subscriptions/pro/creation/create_subscription_spec.rb spec/use_cases/subscriptions/pro/validate_existing_subscription_spec.rb spec/requests/api/v1/subscriptions/pros_expire_when_ineligible_spec.rb spec/requests/citrg/membership_expirations_spec.rb — regressão nos fluxos que chamam ExpireWhenIneligible
  • make test verde
  • Console de produção (somente leitura, sem escrita): confirmar que a query sem .non_expired de fato encontra a subscription 767 da usuária do caso real: ruby user = User.find_by(email: "alessapena04@gmail.com") user.subscriptions.find_by(subscription_plan: SubscriptionPlan.pro, status: [:pending, :active]) # deve retornar a subscription 767, hoje ignorada pelo .non_expired

Documentação

  • Atualizar R-003: a tabela de decisão já descreve o resultado esperado (“PRO expirado é reativado com expires_at limpo”) assumindo que ExpireWhenIneligible consegue marcar a PRO como inactive mesmo já expirada por data — hoje isso não era verdade; adicionar uma nota nas Restrições explicando que o finder de pro_subscription não filtra por expires_at, exatamente para cobrir esse caso.