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 doold_emailé encerrada e o e-mail dela renomeado, liberando o endereço - Consolidar a assinatura: vence a maior
expires_at, com osubscription_statuscorrespondente - 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 oconfirm()da spec20260902151259_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}}):
- Relações — percorre
User._meta.related_objects, ignorandoadmin.logentry. Para cada linha da origem (via_base_manager, para não perder soft-deletadas):- sem conflito de unicidade →
row.user = targete salva; - com conflito → aplica o resolver do model. Os quatro
*Progressusam “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 sejaOneToOneField(UserJourney,ResetPassword). A linha perdedora é removida, para a conta encerrada não deixar registro órfão.
- sem conflito de unicidade →
- Assinatura — vence a maior
expires_atentre origem e destino, levando junto osubscription_statusdessa conta;is_active = True. - Campos do perfil — destino preenche
name,phone,documentemanychat_subscriber_ida partir da origem apenas quando estiver vazio. - Trial — se o
subscription_statusresultante forenabled,UserTrial.mark_converted(target)marca os trials ainda em aberto (converted_atnulo), já incluindo os movidos da origem. Conversão já registrada nunca é sobrescrita (mesmo filtro daR-012). - Push devices — as linhas de
PushDevicesmovidas têm o campoemailatualizado para o e-mail do destino (o model guarda o e-mail próprio, eunique_togetherinclui esse campo). - Encerramento — a origem não passa por
User.delete()(que anonimizaria tudo): o e-mail é prefixado comonion-merge-<hex><epoch>-, o nome com[Desativado], omanychat_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:
- Caso real (Rozinilda) — CSV cria
rose_nariai76@hotmail.com; checkout comrose_nariai@76hotmail.comcria a conta paga comexpires_atem 2027; webhook de troca → sobra uma conta (rose_nariai76@hotmail.com) comexpires_at2027, e a conta de origem fica comdeleted_atpreenchido e e-mailonion-merge-<hex><epoch>-rose_nariai@76hotmail.com. - 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.
- Conflito de progresso — origem com aula concluída e destino com a mesma aula em
positionmenor → o destino fica com o registro concluído (uma única linha por(user, lesson)). - Conflito com destino mais avançado — origem menos avançada é descartada e o registro do destino permanece intacto.
- OneToOne — as duas contas têm
UserJourney→ destino mantém a sua e nada estoura. - Assinatura — quando a maior
expires_até a do destino, a assinatura dele é preservada. - Trial convertido — origem em trial com
UserTrialem aberto e destino com compra aprovada → após o merge oUserTrialdo destino temconverted_atpreenchido; conversão já registrada não é sobrescrita. - Webhook end-to-end —
POST /v1/webhooks/email-changeresponde202e o e-mail antigo deixa de existir como conta ativa. - 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 marcaconverted_at.project/docs/RULES.md— entrada deR-022.project/docs/README.md— entrada desta spec e da regra nova