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_unavailableexistente) - 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
app/services/trg_club_service.rb(novo) — cliente HTTP outbound espelhando as convenções doApoloService(HTTParty, URL base viaENV.fetchcom 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 comoapolo-access-token), enviado emX-Security-Signature-Token - Método
notify_membership_expired(email:, register_number:)→HTTParty.postpara 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::Errorem qualquer falha (erro de conexão ou não-2xx) para que o job possa retentar
app/jobs/notifications/memberships/expiration/expired_yesterday_job.rb(modificar) — após osend_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ánextquando o usuário tem renovação paga comvalid_untilposterior), de modo que só filiações genuinamente expiradas sejam notificadas.- Um hook
notify_external(membership)noBaseExpirationJob#perform(no-op por default, chamado logo apóssend_email) é sobrescrito noExpiredYesterdayJobpara enfileirar umNotifications::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
NotifyTrgClubJobdeclararetry_on TrgClubService::Error, wait: :polynomially_longer, attempts: 10(o GoodJob persiste os retries no Postgres; janela de ~4 h; visível em/good_job) ediscard_on ActiveRecord::RecordNotFound. Isso cobre indisponibilidade do trg-club sem perder eventos - Enviar
membership.user.email(em minúsculas) como chave de correspondência eregister_numberpara rastreabilidade
- Um hook
-
Env / credenciais — documentar
TRG_CLUB_API_URLno.env.example. Sem token novo: a auth reaproveitaAPOLO_ACCESS_TOKEN - Testes (Minitest, Triple-A, mockando a chamada HTTP — sem tocar a rede):
TrgClubServicemonta a URL, os headers (incluindo o token) e o body JSON corretos; retorna graciosamente emStandardErrorExpiredYesterdayJobnotifica o trg-club para uma filiação expirada; não notifica quando existe renovação paga comvalid_untilposterior (guarda existente); uma falha doTrgClubServicenã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
-
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 app/controllers/citrg/membership_expirations_controller.rb(novo) — espelhar o padrão de auth doZeuspay::PaymentStatusesController, mas não reaproveitarWebhookRequests::CreateByRequest/WebhookRequest: esse model tem umbelongs_to :paymentobrigatório (chaveado emgateway_payment_id) e default deoriginpara"ZeusPay"— é específico de webhook de pagamento. Persistir um evento da citrg por ele falharia a validação de presença depaymentem toda chamada (sem payment/pid correspondente) e seria silenciosamente engolido pelo rescue do use case, enchendo oReportErrorcom um falso “WebhookRequest not created” a cada entrega legítima. Em vez disso, apenasRails.logger.infono payload recebido, para rastreabilidade.skip_before_action :verify_authenticity_tokenbefore_action :check_citrg_token!check_citrg_token!:head :unauthorized unless request.headers["X-Security-Signature-Token"] == CITRG_WEBHOOK_TOKENcreate: 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
-
Busca do usuário — casar por
emailprimeiro (a chave que o CITRG envia e a que oValidateEligibilityjá usa):User.find_by(email: params[:email]). OUser#emailjá tem uma declaraçãonormalizes(app/models/user.rb:72, downcase + strip) que o Rails aplica automaticamente tanto em escritas quanto emfind_by/where, então não é necessário normalizar manualmente. Opcionalmente cair paracitrg_idp_user_idse o payload passar a carregá-lo -
config/initializers/citrg.rb(modificar) — definirCITRG_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 doAPOLO_ACCESS_TOKENda Parte 1 - Testes (Triple-A, sem mocks em request spec): 401 com token ausente/inválido (e PRO intocado); 200 + assinatura PRO em
inactiveeregular-terapeutareativado 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
- citrg-api (unitário): rodar os specs de job/service —
expired_yesterday_job_test.rb,notify_trg_club_job_test.rbetrg_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 - 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
- Ponta a ponta (staging): com um terapeuta cujo
valid_untilno CITRG esteja ajustado para ontem e que esteja PRO+publicado no trg-club: disparar oExpiredYesterdayJob(ou esperar o cron das 8h) → confirmar que a citrg faz o POST do webhook → confirmar que o trg-club roda oExpireWhenIneligible→ confirmar que a assinatura PRO ficainactive, oregular-terapeutaé reativado e o perfil sai da busca — sem o usuário fazer login - 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