Skip to content

Registros de saída — cancelamento e exclusão de conta

ADR-0144 · desde 2026-09-19

Dois registros, um para cada evento. O que os une é o campo reasons: o motivo da saída, gravado no instante do evento em vez de deduzido depois.

Antes disto, o motivo não existia em lugar nenhum e o instante do cancelamento era calculado, devolvido na resposta e descartado — o relatório por período caía em updated_at, que muda a cada reprocessamento e não distingue cancelamento de sincronização.


O campo reasons

Presente nos dois registros, com a mesma forma:

"reasons": { "reason_1": "preço", "reason_2": "não usei o suficiente" }
Item Definição
Tipo objeto JSON, string → string
Chaves e valores livres, definidos pelo painel
Validação nenhuma de conteúdo — sem enum, sem lista fixa, sem chave obrigatória
Limites 20 chaves · 500 caracteres por chave e por valor
Estouro 422, com error.field apontando o campo — nunca truncamento silencioso
Padrão {} quando não informado

A API não reordena, não normaliza caixa e não traduz. O que chega é o que fica gravado e o que volta na leitura. O vocabulário é decisão do painel: congelá-lo na API faria de cada motivo novo um deploy.

Uma exceção, e só na camada anonimizada: antes de gravar em deletion_insights — que é permanente — e-mail, CPF e telefone nos valores são trocados por [redigido]. Não é validação nem vocabulário: o valor continua o que era, sem o identificador. Na camada identificada e na assinatura o texto fica íntegro. A redação é grosseira de propósito (um nome próprio no meio da frase passa), então o contrato continua pedindo lista fechada no app — isto é rede de segurança, não solução.


1. Cancelamento de assinatura

Campos novos em Subscription

Campo Tipo Observação
cancelled_at datetime \| null instante do evento
cancelled_at_estimated bool \| null true = data deduzida no backfill, não de primeira mão
cancelled_by "customer" \| "admin" \| "system" \| null deduzido da rota e do token
cancelled_by_id string \| null quem executou
reasons object padrão {}
cancellation_note string \| null texto livre do titular
form_response_id string \| null resposta ao questionário de saída (ADR-0154)

cancelled_by nunca é aceito do cliente. /customers/me/... grava customer; rotas /admin/... gravam admin com o id do administrador; webhook, sincronização diária, expurgo de conta e troca de plano gravam system.

⚠️ Troca de plano não é churn. O renew cancela a assinatura antiga e a marca system, sem motivo. Contá-la como customer inflaria a saída com renovação.

Corpo — opcional em todas as rotas

POST   /subscriptions/{subscription_id}/cancel
POST   /admin/asaas/subscriptions/{subscription_id}/cancel
POST   /admin/stripe/subscriptions/{subscription_id}/cancel
DELETE /customers/me/subscription
DELETE /stripe/subscription
DELETE /asaas/subscription
{
  "reasons": { "reason_1": "preço", "reason_2": "não usei o suficiente" },
  "note": "achei caro para a frequência com que assisto",
  "form_response_id": "66f0a1b2c3d4e5f60718293a"
}

Sem corpo, o cancelamento funciona exatamente como antes e grava reasons: {}. Nenhum app precisa mudar para continuar funcionando.

🔒 Mudança de autorização. GET /subscriptions e POST /subscriptions/{id}/cancel passaram a exigir user_type='admin' (ADR-0145). Estavam marcadas como administrativas mas a autorização dependia só da lista do perfil. Nenhum app muda de comportamento: customer tem type_user vazio e já recebia 401 do RBAC por nome. O caminho do titular segue DELETE /customers/me/subscription.

Ler por período

Já funciona, sem rota nova — cancelled_at entra no query que a listagem já aceita:

GET /subscriptions?query={"status":"cancelled","cancelled_at":{"$gte":"2026-09-13T00:00:00Z","$lte":"2026-09-18T23:59:59Z"}}

O campo é indexado.

O histórico: cancelled_at_estimated (ADR-0156)

Das 1.428 canceladas, só 15 tinham cancelled_at — o campo nasceu em 19/09. O backfill de 21/09 preencheu 974 e deixou 439 nulas, de propósito.

cancelled_at cancelled_at_estimated O que significa Quantas
preenchido false/ausente data de primeira mão (evento de cancelamento) 15 + 3
preenchido true deduzida da última cobrança apagada na Asaas 971
null null não há evidência; use o fallback de hoje 439

No painel: quando cancelled_at_estimated for true, exiba a data como aproximação — é o ~ de vocês, agora vindo do dado em vez de uma heurística de tela.

Por que updated_at não virou cancelled_at. Parecia o caminho óbvio e a medição desmentiu: 469 das 1.413 caem em minutos com 5 ou mais assinaturas — 80 num único minuto de 29/07, 71 em 26/04. É carimbo de operação em lote, porque updated_at é escrito por qualquer sync que só encoste no documento. Gravá-lo criaria picos de churn em datas onde ninguém cancelou, com aparência de dado exato. Nulo declarado é melhor que data errada — e a tela já cai em updated_at quando o campo é nulo, então nada se perdeu.


2. Exclusão de conta

O registro é criado pelo backend, dentro do fluxo de exclusão, nunca por chamada separada do painel. Uma exclusão gera um registro em cada coleção, sempre — inclusive quando não há motivo informado.

Por que duas coleções

A — account_deletions B — deletion_insights
Identifica a pessoa? Sim Não
Base legal Obrigação fiscal + exercício regular de direitos (art. 16, I) Anonimização (art. 16, IV)
Prazo 5 anos, depois expurgo Indefinido
Serve para fisco, auditoria, defesa churn, relatório de motivos
Tem o motivo? Sim — prova de que o titular pediu Sim — estatística

Em B não há customer_id, e-mail, CPF nem hash deles: hash é pseudonimização, e dado pseudonimizado continua sujeito ao pedido de eliminação. B guarda mês (deleted_month), não data, e faixa de valor (total_paid_range), não valor — data e valor exatos, combinados com plano, reidentificam num universo pequeno. B também não guarda note, porque texto livre costuma trazer nome e telefone escritos pelo próprio titular.

O mesmo cuidado alcança os campos que o painel não vê: o _id de B é carimbado com o início do mês, e os campos de auditoria da base (register_date, autor) não são gravados ali. Um ObjectId comum embute o segundo exato da criação, e isso casaria os dois registros 1:1 — inclusive depois do expurgo de 5 anos, quando já não haveria como desfazer a ligação.

Corpo

DELETE /customers/me    → `reasons`, `note` e `form_response_id` somados ao corpo existente
DELETE /customers       → passa a aceitar corpo opcional com os três
{
  "password": "senha_atual",
  "reasons": { "reason_1": "não uso mais" },
  "note": "",
  "form_response_id": "66f0a1b2c3d4e5f60718293a"
}

password continua obrigatório em /customers/me; os três novos são opcionais.

form_response_id — ligar a saída ao questionário (ADR-0154)

O app busca o questionário em GET /forms/purpose/{app_cancel_subscription | app_delete_account} (204 = não há, siga direto), envia a resposta em POST /forms/{slug}/responses e manda aqui o _id que ela devolveu.

É o que preserva o que não cabe em reasons: escala, múltipla escolha, anexo.

Onde ele chega: na assinatura cancelada e em account_deletions (camada identificada, 5 anos). Não entra em deletion_insights — aquela coleção é anônima e permanente, e um ponteiro para a resposta da pessoa desfaria a anonimização que é a base legal dela.

Resposta — transparência do art. 9

{
  "message": "Conta excluida com sucesso.",
  "deleted_at": "2026-09-19T14:02:11+00:00",
  "retained": {
    "reason": "registros fiscais das cobranças",
    "legal_basis": "obrigação legal tributária",
    "until": "2031-09-19"
  },
  "details": { "subscriptions_cancelled_count": 1 }
}

O app deve mostrar retained na tela de confirmação: é o que informa ao titular o que permanece e por quê.

⚠️ A exclusão é atômica. Os dois registros e a remoção do cadastro commitam numa transação só. 500 significa que nada foi apagado — a conta continua e retentar é seguro (assinatura já cancelada no gateway é ignorada na segunda passada). Nunca existe conta apagada sem registro, nem registro sem conta apagada.

O que é apagado na exclusão

O pedido lista o que deve sair: histórico de exibição, my_list, preferências, endereço, telefone, nacionalidade, avatar e códigos promocionais resgatados.

O quê Onde vive Quando sai
my_list, endereço, telefone, nacionalidade, promo resgatados campos do cadastro com o delete_one, na transação
histórico de exibição (watch_history) coleção própria na mesma transação
avatar arquivo no GridFS depois do commit, best-effort

O avatar sai por último e fora da transação porque o arquivo não volta: só faz sentido apagá-lo quando a conta já saiu de fato. Se falhar, sobra um arquivo órfão e a falha vai para o log — melhor do que derrubar a exclusão inteira por causa de uma imagem.

⚠️ Ingresso e pedido não são apagados. ticket_orders é registro fiscal, pela mesma razão que sustenta o billing_snapshot. Ticket pertence ao evento tanto quanto ao titular — apagá-lo desfalcaria a contagem de um evento que ainda vai acontecer. Registros de acesso (IP, data/hora) seguem o Marco Civil (art. 15, 6 meses), com prazo próprio, independente da exclusão.

A resposta traz a contagem em details: watch_history_deleted e avatar_deleted.

Expurgo

Rotina diária no worker apaga de account_deletions o que passou de retention_until (deleted_at + 5 anos). A contraparte anonimizada permanece. Retenção sem prazo cumprido é retenção indevida — por isso o expurgo nasce junto com a coleção, não depois.

Ler no painel

🔒 As três rotas sobem inacessíveis. O RBAC é fail-closed (ADR-061): handler que não está em nenhum users_type leva 401, antes do guard de admin. Liberar é passo de rollout: scripts/setup_registros_de_saida.py --apply.

GET /admin/account-deletions?start=&end=&current_page=&qty_docs_page=
GET /admin/account-deletions/{_id}
GET /admin/deletion-insights?start=&end=&group_by=reason|month

As duas primeiras seguem o envelope docs + pagination.qty_total_docs das demais. A terceira, com group_by, devolve a contagem pronta para gráfico:

{
  "group_by": "reason",
  "total_registros": 42,
  "grupos": [{ "valor": "preço", "total": 18 }, { "valor": "pouco uso", "total": 11 }]
}

⚠️ Com group_by=reason, a soma dos grupos pode passar de total_registros: uma saída com três motivos entra nas três contagens. A pergunta é "quantas vezes este motivo apareceu", não "quantas pessoas saíram" — por isso os dois números vêm separados.


3. by_reason do churn deixou de inferir

GET /admin/finance/churn prometia que os motivos particionavam o total de cancelamentos. Não particionavam: medido em 19/09 para 13→18/09, a soma deu 338 contra 205 cancelamentos, porque payment_failure contava assinaturas suspended e expired contava expired — nenhuma das duas é cancelamento.

Agora são dois blocos, de duas populações:

{
  "by_reason": {
    "customer_cancelled": 0, "admin_cancelled": 0,
    "system_cancelled": 0, "chargeback": 0, "unknown": 205
  },
  "by_suspension_reason": { "payment_failure": 133, "expired": 0 }
}
  • by_reason cobre só cancelled, e os cinco somam o total sob qualquer filtro.
  • by_suspension_reason é a outra população — suspensão e expiração.
  • unknown é o histórico anterior à ADR-0144, que não tem cancelled_by. Classificá-lo como voluntário repetiria o erro que esta mudança corrige. O número fecha, e a parte que ninguém mediu aparece nomeada em vez de diluída.

O campo cai sozinho conforme os cancelamentos novos vão sendo registrados.

⚠️ admin_cancelled vinha zero porque nenhuma rota de produção grava ADMIN.SUBSCRIPTION.CANCEL no log de auditoria — o regex existia, o emissor não. A partir de agora o valor sai de cancelled_by, e o log segue como fallback só para o histórico já registrado por script.