Corrigir o bug de invert_where no scope non_expired e normalizar planos no login

TLDR: Subscription.non_expired usa invert_where, que inverte também a condição user_id da associação e faz o ExpireWhenIneligible expirar a assinatura PRO de um usuário aleatório; corrigir o scope e fazer o login normalizar os planos do usuário em todos os casos.

Contexto

Subscription.non_expired está definido como expired.invert_where. O invert_where inverte todos os predicados acumulados na relation, não só os adicionados pelo scope expired. Encadeado numa associação (user.subscriptions.non_expired), o user_id = X implícito também é invertido, produzindo:

sql WHERE NOT (user_id = X AND expires_at IS NOT NULL AND expires_at < now)

Isso casa com quase toda assinatura do banco (todas as linhas de outros usuários, mais as linhas não expiradas do próprio usuário). Combinado com o default_scope { order(start_at: :desc, created_at: :desc) } do model, o find_by em Subscriptions::Pro::ExpireWhenIneligible#pro_subscription retorna a assinatura PRO ativa/pendente mais recente do banco inteiro — geralmente a de outro usuário.

Confirmado em produção (usuário 12291 / dhsouza.douglas@gmail.com): a query alvo retornou as assinaturas 44441 (usuário 35403), 44161 (usuário 25473) e 44445 (usuário 11161) em execuções sucessivas.

Consequências desde os PRs #679/#681:

  • Todo webhook de expiração do CITRG e todo login inelegível inativa a assinatura PRO de um usuário inocente (update!(status: :inactive, expires_at: Time.current)) em vez da pretendida.
  • O usuário pretendido mantém a assinatura PRO ativa (sintoma reportado: dhsouza.douglas@gmail.com nunca foi rebaixado).
  • As vítimas ficam em limbo: PRO inativo + expirado, e regular-terapeuta ainda expirado desde o upgrade para PRO — sem nenhum plano válido.

Os specs passam porque usam um único usuário: para as linhas não expiradas do próprio usuário, NOT(user_id = X AND expired) continua verdadeiro, então a query se comporta corretamente com apenas um usuário no banco.

Estratégia de remediação decidida com o dono: sem backfill baseado em log. Corrigir a causa raiz e deixar o próximo login de cada usuário, via POST /api/v1/terapeuta/sign_in, normalizar os planos:

  • Usuário elegível: Subscriptions::Pro::Creation::CreateSubscription já reativa a linha PRO existente via find_or_initialize_by + status: :active, expires_at: nil. Funciona assim que o scope for corrigido — sem código novo.
  • Usuário inelegível (caso limbo): hoje o ExpireWhenIneligible retorna cedo quando não encontra PRO ativo e nunca reativa o plano regular. Ele precisa reativar regular-terapeuta (status: :active, expires_at: nil) sempre que o usuário for inelegível, mesmo sem PRO restante para expirar.

Objetivos

  • non_expired compõe corretamente com o escopo de associação (user.subscriptions.non_expired retorna só as linhas daquele usuário)
  • Webhook e login rebaixam a assinatura PRO do usuário correto
  • O login normaliza os planos em todo estado: elegível → PRO ativo; inelegível → regular-terapeuta ativo com expires_at: nil, nunca deixado no passado
  • Nenhum dado de outro usuário é tocado por esses fluxos

Fora de escopo

  • Backfill baseado em log das vítimas já inativadas — elas se recuperam no próximo login
  • Fase de perf: — sem gargalo medido

Mudanças

  • app/models/subscription.rb — reescrever non_expired sem invert_where: ruby scope :non_expired, -> { where(expires_at: nil).or(where(expires_at: Time.current..)) }
  • app/use_cases/subscriptions/pro/expire_when_ineligible.rb — quando inelegível, reativar a assinatura regular-terapeuta mesmo se não houver assinatura PRO ativa; manter a transação quando as duas escritas acontecem
  • spec/models/subscription_spec.rb — comportamento do scope, incluindo a regressão de encadeamento em associação
  • spec/use_cases/subscriptions/pro/expire_when_ineligible_spec.rb — regressão multi-usuário + reativação no caso limbo
  • spec/use_cases/subscriptions/pro/expire_by_citrg_expiry_spec.rb / spec/requests/citrg/membership_expirations_spec.rb — cobertura de regressão multi-usuário no caminho do webhook
  • lib/scripts/diagnose_pro_downgrade.rb — remover a cópia do padrão de query com o bug

Plano de implementação

  1. test: specs RED reproduzindo o bug e travando o novo comportamento:
    • user.subscriptions.non_expired não pode incluir a assinatura de outro usuário (dois usuários, ambos com linhas não expiradas)
    • non_expired inclui linhas com expires_at: nil e expires_at futuro; exclui expires_at passado
    • ExpireWhenIneligible com dois usuários: expira o PRO do usuário do contexto, deixa o do outro intocado
    • Caso limbo: usuário inelegível com PRO inativo/expirado e regular expirado → o regular vira status: :active, expires_at: nil
    • Request spec do webhook com um segundo usuário detendo o PRO ativo mais recente: vítima intocada, alvo rebaixado
  2. fix: reescrever o scope non_expired; ajustar o ExpireWhenIneligible para sempre reativar regular-terapeuta quando inelegível
  3. refactor: atualizar lib/scripts/diagnose_pro_downgrade.rb

Como verificar

  • make test verde
  • Console de produção (somente leitura): ruby user = User.find_by(email: "dhsouza.douglas@gmail.com") user.subscriptions.non_expired.to_sql # deve conter "user_id" = 12291 sem nenhum NOT(...)
  • Disparar o fluxo para o usuário reportado (login via POST /api/v1/terapeuta/sign_in ou replay do webhook do CITRG) e confirmar: o PRO dele fica inativo/expirado, o regular-terapeuta fica ativo com expires_at: nil, e nenhuma assinatura de outro usuário muda
  • Smoke test de vítima: escolher um usuário PRO indevidamente inativado na query de produção, fazê-lo logar (ou simular o fluxo) e confirmar que o PRO é reativado

Documentação

  • Learning: subscriptions_invert_where_association_conditions — invert_where inverte condições de associação quando o scope é encadeado numa associação; nunca usá-lo em scopes que precisam compor
  • R-003 — registrar a reativação do regular-terapeuta no caminho sem PRO ativo