Corrigir o bug de invert_where no scope non_expired e normalizar planos no login
TLDR:
Subscription.non_expiredusainvert_where, que inverte também a condiçãouser_idda associação e faz oExpireWhenIneligibleexpirar 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-terapeutaainda 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::CreateSubscriptionjá reativa a linha PRO existente viafind_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
ExpireWhenIneligibleretorna cedo quando não encontra PRO ativo e nunca reativa o plano regular. Ele precisa reativarregular-terapeuta(status: :active,expires_at: nil) sempre que o usuário for inelegível, mesmo sem PRO restante para expirar.
Objetivos
non_expiredcompõe corretamente com o escopo de associação (user.subscriptions.non_expiredretorna 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-terapeutaativo comexpires_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— reescrevernon_expiredseminvert_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 assinaturaregular-terapeutamesmo se não houver assinatura PRO ativa; manter a transação quando as duas escritas acontecemspec/models/subscription_spec.rb— comportamento do scope, incluindo a regressão de encadeamento em associaçãospec/use_cases/subscriptions/pro/expire_when_ineligible_spec.rb— regressão multi-usuário + reativação no caso limbospec/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 webhooklib/scripts/diagnose_pro_downgrade.rb— remover a cópia do padrão de query com o bug
Plano de implementação
- test: specs RED reproduzindo o bug e travando o novo comportamento:
user.subscriptions.non_expirednão pode incluir a assinatura de outro usuário (dois usuários, ambos com linhas não expiradas)non_expiredinclui linhas comexpires_at: nileexpires_atfuturo; excluiexpires_atpassadoExpireWhenIneligiblecom 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
- fix: reescrever o scope
non_expired; ajustar oExpireWhenIneligiblepara sempre reativarregular-terapeutaquando inelegível - refactor: atualizar
lib/scripts/diagnose_pro_downgrade.rb
Como verificar
make testverde- 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_inou replay do webhook do CITRG) e confirmar: o PRO dele fica inativo/expirado, oregular-terapeutafica ativo comexpires_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_whereinverte 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-terapeutano caminho sem PRO ativo