Merge de contas na troca de e-mail quando o destino já existe

TLDR: Quando o webhook de troca de e-mail aponta para um e-mail que já pertence a outra conta, em vez de falhar em silêncio as duas contas são unificadas na conta do e-mail novo — relações movidas, assinatura consolidada e conta de origem encerrada com o e-mail renomeado.

Contexto

O checkout (ibft-api) sincroniza troca de e-mail chamando POST /webhooks/email-change, que enfileira change_user_email(old_email, new_email) (apps/webhooks/tasks.py:84).

Hoje a task faz user.email = new_email e, no IntegrityError da constraint unique de User.email, apenas loga e retorna {"found": True, "updated": False, "reason": "email_already_in_use"}. A view já respondeu 202 antes disso (apps/webhooks/views.py:87), então de fora o fluxo parece ter dado certo — é uma falha silenciosa.

O cenário real é comum: o cliente erra o e-mail no checkout (rose_nariai@76hotmail.com), a compra cria uma conta nova por get_or_create em save_checkout_ibft, e o e-mail correto (rose_nariai76@hotmail.com) já existe como conta — vinda do CSV de interesse (grant_access_from_csv) ou de trial. Quando o checkout corrige o e-mail, a troca não acontece: o usuário continua com duas contas e o acesso pago preso na conta errada.

Até agora isso era resolvido à mão por um script avulso (scripts/merge_accounts.py, já removido do repo), rodado caso a caso via manage.py shell. Ele já tinha a forma certa da solução — mover relações, consolidar expires_at, source.delete() anonimizando o e-mail e liberando o endereço, limpar cache de auth — mas era manual, sem teste, e não cobria OneToOneField nem UniqueConstraint: só checava unique_together.

Os testes em tests/webhooks/test_email_change_merge_journey.py (ainda não commitados) documentam o bug: afirmam updated=False, duas contas separadas e o acesso pago parado na conta do e-mail errado.

Objetivos

  • Quando o e-mail de destino já pertence a outra conta, unificar as duas contas em vez de abortar
  • A conta do new_email é a que sobrevive; a do old_email é encerrada e o e-mail dela renomeado, liberando o endereço
  • Consolidar a assinatura: vence a maior expires_at, com o subscription_status correspondente
  • Mover todas as relações da origem, resolvendo conflito de unicidade sem estourar IntegrityError
  • Em progresso (LessonProgress, AudioProgress, MeditationProgress, LiveProgress), o registro mais avançado vence
  • Marcar o trial em aberto como convertido quando a conta unificada terminar paga
  • Deixar a lógica num service testável, no lugar do script manual que era rodado à mão

Fora de escopo

  • Troca de e-mail pelo admin (templates/admin/accounts/user/change_form.html) continua só com o confirm() da spec 20260902151259_admin_email_change_warning
  • Nenhum endpoint novo, nenhuma mudança de contrato do webhook (continua 202 + task_id)
  • Sem desfazer merge (rollback manual não é previsto)
  • Sem migration: nenhum model muda

Decisões

Questão Decisão
Qual conta sobrevive A do new_email — é a que o usuário acessa (CSV/trial), preserva id, jornada e histórico. A do old_email é encerrada com o e-mail renomeado
Conflito de unicidade Progresso: vence o mais avançado. Demais models: mantém a linha do destino e descarta a da origem
“Mais avançado” completed_at preenchido vence completed_at nulo; havendo empate, vence o maior position
Descoberta das relações Híbrida: introspecção de User._meta.related_objects (model novo com FK para User não fica para trás) + registro explícito de resolvers por model
Trial Depois de mover os UserTrial, se a assinatura resultante ficar enabled, os trials em aberto do destino são marcados como convertidos — cobre tanto o trial que veio da origem quanto o que já era do destino
Encerramento da origem Mesma forma do IbftCustomerMergeService do checkout: e-mail vira onion-merge-<hex><epoch>-<e-mail original>, nome vira [Desativado] <nome>, conta inativada e soft-deletada. Telefone e documento são preservados; manychat_subscriber_id é limpo para não ficar duplicado entre as duas contas
Onde mora apps/accounts/services/account_merge.py, consumido pela task do webhook

Mudanças

PR 1 — service de merge

Arquivo O que muda
apps/accounts/services/__init__.py Novo — expõe merge_accounts
apps/accounts/services/account_merge.py Novo — o service
tests/accounts/test_account_merge.py Novo — testes unitários do service

merge_accounts(source, target) — recebe duas instâncias de User, roda dentro de transaction.atomic() e devolve um relatório ({"target_id", "source_id", "moved": {label: n}, "skipped": {label: n}}):

  1. Relações — percorre User._meta.related_objects, ignorando admin.logentry. Para cada linha da origem (via _base_manager, para não perder soft-deletadas):
    • sem conflito de unicidade → row.user = target e salva;
    • com conflito → aplica o resolver do model. Os quatro *Progress usam “mais avançado”: se a linha da origem vence, a linha do destino é removida e a da origem movida; senão a da origem é descartada. Qualquer outro model mantém a do destino.
    • a detecção de conflito cobre unique_together (engagements, EmailDispatch, Goal) e FK única, ou seja OneToOneField (UserJourney, ResetPassword). A linha perdedora é removida, para a conta encerrada não deixar registro órfão.
  2. Assinatura — vence a maior expires_at entre origem e destino, levando junto o subscription_status dessa conta; is_active = True.
  3. Campos do perfil — destino preenche name, phone, document e manychat_subscriber_id a partir da origem apenas quando estiver vazio.
  4. Trial — se o subscription_status resultante for enabled, UserTrial.mark_converted(target) marca os trials ainda em aberto (converted_at nulo), já incluindo os movidos da origem. Conversão já registrada nunca é sobrescrita (mesmo filtro da R-012).
  5. Push devices — as linhas de PushDevices movidas têm o campo email atualizado para o e-mail do destino (o model guarda o e-mail próprio, e unique_together inclui esse campo).
  6. Encerramento — a origem não passa por User.delete() (que anonimizaria tudo): o e-mail é prefixado com onion-merge-<hex><epoch>-, o nome com [Desativado] , o manychat_subscriber_id é limpo e a conta fica inativa e soft-deletada. O endereço original continua legível no fim do e-mail, e o endereço antigo fica livre para uso.

O cache de autenticação não precisa de passo próprio: o post_save de User (apps/accounts/signals.py) já invalida user_authentication_<id> e as duas contas são salvas durante o merge.

PR 2 — task e jornada

Arquivo O que muda
apps/webhooks/tasks.py change_user_email passa a mergear quando o destino existe
tests/webhooks/test_email_change_merge_journey.py Reescrito: os mesmos cenários passam a afirmar a unificação
tests/webhooks/test_views_tasks_urls.py Remove test_change_user_email_task_does_not_update_when_email_already_in_use, que afirmava a falha silenciosa (cenário coberto pela jornada)

change_user_email ganha o caminho de merge antes do rename:

  • destino não existe → renomeia, como hoje → {"found": True, "updated": True, "user_id": ...}
  • destino existe → merge_accounts(source=conta do old_email, target=conta do new_email) → {"found": True, "updated": True, "merged": True, "user_id": <id do destino>, "source_id": ..., "moved": {...}, "skipped": {...}}
  • origem não existe → inalterado ({"found": False, "old_email": ...})
  • origem e destino são a mesma conta (old_email == new_email) → no-op, {"found": True, "updated": False, "reason": "same_account"}

A busca do destino usa User.objects (alive only): conta já soft-deletada não é alvo de merge — o e-mail dela já foi anonimizado, então o rename simples funciona.

O IntegrityError deixa de ser o caminho esperado, mas o try/except continua como rede de segurança para corrida entre duas trocas simultâneas.

Como verificar

make run.test path=tests/accounts/test_account_merge.py e make run.test path=tests/webhooks/test_email_change_merge_journey.py, mais make run.ci_local.

Cenários cobertos:

  1. Caso real (Rozinilda) — CSV cria rose_nariai76@hotmail.com; checkout com rose_nariai@76hotmail.com cria a conta paga com expires_at em 2027; webhook de troca → sobra uma conta (rose_nariai76@hotmail.com) com expires_at 2027, e a conta de origem fica com deleted_at preenchido e e-mail onion-merge-<hex><epoch>-rose_nariai@76hotmail.com.
  2. Relações movidas — hábito, goal e push device da origem passam a responder pelo destino; o push device movido fica com o e-mail do destino.
  3. Conflito de progresso — origem com aula concluída e destino com a mesma aula em position menor → o destino fica com o registro concluído (uma única linha por (user, lesson)).
  4. Conflito com destino mais avançado — origem menos avançada é descartada e o registro do destino permanece intacto.
  5. OneToOne — as duas contas têm UserJourney → destino mantém a sua e nada estoura.
  6. Assinatura — quando a maior expires_at é a do destino, a assinatura dele é preservada.
  7. Trial convertido — origem em trial com UserTrial em aberto e destino com compra aprovada → após o merge o UserTrial do destino tem converted_at preenchido; conversão já registrada não é sobrescrita.
  8. Webhook end-to-end — POST /v1/webhooks/email-change responde 202 e o e-mail antigo deixa de existir como conta ativa.
  9. Sem destino existente — rename simples continua funcionando, sem merge.

Verificação manual em staging: rodar o webhook com um par de contas de teste e conferir no dash que sobrou uma conta com o acesso pago.

Documentação

  • .project/docs/rules/accounts/email_change_merges_existing_account.md — regra nova (R-022) em Given/When/Then, com os cenários de conflito e a conversão de trial
  • .project/docs/rules/trials/user_trial_converted_at_on_checkout_approval.md — nota de que o merge é o segundo caminho que marca converted_at
  • .project/docs/RULES.md — entrada de R-022
  • .project/docs/README.md — entrada desta spec e da regra nova