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:
| 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
renewcancela a assinatura antiga e a marcasystem, sem motivo. Contá-la comocustomerinflaria 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 /subscriptionsePOST /subscriptions/{id}/cancelpassaram a exigiruser_type='admin'(ADR-0145). Estavam marcadas como administrativas mas a autorização dependia só da lista do perfil. Nenhum app muda de comportamento: customer temtype_uservazio e já recebia 401 do RBAC por nome. O caminho do titular segueDELETE /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_atnão viroucancelled_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, porqueupdated_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 emupdated_atquando 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 obilling_snapshot.Ticketpertence 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_typeleva 401, antes do guard de admin. Liberar é passo de rollout:scripts/setup_registros_de_saida.py --apply.
GET /admin/account-deletions?start=&end=¤t_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 detotal_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_reasoncobre 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 temcancelled_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_cancelledvinha zero porque nenhuma rota de produção gravaADMIN.SUBSCRIPTION.CANCELno log de auditoria — o regex existia, o emissor não. A partir de agora o valor sai decancelled_by, e o log segue como fallback só para o histórico já registrado por script.