Sincronização de unificação e troca de e-mail com o checkout novo

TLDR: toda unificação de cadastros e toda troca de e-mail de cliente passa a gerar um OutboxEvent para o ibft-backend, reaproveitando o outbox que já existe — sem endpoint novo deste lado, sem chamada HTTP no fluxo do operador.

Contexto

No checkout-api o cliente é um cadastro por e-mail, e é ele que se liga a várias empresas — os pagamentos apontam direto para o cadastro, não para o par cliente-empresa. Quando o suporte encontra a mesma pessoa em dois cadastros, roda a unificação: pagamentos e vínculos com empresa do cadastro errado passam para o correto, o errado é desativado e o e-mail dele é liberado.

Isso já é propagado para os sistemas a jusante (CBTRG e Onion por webhook, Asaas pela API do gateway, Apolo por REST), tudo a partir de IbftEmailSyncService. Falta o ibft-backend: os pedidos importados pelo contrato de reparcelamento ficam pendurados no cadastro que saiu, e o financeiro passa a ver a dívida partida entre dois compradores que são a mesma pessoa. Pior: o e-mail que sai deixa de existir aqui, então se ele continuar ativo lá, a próxima sincronização de reparcelamento casa pelo e-mail antigo e recria a divisão.

O contrato do endpoint receptor (POST /v1/sync/user no ibft-backend) já foi acordado — payload {email, new_email}, respostas merged/updated/noop, mesmo token Bearer da sincronização de reparcelamento. Este spec cobre só o lado que dispara.

Os dois gatilhos já convergem num único ponto: a troca de e-mail (callback after_update em Customer) e a unificação (IbftCustomerMergeService) ambas enfileiram IbftEmailSyncJob, que chama IbftEmailSyncService#sync. Basta um destino novo ali.

Objetivos

  • Adicionar um destino em IbftEmailSyncService#sync que grava um OutboxEvent (event_name: "new_checkout.customer_sync") com o payload {email, new_email} que o serviço já monta hoje.
  • Reaproveitar integralmente o outbox existente: mesma tabela, mesmo job de despacho, mesmas 3 tentativas, mesma tela de admin — só um despachante novo registrado em Outbox::DispatchRegistry.
  • Despachar para POST #{NEW_CHECKOUT_API_URL}/v1/sync/user, com o mesmo Authorization: Bearer #{NEW_CHECKOUT_API_TOKEN} e o mesmo timeout do despachante de reparcelamento.
  • Disparar em toda unificação e toda troca de e-mail, sem filtrar por organização.
  • Documentar o contrato da Funcionalidade 2 em .project/docs/reference/payments/, que hoje só cobre o reparcelamento.

Fora de escopo

  • Filtro por organização (CITRG). Diferente do reparcelamento — onde o disparo é restrito à CITRG porque o pagamento pertence a um produto de uma empresa — a unificação é global por natureza: o mesmo cadastro pode ter pedidos em mais de uma empresa e todos mudam de dono na mesma operação. Filtrar aqui arrisca perder uma unificação em silêncio (por exemplo, quando o vínculo com a CITRG está no cadastro que está sendo desativado), que é exatamente o bug que esta feature existe para evitar. O contrato prevê a resposta noop justamente para absorver os casos sem correspondência do outro lado.
  • WebhookLog no enfileiramento. Os outros quatro destinos gravam um WebhookLog por sincronização; aqui o OutboxEvent já é o registro — com payload, status, tentativas, último erro e tela no admin. Um WebhookLog no enfileiramento registraria “enfileirei”, não “entreguei”.
  • Implementar o endpoint receptor (POST /v1/sync/user) — é responsabilidade do ibft-backend.
  • Mudar a lógica de unificação ou de troca de e-mail (IbftCustomerMergeService, callback do Customer) — o disparo entra no ponto que já existe, sem alterar o que já é propagado para CBTRG, Asaas, Apolo e Onion.
  • Caminho manual pelo console (run_other_services: false) continua sem propagar nada, inclusive para o ibft-backend — quem roda assim assume o sincronismo.

Mudanças

app/services/outbox/dispatchers/new_checkout_customer_sync.rb (novo)

  • Mesmo formato do NewCheckoutRepaymentSync: .call(payload) faz o POST via HTTParty para "#{ENV["NEW_CHECKOUT_API_URL"]}/v1/sync/user", com Content-Type: application/json, Authorization: Bearer #{ENV["NEW_CHECKOUT_API_TOKEN"]} e timeout de 3s.
  • Sem as envs configuradas, levanta erro claro — o job registra isso como falha da tentativa, como já faz para o reparcelamento.

app/services/outbox/dispatch_registry.rb

  • Uma entrada nova: "new_checkout.customer_sync" => Outbox::Dispatchers::NewCheckoutCustomerSync.

app/services/ibft_email_sync_service.rb

  • Novo método sync_new_checkout, chamado de #sync junto com os outros quatro destinos: OutboxEvent.create!(event_name: "new_checkout.customer_sync", payload: @payload).
  • @payload ({email:, new_email:}) já é montado no initialize e é exatamente o corpo que o contrato espera — nenhuma transformação nova.
  • Sem condicional: diferente de sync_onion (que só roda quando o cliente tem pagamento com aquela integração), este destino recebe toda unificação e toda troca de e-mail.

Sobre a transação: diferente do reparcelamento — onde o OutboxEvent nasce dentro da mesma transação que persiste o pagamento — aqui o evento é criado dentro do job, já depois do commit. Isso não enfraquece a garantia: o IbftEmailSyncJob é enfileirado dentro da transação da unificação e, como a fila é o próprio banco (GoodJob), uma transação revertida não deixa job nem evento. Uma falha dentro do job segue o mesmo comportamento que já vale hoje para os outros quatro destinos — este não é tratado de forma diferente.

Specs

  • spec/services/outbox/dispatchers/new_checkout_customer_sync_spec.rb (novo): URL, header de autenticação, timeout, retorno da resposta e erro quando as envs faltam.
  • spec/services/outbox/dispatch_registry_spec.rb: resolve o novo event_name para o despachante certo.
  • spec/services/ibft_email_sync_service_spec.rb (novo): #sync cria exatamente um OutboxEvent com o event_name e o payload corretos; cobre os dois gatilhos (unificação e troca de e-mail) chegando pelo mesmo caminho; confirma que nenhuma chamada HTTP sai no momento do enfileiramento.

Como verificar

  1. bundle exec rspec spec/services/outbox spec/services/ibft_email_sync_service_spec.rb — todos os exemplos passam.
  2. bundle exec rspec — suíte completa sem regressão (os outros quatro destinos continuam sendo chamados como antes).
  3. bundle exec rubocop app spec .project — 0 ofensas.
  4. Manualmente: alterar o e-mail de um Customer no admin e confirmar que outbox_events recebe uma linha pending com event_name: "new_checkout.customer_sync" e payload {email, new_email} — sem nenhuma chamada HTTP no caminho da requisição.
  5. Manualmente: rodar a ação “Unificar” no admin de clientes e confirmar o mesmo, com email = cadastro que sai e new_email = cadastro que fica.
  6. Com NEW_CHECKOUT_API_URL apontando para um servidor local, confirmar o POST em /v1/sync/user feito pelo job enfileirado com o evento, e a transição pending → sent.

Documentação

  • Novo .project/docs/reference/payments/new_checkout_customer_sync_endpoint.md com o contrato da Funcionalidade 2 (gatilhos, payload, respostas merged/updated/noop, autenticação e política de retry), seguindo o mesmo formato do contrato de reparcelamento. Hoje esse contrato não existe em nenhuma branch mergeada — só no material que originou esta spec.
  • Indexar o novo doc em .project/docs/README.md.
  • Nenhuma regra nova em .project/docs/rules/: a semântica de despacho (3 tentativas, sem distinção de erro, reenvio manual) é a mesma já coberta por R-004 — este spec só adiciona um consumidor do mesmo mecanismo.