Status do lado checkout-api: implementado, de forma assíncrona (IbftEmailSyncService#sync_new_checkout grava um OutboxEvent; Outbox::DispatchEventsJob, enfileirado junto com o evento, faz o POST — ver regra de despacho do outbox). Aguardando o ibft-backend expor o endpoint em staging para validação ponta a ponta.

Endpoint de unificação de cliente — ibft-backend

TLDR: o checkout-api manda dois e-mails — o que sai e o que fica. O que fazer com isso é decisão do ibft-backend: se o e-mail que fica já existe lá, é fusão; se não existe, é troca de e-mail. Mesmo host e mesmo token do contrato de reparcelamento — muda só o path.

Por que este endpoint existe

No checkout-api o cliente é um cadastro por e-mail, com unicidade garantida na aplicação, 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 — comprou com um e-mail, depois com outro — 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 hoje para os sistemas a jusante, e o formato varia: CBTRG e Onion recebem um webhook com esses dois e-mails, o Asaas é atualizado pela API do gateway e o Apolo por REST, buscando o usuário pelo e-mail antigo. O ibft-backend entra no primeiro grupo — o mesmo par de e-mails — porque 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.

E o e-mail que sai deixa de existir do lado de cá. Se ele continuar ativo lá, a próxima sincronização de reparcelamento desse cliente casa pelo e-mail antigo e recria a divisão.

A chamada é global, não leva company_slug. O mesmo cadastro pode ter pedidos em mais de uma empresa, e todos mudam de dono na mesma operação.

Quando dispara

Gatilho no checkout-api O que vai no payload
Unificação de dois cadastros — ação “Unificar” no admin ou IbftMergeCustomersJob email = cadastro que sai, new_email = cadastro que fica
Troca de e-mail de um cadastro existente email = endereço antigo, new_email = endereço novo

Os dois casos mandam o mesmo payload. A origem não distingue um do outro e o ibft-backend não precisa de um flag para isso: a diferença é observável lá, pelo new_email já existir ou não.

O envio é assíncrono: o job é enfileirado dentro da transação da unificação e, como a fila é o próprio banco (GoodJob), só fica executável depois do commit — transação revertida não gera chamada. Diferente do Onion, que só recebe quando o cliente tem pagamento com aquela integração, este endpoint recebe toda unificação e toda troca de e-mail: filtrar na origem exigiria ela saber o que já foi sincronizado lá, e é justamente para isso que existe a resposta noop.

Há um caminho manual de unificação pelo console que pula a propagação (run_other_services: false). Quem roda assim assume o sincronismo — nenhuma chamada sai.

A troca de e-mail simples não é opcional: o e-mail é a chave de deduplicação do comprador nos dois lados, e um endereço que muda só aqui faz o próximo reparcelamento criar um comprador duplicado lá.

Requisição

Endpoint

POST /v1/sync/user Content-Type: application/json

É para onde a origem já aponta. Fica sob o mesmo prefixo /v1/sync/ do reparcelamento, e precisa ser estável — mudança de path exige deploy coordenado dos dois lados.

Autenticação

Mesmo token do contrato de reparcelamento, no header Authorization: Bearer <token>. Do lado da origem, os dois contratos leem as mesmas variáveis de ambiente (NEW_CHECKOUT_API_URL e NEW_CHECKOUT_API_TOKEN) — nenhuma configuração nova para este endpoint. Mesmas regras: só HTTPS, nunca em query string, fora do log de aplicação, rotacionável sem redeploy coordenado dos dois lados. Fail-closed: sem token configurado no servidor, rejeitar a requisição em vez de liberar.

JSON de entrada

json { "email": "joao.silva@example.com", "new_email": "joao@example.com" }

Campo Tipo Obrig. Descrição
email string sim Endereço que sai: o cadastro desativado na unificação, ou o e-mail antigo na troca
new_email string sim Endereço que fica: o cadastro correto, ou o e-mail novo

Invariante garantida pela origem: email é diferente de new_email, e os dois vêm em minúsculas.

O que precisa ser feito

Em resultado, não em código:

  1. Autenticar pelo mesmo token compartilhado. Sem token configurado no servidor, rejeitar.
  2. Localizar o comprador pelo email. Não existe lá → nada a fazer, responder 200 com noop: é o caso comum de um cliente que nunca teve pedido sincronizado.
  3. Decidir pelo new_email:
    • já existe um comprador com ele → é fusão. Mover para esse comprador tudo que aponta para o do email — pedidos, parcelas, vínculos com empresa, o que mais houver — e liberar o email;
    • não existe → é troca. O comprador do email passa a ter new_email.
  4. Liberar o endereço que saiu: depois da operação, email não pode mais resolver para um comprador ativo. Desativar, renomear, apagar — a forma é decisão de vocês, o resultado não.
  5. Ser atômico: ou tudo muda de dono, ou nada muda. Um merge pela metade parte a dívida do cliente em dois compradores sem deixar rastro de que são a mesma pessoa.
  6. Ser idempotente, e aqui isso sai de graça: depois da primeira chamada o email não resolve mais, então o retry cai no passo 2 e não faz nada. Por isso o payload não carrega chave de idempotência.
  7. Não produzir nenhum efeito colateral: nenhum e-mail ao cliente, nenhuma confirmação de endereço, nenhum reset de senha, nenhuma liberação ou revogação de acesso, nenhuma comissão, nenhum evento de pedido. A operação é administrativa e os pedidos envolvidos são de compras passadas.
  8. Não chamar o Asaas. O checkout-api já atualiza o cliente no gateway na mesma operação; uma segunda chamada lá só gera divergência.

Resposta

200 OK

json { "status": "merged", "customer_code": "6b1f3a92-5c4d-4e77-8a10-b2c3d4e5f6a7", "orders_moved": 3 }

Campo Descrição
status merged (fusão), updated (troca de e-mail) ou noop (o email não existe lá)
customer_code Código do comprador que ficou. null no noop
orders_moved Pedidos que mudaram de dono. 0 em updated e em noop

401 Unauthorized — token ausente ou inválido

json { "detail": "Não autorizado." }

422 Unprocessable Entity

Mesmo formato do endpoint de reparcelamento — detail, code e errors por campo. Um code só aqui: invalid_payload, para e-mail ausente, malformado ou igual ao outro.

500 Internal Server Error

json { "detail": "Erro interno." }

Política de retry da origem

A mesma do contrato de reparcelamento: 4xx (exceto 429) é falha definitiva, com alerta e intervenção manual. Do lado do checkout-api, esse retry é feito pelo outbox local — até 3 tentativas, espaçadas em 5 segundos pelo retry_on do job.

O que isso exige de cada lado

De vocês: o endpoint, a decisão fusão-ou-troca pelo new_email, e a garantia de que mover o dono do pedido não dispara nada — nem notificação, nem acesso, nem comissão.

Da gente: disparar do mesmo ponto em que hoje sincronizamos e-mail com Asaas, CBTRG, Apolo e Onion (IbftEmailSyncService), reaproveitando o payload que já é montado lá — feito, via OutboxEvent com event_name: "new_checkout.customer_sync".