Webhook v2: traduz payload de evento de compra (Hotmart) para criar/atualizar filiação

TLDR: Novo POST /api/v2/webhook recebe o payload de compra no formato de evento da Hotmart (PURCHASE_APPROVED/PURCHASE_REFUNDED/PURCHASE_DELAYED), enviado por outro sistema (não a Hotmart diretamente), traduz os campos para o formato que WebhookMembershipService já entende, e reaproveita 100% da lógica de criação/atualização de filiação existente — sem alterar o v1.

Contexto

Hoje POST /api/v1/webhook (app/controllers/api/v1/webhook_controller.rb) só entende o payload que o checkout-api envia (via CbtrgService/Cbtrg#webhook_send_payload, ver checkout-api/app/services/cbtrg_service.rb): { payment: { id, status, billing_type, checkout_id, installment_count, customer: { id, email, doc_number, name, phone_number } } }.

Precisamos aceitar um segundo formato de payload — o formato de evento de notificação da Hotmart (event, data.purchase, data.product, data.buyer) — enviado por outro sistema, para criar/atualizar a mesma Membership sem duplicar a lógica de negócio que já existe em WebhookMembershipService.

Exemplo de payload recebido (evento PURCHASE_APPROVED):

json { "id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8", "order_id": 18342, "creation_date": 1755741600000, "event": "PURCHASE_APPROVED", "version": "2.0.0", "data": { "purchase": { "transaction": "f4708f72-89a0-4976-a51a-5068c481f296", "status": "APPROVED", "payment": { "type": "PIX", "installments_number": 1 }, "offer": { "code": "6cikhiz8", "name": "Formação Completa" } }, "product": { "id": 42, "name": "Formação em Terapias Integrativas" }, "buyer": { "id": 9021, "name": "Maria Aparecida Silva", "email": "maria@example.com", "checkout_phone": "+5511999998888", "document": "123.456.789-00", "document_type": "CPF" } } }

Mesmo payload para o evento PURCHASE_REFUNDED (mesma estrutura, data.purchase.status: "REFUNDED", data.purchase.refund_date preenchido).

Payload para o evento PURCHASE_DELAYED (parcela/boleto em atraso — equivalente ao PAYMENT_OVERDUE que hoje chega via Asaas para o fluxo do v1, mas aqui no formato Hotmart):

json { "id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8", "order_id": 18342, "creation_date": 1755741600000, "event": "PURCHASE_DELAYED", "version": "2.0.0", "data": { "purchase": { "transaction": "f4708f72-89a0-4976-a51a-5068c481f296", "status": "PARTIALLY_PAID", "approved_date": null, "payment": { "type": "PIX", "installments_number": 1 }, "offer": { "code": "6cikhiz8", "name": "Formação Completa" } }, "product": { "id": 42, "name": "Formação em Terapias Integrativas" }, "buyer": { "id": 9021, "name": "Maria Aparecida Silva", "email": "maria@example.com", "checkout_phone": "+5511999998888", "document": "123.456.789-00", "document_type": "CPF" } } }

Mapeamento de campos (decidido com o time)

params legado (WebhookMembershipService) origem no payload novo observação
id data.purchase.transaction id da transação, estável entre reenvios do mesmo evento — não o id de nível raiz, que é o id da notificação (mesmo padrão já usado no checkout-api: gateway_id = gateway_payment[:id], não o id do envelope do webhook)
status mapeado do event PURCHASE_APPROVED → "paid"; PURCHASE_REFUNDED → "refunded"; PURCHASE_DELAYED → "overdue"; qualquer outro event não é processado nesta versão
billing_type data.purchase.payment.type  
checkout_id data.purchase.offer.code resolve o MembershipGroup via gateway_product_id, igual ao v1 — offer.code distingue a oferta (equivalente a Black/Blue/White), product.id não distinguiria
installment_count data.purchase.payment.installments_number  
customer.id data.buyer.id  
customer.email data.buyer.email  
customer.doc_number data.buyer.document, só dígitos mesmo padrão de normalização do Customer#phone_number_flat/#parse_doc_number no checkout-api
customer.name data.buyer.name  
customer.phone_number data.buyer.checkout_phone, só dígitos idem

Autenticação

Mesmo token do v1 — params[:auth_token_verification] == Rails.application.credentials.WEBHOOK_AUTH_TOKEN_VERIFICATION. Nenhuma credencial nova. Quem chama este endpoint é outro sistema (não a Hotmart diretamente), então o token compartilhado por query string já é suficiente.

Objetivos

  • Criar POST /api/v2/webhook, aceitando o payload de evento acima.
  • Tratar PURCHASE_APPROVED (→ status: "paid", cria/atualiza filiação), PURCHASE_REFUNDED (→ status: "refunded") e PURCHASE_DELAYED (→ status: "overdue") — os dois últimos só atualizam filiação existente, nunca criam (mesma regra do v1: status != "paid" → find_by, nunca find_or_initialize_by).
  • PURCHASE_DELAYED reproduz para essa origem o mesmo efeito que PAYMENT_OVERDUE (Asaas) já tem hoje no fluxo v1: grava payment_status: "overdue" na filiação, o que já é suficiente para Membership#paid?/#apolo_access_permitted? revogarem o acesso — sem necessidade de replicar a carência de 5 dias do CbtrgService (isso é uma decisão do checkout-api/gateway antes de notificar; aqui o evento só chega quando o “outro sistema” decidir que está de fato atrasado).
  • Qualquer outro event retorna 200 OK sem processar, com mensagem indicando que o evento não é suportado (mesmo padrão de resposta do v1, que sempre responde 200/bad_request sem nunca estourar erro 500 para o remetente).
  • Traduzir o payload para os params do WebhookMembershipService, reaproveitando-o sem alterações.
  • Reaproveitar WebhookHistory para auditoria do payload recebido, igual ao v1.
  • Api::V2::WebhookController não deve herdar de Api::V2::ApplicationController (que exige header apolo-access-token) — herda direto de ApplicationController, como o Api::V1::WebhookController já faz.
  • A lógica de tradução/validação/resolução da filiação vira um use case com a gem u-case (Micro::Case, já usada no projeto — ver Auth::Login, Onboarding::Moderate, Membership::FutureApprove), mantendo o controller fino (result.on_success/result.on_failure), no mesmo padrão do Api::V2::AuthController.

Fora de escopo

  • Não altera Api::V1::WebhookController, WebhookMembershipService nem o payload/contrato do v1 — o checkout-api continua chamando o v1 exatamente como hoje.
  • Não trata outros eventos de compra da Hotmart (PURCHASE_CANCELED, PURCHASE_CHARGEBACK, PURCHASE_PROTEST, etc.) — fica para uma spec futura, se necessário.
  • Não implementa nenhuma carência/reprocessamento automático (equivalente ao PlatformRunIntegrationSyncJob.set(wait: 5.days) do CbtrgService) para PURCHASE_DELAYED — o citrg-api só reage ao evento recebido; se o “outro sistema” reenviar um evento de recuperação (ex: pagamento do boleto atrasado sendo confirmado depois), isso chega como um novo PURCHASE_APPROVED para a mesma transaction, que set_membership/update_membership já tratam corretamente (volta a payment_status: "paid").
  • Não implementa verificação de assinatura própria da Hotmart (ex: hottok/HMAC) — a autenticação é o token compartilhado do v1, porque o payload chega via outro sistema, não da Hotmart diretamente.
  • Não corrige a conversão de reference_id (coluna integer em WebhookHistory) para ids não numéricos (transaction é um UUID) — o v1 já tem essa mesma limitação com payment.id (ex: "payment_92839288432"), e não é o escopo desta mudança alterar esse comportamento herdado.

Mudanças

app/models/membership/process_purchase_webhook.rb (novo — use case)

Toda a tradução do payload, validação de campos mínimos, resolução do evento suportado, resolução/criação da Membership e a chamada ao WebhookMembershipService (sem alterá-lo) ficam concentradas neste Micro::Case, no mesmo padrão de Auth::Login/Onboarding::Moderate/Membership::FutureApprove:

```ruby class Membership::ProcessPurchaseWebhook < Micro::Case EVENT_STATUS_MAP = { “PURCHASE_APPROVED” => “paid”, “PURCHASE_REFUNDED” => “refunded”, “PURCHASE_DELAYED” => “overdue” }.freeze

attributes :payload

def call! return event_not_supported unless mapped_status return missing_fields unless minimal_fields_present?

create_webhook_history

return membership_not_found unless membership

WebhookMembershipService.new(membership, membership_params)

Success result: { message: "Webhook processed" }   end

private

def event_not_supported Rails.logger.error “Purchase webhook received unsupported event: #{event}”

Failure :event_not_supported, result: { message: I18n.t("api.errors.webhook.event_not_supported", event: event) }   end

def missing_fields Rails.logger.error “Purchase webhook missing required fields (event: #{event}, transaction: #{purchase[:transaction]})”

Failure :missing_fields, result: { message: I18n.t("api.errors.webhook.missing_fields") }   end

def membership_not_found Rails.logger.error “Purchase webhook membership not found for payment_reference #{membership_params[:id]}”

Failure :membership_not_found, result: { message: I18n.t("api.errors.webhook.membership_not_found", id: membership_params[:id]) }   end

def event payload[:event] end

def purchase payload.dig(:data, :purchase) || {} end

def buyer payload.dig(:data, :buyer) || {} end

def mapped_status EVENT_STATUS_MAP[event] end

def minimal_fields_present? membership_params[:customer][:doc_number].present? && membership_params[:customer][:email].present? && membership_params[:checkout_id].present? && membership_params[:id].present? end

def membership @membership ||= begin finder = (mapped_status != “paid”) ? :find_by : :find_or_initialize_by

  Membership.send(finder, payment_reference: membership_params[:id])
end   end

def membership_params @membership_params ||= { id: purchase[:transaction], status: mapped_status, billing_type: purchase.dig(:payment, :type), checkout_id: purchase.dig(:offer, :code), installment_count: purchase.dig(:payment, :installments_number), customer: { id: buyer[:id], email: buyer[:email], doc_number: only_numbers(buyer[:document]), name: buyer[:name], phone_number: only_numbers(buyer[:checkout_phone]) } } end

def create_webhook_history WebhookHistory.create( reference_id: membership_params[:id], reference_type: “membership”, webhook_type: “payment”, payload: payload.to_json ) end

def only_numbers(value) value.to_s.gsub(/\D/, “”) end end ```

Observação: citrg-api não tem um StringUtils::OnlyNumbers equivalente ao do checkout-api — normaliza inline com .gsub(/\D/, "") (único ponto de uso, não justifica extrair um utilitário novo).

O use case recebe o payload bruto (o params inteiro da requisição, sem permit) como único atributo — não campos pré-extraídos pelo controller. Isso mantém o WebhookHistory gravando o payload bruto (igual ao v1, params.to_json), e concentra toda a extração/parsing do formato Hotmart dentro do use case, não no controller. Como o use case só faz leitura ([]/dig) pra montar membership_params campo a campo — nunca mass-assignment direto num model a partir do payload —, não precisa de permit para ser seguro.

app/controllers/api/v2/webhook_controller.rb (novo — controller fino)

```ruby class Api::V2::WebhookController < ApplicationController skip_before_action :verify_authenticity_token before_action :verify_auth_token

def create result = Membership::ProcessPurchaseWebhook.call(payload: params)

result.on_success { render json: {}, status: :ok }
result.on_failure { |r| render json: { message: r[:message] }, status: :ok }   end

private

def verify_auth_token render json: { message: I18n.t(“api.errors.webhook.invalid_auth_token”) }, status: :ok and return unless auth_token_validity end

def auth_token_validity params[:auth_token_verification] == Rails.application.credentials.WEBHOOK_AUTH_TOKEN_VERIFICATION end end ```

A checagem de autenticação (auth_token_verification) fica no controller — é uma preocupação de HTTP/autenticação, não regra de negócio, e replica exatamente o que Api::V1::WebhookController#verify_auth_token já faz. O controller não faz mais permit/parsing nenhum — só repassa params pro use case.

config/routes.rb

Dentro de namespace :v2, adicionar:

ruby resources :webhook, only: [ :create ]

config/locales/{pt-BR,en,es}.yml

Reaproveita as chaves já existentes em api.errors.webhook (missing_fields, membership_not_found, invalid_auth_token). Adiciona uma chave nova:

yaml webhook: event_not_supported: "Evento '%{event}' não é suportado" (equivalente em en.yml/es.yml)

test/fixtures/membership_groups.yml

Nova fixture representando a oferta usada nos testes (ex: hotmart_offer_group, gateway_product_id: "6cikhiz8"), para testar a resolução de checkout_id a partir de offer.code sem colidir com a fixture test_group existente.

test/models/membership/process_purchase_webhook_test.rb (novo — TDD, unit test)

Cobertura principal do use case (Triple-A, fixtures, sem HTTP), no mesmo padrão de test/models/auth/login_test.rb:

  • evento não suportado (ex: PURCHASE_CREATED) → Failure(:event_not_supported), nenhuma WebhookHistory/Membership criada
  • campos mínimos ausentes (buyer.document/buyer.email/offer.code/purchase.transaction) → Failure(:missing_fields)
  • PURCHASE_APPROVED com dados válidos → Success, cria Membership com payment_status: "paid", payment_reference: data.purchase.transaction, membership_group resolvido por offer.code
  • PURCHASE_APPROVED normaliza doc_number/phone_number para só dígitos
  • PURCHASE_REFUNDED para filiação existente → Success, atualiza payment_status para "refunded", não cria filiação nova
  • PURCHASE_REFUNDED sem filiação existente → Failure(:membership_not_found), nenhuma criação
  • PURCHASE_DELAYED para filiação existente → Success, atualiza payment_status para "overdue", não cria filiação nova
  • PURCHASE_DELAYED sem filiação existente → Failure(:membership_not_found), nenhuma criação
  • WebhookHistory é criado com o payload bruto recebido em uma chamada válida
  • as 3 falhas (event_not_supported, missing_fields, membership_not_found) logam via Rails.logger.error

test/controllers/api/v2/webhook_controller_test.rb (novo — TDD, integration test)

Cobertura fina do controller (HTTP + autenticação), no mesmo padrão de test/controllers/api/v2/auth_controller_test.rb — não repete os casos de regra de negócio já cobertos no teste do use case:

  • token de autenticação inválido/ausente → 200 com mensagem invalid_auth_token, Membership::ProcessPurchaseWebhook nunca é chamado
  • token válido + evento válido → 200, delega corretamente pro use case e persiste a Membership
  • token válido + Failure do use case (ex: evento não suportado) → 200 com a mensagem do result[:message]

Como verificar

bash make run.test path=test/models/membership/process_purchase_webhook_test.rb make run.test path=test/controllers/api/v2/webhook_controller_test.rb

Manual:

bash curl -X POST http://localhost:3000/api/v2/webhook \ -H "Content-Type: application/json" \ -d '{ "auth_token_verification": "TOKEN_DO_V1", "id": "9f1c2a34-7b8d-4e5f-a1b2-c3d4e5f6a7b8", "event": "PURCHASE_APPROVED", "data": { "purchase": { "transaction": "f4708f72-89a0-4976-a51a-5068c481f296", "status": "APPROVED", "payment": { "type": "PIX", "installments_number": 1 }, "offer": { "code": "6cikhiz8", "name": "Formação Completa" } }, "buyer": { "id": 9021, "name": "Maria Aparecida Silva", "email": "maria@example.com", "checkout_phone": "+5511999998888", "document": "123.456.789-00" } } }'

Confirmar que uma Membership é criada com payment_reference: "f4708f72-89a0-4976-a51a-5068c481f296" e payment_status: "paid", e que reenviar o mesmo PURCHASE_APPROVED não duplica a filiação (idempotência via payment_reference único).

Documentação

  • .project/docs/reference/api/public_endpoints.md — adicionar POST /api/v2/webhook na tabela e um contrato de exemplo, junto do v1.
  • .project/docs/reference/membership/membership_activation_flow.md — nota linkando para o novo formato de origem (v2), sem duplicar o fluxo (a lógica de ativação em si não muda).
  • .project/docs/README.md — nova linha na tabela de specs/.

Checklist de implementação

  1. Setup
    • [x] git pull --rebase origin main
    • [x] git checkout -b feature/webhook_v2_purchase_membership
  2. Fixture (test/fixtures/membership_groups.yml)
    • [x] Adicionar hotmart_offer_group (nomeada purchase_offer_group) com gateway_product_id de teste, sem colidir com test_group
  3. Locale (config/locales/{pt-BR,en,es}.yml)
    • [x] Adicionar api.errors.webhook.event_not_supported nos 3 arquivos
  4. Use case — TDD (Membership::ProcessPurchaseWebhook)
    • [x] Escrever test/models/membership/process_purchase_webhook_test.rb (vermelho) com os casos: evento não suportado, campos mínimos ausentes, PURCHASE_APPROVED cria filiação, PURCHASE_APPROVED normaliza doc/telefone, PURCHASE_REFUNDED atualiza existente, PURCHASE_REFUNDED sem filiação, PURCHASE_DELAYED atualiza existente, PURCHASE_DELAYED sem filiação, WebhookHistory criado
    • [x] Implementar app/models/membership/process_purchase_webhook.rb até os testes ficarem verdes
    • [x] Refatorar se necessário (sem alterar comportamento coberto pelos testes)
  5. Controller — TDD (Api::V2::WebhookController)
    • [x] Adicionar rota em config/routes.rb (namespace :v2 { resources :webhook, only: [:create] })
    • [x] Escrever test/controllers/api/v2/webhook_controller_test.rb (vermelho): token inválido/ausente, token válido delega e persiste, Failure do use case retorna a mensagem
    • [x] Implementar app/controllers/api/v2/webhook_controller.rb até os testes ficarem verdes
  6. Documentação
    • [x] Atualizar .project/docs/reference/api/public_endpoints.md
    • [x] Atualizar .project/docs/reference/membership/membership_activation_flow.md
    • [x] Atualizar .project/docs/README.md (linha nova em specs/)
    • [x] Atualizar o status deste arquivo de spec: proposed → in_progress → done
  7. Verificação
    • [x] make run.test path=test/models/membership/process_purchase_webhook_test.rb
    • [x] make run.test path=test/controllers/api/v2/webhook_controller_test.rb
    • [x] make run.lint
    • [x] make run.test (suíte completa, sem regressão)
    • [x] Coberto pelo teste de integração (ActionDispatch::IntegrationTest exercita a stack real via HTTP) — não subimos o servidor de dev pra rodar o curl manual à parte
  8. Commit
    • [x] Confirmar com você antes de commitar
    • [x] Commits pequenos e atômicos, um por contexto (ex: fixture+locale, use case+teste, controller+teste, docs)