Notificar o trg-club na expiração da filiação CITRG

TLDR: Quando uma filiação CITRG expira, a citrg-api envia um webhook para o trg-club-api, de modo que o perfil PRO seja rebaixado mesmo que o usuário nunca mais faça login.

Contexto

O rebaixamento de PRO no trg-club-api já existe, mas só roda no login: sign-in do terapeuta → sign_in_user → auto_subscribe_pro → Subscriptions::Pro::AutoSubscribeJob.perform_now → Subscriptions::Pro::Creation::CreateFlow.call(user:, status: :active), que roda ValidateEligibility (chama MembershipByEmail no CITRG, verifica valid_until_timestamp) e depois ExpireWhenIneligible (define a assinatura PRO como inactive e reativa regular-terapeuta).

Consequência: um terapeuta cuja filiação CITRG expira mas que nunca mais loga mantém a assinatura PRO ativa e o perfil publicado/visível na busca indefinidamente, porque nada reavalia a elegibilidade fora do caminho de login.

Na citrg-api, a expiração da filiação é computada na leitura a partir de Membership#valid_until (um date); nenhum job vira status. Já existe um cron diário no GoodJob (Notifications::Memberships::Expiration::ExpiredYesterdayJob, 0 8 * * *) que calcula exatamente o conjunto de filiações que expiraram ontem e hoje apenas envia um e-mail de lembrete. A citrg-api não tem nenhuma integração outbound com o trg-club-api ainda (só com o Apolo, via HTTParty no ApoloService).

Decisão de abordagem

Opção A escolhida — a citrg empurra na expiração, de forma autoritativa. A citrg-api, a partir do cron diário de expiração já existente, notifica o trg-club-api que a filiação de um membro expirou. O webhook é tratado como autoritativo: a citrg só o envia após a própria guarda de renovação, então o trg-club rebaixa direto pelo use case Subscriptions::Pro::ExpireWhenIneligible (pro_eligible: false), sem reconsultar o CITRG.

Reconsultar ao vivo foi considerado e descartado: perde-se o evento quando o CITRG está indisponível no momento do processamento (o fail-safe citrg_unavailable pula o rebaixamento e nada retenta), e um rebaixamento errado se autocorrige no próximo login (auto_subscribe_pro). Essa opção reaproveita o cron da citrg, o use case de rebaixamento do trg-club e o padrão de autenticação do webhook da zeuspay.

Alternativas rejeitadas:

Opção Descrição Por que não
B varredura noturna no trg-club rodando CreateFlow para todo usuário PRO ativo sem mudança na citrg, mas N chamadas por dia e sem semântica de push
C endpoint bulk expired_since na citrg + pull do trg-club mais superfície nova nos dois lados

Objetivos

  • Rebaixar um perfil PRO no trg-club-api quando a filiação CITRG expira, sem depender de login
  • Manter a citrg-api como iniciadora e fonte da verdade do evento de expiração
  • Reaproveitar o fluxo de rebaixamento existente (CreateFlow / ExpireWhenIneligible) — sem nova lógica de rebaixamento e sem confiar no payload do webhook para a decisão
  • Ser idempotente e seguro: reentrega, usuários já rebaixados e renovações não podem causar estado errado. Uma indisponibilidade do trg-club/CITRG não pode rebaixar indevidamente (fail-safe citrg_unavailable existente)
  • Não degradar o comportamento atual de e-mail de lembrete do cron diário

Fora de escopo

  • Autenticação por token novo — reaproveita o token de integração existente

Mudanças

Parte 1 — citrg-api (outbound: notificar o trg-club na expiração)

Branch: feat/notify-trg-club-on-membership-expiry

  1. app/services/trg_club_service.rb (novo) — cliente HTTP outbound espelhando as convenções do ApoloService (HTTParty, URL base via ENV.fetch com default).
    • BASE_URL = ENV.fetch("TRG_CLUB_API_URL", "https://api.trgclub.com/") (confirmar os hosts exatos de prod/staging — staging é https://staging-api.trg.club/, ver .github/workflows/deploy-staging.yml)
    • Auth: reaproveitar o token de integração existente (APOLO_ACCESS_TOKEN, o mesmo valor validado inbound como apolo-access-token), enviado em X-Security-Signature-Token
    • Método notify_membership_expired(email:, register_number:) → HTTParty.post para o novo path do webhook do trg-club, com body JSON { email:, register_number: } e headers { "Content-Type" => "application/json", "X-Security-Signature-Token" => token }
    • Levantar TrgClubService::Error em qualquer falha (erro de conexão ou não-2xx) para que o job possa retentar
  2. app/jobs/notifications/memberships/expiration/expired_yesterday_job.rb (modificar) — após o send_email(membership) existente, também notificar o trg-club para a mesma filiação expirada. Reaproveitar a guarda de renovação do job base (que já dá next quando o usuário tem renovação paga com valid_until posterior), de modo que só filiações genuinamente expiradas sejam notificadas.
    • Um hook notify_external(membership) no BaseExpirationJob#perform (no-op por default, chamado logo após send_email) é sobrescrito no ExpiredYesterdayJob para enfileirar um Notifications::Memberships::Expiration::NotifyTrgClubJob.perform_later(membership.id) por filiação expirada — e-mail e notificação falham de forma independente, e o loop do cron nunca faz HTTP
    • Retry: o NotifyTrgClubJob declara retry_on TrgClubService::Error, wait: :polynomially_longer, attempts: 10 (o GoodJob persiste os retries no Postgres; janela de ~4 h; visível em /good_job) e discard_on ActiveRecord::RecordNotFound. Isso cobre indisponibilidade do trg-club sem perder eventos
    • Enviar membership.user.email (em minúsculas) como chave de correspondência e register_number para rastreabilidade
  3. Env / credenciais — documentar TRG_CLUB_API_URL no .env.example. Sem token novo: a auth reaproveita APOLO_ACCESS_TOKEN

  4. Testes (Minitest, Triple-A, mockando a chamada HTTP — sem tocar a rede):
    • TrgClubService monta a URL, os headers (incluindo o token) e o body JSON corretos; retorna graciosamente em StandardError
    • ExpiredYesterdayJob notifica o trg-club para uma filiação expirada; não notifica quando existe renovação paga com valid_until posterior (guarda existente); uma falha do TrgClubService não interrompe o loop nem impede o e-mail de lembrete

Parte 2 — trg-club-api (inbound: webhook que dispara o rebaixamento existente)

Branch: feat/downgrade-on-citrg-expiry-webhook

  1. config/routes.rb (modificar) — adicionar a rota inbound espelhando o padrão da zeuspay (namespace de topo): ruby namespace :citrg do post "memberships/expired", to: "membership_expirations#create", as: :membership_expirations end

  2. app/controllers/citrg/membership_expirations_controller.rb (novo) — espelhar o padrão de auth do Zeuspay::PaymentStatusesController, mas não reaproveitar WebhookRequests::CreateByRequest/WebhookRequest: esse model tem um belongs_to :payment obrigatório (chaveado em gateway_payment_id) e default de origin para "ZeusPay" — é específico de webhook de pagamento. Persistir um evento da citrg por ele falharia a validação de presença de payment em toda chamada (sem payment/pid correspondente) e seria silenciosamente engolido pelo rescue do use case, enchendo o ReportError com um falso “WebhookRequest not created” a cada entrega legítima. Em vez disso, apenas Rails.logger.info no payload recebido, para rastreabilidade.
    • skip_before_action :verify_authenticity_token
    • before_action :check_citrg_token!
    • check_citrg_token!: head :unauthorized unless request.headers["X-Security-Signature-Token"] == CITRG_WEBHOOK_TOKEN
    • create: logar a requisição, buscar o usuário. Se não encontrar, logar e retornar :ok (200) para que a citrg não retente um membro que simplesmente não tem conta no trg-club. Se encontrar, rebaixar inline: Subscriptions::Pro::ExpireWhenIneligible.call(user:, pro_eligible: false). Retornar :ok
    • Justificativa: o webhook é autoritativo (a citrg aplica a guarda de renovação antes de enviar), então não é necessário revalidar ao vivo — revalidar perderia o evento sempre que o CITRG estivesse momentaneamente indisponível. Idempotente: se já rebaixado, pro_subscription é blank e é no-op. Um rebaixamento errado se autocorrige no próximo login
  3. Busca do usuário — casar por email primeiro (a chave que o CITRG envia e a que o ValidateEligibility já usa): User.find_by(email: params[:email]). O User#email já tem uma declaração normalizes (app/models/user.rb:72, downcase + strip) que o Rails aplica automaticamente tanto em escritas quanto em find_by/where, então não é necessário normalizar manualmente. Opcionalmente cair para citrg_idp_user_id se o payload passar a carregá-lo

  4. config/initializers/citrg.rb (modificar) — definir CITRG_WEBHOOK_TOKEN = ENV.fetch("CITRG_MEMBERSHIP_TOKEN", Rails.application.credentials.dig(:citrg, :api_membership_token)) — o token de integração trg-club↔citrg existente, mesmo valor do APOLO_ACCESS_TOKEN da Parte 1

  5. Testes (Triple-A, sem mocks em request spec): 401 com token ausente/inválido (e PRO intocado); 200 + assinatura PRO em inactive e regular-terapeuta reativado para um e-mail conhecido (inclusive com correspondência case-insensitive); 200 no-op em reentrega (sem assinatura PRO); 200 para e-mail desconhecido

Contrato entre os dois repos

Aspecto Definição
Transporte POST JSON
Auth segredo compartilhado no header X-Security-Signature-Token (mesmo valor nos dois lados)
Body { "email": "<e-mail do membro>", "register_number": <bigint> }
Chave de correspondência email (canônico para busca de filiação nos dois lados)
Resposta trg-club retorna 200 para aceito/conhecido/desconhecido-mas-processado, 401 para token inválido
Retry a citrg retenta falhas de requisição via NotifyTrgClubJob (retry_on com backoff polinomial); o loop do cron nunca faz HTTP; a citrg não depende do body da resposta
Semântica evento autoritativo “este membro expirou” — a citrg só envia após a guarda de renovação, e o trg-club rebaixa direto via ExpireWhenIneligible no recebimento

Como verificar

  1. citrg-api (unitário): rodar os specs de job/service — expired_yesterday_job_test.rb, notify_trg_club_job_test.rb e trg_club_service_test.rb. Afirmar que o job de notificação é enfileirado para uma filiação expirada, pulado em renovação, e que falhas levantam para retry
  2. trg-club-api (unitário): rodar o request spec — 401 com token inválido, 200 + PRO expirado com e-mail válido conhecido, no-op para e-mail desconhecido
  3. Ponta a ponta (staging): com um terapeuta cujo valid_until no CITRG esteja ajustado para ontem e que esteja PRO+publicado no trg-club: disparar o ExpiredYesterdayJob (ou esperar o cron das 8h) → confirmar que a citrg faz o POST do webhook → confirmar que o trg-club roda o ExpireWhenIneligible → confirmar que a assinatura PRO fica inactive, o regular-terapeuta é reativado e o perfil sai da busca — sem o usuário fazer login
  4. Idempotência e segurança: reentregar o mesmo webhook (sem efeito duplo); renovar o membro no CITRG e então entregar (sem rebaixamento); apontar o trg-club para um CITRG inalcançável (sem rebaixamento — fail-safe)

Documentação

  • citrg-api: criar/atualizar um doc de regra de negócio para “expiração de filiação notifica o trg-club” e registrá-lo no índice de regras; documentar TRG_CLUB_API_URL
  • trg-club-api: o mecanismo resultante está em pro_downgrade_on_citrg_expiry; a regra em R-003 registra o gatilho fora do login