Notificar o trg-club no vencimento da filiação CITRG

TLDR: Quando uma filiação CITRG vence, o citrg-api empurra um webhook para o trg-club-api para que o perfil PRO seja rebaixado mesmo que a pessoa nunca mais faça login.

Contexto

O rebaixamento de PRO no trg-club-api já existia, mas só rodava 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 executa ValidateEligibility (chama o MembershipByEmail do CITRG e checa valid_until_timestamp) e depois ExpireWhenIneligible (marca a assinatura PRO como inactive e reativa regular-terapeuta).

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

No citrg-api, o vencimento é calculado na leitura a partir de Membership#valid_until (um date); nenhum job vira status. Já existia um cron diário no GoodJob (Notifications::Memberships::Expiration::ExpiredYesterdayJob, 0 8 * * *) que calcula exatamente o conjunto de filiações vencidas ontem e, na época, só enviava e-mail de lembrete. O citrg-api não tinha nenhuma integração de saída com o trg-club-api (só com o Apolo, via HTTParty no ApoloService).

Abordagem escolhida (Opção A — citrg empurra no vencimento, autoritativo)

O citrg-api, a partir do cron diário de vencimento, notifica o trg-club-api de que a filiação de um membro venceu. O webhook é tratado como autoritativo: o citrg só o envia depois da própria guarda de renovação, e o trg-club rebaixa direto pelo caso de uso existente Subscriptions::Pro::ExpireWhenIneligible (pro_eligible: false), sem reconsultar o CITRG. Reconsultar ao vivo foi considerado e descartado: perde o evento quando o CITRG está indisponível no momento do processamento, e um rebaixamento indevido se auto-corrige no próximo login (auto_subscribe_pro).

Alternativas consideradas e rejeitadas:

Opção Por que foi rejeitada
B — varredura noturna do trg-club rodando CreateFlow para todo usuário PRO ativo não exige mudança no citrg, mas gera N chamadas/dia e não tem semântica de push
C — endpoint expired_since em massa no citrg + pull do trg-club mais superfície nova nos dois lados

Objetivos

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

Fora de escopo

  • Nenhuma mudança na lógica de elegibilidade do lado do Apolo.
  • Nenhum backfill de usuários já vencidos antes desta entrega.

Mudanças

Parte 1 — citrg-api (saída: notificar o trg-club no vencimento)

  1. app/services/trg_club_service.rb (novo) — cliente HTTP de saída 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/") (staging é https://staging-api.trg.club/).
    • Auth: reusa o token de integração existente (APOLO_ACCESS_TOKEN, o mesmo valor validado na entrada como apolo-access-token), enviado em X-Security-Signature-Token.
    • notify_membership_expired(email:, register_number:) → HTTParty.post para o path novo do trg-club, corpo JSON { email:, register_number: }.
    • Levanta TrgClubService::Error em qualquer falha (erro de conexão ou não-2xx) para o job enfileirador poder fazer retry.
  2. app/jobs/notifications/memberships/expiration/base_expiration_job.rb — hook notify_external(membership) (no-op por default) chamado logo depois de send_email, dentro da guarda de renovação existente.

  3. app/jobs/notifications/memberships/expiration/expired_yesterday_job.rb — sobrescreve notify_external para enfileirar um NotifyTrgClubJob.perform_later(membership.id) por filiação vencida. E-mail e notificação falham de forma independente, e o loop do cron nunca faz HTTP.

  4. app/jobs/notifications/memberships/expiration/notify_trg_club_job.rb (novo) — retry_on TrgClubService::Error, wait: :polynomially_longer, attempts: 10 (GoodJob persiste os retries no Postgres; janela de ~4h; visível em /good_job) e discard_on ActiveRecord::RecordNotFound. Envia membership.user.email em minúsculas como chave de match e register_number para rastreabilidade.

  5. Env — documentar TRG_CLUB_API_URL no .env.example. Sem token novo.

  6. Testes (Minitest, Triple-A, com mock do HTTP): TrgClubService monta URL, headers e corpo corretos e levanta TrgClubService::Error em falha de conexão e em não-2xx; ExpiredYesterdayJob enfileira NotifyTrgClubJob para filiação vencida e não enfileira quando existe renovação paga com valid_until maior; NotifyTrgClubJob chama o service com e-mail/register_number e descarta quando a filiação não existe mais.

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

  1. config/routes.rb — rota de webhook de entrada espelhando o padrão do zeuspay: ruby namespace :citrg do post "memberships/expired", to: "membership_expirations#create", as: :membership_expirations end

  2. app/controllers/citrg/membership_expirations_controller.rb (novo) — espelha Zeuspay::PaymentStatusesController: skip_before_action :verify_authenticity_token; before_action :check_citrg_token!, :save_request; head :unauthorized quando X-Security-Signature-Token não bate com CITRG_WEBHOOK_TOKEN. No create, loga o request, busca o usuário e, se não achar, loga e devolve :ok (para o citrg não ficar em retry por um membro que simplesmente não tem conta no trg-club). Se achar, rebaixa inline com Subscriptions::Pro::ExpireWhenIneligible.call(user:, pro_eligible: false) e devolve :ok. Idempotente: se já rebaixado, pro_subscription está em branco e é no-op.

  3. Lookup do usuário — match por email: User.find_by("lower(email) = ?", params[:email].to_s.downcase.strip).

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

  5. Testes — request spec: 401 com token ausente/inválido (e PRO intocado); 200 + assinatura PRO inactive e regular-terapeuta reativada para e-mail conhecido (inclusive match case-insensitive); 200 no-op em reentrega; 200 para e-mail desconhecido.

Contrato entre os dois repos

  • Transporte: POST JSON.
  • Auth: segredo compartilhado no header X-Security-Signature-Token (mesmo valor nos dois lados).
  • Corpo: { "email": "<e-mail do membro>", "register_number": <bigint> }.
  • Chave de match: email.
  • Resposta: trg-club devolve 200 para aceito/conhecido/desconhecido-mas-processado e 401 para token inválido. O citrg faz retry de falha de request via NotifyTrgClubJob; o loop do cron nunca faz HTTP; o citrg não depende do corpo da resposta.
  • Semântica: o webhook é um evento autoritativo “este membro venceu” — o citrg só o envia depois da guarda de renovação.

Como verificar

  1. citrg-api (unitário): expired_yesterday_job_test.rb, notify_trg_club_job_test.rb, trg_club_service_test.rb — o job de notificação é enfileirado para filiação vencida, pulado em caso de renovação, e falhas levantam para retry.
  2. trg-club-api (unitário): request spec — 401 com token inválido, 200 + PRO expirado para e-mail conhecido válido, no-op para e-mail desconhecido.
  3. Ponta a ponta (staging): com um terapeuta cujo valid_until no CITRG é ontem e que está PRO+publicado no trg-club: disparar ExpiredYesterdayJob (ou esperar o cron das 8h) → confirmar o POST do citrg → confirmar que o trg-club roda ExpireWhenIneligible → confirmar assinatura PRO inactive, regular-terapeuta reativada e perfil fora da busca — sem o usuário logar.
  4. Idempotência/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