Skip to content
consumer-appPUBLIC

Devocional Diário via WhatsApp

Devocionais diários em texto e áudio entregues direto no WhatsApp do assinante.

39,174 lines335,489 words46 sectionsgenerated in 3h 38mAug 25, 2026

Palavra Diária — Especificação Técnica Completa #

Devocional cristão diário em texto e áudio, entregue no WhatsApp. Modelo freemium, cobrança recorrente pela Asaas, painel próprio do assinante e painel administrativo editorial. Mercado brasileiro, português do Brasil.

Versão 1.0 — Final

Visão Geral do Documento #

Este documento especifica, de ponta a ponta, um sistema que entrega um devocional cristão todos os dias às 06:00 (America/Sao_Paulo) no WhatsApp do assinante, em texto e em áudio narrado. O plano gratuito recebe o devocional em texto uma vez por semana, aos domingos. O plano pago recebe texto e áudio todos os dias.

Ele foi escrito para ser executado por um agente de IA trabalhando sozinho, sem poder fazer perguntas. Toda decisão de produto, de arquitetura, de dados, de segurança e de operação já está tomada e registrada aqui. Onde havia mais de um caminho razoável, a escolha foi feita e justificada; onde havia restrição externa — as regras de mensagem do WhatsApp, o modelo de cobrança da Asaas, a LGPD — a restrição é descrita antes da solução que a atende.

Três decisões estruturam o resto do documento e vale conhecê-las antes de ler:

  1. A entrega do áudio contorna uma restrição real da plataforma. A API do WhatsApp não permite enviar áudio como primeira mensagem do dia, e não permite texto com parágrafos dentro de um template. O sistema resolve isso com o desenho "Template + Janela": um convite curto por template às 06:00 abre a janela de atendimento de 24 horas, e o devocional completo e o áudio seguem como mensagens livres dentro dessa janela. A Seção 17 descreve o desenho e o que aconteceria em cada alternativa descartada.
  2. Falha de pagamento encerra o acesso pago na hora, sem carência. Essa é uma regra de negócio explícita do produto, não um efeito colateral de implementação. Ela é diferente do cancelamento voluntário, em que o assinante mantém o acesso até o fim do período que já pagou. A Seção 13 separa os dois casos com linhas do tempo.
  3. Cada assunto tem uma seção dona. O schema do banco é da Seção 6; o contrato de API é da Seção 7; a matriz de planos é da Seção 13; as variáveis de ambiente são da Seção 26. As demais seções referenciam a dona em vez de repetir a definição, para que nada divirja.

Como ler. Um executor deve começar pela Seção 30, que explica a ordem de leitura e as regras invioláveis, e depois seguir o plano de milestones da Seção 28. Um leitor de negócio encontra o essencial nas Seções 1, 2 e 13. A Seção 1 lista as decisões que podem ser trocadas antes da execução, cada uma já com um padrão que vale caso ninguém responda.

Índice #

  1. Antes de Começar — Decisões Configuráveis
  2. Visão Geral do Produto e Objetivos
  3. Personas, Papéis e Matriz de Permissões
  4. Arquitetura e Stack Tecnológica
  5. Convenções de Código e Padrões de Projeto
  6. Modelo de Dados e Schema do Banco
  7. Design de API — Contratos, Erros e Padrões
  8. Autenticação, Sessões e Autorização
  9. Landing Page e Página de Vendas — Especificação Funcional
  10. Copy Aprovado — Página de Vendas e Mensagens ao Assinante
  11. Cadastro, Opt-in e Onboarding do Assinante
  12. Integração de Pagamentos — Asaas
  13. Assinaturas: Ciclo de Vida, Inadimplência e Entitlements
  14. Painel do Assinante
  15. Painel Administrativo e Calendário Editorial
  16. Geração de Áudio (TTS) e Pipeline de Mídia
  17. Integração WhatsApp Business Cloud API
  18. Motor de Envio Diário
  19. Catálogo de Mensagens e Fluxos Conversacionais
  20. Cancelamento, Opt-out e Retenção
  21. Dashboard de Métricas e Analytics
  22. Segurança, Privacidade e LGPD
  23. Observabilidade, Logs, Métricas e Alertas
  24. Estratégia de Testes e QA
  25. Infraestrutura, Deploy e CI/CD
  26. Registro de Configuração e Variáveis de Ambiente
  27. Tratamento de Erros, Resiliência e Runbooks Operacionais
  28. Plano de Execução e Milestones
  29. Critérios de Aceitação
  30. Instruções para o Agente Executor
  31. Glossário e Referências Externas

1. Antes de Começar — Decisões Configuráveis #

Esta seção existe para uma finalidade única: permitir que o dono do produto ajuste o serviço antes da primeira linha de código, sem abrir nenhuma outra seção do documento. Cada item abaixo é uma pergunta fechada, com um padrão já decidido. Nada aqui bloqueia a execução.

1.1 Como usar esta seção #

  1. Leia as perguntas de 1.3 a 1.30. Responda apenas as que você quer mudar.
  2. O que não for respondido entra em produção com o padrão indicado.
  3. O executor registra cada resposta no local indicado em "Onde impacta". Há três locais possíveis, e a escolha do local não é livre:
    • Variável de ambiente — valores de infraestrutura, credenciais e chaves de operação. Catálogo normativo na Seção 26.
    • Tabela settings — valores de negócio que o administrador pode mudar em produção sem novo deploy (preço exibido, dia do envio gratuito, limites de reenvio, texto de consentimento). Estrutura da tabela na Seção 6.
    • Código versionado — identidade visual, textos de interface, tokens de tema.
  4. Nenhuma resposta desta seção altera o schema do banco, os contratos de API ou a máquina de estados da assinatura. Se uma resposta parecer exigir isso, ela está fora do escopo do MVP e deve ser tratada como pedido de mudança, não como configuração.

1.2 Regra de não bloqueio #

Ausência de resposta nunca interrompe a execução. O executor aplica o padrão listado, anota a decisão no README.md do repositório sob o título "Decisões configuráveis aplicadas" e segue para a próxima tarefa. Perguntar ao dono do produto e esperar resposta é comportamento proibido nesta fase.

1.3 Q01 — Nome comercial do serviço #

  • Pergunta: qual é o nome comercial exibido ao público?
  • Por que importa: aparece na landing, nos e-mails, no nome de exibição do WhatsApp, nos termos de uso e no cabeçalho dos painéis. Trocar depois exige revisar textos aprovados e reenviar templates para aprovação da Meta.
  • Onde impacta: Seção 9 (landing), Seção 10 (copy aprovado), Seção 17 (nome de exibição do remetente). Código: apps/web/src/i18n/pt-BR.ts, chave brand.name.
  • Padrão: Palavra Diária. O identificador técnico do monorepo e dos artefatos permanece palavra-diaria mesmo que o nome comercial mude, para evitar renomeação de imagens Docker, buckets e filas.

1.4 Q02 — Domínio e subdomínios #

  • Pergunta: qual domínio de produção e qual a divisão de subdomínios?
  • Por que importa: define emissão de certificado TLS, cookies de sessão, URLs de webhook registradas na Asaas e na Meta, e o canonical de SEO. Mudar depois exige reconfigurar os dois provedores externos e invalidar sessões.
  • Onde impacta: Seção 9 (SEO e rotas públicas), Seção 12 (URL do webhook da Asaas), Seção 17 (URL do webhook da Meta), Seção 25 (Caddy e TLS), Seção 26 (APP_URL).
  • Padrão: palavradiaria.com.br para o site público, app.palavradiaria.com.br para os painéis do assinante e administrativo, api.palavradiaria.com.br para webhooks e API pública. Cookie de sessão com prefixo __Host-, portanto sem Domain e restrito ao host que o emitiu (ver Seção 8).

1.5 Q03 — Paleta de cores #

  • Pergunta: quais são as cores da marca?
  • Por que importa: todo o tema é gerado a partir de tokens; contraste insuficiente reprova os critérios de acessibilidade WCAG 2.2 AA exigidos na Seção 9.
  • Onde impacta: Seção 9 e Seção 14. Código: apps/web/src/styles/theme.css (tokens CSS consumidos pelo Tailwind) e apps/web/src/components/ui/*.
  • Padrão: primária #1F4E5F (azul-petróleo), secundária #C8873B (âmbar), fundo claro #FAF8F4, texto #1A1A1A, sucesso #2E7D5B, erro #B3261E, aviso #8A6100. Todos os pares texto/fundo validados com contraste mínimo de 4,5:1 para texto normal e 3:1 para texto grande e elementos de interface. Tema escuro derivado por inversão de luminância, não por cores novas.

1.6 Q04 — Tipografia #

  • Pergunta: quais famílias tipográficas, e elas são auto-hospedadas?
  • Por que importa: fonte externa adiciona uma requisição de terceiro na rota crítica da landing e conflita com a meta de desempenho da página de vendas (Seção 9). Fonte de leitura ruim prejudica o texto do devocional no painel.
  • Onde impacta: Seção 9 (desempenho e SEO). Código: apps/web/app/layout.tsx e apps/web/src/styles/theme.css.
  • Padrão: títulos em Fraunces (serifada, peso 600), corpo em Inter (peso 400/600), ambas auto-hospedadas em apps/web/public/fonts/ com font-display: swap e subset latin-ext. Tamanho base de 17 px no corpo do devocional, altura de linha 1,7.

1.7 Q05 — Logo, favicon e imagem de compartilhamento #

  • Pergunta: existe logo pronto? Em quais formatos?
  • Por que importa: ausência de logo trava a landing, o cabeçalho dos painéis, o og:image e o perfil comercial do WhatsApp.
  • Onde impacta: Seção 9 (Open Graph), Seção 17 (perfil comercial). Código: apps/web/public/brand/logo.svg, logo-mark.svg, favicon.ico, og-default.png.
  • Padrão: marca tipográfica gerada com a tipografia de Q04 sobre a cor primária de Q03, mais um símbolo de sol nascente em traço único. Entregues em SVG, PNG 512×512 (perfil do WhatsApp exige imagem quadrada) e PNG 1200×630 para og:image.

1.8 Q06 — Preço do plano mensal #

  • Pergunta: qual o valor mensal em reais?
  • Por que importa: define o value enviado à Asaas na criação da assinatura, o MRR e a viabilidade do custo por assinante. Alterar o preço não altera assinaturas já ativas.
  • Onde impacta: Seção 12 (criação da assinatura na Asaas), Seção 13 (planos vigentes), Seção 21 (MRR). Dados: registro plan_monthly na tabela plans (Seção 6), semeado em packages/db/prisma/seed.ts.
  • Padrão: R$ 19,90/mês, cobrança recorrente, sem período de teste gratuito.

1.9 Q07 — Preço do plano anual #

  • Pergunta: qual o valor anual e qual o desconto implícito?
  • Por que importa: o plano anual reduz o efeito de cartão negado e de churn involuntário, que é alto no Brasil, mas antecipa receita e aumenta o custo de reembolso.
  • Onde impacta: Seção 12, Seção 13, Seção 21. Dados: registro plan_annual em plans.
  • Padrão: R$ 199,00/ano, equivalente a R$ 16,58/mês, desconto de 16,7% sobre doze mensalidades. O número 16,7% é interno: o rótulo exibido ao público é sempre arredondado para baixo, portanto "economize 16%", para nunca prometer mais do que se entrega (regra de arredondamento na Seção 9.5). A página de planos usa "economize 16%" e "economize 2 meses" como chamada secundária.

1.10 Q08 — Formas de pagamento aceitas #

  • Pergunta: aceitar cartão de crédito, PIX, ambos?
  • Por que importa: cartão permite recorrência automática; PIX exige gerar uma cobrança nova a cada ciclo e depende de ação do assinante, o que muda o desenho de lembretes.
  • Onde impacta: Seção 12 (fluxos de checkout), Seção 13 (renovação e inadimplência), Seção 19 (mensagens de lembrete).
  • Padrão: CREDIT_CARD e PIX. Boleto fica fora do MVP por causa do prazo de compensação, que é incompatível com a regra de revogação imediata da Seção 13.

1.11 Q09 — Dia da semana do envio gratuito #

  • Pergunta: em que dia o assinante do plano gratuito recebe seu devocional semanal?
  • Por que importa: define o pico de volume semanal, o custo de mensagens do dia e a janela em que a conversão para o plano pago é mais provável.
  • Onde impacta: Seção 13 (entitlements), Seção 18 (cálculo do lote diário). Dados: chave send.free_tier_weekday na tabela settings (catálogo na Seção 26.8.1). Não existe variável de ambiente equivalente: o dia do envio gratuito é decisão de negócio e vive exclusivamente em settings.
  • Padrão: domingo (0, no padrão em que domingo é 0 e sábado é 6). Exemplo: em um cenário de 7.000 assinantes gratuitos e 3.000 pagos, o domingo dispara 10.000 envios e os demais dias, 3.000.

1.12 Q10 — Horário do envio diário #

  • Pergunta: a que horas o devocional chega?
  • Por que importa: é a promessa central do produto. Também define o horário do planejamento do lote e a janela de manutenção, que não pode colidir com o envio.
  • Onde impacta: Seção 18 (motor de envio), Seção 23 (alertas de atraso). Dados: chaves send.daily_hour_local e send.plan_offset_minutes em settings (Seção 26.8.1).
  • Padrão: 06:00 no fuso America/Sao_Paulo, com o job de planejamento do lote às 05:40 local. O fuso é sempre o nomeado America/Sao_Paulo; deslocamento numérico fixo é proibido (Seção 30.3, regra 13). O sistema não oferece horário personalizado por assinante — isso é explicitamente fora de escopo (ver Seção 2.7).

1.13 Q11 — Versão bíblica utilizada #

  • Pergunta: qual tradução da Bíblia será citada nos devocionais?
  • Por que importa: traduções modernas amplamente conhecidas são obras licenciadas. Publicar versículos delas em um produto pago, sem contrato, é risco jurídico real.
  • Onde impacta: Seção 15 (editor de conteúdo, campo bible_version), Seção 22 (risco jurídico e direitos autorais). Dados: chave content.bible_version_default em settings (Seção 26.8.1).
  • Padrão: ALMEIDA_1911 — a Almeida Revista e Corrigida na edição de 1911, em domínio público — com BIBLIA_LIVRE (Almeida Livre / Bíblia Livre) como alternativa aceita. A sigla ARC é proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911. A atribuição impressa em toda entrega é João 3:16 (Almeida 1911) ou João 3:16 (Bíblia Livre), nunca uma sigla ambígua. O campo bible_version é obrigatório em todo devocional, aceita apenas os valores da lista fechada e o painel administrativo bloqueia a publicação se estiver vazio. Adotar uma tradução licenciada exige contrato assinado antes da publicação, e essa decisão não é configuração — é mudança de escopo.

1.14 Q12 — Provedor de TTS e voz #

  • Pergunta: qual provedor de síntese de voz e qual voz específica?
  • Por que importa: a voz é a identidade sonora do produto. Trocar depois de o acervo estar publicado gera inconsistência entre devocionais antigos e novos.
  • Onde impacta: Seção 16 (pipeline de áudio). Configuração: TTS_PROVIDER, TTS_VOICE_ID, TTS_MODEL_ID (Seção 26).
  • Padrão: ElevenLabs como provedor primário, modelo multilíngue corrente, voz feminina brasileira de timbre grave e ritmo pausado, stability=0.45, similarity_boost=0.75, saída mp3_44100_128. Google Cloud TTS como fallback automático após três falhas consecutivas do primário. O provedor efetivamente usado é gravado em cada ativo de áudio, para auditoria de custo e de timbre.

1.15 Q13 — Número e nome de exibição do WhatsApp #

  • Pergunta: qual número de telefone e qual nome de exibição comercial?
  • Por que importa: o nome de exibição passa por aprovação da Meta e não pode ser trocado livremente. O número é a identidade do remetente; perder acesso a ele é perder o canal.
  • Onde impacta: Seção 17 (operação do canal), Seção 27 (runbook de bloqueio de número). Configuração: WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_BUSINESS_ACCOUNT_ID.
  • Padrão: um número novo, dedicado e exclusivo do serviço, nunca um número já usado em aplicativo pessoal ou WhatsApp Business comum. Nome de exibição igual ao nome comercial de Q01. O número não recebe ligações e a mensagem de ausência informa o canal de suporte.

1.16 Q14 — Política de reenvio manual pelo painel #

  • Pergunta: quantas vezes por dia o assinante pode pedir o reenvio do devocional do dia?
  • Por que importa: cada reenvio é uma mensagem paga se estiver fora da janela de 24 horas. Limite alto abre espaço para abuso e para custo imprevisível.
  • Onde impacta: Seção 13 (entitlements) e Seção 14 (painel do assinante). Dados: chaves resend.free_daily_limit e resend.paid_daily_limit em settings (Seção 26.8.1).
  • Padrão: 1 reenvio por dia no plano gratuito e 3 por dia no plano pago, contados por dia de calendário em America/Sao_Paulo e reiniciados à meia-noite local. Exemplo: um assinante pago que pediu reenvio às 07:10, 12:30 e 19:45 recebe RESEND_LIMIT_REACHED na quarta tentativa do mesmo dia e volta a ter três créditos às 00:00 do dia seguinte.

1.17 Q15 — Limite do acervo no plano gratuito #

  • Pergunta: quantos dias de histórico o assinante gratuito vê no painel web?
  • Por que importa: o acervo é um dos dois principais motivos de conversão, junto com o áudio. Um limite generoso demais remove o incentivo de assinar.
  • Onde impacta: Seção 13 (entitlements) e Seção 14 (listagem do acervo). Dados: chave archive.free_days em settings (Seção 26.8.1).
  • Padrão: últimos 7 dias para o plano gratuito; acervo completo desde a primeira assinatura paga do assinante para o plano pago (regra detalhada na Seção 13.6.1: quem foi pago, deixou de ser e voltou, vê tudo desde a primeira vez). O limite é imposto no servidor, não apenas na interface. Exemplo: em 25/08, o gratuito enxerga devocionais de 19/08 a 25/08; itens anteriores aparecem bloqueados, com chamada para assinar, e não desaparecem da lista — o bloqueio visível converte melhor do que a ausência.

1.18 Q16 — Texto do consentimento LGPD #

  • Pergunta: qual o texto exato do aceite exibido no cadastro?
  • Por que importa: o consentimento é a base legal do envio de mensagens. Ele é gravado em formato imutável, com versão, e precisa ser exibível anos depois exatamente como foi aceito.
  • Onde impacta: Seção 11 (formulário de cadastro), Seção 22 (LGPD e prova de consentimento). Dados: chave privacy.policy_version em settings (Seção 26.8.1); o texto de cada versão fica em apps/web/src/legal/consent/, versionado no repositório.
  • Padrão: versão v1, com o texto: "Autorizo o envio de mensagens no meu WhatsApp com o devocional diário e avisos sobre a minha assinatura. Posso cancelar a qualquer momento respondendo SAIR. Li e aceito os Termos de Uso e a Política de Privacidade." O aceite é registrado com data e hora, endereço IP, agente de usuário, canal e versão do texto. Alterar o texto exige criar v2 — nunca editar v1 no lugar.

1.19 Q17 — Encarregado de dados (DPO) e canal do titular #

  • Pergunta: quem é o encarregado e qual e-mail público?
  • Por que importa: a LGPD exige indicação de encarregado com canal de contato divulgado. A ausência é infração autônoma, independente de vazamento.
  • Onde impacta: Seção 22 (privacidade e direitos do titular), Seção 9 (rodapé e página de privacidade). Configuração: chave privacy.dpo_email em settings (Seção 26.8.1).
  • Padrão: privacidade@palavradiaria.com.br, com resposta em até 15 dias corridos. Esta seção é a dona do valor padrão (Seção 30.2); o seed grava exatamente este endereço em privacy.dpo_email. O nome do encarregado é publicado na página de privacidade. Enquanto não houver indicação formal de terceiro, o encarregado é o sócio responsável pela operação.

1.20 Q18 — Razão social, CNPJ e endereço nos termos #

  • Pergunta: qual entidade jurídica figura nos termos, na política de privacidade e no recibo de pagamento?
  • Por que importa: contrato de adesão sem parte identificada é inexequível, e a Asaas vincula a conta de recebimento a um CNPJ ou CPF específico.
  • Onde impacta: Seção 9 (páginas legais), Seção 12 (conta de recebimento). Dados: chave única privacy.legal_entity em settings (Seção 26.8.1), do tipo JSON, com os campos razaoSocial, cnpj e endereco.
  • Padrão: os campos são preenchidos no seed com os dados reais da empresa antes do primeiro deploy de produção. Em ambientes local e de homologação, o padrão é Palavra Diária Conteúdo Digital LTDA, CNPJ 00.000.000/0001-00 e endereço fictício, claramente marcados como dados de teste. Regra dura: o deploy de produção falha na verificação de sanidade descrita na Seção 25 se qualquer campo de privacy.legal_entity ainda estiver com valor de teste.

1.21 Q19 — Canal de suporte e SLA #

  • Pergunta: qual o e-mail de suporte e qual o prazo prometido?
  • Por que importa: o prazo aparece na página de planos como diferencial do plano pago e vira promessa contratual.
  • Onde impacta: Seção 9 (comparativo de planos), Seção 13 (entitlements), Seção 19 (respostas automáticas). Dados: chave support.email em settings (Seção 26.8.1). O prazo prometido não é uma chave de configuração: ele é texto aprovado da Seção 10 e valor da matriz de entitlements da Seção 13.
  • Padrão: suporte@palavradiaria.com.br. Plano gratuito: FAQ e e-mail sem prazo prometido. Plano pago: resposta em 1 dia útil. O robô do WhatsApp não faz atendimento humano; ele responde com o FAQ e encaminha o contato por e-mail.

1.22 Q20 — Idioma da interface administrativa #

  • Pergunta: o painel administrativo fica em português ou em inglês?
  • Por que importa: o painel do assinante é obrigatoriamente em português. O painel administrativo é usado por editores brasileiros, mas manter dois idiomas dobra o custo de texto.
  • Onde impacta: Seção 15 (painel administrativo), Seção 5.14 (arquivo único de textos).
  • Padrão: português do Brasil em toda a interface, incluindo o painel administrativo. Rótulos de dados técnicos que espelham valores do sistema (nomes de estado, códigos de erro da Meta, identificadores) permanecem em inglês, exatamente como são gravados, e ganham um texto explicativo em português ao lado. Não há alternância de idioma no MVP.

1.23 Q21 — Provedor de e-mail transacional e domínio remetente #

  • Pergunta: qual serviço envia e-mails e de qual domínio?
  • Por que importa: e-mail é canal secundário — recibo, magic link de acesso alternativo, aviso de falha de pagamento. Sem SPF, DKIM e DMARC configurados, esses e-mails vão para spam e o assinante perde o acesso alternativo.
  • Onde impacta: Seção 8 (magic link), Seção 13 (avisos de cobrança), Seção 25 (registros DNS). Configuração: RESEND_API_KEY, EMAIL_FROM.
  • Padrão: Resend, remetente Palavra Diária <nao-responda@palavradiaria.com.br>, com SPF, DKIM e DMARC em política quarantine no domínio de envio. Endereço de resposta apontando para o suporte de Q19.

1.24 Q22 — Provedor de armazenamento de mídia #

  • Pergunta: onde ficam os arquivos de áudio?
  • Por que importa: o áudio é o ativo mais caro de produzir. Perder o acervo significa regerar tudo e pagar TTS de novo.
  • Onde impacta: Seção 16 (pipeline de mídia), Seção 25 (backup). Configuração: S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.
  • Padrão: qualquer serviço compatível com S3, acessado pelo SDK oficial da AWS para S3 (ver Seção 4). Padrão operacional: bucket privado em provedor com egresso gratuito ou barato, chaves no formato devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.{ext}, URLs assinadas com validade de 15 minutos para o player web. Armazenamento em disco local do servidor é explicitamente rejeitado, porque impede restaurar o serviço em outra máquina.

1.25 Q23 — Região de hospedagem #

  • Pergunta: o servidor fica no Brasil ou fora?
  • Por que importa: latência para o assinante, custo do servidor e leitura de conforto regulatório. A LGPD não exige hospedagem no país, mas hospedar no Brasil elimina a necessidade de justificar transferência internacional na política de privacidade.
  • Onde impacta: Seção 22 (transferência internacional), Seção 25 (provisionamento).
  • Padrão: região no Brasil (São Paulo) para o servidor de aplicação e o banco. O armazenamento de mídia pode ficar fora do país, desde que a política de privacidade declare a transferência e o bucket permaneça privado. Backups replicados para uma segunda região, sempre cifrados.

1.26 Q24 — Janela de manutenção #

  • Pergunta: em que horário são aplicadas migrações e reinícios planejados?
  • Por que importa: manutenção durante o planejamento ou o disparo do lote diário faz o produto quebrar sua única promessa.
  • Onde impacta: Seção 25 (deploy), Seção 23 (silenciamento de alertas), Seção 27 (runbooks).
  • Padrão: terças e quintas, das 14:00 às 16:00 (America/Sao_Paulo). Nenhum deploy planejado entre 05:00 e 08:00 em qualquer dia. Correções de emergência são permitidas a qualquer hora, com registro no diário de incidentes.

1.27 Q25 — Canal de alertas operacionais #

  • Pergunta: para onde vão os alertas de falha?
  • Por que importa: alerta que ninguém lê é alerta inexistente. O produto tem um único momento crítico por dia e precisa de aviso ativo se ele falhar.
  • Onde impacta: Seção 23 (observabilidade e alertas), Seção 27 (runbooks). Configuração: OPS_WEBHOOK_URL e OPS_WEBHOOK_SECRET (Seção 26.3).
  • Padrão: webhook de entrada em um grupo dedicado do Telegram ou do Slack, com duplicação por e-mail para o endereço de plantão em alertas de severidade crítica. O canal recebe também um resumo diário do lote às 06:30, mesmo quando tudo dá certo — silêncio absoluto impede distinguir "sem problema" de "monitoramento quebrado".

1.28 Q26 — Palavras-chave de saída e de retorno #

  • Pergunta: quais palavras o assinante pode enviar para sair, e qual para voltar?
  • Por que importa: reconhecer o pedido de saída é obrigação regulatória e de política da Meta. Falhar nisso derruba a qualidade do número.
  • Onde impacta: Seção 19 (fluxos conversacionais), Seção 20 (opt-out). Dados: chaves send.optout_keywords e send.optin_keywords em settings (Seção 26.8.1).
  • Padrão: saída por sete palavras — SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR ou REMOVER; retorno por VOLTAR, RETORNAR, QUERO VOLTAR ou REATIVAR. A normalização canônica é a função normalizeKeyword() da Seção 20.3.2, que remove acentos e pontuação, colapsa espaços e compara em maiúsculas; nenhuma seção reimplementa essa regra. A comparação exige correspondência da mensagem inteira, não de um trecho. Exemplo: " sair, por favor " não é reconhecido como saída porque a mensagem inteira não corresponde à palavra, e cai no fluxo de ajuda com um botão explícito de cancelamento; " Sair " é reconhecido. O reconhecimento das palavras de saída tem precedência absoluta sobre qualquer outro processamento de mensagem de entrada (Seção 19.5).

1.29 Q27 — Analytics de produto e cookies #

  • Pergunta: haverá ferramenta de análise no site, e ela usa cookies?
  • Por que importa: ferramenta com cookies exige banner de consentimento e mais texto legal. Sem cookies, o banner deixa de ser necessário.
  • Onde impacta: Seção 21 (analytics), Seção 22 (cookies e consentimento).
  • Padrão: análise sem cookies e sem identificador persistente, auto-hospedada, com agregação por página e origem. Nesse desenho o site exibe apenas um aviso informativo, não um banner de consentimento com bloqueio. Se o dono do produto quiser uma ferramenta baseada em cookies, o banner com bloqueio prévio passa a ser obrigatório.

1.30 Q28 — Retenção de registros de mensagem #

  • Pergunta: por quanto tempo guardar o histórico de mensagens enviadas e recebidas?
  • Por que importa: o volume de registros de mensagem cresce mais rápido que qualquer outra tabela. Retenção longa aumenta custo de banco e de backup; retenção curta destrói a capacidade de investigar disputas de entrega.
  • Onde impacta: Seção 6 (particionamento e expurgo), Seção 22 (retenção e anonimização). Dados: chave privacy.message_log_retention_months em settings (Seção 26.8.1).
  • Padrão: 18 meses para registros de mensagem, com partição mensal e expurgo automático da partição mais antiga. Registros de consentimento e de eventos de pagamento têm retenção de 5 anos, por obrigação legal e fiscal, e não seguem esta chave. Backups têm retenção própria de 35 dias.

1.31 Tabela-resumo — Pergunta, Padrão e Onde muda #

# Pergunta Padrão aplicado sem resposta Onde muda
Q01 Nome comercial Palavra Diária apps/web/src/i18n/pt-BR.tsbrand.name
Q02 Domínio e subdomínios palavradiaria.com.br, app., api. APP_URL (Seção 26) + Caddy (Seção 25)
Q03 Paleta de cores Azul-petróleo #1F4E5F / âmbar #C8873B apps/web/src/styles/theme.css
Q04 Tipografia Fraunces + Inter, auto-hospedadas apps/web/app/layout.tsx
Q05 Logo e imagens de marca Marca tipográfica gerada + símbolo apps/web/public/brand/
Q06 Preço mensal R$ 19,90 plans.plan_monthly (seed, Seção 6)
Q07 Preço anual R$ 199,00, rótulo "economize 16%" plans.plan_annual (seed, Seção 6)
Q08 Formas de pagamento CREDIT_CARD e PIX Seção 12
Q09 Dia do envio gratuito Domingo send.free_tier_weekday
Q10 Horário do envio 06:00 America/Sao_Paulo send.daily_hour_local e send.plan_offset_minutes
Q11 Versão bíblica ALMEIDA_1911 (domínio público) content.bible_version_default; campo devotionals.bible_version (Seção 15)
Q12 Provedor e voz de TTS ElevenLabs, voz PT-BR grave; fallback Google TTS_PRIMARY_PROVIDER, TTS_VOICE_ID
Q13 Número e nome no WhatsApp Número novo dedicado, nome = Q01 WHATSAPP_PHONE_NUMBER_ID
Q14 Limite de reenvio manual 1/dia gratuito, 3/dia pago resend.free_daily_limit, resend.paid_daily_limit
Q15 Acervo do plano gratuito Últimos 7 dias archive.free_days
Q16 Texto de consentimento Versão v1 descrita em 1.18 privacy.policy_version; textos em apps/web/src/legal/consent/
Q17 Encarregado de dados privacidade@palavradiaria.com.br privacy.dpo_email
Q18 Razão social e CNPJ Preenchido antes do deploy de produção privacy.legal_entity (JSON)
Q19 Suporte e SLA suporte@palavradiaria.com.br, 1 dia útil no pago support.email; prazo no copy da Seção 10
Q20 Idioma do painel administrativo Português do Brasil apps/web/src/i18n/pt-BR.ts
Q21 E-mail transacional Resend, nao-responda@palavradiaria.com.br RESEND_API_KEY, EMAIL_FROM
Q22 Storage de mídia Bucket privado compatível com S3 S3_* (Seção 26)
Q23 Região de hospedagem Brasil (São Paulo) Seção 25
Q24 Janela de manutenção Terça e quinta, 14:00–16:00 Seção 25
Q25 Canal de alertas Webhook de grupo + e-mail em crítico OPS_WEBHOOK_URL
Q26 Palavras de saída e retorno SAIR/PARAR/PARE/CANCELAR/STOP/DESCADASTRAR/REMOVER; VOLTAR send.optout_keywords, send.optin_keywords
Q27 Analytics e cookies Sem cookies, auto-hospedado Seção 21
Q28 Retenção de mensagens 18 meses privacy.message_log_retention_months

Toda chave citada na coluna "Onde muda" segue o formato grupo.chave e existe no catálogo canônico da Seção 26.8.1 e no seed obrigatório da Seção 6.32.4. Nenhuma outra grafia é aceita: nome em maiúsculas é variável de ambiente (Seção 26.3), não chave de settings.

1.32 O que esta seção não permite mudar #

As decisões abaixo estão travadas e não são configuráveis. Alterá-las é mudança de escopo, com novo planejamento:

  • Identidade do assinante é o número de telefone em E.164 (Seção 8).
  • Não há período de carência em caso de falha de pagamento (Seção 13).
  • Existem exatamente dois níveis de acesso, gratuito e pago, sem plano intermediário e sem teste gratuito (Seção 13).
  • O áudio nunca é enviado como primeira mensagem do dia, por restrição da plataforma (Seção 17 e Seção 18).
  • O conteúdo é escrito por um editor humano; não há geração de devocional por inteligência artificial no MVP (Seção 2.7).
  • Fuso operacional único, sem horário personalizado por assinante (Seção 18).

2. Visão Geral do Produto e Objetivos #

2.1 O problema #

O cristão brasileiro médio quer começar o dia com uma leitura devocional e não consegue manter o hábito. Três causas se repetem:

  1. Atrito de acesso. O devocional impresso fica na estante, o aplicativo devocional fica na terceira tela do celular e as notificações são silenciadas junto com todas as outras.
  2. Ausência de horário. Sem um gatilho externo em horário fixo, a leitura compete com o despertador, o trânsito e o trabalho — e perde.
  3. Formato único. Quem dirige, cozinha ou cuida de filhos às 6h da manhã não consegue ler. Precisa ouvir. A maioria das soluções entrega só texto.

O resultado é um padrão conhecido: a pessoa baixa o aplicativo, usa por quatro dias e abandona. O problema não é falta de conteúdo — conteúdo devocional é abundante e gratuito. O problema é entrega no lugar certo, na hora certa, no formato que cabe na rotina.

2.2 Proposta de valor #

Palavra Diária entrega o devocional dentro do WhatsApp, todos os dias às 6h da manhã, em texto e em áudio narrado. Não há aplicativo para instalar, conta para criar em outro lugar, nem notificação nova para ignorar. A mensagem chega no mesmo lugar onde a pessoa já conversa com a família.

Três afirmações resumem a promessa:

  • Chega sozinho. O assinante não precisa lembrar de abrir nada.
  • Cabe na rotina. Texto para quem pode ler, áudio de três a seis minutos para quem só pode ouvir.
  • Sai quando quiser. Uma palavra no WhatsApp encerra o envio, sem formulário e sem ligação de retenção.

O plano gratuito existe para provar a promessa: um devocional em texto por semana, aos domingos. O plano pago entrega todos os dias, com áudio e acervo completo. A conversão acontece porque a diferença é sentida no cotidiano, não porque um recurso foi bloqueado artificialmente.

2.3 Público-alvo #

O produto atende dois perfis de comprador. Eles têm a mesma fé e rotinas opostas, e por isso justificam os dois formatos de entrega.

2.3.1 Persona 1 — Regina, 52 anos, auxiliar administrativa (Belo Horizonte/MG) #

  • Rotina: acorda às 5h30, prepara o café, sai de casa às 6h40. Usa WhatsApp o dia inteiro, principalmente em grupos de família e da igreja. Instala poucos aplicativos e desconfia de cadastro que pede muitos dados.
  • Relação com o conteúdo: frequenta a igreja há vinte anos, tem o hábito de "leitura da manhã" e sente culpa quando fica dias sem ler. Prefere ler a ouvir.
  • Por que assina: valoriza o acervo e o hábito diário. Encaminha o devocional para o grupo da família quase todo dia, o que torna a persona também um canal de aquisição.
  • Como paga: PIX. Tem cartão de crédito, mas evita usar em serviços recorrentes por receio de esquecer de cancelar.
  • O que a faz cancelar: mensagens demais, tom sensacionalista ou promessa de prosperidade.
  • Consequência de projeto: o texto completo precisa ser legível e encaminhável no WhatsApp, sem depender de link. A cobrança por PIX precisa ser um caminho de primeira classe, com lembretes antes do vencimento (Seção 12).

2.3.2 Persona 2 — Tiago, 34 anos, motorista de aplicativo (Guarulhos/SP) #

  • Rotina: começa a rodar às 5h50. Fica com as mãos ocupadas e os olhos na rua até o meio da tarde. Consome quase tudo em áudio e vídeo.
  • Relação com o conteúdo: vai à igreja aos domingos, ouve pregação em áudio no carro. Nunca terminou um plano de leitura escrito.
  • Por que assina: o áudio. É a única forma de consumo compatível com o trabalho dele.
  • Como paga: cartão de crédito, débito automático, sem pensar no assunto de novo.
  • O que o faz cancelar: áudio robótico demais, longo demais ou que não abre no player do WhatsApp.
  • Consequência de projeto: o áudio precisa chegar como mensagem de voz nativa, com player e forma de onda, e não como arquivo anexo. Isso determina o formato de codificação (Seção 16) e o desenho de entrega em duas etapas (Seção 18).

As duas personas convergem em um ponto decisivo: nenhuma das duas quer instalar mais um aplicativo. Esse é o eixo do produto, tratado em 2.8.

Os perfis internos que operam o sistema — editor de conteúdo, administrador e proprietário — são detalhados na Seção 3, junto com a matriz de permissões.

2.4 A jornada do assinante #

Em texto corrido: a pessoa chega à página de vendas por indicação, busca ou compartilhamento de um devocional encaminhado. Informa nome e telefone, marca o aceite de consentimento e recebe um código de seis dígitos no WhatsApp. Ao confirmar o código, a conta é criada no plano gratuito e o sistema envia uma mensagem de boas-vindas pedindo confirmação explícita de recebimento. Enquanto essa confirmação não vier, nenhum devocional é enviado. Confirmado o opt-in, o assinante entra na lista do próximo domingo.

No domingo às 6h, o sistema envia o convite do dia. O assinante toca no botão para ler e ouvir, e recebe o texto completo. Se estiver no plano gratuito, não recebe áudio, e a mensagem de fechamento informa que o áudio e os envios diários fazem parte do plano pago. Ao decidir assinar, ele abre o painel, escolhe mensal ou anual, escolhe cartão ou PIX e conclui o pagamento. A confirmação do pagamento chega por webhook, o acesso pago é liberado na hora e o envio diário começa no dia seguinte.

A partir daí, o ciclo é diário: convite às 6h, texto completo e áudio quando o assinante interage, e acervo disponível no painel a qualquer momento. Se o assinante já tiver conversado com o número nas últimas 24 horas, o sistema pula o convite e entrega o pacote completo diretamente — caminho mais barato e com menos atrito. Se o pagamento falhar, o acesso pago é revogado no mesmo instante e o assinante volta ao ritmo semanal, sem período de tolerância. Se ele pedir para sair, os envios param imediatamente e a cobrança do ciclo seguinte é suspensa na hora — mas a assinatura não é cancelada de imediato, para que um "SAIR" acidental não destrua a contratação. O painel deixa a distinção explícita: parar mensagens e encerrar a assinatura são ações separadas. Se em 30 dias não houver reativação, a assinatura é encerrada ao fim do período já pago, com aviso. A regra completa está na Seção 13.

 [Descoberta]                    [Cadastro]                     [Ativação]
 indicação, busca,     ┌────────────────────────────┐   ┌──────────────────────────┐
 devocional            │ nome + telefone + aceite   │   │ código de 6 dígitos no   │
 encaminhado  ───────► │ LGPD na página de vendas   ├──►│ WhatsApp (10 min de TTL) │
                       └────────────────────────────┘   └────────────┬─────────────┘
                                                                     │
                       ┌─────────────────────────────────────────────▼─────────────┐
                       │ boas-vindas no WhatsApp + confirmação ativa de opt-in     │
                       │ nenhum devocional sai antes desta confirmação             │
                       └─────────────────────────────┬────────────────────────────-┘
                                                     │
                                        ┌────────────▼────────────┐
                                        │  PLANO GRATUITO         │
                                        │  domingo 06:00, texto   │
                                        │  acervo de 7 dias       │
                                        └───┬─────────────────┬───┘
                                            │                 │
                       decide assinar ◄─────┘                 └────► segue gratuito
                              │                                      (sem prazo)
                 ┌────────────▼─────────────┐
                 │ checkout: mensal|anual   │
                 │ cartão (recorrente)      │
                 │ ou PIX (ciclo a ciclo)   │
                 └────────────┬─────────────┘
                              │  webhook de pagamento confirmado
                 ┌────────────▼─────────────┐
                 │  PLANO PAGO              │
                 │  todo dia 06:00          │
                 │  texto + áudio narrado   │
                 │  acervo completo         │
                 └────────────┬─────────────┘
                              │
        ┌─────────────────────┼─────────────────────┬───────────────────────┐
        │                     │                     │                       │
  falha de pagamento   cancela no painel      pede SAIR              permanece ativo
        │                     │                     │                       │
  revogação imediata   acesso até o fim do    envios param na hora;    renovação
  → volta ao gratuito  ciclo já pago →        cobrança do ciclo        automática
  (sem carência)       depois gratuito        seguinte suspensa;       a cada ciclo
                                              encerra em 30 dias
                                              sem reativação

2.5 O que é o MVP #

Está dentro do MVP, e nada aqui é opcional:

  1. Página de vendas pública, com amostra de áudio e comparativo de planos.
  2. Cadastro por telefone com código de acesso entregue no WhatsApp, opt-in em duas etapas e registro imutável de consentimento.
  3. Dois níveis de acesso: gratuito semanal em texto e pago diário com texto e áudio.
  4. Painel do assinante: acervo, dados cadastrais, reenvio do devocional do dia, gestão da assinatura, exportação dos próprios dados e exclusão da conta.
  5. Painel administrativo: calendário editorial, editor de devocional, fluxo de publicação, revisão de áudio, gestão de assinantes, gestão de usuários internos e trilha de auditoria.
  6. Pipeline de áudio: geração por síntese de voz, transcodificação para mensagem de voz, armazenamento em bucket privado e envio único de mídia ao canal.
  7. Motor de envio diário com planejamento antecipado, controle de taxa, idempotência, registro de estado por mensagem, reprocessamento seguro e reavaliação do nível de acesso no instante do disparo, e não no planejamento.
  8. Integração de pagamentos com criação de cliente, assinatura recorrente por cartão, cobrança por PIX, tratamento de webhooks e reconciliação diária.
  9. Ciclo de vida da assinatura com revogação imediata em caso de falha de pagamento.
  10. Opt-out por palavra-chave, reativação e a separação explícita entre parar mensagens e cancelar cobrança.
  11. Dashboard de métricas com as definições da Seção 21.
  12. Observabilidade: logs estruturados, métricas, alertas e runbooks para os modos de falha conhecidos.
  13. Conformidade com a LGPD: base legal declarada, direitos do titular atendidos por autoatendimento, retenção definida e encarregado indicado.

2.6 Definição de sucesso #

Metas para os primeiros doze meses de operação. Cada métrica tem definição formal e fórmula na Seção 21; aqui estão apenas os alvos.

Dimensão Métrica Meta em 12 meses Limite de alerta
Adoção Assinantes cadastrados 10.000
Adoção Assinantes pagos 3.000
Ativação Opt-in confirmado em até 24 h do cadastro ≥ 70% < 55%
Conversão Gratuito → pago em até 90 dias do cadastro ≥ 8% < 5%
Conversão Escolha do plano anual entre os pagantes ≥ 25%
Retenção Churn mensal de assinaturas pagas ≤ 6% ao mês > 9%
Retenção Assinantes pagos ativos após 30 dias ≥ 85% < 75%
Engajamento Abertura da janela de conversa no dia do envio ≥ 45% < 30%
Entrega Mensagens com status entregue ou lido ≥ 97% < 93%
Entrega Lote diário concluído até 06:20 100% dos dias qualquer atraso
Operação Disponibilidade mensal das superfícies web ≥ 99,5% < 99,0%
Operação p95 de resposta das rotas de API < 400 ms > 700 ms
Custo Custo mensal por assinante pago (mensagens + voz + infraestrutura) ≤ R$ 3,00 > R$ 5,00
Reputação Classificação de qualidade do número no canal verde amarelo ou vermelho

Exemplo numérico do alvo de custo: com preço mensal de R$ 19,90 e custo por assinante pago de R$ 3,00, a margem bruta por assinante é de R$ 16,90, ou 85%. Se a categoria do template diário for reclassificada e o custo por mensagem subir de forma relevante, o gatilho de revisão de preço é o custo mensal por assinante ultrapassar R$ 5,00 — não antes.

2.7 Fora de escopo #

Esta seção é a dona da lista. Nenhum item abaixo é especificado, projetado, prototipado ou "deixado preparado" em qualquer outra seção. Tentativa de implementar qualquer um deles é desvio de escopo e deve ser rejeitada pelo executor.

  1. Telegram, e-mail ou SMS como canal de entrega do devocional. E-mail existe somente como canal transacional secundário (recibo, acesso alternativo, aviso de cobrança).
  2. Multi-idioma. O produto é apenas em português do Brasil, interface e conteúdo.
  3. Recursos sociais entre assinantes. Sem comentários, sem curtidas, sem seguir.
  4. Horário de envio personalizado por assinante. Fuso e horário são únicos.
  5. Narração humana gravada. Toda narração é sintetizada.
  6. Multi-tenant ou white-label para outras igrejas. Uma marca, uma base.
  7. Geração de devocional por inteligência artificial. O autor é humano, sempre.
  8. Aplicativo móvel nativo. Nem Android, nem iOS, nem aplicativo web instalável com notificações próprias.
  9. Pedidos de oração, aconselhamento ou qualquer atendimento pastoral.
  10. Grupos e comunidades do WhatsApp. A entrega é individual, um a um.
  11. Boleto bancário como forma de pagamento.
  12. Emissão automática de nota fiscal.
  13. Cupons, campanhas promocionais e descontos. Não existe tabela de cupons no modelo de dados, e nenhuma seção pode criá-la.
  14. Testes A/B de conteúdo devocional.

2.8 Por que WhatsApp e não aplicativo próprio #

A escolha do canal é a decisão de produto mais importante, e é anterior a qualquer decisão técnica. O raciocínio:

A favor do WhatsApp

  • Distribuição pronta. As duas personas já usam o aplicativo várias horas por dia. Não há passo de instalação, e o passo de instalação é onde a maioria dos funis morre.
  • Entrega ativa e confiável. Mensagem no WhatsApp é lida. Notificação push de aplicativo novo é silenciada, agrupada ou bloqueada pelo sistema de economia de bateria, cenário comum em aparelhos Android de entrada.
  • Áudio nativo. O canal já reproduz mensagens de voz com player e forma de onda, em velocidade ajustável, sem que seja preciso escrever um reprodutor.
  • Compartilhamento embutido. Encaminhar um devocional para o grupo da família é um gesto de um toque. Isso vira aquisição orgânica sem custo.
  • Custo de construção. Um aplicativo nativo em duas plataformas exigiria ciclos de revisão de loja, distribuição de versões e suporte a aparelhos antigos. O canal remove tudo isso.

Contra o WhatsApp, e como o projeto responde

Limitação Resposta do projeto
Mensagem iniciada pelo negócio fora da janela de conversa exige template aprovado Entrega em duas etapas: convite por template, conteúdo completo em mensagem livre dentro da janela (Seção 18)
Cabeçalho de template não aceita áudio O áudio nunca é a primeira mensagem do dia; há um caminho alternativo com mídia em vídeo para quem nunca interage (Seção 18)
Custo por mensagem iniciada pelo negócio O caminho preferencial usa a janela já aberta, que não gera custo por mensagem; o custo é monitorado por assinante (Seção 21)
Dependência de política de uma única plataforma Camada de abstração do provedor de mensageria (Seção 4.10), painel web como superfície independente e acervo próprio no banco
Interface limitada para gestão de conta Todo o autoatendimento fica no painel web (Seção 14); o canal serve para entrega e comandos simples
Risco de bloqueio ou queda de qualidade do número Monitoramento ativo de qualidade, respeito estrito a opt-out e runbook de contingência (Seção 27)

Decisão registrada: o WhatsApp é o canal de entrega; o painel web é a superfície de gestão. Um aplicativo próprio não entra no roteiro enquanto a base for inferior a 50.000 assinantes, porque o custo de aquisição de instalação supera o ganho marginal de controle.

2.9 Contexto de mercado brasileiro #

As afirmações abaixo são premissas de trabalho adotadas no planejamento. Elas orientam prioridade, não substituem pesquisa própria.

  • Onipresença do canal. O WhatsApp está instalado em praticamente todos os smartphones brasileiros em uso e é, para boa parte da população, sinônimo de "internet". Para o público das personas, é o primeiro aplicativo aberto de manhã e o último fechado à noite. Nenhum outro canal digital tem esse alcance no país.
  • Pagamento instantâneo como padrão. O PIX se consolidou como meio de pagamento cotidiano e é frequentemente preferido ao cartão, sobretudo em faixas de renda média e para valores baixos. Ignorar o PIX em um produto de R$ 19,90 elimina uma parcela relevante de compradores dispostos. Em contrapartida, PIX não tem recorrência automática: cada ciclo exige uma cobrança nova e um lembrete. Isso é custo operacional aceito conscientemente.
  • Fragilidade do cartão recorrente. No Brasil, cartões negados, limites baixos e trocas de cartão produzem churn involuntário significativo. O plano anual e o PIX são as duas respostas do produto a esse fato.
  • Público cristão numeroso e com hábito de consumo de conteúdo religioso. Uma parcela expressiva da população brasileira se declara evangélica ou católica praticante, e o consumo diário de conteúdo religioso por rádio, vídeo e mensagens já é um comportamento estabelecido. O produto não precisa criar o hábito de consumo — precisa capturar um hábito existente e dar a ele horário e formato.
  • Disposição a pagar por conteúdo devocional. Existe precedente de assinatura paga em aplicativos devocionais e em plataformas de conteúdo cristão. O ponto de preço de R$ 19,90/mês fica na faixa de assinaturas de baixo valor com as quais o público já convive, e o plano anual de R$ 199,00 reduz a fricção mensal.
  • Sensibilidade de tom. O público reage mal a linguagem de venda agressiva, promessa de prosperidade e uso de culpa. O tom aprovado do produto está na Seção 10 e é uma decisão de produto, não de estilo.
  • Custo de mensagens. O preço por mensagem iniciada pelo negócio varia por categoria e é revisado periodicamente pela plataforma. O desenho de entrega em duas etapas existe em boa parte para tornar o custo por assinante previsível mesmo se a categoria mudar. As duas faixas de custo consideradas estão na Seção 17.

2.10 Premissas de negócio #

# Premissa Se for falsa
P1 É possível obter aprovação de conta comercial e de templates na plataforma de mensageria em prazo compatível com o cronograma O lançamento atrasa; o trabalho segue com número de teste (Seção 28)
P2 O template de devocional diário é aceito na categoria de utilidade O custo por mensagem sobe; o caminho de janela aberta passa a ser essencial e a meta de custo de 2.6 é revista
P3 Existe capacidade editorial para produzir ao menos 30 devocionais por mês com antecedência de 14 dias O calendário editorial fica exposto no painel com alerta de lacuna (Seção 15)
P4 A qualidade da voz sintetizada é aceita pelo público como narração adequada Aumenta o churn do plano pago; a resposta é trocar a voz, não gravar narração humana (fora de escopo)
P5 A operadora de pagamentos entrega webhooks de forma confiável A reconciliação diária corrige divergências (Seção 12)
P6 O uso de tradução bíblica em domínio público é aceito pelo público Exige contrato de licenciamento antes de trocar a tradução (Seção 22)
P7 Um único servidor suporta a base do primeiro ano Escala vertical, depois separação de processos, conforme 4.11

2.11 Riscos de produto e mitigação #

# Risco Impacto Probabilidade Mitigação
R1 Bloqueio ou restrição do número no canal de mensageria Crítico — produto para de entregar Baixa Opt-out respeitado em segundos, monitoramento de qualidade com alerta imediato, número reserva pré-verificado, runbook na Seção 27
R2 Reclassificação do template diário para categoria de marketing Alto — custo por mensagem sobe Média Preferir o caminho de janela aberta, medir custo por assinante diariamente, registrar a categoria efetiva retornada pela plataforma
R3 Limitação de entrega por saúde do ecossistema, comum no Brasil Médio — parte da base não recebe no dia Média Adiar o envio e reinserir o assinante no lote seguinte sem contar como falha de entrega (Seção 27)
R4 Baixa taxa de abertura da janela de conversa Médio — assinantes pagos não recebem áudio Média Botão de ação direto no convite, caminho alternativo com mídia em vídeo após três dias sem interação, reforço no painel
R5 Conversão de gratuito para pago abaixo da meta Alto — inviabiliza a operação Média Bloqueio visível do acervo, amostra de áudio na página de vendas, mensagem de fechamento dominical com convite explícito
R6 Churn involuntário por cartão negado Alto — perda de receita sem intenção do cliente Alta Lembretes antes do vencimento, aviso imediato de falha, caminho de pagamento por PIX em um toque, incentivo ao plano anual
R7 Lacuna no calendário editorial Alto — dia sem conteúdo publicado Média Alerta com 7 dias de antecedência, exigência de 14 dias de fila, política de reaproveitamento de devocional do acervo com marcação explícita (Seção 15)
R8 Qualidade da narração sintetizada rejeitada pelo público Médio Baixa Amostra pública na página de vendas antes da compra, voz configurável, provedor de fallback
R9 Reclamação de titular de dados ou notificação de autoridade Alto — exposição legal Baixa Consentimento versionado e imutável, exportação e exclusão por autoatendimento, encarregado indicado (Seção 22)
R10 Uso indevido de tradução bíblica licenciada por um editor Alto — risco jurídico Baixa Campo de versão obrigatório, lista de versões permitidas no painel, aviso no editor
R11 Falha do provedor de síntese de voz no dia do envio Médio — dia sem áudio Baixa Áudio gerado com antecedência, no momento em que o devocional fica pronto, e não no momento do envio; provedor de fallback
R12 Custo de infraestrutura crescendo mais rápido que a receita Médio Baixa Métrica de custo por assinante calculada todo dia, com limite de alerta definido em 2.6
R13 Encaminhamento em massa do conteúdo pago reduzindo a conversão Baixo Alta Aceito como custo de aquisição: o rodapé de cada devocional convida quem recebeu encaminhado a assinar

3. Personas, Papéis e Matriz de Permissões #

3.1 Enum canônico de papéis #

O sistema tem exatamente quatro papéis. A lista é fechada. Nenhuma outra seção pode introduzir um quinto papel, e nenhuma funcionalidade pode depender de um papel que não esteja aqui.

// packages/core/src/auth/roles.ts
export const ROLES = ['SUBSCRIBER', 'EDITOR', 'ADMIN', 'OWNER'] as const
export type Role = (typeof ROLES)[number]

/** Ordem de precedência. Índice maior nunca é menos poderoso que índice menor. */
export const ROLE_RANK: Record<Role, number> = {
  SUBSCRIBER: 0,
  EDITOR: 1,
  ADMIN: 2,
  OWNER: 3,
}

/** Papéis que existem dentro do painel administrativo. */
export const STAFF_ROLES = ['EDITOR', 'ADMIN', 'OWNER'] as const satisfies readonly Role[]
export type StaffRole = (typeof STAFF_ROLES)[number]

O tipo enumerado correspondente é declarado no banco na Seção 6, com o mesmo nome e os mesmos valores. SUBSCRIBER nunca aparece como papel de usuário interno: a restrição é aplicada por CHECK constraint na tabela de usuários internos (Seção 6).

Nível de acesso é coisa diferente de papel. Todo assinante tem papel SUBSCRIBER; o que distingue gratuito de pago é o nível de acesso (FREE ou PAID), resolvido pela função resolveEntitlements(subscriber) descrita na Seção 13. Nenhum código pode inferir capacidades a partir do papel para assinantes, nem a partir do nível de acesso para usuários internos.

3.2 Como cada papel nasce e como morre #

Papel Como é criado Como é removido Onde vive
SUBSCRIBER Autocadastro na página pública, com telefone verificado por código de acesso (Seção 11) Autoexclusão no painel, ou exclusão administrativa; em ambos os casos vira exclusão lógica seguida de anonimização em 30 dias (Seção 22) Tabela subscribers
EDITOR Criado por ADMIN ou OWNER no painel administrativo, com convite por e-mail e definição de senha na primeira entrada Desativado por ADMIN ou OWNER (exclusão lógica); sessões revogadas na hora Tabela admin_users
ADMIN Criado ou promovido por OWNER Rebaixado ou desativado por OWNER Tabela admin_users
OWNER Primeiro registro criado pelo seed inicial do banco; demais promovidos por outro OWNER Rebaixado ou desativado por outro OWNER, respeitando a regra do último proprietário em 3.10 Tabela admin_users

Não existe autocadastro de usuário interno. Não existe rota pública que crie um registro em admin_users. A única exceção é o seed de instalação, que cria um único OWNER a partir de variáveis de ambiente e exige troca de senha no primeiro acesso.

3.3 Persona operacional — Assinante gratuito #

  • Quem é: pessoa cadastrada, com opt-in confirmado, sem assinatura paga ativa.
  • O que faz: recebe o devocional semanal aos domingos às 06:00; abre o painel para ler o acervo dos últimos 7 dias; pede um reenvio por dia; edita nome e e-mail; exporta seus próprios dados; exclui a própria conta; assina um plano pago.
  • O que não pode fazer: receber áudio; receber devocional em dias que não sejam o dia do envio gratuito; abrir devocionais fora da janela de acervo; pedir mais de um reenvio no mesmo dia; ver dados de qualquer outro assinante; acessar qualquer rota do painel administrativo.
  • Cenário concreto: em uma quarta-feira, Regina abre o painel e clica em "reenviar devocional de hoje". O sistema responde NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER, porque no nível gratuito o dia corrente não tem devocional atribuído a ela. O painel exibe o convite para assinar, com o texto aprovado na Seção 10.

3.4 Persona operacional — Assinante pago #

  • Quem é: assinante com assinatura em estado ativo e nível de acesso PAID.
  • O que faz: tudo do gratuito, mais: recebe devocional todos os dias; recebe áudio narrado; acessa o acervo completo desde a primeira assinatura paga (Seção 13.6.1); pede até três reenvios por dia; troca de plano mensal para anual; troca a forma de pagamento; consulta recibos; cancela a assinatura.
  • O que não pode fazer: pausar a assinatura mantendo o acesso; escolher outro horário de envio; escolher outra voz de narração; baixar o arquivo de áudio fora do player do painel por URL permanente — as URLs são assinadas e expiram em 15 minutos (Seção 16).
  • Cenário concreto: Tiago cancela no dia 10, com ciclo pago até o dia 28. Ele continua recebendo áudio até o dia 28 e é rebaixado para gratuito na virada do dia 29. Isso não é período de carência; é o período que ele já pagou. A distinção é normativa e está na Seção 13.

3.5 Persona interna — Editor de conteúdo (EDITOR) #

  • Quem é: responsável pela produção do devocional. Perfil editorial, não técnico.
  • O que faz: cria devocionais; edita rascunhos; preenche título, referência bíblica, texto do versículo, versão da tradução, reflexão, oração e teaser; agenda a data de publicação no calendário editorial; marca o devocional como pronto, o que dispara a geração de áudio; ouve o áudio gerado; solicita nova geração de áudio; publica o devocional; consulta o histórico de revisões e restaura uma revisão anterior.
  • O que não pode fazer: ver telefone, CPF, e-mail ou qualquer dado pessoal de assinante; ver dados de pagamento; alterar preços; criar, editar ou desativar usuários internos; alterar configurações do sistema; ligar ou desligar feature flags; exportar bases de assinantes; disparar reenvio para um assinante específico; alterar templates do canal de mensageria; acessar a trilha de auditoria.
  • Cenário concreto: o editor abre a lista de assinantes por curiosidade. A rota retorna 403 com code FORBIDDEN_ROLE, e o evento é registrado com o identificador do usuário, a rota e o papel exigido. Tentativas repetidas geram alerta operacional (Seção 23).

3.6 Persona interna — Administrador (ADMIN) #

  • Quem é: responsável pela operação diária do serviço.
  • O que faz: tudo do editor, mais: consulta e edita cadastro de assinantes; consulta assinaturas, pagamentos e eventos de pagamento; força a reconciliação de uma assinatura específica contra a operadora de pagamentos; dispara reenvio manual para um assinante; consulta lotes de envio, tentativas de entrega e registros de mensagem; reprocessa um lote com falha; consulta mensagens recebidas; gerencia templates do canal de mensageria; consulta o dashboard de métricas; exporta relatórios; cria e desativa EDITOR; consulta a trilha de auditoria; consulta execuções de job e entregas de webhook.
  • O que não pode fazer: criar, promover, rebaixar ou desativar ADMIN ou OWNER; alterar preços dos planos; alterar chaves de integração; excluir permanentemente assinantes ou devocionais; alterar registros de consentimento; alterar a trilha de auditoria; alterar configurações marcadas como sensíveis (lista em 3.9).
  • Cenário concreto: um assinante liga dizendo que não recebeu o devocional. O administrador abre a ficha, vê que a última tentativa de entrega falhou com um código de limitação de entrega do canal, e usa o botão de reenvio manual. A ação é registrada na trilha de auditoria com o identificador do assinante e o motivo informado.

3.7 Persona interna — Proprietário (OWNER) #

  • Quem é: responsável final pelo negócio e pelos dados. Tipicamente uma ou duas pessoas.
  • O que faz: tudo do administrador, mais: cria, promove, rebaixa e desativa usuários internos de qualquer papel; altera preços e cria novas versões de plano; altera configurações sensíveis; aciona a exclusão definitiva de um assinante a pedido do titular; aprova e executa exportações completas de base; visualiza custos e chaves de integração em forma mascarada; encerra sessões de qualquer usuário.
  • O que não pode fazer: nada além do sistema — mas continua sujeito a três limites invioláveis: (a) não pode remover ou rebaixar o último proprietário ativo (3.10); (b) não pode editar nem apagar registros de consentimento e a trilha de auditoria, que são somente-inserção; (c) não pode ler valores em texto claro de segredos de integração, que nunca são devolvidos pela API.
  • Cenário concreto: o proprietário decide promover um editor a administrador. A operação exige reautenticação com o segundo fator, grava um registro de auditoria com papel anterior e novo papel, e revoga todas as sessões ativas do usuário afetado, forçando novo login com o papel atualizado.

3.8 Matriz de permissões — papel × recurso × ação #

Legenda: L ler · C criar · E editar · A apagar · X exportar · nenhuma permissão · p apenas os próprios registros do usuário autenticado.

Onde consta A para assinantes e devocionais, entenda-se exclusão lógica; a exclusão física é feita apenas por rotina de retenção (Seção 22).

Recurso SUBSCRIBER EDITOR ADMIN OWNER
Devocionais (rascunho) L C E A L C E A L C E A
Devocionais (publicados) L (limitado ao acervo do nível) L C E L C E L C E A
Revisões de devocional L C L C L C
Ativos de áudio L (do próprio acervo, via URL assinada) L C L C E L C E A
Templates do canal de mensageria L L C E L C E A
Assinantes L p, E p, A p, X p L C E A X L C E A X
Perfil do assinante (nome, e-mail, CPF) L p, E p L E L E
Registros de consentimento L p, X p L X L X
Sessões L p, A p L p, A p L, A L, A
Códigos de acesso (OTP) L L
Planos L (público) L L L C E
Assinaturas L p, C p, A p L E L C E
Eventos de assinatura L p L X L X
Pagamentos L p L X L X
Eventos de pagamento (bruto) L L X
Lotes de envio L L C E L C E
Tentativas de entrega L E L E
Registros de mensagem L p (resumo, sem dados internos) L X L X
Mensagens recebidas L L X
Uploads de mídia L C L C E L C E A
Usuários internos L p, E p (só o próprio perfil e senha) L, C (apenas EDITOR), E (apenas EDITOR), A (apenas EDITOR) L C E A
Trilha de auditoria L X L X
Configurações comuns L L E L E
Configurações sensíveis L (mascarado) L (mascarado), E
Feature flags L L E L C E A
Métricas diárias L (somente conteúdo) L X L X
Entregas de webhook L E (reprocessar) L E
Execuções de job L E (reexecutar) L E

Esta tabela é a única fonte do papel mínimo de qualquer recurso. O inventário de rotas da Seção 7 e o mapa de telas da Seção 15 derivam dela e nunca a contradizem. Onde uma rota ou uma tela precisou de leitura própria, a resolução é esta, e vale como norma:

Rota ou tela Papel mínimo resolvido
GET /api/admin/subscribers e GET /api/admin/subscribers/{id} ADMIN. EDITOR não tem nenhuma permissão sobre assinantes.
DELETE /api/admin/devotionals/{id} EDITOR quando status = 'DRAFT'; OWNER nos demais casos. O papel é resolvido pelo estado do recurso, não pela rota.
POST /api/admin/admin-users e PATCH /api/admin/admin-users/{id} ADMIN quando o papel alvo é EDITOR; OWNER nos demais casos.
GET /api/admin/settings, PUT /api/admin/settings/{key}, GET /api/admin/feature-flags, PATCH /api/admin/feature-flags/{key} ADMIN, respeitado ainda o editable_by de cada linha (Seção 26.8.2).
PATCH /api/admin/plans/{code} OWNER.
Tela de envios do painel EDITOR para leitura; ADMIN para reprocessar e cancelar lote.
Tela de configurações do painel ADMIN para abrir; abas de planos e de administradores exigem OWNER.

Regras que a tabela não expressa e que valem como norma:

  1. Toda leitura marcada com p é filtrada no repositório pelo identificador do assinante contido na sessão, nunca por parâmetro vindo do cliente. Um assinante que troca o identificador na URL recebe 404 com code SUBSCRIBER_NOT_FOUND, e não 403 — para não confirmar a existência do registro alheio.
  2. Nenhuma rota administrativa aceita filtro por telefone completo sem que o usuário tenha permissão de leitura de assinantes. Busca por telefone parcial exige ADMIN.
  3. Exportações sempre geram registro na trilha de auditoria, com a quantidade de linhas exportadas e o filtro aplicado.
  4. Configurações sensíveis são: chaves de integração, token de webhook, segredo de assinatura de sessão, dados da entidade jurídica e limites de taxa de envio. A API nunca devolve o valor em texto claro; devolve apenas os quatro últimos caracteres e um indicador de que o valor está preenchido.

3.9 Catálogo de permissões nomeadas #

A verificação de acesso no código nunca compara papéis diretamente em regra de negócio. Ela compara permissões nomeadas, e o mapa de papel para permissões é único.

// packages/core/src/auth/permissions.ts
export const PERMISSIONS = [
  'devotionals:read', 'devotionals:write', 'devotionals:publish', 'devotionals:delete',
  'audio:read', 'audio:regenerate',
  'templates:read', 'templates:write',
  'subscribers:read', 'subscribers:write', 'subscribers:delete', 'subscribers:export',
  'billing:read', 'billing:write', 'billing:reconcile',
  'delivery:read', 'delivery:resend', 'delivery:reprocess',
  'metrics:read', 'metrics:export',
  'staff:read', 'staff:write:editor', 'staff:write:any',
  'audit:read',
  'settings:read', 'settings:write', 'settings:write:sensitive',
  'flags:read', 'flags:write',
  'jobs:read', 'jobs:write',
] as const
export type Permission = (typeof PERMISSIONS)[number]

const EDITOR_PERMISSIONS: Permission[] = [
  'devotionals:read', 'devotionals:write', 'devotionals:publish',
  'audio:read', 'audio:regenerate',
  'templates:read', 'delivery:read', 'metrics:read',
  'settings:read', 'flags:read',
]

const ADMIN_PERMISSIONS: Permission[] = [
  ...EDITOR_PERMISSIONS,
  'devotionals:delete',
  'templates:write',
  'subscribers:read', 'subscribers:write', 'subscribers:delete', 'subscribers:export',
  'billing:read', 'billing:write', 'billing:reconcile',
  'delivery:resend', 'delivery:reprocess',
  'metrics:export', 'audit:read',
  'staff:read', 'staff:write:editor',
  'settings:write', 'flags:write',
  'jobs:read', 'jobs:write',
]

const OWNER_PERMISSIONS: Permission[] = [
  ...ADMIN_PERMISSIONS,
  'staff:write:any',
  'settings:write:sensitive',
]

export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
  SUBSCRIBER: [], // assinante nunca usa permissões nomeadas; usa escopo próprio + entitlements
  EDITOR: EDITOR_PERMISSIONS,
  ADMIN: ADMIN_PERMISSIONS,
  OWNER: OWNER_PERMISSIONS,
}

export function can(role: Role, permission: Permission): boolean {
  return ROLE_PERMISSIONS[role].includes(permission)
}

Consequência prática: para descobrir se um administrador pode reprocessar um lote, o código pergunta can(session.role, 'delivery:reprocess'). Se amanhã a permissão migrar para OWNER, muda uma linha, e não vinte rotas. A aplicação dessa verificação nas rotas HTTP é descrita na Seção 8.

3.10 Escalonamento, rebaixamento e a regra do último proprietário #

Regras normativas, verificadas na camada de serviço e reforçadas por constraint no banco onde possível:

  1. Ninguém promove a si mesmo. Se actor.id === target.id e o papel muda, a operação falha com CANNOT_CHANGE_OWN_ROLE.
  2. Ninguém cria alguém acima de si. O papel alvo precisa ter posto menor ou igual ao do autor. Um ADMIN só cria e edita EDITOR. Tentativa de criar ADMIN falha com INSUFFICIENT_ROLE_TO_ASSIGN.
  3. Ninguém edita alguém acima de si. Um ADMIN que tenta desativar um OWNER recebe INSUFFICIENT_ROLE_TO_ASSIGN. Um ADMIN que tenta editar outro ADMIN recebe o mesmo erro: papéis de mesmo posto não se administram, exceto no posto de proprietário.
  4. Último proprietário é intocável. Não é possível rebaixar, desativar ou excluir um OWNER se ele for o único proprietário ativo. A verificação é feita dentro da mesma transação da alteração, com bloqueio de linha, e o erro é LAST_OWNER_PROTECTED.
  5. Reautenticação obrigatória. Qualquer mudança de papel exige que o autor apresente novamente o segundo fator, dentro de uma janela de 5 minutos (Seção 8).
  6. Revogação de sessão. Alterar o papel de um usuário revoga imediatamente todas as sessões ativas dele. O papel gravado na sessão nunca é atualizado no lugar.
// packages/core/src/staff/change-role.ts (trecho normativo)
export async function assertCanAssignRole(actor: StaffContext, targetRole: StaffRole) {
  if (ROLE_RANK[targetRole] > ROLE_RANK[actor.role]) {
    throw new AppError('INSUFFICIENT_ROLE_TO_ASSIGN', 403)
  }
  if (ROLE_RANK[targetRole] === ROLE_RANK[actor.role] && actor.role !== 'OWNER') {
    throw new AppError('INSUFFICIENT_ROLE_TO_ASSIGN', 403)
  }
}

/** Executada SEMPRE dentro da transação que altera o papel ou desativa o usuário. */
export async function assertNotLastOwner(tx: Tx, targetId: string) {
  const owners = await tx.$queryRaw<{ id: string }[]>`
    SELECT id FROM admin_users
    WHERE role = 'OWNER' AND deleted_at IS NULL AND is_active = true
    FOR UPDATE
  `
  if (owners.length <= 1 && owners.some((o) => o.id === targetId)) {
    throw new AppError('LAST_OWNER_PROTECTED', 409)
  }
}

Exemplo concreto de concorrência: dois proprietários, Ana e Bruno, tentam rebaixar um ao outro no mesmo segundo. As duas transações disputam o mesmo conjunto de linhas bloqueado por FOR UPDATE. A primeira a obter o bloqueio rebaixa o outro; a segunda, ao reexecutar a consulta, encontra um único proprietário ativo, que é o próprio alvo, e falha com LAST_OWNER_PROTECTED. Nunca existe estado com zero proprietários.

3.11 Trilha de auditoria de mudanças de papel #

Toda alteração de papel, criação de usuário interno, desativação e reativação grava um registro na trilha de auditoria (tabela admin_audit_log, definida na Seção 6). A trilha é somente-inserção: não há rota de edição nem de exclusão, e a permissão de escrita do banco para a aplicação não inclui UPDATE nem DELETE nessa tabela.

Campos gravados nas ações de papel:

Os nomes de coluna são os da Seção 6.9, que é a dona da definição física de admin_audit_log: não existem colunas actor_id, actor_admin_id, target_type nem target_id.

Campo Conteúdo
actor_type ADMIN nestas ações; o enum completo é ADMIN, SUBSCRIBER e SYSTEM
admin_user_id Usuário interno que executou a ação
actor_role Papel do autor no momento da ação
action staff.role_changed, staff.created, staff.deactivated, staff.reactivated; e, no grupo de assinantes, ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED (Seção 23.11.1)
entity_type admin_user
entity_id Usuário afetado
before { "role": "EDITOR", "isActive": true }
after { "role": "ADMIN", "isActive": true }
reason Texto livre obrigatório, mínimo de 10 caracteres
ip Endereço de origem
user_agent Agente de usuário
created_at Data e hora em UTC

Exemplo de registro:

{
  "id": "aud_01J9Z8Q6M0Q2W7X3B4C5D6E7F8",
  "actorType": "ADMIN",
  "adminUserId": "adm_01J9Z0000000000000000OWNER",
  "actorRole": "OWNER",
  "action": "staff.role_changed",
  "entityType": "admin_user",
  "entityId": "adm_01J9Z1111111111111111EDITR",
  "before": { "role": "EDITOR", "isActive": true },
  "after": { "role": "ADMIN", "isActive": true },
  "reason": "Assume operação de suporte a partir de setembro.",
  "ip": "189.45.10.22",
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
  "createdAt": "2026-08-25T13:41:07.882Z"
}

A trilha alimenta uma tela no painel administrativo com filtro por autor, por tipo de ação e por período, com exportação em CSV disponível para ADMIN e OWNER. Retenção: 5 anos, igual à dos registros de consentimento (Seção 22).

3.12 Casos de borda de autorização #

Situação Comportamento normativo
Sessão válida de um usuário interno desativado enquanto navegava Toda requisição autenticada revalida is_active e deleted_at; a sessão é encerrada e a resposta é 401 com code SESSION_REVOKED
Papel alterado durante uma sessão ativa Sessões revogadas na alteração (3.10, regra 6); a próxima requisição devolve 401 SESSION_REVOKED
Assinante tenta acessar rota administrativa 403 com code FORBIDDEN_ROLE, registrado com nível de log warn
Usuário interno tenta acessar rota de assinante com sessão administrativa 403 com code WRONG_SESSION_AUDIENCE; sessões de assinante e de equipe têm público distinto no token (Seção 8)
Assinante excluído logicamente com sessão ativa 401 SESSION_REVOKED; nenhuma leitura de dados é permitida após a exclusão lógica
Editor solicita regeneração de áudio de devocional já enviado 409 com code DEVOTIONAL_ALREADY_SENT; regeneração só é permitida antes do primeiro envio
Administrador tenta exportar base sem filtro Permitido, com registro de auditoria e limite de 50.000 linhas por exportação; acima disso, o pedido é fatiado por período
Requisição sem sessão em rota que exige papel 401 com code UNAUTHENTICATED, nunca 403 — a distinção entre "não autenticado" e "sem permissão" é obrigatória
Usuário interno com segundo fator não configurado Acesso limitado à tela de configuração do segundo fator; qualquer outra rota devolve 403 com code MFA_SETUP_REQUIRED
Administrador em sessão de impersonação tenta qualquer escrita em nome do assinante Recusado com 403 IMPERSONATION_READ_ONLY, independentemente do prefixo da rota. A trava vale para toda rota cuja guarda seja requireSubscriber, inclusive cancelamento de assinatura, troca de forma de pagamento, troca de plano, reenvio, pedido de eliminação e opt-out. Impersonação é somente leitura, sem exceção (Seção 8.13)

4. Arquitetura e Stack Tecnológica #

4.1 A arquitetura em um parágrafo #

Um monorepo com três aplicações e três pacotes compartilhados. A aplicação web serve a página de vendas, os dois painéis e todas as rotas HTTP, inclusive os webhooks. O worker executa tudo que é assíncrono ou agendado: planejar o lote do dia, gerar áudio, enviar mensagens, processar eventos de pagamento e reconciliar cobranças. A CLI de operação existe para semear, corrigir e reprocessar sem abrir o banco na mão. Postgres é a única fonte de verdade transacional; Redis é fila e cache, nunca fonte de verdade. Um único servidor com Docker Compose atende o alvo de escala do primeiro ano.

4.2 Stack canônica #

Esta seção é a dona de todas as versões de dependência do projeto. Nenhuma outra seção declara versão; todas referenciam esta tabela.

Camada Escolha Linha de versão
Runtime Node.js LTS ("Krypton") 24.x
Linguagem TypeScript 7.x
Gerenciador de pacotes pnpm workspaces 10.x
App web (landing + painéis + API HTTP) Next.js App Router 16.x
UI React 19.x
Estilo Tailwind CSS 4.x
Componentes shadcn/ui (primitivos Radix UI) corrente
Formulários react-hook-form 7.x
Validação (compartilhada web + worker) Zod 4.x
Estado de servidor no cliente TanStack Query 5.x
Gráficos do dashboard Recharts 3.x
Banco de dados PostgreSQL 17.x
ORM e migrações Prisma 7.x
Fila e agendamento BullMQ sobre Redis BullMQ 6.x / Redis 8.x
Cliente Redis ioredis 6.x
HTTP interno do worker (health e métricas) Fastify 5.x
Logs pino + pino-http pino 10.x
Sessões e JWT jose 6.x
Hash de senha (usuários internos) argon2 0.4x
Segundo fator TOTP otplib 13.x
Datas e fuso horário date-fns + date-fns-tz 4.x
Storage de mídia S3-compatível via @aws-sdk/client-s3 3.x
Transcodificação de áudio ffmpeg (binário do sistema operacional) 7.x
Identificadores ulid 3.x
Telefone libphonenumber-js corrente
Testes unitários e de integração Vitest 4.x
Testes de ponta a ponta Playwright 1.6x
E-mail transacional Resend SDK corrente
Containers Docker e Docker Compose Engine 27.x ou superior
Proxy reverso e TLS Caddy 2.x
Integração contínua GitHub Actions

Ferramentas de desenvolvimento sem linha de versão fixada — instale a estável corrente no momento do build: ESLint com typescript-eslint, Prettier, eslint-plugin-import, eslint-plugin-jsx-a11y, commitlint com @commitlint/config-conventional, lefthook para hooks de Git, tsx para execução de scripts TypeScript e dotenv-cli.

Estas linhas de versão são um piso conhecido-bom, não um lockfile. No momento do build, instale a release estável corrente de cada dependência (pnpm add <pkg>@latest, ou o equivalente do ecossistema), confirme que a linha major ainda corresponde e deixe o lockfile registrar as versões exatas resolvidas.

O pnpm-lock.yaml é versionado e obrigatório. A integração contínua executa pnpm install --frozen-lockfile; divergência entre o manifesto e o lockfile reprova o build. Atualizações de dependência entram por commit próprio, com prefixo chore(deps).

4.3 Serviços externos e suas interfaces #

Serviço Uso Base Autenticação
WhatsApp Business Cloud API (Meta Graph API) Envio de templates e mensagens livres, upload de mídia, webhooks de entrada e de status https://graph.facebook.com/v26.0 Token de acesso permanente no header Authorization: Bearer
Asaas Cliente, assinatura recorrente, cobrança PIX, webhooks de pagamento Produção https://api.asaas.com/v3; sandbox https://api-sandbox.asaas.com/v3 Header access_token
ElevenLabs Síntese de voz primária https://api.elevenlabs.io/v1 Chave de API em header
Google Cloud Text-to-Speech Síntese de voz de fallback texttospeech.googleapis.com/v1 Conta de serviço
Resend E-mail transacional SDK oficial Chave de API
Armazenamento compatível com S3 Ativos de áudio e mídia Endpoint configurável Chave e segredo

A versão da Graph API acima é o piso conhecido-bom. No momento do build, o executor confirma qual é a versão estável corrente aceita pelo endpoint e usa a mais nova, ajustando apenas a constante de versão no cliente tipado. Nenhuma outra parte do código conhece a versão.

4.4 Diagrama de componentes #

                       Internet
                          │
                 ┌────────▼─────────┐
                 │  Caddy (TLS,     │  palavradiaria.com.br
                 │  proxy reverso)  │  app.palavradiaria.com.br
                 └───┬──────────┬───┘  api.palavradiaria.com.br
                     │          │
        ┌────────────▼───┐   ┌──▼──────────────────┐
        │  apps/web      │   │ (mesma aplicação:    │
        │  Next.js       │   │  rotas /api/*        │
        │  ─ landing     │   │  e webhooks)         │
        │  ─ painel      │   └──┬──────────────────┘
        │    assinante   │      │
        │  ─ painel      │      │ grava evento cru + enfileira
        │    admin       │      │
        └───┬────────┬───┘      │
            │        │          │
            │        └──────────┼───────────────┐
            │                   │               │
      ┌─────▼─────────┐   ┌─────▼──────────┐    │
      │  PostgreSQL   │◄──┤   Redis         │    │
      │  fonte única  │   │  filas BullMQ   │    │
      │  de verdade   │   │  + cache leve   │    │
      └─────▲─────────┘   └─────▲──────────┘    │
            │                   │               │
            │             ┌─────┴───────────────▼────┐
            └─────────────┤  apps/worker             │
                          │  treze filas, Seção 18.8 │
                          │  ─ send.plan (05:40)     │
                          │  ─ send.dispatch         │
                          │  ─ send.followup         │
                          │  ─ whatsapp.inbound      │
                          │  ─ tts.generate          │
                          │  ─ media.upload          │
                          │  ─ billing.webhook       │
                          │  ─ billing.reconcile     │
                          │  ─ billing.lifecycle     │
                          │  ─ metrics.rollup        │
                          │  ─ maintenance.cleanup   │
                          │  ─ email.send            │
                          │  ─ messaging.pause       │
                          │  Fastify: /api/internal/health
                          │           /api/internal/metrics
                          └──┬───────┬───────┬───────┘
                             │       │       │
             ┌───────────────▼┐ ┌────▼─────┐ ┌▼──────────────────┐
             │ WhatsApp Cloud │ │ Asaas    │ │ TTS (ElevenLabs / │
             │ API (Meta)     │ │ API v3   │ │ Google) + S3      │
             └────────────────┘ └──────────┘ └───────────────────┘

             apps/ops  ── CLI executada sob demanda no host,
                          fala com Postgres, Redis e as integrações

A lista de filas acima é exatamente a da Seção 18.8, que é a dona: são treze, com nome único no formato dominio.acao. Nenhuma outra seção usa apelido ou variação. Não existe fila de mensagens mortas: um job que esgota as tentativas fica no estado failed da própria fila e grava uma linha em job_runs com status = 'DEAD', que é a dead-letter do sistema.

Regra de fronteira: somente o worker fala com o canal de mensageria e com o provedor de voz. A aplicação web nunca envia mensagem de forma síncrona dentro de uma requisição HTTP. Quando o painel pede um reenvio, ele enfileira um job e responde imediatamente. Isso mantém o p95 das rotas dentro da meta e evita que uma lentidão de terceiro derrube a interface.

4.5 Estrutura do monorepo #

palavra-diaria/
├── apps/
│   ├── web/                          # Next.js: público, painéis e API HTTP
│   │   ├── app/
│   │   │   ├── (marketing)/          # rotas públicas: /, /planos, /faq, /termos...
│   │   │   ├── (subscriber)/         # painel do assinante, exige sessão SUBSCRIBER
│   │   │   ├── (admin)/              # painel administrativo, exige sessão de equipe
│   │   │   ├── api/                  # route handlers; um arquivo route.ts por recurso
│   │   │   │   ├── public/
│   │   │   │   ├── auth/
│   │   │   │   ├── signups/
│   │   │   │   ├── sessions/
│   │   │   │   ├── me/
│   │   │   │   ├── devotionals/
│   │   │   │   ├── checkout/
│   │   │   │   ├── subscriptions/
│   │   │   │   ├── payments/
│   │   │   │   ├── admin/
│   │   │   │   ├── internal/         # health, ready, metrics, version
│   │   │   │   └── webhooks/         # asaas/route.ts, whatsapp/route.ts
│   │   │   ├── layout.tsx
│   │   │   └── not-found.tsx
│   │   ├── src/
│   │   │   ├── components/
│   │   │   │   ├── ui/               # primitivos shadcn/ui, sem regra de negócio
│   │   │   │   ├── marketing/
│   │   │   │   ├── subscriber/
│   │   │   │   └── admin/
│   │   │   ├── hooks/                # hooks de cliente, TanStack Query
│   │   │   ├── i18n/pt-BR.ts         # arquivo único de textos de interface
│   │   │   ├── legal/consent/        # versões do texto de consentimento
│   │   │   ├── lib/                  # http, formatadores, guards de sessão
│   │   │   └── styles/theme.css      # tokens de cor, tipografia e espaçamento
│   │   ├── public/
│   │   ├── middleware.ts             # roteamento por audiência de sessão
│   │   ├── next.config.ts
│   │   └── package.json
│   ├── worker/                       # processos assíncronos
│   │   ├── src/
│   │   │   ├── queues/               # definição de filas e opções de retry
│   │   │   ├── jobs/                 # um arquivo por fila; nome do arquivo = nome da fila
│   │   │   │   │                     # com ponto trocado por hífen (Seção 18.8)
│   │   │   │   ├── send-plan.ts
│   │   │   │   ├── send-dispatch.ts
│   │   │   │   ├── send-followup.ts
│   │   │   │   ├── whatsapp-inbound.ts
│   │   │   │   ├── tts-generate.ts
│   │   │   │   ├── media-upload.ts
│   │   │   │   ├── billing-webhook.ts
│   │   │   │   ├── billing-reconcile.ts
│   │   │   │   ├── billing-lifecycle.ts
│   │   │   │   ├── metrics-rollup.ts
│   │   │   │   ├── maintenance-cleanup.ts
│   │   │   │   ├── email-send.ts
│   │   │   │   └── messaging-pause.ts
│   │   │   ├── scheduler.ts          # registro dos jobs repetíveis com fuso
│   │   │   ├── http.ts               # Fastify: /api/internal/health, /api/internal/ready, /api/internal/metrics
│   │   │   └── main.ts
│   │   └── package.json
│   └── ops/                          # CLI de operação
│       ├── src/commands/             # seed, backfill, resend, health, template-sync
│       └── package.json
├── packages/
│   ├── db/                           # Prisma: schema, client, migrações, seeds
│   │   ├── prisma/
│   │   │   ├── schema.prisma
│   │   │   ├── migrations/
│   │   │   └── seed.ts
│   │   └── src/index.ts              # singleton do client + helpers de transação
│   ├── core/                         # domínio puro, sem I/O
│   │   └── src/
│   │       ├── auth/                 # papéis, permissões, contexto de sessão
│   │       ├── entitlements/         # resolveEntitlements e limites por nível
│   │       ├── subscription/         # máquina de estados da assinatura
│   │       ├── devotional/           # estados editoriais, teaser, roteiro de narração
│   │       ├── delivery/             # cálculo do lote, chaves de idempotência
│   │       ├── phone.ts              # normalização E.164 e variantes brasileiras
│   │       ├── errors.ts             # AppError e catálogo de códigos
│   │       ├── logger.ts             # fábrica pino e regras de redação
│   │       ├── schemas/              # Zod compartilhado entre web e worker
│   │       └── time.ts               # helpers de fuso America/Sao_Paulo
│   └── integrations/                 # clientes tipados de terceiros
│       └── src/
│           ├── whatsapp/             # provider, tipos, mapeamento de erros
│           ├── asaas/
│           ├── tts/                  # elevenlabs.ts, google.ts, provider.ts
│           ├── storage/              # cliente S3 e URLs assinadas
│           └── email/
├── docker/                           # Dockerfile.web, Dockerfile.worker, Caddyfile
├── .github/workflows/                # ci.yml, deploy.yml
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env.example
├── pnpm-workspace.yaml
├── package.json                      # scripts raiz: dev, build, test, lint, typecheck
├── tsconfig.base.json
└── README.md

Decisão registrada: não há Turborepo, Nx ou qualquer orquestrador de build no MVP. Com três aplicações e três pacotes, os filtros nativos do gerenciador de pacotes (pnpm --filter web build) resolvem o problema sem adicionar uma camada de cache para manter. Se o tempo de build ultrapassar cinco minutos na integração contínua, a decisão é revista.

4.6 Responsabilidade de cada aplicação #

apps/web — superfície HTTP única. Serve HTML das páginas públicas e dos painéis, e expõe todas as rotas /api/*, incluindo os webhooks. Responsabilidades exclusivas: renderizar interface, validar entrada, autenticar e autorizar, escrever no banco o que é síncrono por natureza (cadastro, aceite de consentimento, criação de assinatura) e enfileirar tudo que é lento. Proibições: chamar o canal de mensageria; chamar o provedor de voz; executar transcodificação; rodar laços sobre a base inteira.

apps/worker — dono de todo trabalho assíncrono, agendado e de longa duração. Registra os jobs repetíveis com fuso America/Sao_Paulo, consome as filas, aplica limite de taxa, retries com espera exponencial e idempotência. Expõe um servidor HTTP interno mínimo com verificação de saúde e métricas, acessível apenas na rede interna do Compose. É o único processo com credenciais do canal de mensageria e do provedor de voz.

apps/ops — comandos de operação executados sob demanda: semear planos e usuário inicial, sincronizar templates com a plataforma, reprocessar um lote, reenviar para um assinante, recalcular métricas de um período, verificar saúde das integrações, exportar dados de um titular. Toda operação destrutiva exige confirmação explícita por argumento e grava registro na trilha de auditoria com autor ops-cli e o usuário do sistema operacional.

4.7 Responsabilidade de cada pacote #

packages/db — dono do schema e do acesso ao banco. Exporta o client singleton, o helper de transação e os tipos gerados. Contém as migrações e os seeds. Nenhuma aplicação instancia o client por conta própria; todas importam daqui, o que garante um único pool de conexões por processo.

packages/core — domínio puro, sem entrada e sem saída. Regras de nível de acesso, máquina de estados da assinatura, estados editoriais, normalização de telefone, cálculo do lote diário, chaves de idempotência, classe de erro, catálogo de códigos, fábrica de logger e todos os schemas Zod. Regra dura: este pacote não importa packages/db nem packages/integrations, não faz chamada de rede, não lê variável de ambiente e não usa relógio global sem receber o instante por parâmetro. Isso o torna testável sem infraestrutura e é o que permite que web e worker apliquem exatamente a mesma regra.

packages/integrations — clientes tipados de terceiros. Cada integração expõe uma interface e uma implementação. Traduz erros de terceiros para o catálogo de erros interno, aplica tempo limite, retry e registro estruturado. Nunca contém regra de negócio: decidir se uma mensagem deve ser enviada é do domínio; como enviar é daqui.

O grafo de dependências entre pacotes é acíclico e verificado por regra de lint:

core  ◄── db            core  ◄── integrations
  ▲                       ▲
  └────── web ────────────┘
  └────── worker ─────────┘
  └────── ops ────────────┘

4.8 Fluxos de ponta a ponta #

Os quatro caminhos abaixo são os que definem a arquitetura. O detalhamento de cada passo está nas seções donas; aqui interessa quem chama quem, o que é síncrono e onde está a fronteira transacional.

4.8.1 Cadastro e opt-in #

Navegador        apps/web            Postgres        Redis/Fila        worker          Canal
   │                │                   │               │                │              │
   │ POST /api/signups                  │               │                │              │
   ├───────────────►│ valida Zod        │               │                │              │
   │                │ normaliza telefone│               │                │              │
   │                │ (E.164 validado   │               │                │              │
   │                │  ANTES de cifrar) │               │                │              │
   │                ├── TX ────────────►│ subscriber (PENDING_VERIFICATION)             │
   │                │                   │ consent_event (append-only)    │              │
   │                │                   │ otp_code (hash, TTL 10 min)    │              │
   │                │◄── commit ────────┤               │                │              │
   │                ├───────────────────┼──────────────►│ send.dispatch  │              │
   │◄─ 202 {status} │                   │               ├───────────────►│ template     │
   │                │                   │               │                ├─────────────►│
   │ POST /api/signups/verifications    │               │                │              │
   ├───────────────►│ confere hash e tentativas         │                │              │
   │                ├── TX ────────────►│ marca telefone verificado      │              │
   │                │                   │ cria sessão (30 dias)          │              │
   │◄─ 200 + cookie │                   │               │                │              │
   │                │                   │               │ send.dispatch  │              │
   │                │                   │               ├───────────────►│ boas-vindas  │
   │                │                   │               │                ├─────────────►│
   │                │  (assinante responde SIM ou toca no botão)         │              │
   │                │  webhook de entrada ──────────────────────────────►│ whatsapp.    │
   │                │                   │                                │ inbound      │
   │                │                   │◄─ opt_in_confirmed_at ─────────┤              │
   │                │                   │  nível FREE, elegível ao envio │              │

Regra crítica: enquanto opt_in_confirmed_at for nulo, o assinante não entra em nenhum lote. O cadastro é reversível e não gera cobrança.

4.8.2 Pagamento e liberação do acesso #

Painel          apps/web           Asaas            Postgres         Fila          worker
  │                │                 │                 │              │              │
  │ POST /api/me/subscription        │                 │              │              │
  ├───────────────►│ valida CPF e plano                │              │              │
  │                ├── TX ──────────────────────────►  │ subscription (PENDING_PAYMENT)
  │                │◄─ commit ───────────────────────  │              │              │
  │                ├─ POST /v3/customers ──►│          │              │              │
  │                ├─ POST /v3/subscriptions ►│ externalReference = id nosso        │
  │                │◄─ id da assinatura ─────┤          │              │              │
  │◄─ 200 {checkout│ ou QR PIX}      │       │          │              │              │
  │                │                 │       │          │              │              │
  │        (assinante paga)          │       │          │              │              │
  │                │  POST /api/webhooks/asaas         │              │              │
  │                │◄────────────────┤ PAYMENT_CONFIRMED             │              │
  │                ├── TX ──────────────────────────►  │ payment_event (dedupe por id)
  │                ├───────────────────────────────────┼─────────────►│ billing.webhook
  │                ├─ 200 em < 2 s ─►│       │          │              │              │
  │                │                 │       │          │              ├── TX ───────►│
  │                │                 │       │  subscription ACTIVE + tier PAID       │
  │                │                 │       │  subscription_event + payment          │
  │                │                 │       │          │              │  notifica canal

O handler do webhook faz o mínimo: autentica, persiste o evento cru, enfileira e responde. Todo o efeito colateral acontece no worker, de forma idempotente por identificador de evento. O motivo é operacional: a operadora pausa a fila de webhooks após falhas consecutivas, e um handler lento vira uma falha. Pela mesma razão, corpo inválido nunca produz resposta não-2xx: autenticado o remetente, qualquer falha posterior responde 200 e é persistida para reprocessamento, conforme a Seção 7.15.1. A única exceção é 413 para corpo acima do limite, medido antes da leitura.

4.8.3 Envio diário #

05:40  scheduler (job repetível, fuso nomeado America/Sao_Paulo)
   │
   ├─► send.plan  ── chave de idempotência plan:{data}
   │      ├─ carrega devocional publicado da data
   │      ├─ se não houver → alerta crítico e encerra sem enviar
   │      ├─ seleciona destinatários:
   │      │    PAID  → todos com opt-in confirmado e sem opt-out
   │      │    FREE  → somente no dia do envio gratuito
   │      ├─ cria send_batch + uma delivery_attempt por assinante
   │      └─ enfileira send.dispatch com atraso até 06:00
   │
06:00  send.dispatch (limite de 20 mensagens por segundo)
   │      ├─ RELÊ o assinante por chave primária, imediatamente antes de chamar
   │      │  o provedor: deleted_at, blocked_at, opt_out_at e tier
   │      │     deleted_at/blocked_at → encerra como SKIPPED_INELIGIBLE
   │      │     opt_out_at            → encerra como SKIPPED_OPTED_OUT
   │      │     tier FREE em item planejado como PAID → rebaixa na hora,
   │      │        entrega só o texto, nunca o áudio
   │      ├─ janela de conversa aberta?
   │      │     sim → envia texto completo; se PAID, envia áudio; envia fechamento
   │      │     não → envia template de convite com dois botões
   │      ├─ grava message_log (status inicial: sent)
   │      └─ erro conhecido do canal → política própria por código (Seção 27)
   │
06:00+ webhooks de status ─► whatsapp.inbound ─► atualiza message_logs
   │
06:30  resumo do lote no canal de alertas: planejados, enviados, falhos, custo estimado

4.8.4 Resposta do assinante #

Assinante toca em "Ler e ouvir agora"
   │
   ├─► Canal ─── POST /api/webhooks/whatsapp ───► apps/web
   │                 ├─ valida assinatura HMAC do corpo cru
   │                 ├─ grava inbound_message
   │                 ├─ enfileira whatsapp.inbound
   │                 └─ responde 200 imediatamente
   │
   └─► worker: whatsapp.inbound
          ├─ localiza assinante: wa_id_hmac → phone_hmac → variantes de nono dígito
          ├─ PRIMEIRA operação sobre a mensagem, antes de qualquer outra:
          │     palavra de saída → opt_out_at, confirma uma única vez, para tudo.
          │     Precedência absoluta, válida em qualquer estado do assinante,
          │     inclusive em atendimento humano ou modo silencioso (Seção 19.5)
          ├─ atualiza service_window_expires_at = agora + 24 h
          ├─ classifica o restante:
          │     payload OPEN_DEVOTIONAL  → entrega o pacote do dia
          │     payload SNOOZE           → reagenda para 12:00 do mesmo dia
          │     palavra de retorno       → limpa opt_out_at, confirma
          │     texto livre              → responde com o menu de ajuda
          └─ toda entrega respeita o nível de acesso relido no instante do envio

4.9 Decisões de arquitetura e seus custos #

# Decisão Alternativa descartada Por quê O que custa
A1 Next.js servindo interface e API na mesma aplicação API separada em Fastify Um artefato, um deploy, tipos compartilhados sem contrato duplicado; o volume de rotas não justifica dois serviços Rotas de API herdam o ciclo de vida do framework de interface; mitigado por manter as rotas finas
A2 Worker em processo separado Executar jobs dentro da aplicação web Envio diário e transcodificação são longos e travariam requisições; escalar e reiniciar independente é requisito Mais um processo para operar e observar
A3 BullMQ sobre Redis cron do sistema chamando um script Precisamos de retry com espera exponencial, limite de taxa, idempotência, jobs atrasados, visibilidade de falha e reprocessamento. cron não oferece nada disso e não sobrevive a reinício no meio do lote Redis vira dependência de operação; exige política de memória sem despejo
A4 PostgreSQL como única fonte de verdade Coleção de documentos ou banco gerenciado exótico Dados fortemente relacionais, necessidade de transação em várias tabelas na revogação de acesso, e agregações analíticas simples Escala vertical antes de horizontal
A5 Prisma como ORM e ferramenta de migração SQL puro Tipos gerados, migrações versionadas e verificação em tempo de compilação; consultas críticas usam SQL bruto onde o plano importa Camada extra; consultas complexas exigem escape para SQL
A6 ULID gerado na aplicação UUID v4 ou chave serial Ordenável no tempo, o que preserva localidade de índice em tabelas de alto volume; gerado antes da ida ao banco, o que permite montar grafos de objetos e chaves de idempotência sem ida e volta; não expõe contagem de registros como serial 26 caracteres em vez de 16 bytes; sem tipo nativo no banco
A7 Armazenamento compatível com S3 Disco local do servidor O acervo de áudio é o ativo mais caro; disco local impede restaurar o serviço em outra máquina e complica backup Egresso e latência de rede; mitigado por gerar o áudio uma vez por devocional
A8 Canal de mensageria oficial, sem intermediário Provedor de solução (BSP) Ver 4.10 Menos suporte humano em incidente
A9 Um servidor com Docker Compose Orquestrador de containers ou plataforma gerenciada Alvo de escala do primeiro ano cabe folgadamente em uma máquina; a complexidade operacional de um orquestrador não se paga Reinício do host derruba tudo; mitigado com verificação de saúde, reinício automático e restauração ensaiada
A10 Redis com política sem despejo Cache com despejo automático Fila e cache dividem a instância; despejo silencioso apagaria jobs Uso de memória precisa ser monitorado com alerta
A11 Fuso operacional único Horário por assinante Simplifica o motor de envio, o planejamento e os testes; personalização está fora de escopo Assinante fora do fuso recebe em horário estranho — aceito

4.10 Canal oficial em vez de intermediário, e a camada que permite trocar #

Decisão: integrar diretamente à API oficial do canal de mensageria, sem provedor intermediário.

Motivos: custo por mensagem sem margem de terceiro; ausência de assinatura mensal fixa, relevante enquanto a base é pequena; acesso imediato a recursos novos, sem esperar o intermediário implementar; controle direto sobre o cadastro e a aprovação de templates; e um elo a menos na cadeia de entrega, o que reduz a superfície de falha do momento mais crítico do dia.

O que se perde: suporte humano em incidente, painéis prontos de análise, filas gerenciadas e ferramentas de atendimento. Como o produto é de entrega, não de atendimento, a perda é aceitável.

Gatilhos objetivos para migrar para um intermediário, decididos desde já: volume mensal acima de 500.000 mensagens; necessidade de mais de um número na mesma operação; ou dois incidentes de suspensão de conta em doze meses. Nenhum deles exige reescrever o motor de envio, por causa da abstração abaixo.

// packages/integrations/src/whatsapp/provider.ts
export interface WhatsAppProvider {
  sendTemplate(input: SendTemplateInput): Promise<SendResult>
  sendText(input: SendTextInput): Promise<SendResult>
  sendAudio(input: SendAudioInput): Promise<SendResult>
  uploadMedia(input: UploadMediaInput): Promise<{ mediaId: string; expiresAt: Date }>
  markAsRead(messageId: string): Promise<void>
  parseInboundWebhook(rawBody: string, signature: string): InboundEvent[]
}

export type SendResult =
  | { ok: true; providerMessageId: string; conversationCategory: string | null }
  | { ok: false; error: ProviderError }

/** Erro já traduzido para o vocabulário interno; o motor nunca vê código bruto. */
export interface ProviderError {
  kind:
    | 'OUTSIDE_SERVICE_WINDOW'
    | 'UNDELIVERABLE'
    | 'DELIVERY_THROTTLED'
    | 'TEMPLATE_PROBLEM'
    | 'RATE_LIMITED'
    | 'ACCOUNT_PROBLEM'
    | 'UNKNOWN'
  providerCode: string
  retryable: boolean
  retryAfterMs?: number
  message: string
}

O motor de envio (Seção 18) depende apenas desta interface. Trocar de provedor significa escrever uma segunda implementação e mudar uma linha de composição no início do worker. A tradução de códigos de erro do canal para ProviderError.kind fica dentro da implementação, e é o único lugar do sistema que conhece números de erro de terceiros. O mesmo padrão vale para voz (TtsProvider, Seção 16) e para pagamentos.

4.11 Limites de escala do desenho #

Dimensionamento de referência: 10.000 assinantes no primeiro ano, sendo 3.000 pagos; arquitetura sustentada até 50.000 sem mudança estrutural. Uma máquina com 4 vCPU e 8 GB de memória atende com folga.

Contas que sustentam a afirmação: 3.000 mensagens a 20 por segundo levam 2,5 minutos de chamadas ao canal; a janela de envio permitida é de 20 minutos, o que deixa margem de oito vezes para retries. No domingo, 10.000 mensagens levam cerca de 8,5 minutos. O áudio é gerado uma vez por devocional, não por assinante: 30 gerações por mês, independentemente do tamanho da base. O volume diário de registros de mensagem em 50.000 assinantes fica na casa de 150.000 linhas por dia, o que a partição mensal absorve.

Limite Sinal de que foi atingido O que muda
~50.000 assinantes Lote diário passando de 15 minutos Aumentar o limite de taxa de envio e rodar dois processos de worker consumindo a mesma fila; nenhuma mudança de código
Postgres saturado em escrita Espera de escrita acima de 50 ms no p95 Mover o banco para instância própria e separar réplica de leitura para o dashboard
Redis acima de 70% da memória Alerta de memória Reduzir retenção de jobs concluídos e mover cache para instância separada
Volume acima de 500.000 mensagens/mês Custo por assinante acima do limite de alerta Reavaliar provedor de mensageria (4.10) e negociar volume
Mais de um número de origem Necessidade de segmentar por marca ou região Introduzir seleção de número por lote; exige tocar no motor de envio
Registros de mensagem acima de 100 milhões de linhas Consulta de auditoria lenta Mover partições antigas para armazenamento frio e manter apenas 6 meses ativos
Equipe editorial acima de cinco pessoas Conflito de edição no mesmo devocional Introduzir bloqueio otimista visível na interface; o histórico de revisões já existe

O que não muda em nenhum desses cenários: a fonte única de verdade continua sendo o banco relacional; o áudio continua sendo gerado uma vez por devocional; e o envio continua idempotente por assinante e data.

4.12 Topologia de processos #

Processo Réplicas no MVP Reinício Verificação de saúde
caddy 1 sempre porta 443 respondendo
web 1 sempre GET /api/internal/health retornando 200
worker 1 sempre GET /api/internal/health na porta interna
postgres 1 sempre pg_isready
redis 1 sempre PING

As migrações são aplicadas na inicialização do container da aplicação web, com bloqueio de advertência no banco para impedir execução concorrente. O worker aguarda o banco estar na versão esperada antes de consumir filas; se a versão divergir, ele registra erro e não consome, para evitar processar com schema antigo. Detalhes de deploy, imagens e volumes estão na Seção 25.


5. Convenções de Código e Padrões de Projeto #

5.1 Princípios #

  1. Regra de negócio mora no domínio. Rota HTTP e job de fila são cascas finas.
  2. Uma regra, um lugar. Nível de acesso, normalização de telefone, estados da assinatura e chaves de idempotência existem uma única vez, no domínio compartilhado.
  3. Validação na borda, tipos por dentro. Toda entrada externa passa por Zod. Depois disso, o código confia nos tipos.
  4. Erro é dado, não exceção genérica. Todo erro previsível tem código estável.
  5. Explícito vence esperto. Sem metaprogramação, sem abstração antecipada, sem herança profunda.

5.2 Convenções de nomes #

Elemento Convenção Exemplo
Arquivo TypeScript kebab-case.ts send-dispatch.ts, resolve-entitlements.ts
Componente React PascalCase.tsx, um componente exportado por arquivo DevotionalCard.tsx
Pasta kebab-case, singular para domínio, plural para coleções de rota src/subscription/, app/api/signups/
Tabela Postgres snake_case plural message_logs, delivery_attempts
Coluna snake_case; booleano com prefixo is_ ou has_; data e hora com sufixo _at; data pura com sufixo _on ou nome explícito is_active, opt_in_confirmed_at, scheduled_for
Modelo do ORM PascalCase singular com mapeamento explícito para a tabela model MessageLog { @@map("message_logs") }
Tipo enumerado SCREAMING_SNAKE_CASE nos valores, PascalCase no nome SubscriptionStatus.PENDING_PAYMENT
Campo JSON de API camelCase; conversão só na borda, nunca no banco optInConfirmedAt
Rota HTTP kebab-case, sem verbo; coleção é substantivo no plural /api/admin/devotionals, /api/admin/whatsapp-templates
Ação sobre a própria conta do assinante Sob /api/me/, com nome de ação no singular /api/me/opt-out, /api/me/pause, /api/me/deletion-request, /api/me/subscription/cancel
Código de erro SCREAMING_SNAKE_CASE, estável para sempre RESEND_LIMIT_REACHED
Fila e job dominio.acao em minúsculas, da lista fechada de treze da Seção 18.8 send.dispatch, billing.reconcile
Arquivo de job Nome da fila com o ponto trocado por hífen send-dispatch.ts para send.dispatch
Variável de ambiente SCREAMING_SNAKE_CASE com prefixo do serviço WHATSAPP_PHONE_NUMBER_ID
Chave de configuração em banco grupo.chave, minúsculas, ponto como separador archive.free_days, ops.kill_switch
Campo monetário Inteiro em centavos de BRL, sufixo _amount_cents na coluna e AmountCents no JSON subscriptions.amount_cents, amountCents
Custo unitário muito pequeno Inteiro em milionésimos de BRL, sufixo _cost_micros message_logs.cost_micros, audio_assets.cost_micros
Função camelCase, verbo no início resolveEntitlements, buildNarrationScript
Booleano em código is, has, can, should canResend, hasOpenWindow
Constante de módulo SCREAMING_SNAKE_CASE MAX_TEASER_LENGTH
Tipo e interface PascalCase, sem prefixo I WhatsAppProvider, SendResult
Branch tipo/escopo-curto feat/daily-send-engine
Commit Conventional Commits (5.12) feat(delivery): pular template com janela aberta

Proibições explícitas: abreviações inventadas (subsc, dvtnl); prefixo húngaro; sufixo Manager, Helper ou Util em nome de módulo; arquivo utils.ts genérico — o nome descreve o que faz (format-currency.ts, sanitize-teaser.ts).

Duas regras de dinheiro que valem em todo o código, sem exceção: nunca use ponto flutuante para valor monetário, e nunca misture centavos com micros na mesma fórmula sem conversão explícita. Os divisores são fixos: centavos para reais divide por 100; micros para reais divide por 1_000_000. A conversão entre o decimal do provedor de pagamento e os centavos internos acontece em um único arquivo, packages/integrations/src/asaas/mapper.ts.

5.3 Imports e ordem #

Quatro grupos, separados por linha em branco, nesta ordem, com ordenação alfabética dentro de cada grupo. A regra é aplicada por lint com correção automática.

// 1. Node e bibliotecas externas
import { createHash } from 'node:crypto'
import { z } from 'zod'

// 2. Pacotes internos do monorepo
import { prisma } from '@pd/db'
import { AppError, resolveEntitlements } from '@pd/core'
import { whatsapp } from '@pd/integrations'

// 3. Módulos da própria aplicação, por alias absoluto
import { getSessionOrThrow } from '@/lib/session'
import { t } from '@/i18n/pt-BR'

// 4. Módulos irmãos, por caminho relativo curto
import { resendService } from './resend.service'
import type { ResendInput } from './resend.types'

Regras adicionais: aliases @pd/* para pacotes e @/* para o interior de cada aplicação, definidos em tsconfig.base.json; nada de ../../.. — a partir de dois níveis, use alias; import type obrigatório para importações que só carregam tipos; nenhuma exportação padrão, exceto onde o framework exige (páginas, layouts e route.ts); nenhum arquivo de reexportação em massa dentro de uma aplicação, porque quebra o descarte de código morto.

5.4 Estrutura interna de um módulo de domínio #

Um módulo de domínio agrupa arquivos por assunto, nunca por tipo técnico. Exemplo do módulo de assinatura em packages/core/src/subscription/:

subscription/
├── subscription.types.ts       # tipos e uniões discriminadas do domínio
├── subscription.schemas.ts     # Zod de entrada e saída deste assunto
├── subscription.errors.ts      # códigos de erro específicos do assunto
├── subscription.state.ts       # máquina de estados: transições permitidas e guardas
├── subscription.rules.ts       # funções puras de regra (ex.: cálculo de fim de ciclo)
├── index.ts                    # superfície pública do módulo, explícita
└── __tests__/
    ├── subscription.state.test.ts
    └── subscription.rules.test.ts

O index.ts exporta apenas o que outros módulos podem usar. Importar um arquivo interno de outro módulo é erro de lint. Isso mantém a superfície pequena e permite refatorar por dentro sem quebrar consumidores.

5.5 Camadas: route handler, service, repository #

Três camadas, com responsabilidades sem sobreposição:

Camada Faz Nunca faz
Route handler Autentica, valida entrada com Zod, chama um service, formata a resposta no envelope padrão Consulta o banco; contém if de regra de negócio
Service Orquestra regra de domínio, transação, enfileiramento e efeitos colaterais Conhece Request, Response, cookies ou cabeçalhos
Repository Traduz entre domínio e banco; consultas e escritas Decide regra; lança erro de negócio

Exemplo completo de uma operação real — reenvio manual do devocional do dia pedido pelo assinante no painel. O contrato normativo desta rota está na Seção 7; abaixo ela serve de referência de forma.

// apps/web/app/api/devotionals/[devotionalId]/resends/route.ts
import { NextRequest } from 'next/server'
import { AppError } from '@pd/core'
import { getSubscriberSession } from '@/lib/session'
import { fail, ok } from '@/lib/http'
import { resendTodayDevotional } from '@/server/devotional/resend.service'
import { ResendTodaySchema } from '@/server/devotional/resend.schemas'

export async function POST(req: NextRequest) {
  const requestId = req.headers.get('x-request-id') ?? undefined
  try {
    const session = await getSubscriberSession(req) // lança UNAUTHENTICATED
    const body = ResendTodaySchema.parse(await req.json().catch(() => ({})))
    const result = await resendTodayDevotional({
      subscriberId: session.subscriberId,
      channel: body.channel,
      requestId,
    })
    return ok(result, { requestId })
  } catch (error) {
    return fail(error, { requestId })
  }
}
// apps/web/src/server/devotional/resend.service.ts
import { AppError, resolveEntitlements, todayInSaoPaulo } from '@pd/core'
import { prisma, withTransaction } from '@pd/db'
import { enqueue } from '@/lib/queue'
import { subscriberRepo } from './subscriber.repo'
import { devotionalRepo } from './devotional.repo'
import { resendRepo } from './resend.repo'

export async function resendTodayDevotional(input: {
  subscriberId: string
  channel: 'whatsapp'
  requestId?: string
}) {
  const today = todayInSaoPaulo()

  return withTransaction(async (tx) => {
    // 1. Carrega o agregado com bloqueio, para tornar a contagem de reenvios segura.
    const subscriber = await subscriberRepo.findForUpdate(tx, input.subscriberId)
    if (!subscriber) throw new AppError('SUBSCRIBER_NOT_FOUND', 404)
    if (subscriber.optOutAt) throw new AppError('SUBSCRIBER_OPTED_OUT', 409)

    // 2. Regra de domínio: o que este assinante tem direito de fazer hoje.
    const entitlements = resolveEntitlements(subscriber, { now: today })

    // 3. Existe devocional atribuído a este assinante hoje?
    const devotional = await devotionalRepo.findSentToSubscriberOn(tx, subscriber.id, today)
    if (!devotional) {
      throw entitlements.tier === 'FREE'
        ? new AppError('NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER', 409)
        : new AppError('NO_DEVOTIONAL_FOR_TODAY', 409)
    }

    // 4. Limite diário de reenvio, contado no fuso operacional.
    const used = await resendRepo.countForDay(tx, subscriber.id, today)
    if (used >= entitlements.resendLimitPerDay) {
      throw new AppError('RESEND_LIMIT_REACHED', 429, {
        details: [{ field: 'resend', issue: 'daily_limit', limit: entitlements.resendLimitPerDay }],
      })
    }

    // 5. Registro do consumo dentro da mesma transação: sem corrida, sem crédito extra.
    const attempt = await resendRepo.create(tx, {
      subscriberId: subscriber.id,
      devotionalId: devotional.id,
      day: today,
      sequence: used + 1,
    })

    // 6. Efeito colateral fora do banco só depois do commit.
    tx.afterCommit(() =>
      enqueue('send.dispatch', {
        subscriberId: subscriber.id,
        devotionalId: devotional.id,
        reason: 'MANUAL_RESEND',
        idempotencyKey: `resend:${subscriber.id}:${today}:${used + 1}`,
      }),
    )

    return {
      status: 'QUEUED' as const,
      remainingToday: entitlements.resendLimitPerDay - (used + 1),
      attemptId: attempt.id,
    }
  })
}
// apps/web/src/server/devotional/resend.repo.ts
import type { Tx } from '@pd/db'

export const resendRepo = {
  countForDay(tx: Tx, subscriberId: string, day: string) {
    return tx.deliveryAttempt.count({
      where: { subscriberId, deliveryDate: day, reason: 'MANUAL_RESEND' },
    })
  },

  create(tx: Tx, data: { subscriberId: string; devotionalId: string; day: string; sequence: number }) {
    return tx.deliveryAttempt.create({
      data: {
        subscriberId: data.subscriberId,
        devotionalId: data.devotionalId,
        deliveryDate: data.day,
        reason: 'MANUAL_RESEND',
        idempotencyKey: `resend:${data.subscriberId}:${data.day}:${data.sequence}`,
        status: 'QUEUED',
      },
    })
  },
}

Três pontos que o exemplo fixa como norma: a contagem e a gravação do consumo acontecem na mesma transação, com bloqueio de linha, o que elimina a corrida de dois cliques simultâneos; o enfileiramento só ocorre após o commit, o que impede job órfão apontando para um registro que não existe; e a regra de quanto vale o limite vem do domínio, não da rota.

5.6 Validação com Zod #

Os schemas de entrada e saída vivem em packages/core/src/schemas/ quando são compartilhados entre a aplicação web e o worker, e ao lado do módulo quando são locais. Web e worker importam o mesmo schema — nunca existem duas definições da mesma forma de dado.

// packages/core/src/schemas/subscriber.schemas.ts
import { z } from 'zod'
import { normalizePhoneBR } from '../phone'

/**
 * A validação de formato E.164 acontece **aqui**, na borda, antes de o valor ser cifrado.
 * A coluna que guarda o telefone é um envelope cifrado (Seção 6.3) e por isso não pode
 * carregar CHECK de formato: o banco não vê o valor. Zod é a única barreira de formato.
 */
export const PhoneBRSchema = z
  .string()
  .trim()
  .min(10, 'Telefone muito curto.')
  .transform((raw, ctx) => {
    const parsed = normalizePhoneBR(raw)
    if (!parsed.ok) {
      ctx.addIssue({ code: 'custom', message: 'Informe um celular brasileiro válido com DDD.' })
      return z.NEVER
    }
    return parsed.e164 // sempre +55DDNNNNNNNNN a partir daqui
  })

export const RegisterSubscriberSchema = z.object({
  name: z.string().trim().min(2).max(80),
  phone: PhoneBRSchema,
  email: z.email().optional(),
  consentAccepted: z.literal(true, { message: 'É necessário aceitar para continuar.' }),
  consentVersion: z.string().regex(/^v\d+$/),
})

export type RegisterSubscriberInput = z.infer<typeof RegisterSubscriberSchema>

Regras normativas de validação:

  • Todo parse acontece na borda: route handler, consumidor de fila e comando da CLI. Nunca no meio de um service.
  • Falha de validação vira VALIDATION_FAILED com details derivado das issues do Zod, uma entrada por campo, conforme o envelope de erro da Seção 7.
  • Payload de job também é validado na entrada do worker. Um job antigo, enfileirado antes de um deploy, pode ter forma incompatível: nesse caso o job falha de forma explícita, em vez de corromper dados.
  • Mensagens de validação são escritas em português do Brasil e seguras para exibir ao usuário final.
  • z.coerce é proibido em entrada vinda da rede. Conversão explícita evita aceitar "0" onde se espera booleano.

5.7 Tratamento de erro no código #

Existe uma única classe de erro de negócio, e ela se chama AppError — em todo o sistema, em toda seção deste documento e em todo arquivo do repositório. O nome ApiError é proibido: não existe classe com esse nome, e uma regra de lint (no-restricted-syntax sobre NewExpression[callee.name='ApiError'] e sobre a declaração da classe) reprova o build se ele aparecer. A classe vive em packages/core/src/errors.ts e carrega o código estável, o status HTTP e os detalhes que o envelope de erro da Seção 7 exige. A mensagem em português vem do catálogo, não de texto solto no meio do código.

// packages/core/src/errors.ts
export interface ErrorDetail { field?: string; issue: string; [k: string]: unknown }

export class AppError extends Error {
  readonly code: string
  readonly status: number
  readonly details?: ErrorDetail[]
  readonly cause?: unknown
  /** true = falha esperada de negócio; registrada como warn, não como error. */
  readonly expected: boolean

  constructor(
    code: string,
    status = 400,
    options: { details?: ErrorDetail[]; cause?: unknown; expected?: boolean } = {},
  ) {
    super(ERROR_MESSAGES[code] ?? 'Não foi possível concluir a operação.')
    this.name = 'AppError'
    this.code = code
    this.status = status
    this.details = options.details
    this.cause = options.cause
    this.expected = options.expected ?? status < 500
  }
}

export function isAppError(e: unknown): e is AppError {
  return e instanceof AppError
}

O mapeamento para HTTP acontece em um único lugar por aplicação:

// apps/web/src/lib/http.ts
import { ZodError } from 'zod'
import { AppError, isAppError } from '@pd/core'
import { logger } from '@/lib/logger'

export function ok<T>(data: T, meta: { requestId?: string } = {}) {
  return Response.json(
    { data, meta: { requestId: meta.requestId, timestamp: new Date().toISOString() } },
    { status: 200, headers: meta.requestId ? { 'X-Request-Id': meta.requestId } : undefined },
  )
}

export function fail(error: unknown, meta: { requestId?: string } = {}) {
  const mapped = toAppError(error)
  const log = logger.child({ requestId: meta.requestId, code: mapped.code })
  if (mapped.expected) log.warn({ details: mapped.details }, 'request_failed_expected')
  else log.error({ err: mapped, cause: mapped.cause }, 'request_failed_unexpected')

  return Response.json(
    {
      error: { code: mapped.code, message: mapped.message, details: mapped.details },
      meta: { requestId: meta.requestId, timestamp: new Date().toISOString() },
    },
    { status: mapped.status },
  )
}

function toAppError(error: unknown): AppError {
  if (isAppError(error)) return error
  if (error instanceof ZodError) {
    return new AppError('VALIDATION_FAILED', 422, {
      details: error.issues.map((i) => ({ field: i.path.join('.') || undefined, issue: i.code })),
    })
  }
  return new AppError('INTERNAL_ERROR', 500, { cause: error, expected: false })
}

Regras: nenhum catch silencioso; nenhum throw de string ou objeto literal; erro de terceiro é traduzido na camada de integração, nunca vaza para a rota; detalhe interno (consulta SQL, corpo de resposta de terceiro, rastreamento de pilha) nunca entra no campo message, que é exibido ao usuário. O catálogo completo de códigos é da Seção 7.

5.8 Logging estruturado #

Um logger, criado uma vez por processo, sempre em JSON. Nunca console.log em código de produção — é erro de lint.

// packages/core/src/logger.ts
import pino from 'pino'

export function createLogger(service: 'web' | 'worker' | 'ops') {
  return pino({
    level: process.env.LOG_LEVEL ?? 'info',
    base: { service, env: process.env.APP_ENV, version: process.env.BUILD_SHA },
    timestamp: pino.stdTimeFunctions.isoTime,
    redact: {
      paths: [
        'req.headers.authorization', 'req.headers.cookie', 'req.headers["access_token"]',
        'req.headers["asaas-access-token"]', 'req.headers["x-hub-signature-256"]',
        '*.password', '*.passwordHash', '*.otp', '*.otpHash', '*.token', '*.accessToken',
        '*.creditCard', '*.creditCardNumber', '*.ccv', '*.cpfCnpj', '*.cpf',
        '*.phone', '*.phoneE164', '*.waId', '*.email', '*.apiKey', '*.secret',
        // URL assinada É credencial: quem tem a URL tem o arquivo.
        'url', 'mediaUrl', 'signedUrl', 'downloadUrl', 'audioUrl', 'videoUrl',
        'presignedUrl', 'invoiceUrl', '*.url', '*.mediaUrl', '*.signedUrl',
        '*.downloadUrl', '*.audioUrl', '*.videoUrl', '*.presignedUrl', '*.invoiceUrl',
      ],
      censor: '[REDACTED]',
    },
  })
}

Campos obrigatórios em todo registro de log, além dos automáticos: requestId nas rotas HTTP; jobId, jobName e attempt nos jobs; subscriberId quando houver assinante; devotionalId quando houver devocional; code quando houver erro. Mensagens de log são identificadores estáveis em inglês, em snake_case (daily_batch_planned, whatsapp_send_failed), porque são consultadas por filtro — a prosa em português fica na interface, não no log.

Proibições de conteúdo: telefone completo, número de documento, endereço de e-mail, código de acesso, token, corpo de mensagem enviada ao assinante, URL assinada de armazenamento ou de mídia da plataforma de mensagens e qualquer credencial. Para correlacionar sem expor, registre o identificador interno do assinante e, quando for inevitável identificar o telefone em investigação, apenas os quatro últimos dígitos, no campo phoneLast4. Mídia é referida por mediaKey — o caminho no bucket, sem host e sem assinatura — ou pelo identificador de mídia da plataforma, nunca pela URL completa.

5.9 Padrão de teste #

Tipo Arquivo Onde roda Objetivo
Unitário *.test.ts ao lado do módulo, em __tests__/ Sem banco, sem rede Funções puras do domínio
Integração *.integration.test.ts Postgres real em container Repositórios, transações, jobs
Ponta a ponta *.e2e.ts em e2e/ Aplicação em execução Fluxos do usuário

Convenção de nomes de caso: descreve o sujeito > deve <comportamento esperado> quando <condição>. Exemplo: resolveEntitlements > deve limitar o acervo a 7 dias quando o nível é FREE.

Factories ficam em packages/core/src/testing/factories/ e produzem objetos válidos por padrão, com sobrescrita parcial: makeSubscriber({ tier: 'PAID' }). Fixtures de payload de terceiros ficam em __fixtures__/ como JSON real capturado, e não inventado à mão. Toda suíte de integração roda dentro de uma transação revertida ao final, exceto quando o teste verifica commit; nesse caso, a base é truncada por lista explícita de tabelas.

Proibições: teste que depende de horário real — o instante é sempre injetado; teste que depende da ordem de execução; e chamada de rede a serviço externo em qualquer suíte, sem exceção. A estratégia completa, com metas de cobertura, é da Seção 24.

5.10 Lint e formatação #

// eslint.config.js (raiz do monorepo, formato flat)
import js from '@eslint/js'
import ts from 'typescript-eslint'
import importPlugin from 'eslint-plugin-import'
import a11y from 'eslint-plugin-jsx-a11y'

export default ts.config(
  js.configs.recommended,
  ...ts.configs.strictTypeChecked,
  {
    plugins: { import: importPlugin, 'jsx-a11y': a11y },
    languageOptions: { parserOptions: { projectService: true } },
    rules: {
      'no-console': 'error',
      'no-restricted-syntax': [
        'error',
        { selector: "NewExpression[callee.name='Date'][arguments.length=0]", message: 'Injete o instante; não leia o relógio dentro do domínio.' },
      ],
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'import/order': ['error', {
        groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
        pathGroups: [{ pattern: '@pd/**', group: 'internal' }, { pattern: '@/**', group: 'internal' }],
        'newlines-between': 'always',
        alphabetize: { order: 'asc', caseInsensitive: true },
      }],
      'import/no-default-export': 'error',
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
    },
  },
  {
    // O framework de interface exige exportação padrão nestes arquivos.
    files: ['apps/web/app/**/{page,layout,route,error,loading,not-found}.tsx', 'apps/web/app/**/route.ts'],
    rules: { 'import/no-default-export': 'off' },
  },
  {
    // O domínio é puro: não conhece banco, rede nem ambiente.
    files: ['packages/core/**'],
    rules: {
      'no-restricted-imports': ['error', { patterns: ['@pd/db', '@pd/integrations', 'node:fs', 'node:net'] }],
    },
  },
)
// .prettierrc.json
{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "plugins": ["prettier-plugin-tailwindcss"]
}

A verificação pnpm lint, pnpm typecheck e pnpm format:check roda em hook de pré-commit sobre os arquivos alterados e novamente na integração contínua sobre o repositório inteiro. Aviso de lint não existe: toda regra é erro ou não está configurada. Desativação pontual exige // eslint-disable-next-line <regra> -- motivo com o motivo escrito; comentário de desativação sem justificativa é reprovado na revisão.

5.11 Commits e branches #

Commits seguem Conventional Commits, com o assunto em português, no imperativo, sem ponto final e com no máximo 72 caracteres:

feat(delivery): pular template quando a janela de 24h estiver aberta
fix(billing): revogar acesso na mesma transação do evento de atraso
refactor(core): extrair cálculo de fim de ciclo para subscription.rules
test(phone): cobrir variantes de nono dígito em números de celular
chore(deps): atualizar dependências de desenvolvimento
docs(readme): registrar decisões configuráveis aplicadas

Tipos aceitos: feat, fix, refactor, perf, test, docs, build, ci, chore, revert. Escopos aceitos, alinhados aos módulos: web, worker, ops, db, core, integrations, auth, billing, delivery, content, media, metrics, infra. Mudança incompatível usa ! após o escopo e um rodapé BREAKING CHANGE:.

Branches: feat/, fix/, chore/, refactor/ seguidos de descrição curta em kebab-case. A branch main é protegida, sempre implantável, e recebe apenas merge com fast-forward após revisão e integração contínua verde. Rebase antes do merge; merge com histórico de merge comercial não é aceito. Uma branch por unidade de trabalho, vida útil máxima de três dias.

5.12 Comentários, TODOs e código morto #

  • Comentário explica por quê, nunca o quê. Se o código precisa de comentário para ser entendido, o problema é o nome da função.
  • TODO e FIXME são proibidos no código versionado. Trabalho pendente vira tarefa registrada fora do código. A regra é verificada na integração contínua: a presença de TODO, FIXME ou XXX em arquivo versionado reprova o build.
  • Código morto é removido, não comentado. O histórico de versão é o arquivo morto.
  • Nada de código "preparado para o futuro": parâmetro sem uso, flag que nunca liga, abstração com uma única implementação prevista. Cada um deles é removido na revisão.
  • Comentário obrigatório em três situações: contorno de limitação de terceiro (com o código de erro que o motiva), decisão contraintuitiva de desempenho e trecho que reproduz uma regra legal ou de plataforma.
  • Documentação de módulo, quando necessária, é um bloco no topo do arquivo index.ts, descrevendo a superfície pública em três linhas.

5.13 Acessibilidade nos componentes #

O alvo é WCAG 2.2 nível AA em todas as superfícies, verificado conforme a Seção 24. Regras que valem no momento de escrever o componente:

  • HTML semântico primeiro: button para ação, a para navegação, label associado a todo campo. div com onClick é erro de lint.
  • Todo campo de formulário tem rótulo visível; texto de ajuda ligado por aria-describedby; erro anunciado com role="alert" e vinculado ao campo por aria-errormessage.
  • Foco sempre visível, com anel de contraste mínimo de 3:1. Remover o indicador de foco é proibido.
  • Ordem de tabulação segue a ordem visual. Diálogos prendem o foco, devolvem o foco ao elemento de origem ao fechar e respondem à tecla Escape.
  • Estados de carregamento e conclusão são anunciados por região viva (aria-live="polite"). Isso vale para o player de áudio e para o botão de reenvio.
  • Contraste mínimo de 4,5:1 para texto normal e 3:1 para texto grande e componentes de interface. A paleta padrão de 1.5 já atende.
  • Alvo de toque mínimo de 44 por 44 pixels em telas pequenas.
  • Nenhuma informação transmitida só por cor: estado de assinatura tem rótulo em texto, além da cor.
  • Animação respeita prefers-reduced-motion; transições decorativas são suprimidas.
  • Áudio do devocional é acompanhado do texto completo na mesma página, o que satisfaz a alternativa textual para conteúdo sonoro.
  • Idioma declarado no documento: <html lang="pt-BR">.

5.14 Textos de interface #

Todo texto exibido ao usuário vive em um único arquivo por aplicação, nunca solto no JSX. Não há biblioteca de internacionalização, porque não há segundo idioma — e adicionar uma agora seria complexidade sem uso.

// apps/web/src/i18n/pt-BR.ts
export const messages = {
  brand: { name: 'Palavra Diária' },
  common: {
    save: 'Salvar',
    cancel: 'Cancelar',
    loading: 'Carregando…',
    genericError: 'Não foi possível concluir. Tente novamente em instantes.',
  },
  subscriber: {
    resend: {
      button: 'Reenviar devocional de hoje',
      queued: 'Enviamos de novo. Deve chegar em alguns segundos.',
      limitReached: 'Você já usou seus reenvios de hoje. Amanhã libera de novo.',
      freeTierNoDevotional: 'No plano gratuito o devocional chega aos domingos.',
    },
    archive: {
      lockedTitle: 'Disponível no plano pago',
      lockedBody: 'Assine para ler todo o acervo e receber o áudio todos os dias.',
    },
  },
} as const

type Leaves<T> = T extends string ? '' : { [K in keyof T & string]: `${K}${Leaves<T[K]> extends '' ? '' : `.${Leaves<T[K]>}`}` }[keyof T & string]
export type MessageKey = Leaves<typeof messages>

export function t(key: MessageKey): string {
  return key.split('.').reduce<unknown>((acc, part) => (acc as Record<string, unknown>)[part], messages) as string
}

Regras: chave em inglês, valor em português do Brasil; agrupamento por superfície, não por tela; texto literal em JSX é erro de revisão; mensagens de erro exibidas ao usuário vêm do catálogo de erros (Seção 7) e não deste arquivo, para que a mesma falha diga a mesma coisa na interface e na API; valores dinâmicos usam função em vez de concatenação (t('x.y') para texto fixo, funções nomeadas para texto com variável), preservando a concordância em português.

5.15 Lista de verificação antes de abrir uma revisão #

  1. pnpm lint, pnpm typecheck, pnpm test e pnpm format:check passam localmente.
  2. Nenhum console.log, TODO, FIXME ou código comentado.
  3. Toda entrada externa nova tem schema Zod e todo erro novo tem código no catálogo.
  4. Nenhuma regra de negócio nova dentro de route handler ou de componente.
  5. Nenhum dado pessoal novo em log; campos sensíveis adicionados à lista de redação.
  6. Migração de banco acompanhada de teste de integração e reversível.
  7. Texto de interface no arquivo único, não no JSX.
  8. Componente novo navegável por teclado e com rótulos associados.
  9. Commit no formato convencional, com escopo válido.
  10. Nada fora do escopo definido em 2.7 foi introduzido.

6. Modelo de Dados e Schema do Banco #

Esta seção é a fonte única da verdade do modelo de dados. Nenhuma outra seção define tabelas, colunas, índices ou enums. Onde outra seção precisar falar de persistência, ela referencia a subseção correspondente aqui.

O banco é PostgreSQL na linha de versão declarada na Seção 4. O ORM e o mecanismo de migrations são Prisma, também na linha declarada na Seção 4.

6.1 Princípios do modelo e convenções aplicadas #

6.1.1 Regras estruturais #

Regra Decisão
Nomenclatura de tabelas snake_case, plural. Ex.: subscribers, message_logs.
Nomenclatura de colunas snake_case.
Modelos Prisma PascalCase singular com @@map("<tabela>").
Enums SCREAMING_SNAKE_CASE, tipo nativo do Postgres criado pelo Prisma.
Chave primária id CHAR(26) contendo um ULID gerado na aplicação. Nunca UUID, nunca serial.
Timestamps timestamptz, sempre gravados em UTC. Conversão para America/Sao_Paulo só na borda de apresentação e no planejador de envios.
Datas de calendário date puro quando a semântica é "dia do calendário brasileiro" (ex.: devotionals.scheduled_for).
Dinheiro inteiro em centavos de BRL, coluna sempre com sufixo _amount_cents ou _cents, tipo integer. Divisor para BRL: 100. Nunca float, nunca numeric, nunca string.
Custos fracionários de fornecedor inteiro em micros de BRL (10⁻⁶ BRL), sufixo _cost_micros ou _micros, tipo bigint. Divisor para BRL: 1.000.000. Exceção deliberada à regra de centavos, porque custo unitário de TTS e de mensagem WhatsApp é menor que um centavo.
Mistura de unidades monetárias proibida. Centavos e micros nunca entram na mesma soma ou comparação sem conversão explícita e escrita no próprio SQL ou no próprio código. Toda fórmula de outra seção que envolva dinheiro cita a coluna com o sufixo e o divisor corretos.
Soft delete apenas em subscribers, devotionals e admin_users, via deleted_at timestamptz NULL. Todas as outras 25 tabelas usam hard delete.
Booleanos prefixo is_ ou has_, nunca negativos (is_active, não is_not_active).
Colunas de auditoria created_at obrigatório em todas as tabelas; updated_at apenas onde a linha é mutável.

6.1.2 Por que ULID e não UUID #

ULID é ordenável por tempo de criação. Isso dá três ganhos diretos neste produto:

  1. Índices B-tree com inserção quase sequencial. As tabelas de maior volume (message_logs, delivery_attempts, inbound_messages) recebem escrita concentrada entre 05:40 e 06:20. Com UUIDv4 a inserção espalha por todo o índice e infla o WAL.
  2. Paginação por cursor barata. A ordenação padrão ORDER BY id DESC já é cronológica, então o cursor da Seção 7.8 pode ser o próprio id na maioria das listas.
  3. Depuração. O ULID 01K3F8QZ7M0000000000000000 carrega o instante de criação em seus 10 primeiros caracteres, o que acelera correlação com logs.

Geração exclusivamente na aplicação, via biblioteca ulid (linha 3.x). O banco não tem default para id — a ausência de default é intencional e força o caminho único de geração. Nenhuma migration ou seed pode inserir id fora desse formato.

Validação de formato garantida por CHECK global em todas as tabelas com id:

CONSTRAINT chk_<tabela>_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$')

O alfabeto é o Crockford Base32 (sem I, L, O, U), sempre em maiúsculas.

6.1.3 Prefixos públicos de identificador #

O id no banco é o ULID cru. Na API pública ele é exposto com prefixo por entidade, convertido na borda de serialização e nunca persistido com prefixo.

Entidade Prefixo público Exemplo
subscribers sub_ sub_01K3F8QZ7MHV2N9R4B6T0XYZAB
subscriptions subn_ subn_01K3F8R1P2...
devotionals dev_ dev_01K3F8R3T5...
payments pay_ pay_01K3F8R5W7...
audio_assets aud_ aud_01K3F8R7Y9...
message_logs msg_ msg_01K3F8R9A1...
delivery_attempts att_ att_01K3F8RBC3...
send_batches bat_ bat_01K3F8RDE5...
sessions ses_ ses_01K3F8RFG7...
admin_users adm_ adm_01K3F8RHJ9...
plans pln_ pln_01K3F8RKL1...
requisições HTTP req_ req_01K3F8RMN3...

Um identificador recebido pela API com prefixo errado devolve VALIDATION_ERROR (catálogo na Seção 7.11), nunca NOT_FOUND — o prefixo é sintaxe, não existência.

6.1.4 Concorrência e versionamento otimista #

Três tabelas têm edição concorrente real (dois administradores no mesmo recurso) e por isso carregam coluna version integer NOT NULL DEFAULT 1:

  • devotionals — dois editores no mesmo devocional;
  • settings — dois administradores ajustando runtime;
  • whatsapp_templates — submissão concorrente à Meta.

O UPDATE sempre inclui WHERE version = :expectedVersion e incrementa a coluna. Zero linhas afetadas devolve OPTIMISTIC_LOCK_FAILED (Seção 7.11). Exemplo:

UPDATE devotionals
   SET title = $2, reflection_md = $3, version = version + 1, updated_at = now()
 WHERE id = $1 AND version = $4 AND deleted_at IS NULL;
-- 0 linhas => OPTIMISTIC_LOCK_FAILED

As demais tabelas não usam versionamento otimista: ou são append-only, ou têm dono único (o worker), ou a última escrita legitimamente vence.

6.1.5 Extensões Postgres exigidas #

CREATE EXTENSION IF NOT EXISTS citext;      -- e-mails case-insensitive
CREATE EXTENSION IF NOT EXISTS pgcrypto;    -- digest() para hashes em migrations e seeds
CREATE EXTENSION IF NOT EXISTS pg_trgm;     -- busca por trecho em títulos de devocionais
CREATE EXTENSION IF NOT EXISTS btree_gin;   -- índices compostos com jsonb em auditoria
CREATE EXTENSION IF NOT EXISTS unaccent;    -- normalização de busca editorial em PT-BR

unaccent é usado apenas na busca administrativa de devocionais. Palavras-chave de opt-out chegam pelo WhatsApp e são normalizadas em TypeScript (String.normalize('NFD')), não no banco, porque o worker precisa decidir sem ida ao banco.

6.1.6 Catálogo de enums #

Todos os enums são tipos nativos do Postgres, criados e versionados pelo Prisma. Adicionar valor a enum é operação de migration própria (ALTER TYPE ... ADD VALUE), nunca misturada com outras alterações, porque em Postgres o novo valor não pode ser usado na mesma transação em que foi criado.

Enum Valores Onde é usado
SubscriberTier FREE, PAID subscribers.tier, plans.tier_granted, delivery_attempts.tier_at_send, feature_flags.target_tier
SubscriberStatus PENDING_VERIFICATION, VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID, PAUSED, OPTED_OUT, BLOCKED subscribers.status
Role SUBSCRIBER, EDITOR, ADMIN, OWNER admin_users.role, sessions.role, settings.editable_by
ConsentType OPT_IN_WEB, OPT_IN_WHATSAPP, OPT_OUT, RE_OPT_IN, TERMS_ACCEPTED, PRIVACY_ACCEPTED, DATA_EXPORT_REQUESTED, DATA_DELETION_REQUESTED, PAUSE_STARTED, PAUSE_ENDED, SERVICE_TERMS, SENSITIVE_DATA, MARKETING, COOKIE_CONSENT consent_events.type
ConsentChannel WEB, WHATSAPP, EMAIL, ADMIN, OPS_CLI consent_events.channel
SessionSubjectType SUBSCRIBER, ADMIN sessions.subject_type
SessionScope FULL, RESTRICTED, IMPERSONATION_READONLY sessions.scope
OtpPurpose LOGIN, PHONE_VERIFICATION, PHONE_CHANGE, EMAIL_VERIFICATION otp_codes.purpose
OtpChannel WHATSAPP, EMAIL_LINK otp_codes.channel
PlanInterval MONTHLY, YEARLY plans.interval
SubscriptionStatus PENDING_PAYMENT, ACTIVE, CANCELED, EXPIRED, REFUNDED subscriptions.status
BillingType CREDIT_CARD, PIX subscriptions.billing_type, payments.billing_type
PaymentStatus PENDING, CONFIRMED, RECEIVED, OVERDUE, REFUNDED, CHARGEBACK_REQUESTED, CHARGEBACK_DISPUTE, DELETED, FAILED payments.status
SubscriptionEventType CREATED, ACTIVATED, RENEWED, CANCEL_REQUESTED, CANCELED, EXPIRED, REFUNDED, CHARGEBACK, REVOKED, REACTIVATED, PLAN_CHANGED subscription_events.type
DevotionalStatus DRAFT, READY, AUDIO_PENDING, AUDIO_READY, PUBLISHED, SENT devotionals.status
AudioProvider ELEVENLABS, GOOGLE_TTS audio_assets.provider
AudioAssetStatus PENDING, PROCESSING, READY, FAILED audio_assets.status
AudioFormat OGG_OPUS, MP3, MP4 audio_assets.format
TemplateCategory UTILITY, MARKETING, AUTHENTICATION whatsapp_templates.category e .effective_category
TemplateStatus DRAFT, PENDING, APPROVED, REJECTED, PAUSED, DISABLED whatsapp_templates.status
MessageDirection OUTBOUND, INBOUND message_logs.direction
MessageKind TEMPLATE, TEXT, AUDIO, VIDEO, IMAGE, INTERACTIVE, REACTION, UNKNOWN message_logs.kind, inbound_messages.kind
MessageStatus QUEUED, SENT, DELIVERED, READ, FAILED, DEFERRED message_logs.status
DeliveryStep TEMPLATE_INVITE, FULL_TEXT, AUDIO, CLOSING, VIDEO_FALLBACK delivery_attempts.step
DeliveryAttemptStatus PLANNED, IN_FLIGHT, SUCCEEDED, FAILED, SKIPPED, SKIPPED_OPTED_OUT, SKIPPED_INELIGIBLE, DEFERRED delivery_attempts.status
SendBatchStatus PLANNING, PLANNED, RUNNING, HALTED_BY_GUARD, COMPLETED, FAILED, CANCELED send_batches.status
WebhookSource ASAAS, WHATSAPP webhook_deliveries.source
WebhookDirection INBOUND, OUTBOUND webhook_deliveries.direction
WebhookProcessingStatus RECEIVED, PROCESSING, PROCESSED, FAILED, IGNORED webhook_deliveries.processing_status, payment_events.processing_status
WebhookProcessingResult OK, SCHEMA_REJECTED, PARSE_ERROR, PERSIST_FAILED, UNKNOWN_EVENT, DUPLICATE webhook_deliveries.processing_result
MediaUploadStatus PENDING, UPLOADED, EXPIRED, FAILED media_uploads.status
MediaPurpose DAILY_AUDIO, VIDEO_FALLBACK, TEMPLATE_HEADER media_uploads.purpose
JobRunStatus RUNNING, SUCCEEDED, FAILED, DEAD job_runs.status
SettingValueType STRING, INT, BOOL, JSON, DECIMAL settings.value_type
InboundIntent OPT_OUT, OPT_IN, OPEN_DEVOTIONAL, SNOOZE, HELP, SUPPORT, UNKNOWN inbound_messages.intent

São 35 tipos enum. Quatro deles merecem explicação, porque a escolha de valores é consequência direta de decisões tomadas em outras seções:

  1. SubscriberStatus tem sete valores e DELETED não está entre eles. Conta excluída não é um estado do enum: é subscribers.deleted_at preenchido. Misturar exclusão com estado operacional criaria um oitavo valor que nenhuma transição consegue deixar, e quebraria o índice parcial de elegibilidade, que já filtra por deleted_at IS NULL. As transições completas estão na Seção 11.8.1; a garantia física está em 6.3.3. BLOCKED tem entrada (bloqueio administrativo ou erro permanente da Meta) e saída (desbloqueio administrativo ou mensagem recebida do assinante) — não é estado morto.
  2. JobRunStatus tem DEAD. Não existe fila de dead-letter no sistema. Um job que esgota as tentativas fica no estado failed da própria fila do BullMQ e grava uma linha em job_runs com status = 'DEAD'. job_runs é a dead-letter do sistema (Seção 18.8). Nenhuma seção pode introduzir uma fila com sufixo .dlq.
  3. DeliveryAttemptStatus tem dois motivos de pulo explícitos. SKIPPED_OPTED_OUT e SKIPPED_INELIGIBLE existem porque o tier e o consentimento são revalidados no instante do disparo, e não no planejamento (Seção 18.5): o item precisa registrar por que não saiu, sem depender de texto livre. SKIPPED genérico permanece para os pulos decididos já no planejamento.
  4. SessionScope existe para que a impersonação seja estruturalmente somente leitura. Uma sessão IMPERSONATION_READONLY é recusada em qualquer método não seguro antes de chegar ao handler (Seção 8.13.3), e uma sessão RESTRICTED é a que a reverificação por dormência emite (Seção 8.15.1.1). O escopo mora na linha da sessão, no banco, e não apenas no token — assim ele não pode ser perdido em uma renovação.

6.1.7 Lista fechada de tabelas #

O modelo tem exatamente 28 tabelas. A lista é fechada: nenhuma outra seção pode introduzir tabela nova. Necessidade de persistência não coberta usa settings (chave/valor tipado, Seção 6.23) e declara isso explicitamente.

# Tabela Grupo Volume esperado em 1 ano (10.000 assinantes)
1 subscribers Identidade 10.000 linhas
2 subscriber_profiles Identidade 10.000 linhas
3 consent_events Conformidade 45.000 linhas
4 sessions Autenticação 60.000 linhas (com expurgo)
5 otp_codes Autenticação 120.000 linhas (com expurgo)
6 admin_users Administração dezenas
7 admin_audit_log Administração 80.000 linhas
8 plans Cobrança 2 linhas
9 subscriptions Cobrança 4.500 linhas
10 subscription_events Cobrança 40.000 linhas
11 payments Cobrança 30.000 linhas
12 payment_events Cobrança 150.000 linhas
13 devotionals Editorial 365 linhas
14 devotional_revisions Editorial 3.000 linhas
15 audio_assets Mídia 730 linhas
16 whatsapp_templates Mensageria dezenas
17 message_logs Mensageria 6.000.000 linhas — particionada
18 delivery_attempts Envio 4.000.000 linhas
19 inbound_messages Mensageria 900.000 linhas
20 send_batches Envio 365 linhas
21 settings Configuração ~60 linhas
22 feature_flags Configuração ~15 linhas
23 daily_metrics Analytics 120.000 linhas — formato longo, uma linha por dia, métrica e dimensão
24 webhook_deliveries Integrações 400.000 linhas
25 media_uploads Mídia 900 linhas
26 job_runs Operação 200.000 linhas
27 admin_trusted_devices Administração dezenas
28 unsubscribe_tokens Conformidade ~15.000 linhas (com expurgo)

A tabela 28, unsubscribe_tokens, é a persistência do link público de descadastro (POST /api/public/unsubscribe). Ela é definida por completo em 6.7.4 — colunas, tipos, índices, chave estrangeira com ON DELETE e retenção — e nenhuma outra seção redefine qualquer parte dela: as Seções 20 e 22 apenas a referenciam.

Duas tabelas concentram mais de 80% do volume: message_logs e delivery_attempts. O tratamento específico delas está em 6.33 (particionamento e retenção) e 6.34 (manutenção).

6.1.8 Invariante de dado pessoal #

Nenhuma tabela deste modelo armazena telefone, wa_id, e-mail ou CPF em texto claro. Onde a busca por igualdade é necessária, ela é feita pela coluna _hmac correspondente — um índice cego, HMAC-SHA-256 determinístico do valor normalizado, com chave própria e distinta da chave de cifra. As colunas cifradas e as colunas de índice cego são declaradas aqui, na Seção 6, e existem desde a primeira migration: não são endurecimento posterior, são schema. A Seção 22 descreve o algoritmo do envelope, a gestão da chave e o procedimento de rotação, e referencia esta seção para saber quais colunas existem.

As quatro duplas são:

Valor pessoal Coluna cifrada Coluna de índice cego Unicidade
Telefone E.164 subscribers.phone_e164 (text) subscribers.phone_hmac (char(64)) única entre contas vivas
Identificador do WhatsApp subscribers.wa_id (text) subscribers.wa_id_hmac (char(64)) única quando presente
E-mail subscribers.email (text) subscribers.email_hmac (char(64)) única entre contas vivas
CPF/CNPJ subscriber_profiles.cpf (text) subscriber_profiles.cpf_hmac (char(64)) não única, por decisão explícita (6.4.1)

Três regras derivam disso e valem em todo o documento:

  1. Nenhum CHECK de banco valida o conteúdo de uma coluna cifrada. O banco só enxerga o envelope, então validar formato de telefone, de e-mail ou de CPF em SQL é impossível. A validação de formato é da camada de aplicação, por Zod, na borda, antes de cifrar (Seção 22.2.1). O único CHECK admissível sobre coluna cifrada é o do formato do envelope (~ '^v[0-9]+:'), que não revela nada.
  2. Nenhuma consulta compara coluna cifrada por igualdade. A cifra é aleatorizada por nonce: dois envelopes do mesmo telefone são bytes diferentes. Toda busca por telefone, wa_id, e-mail ou CPF usa a coluna _hmac (6.35.3).
  3. Um teste de schema percorre information_schema.columns e falha se encontrar coluna chamada phone_e164, wa_id, email ou cpf cujo tipo não seja text com o CHECK de envelope, ou cuja coluna _hmac correspondente esteja ausente. O teste roda no pipeline, contra o banco recém-migrado.

6.2 Diagrama ER #

                        ┌──────────────────────┐
                        │      plans           │
                        │  code (uniq)         │
                        │  interval            │
                        │  amount_cents        │
                        └───────────┬──────────┘
                                    │ 1
                                    │
                                    │ N
┌──────────────────┐ 1     1 ┌──────┴───────────────┐ 1    N ┌────────────────────────┐
│ subscriber_      ├─────────┤    subscriptions     ├────────┤  subscription_events   │
│   profiles       │         │  status              │        │  type / from / to      │
│  cpf (cifrado)   │         │  billing_type        │        │  cancel_reason         │
│  cpf_hmac (idx)  │         │  current_period_end  │        └────────────────────────┘
└──────────────────┘         │  pending_plan_id     │
          │ 1                └──────┬───────────────┘
          │                         │ 1
          │ 1                       │
┌─────────┴────────────┐            │ N        ┌──────────────────────┐
│     subscribers      │     ┌──────┴──────────┤   payment_events     │
│  phone_e164 (cifr.)  │     │   payments      │   asaas_event_id     │
│  phone_hmac (uniq)   │ 1  N│  asaas_payment  │   (uniq)             │
│  wa_id (cifrado)     ├─────┤   _id (uniq)    └──────────────────────┘
│  wa_id_hmac (uq p.)  │     └─────────────────┘
│  email (cifrado)     │
│  email_hmac (uq p.)  │
│  tier / status       │
│  opt_in_confirmed_at │
│  paused_until        │
│  service_window_     │
│    expires_at        │ 1   N ┌──────────────────────┐
│  deleted_at          ├───────┤   consent_events     │  append-only
└──┬───┬───┬───┬───┬───┘       └──────────────────────┘
   │   │   │   │   │
   │   │   │   │   │ N  ┌────────────────┐
   │   │   │   │   └────┤   sessions     │──┐ self-FK (rotação)
   │   │   │   │        │  refresh_hash  │◄─┘
   │   │   │   │        └────────────────┘
   │   │   │   │ N  ┌────────────────┐
   │   │   │   └────┤   otp_codes    │
   │   │   │        └────────────────┘
   │   │   │ N  ┌──────────────────────┐
   │   │   └────┤  inbound_messages    │  wamid (uniq)
   │   │        └──────────────────────┘
   │   │ N  ┌──────────────────────────┐ N   1 ┌───────────────────────┐
   │   └────┤   delivery_attempts      ├───────┤    send_batches       │
   │        │  idempotency_key (uniq)  │       │ idempotency_key(uniq) │
   │        │  step / status           │       │ devotional_date       │
   │        └────────────┬─────────────┘       └───────────┬───────────┘
   │                     │ N                               │ N
   │ N                   │                                 │
┌──┴──────────────────┐  │ 1                               │ 1
│    message_logs     │  │                    ┌────────────┴───────────┐
│  PARTITION BY RANGE │  │                    │      devotionals       │
│    (created_at)     │  └────────────────────┤  scheduled_for (uniq)  │
│  wamid              │      N              1 │  status / teaser       │
│  status/error_code  │                       │  version               │
└─────────────────────┘                       └──┬──────────┬──────────┘
                                                 │ 1        │ 1
                                                 │ N        │ N
                                  ┌──────────────┴───┐  ┌───┴─────────────────────┐
                                  │  audio_assets    │  │  devotional_revisions   │
                                  │  provider/format │  │  revision_number        │
                                  └────────┬─────────┘  └─────────────────────────┘
                                           │ 1
                                           │ N
                                  ┌────────┴─────────┐
                                  │  media_uploads   │
                                  │ whatsapp_media_id│
                                  │ expires_at (30d) │
                                  └──────────────────┘

┌──────────────────┐  1   N ┌────────────────────┐
│   admin_users    ├────────┤  admin_audit_log   │
│  email (uniq)    │        │  action/entity     │
│  totp_secret_enc │        └────────────────────┘
└────────┬─────────┘
         │ 1
         │ N
┌────────┴──────────────┐
│ admin_trusted_devices │
│  token_hash (uniq)    │
│  expires_at (30d)     │
└───────────────────────┘

┌──────────────────────┐  1   N ┌─────────────────────────┐
│     subscribers      ├────────┤   unsubscribe_tokens    │  ON DELETE CASCADE
│   (a mesma acima)    │        │  token_hash (uniq)      │
└──────────────────────┘        │  expires_at / used_at   │
                                └─────────────────────────┘

TABELAS SEM RELACIONAMENTO OBRIGATÓRIO (configuração, mensageria e operação):
┌──────────────┐ ┌────────────────┐ ┌──────────────────────────┐ ┌────────────────────┐
│  settings    │ │ feature_flags  │ │      daily_metrics       │ │ whatsapp_templates │
│  key (PK)    │ │  key (PK)      │ │ (metric_date, metric_key,│ │ (name, language)   │
│ grupo.chave  │ │  snake_case    │ │  dimension) uq — formato │ │       uq           │
│              │ │                │ │  longo                   │ │                    │
└──────────────┘ └────────────────┘ └──────────────────────────┘ └────────────────────┘
┌────────────────────────┐ ┌──────────────────────────────┐
│  webhook_deliveries    │ │  job_runs                    │
│  direction / source    │ │  job_name / queue            │
│  processing_result     │ │  status DEAD = dead-letter   │
└────────────────────────┘ └──────────────────────────────┘

LEGENDA: 1──N = um para muitos.  (uniq) = índice único.  (uniq part.) / (uq p.) = índice
único parcial.  (cifr.) / (cifrado) = envelope AES-256-GCM, nunca indexado.  (idx) = índice
não único.

6.3 Tabela subscribers #

Registro central do assinante: identidade por telefone, tier vigente, estado de opt-in e estado da janela de atendimento do WhatsApp.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. Chave primária.
phone_e164 text não Telefone canônico em E.164 (com o nono dígito quando celular brasileiro), guardado como envelope cifrado AES-256-GCM no formato v1:<iv>:<ct>:<tag> (Seção 22.5.3). Nunca indexado, nunca comparado por igualdade. O formato E.164 é validado por Zod antes de cifrar (6.1.8).
phone_hmac char(64) não HMAC-SHA-256, em hex minúsculo, do E.164 normalizado, com a chave PHONE_INDEX_KEY. É a coluna de identidade e de unicidade. Toda busca por telefone passa por aqui.
wa_id text sim NULL Identificador devolvido pela Meta no primeiro contato bem-sucedido. Envelope cifrado, mesmo formato. Pode diferir de phone_e164 (ver 6.36.1).
wa_id_hmac char(64) sim NULL HMAC do wa_id. Resolve o webhook de entrada em uma única leitura.
wa_id_changed_at timestamptz sim NULL Última troca do wa_id associado a este telefone. Preenchê-la revoga todas as sessões do assinante e exige nova verificação por código antes de liberar qualquer dado pessoal (6.36.1).
display_name varchar(120) sim NULL Nome de exibição informado no cadastro ou no perfil do WhatsApp. É o nome do assinante em todo o documento; não existe subscribers.name.
email text sim NULL E-mail opcional, usado no fallback de login e em recibos. Envelope cifrado, mesmo formato.
email_hmac char(64) sim NULL HMAC do e-mail já em minúsculas. Substitui o antigo índice único sobre citext.
email_verified_at timestamptz sim NULL Preenchido quando o magic link é consumido. Só e-mail verificado habilita o fallback de login.
phone_verified_at timestamptz sim NULL Instante em que o telefone foi comprovado por código no WhatsApp. Nulo enquanto o cadastro está em PENDING_VERIFICATION.
tier SubscriberTier não 'FREE' Tier vigente. Fonte de verdade para entitlements. Relido no instante do disparo de cada mensagem, nunca apenas no planejamento (Seção 18.5).
status SubscriberStatus não 'PENDING_VERIFICATION' Estado operacional materializado (ver 6.3.4).
locale varchar(10) não 'pt-BR' Locale de apresentação. Valor único no MVP.
timezone varchar(40) não 'America/Sao_Paulo' Fuso de apresentação. Não personalizável no MVP; a coluna existe para não exigir migration futura.
opt_in_confirmed_at timestamptz sim NULL Instante da confirmação ativa no WhatsApp. Nenhum devocional é enviado com este campo nulo.
opt_out_at timestamptz sim NULL Instante do opt-out. Envios cessam imediatamente.
opt_out_reason varchar(80) sim NULL Palavra-chave e origem do opt-out, no mesmo campo. Ex.: keyword:SAIR, panel, link, admin. É o único nome desta coluna em todo o documento: não existe opt_out_source, e uma seção que precise da origem lê o prefixo daqui.
opt_out_confirmation_sent_at timestamptz sim NULL Instante do envio da confirmação de opt-out. Verificada dentro da mesma transação que grava opt_out_at, para que uma reentrega de webhook não produza duas mensagens de "você foi removido" (Seção 27.5.1). Sem índice.
email_marketing_consent_at timestamptz sim NULL Consentimento específico para e-mail de marketing, distinto do consentimento de serviço. A régua de reengajamento por e-mail (Seção 20.11) só alcança quem tem esta coluna preenchida. Sem índice: a régua já filtra por opt_out_at IS NULL.
billing_blocked_at timestamptz sim NULL Instante em que o assinante passou a ser recusado em novo checkout com cartão, por contestação confirmada. Não impede pagamento por PIX. Escrita na mesma transação de revokePaidAccess() no evento de contestação (Seção 12.11).
service_window_expires_at timestamptz sim NULL Fim da janela de atendimento de 24h. Governa a escolha entre template e free-form.
last_inbound_at timestamptz sim NULL Última mensagem recebida do assinante.
last_delivered_at timestamptz sim NULL Último devocional confirmado como entregue.
consecutive_window_misses smallint não 0 Dias consecutivos em que o assinante PAID recebeu mensagem entregue ou lida e não abriu a janela. É o único nome deste contador em todo o documento; no_interaction_days não existe. Em 3, o 4º dia usa o fallback de vídeo.
undelivered_days smallint não 0 Dias em que a única saída foi adiada, suprimida ou falhou. Contador separado, porque nesses dias o assinante não teve oportunidade de interagir e empurrá-lo ao vídeo só agrava a supressão (Seção 17.3). Alimenta a regra de entrega mínima do plano pago.
undeliverable_count smallint não 0 Falhas permanentes consecutivas de entrega ao número. Base do bloqueio automático por erro 131026 reincidente.
unrecognized_replies_in_window smallint não 0 Respostas não reconhecidas na janela corrente. Alimenta a proteção anti-loop da Seção 19.6. Zerado a cada mensagem reconhecida.
blocked_at timestamptz sim NULL Bloqueio operacional (bloqueio administrativo, número inválido, erro 131026 reincidente).
blocked_reason varchar(120) sim NULL Motivo do bloqueio, legível por operador.
paused_until timestamptz sim NULL Fim da pausa temporária. Sempre 03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, para que o retorno caia antes do envio das 06:00 (Seção 20.6.1). Nulo significa sem pausa.
courtesy_until timestamptz sim NULL Fim da cortesia concedida por administrador. Enquanto vigente, o assinante tem acesso pago sem assinatura (Seção 13.11).
human_handoff_until timestamptz sim NULL Fim do atendimento humano. Enquanto vigente, as respostas automáticas ficam suspensas (Seção 19.7).
autoreply_suppressed_until timestamptz sim NULL Fim da supressão de resposta automática disparada pela proteção anti-loop (Seção 19.6).
marketing_blocked_at timestamptz sim NULL Recusa específica de mensagens de categoria MARKETING, que não implica opt-out do serviço.
welcome_backfill_sent_at timestamptz sim NULL Instante do envio do devocional de boas-vindas retroativo, quando o cadastro ocorre depois do envio do dia (Seção 11.10).
asaas_customer_id varchar(32) sim NULL Identificador do cliente no provedor de pagamento, no nível do assinante. Criado uma vez e reaproveitado por todas as assinaturas.
last_login_at timestamptz sim NULL Último login bem-sucedido no painel. Junto com last_inbound_at, define a dormência de 90 dias que rebaixa a sessão para RESTRICTED (Seção 8.15.1.1).
source varchar(40) não 'landing' Canal de aquisição. Ex.: landing, admin, import, ops_cli.
current_subscription_id char(26) sim NULL Atalho para a assinatura vigente. Denormalização deliberada (ver 6.3.5).
anonymized_at timestamptz sim NULL Instante do estágio 1 (pseudonimização) do pedido de eliminação por LGPD.
fully_anonymized_at timestamptz sim NULL Instante do estágio 2 (anonimização), quando a retenção fiscal vence e CPF e identificadores do provedor de pagamento também são zerados (Seção 22.9).
deleted_at timestamptz sim NULL Soft delete. Não é um valor de status: conta excluída continua com o último status operacional e é filtrada por esta coluna.
created_at timestamptz não now()
updated_at timestamptz não now() Atualizado pela aplicação em toda escrita.

6.3.1 Índices #

Índice Definição Justificativa
subscribers_pkey PRIMARY KEY (id) Acesso por identificador.
uq_subscribers_phone_hmac UNIQUE (phone_hmac) WHERE deleted_at IS NULL Identidade do assinante. Substitui integralmente o antigo índice sobre phone_e164, que hoje é coluna cifrada e não pode ser comparada por igualdade. Impede duplicidade entre contas vivas; telefone de conta excluída volta a ser utilizável só depois da anonimização (ver 6.36.1).
uq_subscribers_wa_id_hmac UNIQUE (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL Resolução de webhook de entrada em uma única leitura. Parcial porque a maioria das linhas nasce sem wa_id.
uq_subscribers_email_hmac UNIQUE (email_hmac) WHERE email_hmac IS NOT NULL AND deleted_at IS NULL E-mail é opcional e único entre contas vivas.
ix_subscribers_send_eligible (tier, status) WHERE opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND deleted_at IS NULL AND blocked_at IS NULL Índice parcial que atende a consulta de planejamento diário (6.35.1). Reduz a varredura ao conjunto elegível.
ix_subscribers_service_window (service_window_expires_at) WHERE service_window_expires_at IS NOT NULL Seleciona quem tem janela aberta para o atalho free-form.
ix_subscribers_current_subscription (current_subscription_id) WHERE current_subscription_id IS NOT NULL Junção reversa a partir da assinatura.
ix_subscribers_created_at (created_at DESC) Lista administrativa e métrica de novos assinantes por dia.
ix_subscribers_window_misses (consecutive_window_misses) WHERE tier = 'PAID' AND consecutive_window_misses >= 3 Localiza candidatos ao fallback de vídeo sem varrer a tabela.
uq_subscribers_asaas_customer UNIQUE (asaas_customer_id) WHERE asaas_customer_id IS NOT NULL Um cadastro no provedor de pagamento pertence a um assinante só. Resolve webhook de cliente sem varredura.
ix_subscribers_paused_until (paused_until) WHERE paused_until IS NOT NULL Job das 05:30 que limpa pausas encerradas e enfileira a saudação de retorno.
ix_subscribers_courtesy_until (courtesy_until) WHERE courtesy_until IS NOT NULL Job de ciclo que expira cortesias vencidas.
ix_subscribers_dormant (last_inbound_at NULLS FIRST, last_login_at NULLS FIRST) WHERE deleted_at IS NULL Avaliação da dormência de 90 dias no momento do login (Seção 8.15.1.1), sem varrer a tabela.
ix_subscribers_billing_blocked (billing_blocked_at) WHERE billing_blocked_at IS NOT NULL Lista de contas com cartão recusado por contestação. Parcial porque a esmagadora maioria das linhas tem a coluna nula.

6.3.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE Observação
current_subscription_id subscriptions(id) SET NULL CASCADE FK circular com subscriptions.subscriber_id. Declarada DEFERRABLE INITIALLY DEFERRED para permitir criação de assinante e assinatura na mesma transação.

6.3.3 Constraints CHECK #

ALTER TABLE subscribers
  ADD CONSTRAINT chk_subscribers_phone_envelope
      CHECK (phone_e164 ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_phone_hmac
      CHECK (phone_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_subscribers_wa_id_envelope
      CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_wa_id_hmac
      CHECK (wa_id_hmac IS NULL OR wa_id_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_subscribers_wa_id_pair
      CHECK ((wa_id IS NULL) = (wa_id_hmac IS NULL)),
  ADD CONSTRAINT chk_subscribers_email_envelope
      CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_email_hmac
      CHECK (email_hmac IS NULL OR email_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_subscribers_email_pair
      CHECK ((email IS NULL) = (email_hmac IS NULL)),
  ADD CONSTRAINT chk_subscribers_optout_after_optin
      CHECK (opt_out_at IS NULL OR opt_in_confirmed_at IS NULL
             OR opt_out_at >= opt_in_confirmed_at),
  ADD CONSTRAINT chk_subscribers_window_misses
      CHECK (consecutive_window_misses BETWEEN 0 AND 365),
  ADD CONSTRAINT chk_subscribers_undelivered_days
      CHECK (undelivered_days BETWEEN 0 AND 365),
  ADD CONSTRAINT chk_subscribers_undeliverable_count
      CHECK (undeliverable_count BETWEEN 0 AND 365),
  ADD CONSTRAINT chk_subscribers_unrecognized_replies
      CHECK (unrecognized_replies_in_window BETWEEN 0 AND 100),
  ADD CONSTRAINT chk_subscribers_status_verification
      CHECK (status <> 'PENDING_VERIFICATION' OR phone_verified_at IS NULL),
  ADD CONSTRAINT chk_subscribers_status_verified_pending
      CHECK (status <> 'VERIFIED_PENDING_OPTIN'
             OR (phone_verified_at IS NOT NULL AND opt_in_confirmed_at IS NULL)),
  ADD CONSTRAINT chk_subscribers_status_active_needs_optin
      CHECK (status NOT IN ('ACTIVE_FREE','ACTIVE_PAID','PAUSED')
             OR opt_in_confirmed_at IS NOT NULL),
  ADD CONSTRAINT chk_subscribers_status_tier_matches
      CHECK ((status <> 'ACTIVE_PAID' OR tier = 'PAID')
         AND (status <> 'ACTIVE_FREE' OR tier = 'FREE')),
  ADD CONSTRAINT chk_subscribers_status_paused
      CHECK (status <> 'PAUSED' OR paused_until IS NOT NULL),
  ADD CONSTRAINT chk_subscribers_status_optout
      CHECK ((status = 'OPTED_OUT') = (opt_out_at IS NOT NULL
                                       AND blocked_at IS NULL
                                       AND deleted_at IS NULL)),
  ADD CONSTRAINT chk_subscribers_status_blocked
      CHECK ((status = 'BLOCKED') = (blocked_at IS NOT NULL AND deleted_at IS NULL)),
  ADD CONSTRAINT chk_subscribers_blocked_pair
      CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)),
  ADD CONSTRAINT chk_subscribers_anonymized
      CHECK (anonymized_at IS NULL OR deleted_at IS NOT NULL),
  ADD CONSTRAINT chk_subscribers_full_anonymized_order
      CHECK (fully_anonymized_at IS NULL
             OR (anonymized_at IS NOT NULL AND fully_anonymized_at >= anonymized_at));

Três observações que evitam erro de implementação:

  1. Não existe CHECK de formato E.164 nesta tabela, e não pode existir. phone_e164 guarda um envelope cifrado; um CHECK com a expressão '^\+[1-9][0-9]{7,14}$' sobre ele faria todo INSERT de assinante falhar. A validação do formato é do Zod, na borda, antes de cifrar (6.1.8, regra 1). O que o banco valida aqui é apenas o formato do envelope e o formato hexadecimal do índice cego.
  2. wa_id não carrega o +: a Meta devolve o número sem sinal. Comparar telefone com wa_id exige remover o + antes de calcular o HMAC. Isso é responsabilidade de packages/core/src/phone.ts, nunca de SQL ad hoc, e a normalização canônica está na Seção 11.4.
  3. DELETED não aparece em nenhum destes CHECKs porque não é um valor de SubscriberStatus. Exclusão é deleted_at IS NOT NULL, e os dois CHECKs de OPTED_OUT e BLOCKED já exigem deleted_at IS NULL para valer, o que mantém a linha excluída fora das duas equivalências sem precisar de um estado próprio.

6.3.4 A coluna status é derivada, e por que ela existe assim mesmo #

status é redundante em relação a phone_verified_at, opt_in_confirmed_at, paused_until, opt_out_at, blocked_at e tier. A redundância é deliberada: a consulta de planejamento diário roda uma vez por dia sobre toda a base e precisa de um índice parcial estreito. Índice parcial sobre seis colunas nulas produz plano pior do que sobre um enum.

O enum tem sete valores. Regra de consistência, aplicada em uma única função applySubscriberStatus() em packages/core e garantida pelos CHECKs de 6.3.3:

Ordem Condição status resultante
1 blocked_at IS NOT NULL BLOCKED
2 senão, opt_out_at IS NOT NULL OPTED_OUT
3 senão, phone_verified_at IS NULL PENDING_VERIFICATION
4 senão, opt_in_confirmed_at IS NULL VERIFIED_PENDING_OPTIN
5 senão, paused_until > now() PAUSED
6 senão, tier = 'PAID' ACTIVE_PAID
7 senão ACTIVE_FREE

A precedência é fixa nessa ordem. Nenhum caminho de escrita pode gravar status direto.

Exclusão não é status. Conta excluída tem deleted_at preenchido e conserva o último status operacional que tinha. Toda consulta de produto filtra por deleted_at IS NULL, e os índices parciais de 6.3.1 já embutem esse filtro. O motivo é prático: se DELETED fosse um valor do enum, seria um estado sem transição de saída, e as duas equivalências de OPTED_OUT e BLOCKED deixariam de valer no instante da exclusão, obrigando a reescrever o status de uma linha que ninguém mais deveria tocar.

BLOCKED tem entrada e saída. Entra por bloqueio administrativo explícito ou por erro permanente da Meta (código 131026 em três dias consecutivos, ou bloqueio pelo assinante detectado por webhook), gravando blocked_at e blocked_reason; os envios cessam e a cobrança não é alterada. Sai por desbloqueio administrativo ou por qualquer mensagem recebida do assinante, que zera blocked_at e blocked_reason e devolve o assinante ao status resolvido pela tabela acima. As transições completas, com gatilho e efeito, estão na Seção 11.8.1.

6.3.5 A denormalização current_subscription_id #

Sem essa coluna, saber o tier vigente de um assinante exige uma junção com subscriptions filtrando por status e período. No pico de 05:40, com 10.000 linhas, isso é aceitável; com 50.000, não. A coluna guarda a assinatura vigente e é atualizada na mesma transação que muda subscriptions.status.

tier continua sendo a fonte de verdade dos entitlements. current_subscription_id é navegação, não autorização. Um job de reconciliação diário (Seção 6.35.7 traz a consulta) detecta divergência entre subscribers.tier e o estado real da assinatura e emite alerta.

6.4 Tabela subscriber_profiles #

Dados cadastrais e sensíveis do assinante, separados da tabela principal para que o caminho quente de envio nunca carregue CPF, IP ou user-agent.

Coluna Tipo Nulo Default Descrição
subscriber_id char(26) não PK e FK. Relação 1:1 com subscribers.
full_name varchar(160) sim NULL Nome completo exigido pela criação de cliente no provedor de pagamento.
cpf text sim NULL CPF/CNPJ, apenas dígitos, guardado como envelope cifrado AES-256-GCM no formato v1:<iv>:<ct>:<tag> (Seção 22.5.3). Nunca comparado por igualdade. A validação de dígito verificador é do Zod, antes de cifrar.
cpf_last4 char(4) sim NULL Últimos 4 dígitos, em claro, para conferência visual no suporte.
cpf_hmac char(64) sim NULL HMAC-SHA-256 do CPF (apenas dígitos) com a chave PHONE_INDEX_KEY, em hex. Serve para detectar o mesmo CPF em cadastros diferentes e para consultar a lista de bloqueio por fraude — não para impedir (ver 6.4.1).
birth_date date sim NULL Opcional. Usado só em segmentação editorial futura.
city varchar(120) sim NULL
state char(2) sim NULL UF.
church_name varchar(160) sim NULL Campo opcional de contexto, coletado no onboarding.
preferred_voice varchar(60) sim NULL Reservado. No MVP a voz é global; a coluna evita migration futura.
utm_source varchar(120) sim NULL
utm_medium varchar(120) sim NULL
utm_campaign varchar(120) sim NULL
referrer text sim NULL Referrer HTTP do cadastro.
signup_ip inet sim NULL IP do cadastro. Prova de consentimento.
signup_user_agent text sim NULL User-agent do cadastro.
admin_notes text sim NULL Notas internas de suporte. Nunca exposto ao assinante.
created_at timestamptz não now()
updated_at timestamptz não now()

6.4.1 Índices #

Índice Definição Justificativa
subscriber_profiles_pkey PRIMARY KEY (subscriber_id) Garante o 1:1 e serve à junção.
ix_subscriber_profiles_cpf_hmac INDEX (cpf_hmac) WHERE cpf_hmac IS NOT NULL Índice não único, deliberadamente. Localiza todas as contas que usaram o mesmo CPF, para a pontuação de risco e para a lista de bloqueio por fraude.
ix_subscriber_profiles_utm (utm_campaign) WHERE utm_campaign IS NOT NULL Relatório de aquisição por campanha.

A unicidade de CPF não é imposta, e isso é uma decisão, não um esquecimento. Duas razões concretas, ambas com caso real no Brasil:

  1. Um titular que pediu eliminação mantém cpf e cpf_hmac por até 5 anos, por obrigação fiscal (Seção 22.9, estágio 1). Um índice único o impediria de voltar a assinar: o INSERT colidiria com a linha anonimizada dele mesmo, e o checkout devolveria um erro de banco que nenhum operador de suporte consegue interpretar.
  2. É corriqueiro que uma pessoa contrate para um familiar usando o próprio CPF — mãe que assina para si e depois para a filha. Com índice único, a segunda assinatura falha.

O mesmo cpf_hmac em mais de duas contas ativas incrementa a pontuação de risco da Seção 22.6.3 em 25 pontos e aparece na tela de assinantes como sinal para o operador. Detectar e sinalizar é o comportamento desejado; bloquear no banco não é.

6.4.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) CASCADE CASCADE

CASCADE é correto aqui e só aqui no grupo de identidade: quando um assinante é apagado de fato (hard delete, que só ocorre no expurgo pós-anonimização), o perfil não tem razão de existir. Soft delete não dispara cascade.

6.4.3 Constraints CHECK #

ALTER TABLE subscriber_profiles
  ADD CONSTRAINT chk_profiles_state CHECK (state IS NULL OR state ~ '^[A-Z]{2}$'),
  ADD CONSTRAINT chk_profiles_cpf_last4 CHECK (cpf_last4 IS NULL OR cpf_last4 ~ '^[0-9]{4}$'),
  ADD CONSTRAINT chk_profiles_cpf_envelope CHECK (cpf IS NULL OR cpf ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_profiles_cpf_hmac CHECK (cpf_hmac IS NULL OR cpf_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_profiles_cpf_pair
      CHECK ((cpf IS NULL) = (cpf_hmac IS NULL)),
  ADD CONSTRAINT chk_profiles_birth_date
      CHECK (birth_date IS NULL OR birth_date BETWEEN DATE '1900-01-01' AND CURRENT_DATE);

Como em subscribers, nenhum CHECK valida o conteúdo do CPF: a coluna guarda um envelope. O dígito verificador é conferido por Zod na borda, antes de cifrar, e o erro correspondente é TAX_ID_INVALID (Seção 7.11). O CHECK de envelope e o de formato hexadecimal do índice cego são os únicos que o banco consegue aplicar sem enxergar o valor.

Trilha imutável de consentimento. É a prova documental exigida pela LGPD e a defesa em caso de reclamação de spam no WhatsApp.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
subscriber_id char(26) não Titular do consentimento.
type ConsentType não Natureza do evento.
channel ConsentChannel não Onde o ato ocorreu.
granted boolean não true para concessão, false para revogação.
policy_version varchar(20) não Versão do texto de política vigente. Ex.: 2026-08-01. É o único nome deste campo: não existe text_version.
consent_text text não Texto exato exibido ou enviado, congelado no momento do ato.
consent_text_hash char(64) não sha256 do texto, em hex. Detecta adulteração. É o único nome deste campo: não existe text_hash.
ip inet sim NULL IP quando o canal é WEB.
user_agent text sim NULL User-agent quando o canal é WEB.
evidence jsonb sim NULL Evidência específica do canal. Para WhatsApp: {"wamid":"...","buttonPayload":"CONFIRM_OPT_IN"}.
occurred_at timestamptz não now() Instante do ato.
created_at timestamptz não now() Instante da gravação. Difere de occurred_at quando o evento chega por webhook atrasado.

Sem updated_at. Sem deleted_at. A tabela é append-only, com garantia no banco (ver 6.36.3).

6.5.1 Índices #

Índice Definição Justificativa
consent_events_pkey PRIMARY KEY (id)
ix_consent_events_subscriber (subscriber_id, occurred_at DESC) Linha do tempo de consentimento do titular. Atende a exportação de dados e a defesa de reclamação.
ix_consent_events_type_time (type, occurred_at DESC) Relatórios de opt-in e opt-out por período.
ix_consent_events_policy_version (policy_version) Quantos titulares aceitaram cada versão da política.

6.5.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) RESTRICT CASCADE

RESTRICT é intencional e é a razão de a tabela sobreviver ao expurgo do titular: o processo de eliminação por LGPD anonimiza o assinante (zera telefone, e-mail e nome) mas não apaga a linha, exatamente para preservar o vínculo com o consentimento durante os 5 anos de retenção. Um DELETE acidental em subscribers falha em vez de destruir prova.

6.5.3 Constraints CHECK #

ALTER TABLE consent_events
  ADD CONSTRAINT chk_consent_hash CHECK (consent_text_hash ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_consent_policy_version CHECK (policy_version ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'),
  ADD CONSTRAINT chk_consent_web_evidence
      CHECK (channel <> 'WEB' OR ip IS NOT NULL),
  ADD CONSTRAINT chk_consent_granted_type
      CHECK ((type IN ('OPT_OUT') AND granted = false)
          OR (type NOT IN ('OPT_OUT')));

O último CHECK trava a combinação sem sentido type = 'OPT_OUT' AND granted = true.

Três regras de uso, que outras seções referenciam em vez de redefinir:

  1. A coluna de contexto chama-se evidence, e é a única. Não existe consent_events.metadata, e as tabelas de anonimização e de retenção das Seções 20 e 22 citam policy_version e consent_text_hash, nunca text_version nem text_hash, que não existem. O início e o fim de pausa gravam type = 'PAUSE_STARTED' com granted = false e evidence = {"days": 7, "pausedUntil": "2026-09-02T06:00:00Z"}, e type = 'PAUSE_ENDED' com granted = true.
  2. A grafia do reingresso é RE_OPT_IN, com sublinhados. REOPTIN não existe no enum e um INSERT com esse valor falha.
  3. consent_text_hash é o SHA-256 do texto após normalização canônica: NFC, quebras de linha convertidas para \n, espaços em sequência colapsados em um e espaços removidos das pontas. A normalização torna o hash imune a reformatação do arquivo de textos — sem ela, um formatador de código reindentando o arquivo mudaria o hash de uma versão já apresentada e destruiria a prova de qual texto o titular leu.

6.6 Tabela sessions #

Sessões ativas de assinantes e administradores. É o registro que torna o JWT revogável. A semântica de autenticação está na Seção 8.8; aqui fica só a estrutura.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. Vai no claim jti do JWT.
subject_type SessionSubjectType não SUBSCRIBER ou ADMIN.
subscriber_id char(26) sim NULL Preenchido quando subject_type = 'SUBSCRIBER'.
admin_user_id char(26) sim NULL Preenchido quando subject_type = 'ADMIN'.
role Role não Papel congelado na emissão. Revalidado a cada refresh.
scope SessionScope não 'FULL' Alcance da sessão. RESTRICTED é emitida pela reverificação por dormência (Seção 8.15.1.1) e recusa dado pessoal. IMPERSONATION_READONLY recusa qualquer método não seguro, em qualquer rota, antes de chegar ao handler (Seção 8.13.3).
refresh_token_hash char(64) não sha256 do refresh token opaco. O token em claro nunca é persistido.
parent_session_id char(26) sim NULL Sessão que originou esta por rotação. Detecta reuso de refresh token.
impersonated_by_admin_id char(26) sim NULL Preenchido em sessão de impersonação (Seção 8.13).
impersonation_reason text sim NULL Justificativa registrada ao iniciar a impersonação. Exibida ao titular na lista Acessos do suporte (Seção 8.9.1), sem o nome do operador.
ip inet sim NULL IP da emissão.
user_agent text sim NULL User-agent da emissão.
device_fingerprint char(64) sim NULL sha256 de user-agent + accept-language + plataforma. Detecta dispositivo novo.
issued_at timestamptz não now()
last_seen_at timestamptz não now() Atualizado no máximo uma vez a cada 5 minutos, para não gerar escrita por requisição.
expires_at timestamptz não Fim absoluto da sessão.
revoked_at timestamptz sim NULL Revogação explícita.
revoked_reason varchar(60) sim NULL logout, logout_all, rotation, reuse_detected, password_change, admin_revoke, tier_downgrade, wa_id_changed, impersonation_ended.
created_at timestamptz não now()

6.6.1 Índices #

Índice Definição Justificativa
sessions_pkey PRIMARY KEY (id) Validação do jti a cada requisição autenticada.
uq_sessions_refresh_hash UNIQUE (refresh_token_hash) Localiza a sessão pelo refresh token em O(1) e impede colisão.
ix_sessions_subscriber_active (subscriber_id, expires_at DESC) WHERE revoked_at IS NULL AND subscriber_id IS NOT NULL Lista "meus dispositivos" e executa "sair de todos".
ix_sessions_admin_active (admin_user_id, expires_at DESC) WHERE revoked_at IS NULL AND admin_user_id IS NOT NULL Mesma coisa para administradores.
ix_sessions_expires_at (expires_at) Expurgo noturno de sessões vencidas.
ix_sessions_parent (parent_session_id) WHERE parent_session_id IS NOT NULL Rastreia a cadeia de rotação ao detectar reuso.
ix_sessions_impersonation (impersonated_by_admin_id, issued_at DESC) WHERE impersonated_by_admin_id IS NOT NULL Auditoria de impersonação.

6.6.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) CASCADE CASCADE
admin_user_id admin_users(id) CASCADE CASCADE
parent_session_id sessions(id) SET NULL CASCADE
impersonated_by_admin_id admin_users(id) SET NULL CASCADE

6.6.3 Constraints CHECK #

ALTER TABLE sessions
  ADD CONSTRAINT chk_sessions_exactly_one_subject
      CHECK (num_nonnulls(subscriber_id, admin_user_id) = 1),
  ADD CONSTRAINT chk_sessions_subject_type_matches
      CHECK ((subject_type = 'SUBSCRIBER' AND subscriber_id IS NOT NULL)
          OR (subject_type = 'ADMIN'      AND admin_user_id IS NOT NULL)),
  ADD CONSTRAINT chk_sessions_role_matches_subject
      CHECK ((subject_type = 'SUBSCRIBER' AND role = 'SUBSCRIBER')
          OR (subject_type = 'ADMIN'      AND role IN ('EDITOR','ADMIN','OWNER'))),
  ADD CONSTRAINT chk_sessions_expiry CHECK (expires_at > issued_at),
  ADD CONSTRAINT chk_sessions_revoked_reason
      CHECK ((revoked_at IS NULL) = (revoked_reason IS NULL)),
  ADD CONSTRAINT chk_sessions_impersonation_scope
      CHECK ((impersonated_by_admin_id IS NULL) = (scope <> 'IMPERSONATION_READONLY')),
  ADD CONSTRAINT chk_sessions_impersonation_reason
      CHECK (impersonated_by_admin_id IS NULL
             OR (impersonation_reason IS NOT NULL
                 AND length(btrim(impersonation_reason)) >= 10)),
  ADD CONSTRAINT chk_sessions_restricted_is_subscriber
      CHECK (scope <> 'RESTRICTED' OR subject_type = 'SUBSCRIBER');

chk_sessions_impersonation_scope é a garantia estrutural de que impersonação é somente leitura: uma sessão com administrador impersonando é obrigatoriamente IMPERSONATION_READONLY, e o invólucro de API recusa todo POST, PATCH, PUT e DELETE nessa sessão antes de o handler existir. A trava vale para todas as rotas que alteram estado do assinante, sem exceção de prefixo — inclusive cancelamento de assinatura, troca de forma de pagamento, troca de plano, reenvio, pedido de eliminação e opt-out, que não vivem sob /api/me/. A verificação por handler continua existindo, como segunda camada, nunca como única.

6.7 Tabela otp_codes #

Códigos de uso único para login do assinante e verificações. Cobre tanto o OTP numérico do WhatsApp quanto o token do magic link por e-mail. A semântica está na Seção 8.2 e 8.3.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. Também é o sal de derivação do hash.
purpose OtpPurpose não Para que serve o código.
channel OtpChannel não WHATSAPP (6 dígitos) ou EMAIL_LINK (token de 32 bytes).
phone_e164 text sim NULL Destino quando o canal é WhatsApp. Envelope cifrado, mesmo formato de subscribers.phone_e164 (6.1.8).
phone_hmac char(64) sim NULL HMAC do destino. É por esta coluna que se conta quantos pedidos aquele número recebeu na última hora.
email text sim NULL Destino quando o canal é e-mail. Envelope cifrado.
email_hmac char(64) sim NULL HMAC do e-mail em minúsculas.
subscriber_id char(26) sim NULL Preenchido quando o destinatário já existe. Nulo em verificação de número novo.
code_hash char(64) não sha256(pepper ‖ id ‖ código) em hex. O código em claro nunca é persistido nem logado.
attempts smallint não 0 Tentativas de verificação já feitas.
max_attempts smallint não 5 Limite de tentativas.
expires_at timestamptz não Vencimento. 10 minutos para WhatsApp, 20 para magic link.
consumed_at timestamptz sim NULL Instante do uso bem-sucedido. Torna o código inutilizável.
invalidated_at timestamptz sim NULL Invalidação por reenvio, login bem-sucedido por outro código ou ação de suporte.
request_ip inet sim NULL IP que pediu o código. Alimenta o limite por IP.
delivery_message_log_id char(26) sim NULL Mensagem de entrega correspondente, quando o canal é WhatsApp.
created_at timestamptz não now()

6.7.1 Índices #

Índice Definição Justificativa
otp_codes_pkey PRIMARY KEY (id)
ix_otp_codes_phone_active (phone_hmac, created_at DESC) WHERE consumed_at IS NULL AND invalidated_at IS NULL Busca o código vigente de um número. A contagem de pedidos por número na última hora, que é o limite anti-enumeração da Seção 8.2.1, também sai daqui.
ix_otp_codes_email_active (email_hmac, created_at DESC) WHERE consumed_at IS NULL AND invalidated_at IS NULL AND email_hmac IS NOT NULL Mesma coisa para magic link.
ix_otp_codes_ip_window (request_ip, created_at DESC) WHERE request_ip IS NOT NULL Limite por IP e detecção de varredura de números.
ix_otp_codes_expires_at (expires_at) Expurgo horário.
ix_otp_codes_subscriber (subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL Histórico de tentativas de login exibido no suporte.

6.7.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) CASCADE CASCADE

delivery_message_log_id não é FK. message_logs é particionada e sua chave primária é composta (id, created_at), então uma FK simples por id não é declarável. A coluna é uma referência lógica, resolvida na aplicação. Isso está documentado em 6.19.4.

6.7.3 Constraints CHECK #

ALTER TABLE otp_codes
  ADD CONSTRAINT chk_otp_destination
      CHECK ((channel = 'WHATSAPP'   AND phone_e164 IS NOT NULL AND phone_hmac IS NOT NULL)
          OR (channel = 'EMAIL_LINK' AND email IS NOT NULL AND email_hmac IS NOT NULL)),
  ADD CONSTRAINT chk_otp_phone_envelope
      CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_otp_email_envelope
      CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_otp_phone_hmac
      CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_otp_email_hmac
      CHECK (email_hmac IS NULL OR email_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_otp_attempts CHECK (attempts >= 0 AND attempts <= max_attempts),
  ADD CONSTRAINT chk_otp_max_attempts CHECK (max_attempts BETWEEN 1 AND 10),
  ADD CONSTRAINT chk_otp_expiry CHECK (expires_at > created_at),
  ADD CONSTRAINT chk_otp_code_hash CHECK (code_hash ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_otp_single_terminal_state
      CHECK (consumed_at IS NULL OR invalidated_at IS NULL);

O último CHECK impede que um código esteja simultaneamente consumido e invalidado, o que tornaria ambígua a auditoria de "este código foi usado ou barrado?".

6.7.4 Tabela unsubscribe_tokens #

Tokens opacos de uso único do link público de descadastro, aquele que acompanha o rodapé do e-mail e a mensagem de confirmação. É a 28ª tabela do modelo e a última da lista fechada de 6.1.7. Esta subseção é a única dona da estrutura: a Seção 20, que é a dona do comportamento do opt-out, referencia daqui e não redefine coluna, tipo nem índice.

Ela não cabe em otp_codes: o token do descadastro não é um código de autenticação, não tem contador de tentativas, não tem canal de entrega próprio e a sua validade é de dias, e não de minutos. Reaproveitar otp_codes exigiria um valor novo no enum OtpPurpose e colunas que nunca seriam preenchidas — uma tabela própria é mais barata do que um enum poluído.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. Chave primária.
subscriber_id char(26) não Titular a quem o link pertence.
token_hash char(64) não sha256 do token opaco, em hex minúsculo. O token em claro é entregue uma única vez, no corpo do e-mail ou da mensagem, e nunca é persistido nem registrado em log.
created_at timestamptz não now() Emissão.
expires_at timestamptz não Vencimento. created_at + 90 dias, para que um e-mail antigo ainda funcione dentro de um ciclo razoável de caixa de entrada.
used_at timestamptz sim NULL Instante do consumo. Preenchido torna o token inutilizável: o segundo clique no mesmo link devolve UNSUBSCRIBE_TOKEN_INVALID (Seção 7.11).

Sem updated_at: a linha nasce e, no máximo, recebe used_at uma vez.

Índices

Índice Definição Justificativa
unsubscribe_tokens_pkey PRIMARY KEY (id)
uq_unsubscribe_token_hash UNIQUE (token_hash) Resolve o token apresentado em uma leitura e impede colisão.
ix_unsubscribe_tokens_subscriber (subscriber_id, created_at DESC) Todos os links emitidos para um titular, em ordem, no atendimento.
ix_unsubscribe_tokens_expiring (expires_at) WHERE used_at IS NULL Expurgo dos tokens vencidos e nunca usados, que são a maioria.

Chave estrangeira

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) CASCADE CASCADE

CASCADE aqui é o oposto do RESTRICT de consent_events, e por um motivo: o token não é prova de nada. Ele é uma credencial de curta vida; sumindo o titular, ela perde qualquer função e mantê-la só deixa um segredo vivo apontando para uma conta que não existe. A prova do descadastro fica em consent_events, com type = 'OPT_OUT', que é append-only.

Constraints CHECK

ALTER TABLE unsubscribe_tokens
  ADD CONSTRAINT chk_unsub_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
  ADD CONSTRAINT chk_unsub_token_hash CHECK (token_hash ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_unsub_expiry CHECK (expires_at > created_at),
  ADD CONSTRAINT chk_unsub_used_after_created
      CHECK (used_at IS NULL OR used_at >= created_at);

DDL completo, para a migration M4 (a criação da tabela e a FK) e para M10 e M11 (o índice parcial e os CHECK, que o Prisma não representa — 6.31.2):

CREATE TABLE unsubscribe_tokens (
  id            char(26)    NOT NULL,
  subscriber_id char(26)    NOT NULL,
  token_hash    char(64)    NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  expires_at    timestamptz NOT NULL,
  used_at       timestamptz,
  CONSTRAINT unsubscribe_tokens_pkey PRIMARY KEY (id),
  CONSTRAINT unsubscribe_tokens_subscriber_id_fkey
    FOREIGN KEY (subscriber_id) REFERENCES subscribers(id)
    ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE UNIQUE INDEX uq_unsubscribe_token_hash
  ON unsubscribe_tokens (token_hash);
CREATE INDEX ix_unsubscribe_tokens_subscriber
  ON unsubscribe_tokens (subscriber_id, created_at DESC);
CREATE INDEX ix_unsubscribe_tokens_expiring
  ON unsubscribe_tokens (expires_at) WHERE used_at IS NULL;

Retenção. Expurgo físico 30 dias depois de expires_at, junto com o expurgo de otp_codes (6.33.1 e 6.33.2), e imediato em qualquer token do titular no momento em que o opt-out é gravado — quem já saiu não precisa de um link para sair de novo. Volume esperado em 1 ano, com 10.000 assinantes: cerca de 15.000 linhas.

6.8 Tabela admin_users #

Contas do painel administrativo. Não há autocadastro: contas nascem por seed ou por criação feita por outra conta com papel OWNER ou ADMIN.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
email citext não Login. Case-insensitive.
name varchar(120) não Nome exibido no painel e na auditoria.
password_hash text não Hash argon2id completo, no formato PHC ($argon2id$v=19$m=...). Parâmetros na Seção 8.5.
role Role não 'EDITOR' Papel. Nunca SUBSCRIBER.
totp_secret_encrypted bytea sim NULL Segredo TOTP cifrado com AES-256-GCM. Nulo até a conclusão do enrolamento.
totp_enrolled_at timestamptz sim NULL Conclusão do enrolamento. Enquanto nulo, o login exige enrolar antes de qualquer outra ação.
totp_recovery_codes jsonb sim NULL Array de objetos {"hash":"<sha256>","usedAt":null}. 10 códigos. Nunca em claro.
failed_login_count smallint não 0 Total agregado de falhas de senha ou TOTP, apenas para alerta. A escada de bloqueio incide sobre o par (conta, origem) e é mantida em Redis sob authfail:{adminId}:{ipPrefix} (Seção 8.7); esta coluna nunca decide um bloqueio sozinha.
locked_until timestamptz sim NULL Bloqueio global da conta, usado só na salvaguarda contra ataque distribuído: 20 falhas agregadas em 1 hora vindas de 3 ou mais origens distintas trancam a conta por 1 hora e emitem alerta crítico.
last_login_at timestamptz sim NULL
last_login_ip inet sim NULL
password_changed_at timestamptz não now() Base para expiração de senha e para invalidar sessões antigas.
must_change_password boolean não false Força troca no próximo login. Verdadeiro nas contas criadas por seed.
created_by_admin_id char(26) sim NULL Quem criou a conta. Nulo apenas na conta de seed.
deleted_at timestamptz sim NULL Soft delete. Conta desativada não faz login e não aparece em listas.
created_at timestamptz não now()
updated_at timestamptz não now()

6.8.1 Índices #

Índice Definição Justificativa
admin_users_pkey PRIMARY KEY (id)
uq_admin_users_email UNIQUE (email) WHERE deleted_at IS NULL Login único entre contas vivas. Permite reaproveitar o e-mail depois de uma conta ser desativada.
ix_admin_users_role (role) WHERE deleted_at IS NULL Lista por papel e verificação de "existe ao menos um OWNER".
ix_admin_users_locked (locked_until) WHERE locked_until IS NOT NULL Desbloqueio automático em lote.

6.8.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
created_by_admin_id admin_users(id) SET NULL CASCADE

6.8.3 Constraints CHECK #

ALTER TABLE admin_users
  ADD CONSTRAINT chk_admin_role_not_subscriber CHECK (role <> 'SUBSCRIBER'),
  ADD CONSTRAINT chk_admin_email_shape CHECK (email ~ '^[^@[:space:]]+@[^@[:space:]]+\.[a-z]{2,}$'),
  ADD CONSTRAINT chk_admin_password_hash_argon2 CHECK (password_hash LIKE '$argon2id$%'),
  ADD CONSTRAINT chk_admin_failed_login CHECK (failed_login_count BETWEEN 0 AND 100),
  ADD CONSTRAINT chk_admin_totp_pair
      CHECK ((totp_secret_encrypted IS NULL AND totp_enrolled_at IS NULL)
          OR (totp_secret_encrypted IS NOT NULL));

Regra adicional garantida na aplicação, não no banco: sempre existe ao menos uma conta OWNER ativa. Tentativa de rebaixar ou desativar o último OWNER devolve OWNER_REQUIRED (Seção 7.11). Não é um CHECK porque a verificação é sobre o conjunto da tabela, não sobre a linha, e um trigger para isso criaria contenção no UPDATE.

6.8.4 Tabela admin_trusted_devices #

Dispositivos que o administrador marcou como confiáveis, dispensando o segundo fator em logins seguintes. É a 27ª tabela do modelo; a 28ª e última é unsubscribe_tokens (6.7.4).

Ela existe por um motivo específico: um fingerprint derivado de cabeçalhos não pode governar autenticação. O sessions.device_fingerprint é sha256 de user-agent, Accept-Language e plataforma declarada — três valores inteiramente controlados pelo cliente e de entropia baixíssima. Usar esse valor para dispensar o TOTP significaria que adivinhar a combinação de cabeçalhos mais comum entre administradores brasileiros (Chrome em Macintosh com pt-BR) burla o segundo fator com a senha na mão. O que dispensa o TOTP passa a ser um segredo emitido pelo servidor, guardado só como hash.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
admin_user_id char(26) não Dono do dispositivo.
token_hash char(64) não sha256, em hex, dos 32 bytes de crypto.randomBytes entregues no cookie __Host-admin-device. O valor em claro nunca é persistido.
label varchar(80) sim NULL Rótulo legível, montado a partir do user-agent no momento da criação. Informativo apenas.
created_at timestamptz não now()
expires_at timestamptz não created_at + 30 dias.
last_used_at timestamptz sim NULL Último login que apresentou este cookie.

Índices

Índice Definição Justificativa
admin_trusted_devices_pkey PRIMARY KEY (id)
uq_admin_trusted_token UNIQUE (token_hash) Resolve o cookie em uma leitura e impede colisão.
ix_admin_trusted_admin (admin_user_id, expires_at DESC) Lista de dispositivos confiáveis do administrador e revogação em massa.
ix_admin_trusted_expires (expires_at) Expurgo dos vencidos.

Chave estrangeira

Coluna Referencia ON DELETE ON UPDATE
admin_user_id admin_users(id) CASCADE CASCADE

Constraints CHECK

ALTER TABLE admin_trusted_devices
  ADD CONSTRAINT chk_trusted_token_hash CHECK (token_hash ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_trusted_expiry CHECK (expires_at > created_at);

Regras de uso, garantidas na aplicação:

  1. A senha continua sendo exigida sempre. O cookie dispensa apenas o TOTP.
  2. Toda a lista do administrador é apagada em qualquer troca de senha, em uso de código de recuperação e em logout-all.
  3. sessions.device_fingerprint permanece exclusivamente como sinal informativo para a notificação de dispositivo novo, e é proibido usá-lo em qualquer decisão de autorização. Um teste de arquitetura falha se device_fingerprint for lido fora do módulo de notificação.

6.9 Tabela admin_audit_log #

Registro de toda ação administrativa que muda estado. É append-only na prática, com a mesma proteção de consent_events (ver 6.36.3).

Esta tabela tem um único esquema, e é o desta subseção. As Seções 13, 15 e 23, que listam campos da auditoria em tela, em exportação e em documentação de formato, usam exatamente os nomes e os tipos abaixo. A tabela é append-only com 5 anos de retenção: errar o nome de uma coluna aqui não é reversível por migration barata, então a lista de nomes proibidos ao final desta subseção é tão normativa quanto a própria tabela.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
admin_user_id char(26) sim NULL Autor administrativo da ação. Nulo quando actor_type é SYSTEM ou SUBSCRIBER. É o único nome deste campo; actor_admin_id e actor_id não existem.
actor_type varchar(20) não 'ADMIN' Natureza do autor: ADMIN, SUBSCRIBER ou SYSTEM. É o único dos campos de ator que não é redundante: admin_user_id nulo, sozinho, não distingue "foi o sistema" de "foi o próprio titular" — e o titular grava auditoria quando age sobre a própria conta (Seção 14.6.2).
actor_role varchar(20) não Papel efetivo no momento da ação: EDITOR, ADMIN, OWNER ou SYSTEM. Gravado em vez de derivado, porque o papel do administrador muda depois e o registro precisa dizer o que ele podia fazer naquele dia.
action varchar(80) não Verbo canônico. Ex.: devotional.publish, subscriber.impersonate.start, settings.update.
entity_type varchar(60) sim NULL Nome da tabela ou agregado afetado. É o único nome deste campo; target_type não existe.
entity_id char(26) sim NULL Identificador do recurso afetado. É o único nome deste campo; target_id não existe.
before jsonb sim NULL Estado anterior, apenas com os campos alterados. Campos sensíveis já redigidos. É o único nome deste campo; before_json não existe.
after jsonb sim NULL Estado posterior, mesmos campos. É o único nome deste campo; after_json não existe.
changed_fields text[] não '{}' Nomes dos campos alterados nesta ação. Permite filtrar a auditoria por campo sem abrir o JSON de before e after, que é a consulta cara.
metadata jsonb sim NULL Dados estruturados específicos da ação — por exemplo days e previousCourtesyUntil na concessão de cortesia (Seção 13.11.3). Nunca contém dado pessoal em texto claro.
record_hash char(64) não sha256, em hex minúsculo, do conteúdo canônico da linha concatenado ao record_hash da linha anterior. É o encadeamento que detecta adulteração (Seção 23.11.3). Calculado no gatilho de inserção, junto com a proteção append-only de 6.36.3.
reason text sim NULL Justificativa. Obrigatória em ações destrutivas e em impersonação.
ip inet sim NULL
user_agent text sim NULL
request_id char(26) sim NULL ULID da requisição HTTP. Correlaciona com os logs da aplicação.
session_id char(26) sim NULL Sessão usada. Referência lógica; sessões expurgadas não invalidam a auditoria.
created_at timestamptz não now() Carimbo da ação, sempre timestamptz em UTC. É a única coluna de tempo desta tabela.

Nomes que não são colunas desta tabela, e que nenhuma seção pode citar como se fossem:

Nome citado em outra seção O que usar
actor_admin_id, actor_id admin_user_id
target_type, target_id entity_type, entity_id. Os parâmetros de query da rota de auditoria acompanham: entityType e entityId (Seção 15.12.15)
before_json, after_json before, after
actor_email Não é persistido. O e-mail do autor é resolvido na exportação a partir de admin_user_id, porque duplicar o e-mail em 80.000 linhas append-only cria uma segunda cópia de dado pessoal que a troca de e-mail do administrador nunca alcança.
created_at_utc, created_at_brt Não são colunas. São projeções de apresentação de created_at, calculadas na geração do CSV de exportação (Seção 15.10.3): a primeira é a própria coluna, a segunda é a conversão para America/Sao_Paulo.

6.9.1 Índices #

Índice Definição Justificativa
admin_audit_log_pkey PRIMARY KEY (id)
ix_audit_admin_time (admin_user_id, created_at DESC) "O que este administrador fez?"
ix_audit_entity (entity_type, entity_id, created_at DESC) "Quem mexeu neste devocional?"
ix_audit_action_time (action, created_at DESC) Relatório por tipo de ação e alerta de ações sensíveis.
ix_audit_request_id (request_id) WHERE request_id IS NOT NULL Correlação com log de aplicação a partir do X-Request-Id.
ix_audit_actor_type_time (actor_type, created_at DESC) Separa a ação do administrador da ação do sistema e da ação do próprio titular nos relatórios de acesso.
ix_audit_changed_fields GIN (changed_fields) Filtra "quem mexeu neste campo?" sem abrir before e after.

6.9.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
admin_user_id admin_users(id) RESTRICT CASCADE

RESTRICT protege a trilha: administradores são desativados por soft delete, nunca apagados. Um DELETE real falha, o que é o comportamento desejado.

6.9.3 Constraints CHECK #

ALTER TABLE admin_audit_log
  ADD CONSTRAINT chk_audit_action_shape CHECK (action ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
  ADD CONSTRAINT chk_audit_entity_pair
      CHECK ((entity_type IS NULL) = (entity_id IS NULL)),
  ADD CONSTRAINT chk_audit_actor_type
      CHECK (actor_type IN ('ADMIN','SUBSCRIBER','SYSTEM')),
  ADD CONSTRAINT chk_audit_actor_role
      CHECK (actor_role IN ('EDITOR','ADMIN','OWNER','SYSTEM')),
  ADD CONSTRAINT chk_audit_admin_actor_pair
      CHECK ((actor_type = 'ADMIN') = (admin_user_id IS NOT NULL)),
  ADD CONSTRAINT chk_audit_system_role
      CHECK (actor_type <> 'SYSTEM' OR actor_role = 'SYSTEM'),
  ADD CONSTRAINT chk_audit_record_hash CHECK (record_hash ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_audit_reason_required
      CHECK (action NOT IN ('subscriber.impersonate.start','subscriber.delete',
                            'devotional.delete','admin_user.delete','settings.update')
             OR (reason IS NOT NULL AND length(btrim(reason)) >= 10));

O último CHECK exige justificativa de no mínimo 10 caracteres nas cinco ações consideradas sensíveis. Sem isso a auditoria vira ruído.

chk_audit_admin_actor_pair é o que amarra os dois campos de ator: actor_type = 'ADMIN' exige admin_user_id, e qualquer outro valor exige que ele seja nulo. Sem essa amarra, uma linha com actor_type = 'SYSTEM' e um admin_user_id preenchido atribuiria a uma pessoa uma ação que ela não praticou — o pior defeito possível em uma trilha de auditoria.

action é sempre grupo.verbo em minúsculas, e o CHECK acima recusa qualquer outra grafia. Onde outra seção citar uma ação em maiúsculas, o nome canônico é o desta tabela. As ações que outras seções acionam e que precisam existir com estes nomes exatos são:

Ação Quando é gravada
subscriber.impersonate.start Início de sessão de impersonação (Seção 8.13.4). Exige reason.
subscriber.impersonate.end Encerramento da sessão de impersonação, por logout ou por expiração.
subscriber.viewed Abertura da ficha de um assinante. Alimenta o alerta de acesso em massa.
subscriber.pii_revealed Revelação de telefone, e-mail ou CPF em claro no painel. after guarda só o nome do campo revelado, nunca o valor.
subscriber.anonymized Estágio 1 do pedido de eliminação (pseudonimização).
subscriber.fully_anonymized Estágio 2, quando a retenção fiscal vence e CPF e identificadores do provedor de pagamento são zerados (Seção 22.9).
data_export.downloaded Cada download do pacote de portabilidade, sob sessão plena.
settings.update Alteração de chave de configuração. Exige reason.

O par subscriber.viewed / subscriber.pii_revealed é o único controle capaz de detectar um administrador vasculhando a base. Registrar sem alertar não detecta nada: os limiares e os destinatários do alerta estão na Seção 23.8.

6.10 Tabela plans #

Catálogo de planos vendáveis. Duas linhas no MVP: plan_monthly e plan_annual.

O plano gratuito não é uma linha desta tabela. Ser gratuito é a ausência de assinatura vigente. Onde a interface precisar exibir um "plano gratuito" — a tabela comparativa da página de vendas, por exemplo —, o item é sintetizado pelo handler, com valores fixos e priceCents: 0, e nunca corresponde a nenhuma linha aqui. Nenhuma escrita usa o código plan_free, e nenhum seed o cria. Um executor que criar essa linha para "fazer a rota funcionar" quebra o cálculo de MRR, a contagem de assinantes pagos e a regra de uma assinatura viva por assinante.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
code varchar(40) não Identificador estável usado em código e URL. plan_monthly, plan_annual.
name varchar(80) não Nome exibido. Ex.: Mensal.
description varchar(200) sim NULL Linha de apoio na página de vendas.
interval PlanInterval não MONTHLY ou YEARLY.
amount_cents integer não Preço em centavos de BRL. 1990 = R$ 19,90.
currency char(3) não 'BRL' ISO 4217.
tier_granted SubscriberTier não 'PAID' Tier concedido enquanto a assinatura estiver ativa.
asaas_cycle varchar(20) não Valor de ciclo aceito pelo provedor de pagamento: MONTHLY ou YEARLY.
is_active boolean não true Plano fechado para novas vendas fica false, sem afetar assinaturas existentes.
sort_order smallint não 0 Ordem de exibição na página de vendas.
created_at timestamptz não now()
updated_at timestamptz não now()

6.10.1 Índices #

Índice Definição Justificativa
plans_pkey PRIMARY KEY (id)
uq_plans_code UNIQUE (code) Referência estável a partir do código, da URL de checkout e dos seeds.
ix_plans_active_order (is_active, sort_order) Montagem da grade de preços.

6.10.2 Constraints CHECK #

ALTER TABLE plans
  ADD CONSTRAINT chk_plans_code_shape CHECK (code ~ '^plan_[a-z0-9_]{2,30}$'),
  ADD CONSTRAINT chk_plans_amount CHECK (amount_cents > 0 AND amount_cents <= 100000000),
  ADD CONSTRAINT chk_plans_currency CHECK (currency = 'BRL'),
  ADD CONSTRAINT chk_plans_cycle_matches_interval
      CHECK ((interval = 'MONTHLY' AND asaas_cycle = 'MONTHLY')
          OR (interval = 'YEARLY'  AND asaas_cycle = 'YEARLY'));

Preço nunca é editado in loco depois de existirem assinaturas. Mudança de preço cria uma linha nova com novo code (ex.: plan_monthly_v2) e marca a anterior como is_active = false. Isso preserva o histórico e é o motivo de subscriptions guardar amount_cents como snapshot próprio (6.11).

6.11 Tabela subscriptions #

Assinatura paga de um assinante. A máquina de estados e as regras de revogação imediata estão na Seção 13; aqui fica a estrutura que as sustenta.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. É também o externalReference enviado ao provedor de pagamento.
subscriber_id char(26) não Dono da assinatura.
plan_id char(26) não Plano contratado.
status SubscriptionStatus não 'PENDING_PAYMENT' Estado atual.
billing_type BillingType não CREDIT_CARD ou PIX.
amount_cents integer não Snapshot do preço no momento da contratação.
currency char(3) não 'BRL'
asaas_customer_id varchar(40) sim NULL Identificador do cliente no provedor. Cópia de subscribers.asaas_customer_id, preenchida antes da criação da assinatura.
asaas_subscription_id varchar(40) sim NULL Identificador da assinatura no provedor.
asaas_card_token varchar(64) sim NULL Token do cartão tokenizado no provedor. Não é dado de cartão: é uma referência opaca. Número completo, CVV e validade nunca transitam para persistência nem para log.
card_brand varchar(20) sim NULL Bandeira do cartão vinculado. Ex.: VISA.
card_last4 char(4) sim NULL Últimos 4 dígitos, para o assinante reconhecer o cartão no painel.
card_exp_month smallint sim NULL Mês de validade, 1 a 12. Base do aviso de cartão a vencer.
card_exp_year smallint sim NULL Ano de validade, quatro dígitos.
pending_plan_id char(26) sim NULL Plano para o qual a assinatura migra no próximo ciclo, quando há troca de plano agendada.
pending_plan_effective_at timestamptz sim NULL Instante em que a troca de plano passa a valer. Sempre o fim do período pago vigente.
cancel_at_period_end boolean não false O sinalizador. Verdadeiro quando a assinatura não gera nova cobrança mas o período já pago segue valendo. Ligado no cancelamento voluntário (Seção 13.4.1, caso b) e no opt-out (Seção 13.4.6). É o campo que o job billing.lifecycle lê para decidir se renova.
billing_suspended_at timestamptz sim NULL O carimbo, e só quando a origem foi o opt-out. Guarda o instante em que cancel_at_period_end foi ligado por opt-out; permanece nulo quando a origem foi cancelamento voluntário pelo painel. As duas colunas não são redundantes: o booleano diz o quê (não renova), o carimbo diz por quê e quando, e é ele que a régua de 30 dias usa para encerrar a assinatura sem reativação. Envios param na hora e a cobrança do ciclo seguinte não é emitida; sem reativação em 30 dias, a assinatura é encerrada ao fim do período já pago (Seção 13.4.6).
end_reason varchar(40) sim NULL Causa canônica do encerramento. Ex.: subscriber_request, payment_overdue, refund, chargeback, optout_timeout, admin.
started_at timestamptz sim NULL Primeira confirmação de pagamento.
current_period_start timestamptz sim NULL Início do ciclo pago vigente.
current_period_end timestamptz sim NULL Fim do ciclo pago vigente. Fronteira do acesso após cancelamento.
next_due_date date sim NULL Próximo vencimento informado pelo provedor. Base dos lembretes D-3 e D-1.
cancel_requested_at timestamptz sim NULL Pedido de cancelamento feito pelo assinante.
canceled_at timestamptz sim NULL Efetivação do cancelamento.
cancel_reason varchar(120) sim NULL Motivo escolhido no painel ou informado pelo suporte.
ends_at timestamptz sim NULL Instante em que o acesso PAID termina de fato.
expired_at timestamptz sim NULL Revogação por inadimplência.
refunded_at timestamptz sim NULL Estorno.
last_payment_id char(26) sim NULL Último pagamento associado.
reconciled_at timestamptz sim NULL Última conferência bem-sucedida contra o provedor.
metadata jsonb sim NULL Campos auxiliares do provedor que não merecem coluna própria.
created_at timestamptz não now()
updated_at timestamptz não now()

6.11.1 Índices #

Índice Definição Justificativa
subscriptions_pkey PRIMARY KEY (id)
uq_subscriptions_asaas_id UNIQUE (asaas_subscription_id) WHERE asaas_subscription_id IS NOT NULL Resolve o webhook do provedor em uma leitura e impede duplicidade de vínculo.
uq_subscriptions_one_live_per_subscriber UNIQUE (subscriber_id) WHERE status IN ('PENDING_PAYMENT','ACTIVE') Regra de negócio no banco: um assinante não pode ter duas assinaturas vivas. Tentativa concorrente falha com violação de unicidade, traduzida para SUBSCRIPTION_ALREADY_ACTIVE.
ix_subscriptions_subscriber_time (subscriber_id, created_at DESC) Histórico de assinaturas no painel e no suporte.
ix_subscriptions_status_period_end (status, current_period_end) Encontra assinaturas canceladas cujo período pago venceu e precisam ser rebaixadas.
ix_subscriptions_next_due (next_due_date) WHERE status = 'ACTIVE' Seleciona alvos dos lembretes D-3 e D-1.
ix_subscriptions_reconcile (reconciled_at NULLS FIRST) WHERE status IN ('PENDING_PAYMENT','ACTIVE') Fila da reconciliação diária, do mais desatualizado para o mais recente.
ix_subscriptions_plan (plan_id) Contagem por plano no dashboard.
ix_subscriptions_cancel_at_period_end (current_period_end) WHERE cancel_at_period_end = true Varredura do job billing.lifecycle: quais assinaturas terminam e não renovam. Parcial porque a esmagadora maioria das linhas vivas renova.

6.11.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) RESTRICT CASCADE
plan_id plans(id) RESTRICT CASCADE
pending_plan_id plans(id) RESTRICT CASCADE
last_payment_id payments(id) SET NULL CASCADE

RESTRICT em subscriber_id e plan_id é obrigação fiscal: histórico financeiro não some porque alguém apagou um cadastro. last_payment_id é FK circular com payments.subscription_id, declarada DEFERRABLE INITIALLY DEFERRED.

6.11.3 Constraints CHECK #

ALTER TABLE subscriptions
  ADD CONSTRAINT chk_subs_amount CHECK (amount_cents > 0),
  ADD CONSTRAINT chk_subs_currency CHECK (currency = 'BRL'),
  ADD CONSTRAINT chk_subs_period CHECK (current_period_end IS NULL
                                     OR current_period_start IS NULL
                                     OR current_period_end > current_period_start),
  ADD CONSTRAINT chk_subs_active_needs_period
      CHECK (status <> 'ACTIVE' OR current_period_end IS NOT NULL),
  ADD CONSTRAINT chk_subs_canceled_fields
      CHECK (status <> 'CANCELED' OR canceled_at IS NOT NULL),
  ADD CONSTRAINT chk_subs_expired_fields
      CHECK (status <> 'EXPIRED' OR expired_at IS NOT NULL),
  ADD CONSTRAINT chk_subs_refunded_fields
      CHECK (status <> 'REFUNDED' OR refunded_at IS NOT NULL),
  ADD CONSTRAINT chk_subs_cancel_order
      CHECK (cancel_requested_at IS NULL OR canceled_at IS NULL
             OR canceled_at >= cancel_requested_at),
  ADD CONSTRAINT chk_subs_card_pair
      CHECK ((card_brand IS NULL) = (card_last4 IS NULL)),
  ADD CONSTRAINT chk_subs_card_last4
      CHECK (card_last4 IS NULL OR card_last4 ~ '^[0-9]{4}$'),
  ADD CONSTRAINT chk_subs_card_expiry
      CHECK ((card_exp_month IS NULL) = (card_exp_year IS NULL)),
  ADD CONSTRAINT chk_subs_card_exp_month
      CHECK (card_exp_month IS NULL OR card_exp_month BETWEEN 1 AND 12),
  ADD CONSTRAINT chk_subs_card_exp_year
      CHECK (card_exp_year IS NULL OR card_exp_year BETWEEN 2020 AND 2100),
  ADD CONSTRAINT chk_subs_card_only_for_card
      CHECK (billing_type = 'CREDIT_CARD' OR asaas_card_token IS NULL),
  ADD CONSTRAINT chk_subs_pending_plan_pair
      CHECK ((pending_plan_id IS NULL) = (pending_plan_effective_at IS NULL)),
  ADD CONSTRAINT chk_subs_pending_plan_differs
      CHECK (pending_plan_id IS NULL OR pending_plan_id <> plan_id),
  ADD CONSTRAINT chk_subs_end_reason_shape
      CHECK (end_reason IS NULL OR end_reason ~ '^[a-z][a-z0-9_]{2,39}$'),
  ADD CONSTRAINT chk_subs_ended_needs_reason
      CHECK (status NOT IN ('CANCELED','EXPIRED','REFUNDED') OR end_reason IS NOT NULL),
  ADD CONSTRAINT chk_subs_suspension_implies_flag
      CHECK (billing_suspended_at IS NULL OR cancel_at_period_end = true);

chk_subs_pending_plan_differs impede o caso silencioso de uma troca de plano agendada para o mesmo plano, que passaria pelo UPDATE e nunca produziria efeito visível. chk_subs_ended_needs_reason garante que toda assinatura encerrada carregue a causa: sem ela, o relatório de churn da Seção 21 mede quantidade e não consegue medir motivo.

chk_subs_suspension_implies_flag amarra as duas colunas de suspensão no único sentido em que a implicação vale: carimbo preenchido exige sinalizador ligado, nunca o contrário. O inverso seria errado — o cancelamento voluntário liga o sinalizador e deixa o carimbo nulo de propósito, porque ali não houve opt-out. Sem esta trava, uma escrita parcial produziria uma assinatura marcada como suspensa por opt-out que o job de ciclo continuaria renovando.

6.11.4 Exemplo concreto de linha #

Assinante contrata o plano mensal por PIX em 2026-08-25 e o pagamento é confirmado no mesmo dia:

{
  "id": "01K3F8R1P2ABCDEFGHJKMNPQRS",
  "subscriberId": "01K3F8QZ7MHV2N9R4B6T0XYZAB",
  "planId": "01K3F8RKL1PLANMONTHLY00001",
  "status": "ACTIVE",
  "billingType": "PIX",
  "amountCents": 1990,
  "currency": "BRL",
  "startedAt": "2026-08-25T13:04:11.000Z",
  "currentPeriodStart": "2026-08-25T13:04:11.000Z",
  "currentPeriodEnd": "2026-09-25T13:04:11.000Z",
  "nextDueDate": "2026-09-25"
}

Se em 2026-09-25 o provedor emitir PAYMENT_OVERDUE, a mesma transação grava status = 'EXPIRED', expired_at = now(), ends_at = now() e subscribers.tier = 'FREE'. Não há carência: o envio de 2026-09-26 já sai sem áudio, e no domingo seguinte o assinante volta ao regime semanal.

6.12 Tabela subscription_events #

Histórico imutável das transições de assinatura. Serve de trilha para suporte, para o cálculo de churn (Seção 21) e para reconstruir o estado em caso de divergência.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
subscription_id char(26) não Assinatura afetada.
subscriber_id char(26) não Denormalizado para consultar a linha do tempo do assinante sem junção.
type SubscriptionEventType não Tipo da transição.
from_status SubscriptionStatus sim NULL Estado anterior. Nulo no evento CREATED.
to_status SubscriptionStatus não Estado resultante.
actor varchar(40) não system, subscriber, admin, payment_webhook, reconciliation.
actor_id char(26) sim NULL Identificador do ator quando aplicável.
payment_id char(26) sim NULL Pagamento que motivou a transição.
payment_event_id char(26) sim NULL Evento de webhook que motivou a transição.
cancel_reason varchar(40) sim NULL Motivo escolhido na lista fechada da tela de cancelamento. Ex.: too_expensive, not_using, content, technical, other. É o campo que alimenta o relatório de motivos de churn.
cancel_comment varchar(500) sim NULL Comentário livre do assinante no cancelamento. Obrigatório quando cancel_reason = 'other'.
reason text sim NULL Explicação legível, escrita pelo sistema ou pelo operador. Não confundir com cancel_reason, que é a escolha do assinante em lista fechada.
metadata jsonb sim NULL Contexto adicional.
occurred_at timestamptz não now()
created_at timestamptz não now()

6.12.1 Índices #

Índice Definição Justificativa
subscription_events_pkey PRIMARY KEY (id)
ix_sub_events_subscription (subscription_id, occurred_at DESC) Linha do tempo da assinatura.
ix_sub_events_subscriber (subscriber_id, occurred_at DESC) Linha do tempo do assinante, sem junção.
ix_sub_events_type_time (type, occurred_at DESC) Churn mensal e conversão, calculados por tipo e janela.
ix_sub_events_payment (payment_id) WHERE payment_id IS NOT NULL Rastreia o efeito de um pagamento específico.

6.12.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscription_id subscriptions(id) CASCADE CASCADE
subscriber_id subscribers(id) RESTRICT CASCADE
payment_id payments(id) SET NULL CASCADE
payment_event_id payment_events(id) SET NULL CASCADE

6.12.3 Constraints CHECK #

ALTER TABLE subscription_events
  ADD CONSTRAINT chk_sub_events_actor
      CHECK (actor IN ('system','subscriber','admin','payment_webhook','reconciliation')),
  ADD CONSTRAINT chk_sub_events_created_transition
      CHECK ((type <> 'CREATED') OR (from_status IS NULL)),
  ADD CONSTRAINT chk_sub_events_admin_actor
      CHECK (actor <> 'admin' OR actor_id IS NOT NULL),
  ADD CONSTRAINT chk_sub_events_cancel_reason_shape
      CHECK (cancel_reason IS NULL OR cancel_reason ~ '^[a-z][a-z0-9_]{2,39}$'),
  ADD CONSTRAINT chk_sub_events_cancel_other_needs_comment
      CHECK (cancel_reason <> 'other'
             OR (cancel_comment IS NOT NULL AND length(btrim(cancel_comment)) >= 10)),
  ADD CONSTRAINT chk_sub_events_cancel_reason_only_on_cancel
      CHECK (cancel_reason IS NULL OR type IN ('CANCEL_REQUESTED','CANCELED'));

Esta tabela é o inventário de quais transições ocorreram e de qual estado de assinatura cada uma gravou. O efeito sobre o acesso do assinante não é descrito aqui nem em nenhuma tabela de eventos: ele é sempre e apenas o da Seção 13.3, aplicado por revokePaidAccess() na mesma transação. Repetir o efeito por extenso em cada linha criaria tantos lugares para divergir quantos forem os tipos de evento.

6.13 Tabela payments #

Cobranças individuais emitidas pelo provedor de pagamento. Uma assinatura mensal gera uma linha por ciclo; uma anual, uma por ano.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
subscription_id char(26) não Assinatura de origem.
subscriber_id char(26) não Denormalizado para relatório financeiro por assinante.
asaas_payment_id varchar(40) não Identificador da cobrança no provedor.
status PaymentStatus não 'PENDING' Estado da cobrança.
billing_type BillingType não Forma de pagamento efetiva.
amount_cents integer não Valor bruto cobrado, em centavos de BRL. Divisor para BRL: 100.
net_amount_cents integer sim NULL Valor líquido após taxas, quando informado.
fee_cents integer não 0 Taxa cobrada pelo provedor, em centavos. Quando o provedor informa apenas bruto e líquido, é amount_cents - net_amount_cents. Existe como coluna própria porque a receita líquida da Seção 21 precisa da taxa mesmo quando o líquido chega nulo.
refunded_amount_cents integer não 0 Total estornado. Permite estorno parcial.
currency char(3) não 'BRL'
due_date date não Vencimento.
confirmed_at timestamptz sim NULL Confirmação do pagamento pelo provedor.
received_at timestamptz sim NULL Recebimento efetivo (compensação).
credit_date date sim NULL Data prevista de crédito na conta.
invoice_url text sim NULL Fatura hospedada pelo provedor.
receipt_url text sim NULL Comprovante hospedado pelo provedor, disponível após a confirmação. É o link que o extrato do assinante oferece para download.
chargeback_stage varchar(20) sim NULL Estágio da contestação, quando existe: REQUESTED, IN_DISPUTE, LOST, WON, REVERSED. Nulo quando não há contestação.
pix_payload text sim NULL Código copia-e-cola do PIX.
pix_qr_code_base64 text sim NULL QR Code em base64, entregue ao front.
pix_expires_at timestamptz sim NULL Vencimento do QR Code.
card_brand varchar(20) sim NULL Bandeira. Ex.: VISA.
card_last4 char(4) sim NULL Últimos 4 dígitos. Nenhum outro dado de cartão é persistido.
failure_code varchar(40) sim NULL Código de recusa do provedor.
failure_message text sim NULL Mensagem de recusa.
description varchar(200) sim NULL Descrição exibida na fatura.
reconciled_at timestamptz sim NULL Última conferência contra o provedor.
created_at timestamptz não now()
updated_at timestamptz não now()

6.13.1 Índices #

Índice Definição Justificativa
payments_pkey PRIMARY KEY (id)
uq_payments_asaas_id UNIQUE (asaas_payment_id) Idempotência do processamento de webhook: a mesma cobrança nunca vira duas linhas.
ix_payments_subscription_time (subscription_id, created_at DESC) Histórico de cobranças da assinatura.
ix_payments_subscriber_time (subscriber_id, created_at DESC) Extrato do assinante no painel.
ix_payments_status_due (status, due_date) Localiza vencidos e a vencer para lembretes e reconciliação.
ix_payments_confirmed_at (confirmed_at DESC) WHERE confirmed_at IS NOT NULL Receita confirmada por período.
ix_payments_pix_expiring (pix_expires_at) WHERE status = 'PENDING' AND pix_expires_at IS NOT NULL Regenera QR Code vencido antes do lembrete D0.

6.13.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscription_id subscriptions(id) RESTRICT CASCADE
subscriber_id subscribers(id) RESTRICT CASCADE

6.13.3 Constraints CHECK #

ALTER TABLE payments
  ADD CONSTRAINT chk_payments_amount CHECK (amount_cents > 0),
  ADD CONSTRAINT chk_payments_refund_bounds
      CHECK (refunded_amount_cents >= 0 AND refunded_amount_cents <= amount_cents),
  ADD CONSTRAINT chk_payments_card_pair
      CHECK ((card_brand IS NULL) = (card_last4 IS NULL)),
  ADD CONSTRAINT chk_payments_card_last4 CHECK (card_last4 IS NULL OR card_last4 ~ '^[0-9]{4}$'),
  ADD CONSTRAINT chk_payments_card_only_for_card
      CHECK (billing_type = 'CREDIT_CARD' OR card_brand IS NULL),
  ADD CONSTRAINT chk_payments_pix_only_for_pix
      CHECK (billing_type = 'PIX' OR pix_payload IS NULL),
  ADD CONSTRAINT chk_payments_confirmed_status
      CHECK (status NOT IN ('CONFIRMED','RECEIVED') OR confirmed_at IS NOT NULL),
  ADD CONSTRAINT chk_payments_fee_bounds
      CHECK (fee_cents >= 0 AND fee_cents <= amount_cents),
  ADD CONSTRAINT chk_payments_net_bounds
      CHECK (net_amount_cents IS NULL
             OR (net_amount_cents >= 0 AND net_amount_cents <= amount_cents)),
  ADD CONSTRAINT chk_payments_chargeback_stage
      CHECK (chargeback_stage IS NULL
             OR chargeback_stage IN ('REQUESTED','IN_DISPUTE','LOST','WON','REVERSED')),
  ADD CONSTRAINT chk_payments_chargeback_status
      CHECK (chargeback_stage IS NULL
             OR status IN ('CHARGEBACK_REQUESTED','CHARGEBACK_DISPUTE','REFUNDED'));

Dado de cartão é limitado a bandeira e 4 últimos dígitos por decisão explícita: número completo, CVV e validade nunca transitam para persistência nem para log. A tokenização acontece no provedor.

Todos os valores monetários desta tabela são inteiros em centavos de BRL, com sufixo _cents e divisor 100. Não existe coluna value, fee_value ou paid_at aqui, e nenhuma seção pode citá-las: o valor bruto é amount_cents, a taxa é fee_cents, o líquido é net_amount_cents, e o instante do pagamento é coalesce(confirmed_at, received_at, due_date).

O caso paymentDate, resolvido em definitivo. O provedor de pagamento devolve três campos de data no mesmo evento: confirmedDate, paymentDate e clientPaymentDate. O mapeamento canônico, que a Seção 12.4.3 aplica e nenhuma outra seção altera, é:

Campo do provedor Coluna de destino Regra
confirmedDate confirmed_at Preferência absoluta quando vier preenchido.
paymentDate / clientPaymentDate confirmed_at Usados apenas quando confirmedDate vier vazio; entre os dois, vence paymentDate.
creditDate credit_date Data prevista de crédito, que não é instante de pagamento.

O nome vencedor é confirmed_at, e o instante canônico do pagamento, em toda consulta, todo relatório e todo extrato, é a expressão coalesce(confirmed_at, received_at, due_date). Não existe, e não pode ser criada, uma coluna paid_at — nem como coluna, nem como destino de mapeamento de campo externo. Onde um SELECT precisar do rótulo, ele é um apelido calculado da expressão acima, como em 6.35.6, e nunca uma coluna.

6.14 Tabela payment_events #

Recepção bruta e idempotente dos eventos de cobrança vindos do provedor. O handler HTTP apenas insere aqui e responde 200; o processamento é assíncrono.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
asaas_event_id varchar(60) não Identificador do evento no provedor. Chave de idempotência.
event_type varchar(60) não Ex.: PAYMENT_CONFIRMED.
asaas_payment_id varchar(40) sim NULL Extraído do corpo para indexação.
asaas_subscription_id varchar(40) sim NULL Idem.
payment_id char(26) sim NULL Vínculo resolvido durante o processamento.
subscription_id char(26) sim NULL Idem.
payload jsonb não Corpo completo recebido, sem alteração.
signature_valid boolean não Resultado da comparação em tempo constante do token do webhook.
processing_status WebhookProcessingStatus não 'RECEIVED' Andamento. É o único nome do desfecho do processamento nesta tabela: não existe payment_events.processing_result. Cuidado com a semelhança: webhook_deliveries tem legitimamente as duas colunas (6.26), porque lá o transporte e o negócio são registros distintos; aqui só existe o andamento.
attempts smallint não 0 Tentativas de processamento.
last_error text sim NULL Erro da última tentativa.
next_retry_at timestamptz sim NULL Próxima tentativa, com backoff exponencial.
processed_at timestamptz sim NULL Conclusão.
received_at timestamptz não now() Chegada do webhook.
created_at timestamptz não now()

6.14.1 Índices #

Índice Definição Justificativa
payment_events_pkey PRIMARY KEY (id)
uq_payment_events_asaas_event_id UNIQUE (asaas_event_id) Idempotência estrutural: reentrega do mesmo evento colide e é descartada em silêncio com resposta 200.
ix_payment_events_pending (next_retry_at) WHERE processing_status IN ('RECEIVED','PROCESSING','FAILED') Fila de processamento e de retentativa. Índice parcial mantém a árvore pequena mesmo com 150 mil linhas.
ix_payment_events_asaas_payment (asaas_payment_id) WHERE asaas_payment_id IS NOT NULL Todos os eventos de uma cobrança, em ordem, para diagnóstico.
ix_payment_events_type_time (event_type, received_at DESC) Volume por tipo de evento e detecção de fila pausada.
ix_payment_events_received_at (received_at) Expurgo após 5 anos e relatórios por período.

6.14.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
payment_id payments(id) SET NULL CASCADE
subscription_id subscriptions(id) SET NULL CASCADE

SET NULL porque o evento bruto é a prova do que o provedor mandou e vale por si, mesmo que o vínculo interno deixe de existir.

6.14.3 Constraints CHECK #

ALTER TABLE payment_events
  ADD CONSTRAINT chk_pay_events_attempts CHECK (attempts BETWEEN 0 AND 50),
  ADD CONSTRAINT chk_pay_events_processed
      CHECK (processing_status <> 'PROCESSED' OR processed_at IS NOT NULL),
  ADD CONSTRAINT chk_pay_events_payload_object
      CHECK (jsonb_typeof(payload) = 'object');

6.14.4 Como a idempotência funciona na prática #

O handler executa exatamente isto:

INSERT INTO payment_events (id, asaas_event_id, event_type, asaas_payment_id,
                            asaas_subscription_id, payload, signature_valid)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (asaas_event_id) DO NOTHING
RETURNING id;

Zero linhas em RETURNING significa reentrega. O handler responde 200 com {"data":{"received":true,"duplicate":true}} sem enfileirar nada. Isso satisfaz a exigência do provedor de resposta em menos de 2 segundos e evita processar o mesmo evento duas vezes, mesmo com dois pods concorrentes recebendo a mesma reentrega.

6.15 Tabela devotionals #

Conteúdo editorial. Exatamente um devocional por dia de calendário.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
slug varchar(90) não Identificador legível para URL do painel. Ex.: 2026-08-25-a-forca-do-silencio.
scheduled_for date não Dia de calendário (America/Sao_Paulo) em que o devocional é enviado.
title varchar(120) não Título. Vai no parâmetro {{1}} do template de convite.
bible_reference varchar(80) não Ex.: Salmos 46:10.
bible_text text não Texto do versículo.
bible_version varchar(40) não 'ALMEIDA_1911' Versão usada, de lista fechada. A sigla ARC é proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio público. Valores aceitos: ALMEIDA_1911 e BIBLIA_LIVRE.
reflection_md text não Reflexão em Markdown restrito (parágrafos, ênfase, listas). Sem HTML.
prayer text não Oração de encerramento.
teaser varchar(300) não Resumo de uma linha, de 40 a 300 caracteres. Vai no parâmetro {{2}} do template. Sem quebra de linha, sem tabulação, sem 4+ espaços seguidos.
auto_publish boolean não false Quando verdadeiro, o devocional passa sozinho de AUDIO_READY para PUBLISHED no verificador de prontidão das 05:00, sem clique do editor.
is_evergreen boolean não false Marca conteúdo sem data, elegível para ser usado como reserva quando o devocional do dia não estiver pronto.
last_used_as_fallback_at timestamptz sim NULL Última vez que este devocional foi usado como reserva. O motor escolhe sempre o evergreen usado há mais tempo, para não repetir o mesmo texto.
status DevotionalStatus não 'DRAFT' Estado editorial.
author_admin_id char(26) sim NULL Autor.
approved_by_admin_id char(26) sim NULL Quem aprovou para publicação.
approved_at timestamptz sim NULL
published_at timestamptz sim NULL Momento em que entrou em PUBLISHED.
sent_at timestamptz sim NULL Momento em que o lote de envio terminou.
cover_image_key text sim NULL Chave no storage da arte usada no vídeo de fallback.
narration_char_count integer sim NULL Caracteres do roteiro de narração. Estima custo de TTS.
estimated_audio_seconds integer sim NULL Duração estimada antes da geração.
internal_notes text sim NULL Notas internas do editor, de 0 a 1.000 caracteres. Nunca enviada ao assinante nem incluída em exportação de dados. Preenchida no formulário do editor (Seção 15.2.1), aceita pela importação em CSV (15.5.5) e entra na lista de campos versionados de devotional_revisions.changed_fields (15.6.2). Sem índice.
tags text[] não '{}' Temas. Ex.: {fe,ansiedade}.
version integer não 1 Versionamento otimista (6.1.4).
deleted_at timestamptz sim NULL Soft delete.
created_at timestamptz não now()
updated_at timestamptz não now()

6.15.1 Índices #

Índice Definição Justificativa
devotionals_pkey PRIMARY KEY (id)
uq_devotionals_scheduled_for UNIQUE (scheduled_for) WHERE deleted_at IS NULL Regra "um por dia" no banco. Duas tentativas concorrentes de agendar o mesmo dia: uma vence, a outra recebe DEVOTIONAL_DATE_TAKEN.
uq_devotionals_slug UNIQUE (slug) WHERE deleted_at IS NULL URL estável do acervo.
ix_devotionals_status_date (status, scheduled_for) Calendário editorial e busca do devocional publicável do dia.
ix_devotionals_ready_for_audio (scheduled_for) WHERE status = 'READY' AND deleted_at IS NULL Fila de geração de áudio, sem varrer a tabela.
ix_devotionals_title_trgm GIN (title gin_trgm_ops) Busca por trecho de título no painel administrativo, tolerante a erro de digitação.
ix_devotionals_tags GIN (tags) Filtro por tema no acervo.
ix_devotionals_evergreen (last_used_as_fallback_at NULLS FIRST) WHERE is_evergreen = true AND status IN ('PUBLISHED','SENT') AND deleted_at IS NULL Escolhe a reserva do dia: o evergreen publicado usado há mais tempo, em uma leitura de índice.

6.15.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
author_admin_id admin_users(id) SET NULL CASCADE
approved_by_admin_id admin_users(id) SET NULL CASCADE

SET NULL porque o conteúdo sobrevive à saída do autor. A autoria histórica fica preservada em devotional_revisions e em admin_audit_log.

6.15.3 Constraints CHECK #

ALTER TABLE devotionals
  ADD CONSTRAINT chk_dev_teaser_len CHECK (char_length(teaser) BETWEEN 40 AND 300),
  ADD CONSTRAINT chk_dev_bible_version
      CHECK (bible_version IN ('ALMEIDA_1911','BIBLIA_LIVRE')),
  ADD CONSTRAINT chk_dev_teaser_no_newline CHECK (teaser !~ '[\n\r\t]'),
  ADD CONSTRAINT chk_dev_teaser_no_runs CHECK (teaser !~ '[ ]{4,}'),
  ADD CONSTRAINT chk_dev_title_len CHECK (char_length(btrim(title)) BETWEEN 3 AND 120),
  ADD CONSTRAINT chk_dev_slug_shape CHECK (slug ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]{3,}$'),
  ADD CONSTRAINT chk_dev_reflection_len CHECK (char_length(reflection_md) BETWEEN 200 AND 6000),
  ADD CONSTRAINT chk_dev_prayer_len CHECK (char_length(prayer) BETWEEN 40 AND 1200),
  ADD CONSTRAINT chk_dev_internal_notes_len
      CHECK (internal_notes IS NULL OR char_length(internal_notes) <= 1000),
  ADD CONSTRAINT chk_dev_version CHECK (version >= 1),
  ADD CONSTRAINT chk_dev_published_fields
      CHECK (status NOT IN ('PUBLISHED','SENT') OR published_at IS NOT NULL),
  ADD CONSTRAINT chk_dev_sent_fields CHECK (status <> 'SENT' OR sent_at IS NOT NULL),
  ADD CONSTRAINT chk_dev_approved_pair
      CHECK ((approved_by_admin_id IS NULL) = (approved_at IS NULL));

Os três CHECKs de teaser existem porque o parâmetro de template do WhatsApp rejeita quebra de linha, tabulação e sequências de 4 ou mais espaços. Barrar isso no banco elimina a classe inteira de falha 132xxx em produção.

O mínimo de 40 caracteres do teaser é o mesmo exigido pelo editor (Seção 15.2.2). Os dois valores precisam coincidir: com o mínimo do banco em 20 e o do editor em 40, uma importação por rota administrativa passaria com 25 caracteres e o editor bloquearia em 39, produzindo um teaser que o produto aceita mas ninguém consegue corrigir na interface.

O tamanho da reflexão tem três camadas, e elas não se contradizem: 3.500 caracteres é o aviso do editor, o bloqueio real acontece quando o pacote montado passa de 4096 caracteres (as duas regras são da Seção 15.2.3), e os 6.000 do CHECK são o teto físico da coluna, que existe para que uma importação em massa não falhe no banco antes de a validação editorial explicar o problema. A mensagem de erro apresentada ao editor cita sempre 3.500, que é o número acionável.

6.15.4 Exemplo de linha válida #

{
  "id": "01K3F8R3T5DEVOTIONAL000001",
  "slug": "2026-08-25-a-forca-do-silencio",
  "scheduledFor": "2026-08-25",
  "title": "A força do silêncio",
  "bibleReference": "Salmos 46:10",
  "bibleText": "Aquietai-vos e sabei que eu sou Deus.",
  "bibleVersion": "ALMEIDA_1911",
  "teaser": "Deus não precisa de barulho para agir. Hoje, aprenda a ouvir no silêncio.",
  "status": "PUBLISHED",
  "tags": ["fe", "ansiedade"],
  "version": 4
}

O teaser tem 74 caracteres, acima do mínimo de 40, sem quebra de linha e sem espaços repetidos. Passa nos três CHECKs e cabe no parâmetro {{2}}.

6.16 Tabela devotional_revisions #

Snapshot completo de cada versão salva de um devocional. Permite ver quem mudou o quê e restaurar conteúdo.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
devotional_id char(26) não Devocional versionado.
revision_number integer não Sequencial por devocional, começando em 1.
changed_by_admin_id char(26) sim NULL Autor da alteração.
snapshot jsonb não Cópia integral dos campos de conteúdo após a alteração.
changed_fields text[] não '{}' Nomes dos campos que mudaram nesta revisão.
diff_summary text sim NULL Resumo legível. Ex.: título e reflexão alterados.
origin varchar(20) não 'EDIT' Origem da revisão: EDIT (salvamento no editor), IMPORT (importação em massa), RESTORE (restauração de revisão anterior), SYSTEM (alteração feita por job, como o preenchimento de contagem de caracteres).
restored_from_revision integer sim NULL Número da revisão que originou esta restauração. Preenchida apenas nas revisões criadas por POST /api/admin/devotionals/{id}/revisions/{rid}/restorations (Seção 15.6.3); nula em revisão de edição normal. Sem índice.
status_before DevotionalStatus sim NULL
status_after DevotionalStatus não
created_at timestamptz não now()

6.16.1 Índices #

Índice Definição Justificativa
devotional_revisions_pkey PRIMARY KEY (id)
uq_dev_rev_number UNIQUE (devotional_id, revision_number) Garante a sequência sem buracos e sem duplicidade sob concorrência.
ix_dev_rev_devotional_time (devotional_id, created_at DESC) Histórico exibido no painel.
ix_dev_rev_admin (changed_by_admin_id, created_at DESC) WHERE changed_by_admin_id IS NOT NULL "O que este editor alterou?"

6.16.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
devotional_id devotionals(id) CASCADE CASCADE
changed_by_admin_id admin_users(id) SET NULL CASCADE

6.16.3 Constraints CHECK #

ALTER TABLE devotional_revisions
  ADD CONSTRAINT chk_dev_rev_number CHECK (revision_number >= 1),
  ADD CONSTRAINT chk_dev_rev_snapshot CHECK (jsonb_typeof(snapshot) = 'object'),
  ADD CONSTRAINT chk_dev_rev_origin
      CHECK (origin IN ('EDIT','IMPORT','RESTORE','SYSTEM')),
  ADD CONSTRAINT chk_dev_rev_restored_from
      CHECK (restored_from_revision IS NULL OR restored_from_revision > 0),
  ADD CONSTRAINT chk_dev_rev_restore_pair
      CHECK ((restored_from_revision IS NULL) OR origin = 'RESTORE');

revision_number é calculado dentro da mesma transação do UPDATE otimista do devocional, com SELECT coalesce(max(revision_number),0)+1 ... FOR UPDATE na linha do devocional. Como o UPDATE já toma o lock da linha pai, não há corrida.

6.17 Tabela audio_assets #

Arquivos de áudio gerados por devocional. Um devocional tem tipicamente duas linhas: OGG para o WhatsApp e MP3 para o player web. O MP4 do fallback de vídeo é uma terceira linha quando gerado.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
devotional_id char(26) não Devocional narrado.
format AudioFormat não OGG_OPUS, MP3 ou MP4.
provider AudioProvider não Provedor de TTS efetivamente usado.
voice_id varchar(60) não Identificador da voz no provedor.
model varchar(60) sim NULL Modelo de síntese usado.
status AudioAssetStatus não 'PENDING' Andamento da geração.
storage_bucket varchar(80) sim NULL Bucket S3-compatível.
storage_key text sim NULL Chave do objeto. Padrão devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.{ext}.
byte_size bigint sim NULL Tamanho em bytes.
duration_seconds integer sim NULL Duração medida após a transcodificação.
sample_rate integer sim NULL Ex.: 48000.
bitrate_kbps smallint sim NULL Ex.: 32.
checksum_sha256 char(64) sim NULL Integridade do objeto.
narration_script_hash char(64) sim NULL sha256 do roteiro. Se o texto não mudou, não regera áudio. É o único nome desta coluna em todo o documento: não existe script_hash.
chunk_count smallint não 1 Número de partes em que o roteiro foi dividido para a chamada ao provedor. 1 quando coube em uma chamada única. Alimenta a conferência de custo por geração (Seção 16.4).
generation_attempts smallint não 0 Tentativas de geração.
qc_failed_at timestamptz sim NULL Instante da reprovação nas verificações de qualidade. Preenchida junto com status = 'FAILED' quando a causa foi verificação de qualidade, e nula quando a causa foi falha do provedor — a distinção é o que decide se o job tenta de novo (Seção 16.13) ou se para e chama o operador (Seção 27.4.4).
last_error text sim NULL Erro da última tentativa.
char_count integer não 0 Caracteres efetivamente enviados ao provedor de TTS nesta geração. É a base da conta de custo de síntese e da apuração de consumo da cota de voz.
cost_micros bigint sim NULL Custo em micros de BRL (10⁻⁶ BRL). Divisor para BRL: 1.000.000. Nunca some com colunas em centavos sem converter.
generated_at timestamptz sim NULL Conclusão.
created_at timestamptz não now()
updated_at timestamptz não now()

6.17.1 Índices #

Índice Definição Justificativa
audio_assets_pkey PRIMARY KEY (id)
uq_audio_dev_format_voice UNIQUE (devotional_id, format, voice_id) Impede geração duplicada do mesmo formato e voz para o mesmo devocional, inclusive sob retentativa concorrente.
ix_audio_devotional (devotional_id, status) Verifica se o devocional já tem áudio pronto antes de publicar.
ix_audio_status_pending (created_at) WHERE status IN ('PENDING','PROCESSING') Fila de geração e detecção de trabalho travado.
ix_audio_script_hash (narration_script_hash) WHERE narration_script_hash IS NOT NULL Reaproveita áudio quando o roteiro não mudou.
uq_audio_storage_key UNIQUE (storage_key) WHERE storage_key IS NOT NULL Impede duas linhas apontando para o mesmo objeto.

6.17.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
devotional_id devotionals(id) CASCADE CASCADE

CASCADE aqui é seguro: o hard delete de um devocional só ocorre no expurgo, e o áudio não tem valor fora dele. O objeto no storage é removido por job de limpeza que confere órfãos (6.33.6).

6.17.3 Constraints CHECK #

ALTER TABLE audio_assets
  ADD CONSTRAINT chk_audio_ready_fields
      CHECK (status <> 'READY' OR (storage_key IS NOT NULL AND byte_size IS NOT NULL
                                   AND duration_seconds IS NOT NULL)),
  ADD CONSTRAINT chk_audio_size CHECK (byte_size IS NULL OR byte_size BETWEEN 1 AND 16777216),
  ADD CONSTRAINT chk_audio_duration CHECK (duration_seconds IS NULL OR duration_seconds BETWEEN 1 AND 480),
  ADD CONSTRAINT chk_audio_attempts CHECK (generation_attempts BETWEEN 0 AND 20),
  ADD CONSTRAINT chk_audio_checksum CHECK (checksum_sha256 IS NULL OR checksum_sha256 ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_audio_char_count CHECK (char_count >= 0),
  ADD CONSTRAINT chk_audio_chunk_count CHECK (chunk_count >= 1),
  ADD CONSTRAINT chk_audio_qc_failed_needs_failed
      CHECK (qc_failed_at IS NULL OR status = 'FAILED'),
  ADD CONSTRAINT chk_audio_cost CHECK (cost_micros IS NULL OR cost_micros >= 0);

Os dois tetos são os da plataforma de mensageria, e o banco os aplica para transformar um erro remoto de envio em um erro local de geração, detectado antes do disparo: 16.777.216 bytes (16 MB) e 480 segundos (8 minutos). Áudio substituído manualmente por um administrador segue exatamente as mesmas regras do áudio gerado — não existe caminho de upload com limite maior, porque o destino é o mesmo e o WhatsApp recusaria do mesmo jeito.

Falha de geração não cria estado de devocional. Quando o provedor falha, a linha de audio_assets fica em status = 'FAILED' e o devocional permanece em AUDIO_PENDING; a guarda de publicação impede publicar nesse estado e o motor de envio trata o devocional como não pronto, acionando a reserva. Não existe, e não pode ser criado, um estado AUDIO_FAILED em DevotionalStatus.

6.18 Tabela whatsapp_templates #

Registro local dos templates submetidos à Meta, com estado de aprovação e categoria efetiva. Sem esta tabela, o sistema não sabe se pode disparar.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
name varchar(80) não Nome do template na Meta. Ex.: devocional_diario_v1.
language varchar(10) não 'pt_BR' Código de idioma no formato da Meta.
category TemplateCategory não Categoria submetida.
effective_category TemplateCategory sim NULL Categoria que a Meta efetivamente aplicou. Pode divergir da submetida.
status TemplateStatus não 'DRAFT' Estado na Meta.
meta_template_id varchar(40) sim NULL Identificador do template na Meta.
components jsonb não Estrutura completa de componentes, como enviada e como devolvida.
body_text text não Corpo com marcadores {{n}}, para conferência local.
param_count smallint não 0 Quantidade de parâmetros do corpo.
header_type varchar(20) sim NULL TEXT, IMAGE, VIDEO, DOCUMENT ou nulo. Nunca AUDIO — a Meta não aceita.
quality_score varchar(20) sim NULL GREEN, YELLOW, RED ou UNKNOWN.
rejected_reason text sim NULL Motivo da rejeição.
version integer não 1 Versionamento otimista.
is_current boolean não false Marca a linha vigente para uso em produção.
submitted_at timestamptz sim NULL
approved_at timestamptz sim NULL
last_synced_at timestamptz sim NULL Última sincronização com a Meta.
created_at timestamptz não now()
updated_at timestamptz não now()

6.18.1 Índices #

Índice Definição Justificativa
whatsapp_templates_pkey PRIMARY KEY (id)
uq_wa_templates_name_lang UNIQUE (name, language) A Meta trata o par nome+idioma como identidade.
uq_wa_templates_meta_id UNIQUE (meta_template_id) WHERE meta_template_id IS NOT NULL Resolve o webhook de atualização de template.
ix_wa_templates_current (name) WHERE is_current = true Busca o template vigente por nome no caminho quente de envio.
ix_wa_templates_status (status) Alerta de template pausado ou rejeitado.

6.18.2 Constraints CHECK #

ALTER TABLE whatsapp_templates
  ADD CONSTRAINT chk_wa_tpl_name_shape CHECK (name ~ '^[a-z0-9_]{1,512}$'),
  ADD CONSTRAINT chk_wa_tpl_header_not_audio
      CHECK (header_type IS NULL OR header_type IN ('TEXT','IMAGE','VIDEO','DOCUMENT','LOCATION')),
  ADD CONSTRAINT chk_wa_tpl_body_len CHECK (char_length(body_text) <= 1024),
  ADD CONSTRAINT chk_wa_tpl_param_count CHECK (param_count BETWEEN 0 AND 10),
  ADD CONSTRAINT chk_wa_tpl_approved_fields
      CHECK (status <> 'APPROVED' OR (meta_template_id IS NOT NULL AND approved_at IS NOT NULL)),
  ADD CONSTRAINT chk_wa_tpl_current_requires_approved
      CHECK (is_current = false OR status = 'APPROVED'),
  ADD CONSTRAINT chk_wa_tpl_quality
      CHECK (quality_score IS NULL OR quality_score IN ('GREEN','YELLOW','RED','UNKNOWN')),
  ADD CONSTRAINT chk_wa_tpl_components CHECK (jsonb_typeof(components) = 'object');

chk_wa_tpl_header_not_audio e chk_wa_tpl_body_len codificam duas restrições reais da plataforma: cabeçalho de template não aceita áudio, e o corpo tem teto de 1024 caracteres. Ambas são a razão do desenho de entrega em duas etapas descrito na Seção 18.

Regra adicional garantida na aplicação: no máximo uma linha com is_current = true por name. Não é índice único porque a troca de versão faz false na antiga e true na nova dentro da mesma transação, e um índice único parcial sobre (name) WHERE is_current bloquearia a ordem inversa. A transação sempre desmarca antes de marcar, e o índice ix_wa_templates_current torna a verificação instantânea.

6.19 Tabela message_logs #

Registro de toda mensagem trocada com o WhatsApp, nas duas direções. É a maior tabela do sistema e a única particionada.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
created_at timestamptz não now() Chave de partição. Faz parte da PK.
subscriber_id char(26) sim NULL Assinante. Nulo quando o número não foi resolvido.
direction MessageDirection não OUTBOUND ou INBOUND.
kind MessageKind não Tipo da mensagem.
status MessageStatus não 'QUEUED' Estado de entrega.
devotional_id char(26) sim NULL Devocional relacionado.
delivery_attempt_id char(26) sim NULL Tentativa de entrega que gerou a mensagem.
template_id char(26) sim NULL Template usado, quando kind = 'TEMPLATE'.
template_name varchar(80) sim NULL Nome do template na Meta, denormalizado para relatório. É o único nome desta coluna: não existe message_logs.template_key. Não confundir com message_key, logo abaixo, que é outra coisa — aquela identifica a mensagem do catálogo editorial da Seção 19, esta identifica o template aprovado pela Meta.
wamid varchar(128) sim NULL Identificador da mensagem na Meta.
message_key varchar(60) sim NULL Chave canônica da mensagem de catálogo que originou o envio. Ex.: optout.confirmed, pause.started, welcome.first. É por ela que se mede quais mensagens do catálogo da Seção 19 são realmente disparadas.
phone_e164 text sim NULL Destino ou origem em E.164, como envelope cifrado (6.1.8). Anulável por exigência da LGPD: a anonimização do titular zera esta coluna, e uma coluna NOT NULL tornaria a eliminação impossível na maior tabela do sistema.
phone_hmac char(64) sim NULL HMAC do destino. Permite reconstruir o histórico de um número em investigação sem decifrar nada, e é zerado junto na anonimização.
wa_id text sim NULL Identificador da Meta correspondente, cifrado. Zerado na anonimização.
media_id varchar(80) sim NULL Identificador de mídia da Meta usado no envio.
payload_excerpt varchar(240) sim NULL Primeiros 240 caracteres do conteúdo, para diagnóstico. Nunca o corpo inteiro. Zerado na anonimização: pode conter o nome do titular em parâmetro de template e texto que ele mesmo digitou.
error_code varchar(20) sim NULL Código de erro da Meta. Ex.: 131047.
error_title varchar(160) sim NULL Título do erro.
error_details text sim NULL Detalhe do erro.
conversation_id varchar(80) sim NULL Identificador da conversa faturável.
conversation_category varchar(20) sim NULL UTILITY, MARKETING, AUTHENTICATION, SERVICE. É o único nome desta coluna: billed_category e pricing_category não existem.
pricing_model varchar(20) sim NULL Modelo de cobrança informado pela Meta.
is_billable boolean não false Se a mensagem gerou custo. Nome único; não existe billable.
cost_micros bigint sim NULL Custo estimado em micros de BRL. Divisor para BRL: 1.000.000. Não existe cost_cents nesta tabela, e somar esta coluna como se fosse centavos erra o resultado por um fator de 10.000.
sent_at timestamptz sim NULL Instante em que a Meta aceitou a mensagem. É o carimbo de aceite; não existe accepted_at.
delivered_at timestamptz sim NULL
read_at timestamptz sim NULL
failed_at timestamptz sim NULL
late_open_days smallint sim NULL Distância em dias entre a data do devocional entregue e o dia em que o assinante tocou no botão. Nula quando a abertura foi no mesmo dia, que é o caso comum. Alimenta a métrica de abertura tardia da Seção 21 e é a coluna que sustenta o caso de borda 2 da Seção 17.14. Sem índice: a métrica é apurada no rollup diário, não em consulta interativa.
request_id char(26) sim NULL ULID de correlação com o log da aplicação.

Sem updated_at: a linha muda apenas por progressão de status, e cada progressão tem timestamp próprio, que é informação mais útil.

6.19.1 Particionamento #

CREATE TABLE message_logs (
  ...
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

Uma partição por mês, nomeada message_logs_YYYY_MM. Detalhes de criação automática, retenção e destacamento estão em 6.33.3.

A chave primária é composta porque o Postgres exige que a chave de partição faça parte de qualquer índice único da tabela particionada. Consequências, todas assumidas:

  1. Nenhuma tabela declara FK para message_logs. Referências são lógicas (otp_codes.delivery_message_log_id, delivery_attempts.message_log_id).
  2. Índices únicos precisam incluir created_at. Ver uq_message_logs_wamid abaixo.
  3. Buscar por id sem created_at faz varredura em todas as partições. Por isso todo acesso por identificador na aplicação carrega também a data, que é extraível do próprio ULID.

6.19.2 Índices #

Todos criados em cada partição, herdados da definição na tabela pai.

Índice Definição Justificativa
message_logs_pkey PRIMARY KEY (id, created_at) Exigência do particionamento.
uq_message_logs_wamid UNIQUE (wamid, created_at) WHERE wamid IS NOT NULL Idempotência de status: a Meta reentrega webhooks de status. A unicidade impede duplicar a mensagem. Inclui created_at por exigência do particionamento.
ix_message_logs_subscriber_time (subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL Histórico de mensagens do assinante no suporte.
ix_message_logs_attempt (delivery_attempt_id) WHERE delivery_attempt_id IS NOT NULL Liga a mensagem à tentativa de entrega.
ix_message_logs_status_time (status, created_at DESC) Taxa de entrega diária e detecção de pico de falhas.
ix_message_logs_error_code (error_code, created_at DESC) WHERE error_code IS NOT NULL Agrupa falhas por código da Meta para acionar o runbook correspondente.
ix_message_logs_devotional (devotional_id, created_at DESC) WHERE devotional_id IS NOT NULL Relatório por devocional.
ix_message_logs_conversation (conversation_id) WHERE conversation_id IS NOT NULL Consolidação de custo por conversa.
ix_message_logs_phone_hmac (phone_hmac, created_at DESC) WHERE phone_hmac IS NOT NULL Reconstrói o histórico de um número em investigação, sem decifrar e sem varrer partição.
ix_message_logs_message_key (message_key, created_at DESC) WHERE message_key IS NOT NULL Volume por mensagem do catálogo, para saber quais textos realmente saem.

6.19.3 Constraints CHECK #

ALTER TABLE message_logs
  ADD CONSTRAINT chk_msg_phone_envelope
      CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_msg_phone_hmac
      CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_msg_wa_id_envelope
      CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_msg_message_key_shape
      CHECK (message_key IS NULL OR message_key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
  ADD CONSTRAINT chk_msg_template_needs_name
      CHECK (kind <> 'TEMPLATE' OR direction = 'INBOUND' OR template_name IS NOT NULL),
  ADD CONSTRAINT chk_msg_failed_needs_code
      CHECK (status <> 'FAILED' OR error_code IS NOT NULL),
  ADD CONSTRAINT chk_msg_status_timeline
      CHECK ((delivered_at IS NULL OR sent_at IS NULL OR delivered_at >= sent_at)
         AND (read_at IS NULL OR delivered_at IS NULL OR read_at >= delivered_at)),
  ADD CONSTRAINT chk_msg_cost CHECK (cost_micros IS NULL OR cost_micros >= 0),
  ADD CONSTRAINT chk_msg_late_open_days
      CHECK (late_open_days IS NULL OR late_open_days > 0),
  ADD CONSTRAINT chk_msg_excerpt_len CHECK (payload_excerpt IS NULL OR char_length(payload_excerpt) <= 240);

Não há CHECK de formato E.164 aqui pelo mesmo motivo de 6.3.3: a coluna guarda um envelope cifrado. E phone_e164 é anulável nesta tabela, ao contrário do que a intuição sugere para um registro de transporte. O motivo é jurídico e concreto: a retenção de message_logs é de 18 meses, muito mais longa do que o prazo de eliminação de 30 dias do titular. Se a coluna fosse NOT NULL, a anonimização por pedido de eliminação não conseguiria zerá-la, e centenas de linhas com o telefone completo, o horário de leitura de cada devocional e trechos do conteúdo religioso sobreviveriam por mais de um ano depois de o painel ter confirmado "conta anonimizada". A eliminação precisa alcançar todas as tabelas que guardam identificador pessoal, e esta é a maior delas.

6.19.4 Ausência de chaves estrangeiras #

message_logs não declara nenhuma FK de saída, mesmo tendo subscriber_id e devotional_id. Três motivos:

  1. Cada INSERT numa tabela com FK faz uma verificação na tabela referenciada. No pico de 05:40 são até 12.000 inserções em poucos minutos. Eliminar duas verificações por linha é ganho real.
  2. Destacar (DETACH) e arquivar partições antigas é mais simples sem FKs de saída.
  3. A integridade referencial aqui não é crítica: um log apontando para um assinante anonimizado continua sendo um log válido.

A consistência é garantida pela aplicação, que só grava identificadores que acabou de ler. Um job semanal de conferência conta órfãos e alerta se passar de 0,1% das linhas do mês.

6.20 Tabela delivery_attempts #

Unidade de trabalho do motor de envio diário. Cada linha é uma etapa de entrega para um assinante em uma data. É aqui que mora a idempotência do envio.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
idempotency_key varchar(120) não send:{subscriberId}:{devotionalDate}:{step}.
send_batch_id char(26) sim NULL Lote de planejamento.
subscriber_id char(26) não Destinatário.
devotional_id char(26) não Conteúdo enviado.
devotional_date date não Data do devocional, denormalizada para a chave de idempotência e para os índices.
step DeliveryStep não Etapa: convite por template, texto completo, áudio, fechamento ou fallback de vídeo.
status DeliveryAttemptStatus não 'PLANNED' Estado da tentativa.
tier_at_send SubscriberTier não Tier registrado no planejamento das 05:40. É registro histórico, nunca autoridade (ver 6.20.3).
tier_at_send_effective SubscriberTier sim NULL Tier relido por chave primária imediatamente antes da chamada ao provedor. É este valor que decide o que sai. Nulo enquanto o item não foi disparado.
downgraded_at timestamptz sim NULL Preenchido quando a revalidação no disparo encontrou tier = 'FREE' em item planejado como PAID e rebaixou o pacote na hora.
origin varchar(20) não 'DAILY_BATCH' Origem da tentativa: DAILY_BATCH, MANUAL_RESEND, WELCOME_BACKFILL, ADMIN_RETRY.
steps_completed smallint não 0 Quantas etapas do pacote deste assinante já concluíram com sucesso no dia. Permite retomar de onde parou sem reenviar o que já saiu.
audio_status varchar(20) não 'NOT_APPLICABLE' Estado do áudio para esta tentativa: NOT_APPLICABLE, PENDING, SENT, SKIPPED_DOWNGRADE, FAILED.
manual_resend_count smallint não 0 Reenvios manuais já feitos para este par assinante/dia. Limitado pela regra de reenvio da Seção 14.
attempt_count smallint não 0 Tentativas executadas.
max_attempts smallint não 5 Teto de tentativas.
next_retry_at timestamptz sim NULL Próxima tentativa. É o único nome desta coluna em todo o documento: não existe next_attempt_at.
locked_at timestamptz sim NULL Instante em que um worker reivindicou o item em claimAttempt (Seção 18.9). Um item em IN_FLIGHT com locked_at mais velho que 60 segundos é considerado abandonado e recolhido pela varredura de reparo das 09:00 (Seção 18.15). Limpo no término, com sucesso ou falha. Sem esta coluna a varredura de reparo não tem como ser escrita, e ela é o mecanismo que impede envio duplicado depois de um reinício do Redis.
last_error_code varchar(20) sim NULL Código de erro da Meta na última falha.
last_error_message text sim NULL Mensagem correspondente.
deferred_reason varchar(60) sim NULL outside_window, rate_limited, ecosystem_throttle, quality_hold.
message_log_id char(26) sim NULL Mensagem gerada. Referência lógica (ver 6.19.4).
planned_at timestamptz não now() Momento do planejamento.
started_at timestamptz sim NULL Início da execução.
completed_at timestamptz sim NULL Conclusão, com sucesso ou falha definitiva.
created_at timestamptz não now()
updated_at timestamptz não now()

6.20.1 Índices #

Índice Definição Justificativa
delivery_attempts_pkey PRIMARY KEY (id)
uq_delivery_idempotency_key UNIQUE (idempotency_key) Idempotência do envio. Dois planejamentos concorrentes do mesmo dia produzem colisão, não duplicidade.
uq_delivery_sub_date_step UNIQUE (subscriber_id, devotional_date, step) Mesma garantia expressa em colunas, útil para consultas e para o ON CONFLICT do planejador.
ix_delivery_pending (next_retry_at, id) WHERE status IN ('PLANNED','DEFERRED','FAILED') AND attempt_count < max_attempts Fila de execução. Índice parcial mantém a árvore com milhares de linhas mesmo com milhões na tabela.
ix_delivery_batch_status (send_batch_id, status) WHERE send_batch_id IS NOT NULL Contadores de progresso do lote.
ix_delivery_date_status (devotional_date, status) Relatório diário de entrega.
ix_delivery_subscriber_date (subscriber_id, devotional_date DESC) "O que este assinante recebeu?" no suporte.
ix_delivery_error_code (last_error_code, devotional_date) WHERE last_error_code IS NOT NULL Agrupa falhas por causa.
ix_delivery_locked (locked_at) WHERE status = 'IN_FLIGHT' Varredura de reparo das 09:00: localiza itens reivindicados e abandonados sem varrer 4 milhões de linhas.

6.20.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) CASCADE CASCADE
devotional_id devotionals(id) RESTRICT CASCADE
send_batch_id send_batches(id) SET NULL CASCADE

RESTRICT em devotional_id impede apagar de fato um devocional que já foi enviado. O soft delete continua permitido e é o caminho normal.

6.20.3 Constraints CHECK #

ALTER TABLE delivery_attempts
  ADD CONSTRAINT chk_delivery_idem_shape
      CHECK (idempotency_key ~ '^send:[0-9A-HJKMNP-TV-Z]{26}:[0-9]{4}-[0-9]{2}-[0-9]{2}:[A-Z_]{3,20}$'),
  ADD CONSTRAINT chk_delivery_attempts_bounds
      CHECK (attempt_count >= 0 AND max_attempts BETWEEN 1 AND 10 AND attempt_count <= max_attempts),
  ADD CONSTRAINT chk_delivery_completed
      CHECK (status NOT IN ('SUCCEEDED','FAILED','SKIPPED',
                            'SKIPPED_OPTED_OUT','SKIPPED_INELIGIBLE')
             OR completed_at IS NOT NULL),
  ADD CONSTRAINT chk_delivery_deferred_reason
      CHECK (status <> 'DEFERRED' OR deferred_reason IS NOT NULL),
  ADD CONSTRAINT chk_delivery_audio_step_paid
      CHECK (step <> 'AUDIO' OR tier_at_send = 'PAID'),
  ADD CONSTRAINT chk_delivery_audio_step_paid_effective
      CHECK (step <> 'AUDIO'
             OR status <> 'SUCCEEDED'
             OR tier_at_send_effective = 'PAID'),
  ADD CONSTRAINT chk_delivery_downgrade_pair
      CHECK (downgraded_at IS NULL
             OR (tier_at_send = 'PAID' AND tier_at_send_effective = 'FREE')),
  ADD CONSTRAINT chk_delivery_origin
      CHECK (origin IN ('DAILY_BATCH','MANUAL_RESEND','WELCOME_BACKFILL','ADMIN_RETRY')),
  ADD CONSTRAINT chk_delivery_audio_status
      CHECK (audio_status IN ('NOT_APPLICABLE','PENDING','SENT',
                              'SKIPPED_DOWNGRADE','FAILED')),
  ADD CONSTRAINT chk_delivery_steps_completed
      CHECK (steps_completed BETWEEN 0 AND 5),
  ADD CONSTRAINT chk_delivery_manual_resends
      CHECK (manual_resend_count BETWEEN 0 AND 10),
  ADD CONSTRAINT chk_delivery_timeline
      CHECK (started_at IS NULL OR started_at >= planned_at),
  ADD CONSTRAINT chk_delivery_locked_only_in_flight
      CHECK (locked_at IS NULL OR status = 'IN_FLIGHT');

Os dois CHECKs de áudio codificam no banco a regra de entitlement mais importante do produto — etapa de áudio só existe para tier pago — e o fazem nos dois momentos em que ela pode ser violada:

  • chk_delivery_audio_step_paid protege o planejamento. Se um bug tentar planejar áudio para um assinante FREE às 05:40, a transação falha em vez de enviar.
  • chk_delivery_audio_step_paid_effective protege o disparo, e é o que faltava. O tier é reavaliado no instante do envio de cada mensagem, nunca no planejamento do lote: um assinante que perdeu o acesso pago entre 05:40 e 06:00 não recebe áudio. Sem esse segundo CHECK, o congelamento das 05:40 concederia, na prática, um dia de carência a todo inadimplente — exatamente a carência que a regra do produto proíbe. A revalidação é uma leitura por chave primária, abaixo de 1 ms, sobre um lote de no máximo 12.400 itens, e o procedimento completo (o que é relido, em que ordem, e o que acontece com opt_out_at, blocked_at e deleted_at) está na Seção 18.5.

chk_delivery_downgrade_pair garante que downgraded_at só exista no caso que ele descreve: planejado PAID, disparado FREE. Assim o relatório de revogações no meio do lote é uma contagem exata, não uma heurística.

6.20.4 O planejamento idempotente, na prática #

INSERT INTO delivery_attempts
  (id, idempotency_key, send_batch_id, subscriber_id, devotional_id,
   devotional_date, step, status, tier_at_send, planned_at)
SELECT
  $1, 'send:' || s.id || ':' || $2::text || ':' || $3,
  $4, s.id, $5, $2::date, $3::"DeliveryStep", 'PLANNED', s.tier, now()
FROM subscribers s
WHERE s.id = $6
ON CONFLICT (idempotency_key) DO NOTHING;

Se o job de planejamento das 05:40 rodar duas vezes (falha de rede, reinício do worker, operador executando manualmente), a segunda execução insere zero linhas. Nenhum assinante recebe o devocional duas vezes. Essa é a única garantia que impede o pior incidente possível deste produto.

6.21 Tabela inbound_messages #

Mensagens recebidas dos assinantes, com a intenção já classificada. Alimenta a abertura da janela de atendimento, o opt-out por palavra-chave e o atalho de leitura imediata.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
wamid varchar(128) não Identificador da mensagem na Meta. É o único nome desta coluna; não existe wa_message_id.
subscriber_id char(26) sim NULL Assinante resolvido. Nulo quando o número é desconhecido.
wa_id text não Identificador da Meta do remetente, como envelope cifrado (6.1.8).
wa_id_hmac char(64) não HMAC do wa_id. É por esta coluna que o webhook de entrada resolve o assinante, em uma única leitura.
phone_e164 text sim NULL Normalização em E.164, quando possível, cifrada. É o nome desta coluna em todo o documento; não existe inbound_messages.from. Zerada na anonimização.
phone_hmac char(64) sim NULL HMAC do telefone normalizado. Zerado na anonimização.
kind MessageKind não Tipo recebido.
text_body text sim NULL Texto, truncado em 2000 caracteres. É o nome desta coluna; não existe inbound_messages.body. Zerado na anonimização, porque é conteúdo escrito pelo próprio titular.
support_protocol varchar(20) sim NULL Número de protocolo gerado quando a mensagem abre atendimento humano. Ex.: PD-2026-000431. É o que o assinante cita ao voltar a falar com o suporte.
button_payload varchar(60) sim NULL Payload do botão. Ex.: OPEN_DEVOTIONAL.
interactive_id varchar(60) sim NULL Identificador da opção interativa.
media_id varchar(80) sim NULL Mídia recebida. Não é baixada no MVP.
intent InboundIntent não 'UNKNOWN' Intenção classificada por regra determinística.
intent_confidence smallint não 100 0–100. Sempre 100 no MVP, porque a classificação é por regra, não por modelo.
raw jsonb não Objeto de mensagem original.
is_handled boolean não false Se já foi processado.
handled_at timestamptz sim NULL
handling_error text sim NULL
received_at timestamptz não now() Timestamp informado pela Meta.
created_at timestamptz não now() Chegada no nosso lado.

6.21.1 Índices #

Índice Definição Justificativa
inbound_messages_pkey PRIMARY KEY (id)
uq_inbound_wamid UNIQUE (wamid) Idempotência de entrada. A Meta reentrega webhooks; a mesma mensagem nunca abre a janela duas vezes nem dispara dois opt-outs.
ix_inbound_unhandled (created_at) WHERE is_handled = false Fila de processamento.
ix_inbound_subscriber_time (subscriber_id, received_at DESC) WHERE subscriber_id IS NOT NULL Conversa do assinante no suporte.
ix_inbound_wa_id_time (wa_id_hmac, received_at DESC) Resolve remetente ainda não vinculado, sem decifrar.
ix_inbound_intent_time (intent, received_at DESC) Volume de opt-out e de aberturas por dia.
ix_inbound_support_protocol (support_protocol) WHERE support_protocol IS NOT NULL Busca por protocolo no atendimento.

6.21.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
subscriber_id subscribers(id) SET NULL CASCADE

SET NULL porque a mensagem recebida é fato histórico do canal e vale mesmo sem assinante vinculado.

6.21.3 Constraints CHECK #

ALTER TABLE inbound_messages
  ADD CONSTRAINT chk_inbound_text_len CHECK (text_body IS NULL OR char_length(text_body) <= 2000),
  ADD CONSTRAINT chk_inbound_confidence CHECK (intent_confidence BETWEEN 0 AND 100),
  ADD CONSTRAINT chk_inbound_handled_pair CHECK ((is_handled = false) OR (handled_at IS NOT NULL)),
  ADD CONSTRAINT chk_inbound_raw_object CHECK (jsonb_typeof(raw) = 'object'),
  ADD CONSTRAINT chk_inbound_wa_id_envelope CHECK (wa_id ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_inbound_wa_id_hmac CHECK (wa_id_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_inbound_phone_envelope
      CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_inbound_phone_hmac
      CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_inbound_support_protocol
      CHECK (support_protocol IS NULL OR support_protocol ~ '^PD-[0-9]{4}-[0-9]{6}$');

O raw da Meta é guardado como recebido, e é a única cópia bruta do que o assinante escreveu. Ele é redigido no mesmo passo da anonimização: o campo de texto dentro do JSON é substituído por null, preservando a estrutura para diagnóstico de protocolo e removendo o conteúdo pessoal.

6.22 Tabela send_batches #

Um lote por dia de envio. Agrega contadores, marca início e fim, e garante que o planejamento do dia aconteça uma única vez.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
idempotency_key varchar(80) não plan:{devotionalDate} no lote diário; retry:{parentBatchId} no reprocessamento. Duas formas, e apenas duas.
devotional_id char(26) não Devocional do dia.
devotional_date date não Dia de calendário.
status SendBatchStatus não 'PLANNING' Estado do lote. HALTED_BY_GUARD é o lote que a contenção automática parou sozinha por taxa de falha acima do limiar; a retomada é sempre humana.
batch_kind varchar(20) não 'DAILY' Natureza do lote: DAILY (envio diário regular), BACKFILL (retroativo de boas-vindas), RESEND (reenvio em massa autorizado), RETRY (reprocessamento de um lote anterior), TEST (lote de homologação). Esta coluna é criada aqui, na Seção 6, e é a única fonte do conceito: não existe send_batches.kind, e a seção que descreve o reprocessamento (15.8.4) usa este nome.
parent_batch_id char(26) sim NULL Lote de origem, quando este lote é um reprocessamento (batch_kind = 'RETRY'). Existe para que as métricas do lote original fiquem intactas: o reprocessamento não soma nos contadores de quem falhou (Seção 15.8.4).
canceled_by char(26) sim NULL Administrador que cancelou o lote em andamento (Seção 15.8.5).
canceled_reason text sim NULL Motivo do cancelamento, de no mínimo 10 caracteres e obrigatório na rota. Cancelar um envio diário é decisão com consequência para milhares de pessoas, e a justificativa é o que permite auditá-la depois.
tier_limit_at_plan integer sim NULL Teto de mensagens da conta na plataforma no momento do planejamento do lote (Seção 18.3.3). Congelado aqui porque a Meta muda o teto sem aviso, e a análise posterior de um lote truncado precisa do valor vigente naquela hora, não do atual.
template_used varchar(80) sim NULL Template predominante do lote.
truncated_count integer não 0 Itens cujo texto precisou ser truncado para caber no limite da mensagem. Acima de zero, é sinal editorial: o devocional do dia está longo demais.
fallback_used boolean não false Se o lote usou o devocional de reserva em vez do agendado.
fallback_reason varchar(40) sim NULL Por que a reserva foi usada: no_devotional, audio_missing, audio_failed, template_not_approved, media_expired.
halted_error_code varchar(20) sim NULL Código de erro dominante que motivou a parada automática, quando status = 'HALTED_BY_GUARD'.
planned_count integer não 0 Tentativas planejadas.
queued_count integer não 0 Tentativas enfileiradas.
sent_count integer não 0 Mensagens aceitas pela Meta.
delivered_count integer não 0 Confirmadas como entregues.
read_count integer não 0 Confirmadas como lidas.
failed_count integer não 0 Falhas definitivas.
skipped_count integer não 0 Pulados por regra (sem opt-in, opt-out, bloqueado).
deferred_count integer não 0 Adiados para o dia seguinte.
free_count integer não 0 Alvos com tier FREE.
paid_count integer não 0 Alvos com tier PAID.
started_at timestamptz sim NULL
finished_at timestamptz sim NULL
duration_ms integer sim NULL Duração total. Comparado à meta de 20 minutos.
planned_by varchar(40) não 'scheduler' scheduler, admin, ops_cli.
error text sim NULL Erro fatal do lote.
created_at timestamptz não now()
updated_at timestamptz não now()

6.22.1 Índices #

Índice Definição Justificativa
send_batches_pkey PRIMARY KEY (id)
uq_send_batches_idempotency UNIQUE (idempotency_key) Um planejamento por dia. Reexecução do job das 05:40 colide e não replaneja. Vale também para o reprocessamento: dois cliques no botão de reprocessar o mesmo lote produzem a mesma chave retry:{parentBatchId} e o segundo colide.
uq_send_batches_date UNIQUE (devotional_date) WHERE batch_kind = 'DAILY' Mesma garantia, legível em consultas. Parcial por batch_kind: a regra é "um lote diário por dia de calendário", e um reprocessamento carrega a devotional_date do lote original de propósito, para que o relatório do dia continue somando tudo o que foi tentado naquele dia.
ix_send_batches_status (status, devotional_date DESC) Painel operacional e detecção de lote travado em RUNNING.
ix_send_batches_parent (parent_batch_id) WHERE parent_batch_id IS NOT NULL Encontra todos os reprocessamentos de um lote original. Parcial porque quase nenhum lote é reprocessado.

6.22.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
devotional_id devotionals(id) RESTRICT CASCADE
parent_batch_id send_batches(id) SET NULL CASCADE
canceled_by admin_users(id) SET NULL CASCADE

6.22.3 Constraints CHECK #

ALTER TABLE send_batches
  ADD CONSTRAINT chk_batch_idem_shape
      CHECK (idempotency_key ~ '^plan:[0-9]{4}-[0-9]{2}-[0-9]{2}$'
          OR idempotency_key ~ '^retry:[0-9A-HJKMNP-TV-Z]{26}$'),
  ADD CONSTRAINT chk_batch_idem_shape_matches_kind
      CHECK ((batch_kind = 'RETRY') = (idempotency_key LIKE 'retry:%')),
  ADD CONSTRAINT chk_batch_counts_nonneg
      CHECK (planned_count >= 0 AND queued_count >= 0 AND sent_count >= 0
         AND delivered_count >= 0 AND read_count >= 0 AND failed_count >= 0
         AND skipped_count >= 0 AND deferred_count >= 0),
  ADD CONSTRAINT chk_batch_tier_split CHECK (free_count + paid_count <= planned_count),
  ADD CONSTRAINT chk_batch_delivered_le_sent CHECK (delivered_count <= sent_count),
  ADD CONSTRAINT chk_batch_read_le_delivered CHECK (read_count <= delivered_count),
  ADD CONSTRAINT chk_batch_finished
      CHECK (status NOT IN ('COMPLETED','FAILED','CANCELED') OR finished_at IS NOT NULL),
  ADD CONSTRAINT chk_batch_duration CHECK (duration_ms IS NULL OR duration_ms >= 0),
  ADD CONSTRAINT chk_batch_kind
      CHECK (batch_kind IN ('DAILY','BACKFILL','RESEND','RETRY','TEST')),
  ADD CONSTRAINT chk_batch_parent_only_on_retry
      CHECK (parent_batch_id IS NULL OR batch_kind = 'RETRY'),
  ADD CONSTRAINT chk_batch_parent_not_self
      CHECK (parent_batch_id IS NULL OR parent_batch_id <> id),
  ADD CONSTRAINT chk_batch_canceled_reason
      CHECK ((status <> 'CANCELED')
             OR (canceled_reason IS NOT NULL AND char_length(btrim(canceled_reason)) >= 10)),
  ADD CONSTRAINT chk_batch_tier_limit
      CHECK (tier_limit_at_plan IS NULL OR tier_limit_at_plan > 0),
  ADD CONSTRAINT chk_batch_truncated CHECK (truncated_count >= 0),
  ADD CONSTRAINT chk_batch_fallback_pair
      CHECK ((fallback_used = false) = (fallback_reason IS NULL)),
  ADD CONSTRAINT chk_batch_fallback_reason
      CHECK (fallback_reason IS NULL
             OR fallback_reason IN ('no_devotional','audio_missing','audio_failed',
                                    'template_not_approved','media_expired')),
  ADD CONSTRAINT chk_batch_halted_needs_code
      CHECK (status <> 'HALTED_BY_GUARD' OR halted_error_code IS NOT NULL);

O índice único uq_send_batches_date continua valendo por dia de calendário para o lote diário, e por isso um lote BACKFILL ou RESEND não é uma segunda linha do mesmo dia: ele é um lote com devotional_date própria quando cobre outro dia, ou é executado como conjunto de delivery_attempts com origin distinta dentro do lote diário existente. A regra "um planejamento diário por dia" não tem exceção.

O lote RETRY é o único que compartilha a devotional_date de outro lote, e é por isso que o índice é parcial em batch_kind = 'DAILY'. Ele existe para que o reprocessamento não contamine os contadores do lote original: parent_batch_id aponta para a origem, os números do lote de origem ficam congelados como estavam quando ele terminou, e a taxa de entrega histórica daquele dia continua sendo lida sem correção manual. A unicidade dele vem de uq_send_batches_idempotency, com a chave retry:{parentBatchId} — um lote de origem gera no máximo um reprocessamento.

Os contadores são atualizados por UPDATE ... SET sent_count = sent_count + 1, operação atômica no Postgres. Não há corrida, mas há contenção: com 20 mensagens por segundo, são 20 atualizações por segundo na mesma linha. A decisão é aceitar: 20 escritas por segundo em uma linha é trivial para o Postgres, e o valor em tempo real vale mais do que um agregado calculado depois. A reconciliação final do lote recalcula os contadores a partir de delivery_attempts e corrige eventual divergência.

6.23 Tabela settings #

Configuração de runtime editável pelo administrador. A fronteira entre isto e variável de ambiente está definida na Seção 26.2, e a lista de chaves canônicas está em 26.8.

Coluna Tipo Nulo Default Descrição
key varchar(80) não Chave, no formato grupo.chave: minúsculas, ponto como separador, sem maiúsculas e sem hífen. Chave primária. Nome em maiúsculas é nome de variável de ambiente, nunca de setting.
value jsonb não Valor atual, sempre um escalar ou objeto JSON.
value_type SettingValueType não Tipo declarado. Valida value.
default_value jsonb não Valor de fábrica. Permite "restaurar padrão".
description varchar(300) não Explicação exibida no painel, em português.
group_name varchar(40) não Agrupamento visual. Ex.: envio, cobranca, conteudo.
is_secret boolean não false Se verdadeiro, o valor nunca é devolvido pela API nem aparece em log.
editable_by Role não 'ADMIN' Papel mínimo para editar.
min_value numeric(20,4) sim NULL Limite inferior, para tipos numéricos.
max_value numeric(20,4) sim NULL Limite superior.
allowed_values jsonb sim NULL Array de valores permitidos, quando enumerado.
version integer não 1 Versionamento otimista.
updated_by_admin_id char(26) sim NULL Último editor.
created_at timestamptz não now()
updated_at timestamptz não now()

6.23.1 Índices #

Índice Definição Justificativa
settings_pkey PRIMARY KEY (key) Leitura por chave, que é o único acesso quente.
ix_settings_group (group_name, key) Montagem da tela de configuração, agrupada.
ix_settings_updated_at (updated_at DESC) Últimas alterações no painel e invalidação de cache.

6.23.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
updated_by_admin_id admin_users(id) SET NULL CASCADE

6.23.3 Constraints CHECK #

ALTER TABLE settings
  ADD CONSTRAINT chk_settings_key_shape CHECK (key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
  ADD CONSTRAINT chk_settings_type_matches CHECK (
    (value_type = 'STRING'  AND jsonb_typeof(value) = 'string')
 OR (value_type = 'INT'     AND jsonb_typeof(value) = 'number')
 OR (value_type = 'DECIMAL' AND jsonb_typeof(value) = 'number')
 OR (value_type = 'BOOL'    AND jsonb_typeof(value) = 'boolean')
 OR (value_type = 'JSON'    AND jsonb_typeof(value) IN ('object','array'))
  ),
  ADD CONSTRAINT chk_settings_default_type_matches CHECK (
    jsonb_typeof(default_value) = jsonb_typeof(value)
  ),
  ADD CONSTRAINT chk_settings_bounds CHECK (
    min_value IS NULL OR max_value IS NULL OR min_value <= max_value
  ),
  ADD CONSTRAINT chk_settings_allowed_is_array CHECK (
    allowed_values IS NULL OR jsonb_typeof(allowed_values) = 'array'
  ),
  ADD CONSTRAINT chk_settings_editable_by CHECK (editable_by IN ('EDITOR','ADMIN','OWNER'));

Seis colunas desta tabela são NOT NULL e não têm default utilizável: value, value_type, default_value, description, group_name e editable_by. Isso tem uma consequência operacional que precisa estar escrita: qualquer INSERT manual em settings — inclusive o comando de emergência que liga o interruptor geral de envios — precisa trazer todas as seis. Um INSERT ... ON CONFLICT DO UPDATE que traga apenas key e value funciona quando a linha já existe e aborta quando ela não existe, ou seja, falha exatamente no ambiente recém-provisionado em que o operador mais precisa dele. O seed de 6.32.4 cria todas as chaves canônicas justamente para que o caminho normal nunca dependa disso.

O CHECK de tipo é a razão de value ser jsonb e não text: o banco valida o tipo, e a API não precisa confiar no cliente. Tentativa de gravar "20" (string) numa chave INT falha na borda com VALIDATION_ERROR e, se escapar, falha no banco.

Cache: a aplicação mantém as settings em memória com TTL de 60 segundos e invalidação ativa via canal Redis settings:invalidate publicado no UPDATE. Isso mantém a leitura fora do caminho quente sem exigir reinício para aplicar mudanças.

6.24 Tabela feature_flags #

Chaves booleanas de rollout, separadas de settings porque têm semântica própria (percentual e segmentação por tier) e são consultadas em caminho quente.

Coluna Tipo Nulo Default Descrição
key varchar(60) não Chave. Formato snake_case. Chave primária.
description varchar(300) não O que a flag liga ou desliga, em português.
is_enabled boolean não false Interruptor mestre.
rollout_percentage smallint não 0 0 a 100. Aplicado sobre um hash estável do identificador do assinante.
target_tier SubscriberTier sim NULL Restringe a flag a um tier. Nulo significa todos.
updated_by_admin_id char(26) sim NULL
created_at timestamptz não now()
updated_at timestamptz não now()

6.24.1 Índices #

Índice Definição Justificativa
feature_flags_pkey PRIMARY KEY (key) Único acesso necessário. A tabela cabe inteira em cache.
ix_feature_flags_enabled (is_enabled) Lista rápida das flags ligadas no painel operacional.

6.24.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
updated_by_admin_id admin_users(id) SET NULL CASCADE

6.24.3 Constraints CHECK #

ALTER TABLE feature_flags
  ADD CONSTRAINT chk_flags_key_shape CHECK (key ~ '^[a-z][a-z0-9_]{2,59}$'),
  ADD CONSTRAINT chk_flags_rollout CHECK (rollout_percentage BETWEEN 0 AND 100);

Avaliação determinística, sem estado por assinante:

export function isFlagOn(
  flag: { isEnabled: boolean; rolloutPercentage: number; targetTier: Tier | null },
  subscriber: { id: string; tier: Tier },
): boolean {
  if (!flag.isEnabled) return false;
  if (flag.targetTier && flag.targetTier !== subscriber.tier) return false;
  if (flag.rolloutPercentage >= 100) return true;
  if (flag.rolloutPercentage <= 0) return false;
  // hash estável: mesmo assinante cai sempre no mesmo bucket
  const bucket = Number(BigInt('0x' + sha256Hex(flag.key + ':' + subscriber.id).slice(0, 8)) % 100n);
  return bucket < flag.rolloutPercentage;
}

Mesma entrada devolve sempre o mesmo resultado. Aumentar o percentual só adiciona assinantes; nunca remove quem já estava dentro.

6.25 Tabela daily_metrics #

Agregados diários pré-calculados. Existe para que o painel de métricas não varra message_logs a cada carregamento.

A tabela é de formato longo: uma linha por combinação de dia, métrica e dimensão. Não existe, e não pode ser criada, uma versão de formato largo com uma coluna nomeada por métrica. A razão é concreta: várias métricas do catálogo da Seção 21.2 são quebradas por dimensões de cardinalidade aberta — código de erro da Meta, categoria de cobrança, motivo de churn. Em formato largo, cada valor novo de dimensão exigiria uma migration, e uma métrica nova exigiria outra. Em formato longo, exige apenas uma linha.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID. Chave primária.
metric_date date não Dia de calendário (America/Sao_Paulo) a que o valor se refere.
metric_key text não Chave canônica da métrica, no formato grupo.metrica. Ex.: messages.sent, revenue.gross_cents, subscribers.total. O catálogo completo está na Seção 21.2.
dimension text não '' Recorte da métrica. String vazia, nunca NULL, quando a métrica não é quebrada. Ex.: tier=PAID, error_code=131047, reason=too_expensive.
value numeric(18,6) sim NULL Valor da métrica. Nulo quando a métrica é uma razão cujo denominador é zero.
numerator numeric(18,6) sim NULL Numerador, quando a métrica é uma razão. Permite reagregar corretamente sobre um período sem somar percentuais.
denominator numeric(18,6) sim NULL Denominador, quando a métrica é uma razão.
rollup_version smallint não 1 Versão do algoritmo que produziu a linha. Uma recomputação com versão maior sobrescreve; com versão menor, não.
is_final boolean não false Verdadeiro quando o dia está fechado e não há mais expectativa de correção. Linhas não finais são reprocessadas.
computed_at timestamptz não now() Última recomputação.
created_at timestamptz não now()
updated_at timestamptz não now()

Sem chave estrangeira. A tabela é um agregado derivado: apagar a origem não invalida o número já consolidado, e uma FK aqui só criaria contenção no expurgo de partições.

Três regras de valor que evitam erro de leitura:

  1. Dinheiro vai em metric_key com sufixo explícito de unidaderevenue.gross_cents está em centavos (divisor 100), cost.whatsapp_micros está em micros (divisor 1.000.000). Nunca some as duas sem converter.
  2. Razão nunca é gravada só como percentual. numerator e denominator são gravados junto, porque a média de sete percentuais diários não é o percentual da semana.
  3. dimension usa string vazia em vez de NULL porque NULL nunca é igual a NULL no Postgres, e um índice único com coluna nula deixaria de impedir duplicidade — que é exatamente o que ele existe para impedir.

6.25.1 Índices #

Índice Definição Justificativa
daily_metrics_pkey PRIMARY KEY (id)
daily_metrics_unique UNIQUE (metric_date, metric_key, dimension) Idempotência do rollup. É a chave do ON CONFLICT que permite recomputar qualquer dia passado sem duplicar.
daily_metrics_key_date_idx (metric_key, metric_date DESC) Série temporal de uma métrica, que é o acesso dominante do painel.
daily_metrics_date_idx (metric_date DESC) Todas as métricas de um dia, para a visão geral.
daily_metrics_not_final_idx (metric_date) WHERE is_final = false Localiza os dias que ainda precisam ser reprocessados, sem varrer a tabela.

6.25.2 Constraints CHECK #

ALTER TABLE daily_metrics
  ADD CONSTRAINT chk_metrics_date_sane CHECK (metric_date >= DATE '2026-01-01'),
  ADD CONSTRAINT chk_metrics_key_shape
      CHECK (metric_key ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
  ADD CONSTRAINT chk_metrics_dimension_not_null CHECK (dimension IS NOT NULL),
  ADD CONSTRAINT chk_metrics_denominator CHECK (denominator IS NULL OR denominator >= 0),
  ADD CONSTRAINT chk_metrics_ratio_pair
      CHECK ((numerator IS NULL) = (denominator IS NULL)),
  ADD CONSTRAINT chk_metrics_rollup_version CHECK (rollup_version >= 1);

A recomputação é sempre um INSERT ... ON CONFLICT (metric_date, metric_key, dimension) DO UPDATE, condicionado a daily_metrics.is_final = false OR EXCLUDED.rollup_version > daily_metrics.rollup_version. Assim o job de agregação é idempotente, permite recomputar qualquer dia passado sem duplicar, e não sobrescreve um dia já fechado a menos que o algoritmo tenha mudado de versão.

O rollup roda às 03:10, sobre o dia D−1 já fechado, e reprocessa também D−2, D−3, D−4, D−7 e D−14. Não existe rollup às 23:00: o último job do dia civil fecha as entregas pendentes às 23:55, e consolidar antes disso mediria o dia antes de ele acabar, produzindo mensagens entregues, aberturas de janela e entregas pendentes sistematicamente subestimadas. O agendamento e a justificativa completa estão na Seção 21.5.

A rota pública de eventos nunca escreve nesta tabela. Ela grava eventos brutos, e é o job de rollup que os consolida aqui.

6.26 Tabela webhook_deliveries #

Registro de transporte de todo webhook, nas duas direções. Complementa payment_events: aqui fica o nível HTTP (assinatura, latência, status), lá fica o nível de negócio do provedor de pagamento. Para o WhatsApp, esta tabela é o único registro bruto — não existe tabela específica para eventos da Meta.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
direction WebhookDirection não INBOUND (recebemos) ou OUTBOUND (enviamos).
source WebhookSource não ASAAS ou WHATSAPP.
endpoint text não Caminho recebido ou URL chamada.
http_method varchar(10) não 'POST'
http_status smallint sim NULL Status respondido (inbound) ou recebido (outbound).
event_type varchar(80) sim NULL Tipo extraído do corpo.
dedup_key varchar(160) sim NULL Chave de deduplicação de transporte. É o único nome desta coluna: não existe external_id. Junto com source — que também é o único nome da origem, e não provider — forma o índice único uq_webhook_dedup de 6.26.1.
duplicate_count integer não 0 Quantas vezes o mesmo evento chegou de novo, incrementado no ON CONFLICT da deduplicação. Importa porque a Meta reenvia com frequência real quando há lentidão, e um salto neste contador é o primeiro sinal de que o handler está devolvendo 200 devagar demais (Seção 17.8).
signature_header text sim NULL Valor do cabeçalho de assinatura, guardado para perícia.
is_signature_valid boolean não false Resultado da verificação em tempo constante.
body_sha256 char(64) não Hash do corpo cru. Detecta reentrega idêntica.
body jsonb sim NULL Corpo parseado. Nulo quando o corpo é inválido.
body_raw_size integer não 0 Tamanho em bytes do corpo cru.
headers jsonb sim NULL Cabeçalhos relevantes, já redigidos.
processing_status WebhookProcessingStatus não 'RECEIVED'
processing_result WebhookProcessingResult sim NULL Desfecho detalhado do processamento. SCHEMA_REJECTED, PARSE_ERROR e PERSIST_FAILED existem porque corpo inválido nunca produz resposta não-2xx: autenticado o remetente, qualquer falha posterior responde 200 e é registrada aqui, com o corpo cru preservado para reprocessamento. Devolver 4xx ou 5xx leva o provedor a retentar e, em seguida, a desativar a assinatura do webhook — e é por esse canal que a revogação imediata de acesso pago acontece.
attempts smallint não 0 Tentativas de processamento ou de envio.
next_retry_at timestamptz sim NULL
last_error text sim NULL
latency_ms integer sim NULL Tempo do handler. Monitorado contra o alvo de p99 abaixo de 2 s.
request_id char(26) sim NULL Correlação.
received_at timestamptz não now()
processed_at timestamptz sim NULL
created_at timestamptz não now()

6.26.1 Índices #

Índice Definição Justificativa
webhook_deliveries_pkey PRIMARY KEY (id)
uq_webhook_dedup UNIQUE (source, dedup_key) WHERE dedup_key IS NOT NULL Deduplicação de transporte. Para o WhatsApp, dedup_key é o wamid ou wamid:status; para o provedor de pagamento, é o identificador do evento.
ix_webhook_source_time (source, received_at DESC) Volume por origem e diagnóstico de fila pausada.
ix_webhook_pending (next_retry_at) WHERE processing_status IN ('RECEIVED','PROCESSING','FAILED') Fila de reprocessamento.
ix_webhook_body_hash (body_sha256, received_at DESC) Confirma reentrega idêntica em investigação.
ix_webhook_invalid_signature (received_at DESC) WHERE is_signature_valid = false Alerta de segurança: rajada de assinaturas inválidas indica tentativa de forjar webhook.
ix_webhook_received_at (received_at) Expurgo de 90 dias.

6.26.2 Constraints CHECK #

ALTER TABLE webhook_deliveries
  ADD CONSTRAINT chk_wh_body_hash CHECK (body_sha256 ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_wh_http_status CHECK (http_status IS NULL OR http_status BETWEEN 100 AND 599),
  ADD CONSTRAINT chk_wh_method CHECK (http_method IN ('GET','POST','PUT','PATCH','DELETE')),
  ADD CONSTRAINT chk_wh_attempts CHECK (attempts BETWEEN 0 AND 50),
  ADD CONSTRAINT chk_wh_duplicate_count CHECK (duplicate_count >= 0),
  ADD CONSTRAINT chk_wh_latency CHECK (latency_ms IS NULL OR latency_ms >= 0),
  ADD CONSTRAINT chk_wh_body_size CHECK (body_raw_size >= 0 AND body_raw_size <= 5242880),
  ADD CONSTRAINT chk_wh_processed CHECK (processing_status <> 'PROCESSED' OR processed_at IS NOT NULL),
  ADD CONSTRAINT chk_wh_result_terminal
      CHECK (processing_status NOT IN ('PROCESSED','FAILED','IGNORED')
             OR processing_result IS NOT NULL);

O teto de 5 MB em body_raw_size acompanha o limite de corpo definido na Seção 7.14. Corpo maior é rejeitado antes de chegar aqui.

Sem FKs. A tabela é um diário de transporte e precisa registrar inclusive requisições que não correspondem a nada conhecido — inclusive tentativas de forjar assinatura.

6.27 Tabela media_uploads #

Controle dos identificadores de mídia da Meta. Cada áudio é enviado à Meta uma vez por devocional, nunca por assinante, e o identificador vale 30 dias.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
devotional_id char(26) não Devocional de origem.
audio_asset_id char(26) sim NULL Asset enviado. Nulo quando a mídia é a arte de cabeçalho.
purpose MediaPurpose não DAILY_AUDIO, VIDEO_FALLBACK ou TEMPLATE_HEADER.
whatsapp_media_id varchar(80) sim NULL Identificador devolvido pela Meta.
phone_number_id varchar(40) não Número remetente. O identificador de mídia é válido apenas para este número.
mime_type varchar(60) não Ex.: audio/ogg; codecs=opus.
byte_size bigint não
sha256 char(64) não Hash do arquivo enviado.
status MediaUploadStatus não 'PENDING'
attempts smallint não 0 Tentativas de upload.
last_error text sim NULL
uploaded_at timestamptz sim NULL
expires_at timestamptz sim NULL uploaded_at + 30 dias.
last_used_at timestamptz sim NULL
superseded_at timestamptz sim NULL Instante em que este upload deixou de ser o corrente porque o arquivo foi reenviado e recebeu whatsapp_media_id novo (Seção 16.11). A linha antiga não é apagada: ela prova qual identificador de mídia foi usado em cada mensagem já enviada, e sem ela um diagnóstico de "por que este áudio não tocou?" fica sem resposta.
use_count integer não 0 Quantas mensagens usaram este identificador.
created_at timestamptz não now()
updated_at timestamptz não now()

6.27.1 Índices #

Índice Definição Justificativa
media_uploads_pkey PRIMARY KEY (id)
uq_media_asset_phone UNIQUE (audio_asset_id, phone_number_id, purpose) WHERE audio_asset_id IS NOT NULL Impede reupload por assinante. Dois workers tentando enviar o mesmo áudio colidem; um vence e o outro reaproveita.
uq_media_whatsapp_id UNIQUE (whatsapp_media_id) WHERE whatsapp_media_id IS NOT NULL Um identificador da Meta pertence a uma linha só.
ix_media_devotional_purpose (devotional_id, purpose, status) Busca a mídia pronta do dia no caminho quente de envio.
ix_media_expiring (expires_at) WHERE status = 'UPLOADED' Reupload proativo antes do vencimento.
ix_media_current (devotional_id, purpose) WHERE superseded_at IS NULL Busca o upload corrente do devocional, que é a consulta do caminho quente de envio depois de uma revalidação de mídia.

6.27.2 Chaves estrangeiras #

Coluna Referencia ON DELETE ON UPDATE
devotional_id devotionals(id) CASCADE CASCADE
audio_asset_id audio_assets(id) CASCADE CASCADE

6.27.3 Constraints CHECK #

ALTER TABLE media_uploads
  ADD CONSTRAINT chk_media_size CHECK (byte_size BETWEEN 1 AND 16777216),
  ADD CONSTRAINT chk_media_sha CHECK (sha256 ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_media_uploaded_fields
      CHECK (status <> 'UPLOADED' OR (whatsapp_media_id IS NOT NULL
                                      AND uploaded_at IS NOT NULL
                                      AND expires_at IS NOT NULL)),
  ADD CONSTRAINT chk_media_expiry CHECK (expires_at IS NULL OR uploaded_at IS NULL
                                      OR expires_at > uploaded_at),
  ADD CONSTRAINT chk_media_use_count CHECK (use_count >= 0),
  ADD CONSTRAINT chk_media_superseded_after_upload
      CHECK (superseded_at IS NULL OR uploaded_at IS NULL OR superseded_at >= uploaded_at),
  ADD CONSTRAINT chk_media_attempts CHECK (attempts BETWEEN 0 AND 20);

Exemplo numérico da economia: com 3.000 assinantes pagos, enviar áudio por assinante significaria 3.000 uploads de ~1,5 MB por dia, ou 4,5 GB diários de tráfego de saída. Com o upload único, é 1 upload de 1,5 MB por dia. A unicidade em uq_media_asset_phone é o que garante estruturalmente essa economia, mesmo com bug de concorrência.

6.28 Tabela job_runs #

Execução de jobs do worker. Serve para saber se o agendador rodou, quanto demorou e o que falhou, sem depender do painel do sistema de filas.

Esta tabela é a dead-letter do sistema. Não existe fila morta separada: um job que esgota as tentativas fica no estado failed da própria fila e grava aqui uma linha com status = 'DEAD', job_name, queue, bull_job_id, attempt, payload, error e error_code. O painel de operação lista os jobs mortos a partir daqui, agrupa por error_code e oferece o botão de reprocessar, que relê payload. Nenhuma seção pode introduzir uma fila com sufixo .dlq, e nenhuma política de retentativa pode apontar para uma.

Coluna Tipo Nulo Default Descrição
id char(26) não ULID.
job_name varchar(60) não Nome do job, em dominio.acao. Ex.: send.plan, tts.generate, billing.reconcile.
queue varchar(40) não Fila de origem. Aceita apenas os treze nomes canônicos do catálogo de filas da Seção 18.8, sem apelido e sem variação.
bull_job_id varchar(80) sim NULL Identificador do job na fila.
status JobRunStatus não 'RUNNING' RUNNING, SUCCEEDED, FAILED ou DEAD. FAILED é uma tentativa que falhou e ainda tem retentativa; DEAD é trabalho morto, que esgotou as tentativas.
scheduled_for timestamptz sim NULL Horário previsto, quando é job repetível.
started_at timestamptz não now()
finished_at timestamptz sim NULL
duration_ms integer sim NULL
items_processed integer não 0
items_failed integer não 0
attempt smallint não 1 Número da tentativa.
payload jsonb sim NULL Argumentos do job, com os campos sensíveis já redigidos pelas regras da Seção 23.1.4. É o que permite reprocessar um job morto sem reconstruir os argumentos à mão: sem esta coluna, ops queue:dead:retry (Seção 25.11) não tem de onde tirá-los. Sem índice.
result jsonb sim NULL Resumo estruturado do resultado.
error text sim NULL Mensagem de erro legível, já redigida pelas regras da Seção 23.1.4, com stack truncada em 4000 caracteres. É o único nome desta coluna em todo o documento: não existe job_runs.error_message, e as Seções 16, 18 e 27 usam error ao listar os campos gravados na linha DEAD.
error_code varchar(60) sim NULL Código curto e agrupável da falha, distinto da mensagem. É por ele que ops queue:dead:list --group-by=code agrupa o trabalho morto: mil linhas com a mesma causa viram uma.
request_id char(26) sim NULL Correlação.
created_at timestamptz não now()

6.28.1 Índices #

Índice Definição Justificativa
job_runs_pkey PRIMARY KEY (id)
uq_job_runs_scheduled UNIQUE (job_name, scheduled_for) WHERE scheduled_for IS NOT NULL Impede execução dupla de job repetível. Se dois workers pegarem o mesmo disparo do agendador, um insere e o outro falha e desiste.
ix_job_runs_name_time (job_name, started_at DESC) Histórico e detecção de job que parou de rodar.
ix_job_runs_failed (started_at DESC) WHERE status = 'FAILED' Painel de falhas recentes.
ix_job_runs_dead (queue, started_at DESC) WHERE status = 'DEAD' Fila de trabalho morto. Sustenta a tela de reprocessamento e os alertas por volume de mortos por fila.
ix_job_runs_dead_code (queue, error_code) WHERE status = 'DEAD' Agrupamento por causa em ops queue:dead:list --group-by=code, sem varrer a tabela.
ix_job_runs_running_stale (started_at) WHERE status = 'RUNNING' Detecta jobs travados: RUNNING há mais de 30 minutos vira alerta.

6.28.2 Constraints CHECK #

ALTER TABLE job_runs
  ADD CONSTRAINT chk_job_name_shape CHECK (job_name ~ '^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$'),
  ADD CONSTRAINT chk_job_counts CHECK (items_processed >= 0 AND items_failed >= 0),
  ADD CONSTRAINT chk_job_attempt CHECK (attempt BETWEEN 1 AND 20),
  ADD CONSTRAINT chk_job_queue_shape CHECK (queue ~ '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'),
  ADD CONSTRAINT chk_job_queue_not_dlq CHECK (queue NOT LIKE '%.dlq'),
  ADD CONSTRAINT chk_job_finished
      CHECK (status = 'RUNNING' OR finished_at IS NOT NULL),
  ADD CONSTRAINT chk_job_failed_needs_error
      CHECK (status NOT IN ('FAILED','DEAD') OR error IS NOT NULL),
  ADD CONSTRAINT chk_job_dead_needs_code
      CHECK (status <> 'DEAD' OR error_code IS NOT NULL),
  ADD CONSTRAINT chk_job_error_code_shape
      CHECK (error_code IS NULL OR error_code ~ '^[A-Z][A-Z0-9_]{2,59}$'),
  ADD CONSTRAINT chk_job_payload_object
      CHECK (payload IS NULL OR jsonb_typeof(payload) = 'object'),
  ADD CONSTRAINT chk_job_error_len CHECK (error IS NULL OR char_length(error) <= 4000);

chk_job_queue_not_dlq é uma trava deliberada e um pouco incomum: ela existe para que a decisão "não há fila morta separada" seja imposta pelo banco, e não apenas escrita em prosa. Se alguém implementar uma fila send.dispatch.dlq, o primeiro registro de execução falha e a divergência aparece imediatamente, em vez de virar um segundo sistema de trabalho morto que ninguém monitora.

6.29 schema.prisma completo #

Arquivo prisma/schema.prisma do pacote de banco. É copiável e válido.

Limitação assumida e tratada: o Prisma não representa índices parciais (WHERE), constraints CHECK, tabelas particionadas nem FKs DEFERRABLE. Esses objetos são criados por SQL nos arquivos de migration, conforme 6.31. O fluxo de trabalho adotado é prisma migrate dev --create-only para gerar o esqueleto, edição manual do migration.sql para acrescentar o que falta, e prisma migrate deploy em staging e produção. A lista de objetos não representáveis está em 6.31.9, para que o revisor saiba que a divergência é intencional.

// prisma/schema.prisma

generator client {
  provider        = "prisma-client"
  output          = "../src/generated/client"
  runtime         = "nodejs"
  previewFeatures = ["postgresqlExtensions"]
}

datasource db {
  provider   = "postgresql"
  url        = env("DATABASE_URL")
  extensions = [citext, pgcrypto, pg_trgm, btree_gin, unaccent]
}

// ---------------------------------------------------------------- enums

enum SubscriberTier            { FREE PAID }
enum SubscriberStatus          { PENDING_VERIFICATION VERIFIED_PENDING_OPTIN ACTIVE_FREE ACTIVE_PAID PAUSED OPTED_OUT BLOCKED }
enum Role                      { SUBSCRIBER EDITOR ADMIN OWNER }
enum ConsentType               { OPT_IN_WEB OPT_IN_WHATSAPP OPT_OUT RE_OPT_IN TERMS_ACCEPTED PRIVACY_ACCEPTED DATA_EXPORT_REQUESTED DATA_DELETION_REQUESTED PAUSE_STARTED PAUSE_ENDED SERVICE_TERMS SENSITIVE_DATA MARKETING COOKIE_CONSENT }
enum ConsentChannel            { WEB WHATSAPP EMAIL ADMIN OPS_CLI }
enum SessionSubjectType        { SUBSCRIBER ADMIN }
enum SessionScope              { FULL RESTRICTED IMPERSONATION_READONLY }
enum OtpPurpose                { LOGIN PHONE_VERIFICATION PHONE_CHANGE EMAIL_VERIFICATION }
enum OtpChannel                { WHATSAPP EMAIL_LINK }
enum PlanInterval              { MONTHLY YEARLY }
enum SubscriptionStatus        { PENDING_PAYMENT ACTIVE CANCELED EXPIRED REFUNDED }
enum BillingType               { CREDIT_CARD PIX }
enum PaymentStatus             { PENDING CONFIRMED RECEIVED OVERDUE REFUNDED CHARGEBACK_REQUESTED CHARGEBACK_DISPUTE DELETED FAILED }
enum SubscriptionEventType     { CREATED ACTIVATED RENEWED CANCEL_REQUESTED CANCELED EXPIRED REFUNDED CHARGEBACK REVOKED REACTIVATED PLAN_CHANGED }
enum DevotionalStatus          { DRAFT READY AUDIO_PENDING AUDIO_READY PUBLISHED SENT }
enum AudioProvider             { ELEVENLABS GOOGLE_TTS }
enum AudioAssetStatus          { PENDING PROCESSING READY FAILED }
enum AudioFormat               { OGG_OPUS MP3 MP4 }
enum TemplateCategory          { UTILITY MARKETING AUTHENTICATION }
enum TemplateStatus            { DRAFT PENDING APPROVED REJECTED PAUSED DISABLED }
enum MessageDirection          { OUTBOUND INBOUND }
enum MessageKind               { TEMPLATE TEXT AUDIO VIDEO IMAGE INTERACTIVE REACTION UNKNOWN }
enum MessageStatus             { QUEUED SENT DELIVERED READ FAILED DEFERRED }
enum DeliveryStep              { TEMPLATE_INVITE FULL_TEXT AUDIO CLOSING VIDEO_FALLBACK }
enum DeliveryAttemptStatus     { PLANNED IN_FLIGHT SUCCEEDED FAILED SKIPPED SKIPPED_OPTED_OUT SKIPPED_INELIGIBLE DEFERRED }
enum SendBatchStatus           { PLANNING PLANNED RUNNING HALTED_BY_GUARD COMPLETED FAILED CANCELED }
enum WebhookSource             { ASAAS WHATSAPP }
enum WebhookDirection          { INBOUND OUTBOUND }
enum WebhookProcessingStatus   { RECEIVED PROCESSING PROCESSED FAILED IGNORED }
enum WebhookProcessingResult   { OK SCHEMA_REJECTED PARSE_ERROR PERSIST_FAILED UNKNOWN_EVENT DUPLICATE }
enum MediaUploadStatus         { PENDING UPLOADED EXPIRED FAILED }
enum MediaPurpose              { DAILY_AUDIO VIDEO_FALLBACK TEMPLATE_HEADER }
enum JobRunStatus              { RUNNING SUCCEEDED FAILED DEAD }
enum SettingValueType          { STRING INT BOOL JSON DECIMAL }
enum InboundIntent             { OPT_OUT OPT_IN OPEN_DEVOTIONAL SNOOZE HELP SUPPORT UNKNOWN }

// ---------------------------------------------------------------- identidade

model Subscriber {
  id                      String            @id @db.Char(26)
  // phoneE164, waId e email guardam envelope cifrado (v1:<iv>:<ct>:<tag>).
  // A unicidade e toda busca por igualdade vivem nas colunas _hmac. Ver 6.1.8.
  phoneE164               String            @map("phone_e164") @db.Text
  phoneHmac               String            @unique @map("phone_hmac") @db.Char(64)
  waId                    String?           @map("wa_id") @db.Text
  waIdHmac                String?           @unique @map("wa_id_hmac") @db.Char(64)
  waIdChangedAt           DateTime?         @map("wa_id_changed_at") @db.Timestamptz(3)
  displayName             String?           @map("display_name") @db.VarChar(120)
  email                   String?           @db.Text
  emailHmac               String?           @unique @map("email_hmac") @db.Char(64)
  emailVerifiedAt         DateTime?         @map("email_verified_at") @db.Timestamptz(3)
  phoneVerifiedAt         DateTime?         @map("phone_verified_at") @db.Timestamptz(3)
  tier                    SubscriberTier    @default(FREE)
  status                  SubscriberStatus  @default(PENDING_VERIFICATION)
  locale                  String            @default("pt-BR") @db.VarChar(10)
  timezone                String            @default("America/Sao_Paulo") @db.VarChar(40)
  optInConfirmedAt        DateTime?         @map("opt_in_confirmed_at") @db.Timestamptz(3)
  optOutAt                DateTime?         @map("opt_out_at") @db.Timestamptz(3)
  // optOutReason guarda palavra-chave E origem. Nao existe opt_out_source. Ver 6.3.
  optOutReason            String?           @map("opt_out_reason") @db.VarChar(80)
  optOutConfirmationSentAt DateTime?        @map("opt_out_confirmation_sent_at") @db.Timestamptz(3)
  emailMarketingConsentAt DateTime?         @map("email_marketing_consent_at") @db.Timestamptz(3)
  billingBlockedAt        DateTime?         @map("billing_blocked_at") @db.Timestamptz(3)
  serviceWindowExpiresAt  DateTime?         @map("service_window_expires_at") @db.Timestamptz(3)
  lastInboundAt           DateTime?         @map("last_inbound_at") @db.Timestamptz(3)
  lastDeliveredAt         DateTime?         @map("last_delivered_at") @db.Timestamptz(3)
  lastLoginAt             DateTime?         @map("last_login_at") @db.Timestamptz(3)
  consecutiveWindowMisses Int               @default(0) @map("consecutive_window_misses") @db.SmallInt
  undeliveredDays         Int               @default(0) @map("undelivered_days") @db.SmallInt
  undeliverableCount      Int               @default(0) @map("undeliverable_count") @db.SmallInt
  unrecognizedRepliesInWindow Int           @default(0) @map("unrecognized_replies_in_window") @db.SmallInt
  blockedAt               DateTime?         @map("blocked_at") @db.Timestamptz(3)
  blockedReason           String?           @map("blocked_reason") @db.VarChar(120)
  pausedUntil             DateTime?         @map("paused_until") @db.Timestamptz(3)
  courtesyUntil           DateTime?         @map("courtesy_until") @db.Timestamptz(3)
  humanHandoffUntil       DateTime?         @map("human_handoff_until") @db.Timestamptz(3)
  autoreplySuppressedUntil DateTime?        @map("autoreply_suppressed_until") @db.Timestamptz(3)
  marketingBlockedAt      DateTime?         @map("marketing_blocked_at") @db.Timestamptz(3)
  welcomeBackfillSentAt   DateTime?         @map("welcome_backfill_sent_at") @db.Timestamptz(3)
  asaasCustomerId         String?           @unique @map("asaas_customer_id") @db.VarChar(32)
  source                  String            @default("landing") @db.VarChar(40)
  currentSubscriptionId   String?           @map("current_subscription_id") @db.Char(26)
  anonymizedAt            DateTime?         @map("anonymized_at") @db.Timestamptz(3)
  fullyAnonymizedAt       DateTime?         @map("fully_anonymized_at") @db.Timestamptz(3)
  deletedAt               DateTime?         @map("deleted_at") @db.Timestamptz(3)
  createdAt               DateTime          @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt               DateTime          @updatedAt @map("updated_at") @db.Timestamptz(3)

  profile              SubscriberProfile?
  currentSubscription  Subscription?       @relation("CurrentSubscription", fields: [currentSubscriptionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  subscriptions        Subscription[]      @relation("SubscriberSubscriptions")
  payments             Payment[]
  subscriptionEvents   SubscriptionEvent[]
  consentEvents        ConsentEvent[]
  sessions             Session[]
  otpCodes             OtpCode[]
  unsubscribeTokens    UnsubscribeToken[]
  deliveryAttempts     DeliveryAttempt[]
  inboundMessages      InboundMessage[]

  @@index([tier, status], map: "ix_subscribers_tier_status")
  @@index([serviceWindowExpiresAt], map: "ix_subscribers_service_window")
  @@index([currentSubscriptionId], map: "ix_subscribers_current_subscription")
  @@index([createdAt(sort: Desc)], map: "ix_subscribers_created_at")
  @@index([consecutiveWindowMisses], map: "ix_subscribers_window_misses")
  @@index([pausedUntil], map: "ix_subscribers_paused_until")
  @@index([courtesyUntil], map: "ix_subscribers_courtesy_until")
  @@index([lastInboundAt, lastLoginAt], map: "ix_subscribers_dormant")
  @@index([billingBlockedAt], map: "ix_subscribers_billing_blocked")
  @@map("subscribers")
}

model SubscriberProfile {
  subscriberId      String    @id @map("subscriber_id") @db.Char(26)
  fullName          String?   @map("full_name") @db.VarChar(160)
  // cpf guarda envelope cifrado. cpfHmac NAO e unico, por decisao explicita (6.4.1).
  cpf               String?   @db.Text
  cpfLast4          String?   @map("cpf_last4") @db.Char(4)
  cpfHmac           String?   @map("cpf_hmac") @db.Char(64)
  birthDate         DateTime? @map("birth_date") @db.Date
  city              String?   @db.VarChar(120)
  state             String?   @db.Char(2)
  churchName        String?   @map("church_name") @db.VarChar(160)
  preferredVoice    String?   @map("preferred_voice") @db.VarChar(60)
  utmSource         String?   @map("utm_source") @db.VarChar(120)
  utmMedium         String?   @map("utm_medium") @db.VarChar(120)
  utmCampaign       String?   @map("utm_campaign") @db.VarChar(120)
  referrer          String?   @db.Text
  signupIp          String?   @map("signup_ip") @db.Inet
  signupUserAgent   String?   @map("signup_user_agent") @db.Text
  adminNotes        String?   @map("admin_notes") @db.Text
  createdAt         DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)

  subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  @@index([utmCampaign], map: "ix_subscriber_profiles_utm")
  @@index([cpfHmac], map: "ix_subscriber_profiles_cpf_hmac")
  @@map("subscriber_profiles")
}

model ConsentEvent {
  id              String         @id @db.Char(26)
  subscriberId    String         @map("subscriber_id") @db.Char(26)
  type            ConsentType
  channel         ConsentChannel
  granted         Boolean
  policyVersion   String         @map("policy_version") @db.VarChar(20)
  consentText     String         @map("consent_text") @db.Text
  consentTextHash String         @map("consent_text_hash") @db.Char(64)
  ip              String?        @db.Inet
  userAgent       String?        @map("user_agent") @db.Text
  evidence        Json?          @db.JsonB
  occurredAt      DateTime       @default(now()) @map("occurred_at") @db.Timestamptz(3)
  createdAt       DateTime       @default(now()) @map("created_at") @db.Timestamptz(3)

  subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)

  @@index([subscriberId, occurredAt(sort: Desc)], map: "ix_consent_events_subscriber")
  @@index([type, occurredAt(sort: Desc)], map: "ix_consent_events_type_time")
  @@index([policyVersion], map: "ix_consent_events_policy_version")
  @@map("consent_events")
}

// ---------------------------------------------------------------- autenticação

model Session {
  id                    String             @id @db.Char(26)
  subjectType           SessionSubjectType @map("subject_type")
  subscriberId          String?            @map("subscriber_id") @db.Char(26)
  adminUserId           String?            @map("admin_user_id") @db.Char(26)
  role                  Role
  scope                 SessionScope       @default(FULL)
  refreshTokenHash      String             @unique @map("refresh_token_hash") @db.Char(64)
  parentSessionId       String?            @map("parent_session_id") @db.Char(26)
  impersonatedByAdminId String?            @map("impersonated_by_admin_id") @db.Char(26)
  impersonationReason   String?            @map("impersonation_reason") @db.Text
  ip                    String?            @db.Inet
  userAgent             String?            @map("user_agent") @db.Text
  deviceFingerprint     String?            @map("device_fingerprint") @db.Char(64)
  issuedAt              DateTime           @default(now()) @map("issued_at") @db.Timestamptz(3)
  lastSeenAt            DateTime           @default(now()) @map("last_seen_at") @db.Timestamptz(3)
  expiresAt             DateTime           @map("expires_at") @db.Timestamptz(3)
  revokedAt             DateTime?          @map("revoked_at") @db.Timestamptz(3)
  revokedReason         String?            @map("revoked_reason") @db.VarChar(60)
  createdAt             DateTime           @default(now()) @map("created_at") @db.Timestamptz(3)

  subscriber      Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  adminUser       AdminUser?  @relation("AdminSessions", fields: [adminUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  parentSession   Session?    @relation("SessionRotation", fields: [parentSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  childSessions   Session[]   @relation("SessionRotation")
  impersonatedBy  AdminUser?  @relation("AdminImpersonations", fields: [impersonatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@index([subscriberId, expiresAt(sort: Desc)], map: "ix_sessions_subscriber")
  @@index([adminUserId, expiresAt(sort: Desc)], map: "ix_sessions_admin")
  @@index([expiresAt], map: "ix_sessions_expires_at")
  @@index([parentSessionId], map: "ix_sessions_parent")
  @@index([impersonatedByAdminId, issuedAt(sort: Desc)], map: "ix_sessions_impersonation")
  @@map("sessions")
}

model OtpCode {
  id                    String     @id @db.Char(26)
  purpose               OtpPurpose
  channel               OtpChannel
  phoneE164             String?    @map("phone_e164") @db.Text
  phoneHmac             String?    @map("phone_hmac") @db.Char(64)
  email                 String?    @db.Text
  emailHmac             String?    @map("email_hmac") @db.Char(64)
  subscriberId          String?    @map("subscriber_id") @db.Char(26)
  codeHash              String     @map("code_hash") @db.Char(64)
  attempts              Int        @default(0) @db.SmallInt
  maxAttempts           Int        @default(5) @map("max_attempts") @db.SmallInt
  expiresAt             DateTime   @map("expires_at") @db.Timestamptz(3)
  consumedAt            DateTime?  @map("consumed_at") @db.Timestamptz(3)
  invalidatedAt         DateTime?  @map("invalidated_at") @db.Timestamptz(3)
  requestIp             String?    @map("request_ip") @db.Inet
  deliveryMessageLogId  String?    @map("delivery_message_log_id") @db.Char(26)
  createdAt             DateTime   @default(now()) @map("created_at") @db.Timestamptz(3)

  subscriber Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  @@index([phoneHmac, createdAt(sort: Desc)], map: "ix_otp_codes_phone")
  @@index([emailHmac, createdAt(sort: Desc)], map: "ix_otp_codes_email")
  @@index([requestIp, createdAt(sort: Desc)], map: "ix_otp_codes_ip_window")
  @@index([expiresAt], map: "ix_otp_codes_expires_at")
  @@index([subscriberId, createdAt(sort: Desc)], map: "ix_otp_codes_subscriber")
  @@map("otp_codes")
}

/// 28a e ultima tabela da lista fechada de 6.1.7. Definicao completa em 6.7.4.
model UnsubscribeToken {
  id           String    @id @db.Char(26)
  subscriberId String    @map("subscriber_id") @db.Char(26)
  tokenHash    String    @unique @map("token_hash") @db.Char(64)
  createdAt    DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  expiresAt    DateTime  @map("expires_at") @db.Timestamptz(3)
  usedAt       DateTime? @map("used_at") @db.Timestamptz(3)

  subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  @@index([subscriberId, createdAt(sort: Desc)], map: "ix_unsubscribe_tokens_subscriber")
  @@index([expiresAt], map: "ix_unsubscribe_tokens_expiring")
  @@map("unsubscribe_tokens")
}

model AdminUser {
  id                   String    @id @db.Char(26)
  email                String    @db.Citext
  name                 String    @db.VarChar(120)
  passwordHash         String    @map("password_hash") @db.Text
  role                 Role      @default(EDITOR)
  totpSecretEncrypted  Bytes?    @map("totp_secret_encrypted")
  totpEnrolledAt       DateTime? @map("totp_enrolled_at") @db.Timestamptz(3)
  totpRecoveryCodes    Json?     @map("totp_recovery_codes") @db.JsonB
  failedLoginCount     Int       @default(0) @map("failed_login_count") @db.SmallInt
  lockedUntil          DateTime? @map("locked_until") @db.Timestamptz(3)
  lastLoginAt          DateTime? @map("last_login_at") @db.Timestamptz(3)
  lastLoginIp          String?   @map("last_login_ip") @db.Inet
  passwordChangedAt    DateTime  @default(now()) @map("password_changed_at") @db.Timestamptz(3)
  mustChangePassword   Boolean   @default(false) @map("must_change_password")
  createdByAdminId     String?   @map("created_by_admin_id") @db.Char(26)
  deletedAt            DateTime? @map("deleted_at") @db.Timestamptz(3)
  createdAt            DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt            DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)

  createdBy        AdminUser?           @relation("AdminCreatedBy", fields: [createdByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  createdAdmins    AdminUser[]          @relation("AdminCreatedBy")
  sessions         Session[]            @relation("AdminSessions")
  impersonations   Session[]            @relation("AdminImpersonations")
  auditEntries     AdminAuditLog[]
  trustedDevices   AdminTrustedDevice[]
  authoredDevotionals   Devotional[]    @relation("DevotionalAuthor")
  approvedDevotionals   Devotional[]    @relation("DevotionalApprover")
  canceledSendBatches   SendBatch[]     @relation("BatchCanceledBy")
  devotionalRevisions   DevotionalRevision[]
  updatedSettings       Setting[]
  updatedFeatureFlags   FeatureFlag[]

  @@index([role], map: "ix_admin_users_role")
  @@index([lockedUntil], map: "ix_admin_users_locked")
  @@map("admin_users")
}

model AdminTrustedDevice {
  id          String    @id @db.Char(26)
  adminUserId String    @map("admin_user_id") @db.Char(26)
  tokenHash   String    @unique @map("token_hash") @db.Char(64)
  label       String?   @db.VarChar(80)
  createdAt   DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  expiresAt   DateTime  @map("expires_at") @db.Timestamptz(3)
  lastUsedAt  DateTime? @map("last_used_at") @db.Timestamptz(3)

  adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  @@index([adminUserId, expiresAt(sort: Desc)], map: "ix_admin_trusted_admin")
  @@index([expiresAt], map: "ix_admin_trusted_expires")
  @@map("admin_trusted_devices")
}

/// Esquema unico da trilha administrativa. Ver 6.9: nao existem actor_admin_id,
/// actor_id, target_type, target_id, before_json, after_json, actor_email,
/// created_at_utc nem created_at_brt.
model AdminAuditLog {
  id            String   @id @db.Char(26)
  adminUserId   String?  @map("admin_user_id") @db.Char(26)
  actorType     String   @default("ADMIN") @map("actor_type") @db.VarChar(20)
  actorRole     String   @map("actor_role") @db.VarChar(20)
  action        String   @db.VarChar(80)
  entityType    String?  @map("entity_type") @db.VarChar(60)
  entityId      String?  @map("entity_id") @db.Char(26)
  before        Json?    @db.JsonB
  after         Json?    @db.JsonB
  changedFields String[] @default([]) @map("changed_fields")
  metadata      Json?    @db.JsonB
  recordHash    String   @map("record_hash") @db.Char(64)
  reason        String?  @db.Text
  ip            String?  @db.Inet
  userAgent     String?  @map("user_agent") @db.Text
  requestId     String?  @map("request_id") @db.Char(26)
  sessionId     String?  @map("session_id") @db.Char(26)
  createdAt     DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: Restrict, onUpdate: Cascade)

  @@index([adminUserId, createdAt(sort: Desc)], map: "ix_audit_admin_time")
  @@index([entityType, entityId, createdAt(sort: Desc)], map: "ix_audit_entity")
  @@index([action, createdAt(sort: Desc)], map: "ix_audit_action_time")
  @@index([requestId], map: "ix_audit_request_id")
  @@index([actorType, createdAt(sort: Desc)], map: "ix_audit_actor_type_time")
  @@index([changedFields], type: Gin, map: "ix_audit_changed_fields")
  @@map("admin_audit_log")
}

// ---------------------------------------------------------------- cobrança

model Plan {
  id          String       @id @db.Char(26)
  code        String       @unique @db.VarChar(40)
  name        String       @db.VarChar(80)
  description String?      @db.VarChar(200)
  interval    PlanInterval
  amountCents Int          @map("amount_cents")
  currency    String       @default("BRL") @db.Char(3)
  tierGranted SubscriberTier @default(PAID) @map("tier_granted")
  asaasCycle  String       @map("asaas_cycle") @db.VarChar(20)
  isActive    Boolean      @default(true) @map("is_active")
  sortOrder   Int          @default(0) @map("sort_order") @db.SmallInt
  createdAt   DateTime     @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt   DateTime     @updatedAt @map("updated_at") @db.Timestamptz(3)

  subscriptions        Subscription[] @relation("SubscriptionPlan")
  pendingSubscriptions Subscription[] @relation("SubscriptionPendingPlan")

  @@index([isActive, sortOrder], map: "ix_plans_active_order")
  @@map("plans")
}

model Subscription {
  id                   String             @id @db.Char(26)
  subscriberId         String             @map("subscriber_id") @db.Char(26)
  planId               String             @map("plan_id") @db.Char(26)
  status               SubscriptionStatus @default(PENDING_PAYMENT)
  billingType          BillingType        @map("billing_type")
  amountCents          Int                @map("amount_cents")
  currency             String             @default("BRL") @db.Char(3)
  asaasCustomerId      String?            @map("asaas_customer_id") @db.VarChar(40)
  asaasSubscriptionId  String?            @unique @map("asaas_subscription_id") @db.VarChar(40)
  asaasCardToken       String?            @map("asaas_card_token") @db.VarChar(64)
  cardBrand            String?            @map("card_brand") @db.VarChar(20)
  cardLast4            String?            @map("card_last4") @db.Char(4)
  cardExpMonth         Int?               @map("card_exp_month") @db.SmallInt
  cardExpYear          Int?               @map("card_exp_year") @db.SmallInt
  pendingPlanId        String?            @map("pending_plan_id") @db.Char(26)
  pendingPlanEffectiveAt DateTime?        @map("pending_plan_effective_at") @db.Timestamptz(3)
  // cancelAtPeriodEnd e o sinalizador (qualquer cancelamento voluntario e o opt-out);
  // billingSuspendedAt e o carimbo, preenchido SO quando a origem foi o opt-out. Ver 6.11.
  cancelAtPeriodEnd    Boolean            @default(false) @map("cancel_at_period_end")
  billingSuspendedAt   DateTime?          @map("billing_suspended_at") @db.Timestamptz(3)
  endReason            String?            @map("end_reason") @db.VarChar(40)
  startedAt            DateTime?          @map("started_at") @db.Timestamptz(3)
  currentPeriodStart   DateTime?          @map("current_period_start") @db.Timestamptz(3)
  currentPeriodEnd     DateTime?          @map("current_period_end") @db.Timestamptz(3)
  nextDueDate          DateTime?          @map("next_due_date") @db.Date
  cancelRequestedAt    DateTime?          @map("cancel_requested_at") @db.Timestamptz(3)
  canceledAt           DateTime?          @map("canceled_at") @db.Timestamptz(3)
  cancelReason         String?            @map("cancel_reason") @db.VarChar(120)
  endsAt               DateTime?          @map("ends_at") @db.Timestamptz(3)
  expiredAt            DateTime?          @map("expired_at") @db.Timestamptz(3)
  refundedAt           DateTime?          @map("refunded_at") @db.Timestamptz(3)
  lastPaymentId        String?            @map("last_payment_id") @db.Char(26)
  reconciledAt         DateTime?          @map("reconciled_at") @db.Timestamptz(3)
  metadata             Json?              @db.JsonB
  createdAt            DateTime           @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt            DateTime           @updatedAt @map("updated_at") @db.Timestamptz(3)

  subscriber    Subscriber          @relation("SubscriberSubscriptions", fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  plan          Plan                @relation("SubscriptionPlan", fields: [planId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  pendingPlan   Plan?               @relation("SubscriptionPendingPlan", fields: [pendingPlanId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  lastPayment   Payment?            @relation("LastPayment", fields: [lastPaymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  payments      Payment[]           @relation("SubscriptionPayments")
  events        SubscriptionEvent[]
  paymentEvents PaymentEvent[]
  currentOf     Subscriber[]        @relation("CurrentSubscription")

  @@index([subscriberId, createdAt(sort: Desc)], map: "ix_subscriptions_subscriber_time")
  @@index([status, currentPeriodEnd], map: "ix_subscriptions_status_period_end")
  @@index([nextDueDate], map: "ix_subscriptions_next_due")
  @@index([reconciledAt], map: "ix_subscriptions_reconcile")
  @@index([planId], map: "ix_subscriptions_plan")
  @@index([billingSuspendedAt], map: "ix_subscriptions_billing_suspended")
  @@index([currentPeriodEnd], map: "ix_subscriptions_cancel_at_period_end")
  @@map("subscriptions")
}

model SubscriptionEvent {
  id             String                @id @db.Char(26)
  subscriptionId String                @map("subscription_id") @db.Char(26)
  subscriberId   String                @map("subscriber_id") @db.Char(26)
  type           SubscriptionEventType
  fromStatus     SubscriptionStatus?   @map("from_status")
  toStatus       SubscriptionStatus    @map("to_status")
  actor          String                @db.VarChar(40)
  actorId        String?               @map("actor_id") @db.Char(26)
  paymentId      String?               @map("payment_id") @db.Char(26)
  paymentEventId String?               @map("payment_event_id") @db.Char(26)
  cancelReason   String?               @map("cancel_reason") @db.VarChar(40)
  cancelComment  String?               @map("cancel_comment") @db.VarChar(500)
  reason         String?               @db.Text
  metadata       Json?                 @db.JsonB
  occurredAt     DateTime              @default(now()) @map("occurred_at") @db.Timestamptz(3)
  createdAt      DateTime              @default(now()) @map("created_at") @db.Timestamptz(3)

  subscription Subscription  @relation(fields: [subscriptionId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  subscriber   Subscriber    @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  payment      Payment?      @relation(fields: [paymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  paymentEvent PaymentEvent? @relation(fields: [paymentEventId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@index([subscriptionId, occurredAt(sort: Desc)], map: "ix_sub_events_subscription")
  @@index([subscriberId, occurredAt(sort: Desc)], map: "ix_sub_events_subscriber")
  @@index([type, occurredAt(sort: Desc)], map: "ix_sub_events_type_time")
  @@index([paymentId], map: "ix_sub_events_payment")
  @@index([cancelReason, occurredAt(sort: Desc)], map: "ix_sub_events_cancel_reason")
  @@map("subscription_events")
}

model Payment {
  id                    String        @id @db.Char(26)
  subscriptionId        String        @map("subscription_id") @db.Char(26)
  subscriberId          String        @map("subscriber_id") @db.Char(26)
  asaasPaymentId        String        @unique @map("asaas_payment_id") @db.VarChar(40)
  status                PaymentStatus @default(PENDING)
  billingType           BillingType   @map("billing_type")
  amountCents           Int           @map("amount_cents")
  netAmountCents        Int?          @map("net_amount_cents")
  feeCents              Int           @default(0) @map("fee_cents")
  refundedAmountCents   Int           @default(0) @map("refunded_amount_cents")
  currency              String        @default("BRL") @db.Char(3)
  dueDate               DateTime      @map("due_date") @db.Date
  confirmedAt           DateTime?     @map("confirmed_at") @db.Timestamptz(3)
  receivedAt            DateTime?     @map("received_at") @db.Timestamptz(3)
  creditDate            DateTime?     @map("credit_date") @db.Date
  invoiceUrl            String?       @map("invoice_url") @db.Text
  receiptUrl            String?       @map("receipt_url") @db.Text
  chargebackStage       String?       @map("chargeback_stage") @db.VarChar(20)
  pixPayload            String?       @map("pix_payload") @db.Text
  pixQrCodeBase64       String?       @map("pix_qr_code_base64") @db.Text
  pixExpiresAt          DateTime?     @map("pix_expires_at") @db.Timestamptz(3)
  cardBrand             String?       @map("card_brand") @db.VarChar(20)
  cardLast4             String?       @map("card_last4") @db.Char(4)
  failureCode           String?       @map("failure_code") @db.VarChar(40)
  failureMessage        String?       @map("failure_message") @db.Text
  description           String?       @db.VarChar(200)
  reconciledAt          DateTime?     @map("reconciled_at") @db.Timestamptz(3)
  createdAt             DateTime      @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt             DateTime      @updatedAt @map("updated_at") @db.Timestamptz(3)

  subscription      Subscription        @relation("SubscriptionPayments", fields: [subscriptionId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  subscriber        Subscriber          @relation(fields: [subscriberId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  lastPaymentOf     Subscription[]      @relation("LastPayment")
  paymentEvents     PaymentEvent[]
  subscriptionEvents SubscriptionEvent[]

  @@index([subscriptionId, createdAt(sort: Desc)], map: "ix_payments_subscription_time")
  @@index([subscriberId, createdAt(sort: Desc)], map: "ix_payments_subscriber_time")
  @@index([status, dueDate], map: "ix_payments_status_due")
  @@index([confirmedAt(sort: Desc)], map: "ix_payments_confirmed_at")
  @@index([pixExpiresAt], map: "ix_payments_pix_expiring")
  @@map("payments")
}

model PaymentEvent {
  id                  String                  @id @db.Char(26)
  asaasEventId        String                  @unique @map("asaas_event_id") @db.VarChar(60)
  eventType           String                  @map("event_type") @db.VarChar(60)
  asaasPaymentId      String?                 @map("asaas_payment_id") @db.VarChar(40)
  asaasSubscriptionId String?                 @map("asaas_subscription_id") @db.VarChar(40)
  paymentId           String?                 @map("payment_id") @db.Char(26)
  subscriptionId      String?                 @map("subscription_id") @db.Char(26)
  payload             Json                    @db.JsonB
  signatureValid      Boolean                 @map("signature_valid")
  processingStatus    WebhookProcessingStatus @default(RECEIVED) @map("processing_status")
  attempts            Int                     @default(0) @db.SmallInt
  lastError           String?                 @map("last_error") @db.Text
  nextRetryAt         DateTime?               @map("next_retry_at") @db.Timestamptz(3)
  processedAt         DateTime?               @map("processed_at") @db.Timestamptz(3)
  receivedAt          DateTime                @default(now()) @map("received_at") @db.Timestamptz(3)
  createdAt           DateTime                @default(now()) @map("created_at") @db.Timestamptz(3)

  payment            Payment?            @relation(fields: [paymentId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  subscription       Subscription?       @relation(fields: [subscriptionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  subscriptionEvents SubscriptionEvent[]

  @@index([nextRetryAt], map: "ix_payment_events_pending")
  @@index([asaasPaymentId], map: "ix_payment_events_asaas_payment")
  @@index([eventType, receivedAt(sort: Desc)], map: "ix_payment_events_type_time")
  @@index([receivedAt], map: "ix_payment_events_received_at")
  @@map("payment_events")
}

// ---------------------------------------------------------------- editorial e mídia

model Devotional {
  id                    String           @id @db.Char(26)
  slug                  String           @unique @db.VarChar(90)
  scheduledFor          DateTime         @unique @map("scheduled_for") @db.Date
  title                 String           @db.VarChar(120)
  bibleReference        String           @map("bible_reference") @db.VarChar(80)
  bibleText             String           @map("bible_text") @db.Text
  bibleVersion          String           @default("ALMEIDA_1911") @map("bible_version") @db.VarChar(40)
  reflectionMd          String           @map("reflection_md") @db.Text
  prayer                String           @db.Text
  teaser                String           @db.VarChar(300)
  status                DevotionalStatus @default(DRAFT)
  authorAdminId         String?          @map("author_admin_id") @db.Char(26)
  approvedByAdminId     String?          @map("approved_by_admin_id") @db.Char(26)
  approvedAt            DateTime?        @map("approved_at") @db.Timestamptz(3)
  publishedAt           DateTime?        @map("published_at") @db.Timestamptz(3)
  sentAt                DateTime?        @map("sent_at") @db.Timestamptz(3)
  coverImageKey         String?          @map("cover_image_key") @db.Text
  narrationCharCount    Int?             @map("narration_char_count")
  estimatedAudioSeconds Int?             @map("estimated_audio_seconds")
  internalNotes         String?          @map("internal_notes") @db.Text
  autoPublish           Boolean          @default(false) @map("auto_publish")
  isEvergreen           Boolean          @default(false) @map("is_evergreen")
  lastUsedAsFallbackAt  DateTime?        @map("last_used_as_fallback_at") @db.Timestamptz(3)
  tags                  String[]         @default([])
  version               Int              @default(1)
  deletedAt             DateTime?        @map("deleted_at") @db.Timestamptz(3)
  createdAt             DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt             DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)

  author           AdminUser?           @relation("DevotionalAuthor", fields: [authorAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  approvedBy       AdminUser?           @relation("DevotionalApprover", fields: [approvedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  revisions        DevotionalRevision[]
  audioAssets      AudioAsset[]
  mediaUploads     MediaUpload[]
  deliveryAttempts DeliveryAttempt[]
  sendBatches      SendBatch[]

  @@index([status, scheduledFor], map: "ix_devotionals_status_date")
  @@index([tags], type: Gin, map: "ix_devotionals_tags")
  @@index([isEvergreen, lastUsedAsFallbackAt], map: "ix_devotionals_evergreen")
  @@map("devotionals")
}

model DevotionalRevision {
  id               String            @id @db.Char(26)
  devotionalId     String            @map("devotional_id") @db.Char(26)
  revisionNumber   Int               @map("revision_number")
  changedByAdminId String?           @map("changed_by_admin_id") @db.Char(26)
  snapshot         Json              @db.JsonB
  changedFields    String[]          @default([]) @map("changed_fields")
  diffSummary      String?           @map("diff_summary") @db.Text
  origin           String            @default("EDIT") @db.VarChar(20)
  restoredFromRevision Int?          @map("restored_from_revision")
  statusBefore     DevotionalStatus? @map("status_before")
  statusAfter      DevotionalStatus  @map("status_after")
  createdAt        DateTime          @default(now()) @map("created_at") @db.Timestamptz(3)

  devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  changedBy  AdminUser? @relation(fields: [changedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@unique([devotionalId, revisionNumber], map: "uq_dev_rev_number")
  @@index([devotionalId, createdAt(sort: Desc)], map: "ix_dev_rev_devotional_time")
  @@index([changedByAdminId, createdAt(sort: Desc)], map: "ix_dev_rev_admin")
  @@map("devotional_revisions")
}

model AudioAsset {
  id                  String           @id @db.Char(26)
  devotionalId        String           @map("devotional_id") @db.Char(26)
  format              AudioFormat
  provider            AudioProvider
  voiceId             String           @map("voice_id") @db.VarChar(60)
  model               String?          @db.VarChar(60)
  status              AudioAssetStatus @default(PENDING)
  storageBucket       String?          @map("storage_bucket") @db.VarChar(80)
  storageKey          String?          @unique @map("storage_key") @db.Text
  byteSize            BigInt?          @map("byte_size")
  durationSeconds     Int?             @map("duration_seconds")
  sampleRate          Int?             @map("sample_rate")
  bitrateKbps         Int?             @map("bitrate_kbps") @db.SmallInt
  checksumSha256      String?          @map("checksum_sha256") @db.Char(64)
  // narrationScriptHash e o unico nome deste campo. Nao existe script_hash. Ver 6.17.
  narrationScriptHash String?          @map("narration_script_hash") @db.Char(64)
  chunkCount          Int              @default(1) @map("chunk_count") @db.SmallInt
  generationAttempts  Int              @default(0) @map("generation_attempts") @db.SmallInt
  qcFailedAt          DateTime?        @map("qc_failed_at") @db.Timestamptz(3)
  lastError           String?          @map("last_error") @db.Text
  charCount           Int              @default(0) @map("char_count")
  costMicros          BigInt?          @map("cost_micros")
  generatedAt         DateTime?        @map("generated_at") @db.Timestamptz(3)
  createdAt           DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt           DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)

  devotional   Devotional    @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  mediaUploads MediaUpload[]

  @@unique([devotionalId, format, voiceId], map: "uq_audio_dev_format_voice")
  @@index([devotionalId, status], map: "ix_audio_devotional")
  @@index([createdAt], map: "ix_audio_status_pending")
  @@index([narrationScriptHash], map: "ix_audio_script_hash")
  @@map("audio_assets")
}

model MediaUpload {
  id              String            @id @db.Char(26)
  devotionalId    String            @map("devotional_id") @db.Char(26)
  audioAssetId    String?           @map("audio_asset_id") @db.Char(26)
  purpose         MediaPurpose
  whatsappMediaId String?           @unique @map("whatsapp_media_id") @db.VarChar(80)
  phoneNumberId   String            @map("phone_number_id") @db.VarChar(40)
  mimeType        String            @map("mime_type") @db.VarChar(60)
  byteSize        BigInt            @map("byte_size")
  sha256          String            @db.Char(64)
  status          MediaUploadStatus @default(PENDING)
  attempts        Int               @default(0) @db.SmallInt
  lastError       String?           @map("last_error") @db.Text
  uploadedAt      DateTime?         @map("uploaded_at") @db.Timestamptz(3)
  expiresAt       DateTime?         @map("expires_at") @db.Timestamptz(3)
  lastUsedAt      DateTime?         @map("last_used_at") @db.Timestamptz(3)
  supersededAt    DateTime?         @map("superseded_at") @db.Timestamptz(3)
  useCount        Int               @default(0) @map("use_count")
  createdAt       DateTime          @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime          @updatedAt @map("updated_at") @db.Timestamptz(3)

  devotional Devotional  @relation(fields: [devotionalId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  audioAsset AudioAsset? @relation(fields: [audioAssetId], references: [id], onDelete: Cascade, onUpdate: Cascade)

  @@unique([audioAssetId, phoneNumberId, purpose], map: "uq_media_asset_phone")
  @@index([devotionalId, purpose, status], map: "ix_media_devotional_purpose")
  @@index([expiresAt], map: "ix_media_expiring")
  @@index([devotionalId, purpose], map: "ix_media_current")
  @@map("media_uploads")
}

// ---------------------------------------------------------------- mensageria e envio

model WhatsappTemplate {
  id                String            @id @db.Char(26)
  name              String            @db.VarChar(80)
  language          String            @default("pt_BR") @db.VarChar(10)
  category          TemplateCategory
  effectiveCategory TemplateCategory? @map("effective_category")
  status            TemplateStatus    @default(DRAFT)
  metaTemplateId    String?           @unique @map("meta_template_id") @db.VarChar(40)
  components        Json              @db.JsonB
  bodyText          String            @map("body_text") @db.Text
  paramCount        Int               @default(0) @map("param_count") @db.SmallInt
  headerType        String?           @map("header_type") @db.VarChar(20)
  qualityScore      String?           @map("quality_score") @db.VarChar(20)
  rejectedReason    String?           @map("rejected_reason") @db.Text
  version           Int               @default(1)
  isCurrent         Boolean           @default(false) @map("is_current")
  submittedAt       DateTime?         @map("submitted_at") @db.Timestamptz(3)
  approvedAt        DateTime?         @map("approved_at") @db.Timestamptz(3)
  lastSyncedAt      DateTime?         @map("last_synced_at") @db.Timestamptz(3)
  createdAt         DateTime          @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime          @updatedAt @map("updated_at") @db.Timestamptz(3)

  @@unique([name, language], map: "uq_wa_templates_name_lang")
  @@index([name], map: "ix_wa_templates_current")
  @@index([status], map: "ix_wa_templates_status")
  @@map("whatsapp_templates")
}

/// Tabela particionada por RANGE (created_at). Sem FKs de saída — ver Seção 6.19.4.
model MessageLog {
  id                   String           @db.Char(26)
  createdAt            DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  subscriberId         String?          @map("subscriber_id") @db.Char(26)
  direction            MessageDirection
  kind                 MessageKind
  status               MessageStatus    @default(QUEUED)
  devotionalId         String?          @map("devotional_id") @db.Char(26)
  deliveryAttemptId    String?          @map("delivery_attempt_id") @db.Char(26)
  templateId           String?          @map("template_id") @db.Char(26)
  templateName         String?          @map("template_name") @db.VarChar(80)
  wamid                String?          @db.VarChar(128)
  messageKey           String?          @map("message_key") @db.VarChar(60)
  // phoneE164 e waId sao envelope cifrado e ANULAVEIS: a anonimizacao por LGPD zera as
  // duas colunas, e a retencao aqui e de 18 meses. Ver 6.19.3.
  phoneE164            String?          @map("phone_e164") @db.Text
  phoneHmac            String?          @map("phone_hmac") @db.Char(64)
  waId                 String?          @map("wa_id") @db.Text
  mediaId              String?          @map("media_id") @db.VarChar(80)
  payloadExcerpt       String?          @map("payload_excerpt") @db.VarChar(240)
  errorCode            String?          @map("error_code") @db.VarChar(20)
  errorTitle           String?          @map("error_title") @db.VarChar(160)
  errorDetails         String?          @map("error_details") @db.Text
  conversationId       String?          @map("conversation_id") @db.VarChar(80)
  conversationCategory String?          @map("conversation_category") @db.VarChar(20)
  pricingModel         String?          @map("pricing_model") @db.VarChar(20)
  isBillable           Boolean          @default(false) @map("is_billable")
  costMicros           BigInt?          @map("cost_micros")
  sentAt               DateTime?        @map("sent_at") @db.Timestamptz(3)
  deliveredAt          DateTime?        @map("delivered_at") @db.Timestamptz(3)
  readAt               DateTime?        @map("read_at") @db.Timestamptz(3)
  failedAt             DateTime?        @map("failed_at") @db.Timestamptz(3)
  lateOpenDays         Int?             @map("late_open_days") @db.SmallInt
  requestId            String?          @map("request_id") @db.Char(26)

  @@id([id, createdAt])
  @@unique([wamid, createdAt], map: "uq_message_logs_wamid")
  @@index([subscriberId, createdAt(sort: Desc)], map: "ix_message_logs_subscriber_time")
  @@index([deliveryAttemptId], map: "ix_message_logs_attempt")
  @@index([status, createdAt(sort: Desc)], map: "ix_message_logs_status_time")
  @@index([errorCode, createdAt(sort: Desc)], map: "ix_message_logs_error_code")
  @@index([devotionalId, createdAt(sort: Desc)], map: "ix_message_logs_devotional")
  @@index([conversationId], map: "ix_message_logs_conversation")
  @@index([phoneHmac, createdAt(sort: Desc)], map: "ix_message_logs_phone_hmac")
  @@index([messageKey, createdAt(sort: Desc)], map: "ix_message_logs_message_key")
  @@map("message_logs")
}

model DeliveryAttempt {
  id               String                @id @db.Char(26)
  idempotencyKey   String                @unique @map("idempotency_key") @db.VarChar(120)
  sendBatchId      String?               @map("send_batch_id") @db.Char(26)
  subscriberId     String                @map("subscriber_id") @db.Char(26)
  devotionalId     String                @map("devotional_id") @db.Char(26)
  devotionalDate   DateTime              @map("devotional_date") @db.Date
  step             DeliveryStep
  status           DeliveryAttemptStatus @default(PLANNED)
  tierAtSend       SubscriberTier        @map("tier_at_send")
  tierAtSendEffective SubscriberTier?    @map("tier_at_send_effective")
  downgradedAt     DateTime?             @map("downgraded_at") @db.Timestamptz(3)
  origin           String                @default("DAILY_BATCH") @db.VarChar(20)
  stepsCompleted   Int                   @default(0) @map("steps_completed") @db.SmallInt
  audioStatus      String                @default("NOT_APPLICABLE") @map("audio_status") @db.VarChar(20)
  manualResendCount Int                  @default(0) @map("manual_resend_count") @db.SmallInt
  attemptCount     Int                   @default(0) @map("attempt_count") @db.SmallInt
  maxAttempts      Int                   @default(5) @map("max_attempts") @db.SmallInt
  // nextRetryAt e o unico nome deste campo. Nao existe next_attempt_at. Ver 6.20.
  nextRetryAt      DateTime?             @map("next_retry_at") @db.Timestamptz(3)
  lockedAt         DateTime?             @map("locked_at") @db.Timestamptz(3)
  lastErrorCode    String?               @map("last_error_code") @db.VarChar(20)
  lastErrorMessage String?               @map("last_error_message") @db.Text
  deferredReason   String?               @map("deferred_reason") @db.VarChar(60)
  messageLogId     String?               @map("message_log_id") @db.Char(26)
  plannedAt        DateTime              @default(now()) @map("planned_at") @db.Timestamptz(3)
  startedAt        DateTime?             @map("started_at") @db.Timestamptz(3)
  completedAt      DateTime?             @map("completed_at") @db.Timestamptz(3)
  createdAt        DateTime              @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt        DateTime              @updatedAt @map("updated_at") @db.Timestamptz(3)

  subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  devotional Devotional @relation(fields: [devotionalId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  sendBatch  SendBatch? @relation(fields: [sendBatchId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@unique([subscriberId, devotionalDate, step], map: "uq_delivery_sub_date_step")
  @@index([nextRetryAt, id], map: "ix_delivery_pending")
  @@index([sendBatchId, status], map: "ix_delivery_batch_status")
  @@index([devotionalDate, status], map: "ix_delivery_date_status")
  @@index([subscriberId, devotionalDate(sort: Desc)], map: "ix_delivery_subscriber_date")
  @@index([lastErrorCode, devotionalDate], map: "ix_delivery_error_code")
  @@index([downgradedAt], map: "ix_delivery_downgraded")
  @@index([lockedAt], map: "ix_delivery_locked")
  @@map("delivery_attempts")
}

model InboundMessage {
  id               String        @id @db.Char(26)
  wamid            String        @unique @db.VarChar(128)
  subscriberId     String?       @map("subscriber_id") @db.Char(26)
  waId             String        @map("wa_id") @db.Text
  waIdHmac         String        @map("wa_id_hmac") @db.Char(64)
  phoneE164        String?       @map("phone_e164") @db.Text
  phoneHmac        String?       @map("phone_hmac") @db.Char(64)
  kind             MessageKind
  textBody         String?       @map("text_body") @db.Text
  supportProtocol  String?       @map("support_protocol") @db.VarChar(20)
  buttonPayload    String?       @map("button_payload") @db.VarChar(60)
  interactiveId    String?       @map("interactive_id") @db.VarChar(60)
  mediaId          String?       @map("media_id") @db.VarChar(80)
  intent           InboundIntent @default(UNKNOWN)
  intentConfidence Int           @default(100) @map("intent_confidence") @db.SmallInt
  raw              Json          @db.JsonB
  isHandled        Boolean       @default(false) @map("is_handled")
  handledAt        DateTime?     @map("handled_at") @db.Timestamptz(3)
  handlingError    String?       @map("handling_error") @db.Text
  receivedAt       DateTime      @default(now()) @map("received_at") @db.Timestamptz(3)
  createdAt        DateTime      @default(now()) @map("created_at") @db.Timestamptz(3)

  subscriber Subscriber? @relation(fields: [subscriberId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@index([createdAt], map: "ix_inbound_unhandled")
  @@index([subscriberId, receivedAt(sort: Desc)], map: "ix_inbound_subscriber_time")
  @@index([waIdHmac, receivedAt(sort: Desc)], map: "ix_inbound_wa_id_time")
  @@index([intent, receivedAt(sort: Desc)], map: "ix_inbound_intent_time")
  @@index([supportProtocol], map: "ix_inbound_support_protocol")
  @@map("inbound_messages")
}

model SendBatch {
  id              String          @id @db.Char(26)
  idempotencyKey  String          @unique @map("idempotency_key") @db.VarChar(80)
  devotionalId    String          @map("devotional_id") @db.Char(26)
  // A unicidade de devotionalDate e PARCIAL (batch_kind = 'DAILY') e vive em M10, nao
  // aqui: o Prisma nao representa indice parcial. Ver 6.22.1 e 6.31.2.
  devotionalDate  DateTime        @map("devotional_date") @db.Date
  status          SendBatchStatus @default(PLANNING)
  // batchKind e o unico nome deste campo. Nao existe send_batches.kind. Ver 6.22.
  batchKind       String          @default("DAILY") @map("batch_kind") @db.VarChar(20)
  parentBatchId   String?         @map("parent_batch_id") @db.Char(26)
  canceledBy      String?         @map("canceled_by") @db.Char(26)
  canceledReason  String?         @map("canceled_reason") @db.Text
  tierLimitAtPlan Int?            @map("tier_limit_at_plan")
  templateUsed    String?         @map("template_used") @db.VarChar(80)
  truncatedCount  Int             @default(0) @map("truncated_count")
  fallbackUsed    Boolean         @default(false) @map("fallback_used")
  fallbackReason  String?         @map("fallback_reason") @db.VarChar(40)
  haltedErrorCode String?         @map("halted_error_code") @db.VarChar(20)
  plannedCount    Int             @default(0) @map("planned_count")
  queuedCount     Int             @default(0) @map("queued_count")
  sentCount       Int             @default(0) @map("sent_count")
  deliveredCount  Int             @default(0) @map("delivered_count")
  readCount       Int             @default(0) @map("read_count")
  failedCount     Int             @default(0) @map("failed_count")
  skippedCount    Int             @default(0) @map("skipped_count")
  deferredCount   Int             @default(0) @map("deferred_count")
  freeCount       Int             @default(0) @map("free_count")
  paidCount       Int             @default(0) @map("paid_count")
  startedAt       DateTime?       @map("started_at") @db.Timestamptz(3)
  finishedAt      DateTime?       @map("finished_at") @db.Timestamptz(3)
  durationMs      Int?            @map("duration_ms")
  plannedBy       String          @default("scheduler") @map("planned_by") @db.VarChar(40)
  error           String?         @db.Text
  createdAt       DateTime        @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime        @updatedAt @map("updated_at") @db.Timestamptz(3)

  devotional       Devotional        @relation(fields: [devotionalId], references: [id], onDelete: Restrict, onUpdate: Cascade)
  parentBatch      SendBatch?        @relation("BatchRetry", fields: [parentBatchId], references: [id], onDelete: SetNull, onUpdate: Cascade)
  retryBatches     SendBatch[]       @relation("BatchRetry")
  canceledByAdmin  AdminUser?        @relation("BatchCanceledBy", fields: [canceledBy], references: [id], onDelete: SetNull, onUpdate: Cascade)
  deliveryAttempts DeliveryAttempt[]

  @@index([status, devotionalDate(sort: Desc)], map: "ix_send_batches_status")
  @@index([parentBatchId], map: "ix_send_batches_parent")
  @@map("send_batches")
}

// ---------------------------------------------------------------- config, métricas, operação

model Setting {
  key               String           @id @db.VarChar(80)
  value             Json             @db.JsonB
  valueType         SettingValueType @map("value_type")
  defaultValue      Json             @map("default_value") @db.JsonB
  description       String           @db.VarChar(300)
  groupName         String           @map("group_name") @db.VarChar(40)
  isSecret          Boolean          @default(false) @map("is_secret")
  editableBy        Role             @default(ADMIN) @map("editable_by")
  minValue          Decimal?         @map("min_value") @db.Decimal(20, 4)
  maxValue          Decimal?         @map("max_value") @db.Decimal(20, 4)
  allowedValues     Json?            @map("allowed_values") @db.JsonB
  version           Int              @default(1)
  updatedByAdminId  String?          @map("updated_by_admin_id") @db.Char(26)
  createdAt         DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)

  updatedBy AdminUser? @relation(fields: [updatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@index([groupName, key], map: "ix_settings_group")
  @@index([updatedAt(sort: Desc)], map: "ix_settings_updated_at")
  @@map("settings")
}

model FeatureFlag {
  key               String          @id @db.VarChar(60)
  description       String          @db.VarChar(300)
  isEnabled         Boolean         @default(false) @map("is_enabled")
  rolloutPercentage Int             @default(0) @map("rollout_percentage") @db.SmallInt
  targetTier        SubscriberTier? @map("target_tier")
  updatedByAdminId  String?         @map("updated_by_admin_id") @db.Char(26)
  createdAt         DateTime        @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime        @updatedAt @map("updated_at") @db.Timestamptz(3)

  updatedBy AdminUser? @relation(fields: [updatedByAdminId], references: [id], onDelete: SetNull, onUpdate: Cascade)

  @@index([isEnabled], map: "ix_feature_flags_enabled")
  @@map("feature_flags")
}

/// Formato longo: uma linha por (dia, metrica, dimensao). Ver 6.25.
/// Nao existe versao de formato largo com uma coluna por metrica.
model DailyMetric {
  id            String    @id @db.Char(26)
  metricDate    DateTime  @map("metric_date") @db.Date
  metricKey     String    @map("metric_key") @db.Text
  dimension     String    @default("") @db.Text
  value         Decimal?  @db.Decimal(18, 6)
  numerator     Decimal?  @db.Decimal(18, 6)
  denominator   Decimal?  @db.Decimal(18, 6)
  rollupVersion Int       @default(1) @map("rollup_version") @db.SmallInt
  isFinal       Boolean   @default(false) @map("is_final")
  computedAt    DateTime  @default(now()) @map("computed_at") @db.Timestamptz(3)
  createdAt     DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt     DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)

  @@unique([metricDate, metricKey, dimension], map: "daily_metrics_unique")
  @@index([metricKey, metricDate(sort: Desc)], map: "daily_metrics_key_date_idx")
  @@index([metricDate(sort: Desc)], map: "daily_metrics_date_idx")
  @@index([metricDate], map: "daily_metrics_not_final_idx")
  @@map("daily_metrics")
}

model WebhookDelivery {
  id               String                  @id @db.Char(26)
  direction        WebhookDirection
  source           WebhookSource
  endpoint         String                  @db.Text
  httpMethod       String                  @default("POST") @map("http_method") @db.VarChar(10)
  httpStatus       Int?                    @map("http_status") @db.SmallInt
  eventType        String?                 @map("event_type") @db.VarChar(80)
  // dedupKey e source sao os unicos nomes destes campos. Nao existem external_id
  // nem provider nesta tabela. Ver 6.26.
  dedupKey         String?                 @map("dedup_key") @db.VarChar(160)
  duplicateCount   Int                     @default(0) @map("duplicate_count")
  signatureHeader  String?                 @map("signature_header") @db.Text
  isSignatureValid Boolean                 @default(false) @map("is_signature_valid")
  bodySha256       String                  @map("body_sha256") @db.Char(64)
  body             Json?                   @db.JsonB
  bodyRawSize      Int                     @default(0) @map("body_raw_size")
  headers          Json?                   @db.JsonB
  processingStatus WebhookProcessingStatus  @default(RECEIVED) @map("processing_status")
  processingResult WebhookProcessingResult? @map("processing_result")
  attempts         Int                     @default(0) @db.SmallInt
  nextRetryAt      DateTime?               @map("next_retry_at") @db.Timestamptz(3)
  lastError        String?                 @map("last_error") @db.Text
  latencyMs        Int?                    @map("latency_ms")
  requestId        String?                 @map("request_id") @db.Char(26)
  receivedAt       DateTime                @default(now()) @map("received_at") @db.Timestamptz(3)
  processedAt      DateTime?               @map("processed_at") @db.Timestamptz(3)
  createdAt        DateTime                @default(now()) @map("created_at") @db.Timestamptz(3)

  @@unique([source, dedupKey], map: "uq_webhook_dedup")
  @@index([source, receivedAt(sort: Desc)], map: "ix_webhook_source_time")
  @@index([nextRetryAt], map: "ix_webhook_pending")
  @@index([bodySha256, receivedAt(sort: Desc)], map: "ix_webhook_body_hash")
  @@index([receivedAt], map: "ix_webhook_received_at")
  @@map("webhook_deliveries")
}

model JobRun {
  id             String       @id @db.Char(26)
  jobName        String       @map("job_name") @db.VarChar(60)
  queue          String       @db.VarChar(40)
  bullJobId      String?      @map("bull_job_id") @db.VarChar(80)
  status         JobRunStatus @default(RUNNING)
  scheduledFor   DateTime?    @map("scheduled_for") @db.Timestamptz(3)
  startedAt      DateTime     @default(now()) @map("started_at") @db.Timestamptz(3)
  finishedAt     DateTime?    @map("finished_at") @db.Timestamptz(3)
  durationMs     Int?         @map("duration_ms")
  itemsProcessed Int          @default(0) @map("items_processed")
  itemsFailed    Int          @default(0) @map("items_failed")
  attempt        Int          @default(1) @db.SmallInt
  payload        Json?        @db.JsonB
  result         Json?        @db.JsonB
  // error e o unico nome da mensagem de falha. Nao existe error_message. Ver 6.28.
  error          String?      @db.Text
  errorCode      String?      @map("error_code") @db.VarChar(60)
  requestId      String?      @map("request_id") @db.Char(26)
  createdAt      DateTime     @default(now()) @map("created_at") @db.Timestamptz(3)

  @@unique([jobName, scheduledFor], map: "uq_job_runs_scheduled")
  @@index([jobName, startedAt(sort: Desc)], map: "ix_job_runs_name_time")
  @@index([startedAt(sort: Desc)], map: "ix_job_runs_failed")
  @@index([queue, startedAt(sort: Desc)], map: "ix_job_runs_dead")
  @@index([queue, errorCode], map: "ix_job_runs_dead_code")
  @@index([startedAt], map: "ix_job_runs_running_stale")
  @@map("job_runs")
}

6.30 DDL SQL das tabelas críticas #

Trechos de SQL que o Prisma não gera e que precisam existir literalmente nos arquivos de migration. As demais tabelas são criadas pelo Prisma a partir de 6.29.

6.30.1 subscribers — índices parciais e CHECKs #

-- Identidade e unicidade vivem nas colunas de indice cego, nunca nas cifradas.
CREATE UNIQUE INDEX uq_subscribers_phone_hmac
  ON subscribers (phone_hmac) WHERE deleted_at IS NULL;

CREATE UNIQUE INDEX uq_subscribers_wa_id_hmac
  ON subscribers (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL;

CREATE UNIQUE INDEX uq_subscribers_email_hmac
  ON subscribers (email_hmac) WHERE email_hmac IS NOT NULL AND deleted_at IS NULL;

CREATE UNIQUE INDEX uq_subscribers_asaas_customer
  ON subscribers (asaas_customer_id) WHERE asaas_customer_id IS NOT NULL;

CREATE INDEX ix_subscribers_send_eligible
  ON subscribers (tier, status)
  WHERE opt_in_confirmed_at IS NOT NULL
    AND opt_out_at IS NULL
    AND deleted_at IS NULL
    AND blocked_at IS NULL;

CREATE INDEX ix_subscribers_window_misses
  ON subscribers (consecutive_window_misses)
  WHERE tier = 'PAID' AND consecutive_window_misses >= 3;

CREATE INDEX ix_subscribers_paused_until
  ON subscribers (paused_until) WHERE paused_until IS NOT NULL;

CREATE INDEX ix_subscribers_courtesy_until
  ON subscribers (courtesy_until) WHERE courtesy_until IS NOT NULL;

CREATE INDEX ix_subscribers_dormant
  ON subscribers (last_inbound_at NULLS FIRST, last_login_at NULLS FIRST)
  WHERE deleted_at IS NULL;

CREATE INDEX ix_subscribers_billing_blocked
  ON subscribers (billing_blocked_at) WHERE billing_blocked_at IS NOT NULL;

ALTER TABLE subscribers
  ADD CONSTRAINT chk_subscribers_id_ulid CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
  -- Nao existe CHECK de formato E.164 aqui: a coluna guarda envelope cifrado e a
  -- validacao de formato e do Zod, antes de cifrar (6.1.8).
  ADD CONSTRAINT chk_subscribers_phone_envelope CHECK (phone_e164 ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_phone_hmac CHECK (phone_hmac ~ '^[0-9a-f]{64}$'),
  ADD CONSTRAINT chk_subscribers_wa_id_envelope
      CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_wa_id_pair
      CHECK ((wa_id IS NULL) = (wa_id_hmac IS NULL)),
  ADD CONSTRAINT chk_subscribers_email_envelope
      CHECK (email IS NULL OR email ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_subscribers_email_pair
      CHECK ((email IS NULL) = (email_hmac IS NULL)),
  ADD CONSTRAINT chk_subscribers_status_optout
      CHECK ((status = 'OPTED_OUT') = (opt_out_at IS NOT NULL
                                       AND blocked_at IS NULL
                                       AND deleted_at IS NULL)),
  ADD CONSTRAINT chk_subscribers_status_blocked
      CHECK ((status = 'BLOCKED') = (blocked_at IS NOT NULL AND deleted_at IS NULL)),
  ADD CONSTRAINT chk_subscribers_status_paused
      CHECK (status <> 'PAUSED' OR paused_until IS NOT NULL),
  ADD CONSTRAINT chk_subscribers_status_tier_matches
      CHECK ((status <> 'ACTIVE_PAID' OR tier = 'PAID')
         AND (status <> 'ACTIVE_FREE' OR tier = 'FREE'));

ALTER TABLE subscribers
  DROP CONSTRAINT subscribers_current_subscription_id_fkey,
  ADD CONSTRAINT subscribers_current_subscription_id_fkey
      FOREIGN KEY (current_subscription_id) REFERENCES subscriptions(id)
      ON DELETE SET NULL ON UPDATE CASCADE
      DEFERRABLE INITIALLY DEFERRED;

-- subscriber_profiles: o indice de CPF e deliberadamente NAO unico (6.4.1).
CREATE INDEX ix_subscriber_profiles_cpf_hmac
  ON subscriber_profiles (cpf_hmac) WHERE cpf_hmac IS NOT NULL;

ALTER TABLE subscriber_profiles
  ADD CONSTRAINT chk_profiles_cpf_envelope CHECK (cpf IS NULL OR cpf ~ '^v[0-9]+:'),
  ADD CONSTRAINT chk_profiles_cpf_pair CHECK ((cpf IS NULL) = (cpf_hmac IS NULL));

As três colunas cifradas de subscribers e a de subscriber_profiles nascem assim na primeira migration que cria as tabelas. Não são endurecimento posterior. Adiá-las para um marco de segurança obrigaria a reescrever, depois, toda consulta por telefone escrita até lá — e é justamente esse tipo de retrabalho que a decisão evita.

6.30.2 message_logs — criação particionada #

O Prisma cria a tabela como comum. A migration substitui a definição antes de qualquer dado existir.

DROP TABLE IF EXISTS message_logs;

CREATE TABLE message_logs (
  id                    char(26)          NOT NULL,
  created_at            timestamptz       NOT NULL DEFAULT now(),
  subscriber_id         char(26),
  direction             "MessageDirection" NOT NULL,
  kind                  "MessageKind"      NOT NULL,
  status                "MessageStatus"    NOT NULL DEFAULT 'QUEUED',
  devotional_id         char(26),
  delivery_attempt_id   char(26),
  template_id           char(26),
  template_name         varchar(80),
  wamid                 varchar(128),
  message_key           varchar(60),
  -- phone_e164 e wa_id sao envelope cifrado e ANULAVEIS: a anonimizacao por LGPD
  -- precisa zera-las, e a retencao desta tabela e de 18 meses (6.19.3).
  phone_e164            text,
  phone_hmac            char(64),
  wa_id                 text,
  media_id              varchar(80),
  payload_excerpt       varchar(240),
  error_code            varchar(20),
  error_title           varchar(160),
  error_details         text,
  conversation_id       varchar(80),
  conversation_category varchar(20),
  pricing_model         varchar(20),
  is_billable           boolean           NOT NULL DEFAULT false,
  cost_micros           bigint,
  sent_at               timestamptz,
  delivered_at          timestamptz,
  read_at               timestamptz,
  failed_at             timestamptz,
  late_open_days        smallint,
  request_id            char(26),
  CONSTRAINT message_logs_pkey PRIMARY KEY (id, created_at),
  CONSTRAINT chk_msg_phone_envelope CHECK (phone_e164 IS NULL OR phone_e164 ~ '^v[0-9]+:'),
  CONSTRAINT chk_msg_phone_hmac CHECK (phone_hmac IS NULL OR phone_hmac ~ '^[0-9a-f]{64}$'),
  CONSTRAINT chk_msg_wa_id_envelope CHECK (wa_id IS NULL OR wa_id ~ '^v[0-9]+:'),
  CONSTRAINT chk_msg_failed_needs_code CHECK (status <> 'FAILED' OR error_code IS NOT NULL),
  CONSTRAINT chk_msg_cost CHECK (cost_micros IS NULL OR cost_micros >= 0)
) PARTITION BY RANGE (created_at);

CREATE UNIQUE INDEX uq_message_logs_wamid
  ON message_logs (wamid, created_at) WHERE wamid IS NOT NULL;
CREATE INDEX ix_message_logs_subscriber_time
  ON message_logs (subscriber_id, created_at DESC) WHERE subscriber_id IS NOT NULL;
CREATE INDEX ix_message_logs_status_time  ON message_logs (status, created_at DESC);
CREATE INDEX ix_message_logs_error_code   ON message_logs (error_code, created_at DESC)
  WHERE error_code IS NOT NULL;
CREATE INDEX ix_message_logs_attempt      ON message_logs (delivery_attempt_id)
  WHERE delivery_attempt_id IS NOT NULL;
CREATE INDEX ix_message_logs_phone_hmac   ON message_logs (phone_hmac, created_at DESC)
  WHERE phone_hmac IS NOT NULL;
CREATE INDEX ix_message_logs_message_key  ON message_logs (message_key, created_at DESC)
  WHERE message_key IS NOT NULL;

-- partição de escape: nada fica sem lugar se o job de criação falhar
CREATE TABLE message_logs_default PARTITION OF message_logs DEFAULT;

6.30.3 Função de criação automática de partições #

CREATE OR REPLACE FUNCTION ensure_message_logs_partition(target date)
RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
  start_ts timestamptz := date_trunc('month', target)::timestamptz;
  end_ts   timestamptz := (date_trunc('month', target) + interval '1 month')::timestamptz;
  part     text := format('message_logs_%s', to_char(start_ts, 'YYYY_MM'));
BEGIN
  IF to_regclass(part) IS NOT NULL THEN
    RETURN part || ' (já existia)';
  END IF;
  EXECUTE format(
    'CREATE TABLE %I PARTITION OF message_logs FOR VALUES FROM (%L) TO (%L)',
    part, start_ts, end_ts);
  RETURN part || ' (criada)';
END;
$$;

O job db.ensure_partitions roda diariamente às 03:00 e chama a função para o mês corrente e para os dois meses seguintes. Dois meses de folga garantem que uma falha de job por vários dias não derrube a escrita. A partição DEFAULT é a rede de segurança: linha que caia nela dispara alerta imediato.

6.30.4 delivery_attempts — índices que sustentam o motor de envio #

CREATE INDEX ix_delivery_pending
  ON delivery_attempts (next_retry_at NULLS FIRST, id)
  WHERE status IN ('PLANNED','DEFERRED','FAILED') AND attempt_count < max_attempts;

CREATE INDEX ix_delivery_downgraded
  ON delivery_attempts (downgraded_at) WHERE downgraded_at IS NOT NULL;

-- Sustenta a varredura de reparo das 09:00: itens reivindicados e abandonados.
CREATE INDEX ix_delivery_locked
  ON delivery_attempts (locked_at) WHERE status = 'IN_FLIGHT';

ALTER TABLE delivery_attempts
  -- Guarda do planejamento.
  ADD CONSTRAINT chk_delivery_audio_step_paid
      CHECK (step <> 'AUDIO' OR tier_at_send = 'PAID'),
  -- Guarda do disparo: o tier e relido no instante do envio, nunca so no planejamento.
  ADD CONSTRAINT chk_delivery_audio_step_paid_effective
      CHECK (step <> 'AUDIO' OR status <> 'SUCCEEDED' OR tier_at_send_effective = 'PAID'),
  ADD CONSTRAINT chk_delivery_downgrade_pair
      CHECK (downgraded_at IS NULL
             OR (tier_at_send = 'PAID' AND tier_at_send_effective = 'FREE'));

Com 4 milhões de linhas na tabela e poucos milhares pendentes, ix_delivery_pending ocupa alguns megabytes em vez de centenas. O índice parcial é o que torna a fila viável em um único servidor.

6.30.5 subscriptions — a regra "uma assinatura viva por assinante" #

CREATE UNIQUE INDEX uq_subscriptions_one_live_per_subscriber
  ON subscriptions (subscriber_id)
  WHERE status IN ('PENDING_PAYMENT','ACTIVE');

CREATE INDEX ix_subscriptions_cancel_at_period_end
  ON subscriptions (current_period_end) WHERE cancel_at_period_end = true;

-- Um lote DIARIO por dia de calendario. O lote RETRY compartilha a data de proposito,
-- e a unicidade dele vem de uq_send_batches_idempotency (6.22.1).
CREATE UNIQUE INDEX uq_send_batches_date
  ON send_batches (devotional_date) WHERE batch_kind = 'DAILY';

CREATE INDEX ix_send_batches_parent
  ON send_batches (parent_batch_id) WHERE parent_batch_id IS NOT NULL;

Duas abas de checkout abertas ao mesmo tempo produzem duas requisições. A segunda viola este índice, o repositório traduz o erro 23505 do Postgres para SUBSCRIPTION_ALREADY_ACTIVE e o cliente é redirecionado para a assinatura existente. Nenhuma cobrança duplicada é criada no provedor porque a criação remota só acontece depois do INSERT local bem-sucedido, dentro da mesma transação lógica.

6.30.6 Proteção append-only #

O gatilho de imutabilidade não pode ser um gatilho cego. Se ele recusar toda escrita, ele impede também as duas operações que a própria lei obriga: a anonimização por pedido de eliminação, que precisa zerar IP e user-agent, e o expurgo por retenção, que precisa apagar linhas fora do prazo de 5 anos. Um controle de integridade que impede o cumprimento de um direito do titular não é um controle: é um defeito com aparência de rigor.

A solução também não é desabilitar o gatilho na transação. ALTER TABLE ... DISABLE TRIGGER exige ser dono da tabela, o que o papel de privacidade não é, e abriria uma janela em que qualquer escrita passa. O gatilho nunca é desabilitado. Em vez disso, ele reconhece dois papéis nomeados e verifica, coluna a coluna, que a operação é exatamente a permitida — o conteúdo probatório do consentimento continua imutável mesmo para eles.

CREATE ROLE privacy_operator LOGIN;
CREATE ROLE retention_operator LOGIN;

CREATE OR REPLACE FUNCTION consent_events_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  -- Excecao 1: eliminacao por LGPD, restrita a zerar identificadores de rede.
  IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
     AND NEW.id = OLD.id AND NEW.subscriber_id = OLD.subscriber_id
     AND NEW.type = OLD.type AND NEW.granted = OLD.granted
     AND NEW.policy_version = OLD.policy_version
     AND NEW.consent_text_hash = OLD.consent_text_hash
     AND NEW.occurred_at = OLD.occurred_at AND NEW.created_at = OLD.created_at
     AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
    RETURN NEW;
  END IF;
  -- Excecao 2: expurgo por retencao, restrito a linhas fora do prazo legal.
  IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
     AND OLD.created_at < now() - interval '5 years' THEN
    RETURN OLD;
  END IF;
  RAISE EXCEPTION 'tabela % é append-only: % não permitido', TG_TABLE_NAME, TG_OP
    USING ERRCODE = '42501';
END;
$$;

CREATE TRIGGER trg_consent_events_append_only
  BEFORE UPDATE OR DELETE ON consent_events
  FOR EACH ROW EXECUTE FUNCTION consent_events_immutable();

-- Mesmo padrao, mesmos dois papeis, para a trilha administrativa.
CREATE OR REPLACE FUNCTION admin_audit_log_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
     AND NEW.id = OLD.id
     -- IS NOT DISTINCT FROM porque admin_user_id e anulavel (acao do sistema ou do
     -- proprio titular): com "=" a comparacao daria NULL e a excecao nunca valeria.
     AND NEW.admin_user_id IS NOT DISTINCT FROM OLD.admin_user_id
     AND NEW.actor_type = OLD.actor_type AND NEW.actor_role = OLD.actor_role
     AND NEW.action = OLD.action AND NEW.created_at = OLD.created_at
     AND NEW.record_hash = OLD.record_hash
     AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
    RETURN NEW;
  END IF;
  IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
     AND OLD.created_at < now() - interval '5 years' THEN
    RETURN OLD;
  END IF;
  RAISE EXCEPTION 'tabela % é append-only: % não permitido', TG_TABLE_NAME, TG_OP
    USING ERRCODE = '42501';
END;
$$;

CREATE TRIGGER trg_admin_audit_append_only
  BEFORE UPDATE OR DELETE ON admin_audit_log
  FOR EACH ROW EXECUTE FUNCTION admin_audit_log_immutable();

REVOKE UPDATE, DELETE ON consent_events  FROM palavra_diaria_app;
REVOKE UPDATE, DELETE ON admin_audit_log FROM palavra_diaria_app;
GRANT  INSERT, SELECT ON consent_events  TO palavra_diaria_app;
GRANT  INSERT, SELECT ON admin_audit_log TO palavra_diaria_app;
GRANT  SELECT, UPDATE ON consent_events, admin_audit_log TO privacy_operator;
GRANT  SELECT, DELETE ON consent_events, admin_audit_log TO retention_operator;

Dois mecanismos redundantes de propósito: o REVOKE protege contra o caminho normal, o gatilho protege contra um GRANT acidental em manutenção futura. As duas exceções são caminhos privilegiados explícitos e auditados: cada uma exige um papel nomeado próprio, usado por um único job, e cada execução grava a ação correspondente em admin_audit_log (subscriber.anonymized e o expurgo de retenção). Um teste de integração executa a anonimização e o expurgo de ponta a ponta contra um banco com o gatilho ativo, e falha se qualquer um lançar exceção — e falha também se um UPDATE que altere type, granted, consent_text_hash ou occurred_at for aceito com o papel privacy_operator.

6.31 Ordem de migrations #

Migrations aplicadas por prisma migrate deploy no start do contêiner web, com lock distribuído (Seção 25). A numeração é a ordem cronológica dos diretórios em prisma/migrations/.

Migration Nome Conteúdo
M1 init_extensions CREATE EXTENSION para citext, pgcrypto, pg_trgm, btree_gin, unaccent. Criação dos papéis palavra_diaria_app, palavra_diaria_owner, privacy_operator e retention_operator.
M2 init_enums Todos os 35 tipos enum de 6.1.6. Isolado por causa da restrição do Postgres a ALTER TYPE ... ADD VALUE na mesma transação.
M3 identity_tables subscribers, subscriber_profiles, consent_events, já com as colunas cifradas e as colunas de índice cego (phone_hmac, wa_id_hmac, email_hmac, cpf_hmac). FK circular de subscribers fica sem criar.
M4 auth_tables admin_users, sessions, otp_codes, unsubscribe_tokens, admin_audit_log, admin_trusted_devices.
M5 billing_tables plans, subscriptions, subscription_events, payments, payment_events. Cria a FK circular subscribers.current_subscription_id como DEFERRABLE INITIALLY DEFERRED e a FK subscriptions.last_payment_id.
M6 editorial_tables devotionals, devotional_revisions, audio_assets, media_uploads.
M7 messaging_tables whatsapp_templates, inbound_messages, send_batches, delivery_attempts.
M8 message_logs_partitioned Recria message_logs como tabela particionada (6.30.2), cria ensure_message_logs_partition() (6.30.3), a partição DEFAULT e as partições do mês corrente e dos dois seguintes.
M9 ops_tables settings, feature_flags, daily_metrics, webhook_deliveries, job_runs.
M10 partial_indexes Todos os índices parciais listados em 6.3.1 a 6.28.1 que o Prisma não representa.
M11 check_constraints Todos os CHECK das subseções 6.3.3 a 6.28.2, incluindo os chk_<tabela>_id_ulid.
M12 append_only_guards Funções consent_events_immutable() e admin_audit_log_immutable(), os dois triggers, os REVOKE e os GRANT dos papéis privacy_operator e retention_operator (6.30.6).
M13 grants GRANT SELECT, INSERT, UPDATE, DELETE nas tabelas mutáveis para palavra_diaria_app; GRANT SELECT, INSERT nas append-only; GRANT USAGE no schema; e — obrigatoriamente — ALTER DEFAULT PRIVILEGES para que toda tabela criada por migration futura nasça acessível (ver abaixo).
M14 seed_reference_data Dados de referência obrigatórios: planos, settings, feature flags, templates. Escrito como INSERT ... ON CONFLICT DO NOTHING para ser reaplicável. Conteúdo em 6.32.
M15 retention_functions Funções purge_expired_sessions(), purge_expired_otp_codes(), anonymize_deleted_subscribers(), detach_old_message_partitions() (6.33).

O bloco de M13 precisa, obrigatoriamente, das três últimas linhas:

GRANT ALL PRIVILEGES ON SCHEMA public TO palavra_diaria_owner;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO palavra_diaria_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO palavra_diaria_app;

-- Sem isto, toda tabela criada por uma migration futura nasce inacessivel a aplicacao.
ALTER DEFAULT PRIVILEGES FOR ROLE palavra_diaria_owner IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO palavra_diaria_app;
ALTER DEFAULT PRIVILEGES FOR ROLE palavra_diaria_owner IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO palavra_diaria_app;

GRANT ... ON ALL TABLES concede privilégio apenas às tabelas existentes no momento do comando. Sem o ALTER DEFAULT PRIVILEGES, a próxima migration que criar uma tabela a deixa sem privilégio para a aplicação, o deploy é declarado bem-sucedido pelo healthcheck — que só consulta as tabelas antigas — e a falha aparece horas depois, como permission denied for table, sem se parecer com um problema de permissão. Por isso a verificação pós-migration de pnpm db:verify passa a incluir: nenhuma tabela do schema public pode existir sem SELECT para palavra_diaria_app, percorrendo information_schema.tables contra has_table_privilege. Uma falha aqui bloqueia a troca de tráfego.

6.31.1 Regras de migration #

  1. Nenhuma migration destrutiva sem migration de compatibilidade antes. Remover coluna exige duas releases: a primeira para de escrever, a segunda remove.
  2. Índice em tabela grande usa CREATE INDEX CONCURRENTLY. Isso não roda dentro de transação, então essas migrations ficam em arquivo próprio, marcado no cabeçalho, e o migration.sql desativa a transação implícita do Prisma.
  3. Adicionar valor a enum é migration isolada. Nunca acompanha uso desse valor.
  4. Toda migration é testada com prisma migrate diff contra o banco de staging antes de ir para produção.
  5. Rollback é sempre para frente. Não existe arquivo down. Reverter é escrever a migration seguinte que desfaz.

6.31.2 Objetos deliberadamente fora do schema.prisma #

Para que o revisor não trate a divergência como erro:

Objeto Onde vive Motivo
Índices parciais (WHERE) M10 Prisma não suporta.
Constraints CHECK M11 Prisma não suporta.
Particionamento de message_logs M8 Prisma não suporta.
FKs DEFERRABLE M5 Prisma não suporta.
Triggers e funções PL/pgSQL M8, M12, M15 Prisma não suporta.
GRANT e REVOKE M1, M12, M13 Fora do escopo do ORM.

O comando pnpm db:verify (parte do ops) roda um conjunto de asserções SQL que confirma que todos esses objetos existem no banco alvo. Ele faz parte do healthcheck de deploy: se faltar um índice parcial, o deploy falha em vez de degradar silenciosamente.

6.32 Seeds obrigatórios #

Executados por pnpm --filter @palavra-diaria/db seed, idempotentes, seguros para rodar em qualquer ambiente. Em produção, o seed só cria o que não existe; nunca sobrescreve.

6.32.1 Planos #

INSERT INTO plans (id, code, name, description, interval, amount_cents, currency,
                   tier_granted, asaas_cycle, is_active, sort_order)
VALUES
  ('01K000000000000000PLANMTH', 'plan_monthly', 'Mensal',
   'Devocional em texto e áudio todos os dias.', 'MONTHLY', 1990, 'BRL',
   'PAID', 'MONTHLY', true, 1),
  ('01K000000000000000PLANANL', 'plan_annual', 'Anual',
   'Doze meses de devocional diário com dois meses de desconto.', 'YEARLY', 19900, 'BRL',
   'PAID', 'YEARLY', true, 2)
ON CONFLICT (code) DO NOTHING;

R$ 19,90 por mês e R$ 199,00 por ano. O anual equivale a R$ 16,58 por mês, ou 16,7% de desconto — número interno; o rótulo exibido ao público é arredondado para baixo.

O seed cria duas linhas, e apenas duas. Não existe linha plan_free: ser gratuito é a ausência de assinatura vigente (6.10). Se a página de vendas precisar mostrar um plano gratuito na tabela comparativa, o item é montado pelo handler.

6.32.2 Administrador inicial #

Criado apenas quando não existe nenhuma conta com papel OWNER. A senha vem de variável de ambiente (Seção 26.3.11) e a conta nasce com troca obrigatória no primeiro acesso e sem TOTP enrolado — o enrolamento é imposto no primeiro login (Seção 8.6).

// packages/db/src/seeds/admin.ts

// A verificacao vem PRIMEIRO. BOOTSTRAP_ADMIN_PASSWORD so e lida dentro do caminho que
// cria o primeiro proprietario, nunca no topo do modulo.
const ownerExists = await prisma.adminUser.findFirst({
  where: { role: 'OWNER', deletedAt: null },
  select: { id: true },
});

if (ownerExists) {
  logger.info('owner already exists, skipping bootstrap');
} else {
  const bootstrapEmail = requireEnv('BOOTSTRAP_ADMIN_EMAIL');
  const bootstrapPassword = requireEnv('BOOTSTRAP_ADMIN_PASSWORD');

  await prisma.adminUser.create({
    data: {
      id: ulid(),
      email: bootstrapEmail,
      name: 'Administrador',
      passwordHash: await argon2.hash(bootstrapPassword, ARGON2_OPTIONS), // ver Seção 8.5
      role: 'OWNER',
      mustChangePassword: true,
      // totpEnrolledAt fica nulo: o enrolamento e imposto no primeiro login.
    },
  });
  logger.info({ email: bootstrapEmail }, 'bootstrap owner created');
}

A ordem importa e não é estética. Se a variável fosse lida no topo do módulo, remover BOOTSTRAP_ADMIN_PASSWORD do ambiente — que é justamente o que o checklist de implantação manda fazer depois do primeiro acesso — faria todo deploy posterior falhar, porque o seed é reexecutado e é declarado idempotente e seguro. O operador então reporia a senha original para destravar o contêiner, e ela ficaria no ambiente para sempre, agora com uma conta OWNER ativa correspondente. Lendo a variável dentro do ramo de criação, a ausência dela com proprietário já criado é o estado normal e não produz erro.

Três regras completam o desenho:

  1. O valor precisa ter no mínimo 24 caracteres e é rejeitado pelo schema de validação de ambiente se constar da lista de senhas comuns ou for igual ao exemplo publicado.
  2. A conta nasce com must_change_password = true e totp_enrolled_at nulo, e a sessão emitida no primeiro acesso é restrita às rotas de troca de senha e de enrolamento de segundo fator.
  3. O processo recusa iniciar se o ambiente for produção, existir ao menos um OWNER com TOTP enrolado, e BOOTSTRAP_ADMIN_PASSWORD continuar definida. A remoção deixa de ser um item de checklist que alguém pode esquecer e passa a ser imposta pelo boot.

O seed nunca grava a senha em log, nem mesmo mascarada.

6.32.3 Templates do WhatsApp #

Oito templates. O seed cria as oito linhas locais com status = 'DRAFT'; a submissão à Meta é feita pelo comando pnpm ops templates:submit, e o retorno atualiza meta_template_id, status e effective_category. Os components semeados são idênticos aos definidos na seção dona da integração com o WhatsApp: divergir aqui significa submeter à Meta um template diferente do que o motor de envio espera preencher.

name category header_type param_count Uso
devocional_diario_v1 UTILITY TEXT 2 Convite diário. {{1}} título, {{2}} teaser.
devocional_diario_video_v1 UTILITY VIDEO 2 Fallback para quem não abre a janela há 3 dias.
codigo_acesso_v1 AUTHENTICATION 1 OTP de login. {{1}} código de 6 dígitos.
boas_vindas_v1 UTILITY TEXT 1 Confirmação de opt-in. {{1}} primeiro nome.
lembrete_pagamento_v1 UTILITY TEXT 3 Lembrete de vencimento D-3, D-1 e D0. {{1}} nome, {{2}} data, {{3}} valor. Tem um botão de URL.
pagamento_confirmado_v1 UTILITY TEXT 3 Confirmação de pagamento. {{1}} nome, {{2}} valor, {{3}} fim do período.
acesso_encerrado_v1 UTILITY TEXT 1 Aviso de fim do acesso pago. {{1}} primeiro nome.
reativacao_v1 MARKETING 2 Convite de retorno a quem cancelou. {{1}} nome, {{2}} oferta.

param_count conta apenas os parâmetros do corpo. Parâmetros de botão de URL são numerados em sequência própria pela Meta e não entram nesta contagem — por isso lembrete_pagamento_v1 tem param_count = 3 e, ainda assim, um parâmetro de URL.

INSERT INTO whatsapp_templates
  (id, name, language, category, status, body_text, param_count, header_type, components, is_current)
VALUES
  ('01K000000000000000TPLDIA1', 'devocional_diario_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Hoje: {{1}}. {{2}}', 2, 'TEXT',
   '{"components":[
      {"type":"HEADER","format":"TEXT","text":"Devocional de hoje"},
      {"type":"BODY","text":"Hoje: {{1}}. {{2}}",
       "example":{"body_text":[["A força do silêncio","Deus não precisa de barulho para agir."]]}},
      {"type":"FOOTER","text":"Responda SAIR para cancelar"},
      {"type":"BUTTONS","buttons":[
        {"type":"QUICK_REPLY","text":"Ler e ouvir agora"},
        {"type":"QUICK_REPLY","text":"Depois"}]}]}'::jsonb,
   false),
  ('01K000000000000000TPLVID1', 'devocional_diario_video_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Hoje: {{1}}. {{2}}', 2, 'VIDEO',
   '{"components":[
      {"type":"HEADER","format":"VIDEO"},
      {"type":"BODY","text":"Hoje: {{1}}. {{2}}"},
      {"type":"FOOTER","text":"Responda SAIR para cancelar"}]}'::jsonb,
   false),
  ('01K000000000000000TPLOTP1', 'codigo_acesso_v1', 'pt_BR', 'AUTHENTICATION', 'DRAFT',
   'Seu código de acesso é {{1}}. Ele expira em 10 minutos.', 1, NULL,
   '{"components":[
      {"type":"BODY","text":"Seu código de acesso é {{1}}. Ele expira em 10 minutos."},
      {"type":"BUTTONS","buttons":[{"type":"OTP","otp_type":"COPY_CODE","text":"Copiar código"}]}]}'::jsonb,
   false),
  ('01K000000000000000TPLWEL1', 'boas_vindas_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Olá, {{1}}! Sua inscrição foi confirmada. Responda SIM para começar a receber.', 1, 'TEXT',
   '{"components":[
      {"type":"HEADER","format":"TEXT","text":"Bem-vindo"},
      {"type":"BODY","text":"Olá, {{1}}! Sua inscrição foi confirmada. Responda SIM para começar a receber."},
      {"type":"BUTTONS","buttons":[{"type":"QUICK_REPLY","text":"Confirmar inscrição"}]}]}'::jsonb,
   false),
  ('01K000000000000000TPLLEM1', 'lembrete_pagamento_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.

Valor: {{3}}

Pague pelo link abaixo para manter o áudio diário ativo.', 3, 'TEXT',
   '{"components":[
      {"type":"HEADER","format":"TEXT","text":"Lembrete de pagamento"},
      {"type":"BODY","text":"Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.\n\nValor: {{3}}\n\nPague pelo link abaixo para manter o áudio diário ativo.",
       "example":{"body_text":[["Ana","25/09/2026","R$ 19,90"]]}},
      {"type":"FOOTER","text":"Responda SAIR para cancelar os envios"},
      {"type":"BUTTONS","buttons":[
        {"type":"URL","text":"Pagar agora","url":"https://palavradiaria.com.br/app/assinatura/{{1}}",
         "example":["https://palavradiaria.com.br/app/assinatura/pay_01K3F8R5W7"]}]}]}'::jsonb,
   false),
  ('01K000000000000000TPLPAG1', 'pagamento_confirmado_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Pagamento confirmado, {{1}}. Recebemos {{2}} e seu acesso está garantido até {{3}}.', 3, 'TEXT',
   '{"components":[
      {"type":"HEADER","format":"TEXT","text":"Pagamento confirmado"},
      {"type":"BODY","text":"Pagamento confirmado, {{1}}. Recebemos {{2}} e seu acesso está garantido até {{3}}.",
       "example":{"body_text":[["Ana","R$ 19,90","25/10/2026"]]}},
      {"type":"FOOTER","text":"Obrigado por caminhar com a gente"}]}'::jsonb,
   false),
  ('01K000000000000000TPLENC1', 'acesso_encerrado_v1', 'pt_BR', 'UTILITY', 'DRAFT',
   'Olá, {{1}}. Seu acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto aos domingos.', 1, 'TEXT',
   '{"components":[
      {"type":"HEADER","format":"TEXT","text":"Acesso encerrado"},
      {"type":"BODY","text":"Olá, {{1}}. Seu acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto aos domingos.",
       "example":{"body_text":[["Ana"]]}},
      {"type":"FOOTER","text":"Responda SAIR para parar de receber"}]}'::jsonb,
   false),
  ('01K000000000000000TPLREA1', 'reativacao_v1', 'pt_BR', 'MARKETING', 'DRAFT',
   'Sentimos sua falta, {{1}}. {{2}}', 2, NULL,
   '{"components":[
      {"type":"BODY","text":"Sentimos sua falta, {{1}}. {{2}}",
       "example":{"body_text":[["Ana","Volte a receber o devocional em áudio todos os dias."]]}},
      {"type":"FOOTER","text":"Responda SAIR para não receber mais"},
      {"type":"BUTTONS","buttons":[
        {"type":"QUICK_REPLY","text":"Quero voltar"},
        {"type":"QUICK_REPLY","text":"Não, obrigado"}]}]}'::jsonb,
   false)
ON CONFLICT (name, language) DO NOTHING;

O cabeçalho de devocional_diario_v1 é TEXT estático, não parametrizado, porque cabeçalho de texto com parâmetro tem limite de 60 caracteres e nenhuma vantagem aqui.

header_type é NULL para codigo_acesso_v1 e para reativacao_v1 por motivos diferentes: templates de autenticação não aceitam cabeçalho, e o de reativação simplesmente não usa. Nos dois casos NULL significa "sem cabeçalho", e não "a definir".

6.32.4 Settings de runtime #

São 35 chaves canônicas, todas em grupo.chave, descritas em 26.8. O seed insere todas, com o valor de fábrica e com as seis colunas NOT NULL preenchidas em cada linha. Isso não é zelo excessivo: uma chave citada em outra seção e ausente do seed produz leitura nula em produção, e um INSERT de emergência que tente criá-la sem as seis colunas aborta (6.23.3). Toda chave citada em qualquer seção precisa existir aqui, e nenhuma seção pode citar chave que não esteja nesta lista.

INSERT INTO settings (key, value, value_type, default_value, description, group_name,
                      is_secret, editable_by, min_value, max_value)
VALUES
  ('send.daily_hour_local', '6', 'INT', '6',
   'Hora local (America/Sao_Paulo) do envio diário.', 'envio', false, 'ADMIN', 0, 23),
  ('send.plan_offset_minutes', '20', 'INT', '20',
   'Minutos de antecedência do planejamento em relação ao envio.', 'envio', false, 'ADMIN', 5, 120),
  ('send.free_tier_weekday', '0', 'INT', '0',
   'Dia da semana do envio do plano gratuito. 0 = domingo.', 'envio', false, 'ADMIN', 0, 6),
  ('send.rate_per_second', '20', 'INT', '20',
   'Mensagens por segundo enviadas ao WhatsApp.', 'envio', false, 'ADMIN', 1, 80),
  ('send.max_attempts', '5', 'INT', '5',
   'Tentativas máximas por etapa de entrega.', 'envio', false, 'ADMIN', 1, 10),
  ('send.window_miss_threshold', '3', 'INT', '3',
   'Dias sem abrir a janela antes de usar o template de vídeo.', 'envio', false, 'ADMIN', 1, 14),
  ('send.optout_keywords',
   '["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"]', 'JSON',
   '["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"]',
   'Palavras que encerram os envios. Comparadas sobre a mensagem inteira normalizada.', 'envio', false, 'ADMIN', NULL, NULL),
  ('send.optin_keywords',
   '["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"]', 'JSON',
   '["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"]',
   'Palavras que reativam os envios após um opt-out.', 'envio', false, 'ADMIN', NULL, NULL),
  ('content.teaser_max_length', '300', 'INT', '300',
   'Tamanho máximo do teaser enviado no template.', 'conteudo', false, 'EDITOR', 50, 300),
  ('content.bible_version_default', '"ALMEIDA_1911"', 'STRING', '"ALMEIDA_1911"',
   'Versão bíblica padrão de novos devocionais.', 'conteudo', false, 'EDITOR', NULL, NULL),
  ('content.allowed_bible_versions', '["ALMEIDA_1911","BIBLIA_LIVRE"]', 'JSON',
   '["ALMEIDA_1911","BIBLIA_LIVRE"]',
   'Lista fechada de versões bíblicas aceitas. A sigla ARC é proibida: identifica edição protegida.', 'conteudo', false, 'OWNER', NULL, NULL),
  ('content.bible_source_provenance', '{}', 'JSON', '{}',
   'Origem verificável de cada versão importada: nome da fonte, endereço, data e responsável.', 'conteudo', false, 'OWNER', NULL, NULL),
  ('content.editorial_lock_minutes', '15', 'INT', '15',
   'Minutos de bloqueio editorial de um devocional aberto por outro editor.', 'conteudo', false, 'ADMIN', 1, 120),
  ('resend.free_daily_limit', '1', 'INT', '1',
   'Reenvios manuais por dia no plano gratuito.', 'entitlements', false, 'ADMIN', 0, 10),
  ('resend.paid_daily_limit', '3', 'INT', '3',
   'Reenvios manuais por dia no plano pago.', 'entitlements', false, 'ADMIN', 0, 10),
  ('archive.free_days', '7', 'INT', '7',
   'Dias de acervo visíveis no painel para o plano gratuito.', 'entitlements', false, 'ADMIN', 1, 90),
  ('billing.reminder_days', '[3,1,0]', 'JSON', '[3,1,0]',
   'Dias antes do vencimento em que o lembrete de cobrança é enviado.', 'cobranca', false, 'ADMIN', NULL, NULL),
  ('billing.reconcile_batch_size', '200', 'INT', '200',
   'Assinaturas conferidas por execução da reconciliação diária.', 'cobranca', false, 'ADMIN', 10, 2000),
  ('support.email', '"suporte@palavradiaria.com.br"', 'STRING', '"suporte@palavradiaria.com.br"',
   'E-mail de suporte exibido ao assinante.', 'geral', false, 'ADMIN', NULL, NULL),
  ('support.whatsapp_reply_text',
   '"Recebemos sua mensagem. Nossa equipe responde em até um dia útil."', 'STRING',
   '"Recebemos sua mensagem. Nossa equipe responde em até um dia útil."',
   'Resposta automática enviada quando a mensagem abre atendimento humano.', 'geral', false, 'ADMIN', NULL, NULL),
  ('privacy.dpo_email', '"privacidade@palavradiaria.com.br"', 'STRING', '"privacidade@palavradiaria.com.br"',
   'E-mail do encarregado de dados, exigido pela LGPD.', 'geral', false, 'OWNER', NULL, NULL),
  ('privacy.policy_version', '"2026-08-01"', 'STRING', '"2026-08-01"',
   'Versão vigente do texto de consentimento.', 'geral', false, 'OWNER', NULL, NULL),
  ('privacy.message_log_retention_months', '18', 'INT', '18',
   'Meses de retenção do registro de mensagens. Valor único; nenhuma outra seção o redefine.', 'geral', false, 'OWNER', 6, 60),
  ('privacy.deletion_grace_days', '7', 'INT', '7',
   'Dias entre o pedido de eliminação e a execução, para permitir arrependimento.', 'geral', false, 'OWNER', 0, 30),
  ('privacy.legal_entity',
   '{"razaoSocial":"","cnpj":"","endereco":""}', 'JSON',
   '{"razaoSocial":"","cnpj":"","endereco":""}',
   'Dados da pessoa jurídica controladora, exibidos nos Termos e na Política de Privacidade.', 'geral', false, 'OWNER', NULL, NULL),
  ('security.blocked_email_domains', '[]', 'JSON', '[]',
   'Domínios de e-mail recusados no cadastro.', 'seguranca', false, 'ADMIN', NULL, NULL),
  ('security.mobile_carrier_ranges', '[]', 'JSON', '[]',
   'Faixas de IP de operadoras móveis brasileiras. Isenta CGNAT da pontuação por IP repetido.', 'seguranca', false, 'ADMIN', NULL, NULL),
  ('ops.kill_switch',
   '{"enabled":false,"reason":null,"activatedBy":null,"activatedAt":null}', 'JSON',
   '{"enabled":false,"reason":null,"activatedBy":null,"activatedAt":null}',
   'Interruptor geral de envio. Quando ligado, nenhuma mensagem sai.', 'operacao', false, 'OWNER', NULL, NULL),
  ('ops.maintenance_mode', 'false', 'BOOL', 'false',
   'Bloqueia rotas de escrita e exibe aviso de manutenção.', 'operacao', false, 'OWNER', NULL, NULL),
  ('ops.alert_email', '"alertas@palavradiaria.com.br"', 'STRING', '"alertas@palavradiaria.com.br"',
   'Destino dos alertas operacionais.', 'operacao', false, 'ADMIN', NULL, NULL),
  ('ops.cost_alert_threshold_cents', '0', 'INT', '0',
   'Custo diário, em centavos de BRL, acima do qual o alerta de custo dispara. Zero desliga o alerta.', 'operacao', false, 'OWNER', 0, 100000000),
  ('ops.infra_cost_monthly_cents', '0', 'INT', '0',
   'Custo mensal de infraestrutura, em centavos de BRL, usado no rateio do painel de custos.', 'operacao', false, 'OWNER', 0, 100000000),
  ('ops.cost_per_subscriber_target_cents', '0', 'INT', '0',
   'Meta de custo por assinante, em centavos de BRL.', 'operacao', false, 'OWNER', 0, 100000000),
  ('ops.debug_subscriber_ids', '[]', 'JSON', '[]',
   'Assinantes com log em nível de depuração. Máximo de 20 itens.', 'operacao', false, 'ADMIN', NULL, NULL),
  ('ops.debug_paths', '[]', 'JSON', '[]',
   'Caminhos de API com log em nível de depuração.', 'operacao', false, 'ADMIN', NULL, NULL),
  ('tts.cost_per_1k_chars_micros', '0', 'INT', '0',
   'Custo de síntese de voz por mil caracteres, em micros de BRL, conforme a tabela vigente do provedor primário (Seção 16.4). Alimenta audio_assets.cost_micros e o painel de custos da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero.', 'conteudo', false, 'OWNER', 0, 100000000)
ON CONFLICT (key) DO NOTHING;

Duas observações sobre chaves específicas:

  1. O interruptor geral de envios é ops.kill_switch, e é JSON, não booleano. Ele guarda também o motivo, quem ligou e quando, porque um interruptor acionado às 06:03 sem registro de quem o acionou e por quê é uma investigação que começa no escuro. Não existe send.pause_all.
  2. Valores monetários de configuração vão em centavos, com sufixo _cents. As chaves de custo (ops.infra_cost_monthly_cents, ops.cost_per_subscriber_target_cents, ops.cost_alert_threshold_cents) são divididas por 100 na apresentação. Não existe chave com sufixo _brl: um número em reais dentro de settings seria o único ponto do sistema com dinheiro fracionário, e a primeira soma com uma coluna em centavos erraria por 100.

6.32.5 Feature flags #

INSERT INTO feature_flags (key, description, is_enabled, rollout_percentage, target_tier)
VALUES
  ('video_fallback_enabled',   'Usa o template de vídeo para quem não abre a janela há 3 dias.', true,  100, 'PAID'),
  ('window_shortcut_enabled',  'Pula o template quando a janela de 24h já está aberta.',          true,  100, NULL),
  ('tts_google_fallback',      'Permite cair para o provedor secundário de voz após 3 falhas.',   true,  100, NULL),
  ('web_player_enabled',       'Exibe o player de áudio no painel do assinante.',                 true,  100, 'PAID'),
  ('pix_checkout_enabled',     'Oferece PIX como forma de pagamento no checkout.',                true,  100, NULL),
  ('card_checkout_enabled',    'Oferece cartão de crédito no checkout.',                          true,  100, NULL),
  ('email_fallback_login',     'Permite login por magic link quando há e-mail verificado.',       true,  100, NULL),
  ('admin_impersonation',      'Permite que o suporte acesse o painel como o assinante.',         true,  100, NULL),
  ('metrics_dashboard',        'Exibe o painel de métricas para papéis administrativos.',         true,  100, NULL),
  ('snooze_button_enabled',    'Inclui o botão Depois no template de convite diário.',            true,  100, NULL),
  ('reengagement_campaign',    'Envia mensagem de reengajamento a quem não abre há 14 dias.',     false,   0, NULL),
  ('audio_speed_control',      'Controle de velocidade no player web.',                           false,   0, 'PAID'),
  ('audio_human_review',       'Exige revisão humana do áudio antes de o devocional ficar AUDIO_READY.', false, 0, NULL)
ON CONFLICT (key) DO NOTHING;

São treze flags. Efeito de cada uma e critério de desligamento estão em 26.9.

As chaves de feature_flags são snake_case sem prefixo de grupo, ao contrário das de settings. A diferença é proposital e o CHECK de 6.24.3 a impõe: setting é configuração permanente e agrupada por assunto, flag é interruptor temporário de rollout que se espera remover. Uma flag citada em outra seção com prefixo — content.require_audio_human_review, por exemplo — não existe; o nome correto é audio_human_review.

6.32.6 Seeds de desenvolvimento #

Executados apenas quando NODE_ENV !== 'production', por pnpm --filter @palavra-diaria/db seed:dev:

  • 30 devocionais, de hoje - 7 a hoje + 22, com status coerente (SENT no passado, PUBLISHED hoje, READY e DRAFT no futuro);
  • 50 assinantes: 35 FREE e 15 PAID, com telefones no bloco de teste +5511900000001 em diante, todos com opt_in_confirmed_at preenchido;
  • 15 assinaturas ativas distribuídas entre os dois planos, com pagamentos confirmados;
  • 3 assinantes em estados de borda: um com opt-out, um bloqueado, um com soft delete;
  • 200 linhas de message_logs distribuídas nos últimos 7 dias, com mistura de status;
  • daily_metrics de 7 dias, em formato longo — cerca de 350 linhas, uma por dia, métrica e dimensão —, para o painel não abrir vazio.

Os telefones do seed de desenvolvimento passam pelo mesmo caminho de cifra e de índice cego da produção. Gravar telefone em claro em desenvolvimento faria as consultas de desenvolvimento divergirem das de produção, que é exatamente o tipo de diferença que só aparece no primeiro deploy.

Nenhum seed de desenvolvimento roda em produção. A verificação é explícita e falha o comando com mensagem clara se NODE_ENV === 'production'.

6.33 Retenção, arquivamento e particionamento #

6.33.1 Política por tabela #

Tabela Retenção on-line Depois Justificativa
subscribers indefinida anonimização 30 dias após pedido de eliminação LGPD; a linha permanece para preservar integridade referencial.
subscriber_profiles indefinida campos sensíveis zerados junto com a anonimização LGPD.
consent_events 5 anos exportação para arquivo assinado, depois exclusão Prova de consentimento; obrigação legal.
sessions 30 dias após expires_at exclusão física Sem valor após vencer.
otp_codes 24 h após expires_at exclusão física Sem valor após vencer; reduz superfície de dado sensível. Expurgo horário, não diário.
unsubscribe_tokens 30 dias após expires_at exclusão física Credencial de uso único, sem valor probatório; a prova do descadastro fica em consent_events (6.7.4). Expurgado também na hora em que o opt-out é gravado.
admin_users indefinida soft delete apenas Trilha de auditoria depende.
admin_audit_log 5 anos exportação e exclusão Auditoria.
plans indefinida Volume irrelevante.
subscriptions indefinida Obrigação fiscal.
subscription_events 5 anos exportação e exclusão Auditoria financeira.
payments 5 anos exportação e exclusão Obrigação fiscal.
payment_events 5 anos exportação e exclusão Obrigação fiscal.
devotionals indefinida É o produto.
devotional_revisions 2 anos mantém apenas a primeira e a última revisão de cada devocional Volume sem valor proporcional.
audio_assets indefinida no banco objeto no storage migra para classe fria após 90 dias Custo de armazenamento.
whatsapp_templates indefinida Volume irrelevante.
message_logs 18 meses partição destacada, exportada em Parquet, depois removida Volume; 18 meses cobrem qualquer disputa de entrega. O valor é dirigido pela chave privacy.message_log_retention_months e não é digitado em dois lugares.
admin_trusted_devices 30 dias após expires_at exclusão física Cookie de dispositivo vencido não tem uso.
delivery_attempts 18 meses exclusão em lote por devotional_date Acompanha message_logs.
inbound_messages 18 meses exclusão em lote Acompanha message_logs.
send_batches indefinida 365 linhas por ano.
settings indefinida Configuração.
feature_flags indefinida Configuração.
daily_metrics indefinida Agregado sem dado pessoal; é a memória histórica do produto.
webhook_deliveries 90 dias exclusão física Diário de transporte; perde valor rápido. Os fatos de negócio já estão em payment_events e inbound_messages.
media_uploads 12 meses exclusão física Identificadores vencem em 30 dias.
job_runs 180 dias exclusão física Diagnóstico operacional recente.

6.33.2 Job de retenção #

retention.purge, diário às 03:30, uma transação por tabela, com teto de 50.000 linhas por execução para não travar o banco. Se atingir o teto, reagenda para 10 minutos depois.

CREATE OR REPLACE FUNCTION purge_expired_sessions(batch_limit int DEFAULT 50000)
RETURNS int LANGUAGE plpgsql AS $$
DECLARE deleted int;
BEGIN
  WITH doomed AS (
    SELECT id FROM sessions
     WHERE expires_at < now() - interval '30 days'
     ORDER BY expires_at
     LIMIT batch_limit
     FOR UPDATE SKIP LOCKED
  )
  DELETE FROM sessions s USING doomed d WHERE s.id = d.id;
  GET DIAGNOSTICS deleted = ROW_COUNT;
  RETURN deleted;
END;
$$;

FOR UPDATE SKIP LOCKED evita que o expurgo brigue com uma sessão sendo usada naquele instante. O mesmo padrão vale para otp_codes, unsubscribe_tokens, webhook_deliveries, job_runs e admin_trusted_devices.

otp_codes é a exceção de cadência: o expurgo dele roda de hora em hora, apagando códigos que venceram há mais de 24 horas, e não uma vez por dia junto com os demais. Um código de acesso é dado de autenticação, e mantê-lo por dias depois de perder a validade só aumenta a superfície de um vazamento sem oferecer nada em troca.

O expurgo por retenção de consent_events e de admin_audit_log roda com o papel retention_operator, cuja exceção está codificada dentro do próprio gatilho append-only (6.30.6). Nenhum gatilho é desabilitado em momento algum.

6.33.3 Particionamento de message_logs #

  • Estratégia: RANGE por created_at, uma partição por mês.
  • Nome: message_logs_YYYY_MM.
  • Criação: job db.ensure_partitions às 03:00, criando o mês corrente e os dois seguintes (6.30.3).
  • Retenção: partições com mais de 18 meses são destacadas, exportadas e removidas.
CREATE OR REPLACE FUNCTION detach_old_message_partitions(keep_months int DEFAULT 18)
RETURNS TABLE(partition_name text, action text)
LANGUAGE plpgsql AS $$
DECLARE r record; cutoff date := date_trunc('month', now())::date - (keep_months || ' months')::interval;
BEGIN
  FOR r IN
    SELECT c.relname
      FROM pg_class c
      JOIN pg_inherits i ON i.inhrelid = c.oid
      JOIN pg_class p ON p.oid = i.inhparent
     WHERE p.relname = 'message_logs'
       AND c.relname ~ '^message_logs_[0-9]{4}_[0-9]{2}$'
       AND to_date(right(c.relname, 7), 'YYYY_MM') < cutoff
  LOOP
    EXECUTE format('ALTER TABLE message_logs DETACH PARTITION %I CONCURRENTLY', r.relname);
    partition_name := r.relname; action := 'detached'; RETURN NEXT;
  END LOOP;
END;
$$;

Destacar é instantâneo e reversível. A remoção definitiva (DROP TABLE) só acontece depois que o job de exportação confirmar o arquivo Parquet no storage e registrar o checksum. Sequência mensal, dia 5 às 04:00:

  1. detach_old_message_partitions(18) — destaca as partições vencidas;
  2. exportação para archive/message_logs/message_logs_YYYY_MM.parquet;
  3. verificação de checksum e contagem de linhas contra a partição;
  4. DROP TABLE message_logs_YYYY_MM;
  5. registro em job_runs com o número de linhas arquivadas.

Se o passo 3 falhar, o processo para e alerta. A partição destacada continua no disco, íntegra e consultável por nome.

6.33.4 Por que delivery_attempts não é particionada #

4 milhões de linhas em 18 meses. É volume alto, mas o acesso é quase todo pelo índice parcial ix_delivery_pending, que enxerga só os pendentes, ou por (devotional_date, status), que já filtra bem. O custo operacional do particionamento (criar partição, tratar FKs, migrar) não se paga nessa escala. A decisão é revisada se a tabela passar de 20 milhões de linhas, o que aconteceria por volta de 50.000 assinantes — e o gatilho de revisão é um alerta automático sobre pg_total_relation_size.

O expurgo usa exclusão em lote por data:

DELETE FROM delivery_attempts
 WHERE devotional_date < CURRENT_DATE - interval '18 months'
   AND id IN (SELECT id FROM delivery_attempts
               WHERE devotional_date < CURRENT_DATE - interval '18 months'
               LIMIT 50000);

6.33.5 Anonimização por LGPD #

A operação tem dois estágios, com regimes jurídicos diferentes, e a distinção não é semântica. Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares afetados em um incidente, que é exatamente o erro que a Autoridade procura.

Estágio 1 — pseudonimização. Roda até 30 dias após o pedido. Todos os identificadores diretos saem, mas cpf/cpf_hmac e os identificadores do provedor de pagamento permanecem enquanto durar a retenção fiscal. Nesse estágio a linha continua sendo dado pessoal, permanece no escopo da LGPD, conta na apuração de qualquer incidente e está sujeita ao mesmo controle de acesso das linhas ativas.

Estágio 2 — anonimização. Roda 5 anos após o último pagamento, quando a retenção fiscal vence. Zera CPF e os identificadores que recuperariam o cadastro completo no provedor.

Regra de alcance, obrigatória. A anonimização percorre todas as tabelas que contenham telefone, wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não apenas subscribers. A lista é derivada da coluna, não da tabela: qualquer coluna cujo nome esteja em ('phone_e164','phone_hmac','wa_id','wa_id_hmac','email','email_hmac','cpf', 'cpf_hmac','payload_excerpt','text_body') e cuja linha se relacione ao assinante é zerada. Nenhuma tabela pode reter telefone, wa_id, CPF ou e-mail em claro depois disso.

CREATE OR REPLACE FUNCTION anonymize_subscriber(target_id char(26))
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
  -- Estagio 1: pseudonimizacao. Executado pelo papel privacy_operator.
  UPDATE subscribers
     SET phone_e164   = 'v1:anonymized:' || substr(target_id, 1, 12),
         phone_hmac   = encode(digest('anonymized:' || target_id, 'sha256'), 'hex'),
         wa_id        = NULL,
         wa_id_hmac   = NULL,
         email        = NULL,
         email_hmac   = NULL,
         display_name = NULL,
         anonymized_at = now(),
         updated_at   = now()
   WHERE id = target_id AND deleted_at IS NOT NULL;

  UPDATE subscriber_profiles
     SET full_name = NULL, birth_date = NULL, city = NULL, state = NULL,
         church_name = NULL, signup_ip = NULL, signup_user_agent = NULL,
         referrer = NULL, admin_notes = NULL, updated_at = now()
   WHERE subscriber_id = target_id;
  -- cpf, cpf_hmac e cpf_last4 permanecem ate o estagio 2: retencao fiscal.

  UPDATE message_logs
     SET phone_e164 = NULL, phone_hmac = NULL, wa_id = NULL, payload_excerpt = NULL
   WHERE subscriber_id = target_id;

  UPDATE inbound_messages
     SET phone_e164 = NULL, phone_hmac = NULL,
         wa_id = 'v1:anonymized', wa_id_hmac = encode(digest('anonymized:' || target_id, 'sha256'), 'hex'),
         text_body = NULL, raw = '{"anonymized":true}'::jsonb
   WHERE subscriber_id = target_id;

  UPDATE otp_codes
     SET phone_e164 = NULL, phone_hmac = NULL, email = NULL, email_hmac = NULL
   WHERE subscriber_id = target_id;

  -- Credenciais vivas de descadastro perdem a funcao e sao apagadas, nao anonimizadas:
  -- a tabela guarda apenas hashes, e um token vivo apontando para conta anonimizada e
  -- um segredo sem dono (6.7.4).
  DELETE FROM unsubscribe_tokens WHERE subscriber_id = target_id;

  -- Identificadores de rede na trilha de consentimento: caminho privilegiado do
  -- gatilho append-only (6.30.6). O conteudo probatorio nao e tocado.
  UPDATE consent_events SET ip = NULL, user_agent = NULL
   WHERE subscriber_id = target_id;
END;
$$;

CREATE OR REPLACE FUNCTION fully_anonymize_subscriber(target_id char(26))
RETURNS void LANGUAGE plpgsql AS $$
BEGIN
  -- Estagio 2: so depois de vencida a retencao fiscal de 5 anos.
  UPDATE subscriber_profiles
     SET cpf = NULL, cpf_hmac = NULL, cpf_last4 = NULL, updated_at = now()
   WHERE subscriber_id = target_id;

  UPDATE subscribers
     SET asaas_customer_id = NULL, fully_anonymized_at = now(), updated_at = now()
   WHERE id = target_id;

  UPDATE subscriptions SET asaas_customer_id = NULL, asaas_subscription_id = NULL,
         asaas_card_token = NULL, card_brand = NULL, card_last4 = NULL,
         card_exp_month = NULL, card_exp_year = NULL, updated_at = now()
   WHERE subscriber_id = target_id;

  UPDATE payments SET invoice_url = NULL, receipt_url = NULL,
         pix_payload = NULL, pix_qr_code_base64 = NULL,
         card_brand = NULL, card_last4 = NULL, updated_at = now()
   WHERE subscriber_id = target_id;
END;
$$;

O telefone é substituído por um marcador derivado do próprio identificador, o que preserva a unicidade do índice cego sem guardar o número. As linhas de consent_events, payments, payment_events e subscriptions não são apagadas: são obrigação legal e ficam pelos 5 anos, agora ligadas apenas ao identificador interno. Essa distinção é a razão de consent_events.subscriber_id ser RESTRICT e não CASCADE.

message_logs é a tabela que mais importa aqui, e é a que costuma ser esquecida. A retenção dela é de 18 meses, muito mais longa que o prazo de eliminação. Sem o UPDATE acima, centenas de linhas com o telefone completo, o horário de leitura de cada devocional e trechos do conteúdo religioso sobreviveriam mais de um ano depois de o painel ter confirmado "conta anonimizada" — e, em um incidente, a pessoa continuaria identificável enquanto a comunicação à Autoridade a teria excluído da contagem de afetados.

Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a anonimização e falha se uma busca pelo phone_hmac, pelo wa_id_hmac ou pelo email_hmac originais devolver qualquer linha em qualquer tabela. O job retention.enforce executa o estágio 2 diariamente sobre as linhas cuja retenção fiscal venceu e registra a transição em admin_audit_log com action = 'subscriber.fully_anonymized'.

6.33.6 Limpeza do storage #

Job semanal storage.gc, domingos às 05:00:

  1. lista objetos sob devotionals/ no bucket;
  2. compara com audio_assets.storage_key;
  3. objetos sem linha correspondente há mais de 7 dias são removidos;
  4. linhas com storage_key apontando para objeto inexistente marcam status = 'FAILED' e disparam alerta.

A janela de 7 dias evita apagar objeto recém-enviado cuja linha ainda não foi confirmada.

6.34 VACUUM, autovacuum e manutenção de índices #

6.34.1 Tabelas de alto volume e seu perfil de escrita #

Tabela Perfil Problema resultante
message_logs inserção em rajada, depois 2 a 3 UPDATE de status por linha Bloat moderado por linha morta de UPDATE.
delivery_attempts inserção em rajada, depois 1 a 5 UPDATE por linha Bloat alto; é a tabela mais atualizada do sistema.
subscribers UPDATE diário de service_window_expires_at e last_delivered_at Bloat contínuo em tabela pequena, o que degrada o índice de elegibilidade.
send_batches dezenas de UPDATE por segundo na mesma linha Bloat concentrado; a linha do dia acumula centenas de versões mortas.
webhook_deliveries inserção alta, UPDATE único Bloat baixo.

6.34.2 Parâmetros por tabela #

-- muito atualizada: vacuum bem mais agressivo que o padrão de 20%
ALTER TABLE delivery_attempts SET (
  autovacuum_vacuum_scale_factor  = 0.02,
  autovacuum_vacuum_threshold     = 1000,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_vacuum_cost_limit    = 2000
);

-- pequena mas atualizada todo dia: gatilho por número absoluto
ALTER TABLE subscribers SET (
  autovacuum_vacuum_scale_factor = 0.05,
  autovacuum_vacuum_threshold    = 500,
  fillfactor                     = 85
);

-- linha única muito atualizada: fillfactor deixa espaço para HOT update
ALTER TABLE send_batches SET (fillfactor = 70, autovacuum_vacuum_threshold = 100);

-- inserção pura na partição corrente
ALTER TABLE message_logs SET (
  autovacuum_vacuum_scale_factor  = 0.1,
  autovacuum_analyze_scale_factor = 0.02
);

fillfactor = 85 em subscribers reserva 15% de espaço em cada página para que o UPDATE diário caiba na mesma página. Isso ativa o HOT update, que não toca os índices e elimina a maior fonte de bloat da tabela. send_batches usa 70% porque a mesma linha é atualizada centenas de vezes na mesma hora.

6.34.3 Manutenção programada #

Quando O quê Comando
Diário 04:15 ANALYZE das tabelas quentes ANALYZE subscribers, delivery_attempts, send_batches;
Semanal, domingo 04:30 REINDEX CONCURRENTLY do índice de fila REINDEX INDEX CONCURRENTLY ix_delivery_pending;
Mensal, dia 3 às 04:45 VACUUM (ANALYZE) na partição do mês anterior VACUUM (ANALYZE) message_logs_YYYY_MM;
Mensal, dia 3 às 05:00 ALTER INDEX ... SET (fillfactor) e reindex de índices com bloat > 30% script pnpm ops db:reindex-bloated
Trimestral Revisão de índices não usados consulta em 6.34.4

Índice parcial de fila é o que mais sofre: linhas entram e saem do índice o tempo todo, porque a condição status IN ('PLANNED','DEFERRED','FAILED') deixa de valer assim que a entrega conclui. Reindexar semanalmente mantém a árvore compacta. CONCURRENTLY evita bloqueio de escrita.

6.34.4 Detecção de índice inútil #

SELECT s.relname AS tabela, s.indexrelname AS indice,
       s.idx_scan AS varreduras,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS tamanho
  FROM pg_stat_user_indexes s
  JOIN pg_index i ON i.indexrelid = s.indexrelid
 WHERE s.idx_scan < 50
   AND NOT i.indisunique
   AND pg_relation_size(s.indexrelid) > 10 * 1024 * 1024
 ORDER BY pg_relation_size(s.indexrelid) DESC;

Índice não único, maior que 10 MB e com menos de 50 varreduras em um trimestre é candidato a remoção. A decisão é registrada como migration, com o motivo no comentário. Índices únicos nunca entram na lista: eles existem por correção, não por desempenho.

6.34.5 Alertas de banco #

Alerta Condição Ação
Bloat crítico n_dead_tup > 500000 em qualquer tabela VACUUM manual e revisão dos parâmetros.
Partição faltando linha inserida em message_logs_default Executar ensure_message_logs_partition() imediatamente.
Transação longa consulta ativa há mais de 5 minutos Investigar; transação longa impede o autovacuum de limpar.
Conexões uso acima de 80% de max_connections Revisar o pool da aplicação.
Crescimento pg_database_size acima de 80% do disco Antecipar o arquivamento de partições.
Replicação de backup último backup com mais de 26 horas Verificar o job de backup.

6.35 Consultas frequentes e planos de índice #

As oito operações que mais rodam. Cada uma com o índice que a atende.

6.35.1 Selecionar destinatários do envio diário #

Roda uma vez por dia, às 05:40, e é a consulta mais importante do sistema.

SELECT s.id, s.phone_e164, s.wa_id, s.tier,
       (s.service_window_expires_at > now()) AS window_open
  FROM subscribers s
 WHERE s.opt_in_confirmed_at IS NOT NULL
   AND s.opt_out_at IS NULL
   AND s.deleted_at IS NULL
   AND s.blocked_at IS NULL
   AND (s.tier = 'PAID' OR EXTRACT(DOW FROM (now() AT TIME ZONE 'America/Sao_Paulo')) = 0)
 ORDER BY s.id
 LIMIT 1000 OFFSET 0;

Índice: ix_subscribers_send_eligible (parcial em (tier, status)). Plano: Index Scan sobre o índice parcial. O predicado do índice já elimina quem não tem opt-in, quem saiu e quem foi excluído, então o planejador só percorre o conjunto elegível. Em 10.000 assinantes com 8.500 elegíveis, é varredura de índice de ~8.500 entradas em vez de Seq Scan em 10.000 linhas largas. Nota de paginação: o planejador percorre em blocos de 1.000 usando cursor por id (WHERE id > :lastId), não OFFSET, para manter o custo constante.

6.35.2 Buscar o próximo lote de tentativas pendentes #

Roda continuamente durante a janela de envio, várias vezes por segundo.

UPDATE delivery_attempts
   SET status = 'IN_FLIGHT', started_at = now(), attempt_count = attempt_count + 1,
       updated_at = now()
 WHERE id IN (
   SELECT id FROM delivery_attempts
    WHERE status IN ('PLANNED','DEFERRED','FAILED')
      AND attempt_count < max_attempts
      AND (next_retry_at IS NULL OR next_retry_at <= now())
    ORDER BY next_retry_at NULLS FIRST, id
    LIMIT 200
    FOR UPDATE SKIP LOCKED
 )
RETURNING id, subscriber_id, devotional_id, step, tier_at_send;

Índice: ix_delivery_pending (parcial em (next_retry_at NULLS FIRST, id)). Plano: Index Scan no índice parcial, limitado a 200 linhas, com SKIP LOCKED. Vários workers rodam a mesma consulta sem conflito: cada um pega um conjunto disjunto. Nenhum trabalho é processado duas vezes, nenhum worker espera por lock.

Esta consulta não decide o que sai. Ela desenfileira trabalho; ela não autoriza conteúdo. tier_at_send volta no RETURNING como informação histórica, e o motor precisa reler o estado do assinante por chave primária imediatamente antes de chamar o provedor:

SELECT tier, opt_out_at, blocked_at, deleted_at, paused_until
  FROM subscribers
 WHERE id = $1;

Índice: chave primária. Custo abaixo de 1 ms, sobre um lote de no máximo 12.400 itens. A ordem de decisão é a da Seção 18.5: deleted_at ou blocked_at preenchido encerra o item como SKIPPED_INELIGIBLE, sem chamada ao provedor; opt_out_at preenchido encerra como SKIPPED_OPTED_OUT; tier = 'FREE' em item planejado como PAID rebaixa o pacote na hora, grava downgraded_at e tier_at_send_effective = 'FREE', e nunca envia o áudio.

O motivo é a regra de produto mais dura que existe aqui: revogação é imediata, sem carência. O planejamento acontece às 05:40 e o disparo começa às 06:00, com varreduras de acompanhamento até 23:55. Se o pacote fosse decidido apenas pelo tier congelado às 05:40, todo assinante cujo pagamento vencesse nesse intervalo ganharia de graça o dia da revogação — que é exatamente a carência de um dia proibida por escrito. Uma leitura por chave primária é o preço de tornar a regra verdadeira também dentro dos 15 minutos de disparo.

6.35.3 Resolver o assinante a partir de um webhook de entrada #

Roda a cada mensagem recebida.

SELECT id, tier, opt_out_at, blocked_at, service_window_expires_at
  FROM subscribers
 WHERE wa_id_hmac = $1 AND deleted_at IS NULL
 LIMIT 1;

O parâmetro $1 é blindIndex(waId), calculado na aplicação. Nunca se compara a coluna cifrada: a cifra é aleatorizada por nonce, então dois envelopes do mesmo wa_id são bytes diferentes e a igualdade jamais casaria.

Índice: uq_subscribers_wa_id_hmac (único parcial). Plano: Index Scan com uma leitura. Se não encontrar, a aplicação tenta em ordem o HMAC do phone_e164 exato (índice uq_subscribers_phone_hmac), o HMAC da variante sem nono dígito e o HMAC da variante com nono dígito. As variantes são geradas antes de calcular o HMAC, porque o índice cego não tolera aproximação: cada variante é uma chave própria. Detalhe da regra em 6.36.1.

6.35.4 Registrar status de mensagem vindo do webhook #

Roda 2 a 3 vezes por mensagem enviada — o maior volume de escrita do sistema.

UPDATE message_logs
   SET status = $3,
       delivered_at = CASE WHEN $3 = 'DELIVERED' THEN $4 ELSE delivered_at END,
       read_at      = CASE WHEN $3 = 'READ'      THEN $4 ELSE read_at END,
       failed_at    = CASE WHEN $3 = 'FAILED'    THEN $4 ELSE failed_at END,
       error_code   = COALESCE($5, error_code)
 WHERE wamid = $1
   AND created_at >= $2::timestamptz - interval '3 days'
   AND created_at <  $2::timestamptz + interval '1 day';

Índice: uq_message_logs_wamid em (wamid, created_at). Plano: partition pruning pela faixa de created_at, depois Index Scan no índice único. A faixa é obrigatória: sem ela o Postgres varre todas as partições. A janela de 3 dias para trás cobre a reentrega atrasada de status; a Meta pode reenviar status de uma mensagem enviada dias antes.

6.35.5 Acervo do assinante no painel #

SELECT d.id, d.slug, d.title, d.scheduled_for, d.teaser,
       a.storage_key AS audio_key, a.duration_seconds
  FROM devotionals d
  LEFT JOIN audio_assets a
         ON a.devotional_id = d.id AND a.format = 'MP3' AND a.status = 'READY'
 WHERE d.status IN ('PUBLISHED','SENT')
   AND d.deleted_at IS NULL
   AND d.scheduled_for <= CURRENT_DATE
   AND ($1::text = 'PAID' OR d.scheduled_for > CURRENT_DATE - interval '7 days')
   AND ($2::date IS NULL OR d.scheduled_for < $2::date)
 ORDER BY d.scheduled_for DESC
 LIMIT 21;

Índices: ix_devotionals_status_date na tabela principal e ix_audio_devotional na junção. Plano: Index Scan Backward em (status, scheduled_for) com limite de 21 (20 itens mais um para saber se há próxima página), depois Nested Loop com Index Scan em audio_assets. O corte de 7 dias para tier FREE é aplicado no mesmo predicado, o que mantém o índice utilizável. $2 é o cursor: a data do último item da página anterior.

6.35.6 Extrato de cobranças do assinante #

SELECT p.id, p.status, p.billing_type, p.amount_cents, p.fee_cents, p.due_date,
       coalesce(p.confirmed_at, p.received_at) AS paid_at,
       p.invoice_url, p.receipt_url
  FROM payments p
 WHERE p.subscriber_id = $1
   AND ($2::char(26) IS NULL OR p.id < $2)
 ORDER BY p.created_at DESC, p.id DESC
 LIMIT 21;

Índice: ix_payments_subscriber_time em (subscriber_id, created_at DESC). Plano: Index Scan direto. O cursor por id funciona como desempate porque o ULID é monotônico: ordenar por id DESC equivale a ordenar por criação.

Duas notas que valem para toda lista deste documento:

  1. LIMIT 21 para uma página de 20. Busca-se uma linha a mais para saber se existe próxima página, o que evita uma consulta COUNT separada — em tabela grande, o COUNT custa mais do que a própria página.
  2. Nenhuma resposta paginada devolve total. A paginação é por cursor, e contagens agregadas vêm dos endpoints de métricas, que leem daily_metrics. O campo paid_at do SELECT acima é um apelido calculado, não uma coluna: payments.paid_at não existe.

6.35.7 Detectar divergência entre tier e assinatura #

Roda na reconciliação diária das 04:00.

SELECT s.id AS subscriber_id, s.tier AS tier_atual,
       sub.status AS status_assinatura, sub.current_period_end
  FROM subscribers s
  LEFT JOIN subscriptions sub ON sub.id = s.current_subscription_id
 WHERE s.deleted_at IS NULL
   AND (
        (s.tier = 'PAID' AND (sub.id IS NULL
                              OR sub.status <> 'ACTIVE'
                              OR sub.current_period_end < now()))
     OR (s.tier = 'FREE' AND sub.status = 'ACTIVE'
                         AND sub.current_period_end > now())
   );

Índices: ix_subscribers_current_subscription e a chave primária de subscriptions. Plano: Seq Scan em subscribers com Nested Loop e Index Scan em subscriptions. Varredura sequencial é aceitável aqui: são 10.000 linhas, uma vez por dia, fora do horário de pico. O resultado esperado é zero linhas; qualquer linha vira alerta e correção automática registrada em subscription_events com actor = 'reconciliation'.

6.35.8 Métricas do dia para o painel #

Série temporal de uma métrica, que é o acesso dominante do painel:

SELECT metric_date, value, numerator, denominator
  FROM daily_metrics
 WHERE metric_key = $1
   AND dimension = $2          -- string vazia quando a metrica nao e quebrada
   AND metric_date BETWEEN $3 AND $4
 ORDER BY metric_date;

Índice: daily_metrics_key_date_idx em (metric_key, metric_date DESC). Plano: Index Scan sobre uma faixa. Para 90 dias, são 90 linhas.

Todas as métricas de um dia, para a visão geral:

SELECT metric_key, dimension, value, numerator, denominator
  FROM daily_metrics
 WHERE metric_date = $1
 ORDER BY metric_key, dimension;

Índice: daily_metrics_date_idx em (metric_date DESC).

É por isso que a tabela existe: a alternativa seria agregar milhões de linhas de message_logs a cada carregamento do painel, o que levaria segundos em vez de milissegundos.

Uma armadilha que o formato longo evita e que vale enunciar. Para agregar uma razão sobre um período, some numerator e denominator e divida no fim — nunca tire a média dos value diários. A taxa de entrega da semana é sum(numerator) / sum(denominator), não avg(value): um dia com 10 envios e um dia com 10.000 pesam igual na média e não deveriam. É exatamente para tornar essa conta possível que as duas colunas são gravadas junto do resultado.

6.36 Regras transversais obrigatórias #

6.36.1 phone_e164 versus wa_id #

O problema é concreto: no Brasil, celulares têm nove dígitos após o DDD, mas a plataforma do WhatsApp historicamente identifica alguns números sem o nono dígito. O wa_id devolvido pode não ser igual ao número cadastrado.

Aspecto phone_e164 wa_id
Origem Digitado pelo usuário, normalizado por libphonenumber-js com região BR Devolvido pela plataforma no primeiro contato
Formato em claro +5511987654321 (com +, com nono dígito) 551187654321 (sem +, pode não ter nono dígito)
Formato no banco Envelope cifrado v1:<iv>:<ct>:<tag> Envelope cifrado, mesmo formato
Preenchimento Sempre, no cadastro Só no primeiro contato bem-sucedido
Coluna de busca phone_hmac wa_id_hmac
Índice UNIQUE (phone_hmac) WHERE deleted_at IS NULL UNIQUE (wa_id_hmac) WHERE wa_id_hmac IS NOT NULL
Papel Identidade canônica do assinante Chave de resolução do canal

A normalização do telefone tem uma única fonte: normalizePhoneBR() de packages/core/src/phone.ts, cuja regra completa (nono dígito, DDD, DDI) está na Seção 11.4. Nenhuma outra seção reimplementa a regra, e nenhuma outra função produz um phone_e164.

Ordem de resolução no webhook de entrada, implementada no mesmo módulo:

export async function resolveSubscriber(waId: string): Promise<Subscriber | null> {
  // 1. wa_id exato — o caminho normal depois do primeiro contato.
  //    A busca e sempre pelo indice cego; a coluna cifrada nunca e comparada.
  const byWaId = await repo.findByWaIdHmac(blindIndex(waId));
  if (byWaId) return byWaId;

  const e164 = '+' + waId;
  // 2. phone_e164 exato
  const exact = await repo.findByPhoneHmac(blindIndex(e164));
  if (exact) return await repo.attachWaId(exact.id, waId);

  // 3. variante SEM o nono dígito  (+5511987654321 -> +551187654321)
  const without = dropNinthDigit(e164);
  if (without) {
    const found = await repo.findByPhoneHmac(blindIndex(without));
    if (found) return await repo.attachWaId(found.id, waId);
  }

  // 4. variante COM o nono dígito  (+551187654321 -> +5511987654321)
  const withNine = addNinthDigit(e164);
  if (withNine) {
    const found = await repo.findByPhoneHmac(blindIndex(withNine));
    if (found) return await repo.attachWaId(found.id, waId);
  }

  return null; // número desconhecido: registra em inbound_messages sem subscriber_id
}

Cada variante é normalizada antes de virar HMAC. Índice cego não tolera aproximação: +5511987654321 e +551187654321 produzem hashes sem nenhuma relação entre si, então as quatro tentativas são quatro chaves distintas, e não uma busca tolerante.

attachWaId grava o wa_id cifrado e o wa_id_hmac na primeira resolução bem-sucedida, de modo que as próximas mensagens caem no passo 1. Se o INSERT violar o índice único parcial — situação real quando dois cadastros diferentes apontam para o mesmo número na plataforma — a operação registra alerta WA_ID_CONFLICT, não vincula, e o suporte decide qual conta mantém o canal. Nenhum dado é sobrescrito automaticamente.

Troca de número e chip reciclado. Números celulares brasileiros são reemitidos pelas operadoras, e a posse do número é o único fator de autenticação do assinante. Por isso, qualquer mudança do wa_id associado a um telefoneattachWaId sobre uma linha que já tinha wa_id diferente, ou troca de número solicitada pelo titular — dispara, na mesma transação:

  1. wa_id_changed_at = now();
  2. revogação de todas as sessões daquele assinante, com revoked_reason = 'wa_id_changed';
  3. exigência de nova verificação por código antes de liberar qualquer dado pessoal.

Nunca se entrega conta, CPF ou histórico a um número apenas porque ele respondeu no WhatsApp. O caso concreto é direto: alguém cancela a linha, a operadora a reemite meses depois, o novo dono recebe o devocional, pede o código no painel, recebe no próprio aparelho e entraria como a titular anterior — com acesso ao CPF dela, ao valor e à data de cada cobrança e a 18 meses de mensagens. A revogação de sessões fecha a porta de dentro; a sessão RESTRICTED por dormência (Seção 8.15.1.1) fecha a de fora.

Números com DDI diferente de 55 são aceitos pelo tipo da coluna mas rejeitados no cadastro com UNSUPPORTED_COUNTRY_CODE (Seção 7.11). A coluna não restringe o DDI para não exigir migration quando o produto abrir outros países.

6.36.2 Idempotência: as quatro chaves #

Operação Tabela Chave Índice O que impede
Planejar o dia send_batches plan:{devotionalDate}, ou retry:{parentBatchId} no reprocessamento uq_send_batches_idempotency Dois planejamentos do mesmo dia, e dois reprocessamentos do mesmo lote.
Enviar uma etapa delivery_attempts send:{subscriberId}:{devotionalDate}:{step} uq_delivery_idempotency_key O mesmo assinante receber a mesma etapa duas vezes.
Processar cobrança payment_events asaas_event_id uq_payment_events_asaas_event_id O mesmo evento de pagamento alterar a assinatura duas vezes.
Processar entrada inbound_messages wamid uq_inbound_wamid A mesma mensagem abrir a janela ou disparar opt-out duas vezes.

Todas usam o mesmo padrão: INSERT ... ON CONFLICT DO NOTHING seguido de verificação de linhas afetadas. Zero linhas significa "já foi feito", e o caminho de sucesso é retornado sem efeito colateral. Nunca ON CONFLICT DO UPDATE, porque atualizar uma reentrega reabriria a possibilidade de efeito duplicado.

Exemplo de reentrega do webhook de pagamento, o cenário mais crítico:

12:00:01  Provedor envia PAYMENT_CONFIRMED (evt_abc123)
12:00:01  INSERT em payment_events → 1 linha → enfileira processamento
12:00:02  Resposta 200 em 340 ms
12:00:03  Worker processa: subscription ACTIVE, subscriber tier PAID
12:00:45  Provedor não registrou o 200 (timeout de rede) e reenvia evt_abc123
12:00:45  INSERT em payment_events → 0 linhas (conflito)
12:00:45  Resposta 200 com {"received":true,"duplicate":true} em 12 ms
          Nenhum reprocessamento. Assinatura permanece ACTIVE, uma única vez.

Duas tabelas nunca aceitam UPDATE nem DELETE pela aplicação:

  • consent_events — é a prova jurídica de consentimento. Alterar destrói a defesa.
  • admin_audit_log — é a trilha de auditoria. Alterar destrói a confiança nela.

Proteção em três camadas:

  1. Permissão. O papel palavra_diaria_app tem apenas SELECT e INSERT (M13).
  2. Trigger. consent_events_immutable() e admin_audit_log_immutable() levantam exceção mesmo se a permissão for concedida por engano (6.30.6).
  3. Modelo. Nenhuma das duas tem updated_at ou deleted_at, então não existe caminho de código que pareça legítimo.

Correção de dado errado é feita por novo evento que descreve a correção, nunca por alteração do anterior. Exemplo: consentimento registrado com o canal errado gera uma nova linha do mesmo tipo, com evidence explicando a correção e referência ao id original.

Duas exceções, e apenas duas, estão codificadas dentro do próprio gatilho — nenhuma delas desabilita nada:

Exceção Papel O que pode fazer O que continua imutável
Eliminação por LGPD privacy_operator UPDATE que zera apenas ip e user_agent Em consent_events: type, granted, policy_version, consent_text_hash, occurred_at, created_at. Em admin_audit_log: admin_user_id, actor_type, actor_role, action, record_hash, created_at
Expurgo por retenção retention_operator DELETE de linhas com mais de 5 anos Qualquer linha dentro do prazo legal

A primeira exceção existe porque um controle de integridade não pode impedir o cumprimento de um direito do titular. Um gatilho que recusasse todo UPDATE faria a transação de anonimização reverter por inteiro — inclusive a parte já aplicada em subscribers — e a eliminação nunca aconteceria para titular nenhum, com o prazo legal de 30 dias vencendo sem que ninguém percebesse. O caminho é privilegiado, explícito, restrito coluna a coluna e auditado: cada execução grava subscriber.anonymized em admin_audit_log.

Nenhum gatilho é desabilitado em momento algum. Desabilitar exigiria ser dono da tabela — o que esses papéis não são — e abriria uma janela em que qualquer escrita passa.

6.36.4 Soft delete: apenas três tabelas #

Tabela Coluna Efeito Quem filtra
subscribers deleted_at Some das listas, para de receber, não pode logar Todos os índices relevantes são parciais com WHERE deleted_at IS NULL
devotionals deleted_at Some do calendário e do acervo, libera a data para novo devocional uq_devotionals_scheduled_for é parcial
admin_users deleted_at Não faz login, some da lista, libera o e-mail uq_admin_users_email é parcial

As outras 25 tabelas usam hard delete ou retenção por tempo. A razão de limitar o soft delete é direta: cada tabela com soft delete adiciona um predicado que toda consulta precisa lembrar de aplicar, e esquecer um deles é um vazamento silencioso de dado excluído. Três tabelas é o número que dá para revisar; vinte e oito, não.

deleted_at não tem valor correspondente em SubscriberStatus. Exclusão é uma coluna, não um estado: o enum descreve o que o assinante é no serviço, e a exclusão o retira do serviço inteiro. Os índices parciais de 6.3.1 já embutem WHERE deleted_at IS NULL, então a consulta correta é também a consulta rápida.

Regra de código obrigatória: todo acesso a essas três tabelas passa por um repositório em packages/db que aplica o filtro por padrão. Ler linhas excluídas exige um método explícito com sufixo IncludingDeleted, e cada uso desse método é justificado em comentário. O teste de arquitetura de pnpm test:arch falha se prisma.subscriber for usado diretamente fora do repositório.

6.36.5 O que nunca é persistido #

Lista fechada, verificada por revisão de código e por teste automatizado que varre o schema em busca de nomes proibidos:

Dado Por quê
Número completo de cartão, CVV, validade Escopo de PCI; a tokenização é do provedor. Só bandeira e 4 últimos dígitos (6.13).
Código OTP em claro Só o hash com pepper (6.7).
Refresh token em claro Só o hash (6.6).
Segredo TOTP em claro Cifrado com AES-256-GCM (6.8).
CPF em claro Envelope cifrado, mais índice cego para detecção e 4 últimos dígitos para conferência (6.4).
Telefone em claro Envelope cifrado, mais phone_hmac para busca (6.3). Vale em subscribers, otp_codes, message_logs e inbound_messages.
wa_id em claro Envelope cifrado, mais wa_id_hmac para resolução do canal (6.3).
E-mail de assinante em claro Envelope cifrado, mais email_hmac (6.3). O e-mail de administrador continua em citext: não é dado de titular e é o login do painel.
Senha de administrador Só o hash argon2id (6.8).
Cookie de dispositivo confiável em claro Só o sha256 em admin_trusted_devices.token_hash (6.8.4).
Corpo completo de mensagem enviada Só um trecho de 240 caracteres em message_logs.payload_excerpt, zerado na anonimização.
Cabeçalho Authorization de webhook webhook_deliveries.headers é redigido antes de gravar.
URL assinada de mídia É credencial, não endereço: quem tem a URL tem o arquivo. Nenhuma coluna e nenhum log a guardam; a referência é sempre a chave do objeto, sem host e sem assinatura.

7. Design de API — Contratos, Erros e Padrões #

Esta seção é a dona do envelope de resposta, do catálogo de códigos de erro, da paginação, do versionamento, do rate limiting e das convenções de rota. Toda outra seção que descreve um endpoint herda estas regras e referencia esta seção em vez de repeti-las.

O que não está aqui: o comportamento de negócio de cada rota. Isso pertence às seções donas de cada domínio, listadas no inventário de 7.16.

7.1 Princípios #

  1. Uma forma de resposta. Toda rota /api/* devolve o mesmo envelope, sucesso ou erro. O cliente escreve um único parser.
  2. Erro é dado, não texto. O cliente decide pelo code, nunca pela message. A message é para o ser humano.
  3. Idempotência explícita. Toda rota que cria recurso ou cobra dinheiro aceita Idempotency-Key e se comporta de forma previsível em repetição.
  4. Nada de segredo em resposta de erro. Erro nunca revela existência de recurso alheio, stack trace, nome de tabela, SQL ou valor de variável de ambiente.
  5. Validação na borda. Todo corpo, query e parâmetro de rota passa por um schema Zod antes de tocar a camada de domínio. O tipo do handler é derivado do schema, não declarado à mão.
  6. Compatível para frente. Adicionar campo à resposta nunca quebra o cliente. Remover ou renomear campo exige versão nova (7.2.4).

7.2 Convenções de rota e versionamento #

7.2.1 Forma das rotas #

Regra Exemplo correto Exemplo errado
kebab-case /api/admin/whatsapp-templates /api/admin/whatsappTemplates
Substantivo no plural /api/signups /api/signup
Sem verbo no caminho POST /api/signups POST /api/create-subscription
Sub-recurso aninhado em no máximo dois níveis /api/admin/devotionals/{id}/revisions/{rid}/restorations /api/admin/devotionals/{id}/revisions/{rid}/diffs/{did}/lines
Singleton com nome próprio /api/me/subscription /api/subscriptions/me
Ação sem forma de recurso vira sub-recurso de ação POST /api/admin/devotionals/{id}/status-changes POST /api/publish-devotional

Ações que não são CRUD são expostas como POST em sub-recurso, nunca como verbo no caminho. É a exceção deliberada à regra "sem verbo": modelar "publicar" como PATCH de status esconde a operação e impede validação específica. Duas formas, e apenas duas:

  1. Ação administrativa ou sobre recurso de terceiro vira sub-recurso substantivo no plural (/cancellations, /resends, /reactivations, /status-changes, /restorations, /impersonations, /retries), nunca verbo (/cancel, /resend, /publish, /retry).
  2. Ação sobre a própria conta do assinante fica sob /api/me/ com nome de ação no singular: POST /api/me/opt-out, POST /api/me/pause, POST /api/me/deletion-request, POST /api/me/subscription/cancel. Ali o sujeito é único e vem da sessão, então não existe coleção a nomear — pluralizar produziria /api/me/opt-outs para um recurso que nunca tem mais de um item vivo.

Sub-recurso aninhado vai a no máximo dois níveis. O terceiro nível exige recurso de topo próprio, com identificador estável: quando o caminho precisa de três saltos para chegar ao objeto, o objeto merece existir sozinho.

O terceiro nível também é o ponto em que o cursor de paginação (7.8) deixa de ser derivável de uma única chave de ordenação, o que é a razão técnica do limite.

7.2.2 Prefixos #

Prefixo Uso Autenticação
/api/public/* Dados abertos: planos, devocional de demonstração Nenhuma
/api/auth/* Login, OTP, desafio de segundo fator, listagem e revogação de sessão Varia por rota
/api/signups/* Cadastro em andamento, antes de existir sessão Nenhuma ou cookie de cadastro
/api/me/* Recursos e ações da conta do assinante autenticado, inclusive assinatura, cobranças e opt-out Sessão SUBSCRIBER
/api/devotionals/* Acervo e conteúdo do dia, sob sessão de assinante Sessão SUBSCRIBER
/api/checkout/* Tokenização de cartão e passos de contratação Sessão SUBSCRIBER
/api/admin/* Painel administrativo Sessão EDITOR, ADMIN ou OWNER
/api/webhooks/* Entrada de provedores externos Assinatura ou token, nunca sessão
/api/internal/* Saúde e métricas Rede interna ou token de operação

Todo caminho /api/* do produto cai em um destes prefixos. Não existe rota sem prefixo declarado: um prefixo novo exige linha nova nesta tabela, com a autenticação exigida escrita ao lado, porque é esta tabela que o teste de arquitetura percorre para decidir qual guarda cada rota precisa carregar.

Os prefixos /api/devotionals/* e /api/checkout/* são de assinante autenticado, e não de recurso público, apesar de não começarem com /api/me/. Eles existem porque o recurso tem identificador próprio no caminho — o devocional, o token de cartão — e ali a guarda de propriedade de 8.12 é obrigatória. Não existem os prefixos /api/sessions/*, /api/subscriptions/* nem /api/payments/*: a sessão vive sob /api/auth/*, e a assinatura e as cobranças do titular vivem sob /api/me/*, porque o sujeito é único e vem da sessão (7.16).

/api/me/* nunca aceita identificador de outro assinante. O identificador vem da sessão. Isso elimina uma classe inteira de falha de autorização por design — não existe parâmetro para adulterar.

7.2.3 Métodos #

Método Semântica Idempotente Corpo
GET Leitura Sim Não
POST Criação ou ação Não por padrão; sim com Idempotency-Key Sim
PATCH Atualização parcial Sim Sim
PUT Substituição total Sim Sim
DELETE Remoção ou desativação Sim Não

PUT é usado apenas em settings e em preferências, onde o recurso é um documento completo. Em todo o resto, PATCH.

7.2.4 Versionamento #

A API é interna ao produto: o único cliente é o front do próprio monorepo. Por isso não há prefixo de versão no caminho (/api/v1/...). Front e back sobem juntos no mesmo deploy.

Duas exceções, ambas com regra explícita:

  1. Webhooks de entrada têm caminho versionado (/api/webhooks/asaas, /api/webhooks/whatsapp). Mudança incompatível cria caminho novo com sufixo -v2, e o antigo continua respondendo por no mínimo 90 dias, porque o provedor externo não sobe junto com a gente.
  2. Mudança incompatível em rota consumida pelo front durante uma janela de deploy parcial é tratada por expansão e contração: primeiro adiciona o campo novo mantendo o antigo, depois o front passa a usar o novo, depois o antigo é removido em release separada. Nunca as três coisas no mesmo deploy.

O header X-Api-Build devolve o SHA curto do commit em toda resposta. É o que permite diagnosticar "o front está falando com uma versão antiga do back".

7.3 Envelope de sucesso #

Toda resposta 2xx de /api/* tem exatamente esta forma:

{
  "data": { "id": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB", "tier": "PAID" },
  "meta": {
    "requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
    "timestamp": "2026-08-25T09:00:00.000Z"
  }
}

Regras:

  • data é sempre presente em 2xx. Para lista, é um array. Para operação sem retorno útil, é um objeto com o resultado ({"ok": true}), nunca null.
  • meta.requestId e meta.timestamp são obrigatórios em toda resposta.
  • Nenhum campo fora de data e meta. Nada de success: true — o status HTTP já diz.
  • 204 No Content não é usado. Toda resposta tem corpo, para que o cliente sempre tenha o requestId disponível para suporte.

Resposta de lista acrescenta os campos de paginação em meta (7.8):

{
  "data": [
    { "id": "dev_01K3F8R3T5...", "title": "A força do silêncio", "scheduledFor": "2026-08-25" },
    { "id": "dev_01K3F8R2S4...", "title": "Quando a espera ensina", "scheduledFor": "2026-08-24" }
  ],
  "meta": {
    "requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
    "timestamp": "2026-08-25T09:00:00.000Z",
    "nextCursor": "eyJrIjoiMjAyNi0wOC0yNCIsImkiOiJkZXZfMDFLM0Y4UjJTNCJ9",
    "hasMore": true,
    "limit": 20
  }
}

Implementação única, usada por todos os handlers:

// apps/web/src/lib/api/envelope.ts
import { ulid } from 'ulid';

export type Envelope<T> = { data: T; meta: Meta };
export type Meta = {
  requestId: string;
  timestamp: string;
  nextCursor?: string | null;
  hasMore?: boolean;
  limit?: number;
};

export function ok<T>(data: T, requestId: string, extra: Partial<Meta> = {}): Response {
  const body: Envelope<T> = {
    data,
    meta: { requestId, timestamp: new Date().toISOString(), ...extra },
  };
  return Response.json(body, {
    status: 200,
    headers: { 'X-Request-Id': requestId, 'Cache-Control': 'no-store' },
  });
}

7.4 Envelope de erro #

Toda resposta 4xx e 5xx tem exatamente esta forma:

{
  "error": {
    "code": "SUBSCRIBER_NOT_FOUND",
    "message": "Assinante não encontrado.",
    "details": [{ "field": "phone", "issue": "invalid_format" }]
  },
  "meta": {
    "requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH",
    "timestamp": "2026-08-25T09:00:00.000Z"
  }
}
Campo Tipo Obrigatório Regra
error.code string Sim SCREAMING_SNAKE_CASE. Estável para sempre. Catálogo em 7.11.
error.message string Sim Português do Brasil, seguro para exibir ao usuário final. Nunca contém identificador interno, SQL, caminho de arquivo ou nome de tabela.
error.details array Não Lista de { field, issue }. Presente em erros de validação e em conflitos com causa específica.
error.retryAfterSeconds number Não Presente em 429 e em 503. Segundos até a próxima tentativa útil.

details[].issue usa um vocabulário fechado, em inglês, para que o front possa mapear para texto próprio quando quiser: required, invalid_format, too_short, too_long, out_of_range, not_unique, already_in_use, unsupported_value, mismatch, expired, not_allowed.

Erro de validação com múltiplos campos:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Alguns campos precisam de correção.",
    "details": [
      { "field": "phone", "issue": "invalid_format" },
      { "field": "cpfCnpj", "issue": "invalid_format" },
      { "field": "acceptedTerms", "issue": "required" }
    ]
  },
  "meta": { "requestId": "req_01K3F8RMN3QWERTYUIOPASDFGH", "timestamp": "2026-08-25T09:00:00.000Z" }
}

O handler central converte qualquer exceção não tratada em INTERNAL_ERROR com status 500 e mensagem genérica. O erro real vai para o log com o mesmo requestId, nunca para a resposta.

// apps/web/src/lib/api/errors.ts
export class AppError extends Error {
  constructor(
    readonly code: ErrorCode,
    readonly status: number,
    message: string,
    readonly details?: ErrorDetail[],
    readonly retryAfterSeconds?: number,
  ) { super(message); }
}

export function fail(err: unknown, requestId: string, log: Logger): Response {
  if (err instanceof AppError) {
    log.warn({ requestId, code: err.code, status: err.status }, 'api error');
    return Response.json(
      { error: { code: err.code, message: err.message, details: err.details,
                 retryAfterSeconds: err.retryAfterSeconds },
        meta: { requestId, timestamp: new Date().toISOString() } },
      { status: err.status,
        headers: { 'X-Request-Id': requestId, 'Cache-Control': 'no-store' } },
    );
  }
  if (err instanceof ZodError) return fail(fromZod(err), requestId, log);
  log.error({ requestId, err }, 'unhandled error');   // stack só no log
  return fail(
    new AppError('INTERNAL_ERROR', 500, 'Ocorreu um erro inesperado. Tente novamente.'),
    requestId, log,
  );
}

A classe de erro da aplicação chama-se AppError em todo o documento e em todo o código, sem exceção. Ela vive em packages/core/src/errors.ts, junto do catálogo de 7.11, e é a única exceção que o invólucro withApi (8.11.3) converte em resposta estruturada. O nome ApiError não existe: uma segunda classe com o mesmo papel faria metade dos catch do código deixar de reconhecer o erro e cair no caminho de INTERNAL_ERROR.

7.5 Status HTTP por categoria #

Status Quando Códigos típicos
200 OK Sucesso de leitura, atualização ou ação
201 Created Criação de recurso. Inclui header Location
400 Bad Request Corpo malformado, JSON inválido, parâmetro sem sentido sintático INVALID_JSON, INVALID_CURSOR
401 Unauthorized Sem sessão, sessão expirada ou credencial inválida UNAUTHENTICATED, SESSION_EXPIRED
403 Forbidden Autenticado, mas sem permissão FORBIDDEN, INSUFFICIENT_ROLE
404 Not Found Recurso inexistente ou existente e não pertencente ao solicitante SUBSCRIBER_NOT_FOUND, NOT_FOUND
409 Conflict Estado atual do recurso impede a operação SUBSCRIPTION_ALREADY_ACTIVE, DEVOTIONAL_DATE_TAKEN
410 Gone Recurso existiu e foi removido de forma permanente SUBSCRIBER_DELETED
413 Payload Too Large Corpo acima do limite de 7.14 PAYLOAD_TOO_LARGE
415 Unsupported Media Type Content-Type não é application/json UNSUPPORTED_MEDIA_TYPE
422 Unprocessable Entity Sintaxe correta, semântica inválida ou regra de negócio violada VALIDATION_ERROR, INVALID_PHONE_NUMBER
429 Too Many Requests Rate limit RATE_LIMITED
500 Internal Server Error Falha não esperada INTERNAL_ERROR
502 Bad Gateway Provedor externo devolveu resposta inválida PAYMENT_PROVIDER_ERROR
503 Service Unavailable Dependência indisponível ou modo manutenção SERVICE_UNAVAILABLE, MAINTENANCE_MODE
504 Gateway Timeout Provedor externo estourou o tempo UPSTREAM_TIMEOUT

Distinção entre 400 e 422, aplicada sem exceção: 400 é falha de sintaxe (não deu para interpretar); 422 é falha de semântica (interpretei, mas não posso aceitar). {"limit": "vinte"} é 422 porque o JSON é válido e o campo existe; {"limit": é 400.

404 é deliberadamente usado tanto para "não existe" quanto para "existe mas não é seu". A justificativa está em 8.12: distinguir os dois casos vaza a existência do recurso.

7.6 requestId e correlação #

  • Formato: ULID com prefixo req_. Exemplo: req_01K3F8RMN3QWERTYUIOPASDFGH.
  • Gerado no middleware, antes de qualquer outra coisa.
  • Se o cliente enviar X-Request-Id com um ULID válido, ele é reaproveitado; caso contrário, é gerado. Valor inválido é descartado em silêncio e substituído.
  • Ecoado no header X-Request-Id de toda resposta, inclusive de erro.
  • Presente em meta.requestId de toda resposta.
  • Presente em todas as linhas de log da requisição, e propagado para jobs enfileirados durante ela (job_runs.request_id) e para chamadas a provedores externos.
// apps/web/src/middleware.ts
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;

export function resolveRequestId(header: string | null): string {
  const raw = header?.startsWith('req_') ? header.slice(4) : header;
  return raw && ULID_RE.test(raw) ? `req_${raw}` : `req_${ulid()}`;
}

Reaproveitar o identificador do cliente é o que permite ao assinante mandar um print da tela de erro para o suporte e o suporte achar a linha exata no log. Aceitar apenas ULID válido impede que um cliente injete conteúdo arbitrário no log.

7.7 Idempotência via Idempotency-Key #

7.7.1 Onde é exigido #

Rota Header Comportamento sem o header
POST /api/me/subscription Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/me/subscription/cancel Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/me/subscription/plan-change Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/devotionals/{devotionalId}/resends Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/admin/subscribers/{id}/courtesy Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/admin/batches Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/admin/batches/{batchId}/retries Obrigatório 400 IDEMPOTENCY_KEY_REQUIRED
POST /api/admin/devotionals/{id}/status-changes Opcional Executa normalmente
POST /api/auth/otp/request Opcional Executa normalmente
Demais POST Opcional Executa normalmente

A regra: exigido em toda rota que gasta dinheiro, envia mensagem ao assinante ou cria recurso caro de desfazer.

As sete rotas marcadas como obrigatórias propagam escrita para fora do nosso banco — para o provedor de pagamento, para a plataforma de mensagens, ou para os dois. Nelas, a ausência do header é erro de programação do cliente e devolve 400 IDEMPOTENCY_KEY_REQUIRED antes de qualquer efeito; a repetição da mesma chave devolve a resposta gravada com Idempotent-Replay: true; e a repetição com chave nova depois de a operação já ter concluído devolve o conflito de estado da própria rota (por exemplo, 409 SUBSCRIPTION_NOT_ACTIVE no cancelamento), sem chamar o provedor de novo. Nenhuma seção de domínio pode declarar o header como opcional em qualquer uma delas.

7.7.2 Formato e armazenamento #

  • Formato aceito: 16 a 128 caracteres de [A-Za-z0-9_-]. Recomendado: um ULID gerado pelo cliente por intenção do usuário, não por tentativa de requisição.
  • Armazenamento: Redis, chave idem:{scope}:{subjectId}:{sha256(idempotencyKey)}, onde scope é o nome da rota.
  • TTL: 24 horas.
  • O escopo inclui o identificador do sujeito autenticado. Duas pessoas usando a mesma chave por acaso não colidem.

O TTL de 24 horas cobre o duplo clique do usuário e a retentativa automática do cliente, não a reexecução operacional. A idempotência de operações reprocessadas dias depois — um webhook reenviado manualmente em D+3, um lote reprocessado pelo painel — é garantida por chave persistida em banco, e não pelo Redis: payment_events.asaas_event_id, delivery_attempts.idempotency_key e send_batches.idempotency_key. O operador que reprocessa não deve contar com a camada HTTP, porque a chave já expirou muito antes.

7.7.3 Máquina de estados do replay #

Requisição chega com Idempotency-Key
        │
        ▼
SET idem:{...} = {state:"in_progress", fingerprint} NX EX 86400
        │
        ├── OK (chave nova) ──► executa o handler
        │                        ├── sucesso: SET {state:"done", status, body} EX 86400 → resposta
        │                        └── erro 5xx: DEL da chave → resposta de erro
        │                            (erro 4xx determinístico é gravado como "done")
        │
        └── já existia
             ├── fingerprint do corpo DIFERENTE ──► 422 IDEMPOTENCY_KEY_REUSED
             ├── state = "in_progress"          ──► 409 IDEMPOTENCY_IN_PROGRESS
             └── state = "done"                 ──► repete status e corpo gravados,
                                                    com header Idempotent-Replay: true
                                                    e requestId NOVO em meta

fingerprint é o sha256 do corpo canonicalizado (chaves ordenadas, espaços removidos). Reusar a mesma chave com corpo diferente é erro do cliente e é reportado como tal — nunca executa a operação nova.

Erro 5xx apaga a chave para que o cliente possa tentar de novo com a mesma chave. Erro 4xx determinístico é gravado, porque repetir a mesma requisição inválida deve dar o mesmo resultado sem custo.

7.7.4 Exemplo completo #

# 1ª chamada
curl -X POST https://app.palavradiaria.com.br/api/me/subscription \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 01K3F8T9Q2XKLMNOPQRSTUVWXY' \
  -H 'Cookie: __Host-session=...' \
  -d '{"planCode":"plan_monthly","billingType":"PIX","cpfCnpj":"12345678909"}'
# HTTP/1.1 201 Created
# Location: /api/me/subscription
# X-Request-Id: req_01K3F8T9R3AAAAAAAAAAAAAAAA

# 2ª chamada, idêntica (o usuário clicou duas vezes)
# HTTP/1.1 201 Created
# Idempotent-Replay: true
# X-Request-Id: req_01K3F8T9S4BBBBBBBBBBBBBBBB   <- novo, corpo idêntico
# Nenhuma cobrança adicional foi criada no provedor.

# 3ª chamada, mesma chave, corpo diferente
# HTTP/1.1 422 Unprocessable Entity
# {"error":{"code":"IDEMPOTENCY_KEY_REUSED",
#           "message":"Esta chave de idempotência já foi usada com outros dados."}}

A idempotência de nível HTTP em 7.7 é uma camada de conveniência. A garantia dura está no banco, nos índices únicos de 6.36.2. Se o Redis cair inteiro, nenhuma cobrança duplicada acontece — o índice uq_subscriptions_one_live_per_subscriber continua impedindo.

7.8 Paginação por cursor #

Nunca offset. Nunca page. Sempre cursor.

7.8.1 Contrato #

Parâmetro Tipo Padrão Limite Descrição
limit inteiro 20 1 a 100 Itens por página.
cursor string opaca ausente Posição devolvida em meta.nextCursor.

Resposta em meta: nextCursor (string ou null), hasMore (booleano), limit (o valor efetivamente aplicado).

Nenhuma resposta paginada devolve total, page, perPage ou totalPages. O tipo Meta de 7.3 tem exatamente os cinco campos declarados ali e nenhum a mais. Contagem agregada é responsabilidade das rotas de métricas da Seção 21.9, que a servem de daily_metrics já consolidado. Uma seção de domínio que precise exibir "412 devocionais" em um contador de tela chama a rota de métricas, nunca acrescenta um campo à paginação.

O motivo é de custo, não de estética: devolver total exige um COUNT separado sobre o mesmo predicado, que em tabela grande custa mais do que a página inteira e piora exatamente na hora em que a base cresce.

7.8.2 Formato do cursor #

O cursor é um objeto JSON serializado em Base64URL sem padding. É opaco por contrato: o cliente não decodifica, não constrói e não interpreta.

type Cursor = {
  v: 1;              // versão do formato
  k: string | number; // valor da chave de ordenação primária do último item
  i: string;          // id do último item — desempate
};

export function encodeCursor(c: Cursor): string {
  return Buffer.from(JSON.stringify(c)).toString('base64url');
}

export function decodeCursor(raw: string): Cursor {
  let parsed: unknown;
  try {
    parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
  } catch {
    throw new AppError('INVALID_CURSOR', 400, 'Cursor de paginação inválido.');
  }
  const result = cursorSchema.safeParse(parsed);
  if (!result.success) {
    throw new AppError('INVALID_CURSOR', 400, 'Cursor de paginação inválido.');
  }
  return result.data;
}

Cursor de versão desconhecida (v diferente de 1) também devolve INVALID_CURSOR. Isso permite trocar o formato no futuro sem que um cursor antigo produza resultado errado.

7.8.3 Estabilidade #

A ordenação é sempre por um par (chave, id), nunca por chave sozinha:

WHERE (scheduled_for, id) < ($cursorKey, $cursorId)
ORDER BY scheduled_for DESC, id DESC
LIMIT $limit + 1

O id como desempate garante ordem total. Sem ele, dois devocionais na mesma data poderiam aparecer duas vezes ou sumir entre páginas.

Busca-se limit + 1 linhas. Se voltarem limit + 1, hasMore = true e a última é descartada. Isso evita uma consulta COUNT separada, que em tabela grande custa mais que a própria página.

Garantias explícitas do cursor:

  • Item inserido depois de a página 1 ser buscada não aparece em páginas seguintes se ficaria antes do cursor. Isso é correto: a paginação é um instantâneo do ponto de corte.
  • Item removido entre páginas não causa erro nem página curta anômala.
  • Cursor não expira. Um cursor de ontem funciona hoje, apontando para a mesma posição lógica. Se o item que gerou o cursor foi excluído, a comparação por par ainda funciona, porque compara valores, não existência.

7.8.4 Chave de ordenação por recurso #

Recurso Chave primária de ordenação Direção padrão
/api/devotionals scheduled_for DESC
/api/me/payments created_at DESC
/api/me/subscription/history occurred_at DESC
/api/admin/subscribers created_at DESC
/api/admin/devotionals scheduled_for DESC
/api/admin/cancellations created_at DESC
/api/admin/audit-logs created_at DESC
/api/admin/batches devotional_date DESC

7.9 Ordenação, filtros e busca #

7.9.1 Ordenação #

Parâmetro sort, valor no formato campo:direção. Apenas campos explicitamente permitidos por rota; qualquer outro devolve UNSUPPORTED_SORT_FIELD.

GET /api/admin/subscribers?sort=createdAt:desc&limit=50

Mudar a ordenação invalida o cursor: nextCursor só é válido para a mesma combinação de sort e filtros. O cursor carrega o hash dos parâmetros de consulta e a validação rejeita uso cruzado com INVALID_CURSOR.

7.9.2 Filtros #

Filtro é sempre um parâmetro nomeado, tipado por Zod. Não existe sintaxe genérica de consulta (nada de ?filter[status][eq]=ACTIVE), porque isso vira um mecanismo de consulta não indexável.

Padrão Exemplo Regra
Igualdade em enum ?status=ACTIVE Valor precisa estar no enum, senão INVALID_ENUM_VALUE
Múltiplos valores ?status=ACTIVE,PENDING_PAYMENT Lista separada por vírgula, máximo 10 valores
Faixa de data ?from=2026-08-01&to=2026-08-31 Ambos date. from maior que to devolve INVALID_DATE_RANGE
Booleano ?hasAudio=true true ou false
Busca textual ?q=silêncio Mínimo 2, máximo 60 caracteres

Toda combinação de filtro exposta corresponde a um índice existente da Seção 6. Filtro sem índice não é exposto — essa é a regra que impede a API de virar um gerador de varredura sequencial.

Faixa de data tem teto de 366 dias. Pedido maior devolve INVALID_DATE_RANGE com details: [{ "field": "to", "issue": "out_of_range" }].

7.10 Formatos canônicos #

Tipo Formato na API Exemplo Observação
Instante ISO 8601 com Z, milissegundos "2026-08-25T09:00:00.000Z" Sempre UTC. Nunca offset local.
Data de calendário YYYY-MM-DD "2026-08-25" Interpretada em America/Sao_Paulo.
Dinheiro inteiro em centavos, campo com sufixo Cents "amountCents": 1990 R$ 19,90. Nunca float, nunca string.
Custo unitário muito pequeno inteiro em milionésimos de BRL, campo com sufixo Micros "costMicros": 4200 R$ 0,0042. Custo por mensagem e por caractere.
Moeda ISO 4217 "currency": "BRL" Sempre acompanha o valor.
Telefone E.164 "phone": "+5511987654321" Nunca formatado com parênteses ou traço.
Identificador ULID com prefixo "id": "sub_01K3F8QZ7M..." Ver 6.1.3.
Enum SCREAMING_SNAKE_CASE "status": "ACTIVE" Igual ao banco.
Percentual inteiro em pontos-base, sufixo Bp "deliveryRateBp": 9743 97,43%.
Duração inteiro em segundos, sufixo Seconds "durationSeconds": 214
Booleano true/false Nunca 0/1, nunca "true".
Campos JSON camelCase "scheduledFor" Conversão de snake_case na borda.

Por que centavos inteiros: 19.90 em ponto flutuante binário não é exatamente 19,90. Somar 12 mensalidades produz erro acumulado. Com inteiros, 1990 × 12 = 23880 é exato.

Por que duas unidades: uma mensagem custa fração de centavo, e arredondar cada uma para centavo zeraria o custo de dezenas de milhares de envios. Valor cobrado do assinante vai sempre em *Cents (divisor para BRL: 100); custo unitário de mensagem, de caractere ou de mídia vai sempre em *Micros (divisor para BRL: 1.000.000). As duas unidades nunca entram na mesma fórmula sem conversão explícita — misturá-las erra o resultado por um fator de 10.000, e o erro é silencioso porque os dois lados são inteiros plausíveis.

Por que UTC no wire: o servidor não adivinha o fuso do cliente. A conversão para America/Sao_Paulo acontece no componente de apresentação, com date-fns-tz, uma única vez. date puro (scheduledFor) não leva hora nem fuso, porque "o devocional de 25 de agosto" é um fato de calendário, não um instante.

Locale: fixo em pt-BR. O header Accept-Language é ignorado. Toda mensagem de erro e todo texto ao usuário sai em português do Brasil.

7.11 Catálogo completo de códigos de erro #

code é contrato. Uma vez publicado, nunca muda de significado nem é removido. Código descontinuado é mantido e deixa de ser emitido.

7.11.1 Autenticação e sessão #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
UNAUTHENTICATED 401 Faça login para continuar. Rota protegida sem cookie de sessão válido Redireciona para o login
SESSION_EXPIRED 401 Sua sessão expirou. Entre novamente. JWT vencido e refresh também vencido Redireciona para o login
SESSION_REVOKED 401 Sua sessão foi encerrada. Entre novamente. sessions.revoked_at preenchido Limpa estado local e redireciona
SESSION_REUSE_DETECTED 401 Detectamos uso indevido da sessão. Entre novamente. Refresh token já rotacionado foi reapresentado Limpa tudo; a família de sessões foi revogada
INVALID_CREDENTIALS 401 E-mail ou senha incorretos. Login de admin com e-mail ou senha errados Mostra erro no formulário; não diz qual campo
ACCOUNT_LOCKED 403 Conta bloqueada por tentativas seguidas. Tente mais tarde. locked_until > now() Mostra o tempo restante
TOTP_REQUIRED 401 Informe o código do aplicativo autenticador. Login de admin passou na senha e falta o segundo fator Exibe o campo de TOTP
TOTP_INVALID 401 Código do autenticador inválido. TOTP errado ou fora da janela Permite nova tentativa
TOTP_ENROLLMENT_REQUIRED 403 Configure o autenticador antes de continuar. Admin sem TOTP enrolado Redireciona para o enrolamento
TOTP_ALREADY_ENROLLED 409 O autenticador já está configurado. Tentativa de enrolar duas vezes Ignora
RECOVERY_CODE_INVALID 401 Código de recuperação inválido ou já usado. Código de recuperação errado ou consumido Permite nova tentativa
PASSWORD_POLICY_VIOLATION 422 A senha não atende aos requisitos mínimos. Senha fraca na troca Exibe os requisitos; details traz o que falhou
PASSWORD_REUSE 422 Escolha uma senha diferente da atual. Nova senha igual à anterior Pede outra
PASSWORD_CHANGE_REQUIRED 403 Troque sua senha para continuar. must_change_password = true Redireciona para a troca
OTP_INVALID 401 Código incorreto. Código do WhatsApp errado Permite nova tentativa; details traz as restantes
OTP_EXPIRED 410 Este código expirou. Peça um novo. Passaram-se mais de 10 minutos desde a emissão Habilita o botão de reenvio sem pedir o código de novo
OTP_MAX_ATTEMPTS 429 Muitas tentativas. Peça um novo código. 5 tentativas erradas contra o mesmo código Invalida o código e oferece reenvio
OTP_ATTEMPTS_EXCEEDED 429 Muitas tentativas de verificação. Tente mais tarde. Teto de 30 verificações por hora para o mesmo IP (8.2.1) Aguarda retryAfterSeconds
OTP_ALREADY_USED 409 Este código já foi utilizado. Código consumido Oferece novo envio
OTP_RESEND_TOO_SOON 429 Aguarde antes de pedir outro código. Reenvio antes de 60 segundos Exibe o contador; usa retryAfterSeconds
OTP_COOLDOWN 429 Aguarde alguns instantes antes de pedir outro código. Intervalo mínimo entre pedidos de código ainda não decorrido, medido pelo fluxo de cadastro (Seção 11.12.3). Mesmo status de OTP_RESEND_TOO_SOON, do qual difere apenas pela origem: aquele é do reenvio explícito, este é do pedido inicial repetido Exibe o contador; usa retryAfterSeconds
OTP_SEND_LIMIT_REACHED 429 Limite de envios atingido. Tente em 1 hora. 3 pedidos na mesma hora para o número, contados antes de qualquer consulta ao banco Oferece o login por e-mail
OTP_RATE_LIMITED 429 Muitas solicitações de código. Tente mais tarde. Teto diário de 8 pedidos em 24 h para o número (8.2.1) Oferece o login por e-mail
OTP_DELIVERY_FAILED 502 Não conseguimos enviar o código pelo WhatsApp. Falha ao entregar o template de autenticação Oferece o login por e-mail
SERVICE_BUSY 503 Estamos com muitos pedidos agora. Tente em instantes. Cota global de envio de código esgotada (8.2.1) Aguarda retryAfterSeconds
REVERIFICATION_REQUIRED 403 Faz tempo que você não aparece. Confirme por e-mail ou fale com a gente. Sessão restrita por dormência (8.15.1.1) pediu rota de dado pessoal Oferece o link por e-mail ou o contato do suporte
CSRF_INVALID 403 Não foi possível concluir a ação. Recarregue a página e tente de novo. Header X-CSRF-Token ausente ou diferente do cookie __Host-csrf (7.13.2) Recarrega e refaz a ação
TOTP_ENROLLMENT_TARGET_FORBIDDEN 403 O autenticador só pode ser configurado para a própria conta. Tentativa de enrolar segundo fator de outra conta (8.6.2) Esconde a ação
MAGIC_LINK_INVALID 401 Link inválido. Token do e-mail não confere Pede novo link
MAGIC_LINK_EXPIRED 401 Este link expirou. Peça um novo. Passaram-se mais de 20 minutos Pede novo link
EMAIL_NOT_VERIFIED 403 Verifique seu e-mail para usar esta opção. Fallback de e-mail sem email_verified_at Oferece só o OTP do WhatsApp
IMPERSONATION_NOT_ALLOWED 403 Você não tem permissão para acessar como assinante. Papel abaixo de ADMIN tentou impersonar Esconde a ação da interface
IMPERSONATION_EXPIRED 401 A sessão de suporte expirou. Sessão de impersonação passou de 30 minutos Volta ao painel administrativo
IMPERSONATION_REASON_REQUIRED 422 Informe o motivo do acesso de suporte. Impersonação sem justificativa de 10+ caracteres Exige o campo

OTP_EXPIRED é 410 Gone em todo o documento, nunca 401. O 410 distingue código expirado — recurso que existiu e não existe mais — de código inválido (401 OTP_INVALID), e essa distinção é o que permite ao cliente habilitar o botão de reenvio sem ambiguidade: com 401 ele não sabe se deve pedir outro código ou se a pessoa apenas digitou errado. Nenhuma seção pode reafirmar OTP_EXPIRED com outro status.

7.11.2 Autorização #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
FORBIDDEN 403 Você não tem permissão para esta ação. Guarda de autorização genérica negou Esconde a ação
INSUFFICIENT_ROLE 403 Seu perfil não permite esta operação. Papel abaixo do exigido pela rota Esconde a ação
FORBIDDEN_ROLE 403 Seu perfil não permite esta operação. Grafia usada pelas guardas de papel das Seções 3, 13, 20 e 21; mesmo efeito de INSUFFICIENT_ROLE, que é o código preferido em rota nova Esconde a ação
OWNER_REQUIRED 403 Apenas o proprietário da conta pode fazer isso. Ação restrita a OWNER Esconde a ação
RESOURCE_NOT_OWNED 404 Não encontrado. Assinante tentou acessar recurso de outro Trata como inexistente
ADMIN_SELF_MODIFICATION_FORBIDDEN 409 Você não pode alterar o próprio perfil por aqui. Admin tentou mudar o próprio papel ou se desativar Desabilita a ação sobre si
CANNOT_MODIFY_SELF 409 Você não pode executar esta ação sobre a sua própria conta. Grafia usada pelas rotas de administração de contas da Seção 15; mesmo efeito de ADMIN_SELF_MODIFICATION_FORBIDDEN, que é o código preferido em rota nova Desabilita a ação sobre si
LAST_OWNER_PROTECTED 409 É preciso manter ao menos um proprietário ativo. Rebaixar ou desativar o último OWNER Bloqueia a ação
LAST_OWNER 409 É preciso manter ao menos um proprietário ativo. Grafia curta usada pelas rotas de administração de contas; mesmo efeito de LAST_OWNER_PROTECTED, que é o código preferido em rota nova Bloqueia a ação
ENTITLEMENT_REQUIRED 403 Este recurso é exclusivo do plano pago. Guarda de entitlement negou antes do handler, sem sequer localizar o recurso Oferece o upgrade
MAINTENANCE_MODE 503 Estamos em manutenção. Volte em instantes. ops.maintenance_mode = true e a rota escreve Exibe aviso; leitura continua
FEATURE_DISABLED 403 Este recurso está indisponível no momento. Feature flag desligada Esconde a funcionalidade

7.11.3 Validação e protocolo #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
VALIDATION_ERROR 422 Alguns campos precisam de correção. Schema Zod rejeitou o corpo Marca os campos de details
INVALID_JSON 400 Não foi possível ler os dados enviados. Corpo não é JSON válido Erro de programação; reporta
UNSUPPORTED_MEDIA_TYPE 415 Formato de envio não suportado. Content-Type não é application/json Corrige o header
PAYLOAD_TOO_LARGE 413 O conteúdo enviado é grande demais. Corpo acima do limite de 7.14 Reduz o conteúdo
METHOD_NOT_ALLOWED 405 Operação não suportada. Método inexistente na rota Erro de programação
NOT_FOUND 404 Não encontrado. Rota ou recurso genérico inexistente Mostra tela de não encontrado
MISSING_REQUIRED_FIELD 422 Preencha os campos obrigatórios. Campo obrigatório ausente Marca os campos
INVALID_ENUM_VALUE 422 Valor não permitido para este campo. Valor fora do enum Marca o campo
INVALID_CURSOR 400 Não foi possível carregar a próxima página. Cursor corrompido, de outra ordenação ou de versão desconhecida Recarrega do início
INVALID_LIMIT 422 Quantidade por página fora do permitido. limit fora de 1 a 100 Usa o padrão
LIMIT_OUT_OF_RANGE 422 Quantidade por página fora do permitido. Grafia usada pelas rotas de listagem administrativa; mesmo efeito de INVALID_LIMIT, que é o código preferido em rota nova Usa o padrão
INVALID_DATE_RANGE 422 Período informado é inválido. from > to ou faixa acima de 366 dias Corrige o período
RANGE_TOO_LARGE 422 O período pedido é longo demais. Faixa de data acima do teto específico da rota, menor que os 366 dias gerais — relatórios e exportações Reduz o período
UNSUPPORTED_SORT_FIELD 422 Não é possível ordenar por este campo. sort com campo não permitido Usa a ordenação padrão
INVALID_PHONE_NUMBER 422 Número de WhatsApp inválido. Não normaliza para E.164 Marca o campo
UNSUPPORTED_COUNTRY_CODE 422 No momento atendemos apenas números do Brasil. DDI diferente de 55 Exibe a mensagem
INVALID_CPF_CNPJ 422 CPF inválido. Dígito verificador incorreto ou já em uso Marca o campo
TAX_ID_INVALID 422 CPF inválido. Grafia usada pelas rotas de cobrança; mesmo efeito de INVALID_CPF_CNPJ, que é o código preferido em rota nova Marca o campo
INVALID_EMAIL 422 E-mail inválido. Formato incorreto Marca o campo
INVALID_ID_FORMAT 422 Identificador inválido. ULID malformado ou prefixo errado Erro de programação
INVALID_ENCODING 422 Não foi possível ler o arquivo enviado. Arquivo de importação que não é UTF-8 válido Pede reenvio em UTF-8
TERMS_NOT_ACCEPTED 422 É preciso aceitar os termos para continuar. Checkbox de consentimento ausente Marca o campo
CAPTCHA_FAILED 422 Não conseguimos confirmar que você não é um robô. Token do verificador anti-bot ausente, expirado ou recusado na conferência com o provedor Reinicia o verificador e permite nova tentativa
BOT_CHECK_FAILED 422 Não conseguimos confirmar que você não é um robô. Grafia usada pelo cadastro e pelo formulário de contato; mesmo efeito de CAPTCHA_FAILED, que é o código preferido em rota nova Reinicia o verificador e permite nova tentativa
HONEYPOT_TRIGGERED 422 Não foi possível concluir o envio. Campo-armadilha invisível preenchido, o que só um preenchedor automático faz. A mensagem ao usuário nunca revela a causa Exibe o erro genérico; nunca explica a armadilha
CSRF_ORIGIN_MISMATCH 403 Não foi possível concluir a ação. Recarregue a página e tente de novo. Header Origin ausente ou fora da lista de origens próprias em método não seguro (7.13.2, camada 2). Distingue-se de CSRF_INVALID, que é a falha do token double-submit Recarrega e refaz a ação
PHONE_NOT_ALLOWED 422 Não atendemos este número. Prefixo do telefone na lista fechada de security.blocked_phone_prefixes Marca o campo e oferece o suporte
INVALID_STATUS_FILTER 422 Filtro de situação inválido. Valor de ?status= fora do enum da entidade listada, em rota administrativa Usa o filtro padrão
STALE_WRITE 409 O registro mudou desde que você abriu a tela. Recarregue e tente de novo. Escrita com version ou carimbo de leitura desatualizado, detectada fora do fluxo de edição de devocional (Seções 15.2.6 e 15.12.3) Recarrega o recurso e mostra a diferença
CONFIRMATION_REQUIRED 422 Confirme a operação para continuar. Ação destrutiva enviada sem o campo de confirmação explícita Exibe o passo de confirmação
CONFIRMATION_MISMATCH 422 O texto de confirmação não confere. Texto digitado diferente do exigido na confirmação Marca o campo
PHONE_CONFIRMATION_MISMATCH 422 Os dois números informados não são iguais. Campo de confirmação de telefone diferente do campo principal Marca os dois campos
REASON_REQUIRED 422 Informe o motivo desta ação. Ação administrativa auditada sem reason de 10+ caracteres Exige o campo
OVERRIDE_REASON_REQUIRED 422 Informe o motivo para ignorar a validação. Operação que ultrapassa uma trava de segurança sem justificativa registrada Exige o campo
FILE_TOO_LARGE 413 O arquivo enviado é grande demais. Upload acima do limite da rota (7.14) Reduz o arquivo
FILE_CONTENT_MISMATCH 422 O conteúdo do arquivo não corresponde à extensão. Número mágico do arquivo diverge do Content-Type declarado Recusa o upload
FILE_DIMENSIONS_EXCEEDED 422 A imagem excede as dimensões permitidas. Largura ou altura acima do teto da rota de capa Redimensiona
IMPORT_LIMIT 413 O arquivo tem linhas demais para uma importação. Importação acima do teto de linhas por lote Divide o arquivo
TOO_MANY_ROWS 413 O arquivo tem linhas demais. Grafia usada pela importação editorial da Seção 15; mesmo efeito de IMPORT_LIMIT, que é o código preferido em rota nova Divide o arquivo
MISSING_COLUMN 422 O arquivo não tem todas as colunas obrigatórias. Cabeçalho do CSV de importação sem uma coluna exigida; details traz o nome de cada coluna faltante Corrige o cabeçalho e reenvia
IMPORT_ABORTED 409 A importação foi interrompida e nada foi gravado. Erro em qualquer linha do lote com a importação em modo tudo-ou-nada; a transação inteira é desfeita Exibe o relatório de linhas com problema
ILLEGAL_STATE_TRANSITION 409 Não é possível mudar para este estado agora. Transição fora da máquina de estados, em qualquer entidade que tenha uma Recarrega e exibe as transições válidas
ILLEGAL_STEP 409 Este passo não pode ser executado agora. Passo de fluxo em várias etapas executado fora de ordem Volta ao passo correto

7.11.4 Assinantes, consentimento e conta #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
SUBSCRIBER_NOT_FOUND 404 Assinante não encontrado. Identificador ou telefone inexistente Mostra não encontrado
SUBSCRIBER_ALREADY_EXISTS 409 Este número já está cadastrado. Cadastro com telefone existente Oferece login
PHONE_ALREADY_REGISTERED 409 Este número já está cadastrado. Grafia usada pelo fluxo de cadastro da Seção 11; mesmo efeito de SUBSCRIBER_ALREADY_EXISTS, que é o código preferido em rota nova Oferece login
SUBSCRIBER_DELETED 410 Esta conta foi excluída. Conta com deleted_at Oferece cadastro novo
SUBSCRIBER_BLOCKED 403 Não conseguimos enviar mensagens para este número. blocked_at preenchido, em rota já autenticada. Nunca no pedido de código, onde revelaria a existência do cadastro (8.2.4) Instrui a contatar o suporte
SUBSCRIBER_OPTED_OUT 409 Este assinante pediu para não receber mensagens. Ação administrativa ou de envio contra assinante com opt_out_at preenchido Bloqueia a ação e explica
PHONE_ALREADY_IN_USE 409 Este número pertence a outra conta. Troca de número para um já usado Marca o campo
EMAIL_ALREADY_IN_USE 409 Este e-mail pertence a outra conta. E-mail duplicado Marca o campo
PHONE_CHANGE_NOT_ALLOWED 403 Não é possível trocar o número por aqui. Troca de número fora do fluxo verificado Direciona ao suporte
PHONE_CHANGE_NOT_FOUND 404 Pedido de troca de número não encontrado. Identificador de troca inexistente ou já concluído Reinicia o fluxo
PHONE_CHANGE_LIMIT 429 Você já trocou de número recentemente. Teto de trocas de número na janela definida pela Seção 20 Exibe quando poderá tentar
PHONE_CORRECTION_LIMIT 429 Você já corrigiu o número várias vezes. Teto de correções de telefone dentro do mesmo cadastro Direciona ao suporte
SIGNUP_NOT_FOUND 404 Não encontramos seu cadastro em andamento. Cookie de cadastro ausente, expirado ou apontando para cadastro concluído Reinicia o cadastro
SIGNUP_BLOCKED 422 Não foi possível concluir o cadastro. Fale com a gente. Pontuação anti-abuso acima do limiar de recusa Exibe o contato humano, nunca o motivo detalhado
WELCOME_RESEND_COOLDOWN 429 Aguarde para reenviar a mensagem de boas-vindas. Reenvio antes do intervalo mínimo Exibe o contador
WELCOME_RESEND_LIMIT 429 Limite de reenvios da mensagem de boas-vindas atingido. Teto de reenvios por cadastro Direciona ao suporte
CONSENT_REQUIRED 422 É preciso autorizar o tratamento para continuar. Consentimento específico ausente na requisição Marca o campo
CONSENT_VERSION_MISMATCH 409 Os termos foram atualizados. Leia e confirme de novo. Versão de texto enviada diferente da vigente Recarrega o texto e pede nova confirmação
OPT_IN_REQUIRED 403 Confirme sua inscrição no WhatsApp para continuar. Ação que exige opt_in_confirmed_at Reenvia a confirmação
OPT_IN_ALREADY_CONFIRMED 409 Sua inscrição já está confirmada. Confirmação repetida Segue para o painel
ALREADY_OPTED_OUT 409 Você já cancelou o recebimento das mensagens. Opt-out repetido Oferece reativação
NOT_OPTED_OUT 409 Você já está recebendo as mensagens. Reativação sem opt-out anterior Ignora
UNSUBSCRIBE_TOKEN_INVALID 400 Este link de descadastro não é válido. Token do link público de saída malformado, expirado ou já usado Oferece o descadastro pelo painel
SUBSCRIBER_NOT_OPTED_IN 409 Este assinante ainda não confirmou o recebimento no WhatsApp. Ação administrativa ou de envio contra assinante com opt_in_confirmed_at nulo (Seção 13) Bloqueia a ação e oferece reenviar a confirmação
PHONE_PENDING_DELETION 409 Este número está em processo de exclusão. Cadastro ou troca de número para um telefone cuja conta pediu eliminação e ainda está na janela de 30 dias (Seção 20.12) Explica o prazo e oferece cancelar a exclusão
PAUSE_LIMIT_EXCEEDED 429 Você já usou suas pausas deste período. Teto de pausas na janela definida pela Seção 20 Exibe quando poderá pausar de novo
PAUSE_DAYS_OUT_OF_RANGE 422 Escolha um número de dias dentro do permitido. days fora da faixa aceita pela pausa (Seção 20.12.3) Marca o campo com a faixa válida
PAUSE_NOT_ACTIVE 409 Você não tem uma pausa em andamento. Encerramento de pausa sem paused_until vigente (Seção 20.12.4) Ignora e atualiza a tela
DELETION_NOT_REQUESTED 409 Não há pedido de exclusão em andamento. Confirmação ou cancelamento de exclusão sem pedido aberto (Seção 20.12.9) Atualiza a tela
DELETION_WINDOW_CLOSED 409 O prazo para desfazer a exclusão terminou. Tentativa de cancelar a eliminação depois da janela de arrependimento (Seção 15) Explica o prazo e direciona ao suporte
EXPORT_TIMEOUT 504 Sua exportação demorou mais do que o esperado. Montagem do pacote de portabilidade estourou o tempo máximo do job (Seção 21) Oferece pedir de novo mais tarde
EXPORT_ALREADY_IN_PROGRESS 409 Já existe uma exportação em andamento. Segundo pedido de exportação em 24 h Exibe o pedido atual
EXPORT_NOT_FOUND 404 Exportação não encontrada. Identificador inexistente, expirado ou de outro titular Oferece pedir uma nova
EXPORT_RATE_LIMITED 429 Limite de exportações do mês atingido. Teto de pedidos de portabilidade por assinante por mês Exibe quando poderá pedir de novo
EXPORT_TOO_LARGE 413 Sua exportação ficou grande demais para o download direto. Pacote acima do teto de tamanho de download Direciona ao suporte para entrega alternativa
DELETION_ALREADY_REQUESTED 409 A exclusão da sua conta já foi solicitada. Pedido repetido Exibe a data prevista

7.11.5 Devocionais e editorial #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
DEVOTIONAL_NOT_FOUND 404 Devocional não encontrado. Identificador inexistente ou excluído Mostra não encontrado
DEVOTIONAL_DATE_TAKEN 409 Já existe um devocional para esta data. Violação de uq_devotionals_scheduled_for Oferece abrir o existente
DATE_ALREADY_TAKEN 409 Já existe um devocional para esta data. Grafia usada pelo editor e pela importação da Seção 15; mesmo efeito de DEVOTIONAL_DATE_TAKEN, que é o código preferido em rota nova Oferece abrir o existente
DEVOTIONAL_INVALID_TRANSITION 409 Não é possível mudar para este status agora. Transição fora da máquina de estados Exibe as transições válidas
DEVOTIONAL_NOT_PUBLISHED 403 Este devocional ainda não está disponível. Assinante pediu conteúdo não publicado Mostra o acervo
DEVOTIONAL_ALREADY_SENT 409 Este devocional já foi enviado e não pode ser alterado. Edição de devocional com status SENT Bloqueia a edição
DEVOTIONAL_MISSING_AUDIO 409 Gere o áudio antes de publicar. Publicação sem áudio pronto Dispara a geração
DEVOTIONAL_LOCKED 409 Outro editor está trabalhando neste devocional. Lock editorial de 10 minutos ativo Exibe quem está editando
UNPUBLISH_REQUIRED 409 Despublique o devocional antes desta operação. Edição estrutural ou exclusão pedida sobre devocional publicado Oferece o passo de despublicar
REVISION_NOT_FOUND 404 Revisão não encontrada. Identificador de revisão inexistente ou de outro devocional Recarrega a lista de revisões
TEASER_TOO_LONG 422 O resumo excede 300 caracteres. teaser maior que o limite do template Marca o campo com o contador
TEASER_TOO_SHORT 422 O resumo precisa de pelo menos 40 caracteres. teaser abaixo do mínimo exigido pelo editor e pelo banco (Seção 6.15.3) Marca o campo com o contador
BIBLE_VERSION_NOT_ALLOWED 422 Versão bíblica não permitida. Valor de bibleVersion fora da lista fechada ALMEIDA_1911 e BIBLIA_LIVRE (Seção 22.10.3) Marca o campo e exibe a lista permitida
TEASER_INVALID_CHARS 422 O resumo não pode ter quebras de linha nem espaços repetidos. Violação da restrição do parâmetro de template Marca o campo
TEASER_INVALID 422 O resumo não atende às regras de formato. Grafia genérica usada pelo editor da Seção 15; cobre tamanho e caracteres proibidos, detalhados em details Marca o campo
REFLECTION_TOO_LONG 422 A reflexão ultrapassa o limite da mensagem. Texto final passaria de 4096 caracteres Exibe o contador
MESSAGE_TOO_LONG 422 A mensagem montada ultrapassa o limite da plataforma. Corpo final de qualquer mensagem de saída acima do teto de caracteres da plataforma Encurta o conteúdo
AUDIO_NOT_FOUND 404 Áudio não encontrado. Nenhum audio_assets pronto para o devocional pedido Dispara a geração ou mostra não encontrado
AUDIO_REQUIRED 422 Este devocional precisa de áudio para seguir. Operação que exige áudio pronto e não o encontrou Dispara a geração
AUDIO_TOO_LONG 422 O áudio excede a duração máxima permitida. Áudio acima de 480 s (8 minutos), o teto da plataforma de mensageria Encurta a narração
REVISION_CONFLICT 409 Alguém alterou este devocional. Recarregue e tente de novo. version divergente Recarrega e mostra a diferença
OPTIMISTIC_LOCK_FAILED 409 O registro mudou. Recarregue e tente de novo. Falha de versionamento otimista genérico Recarrega
ARCHIVE_LIMIT_REACHED 403 Assine para acessar o acervo completo. Assinante FREE pediu conteúdo com mais de 7 dias Oferece o upgrade

7.11.6 Planos, assinaturas e entitlements #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
PLAN_NOT_FOUND 404 Plano não encontrado. planCode inexistente Recarrega a grade
PLAN_INACTIVE 409 Este plano não está mais disponível. is_active = false Mostra os planos vigentes
SUBSCRIPTION_NOT_FOUND 404 Assinatura não encontrada. Sem assinatura vigente Oferece contratar
SUBSCRIPTION_ALREADY_ACTIVE 409 Você já tem uma assinatura ativa. Violação de uq_subscriptions_one_live_per_subscriber Leva à assinatura existente
SUBSCRIPTION_NOT_ACTIVE 409 Sua assinatura não está ativa. Ação que exige assinatura viva Oferece reativar
SUBSCRIPTION_PENDING_PAYMENT 409 Estamos aguardando a confirmação do pagamento. Nova contratação com uma pendente Mostra o PIX pendente
ALREADY_CANCELED 409 Esta assinatura já foi cancelada. Cancelamento repetido Exibe a data de término
PLAN_CHANGE_NOT_ALLOWED 409 Não é possível trocar de plano agora. Troca de plano fora da janela permitida Explica a regra
PLAN_CHANGE_ALREADY_PENDING 409 Já existe uma troca de plano agendada. pending_plan_id preenchido e ainda não aplicado Exibe a troca agendada e oferece cancelá-la
PLAN_CHANGE_SAME_PLAN 422 Escolha um plano diferente do atual. Troca agendada para o mesmo plano vigente; é a mesma regra do chk_subs_pending_plan_differs (Seção 6.11.3) Marca o campo e mostra os outros planos
PLAN_CHANGE_NOT_PENDING 409 Não há troca de plano agendada para cancelar. DELETE da troca de plano sem pending_plan_id preenchido Atualiza a tela
REACTIVATION_WINDOW_CLOSED 409 O prazo para reativar esta assinatura terminou. Reativação pedida depois dos 30 dias que se seguem ao cancelamento ou ao opt-out (Seção 14.10.10) Oferece contratar de novo
CANCELLATION_REASON_REQUIRED 422 Escolha um motivo para o cancelamento. Cancelamento sem cancelReason da lista fechada Exige a escolha
CANCELLATION_COMMENT_REQUIRED 422 Conte um pouco mais sobre o motivo. Motivo "outro" escolhido sem comentário livre Exige o campo
RETENTION_OFFER_ALREADY_SHOWN 409 Esta oferta já foi apresentada. Segundo registro de exibição da oferta de retenção no mesmo fluxo Segue para o cancelamento
BILLING_ACKNOWLEDGEMENT_REQUIRED 422 Confirme que entendeu como funciona a cobrança. Contratação sem o aceite explícito do aviso de cobrança recorrente Marca o campo
COURTESY_LIMIT_EXCEEDED 429 Limite de cortesias concedidas atingido. Teto de concessões de cortesia na janela do operador Bloqueia e exige aprovação de OWNER
COURTESY_REASON_REQUIRED 422 Informe o motivo da cortesia. Concessão de cortesia sem justificativa registrada Exige o campo
COURTESY_DAYS_OUT_OF_RANGE 422 Escolha um número de dias dentro do permitido. days fora da faixa aceita pela concessão de cortesia (Seção 13.11.5) Marca o campo com a faixa válida
COURTESY_NOT_ACTIVE 409 Este assinante não tem cortesia em andamento. Revogação de cortesia com courtesy_until nulo ou já vencido Atualiza a ficha
ENTITLEMENT_DENIED 403 Este recurso é exclusivo do plano pago. Assinante FREE pediu recurso PAID, verificado dentro do handler Oferece o upgrade
TIER_UPGRADE_REQUIRED 403 Assine para usar este recurso. Igual ao anterior, em contexto de interface Abre o checkout
AUDIO_REQUIRES_PAID_PLAN 403 O áudio é exclusivo do plano pago. Pedido de credencial de áudio por assinante FREE; é a especialização de ENTITLEMENT_DENIED para o único diferencial do plano pago (Seção 13) Oferece o upgrade
NO_DEVOTIONAL_FOR_TODAY_ON_FREE_TIER 404 Hoje não há devocional no plano gratuito. Volte no próximo domingo. Assinante FREE pediu GET /api/devotionals/today em dia que o regime semanal não cobre (Seção 3.4) Exibe o próximo dia coberto e oferece o upgrade
RESEND_LIMIT_REACHED 429 Você atingiu o limite de reenvios de hoje. 1 reenvio no FREE, 3 no PAID Mostra quando reseta

7.11.7 Pagamentos #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
PAYMENT_NOT_FOUND 404 Cobrança não encontrada. Identificador inexistente Mostra não encontrado
PAYMENT_PROVIDER_ERROR 502 Não foi possível concluir o pagamento. Tente novamente. Provedor devolveu erro tratável Permite nova tentativa
ASAAS_ERROR 502 Não foi possível concluir o pagamento. Tente novamente. Grafia usada pela integração da Seção 12; mesmo efeito de PAYMENT_PROVIDER_ERROR, que é o código preferido em rota nova Permite nova tentativa
CARD_BILLING_BLOCKED 409 Não é possível usar cartão nesta assinatura agora. Cobrança por cartão suspensa para o assinante após recusas seguidas ou contestação — subscribers.billing_blocked_at preenchido (Seção 6.3) Oferece PIX
TAX_ID_MUST_BE_CPF 422 Informe um CPF. Documento informado é um CNPJ, e o produto só aceita pessoa física no checkout (Seção 12.7) Marca o campo
TAX_ID_INVALID_LENGTH 422 O CPF precisa ter 11 dígitos. Quantidade de dígitos diferente de 11 depois de removida a formatação Marca o campo
TAX_ID_REJECTED_BY_PROVIDER 422 O provedor de pagamento recusou este CPF. Documento sintaticamente válido e recusado na criação do cliente no provedor Pede conferência do documento e oferece o suporte
BILLING_TYPE_NOT_PIX 409 Esta operação só vale para cobranças por PIX. Pedido do código PIX em assinatura cujo billing_type é CREDIT_CARD (Seção 12.7.5) Esconde a ação
BILLING_TYPE_NOT_CARD 409 Esta operação só vale para cobranças por cartão. Troca de cartão em assinatura cujo billing_type é PIX (Seção 13.13.5) Esconde a ação
PIX_CODE_UNAVAILABLE 409 O código PIX desta cobrança ainda não está disponível. Cobrança criada e QR Code ainda não emitido pelo provedor Tenta de novo em instantes
PAYMENT_ALREADY_PAID 409 Este pagamento já foi confirmado. Grafia usada pelas rotas de cobrança da Seção 12; mesmo efeito de PAYMENT_ALREADY_CONFIRMED, que é o código preferido em rota nova Leva ao painel
PAYMENT_PROVIDER_UNAVAILABLE 503 O sistema de pagamentos está indisponível. Tente em instantes. Provedor fora do ar ou circuit breaker aberto Nova tentativa com retryAfterSeconds
CARD_DECLINED 422 Cartão recusado. Verifique os dados ou use outro cartão. Recusa do emissor Oferece novo cartão ou PIX
CARD_TOKEN_INVALID 422 Não foi possível validar o cartão. Token de cartão inválido ou expirado Refaz a tokenização
PIX_QR_UNAVAILABLE 502 Não foi possível gerar o código PIX. Falha ao obter o QR Code Nova tentativa
PIX_EXPIRED 409 Este código PIX expirou. Gere um novo. QR Code vencido Gera novo código
PAYMENT_ALREADY_CONFIRMED 409 Este pagamento já foi confirmado. Tentativa de pagar duas vezes Leva ao painel
CHECKOUT_SESSION_EXPIRED 409 Sua sessão de checkout expirou. Recomece. Checkout parado por mais de 30 minutos Reinicia o fluxo
CUSTOMER_CREATE_FAILED 502 Não foi possível registrar seus dados de cobrança. Falha ao criar cliente no provedor Nova tentativa; verifica CPF
REFUND_NOT_ALLOWED 409 Este pagamento não pode ser estornado. Fora da janela ou já estornado Direciona ao suporte
DUPLICATE_PAYMENT 409 Já existe uma cobrança em aberto. Segunda cobrança para o mesmo ciclo Mostra a existente

7.11.8 Mídia, áudio e envio #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
AUDIO_NOT_READY 409 O áudio ainda está sendo gerado. Áudio pedido antes de READY Mostra o progresso e tenta depois
AUDIO_GENERATION_FAILED 500 Não foi possível gerar o áudio. Falha nos dois provedores de voz Permite nova tentativa manual
TTS_PROVIDER_UNAVAILABLE 503 O serviço de voz está indisponível. Ambos os provedores fora Reagenda a geração
TTS_RATE_LIMITED 429 O serviço de voz atingiu o limite de uso. Provedor de TTS devolveu limite de taxa ou cota de caracteres esgotada (Seção 16) Reagenda com retryAfterSeconds e alerta a operação
MEDIA_TOO_LARGE 422 O arquivo excede o limite permitido. Mídia acima de 16 MB Reduz a qualidade
MEDIA_UPLOAD_FAILED 502 Falha ao enviar a mídia. Erro na API de mídia da plataforma Nova tentativa
MEDIA_ID_EXPIRED 409 A mídia expirou e precisa ser reenviada. Identificador com mais de 30 dias Dispara reupload
SIGNED_URL_EXPIRED 403 O link de áudio expirou. Recarregue a página. URL assinada com mais de 15 minutos Pede nova URL
UNSUPPORTED_AUDIO_FORMAT 422 Formato de áudio não suportado. Formato fora do permitido Erro de programação
SEND_LIMIT_REACHED 429 Limite diário de envios atingido. Teto operacional do dia Aguarda o próximo dia
OUTSIDE_SERVICE_WINDOW 409 Não é possível enviar esta mensagem agora. Free-form fora da janela de 24 h Usa template
TEMPLATE_NOT_APPROVED 409 O modelo de mensagem ainda não foi aprovado. Template sem APPROVED Bloqueia o envio e alerta
TEMPLATE_PARAM_INVALID 422 Os dados do modelo de mensagem são inválidos. Parâmetro com quebra de linha ou longo demais Corrige o teaser
TEMPLATE_PARAM_SEQUENCE 422 Os marcadores do modelo estão fora de sequência. Corpo do template com {{n}} que pula número ou começa fora de {{1}}, o que a Meta recusa na submissão Corrige a numeração dos marcadores
TEMPLATE_NAME_TAKEN 409 Já existe um modelo com este nome neste idioma. Violação de uq_wa_templates_name_lang (Seção 6.18.1) Oferece abrir o existente
TEMPLATE_IN_USE 409 Este modelo está em uso e não pode ser removido. Exclusão de template marcado is_current ou referenciado por lote em andamento Oferece despromover antes de remover
SENDING_PAUSED 409 O envio de mensagens está pausado. ops.kill_switch ligado ou contenção automática ativa; nenhuma mensagem sai enquanto durar (Seções 14.9 e 14.10.6) Exibe o aviso e reagenda a ação
TOO_MANY_RECIPIENTS 422 O envio tem destinatários demais. Lote manual acima do teto de destinatários por disparo Divide o envio
WHATSAPP_PROVIDER_ERROR 502 Falha ao enviar a mensagem. Erro genérico da plataforma Retenta com backoff
WHATSAPP_SEND_FAILED 502 Falha ao enviar a mensagem. Grafia usada pelas Seções 17 e 18; mesmo efeito de WHATSAPP_PROVIDER_ERROR, que é o código preferido em rota nova Retenta com backoff
WHATSAPP_UNAVAILABLE 503 O envio de mensagens está indisponível. Plataforma fora do ar ou disjuntor aberto Reagenda com retryAfterSeconds
META_API_ERROR 502 Falha ao falar com a plataforma de mensagens. Erro da API de gestão — template, mídia ou número — fora do caminho de envio Registra e alerta
WHATSAPP_RATE_LIMITED 429 Limite de envio da plataforma atingido. Erro 130429 Reduz a taxa e reagenda
MESSAGE_UNDELIVERABLE 422 Não foi possível entregar a mensagem para este número. Erro 131026 Marca o assinante e alerta
SEND_BATCH_ALREADY_RUNNING 409 O envio deste dia já está em andamento. Violação de uq_send_batches_idempotency Mostra o lote em andamento
BATCH_ALREADY_STARTED 409 Este lote já começou e não pode mais ser alterado. Alteração de composição de lote já em disparo Oferece pausar em vez de alterar
BATCH_NOT_FOUND 404 Lote de envio não encontrado. Identificador de lote inexistente Mostra não encontrado
BATCH_NOT_RUNNING 409 Este lote não está em andamento. Cancelamento de lote cujo status não é RUNNING (Seção 15.12.13) Atualiza a ficha do lote
BATCH_STILL_RUNNING 409 Este lote ainda está em andamento. Reprocessamento pedido antes de o lote original terminar (Seção 15.12.12) Espera o término e reabilita a ação
RETRY_WINDOW_CLOSED 409 O prazo para reprocessar este lote terminou. Reprocessamento de lote fora da janela permitida; depois dela o reenvio vira um envio novo Explica a regra e oferece um lote novo
NO_ELIGIBLE_RECIPIENTS 409 Nenhum assinante elegível para este envio. Planejamento sem alvos Informa e não cria lote

7.11.9 Webhooks #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
WEBHOOK_SIGNATURE_INVALID 401 Assinatura inválida. HMAC do corpo não confere Nada; alerta de segurança é disparado
WEBHOOK_TOKEN_INVALID 401 Token inválido. Token do webhook de pagamento não confere Idem
WEBHOOK_PAYLOAD_INVALID 200 Corpo não é JSON ou não bate com o schema, depois de o remetente já ter sido autenticado Registra em webhook_deliveries.processing_result e responde 200 com corpo vazio
WEBHOOK_EVENT_DUPLICATE 200 Reentrega já processada Resposta de sucesso com duplicate: true
UNKNOWN_EVENT 200 Tipo de evento que o remetente autenticado enviou e que nenhum handler trata. Registra em webhook_deliveries.processing_result com o valor UNKNOWN_EVENT e responde 200, pela mesma regra de 7.15.1 Nada; alimenta o painel de eventos não tratados
WEBHOOK_VERIFICATION_FAILED 403 Verificação falhou. hub.verify_token errado no handshake GET de registro Corrige a configuração
WEBHOOK_SOURCE_UNKNOWN 404 Origem desconhecida. Caminho de webhook inexistente Nada

WEBHOOK_EVENT_DUPLICATE e WEBHOOK_PAYLOAD_INVALID são os dois códigos do catálogo emitidos com status 200. Eles aparecem dentro de data, não de error, porque nenhum dos dois é falha do remetente: duplicidade é o comportamento esperado da reentrega, e corpo malformado é problema nosso de interpretação, que precisa ser corrigido do nosso lado. Nos dois casos o provedor precisa ver sucesso para não pausar nem desativar a assinatura do webhook — a regra completa está em 7.15.1.

7.11.10 Rate limit, idempotência e infraestrutura #

code HTTP Mensagem exibida Quando ocorre O que o cliente faz
RATE_LIMITED 429 Muitas requisições. Aguarde um instante. Estouro do limite da rota Aguarda retryAfterSeconds
IP_RATE_LIMITED 429 Muitas requisições deste endereço. Estouro do limite por IP Aguarda
ACTION_RATE_LIMITED 429 Você repetiu esta ação vezes demais. Estouro do limite de uma ação de negócio específica, contado por sujeito e não por rota Aguarda retryAfterSeconds
SETTING_NOT_FOUND 404 Chave de configuração não encontrada. Chave fora das declaradas no catálogo de settings (Seção 26.8.1) Recarrega a lista
IMMUTABLE_SETTING 409 Esta configuração não pode ser alterada em execução. Escrita em chave marcada como imutável no catálogo de settings; o valor só muda por migration (Seção 15) Esconde o campo de edição
OVERRIDE_NOT_ALLOWED 403 Esta trava não pode ser ignorada. Pedido de sobreposição de uma verificação de segurança que não admite justificativa, ao contrário das cobertas por OVERRIDE_REASON_REQUIRED (Seção 15) Esconde a ação
UNKNOWN_METRIC_KEY 422 Métrica desconhecida. metricKey fora do catálogo de métricas da Seção 21 Recarrega a lista de métricas
FLAG_NOT_FOUND 404 Feature flag não encontrada. Chave fora das declaradas em feature_flags (Seção 26.9) Recarrega a lista
IDEMPOTENCY_KEY_REQUIRED 400 Requisição inválida. Rota que exige Idempotency-Key sem o header Erro de programação
IDEMPOTENCY_KEY_REUSED 422 Esta operação já foi feita com outros dados. Mesma chave, corpo diferente Gera nova chave
IDEMPOTENCY_IN_PROGRESS 409 Estamos processando sua solicitação. Requisição concorrente com a mesma chave Aguarda e consulta
CONFLICT 409 O estado atual impede esta operação. Conflito genérico não coberto por código específico Recarrega
INTERNAL_ERROR 500 Ocorreu um erro inesperado. Tente novamente. Exceção não tratada Nova tentativa; reporta o requestId
SERVICE_UNAVAILABLE 503 Serviço temporariamente indisponível. Dependência crítica fora Nova tentativa com backoff
DATABASE_UNAVAILABLE 503 Serviço temporariamente indisponível. Pool de conexões esgotado ou banco fora Nova tentativa
CACHE_UNAVAILABLE 503 Serviço temporariamente indisponível. Redis fora e a rota depende dele Nova tentativa
UPSTREAM_TIMEOUT 504 O serviço demorou para responder. Provedor externo estourou o tempo Nova tentativa
EMAIL_SEND_FAILED 502 Não foi possível enviar o e-mail. Falha no provedor de e-mail Nova tentativa
EMAIL_PROVIDER_ERROR 502 Não foi possível enviar o e-mail. Grafia usada pela integração de e-mail da Seção 9; mesmo efeito de EMAIL_SEND_FAILED, que é o código preferido em rota nova Nova tentativa
STORAGE_UNAVAILABLE 503 Não foi possível acessar o arquivo. Storage fora Nova tentativa

Total: 252 códigos. Esta subseção é a única dona do catálogo. Nenhuma outra seção pode introduzir, renomear ou mudar o status HTTP de um código de erro: um código novo entra aqui primeiro, e só depois é usado por uma seção de domínio. Um teste do pipeline extrai toda string passada a apiError(...) e a new AppError(...) no código, compara com as chaves de ERROR_CATALOG e falha se encontrar qualquer código não catalogado — e falha também se encontrar um código catalogado e nunca emitido por rota alguma, que é sinal de código morto.

Duas grafias que aparecem em texto de seções de domínio não são códigos e não entram no catálogo. As formas canônicas são:

Grafia incorreta Código canônico
OPTIN_REQUIRED OPT_IN_REQUIRED
CSRF_TOKEN_INVALID CSRF_INVALID

Onde o catálogo traz duas entradas com o mesmo efeito — CANNOT_MODIFY_SELF e ADMIN_SELF_MODIFICATION_FORBIDDEN, LAST_OWNER e LAST_OWNER_PROTECTED, LIMIT_OUT_OF_RANGE e INVALID_LIMIT, TAX_ID_INVALID e INVALID_CPF_CNPJ, DATE_ALREADY_TAKEN e DEVOTIONAL_DATE_TAKEN, PHONE_ALREADY_REGISTERED e SUBSCRIBER_ALREADY_EXISTS, PAYMENT_ALREADY_PAID e PAYMENT_ALREADY_CONFIRMED, ASAAS_ERROR e PAYMENT_PROVIDER_ERROR, WHATSAPP_SEND_FAILED e WHATSAPP_PROVIDER_ERROR, FORBIDDEN_ROLE e INSUFFICIENT_ROLE, BOT_CHECK_FAILED e CAPTCHA_FAILED, TOO_MANY_ROWS e IMPORT_LIMIT, EMAIL_PROVIDER_ERROR e EMAIL_SEND_FAILED — as duas continuam válidas e catalogadas, porque code é contrato e um código publicado nunca é removido. A coluna "Quando ocorre" indica qual é o preferido em rota nova. Rota existente não é reescrita só para trocar de grafia.

Três códigos exigem status que não é o intuitivo e que nenhuma seção pode reafirmar de outra forma: FORBIDDEN_ROLE é sempre 403, OTP_COOLDOWN é sempre 429 e STALE_WRITE é sempre 409. OTP_COOLDOWN não é 403 porque o pedido é legítimo e apenas cedo demais; STALE_WRITE não é 422 porque o corpo enviado está correto — o que mudou foi o estado no servidor.

O tipo TypeScript é derivado do próprio catálogo, o que impede emitir código não catalogado:

// packages/core/src/errors/catalog.ts
export const ERROR_CATALOG = {
  UNAUTHENTICATED:  { status: 401, message: 'Faça login para continuar.' },
  SESSION_EXPIRED:  { status: 401, message: 'Sua sessão expirou. Entre novamente.' },
  VALIDATION_ERROR: { status: 422, message: 'Alguns campos precisam de correção.' },
  OTP_EXPIRED:      { status: 410, message: 'Este código expirou. Peça um novo.' },
  // ... todas as entradas de 7.11.1 a 7.11.10
} as const satisfies Record<string, { status: number; message: string }>;

export type ErrorCode = keyof typeof ERROR_CATALOG;

export function apiError(code: ErrorCode, details?: ErrorDetail[]): AppError {
  const entry = ERROR_CATALOG[code];
  return new AppError(code, entry.status, entry.message, details);
}

Um teste automatizado garante que cada code do catálogo aparece na documentação e que nenhum throw new AppError no código usa string literal fora do catálogo.

7.12 Rate limiting #

7.12.1 Algoritmo #

Token bucket em Redis, executado como script Lua para ser atômico. Cada balde tem capacidade (rajada) e taxa de recarga (sustentado).

-- packages/core/src/ratelimit/token-bucket.lua
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])   -- tokens por segundo
local now      = tonumber(ARGV[3])   -- epoch em milissegundos
local cost     = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local ts     = tonumber(bucket[2]) or now

tokens = math.min(capacity, tokens + ((now - ts) / 1000) * refill)

local allowed = tokens >= cost
if allowed then tokens = tokens - cost end

redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil((capacity / refill) * 1000) + 1000)

local retry_after = 0
if not allowed then retry_after = math.ceil(((cost - tokens) / refill)) end
return { allowed and 1 or 0, math.floor(tokens), retry_after }

Token bucket, e não janela fixa, porque janela fixa permite o dobro do limite na virada (fim de uma janela mais início da seguinte) e porque o balde absorve rajada legítima — carregar o painel dispara 6 requisições em 200 ms e isso é normal.

7.12.2 Limites por rota #

identidade é a chave do balde: ip, phone, subscriber (identificador da sessão) ou admin.

Rota Identidade Capacidade Recarga Motivo
POST /api/auth/otp/request phone 3 3/hora Cada envio custa uma mensagem de autenticação
POST /api/auth/otp/request phone 8 8/24 h Teto diário; devolve OTP_RATE_LIMITED
POST /api/auth/otp/request ip 10 10/hora Impede varredura de números
POST /api/auth/otp/verify phone 10 10/hora Complementa o limite de 5 tentativas por código
POST /api/auth/otp/verify ip 30 30/hora Impede varredura de códigos entre números; devolve OTP_ATTEMPTS_EXCEEDED
POST /api/auth/email/request ip 5 5/hora Custo de e-mail e antiabuso
POST /api/auth/admin/login ip 10 5/hora Antiforça bruta
POST /api/auth/admin/login email 5 5/hora Complementa o bloqueio de conta
POST /api/signups ip 5 5/hora Antiabuso de cadastro
POST /api/public/unsubscribe ip 10 10/hora Rota pública que aceita token opaco; o limite impede varredura de tokens
POST /api/me/subscription subscriber 5 5/hora Evita cobrança acidental repetida
POST /api/devotionals/{devotionalId}/resends subscriber 3 3/dia Limite de entitlement (Seção 13)
GET /api/me/** subscriber 120 60/minuto Navegação normal
GET /api/admin/** admin 300 120/minuto Painel administrativo é mais intenso
POST/PATCH /api/admin/** admin 60 30/minuto Escrita administrativa
POST /api/admin/batches admin 3 3/hora Disparo manual é operação séria
GET /api/public/** ip 60 30/minuto Landing page
POST /api/webhooks/** ip 600 300/minuto Alto por desenho; o provedor faz rajada
Global, qualquer rota ip 600 300/minuto Rede de segurança

O limite de /api/devotionals/{devotionalId}/resends é o limite máximo do tier pago. O limite real por tier (1 no FREE, 3 no PAID) é uma regra de negócio verificada no handler, que devolve RESEND_LIMIT_REACHED. Rate limit e entitlement são coisas distintas e ambos existem: o primeiro protege a infraestrutura, o segundo protege o modelo de negócio.

Webhooks têm limite generoso porque o provedor de pagamento pausa a fila em caso de falha. Devolver 429 para ele seria pior do que aceitar a rajada. O limite existe só como proteção contra inundação.

7.12.3 Headers #

Toda resposta de rota com limite carrega:

RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 43
RateLimit-Policy: 120;w=60

RateLimit-Reset é em segundos, relativo. Resposta 429 acrescenta Retry-After com o mesmo valor, para clientes que só entendem esse header:

HTTP/1.1 429 Too Many Requests
Retry-After: 43
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 43
Content-Type: application/json

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Muitas requisições. Aguarde um instante.",
    "retryAfterSeconds": 43
  },
  "meta": { "requestId": "req_01K3F8TV5...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

7.12.4 Falha do Redis #

Se o Redis estiver indisponível, o limitador permite a requisição e registra ratelimit_bypass no log com nível warn. Decisão explícita: derrubar o produto inteiro porque o limitador caiu é pior do que ficar temporariamente sem proteção de taxa. As rotas de autenticação são a exceção — sem Redis, elas devolvem SERVICE_UNAVAILABLE, porque ali a proteção contra força bruta é mais importante que a disponibilidade.

7.13 CORS, CSRF e cabeçalhos de segurança #

7.13.1 CORS #

O front é servido do mesmo domínio da API. Não há cliente cross-origin legítimo. Portanto:

  • /api/me/*, /api/admin/*, /api/auth/*: CORS não habilitado. Requisição cross-origin é bloqueada pelo navegador. Sem header Access-Control-Allow-Origin.
  • /api/public/*: Access-Control-Allow-Origin com lista fechada (ALLOWED_ORIGINS, Seção 26.3.7), métodos GET, OPTIONS, sem credenciais.
  • /api/webhooks/*: CORS irrelevante; a chamada não vem de navegador.

Origem não permitida recebe 403 sem header de CORS. Nada de Access-Control-Allow-Origin: * em rota que usa cookie — a combinação é rejeitada pelo próprio navegador e mascara o erro.

7.13.2 CSRF #

Defesa em três camadas:

  1. SameSite=Lax no cookie de sessão. Bloqueia o envio do cookie em POST cross-site, que é o vetor principal.
  2. Verificação de Origin. Todo POST, PATCH, PUT e DELETE em rota autenticada compara o header Origin com a lista de origens próprias. Ausente ou divergente devolve 403 FORBIDDEN. Requisição sem Origin só é aceita em rota de webhook.
  3. Token double-submit em rotas destrutivas. Cancelar assinatura, excluir conta, excluir devocional e alterar papel de admin exigem o header X-CSRF-Token igual ao cookie __Host-csrf (que é legível por script, ao contrário do cookie de sessão), com comparação em tempo constante.

O cookie de CSRF tem um único nome em todo o documento e em todo o código: __Host-csrf. O nome csrf-token não existe. Um emissor que grave um nome e um verificador que leia outro produz 403 em toda requisição que muda estado — cadastro, checkout, cancelamento, opt-out e o painel administrativo inteiro —, e nenhum teste unitário pega, porque os testes injetam o par diretamente.

export function assertSameOrigin(req: Request): void {
  if (SAFE_METHODS.has(req.method)) return;
  const origin = req.headers.get('origin');
  if (!origin || !ALLOWED_APP_ORIGINS.includes(origin)) {
    throw apiError('FORBIDDEN');
  }
}

export function assertCsrf(req: Request): void {
  if (SAFE_METHODS.has(req.method)) return;
  const cookieToken = readCookie(req, '__Host-csrf');   // nome único; ver 8.8.3
  const headerToken = req.headers.get('x-csrf-token');
  if (!cookieToken || !headerToken) throw apiError('CSRF_INVALID');
  const a = Buffer.from(cookieToken, 'utf8');
  const b = Buffer.from(headerToken, 'utf8');
  if (a.length !== b.length || !timingSafeEqual(a, b)) throw apiError('CSRF_INVALID');
}

A falha de CSRF devolve 403 CSRF_INVALID, e não 403 FORBIDDEN genérico: o cliente precisa distinguir "sua sessão não permite isso" de "recarregue a página e tente de novo", porque só o segundo caso tem uma ação útil para o usuário.

7.13.3 Cabeçalhos de resposta #

Aplicados a todas as respostas pelo middleware e reforçados no proxy reverso:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob: https://*.r2.cloudflarestorage.com https://media.palavradiaria.com.br; connect-src 'self' https://challenges.cloudflare.com https://media.palavradiaria.com.br; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=(), payment=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
X-Frame-Options: DENY

Rotas /api/* acrescentam Cache-Control: no-store sem exceção. Nenhuma resposta de API é cacheável — dados de assinante em cache de proxy é vazamento.

media-src inclui o domínio do storage e o domínio de mídia porque o player web consome URLs assinadas. connect-src precisa incluir o mesmo domínio de mídia: o player faz fetch da URL assinada antes de criar o blob, e media-src governa o elemento <audio>, não a requisição. Sem os dois, o áudio — que é o único diferencial do plano pago — não toca em navegador nenhum.

frame-src é declarada explicitamente porque default-src 'self' não basta para o verificador anti-bot: o widget do Cloudflare Turnstile roda dentro de um iframe servido por https://challenges.cloudflare.com, e o mesmo host precisa constar de script-src e de connect-src. Esse host é também a razão de o cadastro funcionar: sem ele, o widget nunca renderiza, o token nunca é preenchido e o POST de cadastro devolve 422 VALIDATION_ERROR para todo visitante.

style-src permite unsafe-inline por exigência do runtime de estilos do framework de UI. script-src não permite: scripts inline são bloqueados, e o nonce por requisição é usado onde o framework precisa e para carregar o script do verificador anti-bot.

Esta é a política de linha de base. A Seção 22.3.1 é a dona da política completa, com as variantes por tipo de página, e nenhuma delas pode ser mais permissiva do que a linha de base acima em object-src, base-uri, form-action e frame-ancestors.

7.14 Limites de corpo, timeouts e uploads #

Escopo Limite Erro ao exceder
Corpo JSON, rotas gerais 256 KB 413 PAYLOAD_TOO_LARGE
Corpo JSON, /api/admin/devotionals 1 MB 413 PAYLOAD_TOO_LARGE
Corpo de webhook 512 KB, medido antes da leitura 413 PAYLOAD_TOO_LARGE
Upload de imagem de capa 8 MB 413 PAYLOAD_TOO_LARGE
Profundidade de JSON 20 níveis 422 VALIDATION_ERROR
Itens em array de entrada 500 422 VALIDATION_ERROR
Tamanho de string em campo de texto livre 10.000 caracteres 422 VALIDATION_ERROR
Quantidade de headers 60 431 do proxy reverso
Tamanho da URL 4 KB 414 do proxy reverso

Timeouts:

Operação Timeout Comportamento ao estourar
Handler HTTP (total) 25 s 504 UPSTREAM_TIMEOUT
Consulta ao banco 8 s DATABASE_UNAVAILABLE, com statement_timeout no Postgres
Comando Redis 1 s Degrada conforme 7.12.4
Chamada ao provedor de pagamento 10 s PAYMENT_PROVIDER_UNAVAILABLE
Chamada à plataforma de mensagens 15 s WHATSAPP_PROVIDER_ERROR, com retentativa na fila
Chamada ao provedor de voz 120 s Cai para o provedor secundário
Upload de mídia 60 s MEDIA_UPLOAD_FAILED, com retentativa
Handler de webhook 2 s Registra e responde 200 mesmo assim

O handler de webhook é o caso especial: o alvo é p99 abaixo de 2 segundos porque tanto o provedor de pagamento quanto a plataforma de mensagens penalizam handler lento — um pausa a fila, o outro degrada a qualidade do número. Por isso o handler persiste e enfileira. Se a persistência ultrapassar 2 segundos, ele responde 200 mesmo assim e registra webhook_slow_path no log, porque um 200 tardio é melhor do que um 500.

7.15 Convenções de webhook #

7.15.1 Entrada — regra geral #

Todo handler de webhook segue exatamente esta sequência, sem exceção:

0. Mede o tamanho declarado do corpo ANTES de ler. Acima de 512 KB (7.14) → 413.
   É o único caminho de 4xx depois do passo 2.
1. Lê o corpo CRU (bytes), antes de qualquer parse.
2. Verifica a assinatura ou o token sobre os bytes crus, em tempo constante.
   Falha  → registra em webhook_deliveries com is_signature_valid = false
          → responde 401 e dispara alerta se houver rajada.
   É a ÚNICA condição que autoriza 401.
3. Faz o parse do JSON e valida o schema.
   Falha → grava webhook_deliveries.processing_result = 'PARSE_ERROR' ou
           'SCHEMA_REJECTED', guarda o corpo cru para reprocessamento,
           e responde 200 com corpo vazio.
4. Calcula dedup_key e persiste (INSERT ... ON CONFLICT DO NOTHING).
   Conflito → responde 200 com { "received": true, "duplicate": true }.
   Falha de banco ou de fila → processing_result = 'PERSIST_FAILED' → 200.
5. Enfileira o processamento no worker.
6. Responde 200 em menos de 2 segundos.

O processamento de negócio NUNCA acontece dentro do handler HTTP.

Verificar sobre os bytes crus, e não sobre o objeto reserializado, é obrigatório: qualquer reserialização altera a ordem de chaves ou o escape de caracteres e quebra o HMAC.

Corpo inválido nunca produz resposta não-2xx. Autenticado o remetente, qualquer falha posterior — corpo que não é JSON, schema reprovado, evento desconhecido, indisponibilidade do banco ou da fila — responde 200 com corpo vazio e persiste o ocorrido. O alerta webhook_payload_rejected (severidade alta, plantonista) dispara acima de 3 ocorrências em 15 minutos, e o alerta webhook_silence dispara quando nenhum evento de cobrança é processado com sucesso por 90 minutos entre 08:00 e 22:00.

O motivo é operacional e vale mais do que a pureza do protocolo: provedores de pagamento e a plataforma de mensagens tratam resposta não-2xx como falha de entrega, retentam e, depois de uma sequência de falhas, desativam a assinatura do webhook. Perder o canal de eventos é catastroficamente pior do que engolir um corpo malformado, porque é por ele que a revogação imediata de acesso pago acontece — sem ele, inadimplente segue recebendo conteúdo pago por dias e o único detector é a reconciliação diária.

7.15.2 Verificação de assinatura #

Plataforma de mensagens — HMAC SHA-256 do corpo cru, chave é o segredo do aplicativo:

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyMetaSignature(rawBody: Buffer, header: string | null, secret: string): boolean {
  if (!header?.startsWith('sha256=')) return false;
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(header.slice(7), 'hex');
  return expected.length === received.length && timingSafeEqual(expected, received);
}

Provedor de pagamento — token estático em header, comparado em tempo constante:

export function verifyPaymentToken(header: string | null, expected: string): boolean {
  if (!header) return false;
  const a = Buffer.from(header, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
}

timingSafeEqual exige buffers do mesmo tamanho, por isso a checagem de comprimento vem antes. Comparar com === vazaria informação pelo tempo de execução.

7.15.3 Handshake de verificação #

A plataforma de mensagens exige um GET de verificação ao registrar o webhook:

GET /api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=1158201444

Resposta: 200 com o valor de hub.challenge em texto puro, sem envelope JSON. É a única rota de todo o sistema que não usa o envelope da Seção 7.3, porque o protocolo externo exige. Token incorreto devolve 403 com WEBHOOK_VERIFICATION_FAILED.

O hub.challenge só é ecoado se casar com ^[0-9]{1,32}$, e a comparação do token é feita em tempo constante. O desafio da plataforma é sempre numérico e curto; validar o formato antes de ecoar impede que o endpoint sirva de refletor de conteúdo arbitrário a partir do nosso domínio caso o token de verificação vaze em um log de integração contínua.

Este GET de handshake é a única exceção à regra do passo 2 de 7.15.1: aqui o 403 é correto, porque não há entrega de evento a preservar — é o registro do webhook que está sendo negociado, e o provedor precisa da recusa para não concluir um registro inválido.

7.15.4 Webhooks de saída #

O produto não expõe webhooks públicos para terceiros. webhook_deliveries.direction = 'OUTBOUND' cobre um caso só: notificação operacional para um endpoint interno de alertas.

Aspecto Regra
Destino Uma única URL, em OPS_WEBHOOK_URL (Seção 26.3.10)
Assinatura HMAC SHA-256 do corpo, header X-Signature-256, chave em OPS_WEBHOOK_SECRET
Retentativa 5 tentativas com backoff exponencial: 1 s, 4 s, 16 s, 64 s, 256 s
Timeout 5 s por tentativa
Sucesso Qualquer 2xx
Desistência Após a 5ª falha, marca FAILED e registra; não retenta mais
Corpo {"event": "<nome>", "occurredAt": "<ISO>", "data": {...}}

Se o endpoint de alertas estiver fora, o alerta também vai para o log com nível error. Alerta nunca depende de um único canal.

7.16 Inventário mestre de rotas #

Esta subseção é a única dona da lista de rotas HTTP do produto. Nenhuma outra seção introduz, renomeia ou remove rota: uma rota nova entra aqui primeiro e só depois é especificada em detalhe pela seção dona. O comportamento de negócio de cada linha está na seção indicada na última coluna; onde este inventário e uma seção de domínio divergirem no caminho, este inventário vence.

Regras de nome, aplicadas sem exceção (7.2.1):

  1. Coleções são substantivos no plural em kebab-case (/api/signups, /api/admin/whatsapp-templates, /api/me/phone-changes).
  2. Ações que não são CRUD sobre recurso de terceiro ou sobre recurso administrativo viram sub-recurso substantivo no plural (/status-changes, /retries, /cancellations, /resends, /restorations, /synchronizations, /exports).
  3. Ações sobre a própria conta do assinante ficam sob /api/me/, com nome de ação no singular (/api/me/opt-out, /api/me/pause, /api/me/deletion-request, /api/me/subscription/cancel), porque ali o sujeito é único e vem da sessão.
  4. Sub-recurso aninhado vai a no máximo dois níveis.
  5. Nenhum verbo no caminho de topo. POST /api/create-subscription e POST /api/publish-devotional são as formas erradas de 7.2.1 e não existem.

Coluna Autenticação: Não = rota pública; Cookie de sessão = __Host-session (assinante) ou __Host-admin-session (administração), conforme a Seção 8; formas próprias estão nomeadas na linha. Toda mutação autenticada por cookie exige também o header X-CSRF-Token casado com o cookie __Host-csrf (7.13.2).

Coluna Papel mínimo: papel exigido pela guarda de autorização (Seção 3.8). = rota sem exigência de papel.


7.16.1 Públicas #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/public/plans Não 9.16.2
GET /api/public/config Não 9.16.5
GET /api/public/devotional-preview Não 9.16.1
POST /api/public/events Não 9.16.3
POST /api/public/contact Não (exige token Turnstile) 9.16.4
POST /api/public/unsubscribe Token opaco de descadastro no corpo 20.12.10
POST /api/csp-report Não 22.3.1

7.16.2 Cadastro e opt-in #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
POST /api/signups Não 11.12.1
POST /api/signups/verifications Não (é a rota que cria a sessão) 11.12.2
POST /api/signups/otp-resends Não 11.12.3
GET /api/signups/current Cookie de sessão ou signupId em query, só em PENDING_VERIFICATION 11.12.4
POST /api/signups/phone-corrections Não (exige signupId em PENDING_VERIFICATION) 11.12.5
POST /api/signups/welcome-resends Cookie de sessão SUBSCRIBER 11.12.6

7.16.3 Autenticação e sessões #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
POST /api/auth/otp/request Não 8.2.3
POST /api/auth/otp/verify Não 8.2.5
POST /api/auth/email/request Não 8.3.1
GET /api/auth/email/verify Token de link mágico em query 8.3.2
POST /api/auth/admin/login Não 8.4.1
POST /api/auth/admin/totp Cookie __Host-auth-challenge 8.4.2
POST /api/auth/admin/totp/enroll Cookie de sessão EDITOR 8.6.2
POST /api/auth/admin/totp/confirm Cookie de sessão EDITOR 8.6.3
POST /api/auth/admin/recovery Link mágico + senha + TOTP 8.6.4
POST /api/auth/refresh Cookie __Host-refresh 8.8.4
POST /api/auth/logout Cookie de sessão 8.9.3
POST /api/auth/logout-all Cookie de sessão 8.9.4
GET /api/auth/sessions Cookie de sessão 8.9.1
DELETE /api/auth/sessions/{id} Cookie de sessão 8.9.2

7.16.4 Assinante — conta e preferências #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/me Cookie de sessão SUBSCRIBER 14.10.1
PATCH /api/me Cookie de sessão SUBSCRIBER 14.10.11
PUT /api/me/preferences Cookie de sessão SUBSCRIBER 14.10.12
GET /api/me/entitlements Cookie de sessão SUBSCRIBER 13.13.2
GET /api/me/consents Cookie de sessão SUBSCRIBER 14.10.17
POST /api/me/pause Cookie de sessão SUBSCRIBER 20.12.3
DELETE /api/me/pause Cookie de sessão SUBSCRIBER 20.12.4
POST /api/me/opt-out Cookie de sessão SUBSCRIBER 20.12.1
POST /api/me/opt-in Cookie de sessão SUBSCRIBER 20.12.2
POST /api/me/phone-changes Cookie de sessão SUBSCRIBER 14.10.14
POST /api/me/phone-changes/{changeId}/current-verifications Cookie de sessão SUBSCRIBER 14.10.14
POST /api/me/phone-changes/{changeId}/new-phone Cookie de sessão SUBSCRIBER 14.10.14
POST /api/me/phone-changes/{changeId}/new-verifications Cookie de sessão SUBSCRIBER 14.10.14
POST /api/me/data-exports Cookie de sessão SUBSCRIBER 14.10.15
GET /api/me/data-exports/current Cookie de sessão SUBSCRIBER 14.10.15
POST /api/me/data-exports/current/download-links Cookie de sessão SUBSCRIBER 14.10.15
POST /api/me/deletion-request Cookie de sessão SUBSCRIBER 20.12.8
POST /api/me/deletion-request/confirm Cookie de sessão + código de acesso SUBSCRIBER 20.12.9

7.16.5 Assinante — assinatura e pagamentos #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/me/subscription Cookie de sessão SUBSCRIBER 13.13.1
POST /api/me/subscription Cookie de sessão SUBSCRIBER 12.9.2 e 13.2.2
POST /api/me/subscription/cancel Cookie de sessão SUBSCRIBER 14.10.9 e 20.12.7
GET /api/me/subscription/cancellation-preview Cookie de sessão SUBSCRIBER 20.12.5
POST /api/me/subscription/retention-offer-shown Cookie de sessão SUBSCRIBER 20.12.6
POST /api/me/subscription/reactivate Cookie de sessão SUBSCRIBER 14.10.10
POST /api/me/subscription/plan-change Cookie de sessão SUBSCRIBER 13.8.3
DELETE /api/me/subscription/plan-change Cookie de sessão SUBSCRIBER 13.8.3
POST /api/me/subscription/card Cookie de sessão SUBSCRIBER 13.13.5
GET /api/me/subscription/pix-code Cookie de sessão SUBSCRIBER 12.7.5
GET /api/me/subscription/history Cookie de sessão SUBSCRIBER 13.13.3
GET /api/me/payments Cookie de sessão SUBSCRIBER 13.13.4
POST /api/checkout/card-tokens Cookie de sessão SUBSCRIBER 12.6.4

7.16.6 Assinante — acervo de devocionais #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/devotionals/today Cookie de sessão SUBSCRIBER 14.10.2
GET /api/devotionals Cookie de sessão SUBSCRIBER 14.10.3
GET /api/devotionals/{devotionalId} Cookie de sessão SUBSCRIBER 14.10.4
POST /api/devotionals/{devotionalId}/media-links Cookie de sessão SUBSCRIBER (entitlement PAID) 14.10.5
POST /api/devotionals/{devotionalId}/resends Cookie de sessão + Idempotency-Key SUBSCRIBER 14.10.6

7.16.7 Administração — editorial #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/admin/devotionals Cookie de sessão EDITOR 15.12.1
POST /api/admin/devotionals Cookie de sessão EDITOR 15.12.2
PATCH /api/admin/devotionals/{id} Cookie de sessão EDITOR 15.12.3
DELETE /api/admin/devotionals/{id} Cookie de sessão ADMIN 15.12.3
POST /api/admin/devotionals/{id}/status-changes Cookie de sessão EDITOR (DRAFT ↔ READY); ADMIN (PUBLISHED e despublicação) 15.12.4
POST /api/admin/devotionals/{id}/reschedules Cookie de sessão EDITOR 15.12.5
POST /api/admin/devotionals/{id}/duplications Cookie de sessão EDITOR 15.12.6
GET /api/admin/devotionals/{id}/revisions Cookie de sessão EDITOR 15.12.7
POST /api/admin/devotionals/{id}/revisions/{rid}/restorations Cookie de sessão EDITOR 15.12.7
POST /api/admin/devotionals/imports Cookie de sessão EDITOR 15.12.8

7.16.8 Administração — assinantes e assinaturas #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/admin/subscribers Cookie de sessão ADMIN 15.12.9
GET /api/admin/subscribers/{id} Cookie de sessão ADMIN 15.12.9
POST /api/admin/subscribers/{id}/actions Cookie de sessão conforme a matriz de 15.7.3 15.12.10
POST /api/admin/subscribers/{id}/impersonations Cookie de sessão + TOTP validado ADMIN 8.13.2
POST /api/admin/subscribers/{id}/courtesy Cookie de sessão + TOTP validado + Idempotency-Key ADMIN 13.11.5
DELETE /api/admin/subscribers/{id}/courtesy Cookie de sessão + TOTP validado ADMIN 13.11.5
GET /api/admin/subscriptions Cookie de sessão EDITOR (somente leitura) 13.13.6
GET /api/admin/cancellations Cookie de sessão ADMIN 20.12.11
GET /api/admin/cancellations/reasons Cookie de sessão EDITOR 20.12.12

7.16.9 Administração — envio e mensageria #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/admin/batches Cookie de sessão EDITOR 15.12.11
POST /api/admin/batches Cookie de sessão + Idempotency-Key ADMIN 15.12.11
GET /api/admin/batches/{batchId} Cookie de sessão EDITOR 15.12.11
POST /api/admin/batches/{batchId}/retries Cookie de sessão + Idempotency-Key ADMIN 15.12.12
POST /api/admin/batches/{batchId}/cancellations Cookie de sessão ADMIN 15.12.13
GET /api/admin/whatsapp-templates Cookie de sessão ADMIN 15.12.14
POST /api/admin/whatsapp-templates Cookie de sessão ADMIN 15.12.14
DELETE /api/admin/whatsapp-templates/{id} Cookie de sessão ADMIN 15.12.14
POST /api/admin/whatsapp-templates/synchronizations Cookie de sessão ADMIN 15.12.14

7.16.10 Administração — configuração, métricas e auditoria #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/admin/settings Cookie de sessão ADMIN 26.8.2
PUT /api/admin/settings/{key} Cookie de sessão ADMIN, respeitado o editable_by da linha; OWNER quando is_secret = true 15.12.16
PATCH /api/admin/plans/{code} Cookie de sessão OWNER 15.12.16
GET /api/admin/feature-flags Cookie de sessão ADMIN 26.9
PATCH /api/admin/feature-flags/{key} Cookie de sessão ADMIN 15.12.16
POST /api/admin/admin-users Cookie de sessão ADMIN (alvo EDITOR); OWNER nos demais 15.12.17
PATCH /api/admin/admin-users/{id} Cookie de sessão ADMIN (alvo EDITOR); OWNER nos demais 15.12.17
GET /api/admin/audit-logs Cookie de sessão ADMIN 15.12.15
POST /api/admin/audit-logs/exports Cookie de sessão ADMIN 15.12.15
GET /api/admin/metrics/series Cookie de sessão ADMIN; EDITOR apenas para chaves de Conteúdo e Entrega 21.9.1
GET /api/admin/metrics/export Cookie de sessão ADMIN 21.9.2
GET /api/admin/metrics/overview Cookie de sessão ADMIN; EDITOR apenas para chaves de Conteúdo e Entrega 21.9.3

7.16.11 Webhooks de entrada #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/webhooks/whatsapp hub.verify_token (WHATSAPP_VERIFY_TOKEN) 7.15.3
POST /api/webhooks/whatsapp HMAC X-Hub-Signature-256 17.8
POST /api/webhooks/asaas Header asaas-access-token 12.10.1

7.16.12 Internas e de operação #

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
GET /api/internal/health Não (publicada; 60 req/min por endereço) 23.6.1
GET /api/internal/ready Authorization: Bearer <METRICS_TOKEN>; sem token o proxy devolve 404 23.6.2
GET /api/internal/metrics Authorization: Bearer <METRICS_TOKEN> 23.3
GET /api/internal/version Authorization: Bearer <METRICS_TOKEN>; nunca publicada 23.6.1

/api/internal/health é liveness: responde 200 se o processo está vivo, sem tocar dependência. /api/internal/ready é readiness: verifica banco, Redis e filas, e responde 503 com o detalhe de qual dependência falhou. O balanceador usa ready; o supervisor de contêiner usa health. Confundir os dois causa reinício em cascata quando o banco oscila.

7.16.13 Ganchos de teste — nunca publicados #

Existem apenas quando E2E_TEST_HOOKS=true e exigem o header x-e2e-secret igual a E2E_HOOK_SECRET. O proxy reverso devolve 404 para todo o prefixo /api/internal/test/* em qualquer ambiente, e o web falha ao iniciar se E2E_TEST_HOOKS=true com NODE_ENV=production (Seção 24.7.1).

Método Caminho Autenticação Papel mínimo Seção que especifica em detalhe
POST /api/internal/test/clock Header x-e2e-secret 24.7.1
POST /api/internal/test/inbound Header x-e2e-secret 24.7.1
GET /api/internal/test/otp Header x-e2e-secret 24.7.1
POST /api/internal/test/run-jobs Header x-e2e-secret 24.7.1
POST /api/internal/test/enqueue-send Header x-e2e-secret 24.9

Total: 115 rotas, das quais 5 são ganchos de teste que não existem em produção — 110 em produção. Um teste do pipeline percorre a árvore de rotas do aplicativo, compara com esta tabela e reprova o build tanto quando encontra rota não inventariada quanto quando uma linha desta tabela não tem rota correspondente.

7.17 Geração do openapi.yaml #

7.17.1 Fonte única #

Os schemas Zod da Seção 26 e das seções de domínio são a fonte. O documento OpenAPI é gerado, nunca escrito à mão. Documentação escrita à mão diverge do código na primeira semana.

// packages/core/src/openapi/registry.ts
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';

export const registry = new OpenAPIRegistry();

registry.registerPath({
  method: 'post',
  path: '/api/auth/otp/request',
  tags: ['Autenticação'],
  summary: 'Solicita o envio de um código de acesso pelo WhatsApp',
  request: {
    headers: z.object({ 'Idempotency-Key': z.string().optional() }),
    body: { content: { 'application/json': { schema: OtpRequestSchema } } },
  },
  responses: {
    200: {
      description: 'Código enviado ou reenviado',
      content: { 'application/json': { schema: envelopeOf(OtpRequestResponseSchema) } },
    },
    422: errorResponse('VALIDATION_ERROR', 'INVALID_PHONE_NUMBER', 'UNSUPPORTED_COUNTRY_CODE'),
    429: errorResponse('OTP_SEND_LIMIT_REACHED', 'OTP_RESEND_TOO_SOON', 'RATE_LIMITED'),
    502: errorResponse('OTP_DELIVERY_FAILED'),
  },
});

envelopeOf() e errorResponse() são utilitários que envolvem o schema no envelope de 7.3 e 7.4, garantindo que nenhum endpoint documente uma forma de resposta diferente.

Geração: pnpm openapi:build escreve openapi.yaml na raiz do repositório. A CI roda o comando e falha se o arquivo no repositório estiver desatualizado — o mesmo padrão de verificação usado para o lockfile.

7.17.2 Exemplo do documento gerado #

openapi: 3.1.0
info:
  title: Palavra Diária  API interna
  version: 1.0.0
  description: |
    API interna do produto. Todas as respostas usam o envelope padrão.
    Erros são identificados pelo campo error.code, estável e catalogado.
servers:
  - url: https://app.palavradiaria.com.br
    description: Produção
  - url: https://staging.palavradiaria.com.br
    description: Staging
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: __Host-session
  schemas:
    Meta:
      type: object
      required: [requestId, timestamp]
      properties:
        requestId: { type: string, example: req_01K3F8RMN3QWERTYUIOPASDFGH }
        timestamp: { type: string, format: date-time }
        nextCursor: { type: [string, 'null'] }
        hasMore: { type: boolean }
        limit: { type: integer, minimum: 1, maximum: 100 }
    ErrorDetail:
      type: object
      required: [field, issue]
      properties:
        field: { type: string, example: phone }
        issue:
          type: string
          enum: [required, invalid_format, too_short, too_long, out_of_range,
                 not_unique, already_in_use, unsupported_value, mismatch, expired, not_allowed]
    ErrorEnvelope:
      type: object
      required: [error, meta]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, example: OTP_EXPIRED }
            message: { type: string, example: Este código expirou. Peça um novo. }
            details: { type: array, items: { $ref: '#/components/schemas/ErrorDetail' } }
            retryAfterSeconds: { type: integer, minimum: 0 }
        meta: { $ref: '#/components/schemas/Meta' }
paths:
  /api/auth/otp/request:
    post:
      tags: [Autenticação]
      summary: Solicita o envio de um código de acesso pelo WhatsApp
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string, minLength: 16, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, example: '+5511987654321' }
                channel: { type: string, enum: [WHATSAPP, EMAIL_LINK], default: WHATSAPP }
      responses:
        '200':
          description: Código enviado
          headers:
            X-Request-Id: { schema: { type: string } }
            RateLimit-Remaining: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: object
                    properties:
                      sent: { type: boolean, example: true }
                      expiresInSeconds: { type: integer, example: 600 }
                      maskedDestination: { type: string, example: '+5511*****4321' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '429':
          description: Limite de envios atingido
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

7.17.3 Regras do documento gerado #

  1. Todo endpoint declara todos os códigos de erro que pode emitir. Um teste compara os códigos declarados com os efetivamente lançados nos testes de integração e falha se houver código lançado e não documentado.
  2. Todo exemplo usa dados fictícios coerentes: telefones do bloco +55119000000xx, CPF 123.456.789-09, e-mails @example.com. Nenhum dado real, nem em staging.
  3. O documento é servido em /api/internal/openapi.yaml, protegido pelo mesmo token de /api/internal/metrics. Não é público.
  4. security: [{ sessionCookie: [] }] é declarado por endpoint, não globalmente, para que as rotas públicas fiquem visivelmente sem exigência.

7.18 Cliente de referência #

O front consome a API por um único cliente tipado. Nenhum componente chama fetch direto.

// apps/web/src/lib/api/client.ts
export class ApiClientError extends Error {
  constructor(
    readonly code: ErrorCode,
    readonly status: number,
    message: string,
    readonly details: ErrorDetail[] = [],
    readonly requestId: string,
    readonly retryAfterSeconds?: number,
  ) { super(message); }
}

export async function apiFetch<T>(
  path: string,
  init: RequestInit & { idempotencyKey?: string } = {},
): Promise<T> {
  const headers = new Headers(init.headers);
  headers.set('Content-Type', 'application/json');
  headers.set('X-Request-Id', `req_${ulid()}`);
  if (init.idempotencyKey) headers.set('Idempotency-Key', init.idempotencyKey);

  const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
  const body = await res.json().catch(() => null);
  const requestId = res.headers.get('X-Request-Id') ?? 'desconhecido';

  if (!res.ok) {
    const e = body?.error;
    throw new ApiClientError(
      e?.code ?? 'INTERNAL_ERROR',
      res.status,
      e?.message ?? 'Ocorreu um erro inesperado. Tente novamente.',
      e?.details ?? [],
      requestId,
      e?.retryAfterSeconds,
    );
  }
  return body.data as T;
}

Tratamento padronizado no cliente, aplicado por um interceptador da camada de estado de servidor:

Situação Comportamento
401 com SESSION_EXPIRED Tenta POST /api/auth/refresh uma vez; se falhar, redireciona ao login
401 com qualquer outro código Redireciona ao login sem tentar refresh
403 com ENTITLEMENT_DENIED ou TIER_UPGRADE_REQUIRED Abre o fluxo de upgrade
403 com MAINTENANCE_MODE Exibe a faixa de manutenção
409 com OPTIMISTIC_LOCK_FAILED ou REVISION_CONFLICT Recarrega o recurso e mostra a diferença
422 com VALIDATION_ERROR Mapeia details[].field para os campos do formulário
429 Desabilita a ação por retryAfterSeconds e exibe contador
5xx Retenta até 2 vezes com backoff, só em GET; exibe o requestId na mensagem

Retentativa automática só em GET. Repetir POST automaticamente é como se cria cobrança duplicada — a retentativa de escrita é sempre uma decisão explícita do usuário, com a mesma Idempotency-Key da tentativa original.

8. Autenticação, Sessões e Autorização #

Todos os endpoints desta seção seguem o envelope, o catálogo de erros e as regras de rate limiting da Seção 7. As tabelas usadas (sessions, otp_codes, admin_users, subscribers) estão definidas na Seção 6.

8.1 Modelo de identidade e papéis #

A identidade do assinante é o telefone normalizado pela Seção 11.4, que é a única fonte da normalização brasileira (nono dígito, DDD, DDI) e a dona da função que produz um phone_e164. Esta seção define apenas como essa identidade vira sessão, e não reimplementa nenhuma regra de normalização.

Como a coluna subscribers.phone_e164 é um envelope cifrado (Seção 6.3), toda busca por telefone nesta seção é feita pela coluna de índice cego subscribers.phone_hmac. Nenhuma consulta compara a coluna cifrada por igualdade.

8.1.1 Dois mundos separados #

Aspecto Assinante Administrador
Identidade Telefone em E.164 E-mail
Tabela subscribers admin_users
Fator primário OTP de 6 dígitos no WhatsApp Senha argon2id
Segundo fator Não TOTP obrigatório
Fallback Magic link por e-mail verificado Códigos de recuperação
Autocadastro Sim, pela landing Não. Criado por seed ou por OWNER
Papel SUBSCRIBER EDITOR, ADMIN ou OWNER
TTL da sessão 30 dias 12 horas
Cookie __Host-session __Host-admin-session
Escopo de rota Prefixos de assinante de 7.2.2: /api/me/*, /api/devotionals/*, /api/subscriptions/*, /api/payments/*, /api/checkout/* /api/admin/*

Os dois mundos não se cruzam. Uma sessão de assinante nunca acessa /api/admin/*, e uma sessão de administrador nunca acessa rota de assinante — exceto durante impersonação (8.13), que emite uma sessão de assinante marcada, somente leitura e auditada.

O escopo do assinante é definido pela guarda requireSubscriber, e não pelo prefixo do caminho. Vários recursos do assinante vivem fora de /api/me/* porque têm identificador próprio no caminho, e qualquer regra desta seção que fale em "rota de assinante" se refere à guarda, nunca ao prefixo.

Uma pessoa que é assinante e administrador tem duas contas e dois cookies, que coexistem sem conflito porque os nomes são diferentes.

8.1.2 Papéis #

Papel Concedido a Pode
SUBSCRIBER Todo assinante Ver e gerenciar apenas os próprios dados
EDITOR Equipe editorial Criar e editar devocionais, ver assinantes (somente leitura), ver métricas
ADMIN Operação Tudo de EDITOR, mais publicar, disparar envios, editar assinantes, impersonar, editar settings e flags
OWNER Responsável pelo produto Tudo de ADMIN, mais gerenciar contas administrativas, executar jobs manualmente e alterar settings restritos

Hierarquia estritamente crescente: EDITOR < ADMIN < OWNER. Quem tem OWNER pode tudo o que ADMIN pode. A matriz detalhada por funcionalidade está na Seção 3.

8.2 Login do assinante por OTP no WhatsApp #

8.2.1 Parâmetros canônicos #

Parâmetro Valor Motivo
Comprimento do código 6 dígitos numéricos Padrão reconhecível; digitável no celular
Espaço de códigos 1.000.000 (000000 a 999999) Com 5 tentativas, a chance de acerto é 1 em 200.000
Geração crypto.randomInt(0, 1_000_000) com zero à esquerda Aleatoriedade criptográfica, distribuição uniforme
TTL 10 minutos Tempo confortável para achar a mensagem sem manter janela longa
Tentativas por código 5 Depois disso o código é invalidado
Pedidos por número, por hora 3 por hora Cada envio custa uma mensagem de autenticação
Pedidos por número, por dia 8 por 24 h Teto diário além do limite horário; devolve OTP_RATE_LIMITED
Pedidos por IP 10 por hora Impede varredura de números
Verificações por IP 30 por hora Impede varredura de códigos entre números; devolve OTP_ATTEMPTS_EXCEEDED
Cota global do sistema 500 por hora, com reserva de 20% para números já cadastrados Teto de custo; devolve SERVICE_BUSY
Intervalo mínimo entre envios 60 segundos Evita reenvio por ansiedade
Códigos ativos simultâneos 1 por número Novo envio invalida o anterior

Esta subseção é a única dona dos limites de código de acesso. As Seções 11 e 22 são réplicas informativas e referenciam esta tabela; nenhuma delas define número próprio.

Duas propriedades da tabela são obrigatórias e fáceis de perder na implementação:

  • Os contadores contam pedidos, não envios. Os três limites por número e por IP são incrementados antes de qualquer consulta ao banco, igualmente para número existente e inexistente. Se contassem apenas envios reais, o quarto pedido se comportaria de forma diferente nos dois casos — 429 para assinante, 200 para não assinante — e isso sozinho seria um oráculo completo de enumeração, anulando as outras defesas de 8.2.4.
  • A cota global tem balde reservado. Uma cota global única é, por si, um vetor de negação de serviço: esgotada de propósito por um atacante, ela impediria o login de toda a base. Por isso 20% dos 500 por hora ficam num balde separado, consumido apenas por números já cadastrados, que o tráfego de cadastro não alcança.

8.2.2 Geração e armazenamento #

O código nunca é persistido em claro. O que vai para o banco é sha256(pepper ‖ otpId ‖ code) em hexadecimal.

// packages/core/src/auth/otp.ts
import { randomInt, createHash, timingSafeEqual } from 'node:crypto';
import { ulid } from 'ulid';

export function generateOtp(): string {
  return String(randomInt(0, 1_000_000)).padStart(6, '0');
}

export function hashOtp(otpId: string, code: string, pepper: string): string {
  return createHash('sha256').update(`${pepper}${otpId}${code}`).digest('hex');
}

export function verifyOtp(otpId: string, code: string, pepper: string, stored: string): boolean {
  const computed = Buffer.from(hashOtp(otpId, code, pepper), 'hex');
  const expected = Buffer.from(stored, 'hex');
  return computed.length === expected.length && timingSafeEqual(computed, expected);
}

Três decisões, cada uma com razão própria:

  • O otpId entra no hash como sal por linha. Sem isso, todos os códigos 123456 no banco teriam o mesmo hash, e um vazamento do banco revelaria os códigos por comparação.
  • O pepper vem de variável de ambiente (OTP_PEPPER, Seção 26.3.6), não do banco. Um vazamento apenas do banco não permite testar códigos offline.
  • Não é argon2. OTP tem espaço de 10⁶ e vive 10 minutos; um hash lento aqui só atrasaria o login legítimo. A proteção real é o limite de 5 tentativas, não a lentidão do hash.

8.2.3 POST /api/auth/otp/request #

Solicita o envio de um código.

  • Autenticação: nenhuma.
  • Papel: nenhum.
  • Idempotência: Idempotency-Key opcional. Sem a chave, chamadas repetidas dentro de 60 segundos devolvem OTP_RESEND_TOO_SOON em vez de enviar de novo.
export const OtpRequestSchema = z.object({
  phone: z.string().trim().min(8).max(20),
  channel: z.enum(['WHATSAPP', 'EMAIL_LINK']).default('WHATSAPP'),
});

Resposta 200:

{
  "data": {
    "sent": true,
    "channel": "WHATSAPP",
    "expiresInSeconds": 600,
    "resendAvailableInSeconds": 60,
    "maskedDestination": "+5511*****4321"
  },
  "meta": { "requestId": "req_01K3F8V1A2...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros possíveis:

code HTTP Causa
VALIDATION_ERROR 422 Corpo fora do schema
INVALID_PHONE_NUMBER 422 Não normaliza para E.164
UNSUPPORTED_COUNTRY_CODE 422 DDI diferente de 55
OTP_RESEND_TOO_SOON 429 Menos de 60 s desde o último envio
OTP_SEND_LIMIT_REACHED 429 3 pedidos na última hora para o número, contados antes de qualquer consulta ao banco e independentemente de o número existir
OTP_RATE_LIMITED 429 8 pedidos nas últimas 24 h para o número, contados da mesma forma
IP_RATE_LIMITED 429 10 pedidos na última hora do mesmo IP
SERVICE_BUSY 503 Cota global de 500 por hora esgotada e o número não está na reserva de cadastrados
OTP_DELIVERY_FAILED 502 A plataforma recusou o template de autenticação
SERVICE_UNAVAILABLE 503 Redis fora (rota de autenticação não degrada)

Número bloqueado não produz erro próprio nesta rota: a resposta é 200, idêntica à do caminho normal, e nenhuma mensagem é enviada. SUBSCRIBER_BLOCKED (403) aqui confirmaria a existência do cadastro e tornaria o oráculo de enumeração ainda mais barato do que o descrito em 8.2.4 — bastaria uma requisição por número, em vez de quatro. O código SUBSCRIBER_BLOCKED continua existindo e é devolvido nas rotas já autenticadas, onde o solicitante já provou ser o titular.

Efeitos colaterais: invalida códigos anteriores do mesmo número (invalidated_at = now()), insere uma linha em otp_codes, enfileira o envio do template de autenticação, registra a mensagem em message_logs.

8.2.4 Proteção contra enumeração de números #

A resposta é idêntica para número cadastrado e não cadastrado: sent: true, mesmo expiresInSeconds, mesmo maskedDestination construído a partir do número informado. Quem tem o número não descobre se ele é assinante.

Três reforços:

  1. Tempo constante. Para número não cadastrado, o handler faz um hash descartável de custo equivalente ao da geração real, para que a diferença de latência não vaze a informação. Alvo: variação abaixo de 15 ms entre os dois caminhos.
  2. Rate limit por IP antes de qualquer consulta. 10 pedidos por hora não sustentam varredura.
  3. Nenhuma mensagem é enviada para número não cadastrado. Nada é inserido em otp_codes. Do lado de fora, os dois casos são indistinguíveis; do lado de dentro, não há custo nem lixo.
  4. Os contadores contam pedidos, não envios. Os três limites de 8.2.1 — por número, por IP e o intervalo mínimo — são incrementados antes da consulta ao banco, para número existente e inexistente igualmente. Este é o reforço que sustenta os outros três: se contassem apenas envios reais, o contador nunca incrementaria para número inexistente, e a diferença de comportamento no quarto pedido (429 contra 200) seria, sozinha, um oráculo completo de enumeração. Quatro requisições por número bastariam para mapear toda a base — que é, neste produto, uma base de convicção religiosa.

O maskedDestination é derivado do número enviado pelo cliente, nunca do banco. Se viesse do banco, um número não cadastrado não teria máscara e a diferença seria o vazamento.

O critério verificável é único e vale para os quatro reforços: dado um número cadastrado e um não cadastrado, quatro pedidos em sequência para cada um produzem quatro respostas idênticas em status HTTP, corpo e error.code nos dois casos.

8.2.5 POST /api/auth/otp/verify #

Verifica o código e cria a sessão.

  • Autenticação: nenhuma.
  • Idempotência: não aplicável. O código é de uso único por natureza; a segunda verificação do mesmo código devolve OTP_ALREADY_USED.
export const OtpVerifySchema = z.object({
  phone: z.string().trim().min(8).max(20),
  code: z.string().regex(/^[0-9]{6}$/, 'código deve ter 6 dígitos'),
  deviceName: z.string().trim().max(60).optional(),
});

Resposta 200:

{
  "data": {
    "subscriber": {
      "id": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB",
      "displayName": "Ana",
      "tier": "PAID",
      "optInConfirmed": true
    },
    "session": {
      "expiresAt": "2026-09-24T09:00:00.000Z",
      "isNewDevice": true,
      "scope": "FULL"
    }
  },
  "meta": { "requestId": "req_01K3F8V3C4...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

O campo session.scope vale FULL ou RESTRICTED. Ele é RESTRICTED quando a conta está dormente, pelas regras de 8.15.1.1, e nesse caso o painel abre em modo reduzido e explica o que falta para liberar o resto. O cliente nunca infere o escopo: ele lê este campo.

Headers de resposta: Set-Cookie com __Host-session e __Host-refresh (8.8.3).

Erros possíveis:

code HTTP Causa
VALIDATION_ERROR 422 Código não tem 6 dígitos
OTP_INVALID 401 Código errado. details traz {"field":"code","issue":"mismatch"}
OTP_EXPIRED 410 Passou de 10 minutos. 410 Gone distingue código expirado de código inválido e permite ao cliente habilitar o reenvio sem ambiguidade (7.11.1)
OTP_ATTEMPTS_EXCEEDED 429 30 verificações na última hora do mesmo IP
OTP_MAX_ATTEMPTS 429 5 tentativas erradas; o código foi invalidado
OTP_ALREADY_USED 409 consumed_at preenchido
SUBSCRIBER_NOT_FOUND 401 Devolvido como OTP_INVALID para não enumerar
SUBSCRIBER_DELETED 410 Conta com soft delete
SUBSCRIBER_BLOCKED 403 Assinante bloqueado

A transação de verificação, em uma única unidade atômica:

BEGIN;
  SELECT id, code_hash, attempts, max_attempts, expires_at, consumed_at, invalidated_at
    FROM otp_codes
   WHERE phone_hmac = $1 AND consumed_at IS NULL AND invalidated_at IS NULL
   ORDER BY created_at DESC LIMIT 1
     FOR UPDATE;                      -- lock impede corrida entre duas abas
  -- $1 é o índice cego do telefone (Seção 6.3). A coluna cifrada nunca é comparada.

  -- código errado: incrementa e, no 5º erro, invalida
  UPDATE otp_codes
     SET attempts = attempts + 1,
         invalidated_at = CASE WHEN attempts + 1 >= max_attempts THEN now() ELSE NULL END
   WHERE id = $2;

  -- código certo: consome
  UPDATE otp_codes SET consumed_at = now() WHERE id = $2;
COMMIT;

FOR UPDATE é obrigatório. Sem ele, duas verificações simultâneas do mesmo código poderiam ambas encontrar attempts = 4 e ambas ter uma 5ª tentativa, efetivamente dando 6 chances.

Efeitos colaterais do sucesso: consome o código, cria a sessão (8.8), grava last_login_at, avalia se o dispositivo é novo (8.10) e, se for, envia notificação.

8.2.6 Contagem de tentativas na resposta #

OTP_INVALID traz as tentativas restantes em details:

{
  "error": {
    "code": "OTP_INVALID",
    "message": "Código incorreto.",
    "details": [{ "field": "code", "issue": "mismatch" }]
  },
  "meta": { "requestId": "req_01K3F8V5E6...", "timestamp": "2026-08-25T09:01:12.000Z" }
}

A quantidade restante não é exposta na resposta, apenas na interface, que a calcula localmente contando as tentativas da sessão de digitação. Expor "faltam 2 tentativas" no corpo ajudaria um atacante a calibrar o ataque sem custo.

Disponível apenas para assinantes com email_verified_at preenchido. É a saída para quem perdeu acesso ao WhatsApp (8.15.2).

8.3.1 POST /api/auth/email/request #

export const EmailLoginRequestSchema = z.object({
  email: z.string().trim().toLowerCase().email().max(254),
});

Resposta 200, sempre idêntica, cadastrado ou não:

{
  "data": { "sent": true, "expiresInSeconds": 1200 },
  "meta": { "requestId": "req_01K3F8V7G8...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros: VALIDATION_ERROR (422), INVALID_EMAIL (422), IP_RATE_LIMITED (429), EMAIL_SEND_FAILED (502).

EMAIL_NOT_VERIFIED não é devolvido aqui: isso revelaria que o e-mail existe mas não está verificado. O caso é tratado em silêncio, sem envio.

Token: 32 bytes de crypto.randomBytes, codificados em Base64URL, armazenados como sha256 em otp_codes com channel = 'EMAIL_LINK' e TTL de 20 minutos. Vinte minutos, e não dez, porque e-mail tem latência de entrega maior que o WhatsApp.

8.3.2 GET /api/auth/email/verify #

Consome o token e cria a sessão. É um GET porque o usuário chega por clique em link.

Query: ?token=<base64url>&redirect=<caminho relativo opcional>.

Comportamento: sucesso redireciona (302) para redirect ou para /painel, com os cookies de sessão no Set-Cookie. Falha redireciona para /entrar?erro=<code> — nunca devolve JSON, porque o destino é um navegador em navegação de topo.

Erros mapeados para o parâmetro erro: MAGIC_LINK_INVALID, MAGIC_LINK_EXPIRED, SUBSCRIBER_DELETED, SUBSCRIBER_BLOCKED.

redirect é validado: aceita apenas caminho relativo começando com / e sem //. Isso fecha o vetor de redirecionamento aberto.

export function safeRedirect(raw: string | null): string {
  if (!raw) return '/painel';
  if (!raw.startsWith('/') || raw.startsWith('//')) return '/painel';
  return raw;
}

Como o link chega por e-mail e pode ser pré-carregado por scanner de segurança do provedor de e-mail, o token só é consumido em requisição com Sec-Fetch-Mode: navigate. Requisição de pré-carregamento não invalida o link do usuário legítimo.

8.4 Login do administrador #

Dois passos obrigatórios: senha, depois TOTP. Nunca há sessão completa após só a senha.

8.4.1 POST /api/auth/admin/login #

export const AdminLoginSchema = z.object({
  email: z.string().trim().toLowerCase().email().max(254),
  password: z.string().min(1).max(200),
});

Resposta 200 — atenção: não cria sessão, apenas um desafio intermediário.

{
  "data": {
    "challengeId": "chl_01K3F8VA9B...",
    "requires": "TOTP",
    "expiresInSeconds": 300,
    "totpEnrollmentRequired": false
  },
  "meta": { "requestId": "req_01K3F8VA9B...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

O desafio vive no Redis por 5 minutos, chave authchl:{challengeId}, valor {adminUserId, ip, userAgentHash}. Não vira sessão. Não vira cookie de sessão — vai num cookie temporário __Host-auth-challenge, HttpOnly, Secure, SameSite=Strict, Path=/, TTL 5 minutos. O Path=/ não é opcional: o prefixo __Host- obriga a isso (8.8.3), e um cookie __Host- com qualquer outro Path é descartado pelo navegador sem erro visível.

Erros: VALIDATION_ERROR (422), INVALID_CREDENTIALS (401), ACCOUNT_LOCKED (403), IP_RATE_LIMITED (429), SERVICE_UNAVAILABLE (503).

Regras de tempo constante, obrigatórias:

  • E-mail inexistente executa argon2.verify contra um hash fictício fixo, para que a latência seja igual à de e-mail existente com senha errada.
  • INVALID_CREDENTIALS é o único código para e-mail errado, senha errada ou conta desativada. Nunca "e-mail não encontrado".
  • ACCOUNT_LOCKED é a exceção deliberada: informar o bloqueio é mais útil ao usuário legítimo do que a informação vale para o atacante, que já sabe que está sendo barrado.

8.4.2 POST /api/auth/admin/totp #

export const AdminTotpSchema = z.object({
  challengeId: z.string().regex(/^chl_[0-9A-HJKMNP-TV-Z]{26}$/),
  code: z.string().regex(/^[0-9]{6}$/),
  trustDevice: z.boolean().default(false),
});

Resposta 200:

{
  "data": {
    "admin": { "id": "adm_01K3F8RHJ9...", "name": "Ana Souza", "role": "ADMIN" },
    "session": { "expiresAt": "2026-08-25T21:00:00.000Z" },
    "mustChangePassword": false
  },
  "meta": { "requestId": "req_01K3F8VC1D...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros: VALIDATION_ERROR (422), TOTP_INVALID (401), TOTP_REQUIRED (401, desafio inexistente ou expirado), ACCOUNT_LOCKED (403), TOTP_ENROLLMENT_REQUIRED (403), PASSWORD_CHANGE_REQUIRED (403, com sessão criada mas restrita à rota de troca de senha).

O desafio é consumido no primeiro uso, com sucesso ou falha de TOTP. Falha exige refazer o passo da senha. Isso impede que um desafio roubado sirva de oráculo para adivinhar TOTP indefinidamente.

trustDevice: true não usa device_fingerprint. Um fingerprint derivado de cabeçalhos é controlado pelo cliente e tem entropia baixa demais para servir de fator de autenticação: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/... com Accept-Language: pt-BR,pt;q=0.9 é a combinação de praticamente todo administrador brasileiro em notebook, e adivinhá-la transformaria o segundo fator em opcional para quem já obteve a senha por phishing.

O que o servidor faz é emitir um cookie próprio __Host-admin-device, HttpOnly, Secure, SameSite=Strict, Path=/, contendo 32 bytes de crypto.randomBytes em Base64URL, com validade de 30 dias. O valor é gravado apenas como sha256 em admin_trusted_devices(admin_user_id, token_hash, label, created_at, expires_at, last_used_at), com índice único em token_hash. Em logins seguintes, a apresentação do cookie válido e não expirado dispensa o TOTP; a senha continua sendo exigida sempre.

O device_fingerprint da Seção 8.10 permanece exclusivamente como sinal informativo para a notificação de dispositivo novo, e é proibido usá-lo em qualquer decisão de autorização — um teste de arquitetura falha se device_fingerprint for lido fora do módulo de notificação. A lista inteira de dispositivos confiáveis é apagada em qualquer troca de senha, em uso de código de recuperação e em logout-all.

8.5 Hash de senha — argon2id #

8.5.1 Parâmetros exatos #

// packages/core/src/auth/password.ts
import argon2 from 'argon2';

export const ARGON2_OPTIONS = {
  type: argon2.argon2id,
  memoryCost: 65536,   // 64 MiB
  timeCost: 3,         // 3 iterações
  parallelism: 4,      // 4 threads
  hashLength: 32,      // 32 bytes de saída
} as const;
// saltLength padrão da biblioteca: 16 bytes, gerado por CSPRNG a cada hash

export async function hashPassword(plain: string): Promise<string> {
  return argon2.hash(plain, ARGON2_OPTIONS);
}

export async function verifyPassword(hash: string, plain: string): Promise<boolean> {
  try {
    return await argon2.verify(hash, plain);
  } catch {
    return false;   // hash malformado nunca vira exceção visível
  }
}
Parâmetro Valor Justificativa
Variante argon2id Híbrida: resiste a ataque por canal lateral e a GPU. É a variante recomendada quando não há razão específica para outra.
memoryCost 65536 KiB (64 MiB) Torna o ataque em GPU caro. 64 MiB por verificação é aceitável num servidor com poucas dezenas de logins administrativos por dia.
timeCost 3 Com 64 MiB, resulta em ~120 ms por verificação no hardware alvo.
parallelism 4 Aproveita os núcleos disponíveis sem monopolizar o processo.
hashLength 32 bytes Padrão sólido; não há ganho acima disso.
saltLength 16 bytes Padrão da biblioteca; elimina tabelas pré-computadas.

Alvo de calibragem: entre 100 ms e 250 ms por verificação na máquina de produção. O comando pnpm ops auth:benchmark mede e avisa se sair da faixa. Abaixo de 100 ms, o custo para o atacante é baixo demais; acima de 250 ms, o login vira lento e o servidor fica vulnerável a esgotamento de memória por rajada de tentativas.

O hash é armazenado no formato PHC completo, que já carrega variante, versão, parâmetros e sal:

$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHR2YWx1ZQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG

Isso permite aumentar os parâmetros no futuro sem quebrar hashes antigos: argon2.verify lê os parâmetros do próprio hash. Quando argon2.needsRehash(hash, ARGON2_OPTIONS) devolver true em um login bem-sucedido, a senha é reescrita com os parâmetros novos, na mesma requisição, de forma transparente.

8.5.2 Política de senha #

Regra Valor
Comprimento mínimo 12 caracteres
Comprimento máximo 200 caracteres
Composição Sem exigência de classes de caractere
Lista de bloqueio 10.000 senhas mais comuns, comparação normalizada
Semelhança Rejeita senha que contenha o e-mail ou o nome da conta
Reuso Rejeita senha igual à atual
Expiração Nenhuma
Corte de espaços Nunca. Espaço é caractere válido, inclusive nas pontas

Comprimento em vez de composição é decisão consciente: exigir símbolo e maiúscula produz Senha@123, que está em qualquer lista de bloqueio. Doze caracteres com verificação contra lista de senhas comuns dá mais entropia real.

Expiração periódica também é rejeitada por decisão explícita: troca forçada produz Senha1, Senha2, Senha3. A troca é exigida em evento concreto — comprometimento suspeito, saída de pessoa da equipe, primeiro login.

export const PasswordSchema = z
  .string()
  .min(12, 'A senha precisa de pelo menos 12 caracteres.')
  .max(200, 'A senha é longa demais.')
  .superRefine((value, ctx) => {
    const v = value.toLowerCase();
    if (COMMON_PASSWORDS.has(v)) {
      ctx.addIssue({ code: 'custom', params: { rule: 'not_allowed' },
                     message: 'Esta senha é muito comum. Escolha outra.' });
    }
    // e-mail completo, parte local do e-mail e nome, com 4 ou mais caracteres
    for (const t of accountTokens()) {
      if (t.length >= 4 && v.includes(t)) {
        ctx.addIssue({ code: 'custom', params: { rule: 'mismatch' },
                       message: 'A senha não pode conter seu nome ou e-mail.' });
      }
    }
  });

A verificação de igualdade com a senha atual não vive no schema: ela acontece no serviço de troca de senha, onde o hash vigente está disponível, e devolve PASSWORD_POLICY_VIOLATION com rule: 'reuse'. O schema sozinho não tem como saber a senha atual, e prometer a regra sem implementá-la em algum lugar deixaria a tabela acima mentindo.

Violação devolve PASSWORD_POLICY_VIOLATION (422) com details indicando qual regra falhou: too_short, too_long, not_allowed (lista de bloqueio), mismatch (semelhança com dados da conta) ou reuse (igual à senha atual). As cinco regras da tabela acima têm, cada uma, um rule correspondente — nenhuma fica declarada sem implementação.

8.6 TOTP — segundo fator obrigatório #

8.6.1 Parâmetros #

Parâmetro Valor
Algoritmo HMAC-SHA1 (exigência de compatibilidade com os aplicativos autenticadores)
Dígitos 6
Período 30 segundos
Tamanho do segredo 20 bytes (160 bits), codificado em Base32
Janela de deriva ±1 período (aceita o código anterior, o atual e o próximo)
Reuso de código Proibido. Cada código só vale uma vez por conta
Códigos de recuperação 10, de 10 caracteres em Base32, uso único

Janela de ±1 cobre relógio de celular até 30 segundos adiantado ou atrasado, que é o desvio real observado. Janela maior (±2 ou mais) triplicaria o espaço aceito sem ganho de usabilidade.

Proibição de reuso: o código aceito é gravado no Redis em totp:used:{adminId}:{counter} com TTL de 90 segundos. Um código interceptado não pode ser replayed dentro da mesma janela.

8.6.2 POST /api/auth/admin/totp/enroll #

Inicia o enrolamento. Exige sessão administrativa (que só existe após TOTP, exceto no primeiro acesso, quando a sessão nasce restrita à rota de enrolamento).

Resposta 200:

{
  "data": {
    "secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
    "otpauthUri": "otpauth://totp/Palavra%20Di%C3%A1ria:ana%40palavradiaria.com.br?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Palavra%20Di%C3%A1ria&algorithm=SHA1&digits=6&period=30",
    "qrCodeDataUri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
  },
  "meta": { "requestId": "req_01K3F8VE3F...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

O QR Code é gerado no servidor, como PNG em data URI. Nunca por serviço externo de geração de QR: mandar o segredo TOTP para um terceiro seria entregar o segundo fator.

Nesta etapa o segredo é guardado apenas no Redis (totp:enroll:{adminId}, TTL 10 minutos), cifrado. Só vai para admin_users.totp_secret_encrypted depois da confirmação.

Erros: UNAUTHENTICATED (401), TOTP_ALREADY_ENROLLED (409), TOTP_ENROLLMENT_TARGET_FORBIDDEN (403, tentativa de enrolar o segundo fator de outra conta; o enrolamento é sempre da própria conta autenticada).

O código é TOTP_ENROLLMENT_TARGET_FORBIDDEN, e não ADMIN_SELF_MODIFICATION_FORBIDDEN: este último significa o oposto — proibição de modificar a si mesmo —, e um executor que implemente pelo nome acabaria permitindo enrolar o segundo fator de outra conta, que é entrega direta do fator.

8.6.3 POST /api/auth/admin/totp/confirm #

export const TotpConfirmSchema = z.object({
  code: z.string().regex(/^[0-9]{6}$/),
});

Resposta 200:

{
  "data": {
    "enrolled": true,
    "recoveryCodes": [
      "K7M2-P9Q4-XR", "N3F8-T5W1-BZ", "H6J0-L2V7-CD", "R4S9-M1N6-EF", "T8U3-K7P2-GH",
      "W5X1-Q4R8-JK", "Y2Z6-N9M3-LM", "A7B4-P1Q5-NP", "C3D8-T6W2-QR", "E9F5-K3L7-ST"
    ]
  },
  "meta": { "requestId": "req_01K3F8VG5H...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Os códigos de recuperação são exibidos exatamente uma vez. No banco vão apenas os hashes sha256, no formato [{"hash":"<hex>","usedAt":null}, ...]. A resposta é no-store e o front exige confirmação explícita de "guardei os códigos" antes de sair da tela.

Efeitos colaterais: grava totp_secret_encrypted e totp_enrolled_at, grava os hashes de recuperação, revoga todas as outras sessões da conta, registra admin.totp.enroll em admin_audit_log.

Erros: TOTP_INVALID (401), TOTP_REQUIRED (401, enrolamento expirado no Redis), TOTP_ALREADY_ENROLLED (409).

8.6.4 POST /api/auth/admin/recovery #

Usa um código de recuperação no lugar do TOTP. Exige o desafio de senha já concluído.

export const RecoveryCodeSchema = z.object({
  challengeId: z.string().regex(/^chl_[0-9A-HJKMNP-TV-Z]{26}$/),
  code: z.string().trim().regex(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{2}$/),
});

Efeitos: marca o código como usado (usedAt), cria a sessão administrativa, define must_change_password = true — porque quem usou recuperação provavelmente perdeu o dispositivo e precisa reenrolar —, revoga as demais sessões e envia e-mail de aviso ao titular e ao endereço de alertas.

Erros: RECOVERY_CODE_INVALID (401), TOTP_REQUIRED (401), ACCOUNT_LOCKED (403).

Com menos de 3 códigos restantes, a resposta inclui remainingCodes e o painel exibe aviso para regenerar o conjunto. Regenerar invalida todos os anteriores.

8.7 Bloqueio e desbloqueio de conta administrativa #

A escada de bloqueio incide sobre o par (conta, origem), e não sobre a conta isoladamente. failed_login_count é mantido em Redis sob authfail:{adminId}:{ipPrefix}, onde ipPrefix é o /24 do endereço de origem, e a coluna homônima em admin_users guarda apenas o total agregado, para fins de alerta. Uma origem bloqueada não impede o titular legítimo de entrar de outra rede.

Duas salvaguardas fecham o restante:

  1. A partir de 20 falhas agregadas em 1 hora, vindas de 3 ou mais origens distintas, a conta entra em bloqueio global de 1 hora e um alerta crítico é emitido. O ataque distribuído continua sendo contido, mas com aviso e com prazo curto.
  2. O OWNER sempre pode desbloquear a si mesmo por link mágico enviado ao e-mail cadastrado, que exige em seguida senha e TOTP, e é registrado em admin_audit_log.

O motivo é concreto: o e-mail do OWNER é público — aparece no rodapé, na Política de Privacidade e nos Termos. Sem separação por origem, qualquer pessoa que conheça esse endereço envia dez senhas erradas por dia e tranca a operação inteira em bloqueio perpétuo de 24 horas, e o desbloqueio passa a depender de acesso por SSH exatamente no pior momento possível — às 06:03 de um domingo, com o lote diário falhando.

Evento Efeito
Falha de senha failed_login_count = failed_login_count + 1
Falha de TOTP failed_login_count = failed_login_count + 1
Falha de código de recuperação failed_login_count = failed_login_count + 2
Sucesso completo failed_login_count = 0, locked_until = NULL

Escada de bloqueio, por contagem de falhas consecutivas:

Falhas locked_until
1 a 4 Sem bloqueio
5 +5 minutos
6 +15 minutos
7 +1 hora
8 +6 horas
9 ou mais +24 horas, e alerta operacional

Durante o bloqueio, qualquer tentativa devolve ACCOUNT_LOCKED sem verificar a senha. Isso impede que o bloqueio vire um oráculo de tempo.

Desbloqueio, três caminhos:

  1. Automático. Passado locked_until, a próxima tentativa é avaliada normalmente. O contador não zera: uma nova falha bloqueia de novo, no degrau seguinte.
  2. Por outro OWNER. PATCH /api/admin/users/{id} com {"unlock": true}. Exige reason com no mínimo 10 caracteres e é registrado em admin_audit_log.
  3. Autodesbloqueio do OWNER. Link mágico para o e-mail cadastrado, seguido de senha e TOTP. É o caminho normal quando existe um único OWNER, e é auditado.
  4. Pela linha de comando de operação. pnpm ops admin:unlock --email <e-mail>, para o caso em que nem o e-mail está acessível. Exige acesso ao servidor.

A escada existe para tornar a força bruta inviável sem transformar um erro de digitação em bloqueio permanente. Sete tentativas erradas custam mais de uma hora ao atacante; ao usuário legítimo, custa 5 minutos na quinta tentativa.

8.8 Sessões #

8.8.1 Estrutura do JWT #

Algoritmo EdDSA (Ed25519), com a biblioteca jose na linha declarada na Seção 4.

{
  "alg": "EdDSA",
  "typ": "JWT",
  "kid": "k1"
}
{
  "iss": "https://app.palavradiaria.com.br",
  "aud": "palavra-diaria-web",
  "sub": "sub_01K3F8QZ7MHV2N9R4B6T0XYZAB",
  "jti": "ses_01K3F8VJ7K2MNPQRSTUVWXYZAB",
  "typ": "SUBSCRIBER",
  "role": "SUBSCRIBER",
  "tier": "PAID",
  "iat": 1787635200,
  "exp": 1787636100,
  "imp": null
}
Claim Descrição
iss Emissor. Validado na verificação.
aud Público. palavra-diaria-web. Validado.
sub Identificador público do sujeito, com prefixo.
jti Identificador da sessão em sessions.id. É o que torna o token revogável.
typ SUBSCRIBER ou ADMIN.
role Papel congelado na emissão.
tier Tier no momento da emissão. Dica, não autoridade (ver 8.15.4).
iat / exp Emissão e expiração, em segundos epoch.
imp Identificador do administrador que impersona, ou null.

TTL do access token: 15 minutos, para os dois papéis. O que difere é o TTL da sessão como um todo (30 dias para assinante, 12 horas para administrador), controlado por sessions.expires_at e pelo refresh token.

Por que EdDSA e não HMAC: a chave pública pode ser distribuída para verificação sem permitir emissão. Isso separa quem emite (a rota de autenticação) de quem verifica (todo o resto), e permite rotacionar a chave privada sem redeploy coordenado.

// packages/core/src/auth/jwt.ts
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';

const ISSUER = 'https://app.palavradiaria.com.br';
const AUDIENCE = 'palavra-diaria-web';

export async function signSessionToken(claims: SessionClaims): Promise<string> {
  return new SignJWT(claims)
    .setProtectedHeader({ alg: 'EdDSA', typ: 'JWT', kid: currentKeyId() })
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(await importPKCS8(env.SESSION_JWT_PRIVATE_KEY, 'EdDSA'));
}

export async function verifySessionToken(token: string): Promise<SessionClaims> {
  const { payload } = await jwtVerify(token, resolveKey, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['EdDSA'],   // lista fechada: impede troca de algoritmo
    clockTolerance: 5,       // 5 s de tolerância de relógio
  });
  return SessionClaimsSchema.parse(payload);
}

algorithms: ['EdDSA'] é obrigatório e não negociável. Sem essa lista, um token com alg: none ou alg: HS256 assinado com a chave pública poderia ser aceito.

A chave privada de assinatura de sessão tem um único nome de variável de ambiente: SESSION_JWT_PRIVATE_KEY, e a pública é SESSION_JWT_PUBLIC_KEY. Os nomes SESSION_JWK_PRIVATE e JWT_PRIVATE_KEY não existem em lugar nenhum — nem no código, nem no arquivo de composição, nem nos scripts de inicialização. O guarda de boot do processo web valida exatamente esse nome e aborta se ele faltar; um guarda que procure um nome e um registro de variáveis que declare outro faz o contêiner não subir no primeiro deploy de produção, e o conserto improvisado deixa duas chaves de sessão no ambiente sem que ninguém saiba qual assina.

8.8.2 Refresh token #

  • 32 bytes de crypto.randomBytes, em Base64URL. Opaco, sem estrutura.
  • Armazenado apenas como sha256 em sessions.refresh_token_hash.
  • TTL: igual ao da sessão (30 dias para assinante, 12 horas para administrador).
  • Rotacionado a cada uso. Cada refresh cria uma linha nova em sessions com parent_session_id apontando para a anterior, e revoga a anterior com revoked_reason = 'rotation'.

8.8.3 Cookies #

Cookie Conteúdo Flags Max-Age
__Host-session Access token JWT HttpOnly; Secure; SameSite=Lax; Path=/ 900 (15 min)
__Host-refresh Refresh token opaco HttpOnly; Secure; SameSite=Strict; Path=/ 2592000 (assinante) / 43200 (admin)
__Host-admin-session Access token JWT do admin HttpOnly; Secure; SameSite=Lax; Path=/ 900
__Host-admin-refresh Refresh do admin HttpOnly; Secure; SameSite=Strict; Path=/ 43200
__Host-auth-challenge Desafio intermediário de senha (8.4.1) HttpOnly; Secure; SameSite=Strict; Path=/ 300
__Host-admin-device Dispositivo confiável do admin (8.4.2) HttpOnly; Secure; SameSite=Strict; Path=/ 2592000 (30 dias)
__Host-csrf Token CSRF, 32 bytes aleatórios em Base64URL Secure; SameSite=Lax; Path=/sem HttpOnly igual à sessão

O prefixo __Host- obriga o navegador a exigir Secure, Path=/ e ausência de Domain. Isso impede que um subdomínio comprometido sobrescreva o cookie de sessão — um ataque real e barato quando subdomínios são gerenciados por terceiros.

Os seis cookies com prefixo __Host- são emitidos com Path=/, sem exceção. Um Set-Cookie com prefixo __Host- e Path diferente de / é rejeitado silenciosamente por Chrome, Firefox, Safari e Edge: o cookie simplesmente não é gravado, e não há erro visível. Se o refresh fosse emitido com Path=/api/auth, o navegador o descartaria, o JWT de 15 minutos expiraria, POST /api/auth/refresh chegaria sem o cookie e devolveria 401 — ou seja, todo assinante e todo administrador seria deslogado a cada 15 minutos, para sempre. Nenhum teste unitário pega isso; só um teste com navegador real.

O refresh usa SameSite=Strict, que é onde a proteção de fato mora. A restrição de caminho que se perde vale menos do que a proteção contra sobrescrita por subdomínio que o prefixo garante, e o HttpOnly já impede leitura por script em qualquer rota. Um teste de integração afirma que todo Set-Cookie emitido cujo nome comece com __Host- contém Secure, contém Path=/ e não contém Domain.

__Host-csrf é intencionalmente legível por script: é o padrão double-submit de 7.13.2. Ele não é credencial; sozinho não autentica nada. O nome é único em todo o documento — não existe csrf-token nem qualquer outra grafia — e o cliente o envia de volta no header X-CSRF-Token. Um emissor que grave um nome e um verificador que leia outro devolve 403 em toda requisição que muda estado, e o produto inteiro fica inutilizável sem que nenhum teste unitário acuse, porque os testes injetam o par diretamente.

8.8.4 POST /api/auth/refresh #

  • Autenticação: cookie __Host-refresh. Sem corpo.
  • Idempotência: não. Cada chamada rotaciona. Chamadas concorrentes são tratadas em 8.8.5.

Resposta 200:

{
  "data": {
    "expiresAt": "2026-09-24T09:00:00.000Z",
    "accessExpiresInSeconds": 900,
    "role": "SUBSCRIBER",
    "tier": "FREE"
  },
  "meta": { "requestId": "req_01K3F8VM9N...", "timestamp": "2026-08-25T09:15:00.000Z" }
}

O refresh relê o estado atual do sujeito no banco: papel, tier, deleted_at, blocked_at. É o ponto em que uma mudança de tier passa a valer no token (8.15.4).

Erros: UNAUTHENTICATED (401, sem cookie), SESSION_EXPIRED (401), SESSION_REVOKED (401), SESSION_REUSE_DETECTED (401), SUBSCRIBER_DELETED (410), SUBSCRIBER_BLOCKED (403), ACCOUNT_LOCKED (403).

8.8.5 Detecção de reuso de refresh token #

Um refresh token só pode ser usado uma vez. Apresentar um token já rotacionado significa uma de duas coisas: corrida de rede legítima ou token roubado. O sistema trata como roubo, que é a hipótese cara.

Sessão A ──refresh──► Sessão B ──refresh──► Sessão C   (cadeia normal)
   │
   └── alguém apresenta o refresh de A de novo
       → A já está revoked_at com reason='rotation'
       → REVOGA A CADEIA INTEIRA (A, B, C) com reason='reuse_detected'
       → responde 401 SESSION_REUSE_DETECTED
       → registra alerta de segurança e notifica o titular

A revogação percorre parent_session_id nos dois sentidos, então o atacante e o usuário legítimo são desconectados juntos. É agressivo de propósito: preferir que o usuário refaça o login a manter uma sessão possivelmente roubada.

Corrida legítima (duas abas fazendo refresh no mesmo instante) é absorvida por uma janela de tolerância de 30 segundos: se a sessão foi rotacionada há menos de 30 segundos e o device_fingerprint é o mesmo, o sistema devolve a sessão filha já criada em vez de declarar reuso. Fora da janela, ou com device_fingerprint diferente, é reuso e a cadeia inteira é revogada.

O endereço IP não entra nessa comparação. Em rede móvel brasileira, com CGNAT e alternância entre 4G e Wi-Fi, o IP muda entre duas requisições quase simultâneas do mesmo aparelho; exigi-lo igual transformaria um evento cotidiano — duas abas abertas, ambas percebendo o JWT expirado no mesmo segundo — em revogação da cadeia inteira, e-mail de alerta de sessão roubada e logout. Repetido todo dia para uma fração relevante da base, isso gera chamados de suporte e, pior, transforma um alerta de segurança real em ruído que ninguém mais lê. A mudança de IP dentro da janela é registrada no evento, para análise, mas não altera a decisão.

8.9 Gestão de sessões #

8.9.1 GET /api/auth/sessions #

Lista as sessões ativas do sujeito autenticado. Paginação por cursor (7.8).

{
  "data": [
    {
      "id": "ses_01K3F8VJ7K...",
      "current": true,
      "deviceLabel": "Chrome em Android",
      "ipMasked": "189.45.***.**",
      "createdAt": "2026-08-25T09:00:00.000Z",
      "lastSeenAt": "2026-08-25T09:14:00.000Z",
      "expiresAt": "2026-09-24T09:00:00.000Z"
    }
  ],
  "meta": { "requestId": "req_01K3F8VP1Q...", "timestamp": "2026-08-25T09:15:00.000Z",
            "nextCursor": null, "hasMore": false, "limit": 20 }
}

deviceLabel é derivado do user-agent por uma função de rotulagem simples — nunca o user-agent cru, que é ruidoso e pode conter conteúdo controlado pelo cliente. ipMasked esconde os dois últimos octetos: mostra o suficiente para o titular reconhecer a rede sem expor o endereço completo caso a tela seja compartilhada.

Sessões de impersonação aparecem para o assinante em uma lista separada, rotulada Acessos do suporte, na tela Meus dados, com data, duração e a justificativa registrada — nunca o nome do operador, que é dado do administrador. O registro é retido pelo mesmo prazo de admin_audit_log, e não pelo prazo do log técnico.

O motivo é legal e prático ao mesmo tempo: o Art. 18 dá ao titular o direito de saber como seus dados foram tratados, e uma sessão de suporte dentro da conta dele é tratamento. Esconder isso do titular torna o direito inexequível — seis meses depois, a única resposta possível seria "não sabemos com precisão". A transparência é também o que desestimula o acesso desnecessário.

Do lado administrativo, as mesmas sessões aparecem em /api/admin/audit-logs, com o nome do operador e a justificativa completa.

8.9.2 DELETE /api/auth/sessions/{id} #

Revoga uma sessão específica do próprio sujeito.

Resposta 200: {"data":{"revoked":true},"meta":{...}}.

Erros: UNAUTHENTICATED (401), NOT_FOUND (404, sessão de outro sujeito — nunca 403, por 8.12), CONFLICT (409, sessão já revogada).

Revogar a sessão atual é permitido e equivale a logout.

8.9.3 POST /api/auth/logout #

Revoga apenas a sessão atual e limpa os cookies com Max-Age=0. Sempre devolve 200, mesmo sem sessão válida — logout precisa funcionar mesmo com estado inconsistente.

8.9.4 POST /api/auth/logout-all #

Revoga todas as sessões do sujeito, inclusive a atual.

UPDATE sessions
   SET revoked_at = now(), revoked_reason = 'logout_all'
 WHERE subscriber_id = $1 AND revoked_at IS NULL;

Também limpa a lista de dispositivos confiáveis do TOTP quando o sujeito é administrador.

Gatilhos automáticos de logout-all, sem ação do usuário:

Evento revoked_reason
Troca de senha do administrador password_change
Enrolamento ou reenrolamento de TOTP password_change
Uso de código de recuperação password_change
Detecção de reuso de refresh token reuse_detected
Exclusão ou bloqueio da conta admin_revoke
Alteração de papel do administrador admin_revoke

Mudança de tier de PAID para FREE não revoga sessões. O motivo está em 8.15.4.

8.10 Detecção de dispositivo novo #

device_fingerprint é sha256 de user-agent + Accept-Language + plataforma declarada. Não é rastreamento: é um sinal grosseiro, guardado apenas em sessions, que morre com a sessão.

No login bem-sucedido, se não existir sessão anterior não revogada com o mesmo device_fingerprint para aquele sujeito nos últimos 90 dias, o dispositivo é considerado novo. Efeitos:

Sujeito Ação
Assinante com e-mail verificado E-mail: "Novo acesso à sua conta", com data, dispositivo e IP mascarado
Assinante sem e-mail Nenhuma notificação. Enviar pelo WhatsApp custaria uma mensagem e assustaria mais do que ajudaria
Administrador E-mail ao titular e ao endereço de alertas operacionais

A resposta de otp/verify e de admin/totp inclui session.isNewDevice, para que a interface possa exibir um aviso discreto.

Falso positivo é esperado: atualizar o navegador muda o user-agent e gera um "dispositivo novo". O texto da notificação assume isso e é informativo, não alarmante. Nenhuma ação é bloqueada por dispositivo novo — bloquear geraria mais suporte do que segurança.

8.11 Autorização #

8.11.1 Camadas #

1. Middleware de borda   → resolve requestId, aplica cabeçalhos de segurança,
                           verifica Origin, aplica rate limit por IP.
2. Guarda de autenticação → lê o cookie, verifica o JWT, confirma que sessions.revoked_at
                           é nulo e que expires_at está no futuro. Falha → 401.
3. Guarda de papel        → compara o papel exigido pela rota com o da sessão. Falha → 403.
4. Guarda de propriedade  → confirma que o recurso pertence ao sujeito. Falha → 404.
5. Guarda de entitlement  → resolveEntitlements() decide se o tier permite. Falha → 403.
6. Handler                → regra de negócio.

A ordem importa. Verificar propriedade antes do papel vazaria existência de recurso para quem nem deveria estar na rota.

8.11.2 Implementação #

// apps/web/src/lib/auth/guards.ts
const ROLE_RANK: Record<Role, number> = { SUBSCRIBER: 0, EDITOR: 1, ADMIN: 2, OWNER: 3 };

export async function requireSession(req: Request): Promise<SessionContext> {
  const token = readCookie(req, '__Host-session') ?? readCookie(req, '__Host-admin-session');
  if (!token) throw apiError('UNAUTHENTICATED');

  let claims: SessionClaims;
  try {
    claims = await verifySessionToken(token);
  } catch (e) {
    throw apiError(e instanceof JWTExpired ? 'SESSION_EXPIRED' : 'UNAUTHENTICATED');
  }

  // o JWT ser válido não basta: a sessão precisa estar viva no banco
  const session = await db.session.findUnique({ where: { id: stripPrefix(claims.jti) } });
  if (!session) throw apiError('SESSION_REVOKED');
  if (session.revokedAt) throw apiError('SESSION_REVOKED');
  if (session.expiresAt <= new Date()) throw apiError('SESSION_EXPIRED');

  touchLastSeen(session);   // no máximo 1 escrita a cada 5 minutos
  return toContext(claims, session);
}

export function requireRole(ctx: SessionContext, minimum: Role): void {
  if (ctx.subjectType !== 'ADMIN') throw apiError('INSUFFICIENT_ROLE');
  if (ROLE_RANK[ctx.role] < ROLE_RANK[minimum]) throw apiError('INSUFFICIENT_ROLE');
}

export function requireSubscriber(ctx: SessionContext): SubscriberContext {
  if (ctx.subjectType !== 'SUBSCRIBER') throw apiError('FORBIDDEN');
  return ctx as SubscriberContext;
}

A consulta a sessions em toda requisição autenticada é o preço da revogabilidade. Com sessions_pkey é uma leitura por chave primária, abaixo de 1 ms, e o resultado é cacheado no Redis por 30 segundos.

A invalidação desse cache é ativa e síncrona: toda escrita em sessions.revoked_at apaga a chave correspondente no Redis dentro da mesma transação, antes do commit. O TTL de 30 segundos é apenas a rede de proteção para o caso de a invalidação ativa falhar, nunca o mecanismo principal.

Nos caminhos de revogação por segurança — saída de administrador, resposta a vazamento de segredo, detecção de reuso de refresh (8.8.5) e encerramento de impersonação (8.13) — a operação chama revokeAndPurge(), que só retorna após confirmar a remoção das chaves. Se o Redis estiver indisponível, a revogação falha ruidosamente em vez de suceder pela metade. Sem isso, um administrador desligado às 14:00:00 continuaria com acesso pleno ao painel até 14:00:30, e o procedimento de saída afirmaria revogação imediata sem entregá-la — diferença que importa em uma apuração.

8.11.3 Composição por rota #

// apps/web/src/app/api/admin/devotionals/[id]/publish/route.ts
export const POST = withApi(async (req, { params }) => {
  const ctx = await requireSession(req);
  requireRole(ctx, 'ADMIN');
  assertSameOrigin(req);

  const devotionalId = parseId(params.id, 'dev');   // valida prefixo e ULID
  const result = await publishDevotional(devotionalId, ctx);
  await audit(ctx, 'devotional.publish', 'devotionals', devotionalId, { after: result });

  return ok(result, ctx.requestId);
});

withApi é o invólucro que resolve o requestId, aplica o rate limit, converte exceções pelo tratamento de 7.4 e garante os cabeçalhos de resposta. Nenhum handler é exportado sem ele — um teste de arquitetura falha se encontrar export const POST sem withApi.

8.11.4 Entitlements #

Autorização por papel e autorização por plano são coisas diferentes e ficam em guardas separadas. O papel diz quem você é; o entitlement diz o que seu plano compra.

export function requireEntitlement(
  sub: SubscriberContext,
  capability: Capability,
): void {
  const entitlements = resolveEntitlements(sub.subscriber);   // fonte única em packages/core
  if (!entitlements[capability]) throw apiError('ENTITLEMENT_DENIED');
}

resolveEntitlements é a única função autorizada a decidir o que cada tier pode. Nenhum handler, componente ou consulta SQL recalcula isso localmente. O tier vem do banco, não do claim tier do JWT (8.15.4).

8.12 Propriedade de recurso: 403 versus 404 #

Regra fechada:

Situação Resposta
Recurso não existe 404 com o código específico da entidade
Recurso existe e pertence ao solicitante, mas a operação é proibida 403 FORBIDDEN
Recurso existe e não pertence ao solicitante 404, indistinguível do primeiro caso
Sem papel para acessar a rota inteira 403 INSUFFICIENT_ROLE

O terceiro caso é o que importa. Devolver 403 para recurso alheio confirma que ele existe. Com identificadores ULID de 26 caracteres o risco de enumeração é baixo, mas o princípio se mantém: a resposta não revela existência.

export async function getOwnedDevotional(id: string, sub: SubscriberContext) {
  const devotional = await db.devotional.findFirst({
    where: { id, deletedAt: null, status: { in: ['PUBLISHED', 'SENT'] } },
  });
  if (!devotional) throw apiError('DEVOTIONAL_NOT_FOUND');

  const entitlements = resolveEntitlements(sub.subscriber);
  const oldestVisible = entitlements.archiveDays === null
    ? null
    : subDays(new Date(), entitlements.archiveDays);

  // fora do acervo do tier: 403 com código próprio, porque aqui o upgrade resolve
  if (oldestVisible && devotional.scheduledFor < oldestVisible) {
    throw apiError('ARCHIVE_LIMIT_REACHED');
  }
  return devotional;
}

Exceção deliberada e única: quando o recurso é público por natureza e a restrição é de plano, o código é ARCHIVE_LIMIT_REACHED com 403. Ali, revelar a existência é o ponto — é exatamente o gancho de conversão. Devocionais não são segredo entre assinantes; dados de conta são.

Os recursos de /api/me/* nunca aceitam identificador de outro assinante porque o identificador vem da sessão (7.2.2). A guarda de propriedade só é necessária em recursos com identificador no caminho: devocionais, pagamentos e sessões.

8.13 Impersonação de assinante pelo administrador #

8.13.1 Regras #

Aspecto Regra
Papel exigido ADMIN ou OWNER. EDITOR não pode
Justificativa Obrigatória, mínimo 10 caracteres
Duração 30 minutos, não renovável
Escopo Somente leitura, em toda rota executável sob sessão de assinante, qualquer que seja o prefixo
Escritas bloqueadas Todas. Inclui, sem se limitar a: cancelar assinatura, reativar, trocar de plano, trocar o cartão, pedir reenvio, opt-out, opt-in, pausar, exportar dados, pedir eliminação e contratar plano
Escritas permitidas Nenhuma. A sessão é integralmente somente leitura
Marcação da sessão scope = 'IMPERSONATION_READONLY'
Sessões simultâneas 1 por administrador
Auditoria Início e fim registrados. Toda requisição feita sob impersonação é registrada
Visibilidade Faixa fixa no topo da interface, em vermelho, com o nome do assinante e o tempo restante
Bloqueio recíproco Administrador não pode impersonar um assinante que também seja administrador

8.13.2 POST /api/admin/subscribers/{id}/impersonate #

export const ImpersonateSchema = z.object({
  reason: z.string().trim().min(10).max(500),
  ticketRef: z.string().trim().max(60).optional(),
});

Resposta 200:

{
  "data": {
    "impersonationSessionId": "ses_01K3F8VR3S...",
    "subscriber": { "id": "sub_01K3F8QZ7M...", "displayName": "Ana", "tier": "PAID" },
    "expiresAt": "2026-08-25T09:30:00.000Z",
    "readOnly": true
  },
  "meta": { "requestId": "req_01K3F8VR3S...", "timestamp": "2026-08-25T09:00:00.000Z" }
}

A sessão criada tem subject_type = 'SUBSCRIBER', subscriber_id do alvo, impersonated_by_admin_id do administrador e expires_at = now() + 30 minutos. O JWT carrega imp com o identificador do administrador.

O cookie emitido é __Host-session, o mesmo do assinante — a sessão administrativa continua viva em __Host-admin-session, então sair da impersonação é apenas revogar a sessão de impersonação e recarregar.

Erros: IMPERSONATION_NOT_ALLOWED (403), IMPERSONATION_REASON_REQUIRED (422), SUBSCRIBER_NOT_FOUND (404), SUBSCRIBER_DELETED (410), CONFLICT (409, já existe impersonação ativa deste administrador).

8.13.3 Bloqueio de escrita #

export function assertNotImpersonating(ctx: SessionContext): void {
  if (ctx.impersonatedByAdminId) {
    throw apiError('FORBIDDEN', [{ field: 'session', issue: 'not_allowed' }]);
  }
}

Chamada obrigatória no início de todo handler de escrita executável sob sessão de assinante, independentemente do prefixo da rota. O teste de arquitetura enumera todo POST, PATCH, PUT e DELETE cuja guarda seja requireSubscriber — o critério é a guarda, não o caminho — e falha o build se algum não contiver assertNotImpersonating.

O critério anterior, baseado no prefixo /api/me/*, é insuficiente e está proibido: as escritas de maior consequência do assinante — cancelamento, reativação, troca de plano, troca de cartão e reenvio — vivem sob /api/subscriptions/* e /api/devotionals/*. Com o critério de caminho, um operador de suporte que impersone alguém para "entender a reclamação" e clique sem querer em "Cancelar assinatura" executa o cancelamento de verdade: cancela no provedor de pagamento, grava o evento, dispara e-mail e mensagem no WhatsApp — e o teste de arquitetura fica verde, porque a rota não casa com o prefixo. A pessoa recebe uma confirmação de cancelamento que nunca pediu, e a auditoria registra a ação como se fosse dela.

Adicionalmente, a sessão de impersonação carrega scope = 'IMPERSONATION_READONLY', e o invólucro withApi de 8.11.3 recusa qualquer método não seguro nessa sessão antes de chegar ao handler. A verificação por handler passa a ser a segunda camada, nunca a única: uma trava que depende de o desenvolvedor lembrar de chamar uma função é uma trava que eventualmente falha.

8.13.4 Auditoria #

Momento action Conteúdo
Início subscriber.impersonate.start entity_id do assinante, reason, ticketRef, ip, session_id
Cada requisição subscriber.impersonate.request Método, caminho e request_id. Sem corpo
Fim explícito subscriber.impersonate.end Duração efetiva
Fim por expiração subscriber.impersonate.expire Duração de 30 minutos

O registro por requisição é verboso de propósito. A pergunta que a auditoria precisa responder é "o que exatamente o suporte viu da conta desta pessoa", e só o par método+caminho responde isso.

8.14 Diagramas de sequência #

8.14.1 Login do assinante por OTP #

Assinante        Navegador          API                Redis        Postgres      WhatsApp
    │                │               │                   │             │              │
    │ digita telefone│               │                   │             │              │
    ├───────────────►│               │                   │             │              │
    │                │ POST /api/auth/otp/request        │             │              │
    │                ├──────────────►│                   │             │              │
    │                │               │ rate limit ip+phone             │              │
    │                │               ├──────────────────►│             │              │
    │                │               │◄──────────────────┤ ok          │              │
    │                │               │ busca subscriber por phone_hmac │              │
    │                │               ├────────────────────────────────►│              │
    │                │               │◄────────────────────────────────┤ encontrado   │
    │                │               │ invalida OTPs anteriores        │              │
    │                │               │ gera código, grava sha256(pepper‖id‖code)      │
    │                │               ├────────────────────────────────►│              │
    │                │               │ envia template codigo_acesso_v1 │              │
    │                │               ├───────────────────────────────────────────────►│
    │                │               │◄───────────────────────────────────────────────┤ wamid
    │                │◄──────────────┤ 200 {sent:true, expiresInSeconds:600}          │
    │◄───────────────┤ "Código enviado"                  │             │              │
    │                │               │                   │             │              │
    │ recebe 482913 no WhatsApp ◄────────────────────────────────────────────────────┤
    │                │               │                   │             │              │
    │ digita 482913  │               │                   │             │              │
    ├───────────────►│               │                   │             │              │
    │                │ POST /api/auth/otp/verify         │             │              │
    │                ├──────────────►│                   │             │              │
    │                │               │ SELECT ... FOR UPDATE           │              │
    │                │               ├────────────────────────────────►│              │
    │                │               │ verifica hash em tempo constante│              │
    │                │               │ consumed_at = now()             │              │
    │                │               ├────────────────────────────────►│              │
    │                │               │ INSERT em sessions              │              │
    │                │               ├────────────────────────────────►│              │
    │                │               │ assina JWT EdDSA (exp 15 min)   │              │
    │                │◄──────────────┤ 200 + Set-Cookie __Host-session │              │
    │                │                        + Set-Cookie __Host-refresh             │
    │◄───────────────┤ painel                            │             │              │

8.14.2 Login do administrador #

Admin          Navegador            API              Redis         Postgres
  │                │                 │                 │               │
  │ e-mail + senha │                 │                 │               │
  ├───────────────►│                 │                 │               │
  │                │ POST /api/auth/admin/login        │               │
  │                ├────────────────►│                 │               │
  │                │                 │ rate limit ip + email           │
  │                │                 ├────────────────►│               │
  │                │                 │ SELECT admin_users WHERE email  │
  │                │                 ├────────────────────────────────►│
  │                │                 │◄────────────────────────────────┤
  │                │                 │ locked_until > now()? → 403 ACCOUNT_LOCKED
  │                │                 │ argon2.verify (~120 ms)         │
  │                │                 │   (hash fictício se e-mail não existe)
  │                │                 │ falha → failed_login_count++ → 401
  │                │                 │ sucesso: cria desafio 5 min     │
  │                │                 ├────────────────►│ SET authchl:{id}
  │                │◄────────────────┤ 200 {challengeId, requires:"TOTP"}
  │                │                          + Set-Cookie __Host-auth-challenge
  │◄───────────────┤ tela de código  │                 │               │
  │                │                 │                 │               │
  │ código 6 díg.  │                 │                 │               │
  ├───────────────►│                 │                 │               │
  │                │ POST /api/auth/admin/totp         │               │
  │                ├────────────────►│                 │               │
  │                │                 │ GET + DEL authchl:{id} (consome)│
  │                │                 ├────────────────►│               │
  │                │                 │ decifra totp_secret_encrypted   │
  │                │                 ├────────────────────────────────►│
  │                │                 │ verifica TOTP, janela ±1 período│
  │                │                 │ código já usado? → 401 TOTP_INVALID
  │                │                 ├────────────────►│ SET totp:used:{id}:{counter}
  │                │                 │ INSERT sessions (12 h)          │
  │                │                 ├────────────────────────────────►│
  │                │                 │ failed_login_count = 0          │
  │                │◄────────────────┤ 200 + Set-Cookie __Host-admin-session
  │◄───────────────┤ painel admin    │                 │               │

8.14.3 Refresh de sessão com detecção de reuso #

Navegador                API                       Postgres
    │                     │                             │
    │ GET /api/me (JWT expirado)                        │
    ├────────────────────►│                             │
    │◄────────────────────┤ 401 SESSION_EXPIRED         │
    │                     │                             │
    │ POST /api/auth/refresh  (cookie __Host-refresh)   │
    ├────────────────────►│                             │
    │                     │ sha256(refresh) → busca sessão
    │                     ├────────────────────────────►│
    │                     │◄────────────────────────────┤ sessão A
    │                     │                             │
    │                     │ ┌── A.revoked_at IS NULL ────────────────────┐
    │                     │ │  cria sessão B (parent = A)                │
    │                     │ │  revoga A com reason='rotation'            │
    │                     │ │  relê tier e role do banco                 │
    │                     │ │  assina novo JWT (15 min)                  │
    │                     │ └────────────────────────────────────────────┘
    │◄────────────────────┤ 200 + novos cookies         │
    │                     │                             │
    │ ... mais tarde, atacante apresenta o refresh de A ...
    │                     │                             │
    │ POST /api/auth/refresh (refresh antigo de A)      │
    ├────────────────────►│                             │
    │                     │ A.revoked_at = 'rotation'   │
    │                     │ rotacionada há > 30 s, ou device diferente? → REUSO
    │                     │ revoga A, B, C (cadeia inteira)
    │                     ├────────────────────────────►│
    │                     │ alerta de segurança + e-mail ao titular
    │◄────────────────────┤ 401 SESSION_REUSE_DETECTED  │

8.15 Casos de borda #

8.15.1 Número de telefone trocou de dono #

Cenário real: a operadora recicla um número desativado e ele vai para outra pessoa, que começa a receber os devocionais de quem tinha o número antes.

Sinais que o sistema reconhece:

Sinal Ação
Palavra-chave de saída (SAIR, PARAR, CANCELAR, STOP, DESCADASTRAR, REMOVER) Opt-out imediato, confirmação enviada uma única vez
Erro 131026 (não é possível entregar) por 7 dias seguidos blocked_at preenchido, envios cessam
Mensagem de entrada com texto que não corresponde a nenhuma intenção conhecida, vinda de número com opt-out Ignorada; não reabre nada

O sistema nunca cancela a assinatura paga de imediato por opt-out, para que um SAIR acidental não destrua a contratação. Mas cobrança sem entrega não se sustenta. A regra completa é: o opt-out interrompe os envios na hora e suspende a cobrança no ciclo seguinte. O assinante recebe, no ato, uma mensagem pelo canal que ainda funciona — e-mail verificado, ou o próprio WhatsApp, uma única vez, por ser mensagem transacional de encerramento — com o texto: "Paramos de enviar. Sua próxima cobrança está suspensa. Se quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel." Se em 30 dias não houver reativação, a assinatura é cancelada ao fim do período já pago, com aviso, e o acesso ao acervo permanece até essa data. O painel exibe o estado "envios pausados, cobrança suspensa" com as duas ações possíveis.

O motivo é jurídico e direto: a base legal da entrega é o consentimento específico do Art. 11, I da LGPD; revogado o consentimento, não é lícito prestar o serviço, e cobrar por serviço que não se pode prestar é infração ao Código de Defesa do Consumidor além de dano reputacional garantido.

Se o novo dono do número tentar cadastrar, recebe SUBSCRIBER_ALREADY_EXISTS. A saída é o fluxo de suporte: com prova de titularidade, um ADMIN marca a conta antiga como bloqueada e libera o número. A operação é registrada em admin_audit_log com subscriber.phone.release e justificativa obrigatória.

Troca do wa_id associado a um telefone. O identificador da plataforma de mensagens é reemitido quando a pessoa reinstala o aplicativo em outro aparelho — e também quando o chip troca de dono. Por isso, qualquer mudança do wa_id associado a um phone_hmac existente produz, obrigatoriamente e na mesma transação: revogação de todas as sessões daquele assinante (revoked_reason = 'admin_revoke'), invalidação de todos os OTPs ativos, e phone_verified_at = NULL. A partir daí, nenhum dado pessoal é liberado antes de uma nova verificação por código bem-sucedida. Nunca se entrega conta, CPF ou histórico a um número apenas porque ele respondeu no WhatsApp.

8.15.1.1 Reverificação por dormência #

A posse do número é o único fator do assinante (8.1.1), e números brasileiros são reemitidos pelas operadoras. Por isso, o código de acesso sozinho não concede sessão plena quando a conta está dormente.

Uma conta é dormente quando ocorre qualquer uma destas três condições:

Condição Verificação
Sem sinal de vida há 90 dias Nenhuma mensagem de entrada do assinante nem login bem-sucedido nos últimos 90 dias
Bloqueio por erro de entrega blocked_at preenchido por erro 131026 reincidente
Saída sem retorno Opt-out por palavra-chave sem reativação posterior

Nesses casos, POST /api/auth/otp/verify cria uma sessão com scope = 'RESTRICTED', que permite exclusivamente: ler o devocional do dia, executar opt-out e cancelar a assinatura. A sessão restrita recusa com 403 REVERIFICATION_REQUIRED o acesso a GET /api/me, ao acervo, à tela de assinatura, à tela de pagamentos, à troca de telefone e, sobretudo, à exportação de portabilidade e à exclusão de conta.

A elevação para sessão plena exige o link mágico por e-mail verificado (8.3) ou a conferência de titularidade pelo suporte descrita em 8.15.2, item 2. O painel explica em uma frase: "Faz tempo que você não aparece. Para ver seus dados e sua assinatura, confirme por e-mail ou fale com a gente."

Motivo registrado: entregar sessão plena por posse de número a uma conta parada há meses transforma a reciclagem de chip — evento comum, frequente e fora do nosso controle — em vazamento de CPF, histórico financeiro e 18 meses de mensagens de outra pessoa. Ana cancela a linha em março; em maio a operadora reemite o número para Bruno; Bruno vê chegar um devocional, pede o código, recebe no próprio aparelho e, sem esta regra, exporta em três cliques o dossiê completo de Ana.

8.15.2 Assinante perdeu acesso ao WhatsApp #

Ordem de recuperação, do mais simples ao mais custoso:

  1. E-mail verificado. Magic link (8.3). Resolve sem intervenção humana. É a razão de o cadastro incentivar (sem exigir) o e-mail.
  2. Sem e-mail, com assinatura ativa. O suporte confirma titularidade pelos 4 últimos dígitos do CPF, pelo valor e data da última cobrança e pela bandeira e 4 últimos dígitos do cartão. Com dois dos três conferindo, um ADMIN cadastra e verifica um e-mail para a conta. A ação é auditada com subscriber.email.set_by_support.
  3. Sem e-mail, sem assinatura ativa. Não há prova suficiente de titularidade. A orientação é criar conta nova com o número atual. Assinante FREE não tem histórico que justifique o risco de entregar acesso à conta errada.
  4. Número novo, conta antiga com assinatura ativa. Fluxo de troca de número: confirmação de titularidade pelos dados de cobrança, OTP no número novo e migração feita por ADMIN. phone_e164 é atualizado, wa_id é zerado (será repreenchido no primeiro contato) e subscriber.phone.change é auditado.

Troca de número nunca é self-service. PHONE_CHANGE_NOT_ALLOWED é a resposta para qualquer tentativa por rota de assinante. O motivo é direto: quem controla o WhatsApp controla o login, então permitir trocar o número pela sessão transformaria um acesso temporário em sequestro permanente da conta.

8.15.3 Conta excluída #

deleted_at preenchido produz, em ordem:

Momento Efeito
Imediato Todas as sessões revogadas com admin_revoke; todos os OTPs invalidados
Imediato deleted_at preenchido; sai de todas as consultas de envio, que filtram por deleted_at IS NULL
Imediato Login devolve SUBSCRIBER_DELETED (410), nunca 404
Imediato Assinatura ativa é cancelada no provedor de pagamento
30 dias Anonimização (6.33.5): telefone, e-mail e nome zerados
5 anos Expurgo de consent_events e payment_events

410 Gone em vez de 404 é escolha deliberada: o titular precisa saber que a conta foi excluída, não que "não existe". A informação vazada é sobre a conta dele mesmo, que ele já tem.

Durante os 30 dias, um OWNER pode restaurar a conta (subscriber.restore, auditado). O telefone permanece bloqueado para novo cadastro nesse período, porque uq_subscribers_phone_hmac é único sobre as linhas não excluídas e a linha ainda existe. Depois da anonimização, phone_hmac é substituído por um valor derivado e o número fica livre para uma conta nova.

DELETED não é um status: é deleted_at preenchido. O enum SubscriberStatus tem sete valores (PENDING_VERIFICATION, VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID, PAUSED, OPTED_OUT, BLOCKED), e a exclusão é sinalizada pela coluna de data, não por um oitavo valor — o que evita que uma consulta esqueça de filtrar um dos dois e trate conta excluída como ativa.

8.15.4 Assinante rebaixado de PAID para FREE no meio da sessão #

Cenário: o webhook de PAYMENT_OVERDUE chega às 14:32. O assinante está com o painel aberto e um JWT emitido às 14:25 com tier: "PAID", válido até 14:40.

Decisão canônica: o claim tier do JWT é uma dica de interface, nunca autoridade de autorização. Toda decisão de entitlement lê o tier do banco, por resolveEntitlements.

Linha do tempo concreta:

14:25  JWT emitido com tier=PAID, exp=14:40
14:32  webhook PAYMENT_OVERDUE
       ├─ subscriptions.status = EXPIRED
       ├─ subscribers.tier = FREE
       └─ tudo na mesma transação, sem carência
14:33  GET /api/me/devotionals/{id}/audio-url
       ├─ JWT ainda diz tier=PAID
       ├─ guarda de entitlement lê subscribers.tier do banco → FREE
       └─ 403 ENTITLEMENT_DENIED  ← revogação efetiva na hora
14:33  o front recebe 403 e abre o fluxo de upgrade
14:40  JWT expira
14:40  POST /api/auth/refresh → novo JWT com tier=FREE

Três consequências obrigatórias:

  1. A sessão não é revogada. Rebaixamento não é comprometimento de segurança. Derrubar o login de alguém que acabou de ter o cartão recusado é hostil e não resolve nada.
  2. Nenhuma consulta usa claims.tier para decidir acesso. Um teste de arquitetura proíbe qualquer referência a claims.tier fora da camada de apresentação.
  3. O envio já reflete o novo tier, inclusive dentro do lote em execução. O planejador lê subscribers.tier às 05:40 e grava delivery_attempts.tier_at_send, mas esse valor é registro histórico, nunca autoridade. Imediatamente antes de chamar o provedor, e dentro do mesmo job, o motor de envio relê subscribers.tier, opt_out_at, deleted_at e blocked_at por chave primária e reavalia o tier no momento do disparo (Seção 18.5). Assinante que perdeu o acesso pago entre o planejamento das 05:40 e o disparo das 06:00 recebe apenas o texto do plano gratuito e não recebe áudio. Sem essa releitura, o congelamento das 05:40 concederia na prática um dia de carência a todo inadimplente — exatamente a carência que a regra do produto proíbe. O CHECK chk_delivery_audio_step_paid (6.20.3) continua garantindo no banco que nenhuma etapa de áudio é planejada para tier FREE; ele valida o planejamento, e por isso não substitui a revalidação no disparo.

O caminho inverso — FREE para PAID após confirmação de pagamento — segue a mesma lógica e é até mais visível: o assinante recebe 403 até o pagamento confirmar e passa a receber 200 no instante seguinte, sem precisar sair e entrar de novo.

8.15.5 Outros casos tratados #

Caso Comportamento
Dois OTPs pedidos em abas diferentes O segundo invalida o primeiro. Só o mais recente funciona. A interface avisa: "Enviamos um novo código; use o mais recente."
Relógio do servidor fora de sincronia clockTolerance: 5 no JWT absorve até 5 s. O alerta clock_drift (Seção 23.8) dispara com desvio acima de 2 segundos em relação à fonte de tempo, isto é, bem antes de o limite de tolerância ser atingido — um alerta que só dispara quando a falha já ocorreu não é alerta. Verificação de TOTP com deriva de ±1 período absorve até 30 s no dispositivo do usuário.
Redis indisponível durante o login Rotas de autenticação devolvem SERVICE_UNAVAILABLE (7.12.4). Sem Redis não há proteção contra força bruta, e sessões existentes continuam funcionando pela verificação no banco.
Postgres indisponível Toda autenticação falha com DATABASE_UNAVAILABLE. Não há caminho degradado: sem banco não é possível verificar revogação.
Cookie de sessão sem cookie de refresh Comportamento normal por 15 minutos, depois SESSION_EXPIRED sem recuperação. O usuário refaz o login.
Cookie de refresh sem cookie de sessão O refresh funciona e emite os dois cookies. É o estado normal após 15 minutos de inatividade.
Administrador rebaixado de ADMIN para EDITOR Todas as sessões dele são revogadas com admin_revoke. Papel é autorização estrutural, então a revogação é imediata e total — ao contrário do tier.
Último OWNER tenta se rebaixar LAST_OWNER_PROTECTED (409). Verificado dentro da transação com SELECT count(*) ... FOR UPDATE sobre as contas OWNER ativas, para que duas requisições simultâneas não removam os dois últimos.
Impersonação ativa e o administrador faz logout A sessão de impersonação é revogada junto, com revoked_reason = 'admin_revoke'. Sessão de suporte nunca sobrevive à sessão que a criou.
Opt-out, cancelamento de assinatura, troca de cartão ou pedido de eliminação tentados durante a impersonação Impossível, sem exceção: withApi recusa qualquer método não seguro sob scope = 'IMPERSONATION_READONLY', e assertNotImpersonating é a segunda camada em todo handler com guarda requireSubscriber (8.13.3). O critério é a guarda, não o prefixo da rota.
Conta dormente pede código e acerta Sessão nasce com scope = 'RESTRICTED' (8.15.1.1). Ler o devocional do dia, sair e cancelar funcionam; ver dados pessoais, acervo, pagamentos e exportação devolvem 403 REVERIFICATION_REQUIRED.
wa_id do assinante muda Todas as sessões são revogadas, os OTPs ativos invalidados e phone_verified_at zerado. Nova verificação por código é obrigatória antes de qualquer dado pessoal (8.15.1).
Magic link aberto duas vezes O primeiro consumo grava consumed_at. O segundo devolve MAGIC_LINK_INVALID, e o front oferece pedir um novo.
Login bem-sucedido com senha que precisa de rehash O hash é reescrito com os parâmetros correntes na mesma requisição, de forma transparente (8.5.1).

9. Landing Page e Página de Vendas — Especificação Funcional #

9.1 Escopo, princípios e decisões desta seção #

Esta seção especifica a superfície pública do produto: tudo que um visitante não autenticado vê antes de virar assinante. Ela define rotas, blocos, componentes, dados, comportamento, acessibilidade, SEO, performance, analytics e páginas de erro.

Ela não define o texto de marketing. Headlines, subheadlines, bullets, FAQ, depoimentos e rótulos de CTA são propriedade da Seção 10. Onde esta seção precisa de texto, ela nomeia a chave de conteúdo e diz que o copy aprovado da Seção 10 preenche o bloco. O componente recebe o texto por prop ou por leitura do catálogo de copy; nunca duplica string de marketing dentro do JSX.

Decisões tomadas aqui e válidas para todo o documento:

# Decisão Justificativa
D9.1 A landing e os painéis vivem no mesmo app Next.js (apps/web), separados por route groups (marketing), (app) e (admin). Um deploy, um domínio de sessão, zero duplicação de design system.
D9.2 Páginas públicas são estáticas por padrão (SSG). O único segmento com revalidação é a home, por causa do bloco do devocional público do dia (ISR, revalidate = 300). LCP baixo sem custo de servidor por visita.
D9.3 Não existe página pública por data de devocional no MVP (/devocional/2026-08-25 não é rota). O acervo é benefício de assinante e vive atrás de login (Seção 14.6). O público vê apenas o devocional do dia corrente, com áudio de amostra. Evita canibalizar o valor da assinatura e reduz superfície de SEO duplicado.
D9.4 Analytics é first-party e sem cookie por padrão. Eventos vão para POST /api/public/events, que loga estruturado (Seção 23). A rota pública nunca escreve em daily_metrics: quem consolida é o job de rollup das 03:10 (Seção 21.5), lendo os eventos agregados do dia anterior já fechado. Ferramentas de terceiros são opcionais e só carregam com consentimento (Seção 9.14). Conformidade LGPD por desenho e nenhuma tabela nova fora da lista fechada da Seção 6.
D9.5 O formulário de captura da landing não cria assinante. Ele só valida e faz handoff para /cadastro, que é território da Seção 11. Uma única implementação do cadastro, um único ponto de consentimento.
D9.6 Nenhuma página pública lê a tabela subscribers. A superfície pública consome apenas devotionals publicados, plans e a matriz de entitlements. Reduz risco de vazamento de dado pessoal em cache de CDN.

Restrição de estilo permanente: sem emoji, sem exclamação em série, sem promessa de resultado espiritual. A Seção 10 já garante isso no copy; a Seção 9 garante que nenhum componente injete decoração fora do copy aprovado.

9.2 Mapa de rotas públicas #

Todas as rotas abaixo vivem no route group (marketing) de apps/web, sem middleware de autenticação, e respondem em https://palavradiaria.com.br. As versões www. redirecionam com 308 para o apex.

Rota Arquivo Renderização Propósito Indexável
/ app/(marketing)/page.tsx ISR, revalidate = 300 Home e página de vendas principal. Contém a oferta completa, o devocional público do dia e o formulário de captura. Sim
/planos app/(marketing)/planos/page.tsx SSG Comparativo detalhado FREE × PAID, preços mensal e anual, FAQ de cobrança, CTA para checkout. Sim
/termos app/(marketing)/termos/page.tsx SSG Termos de Uso, com versão e data de vigência visíveis. Sim
/privacidade app/(marketing)/privacidade/page.tsx SSG Política de Privacidade, bases legais, direitos do titular, contato do encarregado. Conteúdo jurídico definido na Seção 22. Sim
/faq app/(marketing)/faq/page.tsx SSG Perguntas frequentes completas. Fonte do JSON-LD FAQPage. Sim
/contato app/(marketing)/contato/page.tsx SSG + Server Action Formulário de contato e canais de atendimento. Sim
/cancelamento app/(marketing)/cancelamento/page.tsx SSG Explica, em linguagem simples, todas as formas de cancelar: palavra-chave no WhatsApp, painel, e-mail. Exigência de transparência do CDC. Sim
/404 app/not-found.tsx Estático Página de não encontrado. Não (noindex)
/500 app/error.tsx + app/global-error.tsx Estático Falha de renderização. Não
/manutencao app/manutencao/page.tsx Estático, servido com 503 Janela de manutenção programada. Não

Redirecionos permanentes (308) configurados em next.config.ts:

// apps/web/next.config.ts (trecho)
const redirects = async () => [
  { source: '/precos', destination: '/planos', permanent: true },
  { source: '/planos/', destination: '/planos', permanent: true },
  { source: '/politica-de-privacidade', destination: '/privacidade', permanent: true },
  { source: '/termos-de-uso', destination: '/termos', permanent: true },
  { source: '/cancelar', destination: '/cancelamento', permanent: true },
  { source: '/perguntas-frequentes', destination: '/faq', permanent: true },
];

Rotas públicas que não são páginas e existem para servir a landing:

Rota Método Auth Propósito
/api/public/devotional-preview GET Nenhuma Devocional público do dia (teaser + metadados + URL do áudio de amostra). Detalhe em 9.16.1.
/api/public/plans GET Nenhuma Planos ativos com preço formatado e matriz de entitlements. Detalhe em 9.16.2.
/api/public/events POST Nenhuma Ingestão de eventos de analytics first-party. Detalhe em 9.16.3.
/api/public/contact POST Nenhuma + Turnstile Envio do formulário de contato. Detalhe em 9.16.4.
/api/public/config GET Nenhuma Configuração pública não secreta que a landing e o painel consomem em tempo de execução. Detalhe em 9.16.5.
/sitemap.xml GET Nenhuma Gerado por app/sitemap.ts.
/robots.txt GET Nenhuma Gerado por app/robots.ts.
/opengraph-image GET Nenhuma Imagem OG dinâmica por rota, via ImageResponse.

9.3 Estrutura de blocos da home, em ordem #

A home é uma sequência determinística de blocos. A ordem abaixo é normativa: o executor não pode reordenar sem alterar esta seção. Cada bloco é um Server Component, exceto onde indicado como Client Component ('use client').

9.3.1 Tabela de blocos #

# Bloco Componente Tipo Dados exibidos Comportamento
B1 Barra de anúncio <AnnouncementBar> Server Texto curto do copy aprovado da Seção 10; link para /planos. Dispensável: botão "fechar" grava pd_announcement_dismissed em localStorage por 30 dias. Só aparece se a chave de configuração landing.announcement_enabled (BOOL, padrão false) estiver ligada. A chave segue o formato grupo.chave e integra o catálogo de settings da Seção 26.8.1, semeado pela Seção 6.
B2 Cabeçalho <SiteHeader> Client Logotipo, navegação (Planos, FAQ, Contato), botão Entrar (→ /login), CTA primário. Sticky a partir de 96 px de rolagem, com sombra. Em telas < md, vira menu em Sheet do shadcn/ui.
B3 Herói <HeroSection> Server Headline, subheadline e CTA do copy aprovado da Seção 10; mockup de conversa do WhatsApp; formulário de captura inline (B3a). Primeiro elemento do DOM depois do header. Contém o LCP: a imagem do mockup é priority.
B3a Captura inline <LeadCaptureForm variant="hero"> Client Campo único de telefone + CTA. Ver 9.7.
B4 Prova social curta <SocialProofStrip> Server Contagem de assinantes ativos arredondada para baixo à centena, avaliação média e número de devocionais publicados. Números vêm de /api/public/plansmeta.stats. Se assinantes_ativos < 500, o bloco esconde a contagem e mostra apenas os devocionais publicados. Regra anti-exagero.
B5 Como funciona <HowItWorksSection> Server Três passos ilustrados: cadastro, confirmação no WhatsApp, devocional às 06:00. Ícones inline em SVG, sem biblioteca de ícones externa no caminho crítico.
B6 Amostra de áudio <AudioSampleSection> Client Devocional público do dia: título, referência bíblica, teaser e player de áudio de amostra. Ver 9.6.
B7 Benefícios <BenefitsGrid> Server Seis cartões de benefício do copy aprovado da Seção 10. Grade 1 / 2 / 3 colunas por breakpoint.
B8 Comparativo de planos <PlanComparison> Server Matriz FREE × PAID renderizada a partir dos entitlements. Preços mensal e anual com alternador. Ver 9.5.
B9 Depoimentos <TestimonialCarousel> Client Depoimentos reais cadastrados, com nome, cidade e foto opcional. Os blocos de exemplo da Seção 10.9 são gabaritos de redação e nunca são renderizados. Carrossel com rolagem por scroll-snap; sem autoplay (ver 9.10.6). Renderiza null quando não há depoimento real: a seção não ocupa espaço nem desloca o layout, e nada a substitui — nem texto de espera, nem esqueleto (Seção 10.9).
B10 FAQ resumido <FaqAccordion limit={6}> Server Seis perguntas mais frequentes do copy aprovado da Seção 10 + link para /faq. Accordion do shadcn/ui, um item aberto por vez, primeiro item aberto por padrão.
B11 Garantia e transparência <TrustSection> Server Como cancelar, o que acontece com os dados, contato do encarregado, selo de pagamento pela Asaas. Link direto para /cancelamento e /privacidade.
B12 CTA final <FinalCtaSection> Server Repetição da oferta e do formulário de captura (variant="final"). Segunda instância do <LeadCaptureForm> com source="final_cta".
B13 Rodapé <SiteFooter> Server Navegação secundária, CNPJ, endereço, e-mail do encarregado, versão dos termos, link para preferências de cookies. O link "Preferências de cookies" reabre o modal de consentimento (9.14).

9.3.2 Regras transversais aos blocos #

  1. Cada bloco de topo renderiza um <section> com aria-labelledby apontando para o id do seu próprio título. Blocos sem título visível usam aria-label.
  2. Cada bloco recebe data-block="B3" (o identificador da tabela acima) para permitir correlação com os eventos de analytics da Seção 9.13 sem depender de seletor CSS.
  3. Nenhum bloco pode empurrar layout depois da hidratação. Todo elemento de altura variável reserva espaço com min-height ou aspect-ratio (orçamento de CLS em 9.12).
  4. Blocos B4, B6 e B8 dependem de dados de servidor. Se a origem falhar, cada um tem degradação definida:
    • B4: esconde o bloco inteiro.
    • B6: mostra o estado de erro do player (9.6.4) e mantém o restante da página.
    • B8: renderiza a matriz a partir do fallback estático embutido em build time (getEntitlementMatrix() de packages/core), sem preço; o preço vira "consulte em /planos" e o CTA aponta para /planos.

9.3.3 Ordem visual em mobile #

Em viewports < 768 px, a ordem lógica do DOM é mantida (importante para leitores de tela), com dois ajustes puramente visuais implementados por order do Flexbox dentro do mesmo bloco, nunca entre blocos:

  • Em B3, o formulário de captura aparece acima do mockup da conversa.
  • Em B8, o cartão do plano PAID aparece antes do FREE, porque é a ação desejada.

Nunca usar order para reordenar seções inteiras: isso quebra a ordem de navegação por teclado (Seção 9.10.4).

9.4 Componentes React e contratos de props #

Os componentes de marketing vivem em apps/web/src/components/marketing/. Todos são tipados; nenhum aceita any. Os tipos de domínio (EntitlementMatrix, PublicPlan, PublicDevotional) são exportados por packages/core e reaproveitados pelo worker.

// packages/core/src/types/public.ts
// 'plan_free' é sintético; não existe em plans (Seção 13.1). Ele é montado pelo handler
// da rota pública de planos e nunca é aceito em nenhuma escrita.
export type PlanCode = 'plan_free' | 'plan_monthly' | 'plan_annual';
export type BillingCycle = 'MONTHLY' | 'YEARLY';
export type Tier = 'FREE' | 'PAID';

export interface PublicPlan {
  code: PlanCode;
  tier: Tier;
  name: string;                 // "Gratuito" | "Mensal" | "Anual"
  cycle: BillingCycle | null;   // null para plan_free
  priceCents: number;           // 0 | 1990 | 19900
  currency: 'BRL';
  priceLabel: string;           // "R$ 19,90/mês" — formatado no servidor
  monthlyEquivalentLabel: string | null; // "R$ 16,58/mês" para o anual
  savingsLabel: string | null;  // "economize 16%" — arredondado para baixo (Seção 9.5.3)
  isRecommended: boolean;
}

export interface EntitlementRow {
  key: string;                  // "daily_frequency", "audio", "archive", ...
  label: string;                // rótulo em português, vindo da Seção 13
  free: EntitlementCell;
  paid: EntitlementCell;
}

export type EntitlementCell =
  | { kind: 'boolean'; value: boolean }
  | { kind: 'text'; value: string }
  | { kind: 'quota'; value: number; unit: string };

export interface EntitlementMatrix {
  version: string;              // "2026-08-25" — muda quando a matriz muda
  rows: EntitlementRow[];
}

export interface PublicDevotional {
  id: string;                   // "dev_01H..."
  date: string;                 // "2026-08-25" (America/Sao_Paulo)
  title: string;
  bibleReference: string;       // "Salmos 23:1-3"
  bibleVersion: string;         // "Almeida 1911" | "Bíblia Livre" — nunca a sigla "ARC"
  teaser: string;               // 40 a 300 caracteres, sem quebras (Seção 15.2.5)
  sampleAudioUrl: string | null;// URL assinada, TTL curto; null se ainda não gerado
  sampleAudioDurationSec: number | null;
  sampleAudioMimeType: 'audio/mpeg' | null;
}

Contratos dos componentes principais:

// apps/web/src/components/marketing/hero-section.tsx
export interface HeroSectionProps {
  headline: string;
  subheadline: string;
  primaryCta: { label: string; href: string };
  secondaryCta?: { label: string; href: string };
  /** Bullets curtos exibidos sob o CTA. Máximo de 3. */
  highlights: readonly string[];
  /** Mockup da conversa. Sempre local, nunca hotlink. */
  media: { src: string; alt: string; width: number; height: number };
}

// apps/web/src/components/marketing/plan-comparison.tsx
export interface PlanComparisonProps {
  plans: readonly PublicPlan[];
  matrix: EntitlementMatrix;
  /** Ciclo destacado por padrão no alternador. */
  defaultCycle: BillingCycle;
  /** Para onde o CTA leva. Sempre /cadastro?plan=..., nunca direto ao checkout. */
  ctaHrefBuilder: (plan: PublicPlan) => string;
}

// apps/web/src/components/marketing/audio-sample-player.tsx
export interface AudioSamplePlayerProps {
  devotional: PublicDevotional;
  /** Rótulos vindos do copy aprovado da Seção 10. */
  labels: {
    play: string; pause: string; loading: string;
    error: string; retry: string; unavailable: string;
  };
  onEvent?: (e: AudioPlayerEvent) => void;
}

export type AudioPlayerEvent =
  | { type: 'play'; positionSec: number }
  | { type: 'pause'; positionSec: number }
  | { type: 'progress'; milestone: 25 | 50 | 75 | 100 }
  | { type: 'error'; reason: 'network' | 'decode' | 'not_found' | 'expired' };

// apps/web/src/components/marketing/lead-capture-form.tsx
export interface LeadCaptureFormProps {
  variant: 'hero' | 'final' | 'inline';
  /** Rastreia de qual bloco veio o lead. Vai para o evento e para a query string. */
  source: 'hero' | 'final_cta' | 'plans_page' | 'faq_page';
  /** Plano pré-selecionado quando o formulário está ao lado de um cartão de plano. */
  planCode?: PlanCode;
  submitLabel: string;
  helperText: string;
  legalText: string; // aviso curto sobre consentimento; texto completo em /cadastro
}

Os componentes de UI genéricos (Button, Card, Accordion, Sheet, Dialog, Skeleton, Slider) vêm de shadcn/ui sobre primitivos Radix, conforme a Seção 4. Nenhum componente de marketing define variantes de botão próprias: todas as variantes vivem no buttonVariants central, descrito na Seção 5.

9.5 Comparativo de planos a partir da matriz de entitlements #

Regra dura: a tabela de comparação nunca é escrita à mão no JSX. Ela é derivada da matriz canônica de entitlements, cuja fonte única é a Seção 13 e cuja implementação é a função resolveEntitlements() em packages/core. Se a matriz mudar, a landing muda sozinha, sem edição de componente.

9.5.1 Derivação #

// packages/core/src/entitlements/matrix.ts
import { resolveEntitlements } from './resolve';

/**
 * Constrói a matriz de comparação pública a partir da MESMA função que o motor de
 * envio e o painel usam para autorizar. Sem literais duplicados.
 */
export function getEntitlementMatrix(): EntitlementMatrix {
  const free = resolveEntitlements({ tier: 'FREE' });
  const paid = resolveEntitlements({ tier: 'PAID' });

  return {
    version: ENTITLEMENT_MATRIX_VERSION,
    rows: [
      {
        key: 'send_frequency',
        label: 'Frequência do devocional',
        free: { kind: 'text', value: describeFrequency(free) },
        paid: { kind: 'text', value: describeFrequency(paid) },
      },
      {
        key: 'full_text',
        label: 'Texto completo do devocional',
        free: { kind: 'boolean', value: free.fullText },
        paid: { kind: 'boolean', value: paid.fullText },
      },
      {
        key: 'audio',
        label: 'Áudio narrado',
        free: { kind: 'boolean', value: free.audio },
        paid: { kind: 'boolean', value: paid.audio },
      },
      {
        key: 'archive',
        label: 'Acervo no painel',
        free: { kind: 'text', value: describeArchive(free) },
        paid: { kind: 'text', value: describeArchive(paid) },
      },
      {
        key: 'manual_resend',
        label: 'Reenvio manual pelo painel',
        free: { kind: 'quota', value: free.manualResendPerDay, unit: 'por dia' },
        paid: { kind: 'quota', value: paid.manualResendPerDay, unit: 'por dia' },
      },
      {
        key: 'support',
        label: 'Suporte',
        free: { kind: 'text', value: describeSupport(free) },
        paid: { kind: 'text', value: describeSupport(paid) },
      },
    ],
  };
}

describeFrequency, describeArchive e describeSupport são funções puras que traduzem os valores resolvidos para português. Elas são as únicas produtoras de texto derivado de entitlement no sistema e são reutilizadas pelo painel do assinante (Seção 14.2.1) e pela ficha do assinante no painel administrativo (Seção 15.7.2). Um teste de contrato (Seção 24) falha o build se a quantidade de linhas da matriz mudar sem atualização do teste — isso força revisão consciente do comparativo sempre que um entitlement nasce ou morre.

9.5.2 Renderização #

  • Em >= md: tabela HTML real (<table> com <caption> visualmente oculto, <thead> com scope="col" e primeira coluna com scope="row"). Leitores de tela anunciam linha e coluna corretamente.
  • Em < md: dois cartões empilhados (<PlanCard>), PAID primeiro, cada um listando as mesmas linhas em <dl>. Não usar tabela com rolagem horizontal: falha em alvo de toque e em zoom de 200%.
  • Célula boolean: true renderiza um ícone de marcação com <span class="sr-only"> contendo "Incluído". Célula boolean: false renderiza traço com texto oculto "Não incluído". Nunca depender só de cor ou de glifo.
  • Célula quota renderiza "{value}x {unit}" (ex.: 3x por dia).

9.5.3 Alternador mensal/anual #

// Estado local do <PlanComparison>, sem persistência entre páginas.
// Regra: alternar NÃO recarrega a página nem chama a API. Os dois preços já vieram.
type CycleToggleState = { cycle: BillingCycle };
  • Controle é um role="radiogroup" com dois role="radio", navegável por setas.
  • Ao trocar para YEARLY, exibe savingsLabel do plano anual (economize 16%). O rótulo vem calculado do servidor; o cliente nunca faz aritmética de preço.
  • Exemplo concreto com os preços padrão da Seção 1: mensal R$ 19,90 → 12 meses = R$ 238,80; anual R$ 199,00; economia = R$ 39,80 = 16,66% → arredondado para baixo, savingsLabel = "economize 16%". A regra de arredondamento é sempre para baixo, para nunca exagerar o benefício.
  • O evento plan_cycle_toggled é emitido a cada troca (Seção 9.13).

9.5.4 Destino do CTA #

O CTA de qualquer plano leva a /cadastro?plan=<code>&source=<source>. Ele nunca leva direto ao checkout da Asaas. Motivo: o checkout exige assinante identificado e verificado por OTP; a Seção 11 é dona desse fluxo e a Seção 12 é dona do checkout.

Exemplo: o cartão anual na home gera /cadastro?plan=plan_annual&source=home_plan_comparison.

9.6 Player de amostra de áudio #

9.6.1 O que é a amostra #

O bloco B6 exibe o devocional público do dia: o mesmo conteúdo que os assinantes receberam às 06:00, com teaser (não o texto completo) e um áudio de amostra de até 60 segundos, cortado do início da narração completa.

Decisões:

  • O corte de 60 s é gerado pelo pipeline de mídia (Seção 16) como um derivado adicional do MP3 web, com sufixo de chave sample. Não há transcodificação em tempo de request.
  • Formato servido ao navegador: MP3 128 kbps mono (audio/mpeg). O OGG/Opus existe para o WhatsApp e não é usado no player web, porque a compatibilidade de MP3 em Safari é irrestrita.
  • A URL é assinada com TTL de 15 minutos (Seção 16). Como a home é ISR com revalidate = 300, a URL em cache nunca é mais velha que 5 minutos e sempre tem pelo menos 10 minutos de validade restante quando o visitante a recebe.
  • Se o devocional do dia ainda não tem áudio pronto (AUDIO_PENDING), a API devolve sampleAudioUrl: null e o player entra no estado "indisponível" (9.6.4), sem erro.

9.6.2 Marcação e controles #

O player usa um elemento <audio> nativo com controls removido e controles customizados, para atingir contraste e alvo de toque adequados. Requisitos:

<div role="group" aria-label="Amostra do devocional de hoje">
  <audio
    ref={audioRef}
    preload="none"                     // nunca baixa áudio sem intenção do usuário
    src={devotional.sampleAudioUrl ?? undefined}
    onLoadedMetadata={...} onTimeUpdate={...} onEnded={...} onError={...}
  />
  <button type="button" aria-label={isPlaying ? labels.pause : labels.play}
          aria-pressed={isPlaying} className="h-12 w-12 ..."></button>
  <div role="slider" tabIndex={0}
       aria-label="Posição da reprodução"
       aria-valuemin={0} aria-valuemax={durationSec}
       aria-valuenow={positionSec}
       aria-valuetext={formatTimeAria(positionSec, durationSec)} />
  <output aria-live="off">{formatTime(positionSec)} / {formatTime(durationSec)}</output>
</div>

Comportamento de teclado no role="slider":

Tecla Efeito
Espaço / Enter no botão Alterna reproduzir/pausar
Seta esquerda / direita ±5 s
Seta abaixo / acima ±5 s
Page Up / Page Down ±15 s
Home / End Início / fim
M Alterna mudo

aria-live="off" no contador é intencional: o tempo muda a cada 250 ms e anunciá-lo inundaria o leitor de tela. Mudanças de estado relevantes (iniciou, pausou, terminou, falhou) são anunciadas por uma região aria-live="polite" separada e de baixa frequência.

9.6.3 Estados de carregamento #

Estado Gatilho UI Duração máxima
idle Render inicial Botão de reproduzir habilitado, duração já conhecida via sampleAudioDurationSec (evita CLS).
buffering play() chamado, readyState < 3 Botão vira spinner com aria-busy="true" e rótulo labels.loading. 10 s, depois vira error:network.
playing onPlay Botão vira pausa, barra progride via requestAnimationFrame limitado a 4 Hz.
paused onPause Botão volta a reproduzir, posição preservada.
ended onEnded Botão volta a reproduzir, posição volta a 0, CTA secundário "Assine para ouvir completo" ganha foco visual (não foco de teclado).
unavailable sampleAudioUrl === null Botão desabilitado com aria-disabled="true" e texto labels.unavailable. Sem spinner infinito.
error onError ou timeout Ver 9.6.4.

Só um player pode tocar por vez na página. O <AudioSampleSection> registra o elemento em um contexto simples; um segundo play() pausa o anterior.

9.6.4 Erros do player #

Causa Detecção AudioPlayerEvent.reason Mensagem exibida Ação oferecida
Rede indisponível MediaError.MEDIA_ERR_NETWORK ou timeout de 10 s network "Não foi possível carregar o áudio. Verifique sua conexão." Botão "Tentar de novo" (recarrega src com cache-buster).
Codec/decodificação MEDIA_ERR_DECODE ou MEDIA_ERR_SRC_NOT_SUPPORTED decode "Seu navegador não conseguiu tocar este áudio." Link "Baixar amostra" apontando para a mesma URL com download.
URL assinada expirada 403 do storage, detectado como MEDIA_ERR_NETWORK após fetch(url, {method:'HEAD'}) de diagnóstico expired "O link do áudio expirou." Botão "Atualizar" que refaz GET /api/public/devotional-preview e substitui o src.
Áudio não existe sampleAudioUrl === null na resposta not_found labels.unavailable: "O áudio de hoje está sendo preparado." Nenhuma; o restante do bloco continua utilizável.

Regras adicionais:

  1. No máximo 2 tentativas automáticas de recarregar após network, com espera de 1 s e 3 s. A terceira exige clique.
  2. Toda ocorrência de error emite o evento audio_sample_error (Seção 9.13) com reason e devotionalId, e um log warn no servidor apenas quando o diagnóstico de expiração confirma 403 (Seção 23).
  3. O player nunca reproduz automaticamente. Autoplay é proibido em toda a superfície pública, por acessibilidade e por política de navegadores.

9.6.5 Acessibilidade do player #

  • Contraste de todos os controles ≥ 4,5:1 para texto e ≥ 3:1 para os ícones e a barra de progresso, conforme 9.10.2.
  • Alvo de toque mínimo 44 × 44 CSS px no botão principal e 44 px de altura na área de arrasto da barra.
  • prefers-reduced-motion: reduce desliga a animação de forma de onda e mantém apenas a barra linear.
  • O player é totalmente operável sem mouse e sem áudio: o teaser textual do mesmo devocional está ao lado, então nenhuma informação existe apenas em áudio (WCAG 1.2.1).

9.7 Formulário de captura e handoff para o cadastro #

9.7.1 O que ele faz e o que ele não faz #

O <LeadCaptureForm> coleta apenas o telefone. Ele não coleta nome, não coleta e-mail, não pede consentimento e não cria nenhum registro no banco. Sua única função é reduzir o atrito da primeira ação e levar o visitante ao cadastro com o campo já preenchido.

Justificativa da decisão: o consentimento precisa ser dado uma vez, com texto versionado e registro imutável em consent_events (Seção 22). Coletar consentimento em dois lugares distintos duplica risco jurídico e código. Portanto, o consentimento acontece só em /cadastro (Seção 11.3).

9.7.2 Validação no cliente #

Validação com react-hook-form + Zod, usando o mesmo schema compartilhado que a Seção 11 usa no servidor, importado de packages/core:

// packages/core/src/schemas/phone.ts
import { z } from 'zod';
import { normalizeBrazilPhone } from '../phone';

export const phoneInputSchema = z
  .string()
  .trim()
  .min(1, 'Informe seu número de WhatsApp.')
  .transform((v) => v.replace(/[^\d+]/g, ''))
  .superRefine((v, ctx) => {
    const result = normalizeBrazilPhone(v);
    if (!result.ok) {
      ctx.addIssue({ code: 'custom', message: PHONE_ERROR_MESSAGES[result.reason] });
    }
  })
  .transform((v) => normalizeBrazilPhone(v).e164!);

As mensagens de erro exatas, a normalização e o tratamento de DDI estrangeiro são canônicos na Seção 11.4. O formulário de captura apenas reaproveita.

Máscara de digitação: aplicada progressivamente em (00) 00000-0000, sem impedir colagem de números em qualquer formato. A máscara é apresentação; o valor submetido é sempre o E.164 produzido pelo schema.

9.7.3 Handoff #

Ao submeter com sucesso, o formulário não faz requisição. Ele navega:

const params = new URLSearchParams({
  phone: e164,                 // "+5511987654321"
  source,                      // "hero" | "final_cta" | ...
  ...(planCode ? { plan: planCode } : {}),
});
router.push(`/cadastro?${params.toString()}`);

Regras do handoff:

  1. O telefone trafega na query string em E.164. Isso é dado pessoal em URL, então:
    • a rota /cadastro é noindex (9.11.2) e não é logada com query string no acesso (a Seção 23 define o redator de logs que remove phone de URLs);
    • imediatamente após a hidratação, /cadastro faz history.replaceState para remover phone da barra de endereços, mantendo o valor apenas no estado do formulário. Isso evita vazamento por Referer e por histórico compartilhado.
  2. source e plan permanecem na URL: não são dados pessoais e são úteis para atribuição.
  3. Se o visitante chegar em /cadastro sem phone, o campo aparece vazio. Nenhum erro.
  4. Se plan for um código inexistente ou inativo, /cadastro ignora o parâmetro e segue com o fluxo padrão (escolha depois do opt-in, Seção 11.7).

9.7.4 Proteção contra abuso #

O formulário de captura não chama a API, então não há superfície de abuso nele. A proteção real (rate limit por IP e por número, e verificação Turnstile) vive nos endpoints da Seção 11.12. O único controle aqui é o honeypot: um campo <input name="company" tabIndex={-1} autoComplete="off" aria-hidden="true"> escondido com position:absolute; left:-9999px. Se preenchido, o submit vira no-op silencioso e emite lead_form_honeypot_triggered.

9.8 Páginas secundárias #

9.8.1 /planos #

Estrutura em ordem: cabeçalho compartilhado → título e subtítulo (copy aprovado da Seção 10) → <PlanComparison> em versão expandida (todas as linhas da matriz, sem corte) → bloco de formas de pagamento (cartão de crédito e PIX, conforme Seção 12; boleto é declarado como não aceito) → FAQ de cobrança (subconjunto do copy aprovado da Seção 10, filtrado por category: 'billing') → bloco de cancelamento com link para /cancelamento → CTA final → rodapé.

Dados: getEntitlementMatrix() em build + plans ativos lidos em build. Como o preço é um dado de negócio raramente alterado, /planos é SSG puro e é revalidado por revalidação sob demanda: quando um administrador altera um plano (Seção 15.12.16), o handler chama revalidatePath('/planos') e revalidatePath('/').

Obrigatório por transparência (CDC): a página mostra, em texto legível e não em rodapé minúsculo, (a) o valor total anual quando o ciclo anual está selecionado, (b) que a renovação é automática, (c) que o cancelamento pode ser feito a qualquer momento pelo painel, (d) que não há multa nem fidelidade.

9.8.2 /termos e /privacidade #

  • Conteúdo em MDX estático versionado no repositório (apps/web/src/content/legal/termos-v{n}.mdx).
  • Cada documento tem, no topo: número da versão, data de vigência e um resumo de uma frase do que mudou em relação à versão anterior.
  • O rodapé de cada página lista as versões anteriores como links permanentes (/termos?v=1). Versões antigas são renderizadas com aviso "Esta versão não está mais em vigor".
  • A versão vigente dos termos é a mesma string gravada em consent_events.policy_version no cadastro (Seção 11.3.4). A constante CURRENT_TERMS_VERSION vive em packages/core e é lida pelos dois lados. Um teste garante que existe arquivo MDX para a versão corrente.
  • Sumário lateral (<TableOfContents>) gerado a partir dos h2/h3 do MDX, sticky em >= lg, colapsado em < lg.

9.8.3 /faq #

  • Todas as perguntas do copy aprovado da Seção 10, agrupadas por categoria: product, whatsapp, billing, privacy, technical.
  • Cada pergunta tem id estável derivado de slug (#como-cancelo-a-assinatura) para deep-link.
  • Fonte do JSON-LD FAQPage (9.11.4). Regra: apenas as perguntas da página /faq entram no JSON-LD; o FAQ resumido da home não emite JSON-LD próprio, para não duplicar.
  • Campo de busca client-side com filtragem por substring sem acento (normalize('NFD').replace(/\p{Diacritic}/gu,'')), sem chamada de rede.
  • Estado vazio da busca: "Nenhuma pergunta encontrada para termo." + link para /contato.

9.8.4 /contato #

Formulário com quatro campos e um Server Action:

Campo Tipo Obrigatório Regra
name texto Sim 2 a 80 caracteres.
email e-mail Sim Formato válido; usado só para responder.
subject select Sim duvida, cobranca, cancelamento, privacidade, outro.
message textarea Sim 10 a 2000 caracteres, contador visível a partir de 1800.

Comportamento: POST /api/public/contact (9.16.4) envia e-mail transacional pelo Resend para a caixa de suporte, com Reply-To do remetente. Nenhuma tabela nova: a mensagem não é persistida no banco; o e-mail é o registro. Se subject = 'privacidade', o e-mail é copiado para o endereço do encarregado (Seção 22).

Estados: idlesubmitting (botão desabilitado com spinner) → success (formulário some, mensagem de confirmação com prazo de resposta de 1 dia útil) ou error (mensagem inline, dados preservados, botão reabilitado).

Anti-abuso: Cloudflare Turnstile em modo invisível + rate limit de 3 envios por IP por hora (chave Redis rl:contact:{ip}). Estourou: 429 com code: RATE_LIMITED e mensagem "Muitas mensagens enviadas. Tente novamente em uma hora."

9.8.5 /cancelamento #

Página de transparência exigida por boa prática de consumo e por política de plataformas de pagamento. Estrutura fixa:

  1. Frase de abertura direta: cancelar é possível a qualquer momento, sem multa.
  2. Três caminhos, cada um em um cartão com passos numerados:
    • Pelo WhatsApp: enviar SAIR. Efeito imediato nos envios. Aviso explícito, em destaque: isso interrompe as mensagens na hora e suspende a próxima cobrança, mas não encerra a assinatura por si só — se não houver reativação em 30 dias, ela é cancelada ao fim do período já pago. Quem quiser encerrar de imediato cancela no painel. A distinção canônica está na Seção 20 e a regra de cobrança, na Seção 13.
    • Pelo painel: entrar em /app/assinatura e usar "Cancelar assinatura". Cancela a cobrança e mantém o acesso pago até o fim do período já pago.
    • Por e-mail: escrever para a caixa de suporte com o número cadastrado.
  3. Tabela "O que acontece depois", derivada das regras da Seção 13:
Ação Mensagens no WhatsApp Cobrança Acesso pago
Enviar SAIR Param imediatamente Próximo ciclo suspenso; sem reativação em 30 dias, a assinatura é cancelada ao fim do período pago Continua ativo até o fim do ciclo pago
Cancelar no painel Continuam até o fim do ciclo pago Não renova Até current_period_end, depois FREE
Ambos Param imediatamente Não renova Até current_period_end, depois FREE
  1. Bloco sobre dados: como pedir exportação e exclusão (/app/dados, Seção 14.8).
  2. CTA discreto de retenção: link para /app/preferencias explicando a pausa de 1 a 30 dias como alternativa ao cancelamento (Seção 14.7.2). Sem barreira, sem pop-up de interceptação.

9.9 Responsividade #

9.9.1 Breakpoints #

Os breakpoints são os padrões do Tailwind (Seção 4), sem customização, para reduzir carga cognitiva:

Nome Largura mínima Uso principal
(base) 0 Telefone em pé. Layout de coluna única.
sm 640 px Telefone grande / telefone deitado.
md 768 px Tablet. Comparativo vira tabela. Navegação sai do menu lateral.
lg 1024 px Notebook. Herói vira duas colunas. Sumário lateral aparece.
xl 1280 px Desktop. Largura máxima de conteúdo em 1200 px.
2xl 1536 px Monitor grande. Conteúdo permanece em 1200 px, margens crescem.

Mobile-first é obrigatório: as classes base descrevem o telefone e os modificadores adicionam complexidade para cima. É proibido escrever max-* como estratégia primária.

9.9.2 Regras por bloco #

Bloco < md md >= lg
B2 Cabeçalho Logotipo + botão de menu (Sheet lateral direita, largura 320 px) Navegação horizontal Navegação + dois CTAs
B3 Herói Coluna única, formulário acima do mockup, mockup com aspect-ratio: 9/16 limitado a 420 px de altura Coluna única com mockup menor Duas colunas 6/6, mockup à direita
B5 Como funciona 1 coluna, passos empilhados com conector vertical 3 colunas 3 colunas com conector horizontal
B6 Player Largura total, controles em duas linhas Uma linha Uma linha, com forma de onda
B7 Benefícios 1 coluna 2 colunas 3 colunas
B8 Planos Cartões empilhados, PAID primeiro Tabela comparativa Tabela comparativa com coluna de destaque
B9 Depoimentos Carrossel horizontal com scroll-snap, 1 por vez 2 por vez 3 por vez, sem carrossel
B13 Rodapé Acordeões por grupo de links 2 colunas 4 colunas

9.9.3 Alvos de toque e ergonomia #

  • Alvo mínimo 44 × 44 CSS px para qualquer elemento interativo (WCAG 2.2, 2.5.8 exige 24 px; adotamos 44 px como padrão do produto, mais rígido).
  • Espaçamento mínimo de 8 px entre alvos adjacentes.
  • CTAs primários em mobile ficam na metade inferior da tela quando aparecem em bloco de altura completa, por alcance do polegar.
  • Nenhum hover é requisito para descobrir função. Todo estado de hover tem equivalente em focus-visible e em toque.
  • Fonte base 16 px em mobile (impede zoom automático do iOS em campos de formulário).
  • <input type="tel" inputMode="numeric" autoComplete="tel-national"> no campo de telefone, para abrir o teclado numérico.
  • Suporte a zoom de até 200% sem rolagem horizontal (WCAG 1.4.4) e a text-spacing aumentado (WCAG 1.4.12): nenhum contêiner usa altura fixa em px para texto.

9.10 Acessibilidade #

9.10.1 Nível exigido #

WCAG 2.2 nível AA é obrigatório em todas as rotas públicas e em todo o painel do assinante (Seção 14). O painel administrativo (Seção 15) também mira AA, com exceção documentada e única: o calendário com arrastar-e-soltar oferece alternativa por teclado e por menu, mas o gesto de arrasto em si não é replicável por leitor de tela — a alternativa cobre o requisito 2.5.7 (Movimentos de arrasto).

Critérios novos da 2.2 explicitamente cobertos:

Critério Como é atendido
2.4.11 Foco não obscurecido (mínimo) O cabeçalho sticky reserva scroll-margin-top: 96px em todos os alvos de âncora e em elementos focáveis.
2.4.13 Aparência do foco Anel de foco de 2 px sólido com contraste ≥ 3:1 contra o fundo adjacente e contra o próprio componente, com 2 px de deslocamento.
2.5.7 Movimentos de arrasto Toda funcionalidade de arrasto tem alternativa por clique/teclado (calendário administrativo, barra do player).
2.5.8 Tamanho do alvo (mínimo) 44 px, acima dos 24 px exigidos.
3.2.6 Ajuda consistente Link para /contato e o e-mail de suporte aparecem na mesma posição do rodapé em todas as páginas.
3.3.7 Entrada redundante Telefone informado no herói é pré-preenchido em /cadastro. Telefone verificado não é pedido de novo no checkout (Seção 12).
3.3.8 Autenticação acessível (mínimo) Login por OTP no WhatsApp; o campo de código aceita colagem e autoComplete="one-time-code". Sem CAPTCHA visual obrigatório no fluxo de login; o Turnstile roda em modo invisível e, se exigir interação, oferece alternativa não visual.

9.10.2 Contraste #

  • Texto normal: ≥ 4,5:1. Texto grande (≥ 24 px, ou ≥ 18,66 px em negrito): ≥ 3:1.
  • Componentes de interface e gráficos essenciais: ≥ 3:1 (bordas de campo, ícones informativos, barra de progresso do player, marcação de "incluído" na tabela de planos).
  • Estados desabilitados estão isentos por norma, mas o produto adota ≥ 3:1 também neles, para não produzir botões ilegíveis.
  • A paleta é validada em CI: um teste (Seção 24) percorre os tokens de cor definidos na Seção 5 e falha se qualquer par documentado como "texto sobre fundo" cair abaixo do mínimo.
  • Nenhuma informação é transmitida por cor (WCAG 1.4.1): status de plano usa ícone + texto; erro de formulário usa ícone + texto + aria-invalid.

9.10.3 Foco visível e ordem #

  • :focus-visible estilizado globalmente; outline: none sem substituto é proibido por regra de lint (Seção 5).
  • Ordem de tabulação segue a ordem do DOM. Nenhum tabindex positivo em lugar nenhum.
  • Link "Pular para o conteúdo" é o primeiro elemento focável de toda página, visível ao receber foco, apontando para #conteudo-principal no <main>.
  • Modais (Dialog do Radix) prendem o foco, fecham com Esc e devolvem o foco ao elemento que os abriu.
  • O Sheet de navegação mobile faz o mesmo e marca o conteúdo de trás com inert.

9.10.4 Navegação por teclado #

Toda funcionalidade pública é operável só com teclado. Casos que exigem atenção:

Componente Teclas Observação
Menu mobile Esc fecha, Tab circula dentro aria-expanded no botão.
Acordeão FAQ Enter/Espaço alterna, Seta cima/baixo move entre cabeçalhos, Home/End Padrão Radix.
Alternador de ciclo Seta move e seleciona radiogroup.
Carrossel de depoimentos Tab entra em cada cartão; botões "anterior"/"próximo" focáveis Sem autoplay, então não há requisito 2.2.2.
Player Ver 9.6.2
Banner de cookies Recebe foco ao aparecer, Esc equivale a "Rejeitar não essenciais" Ver 9.14.

9.10.5 Leitores de tela e semântica #

  • Um <h1> por página. Hierarquia sem saltos.
  • Landmarks: <header>, <nav aria-label="Principal">, <main id="conteudo-principal">, <footer>, <nav aria-label="Rodapé">.
  • Todas as imagens decorativas com alt="" e aria-hidden="true". O mockup da conversa tem alt descritivo real (ex.: "Conversa de WhatsApp mostrando o devocional do dia com título, versículo e um áudio de três minutos").
  • Ícones que carregam significado têm texto acessível; ícones ao lado de texto são aria-hidden.
  • Erros de formulário: aria-invalid="true", aria-describedby apontando para o parágrafo do erro, e a lista de erros do submit anunciada em região role="alert".
  • lang="pt-BR" no <html>. Trechos em outro idioma (nomes de tecnologia não traduzidos) não recebem lang próprio por não afetarem pronúncia relevante.
  • Teste automatizado com axe-core roda em CI sobre todas as rotas públicas (Seção 24) e falha o build com qualquer violação de severidade serious ou critical.

9.10.6 Movimento e prefers-reduced-motion #

/* apps/web/src/styles/globals.css (trecho normativo) */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Regras de produto:

  • Nenhum carrossel avança sozinho. Nenhum conteúdo pisca. Nada se move por mais de 5 segundos sem controle do usuário (WCAG 2.2.2 atendido por ausência de gatilho).
  • Animação de forma de onda do player e efeito de paralaxe do herói são desligados sob reduce.
  • Transições de entrada por rolagem (fade-in-up) usam IntersectionObserver e são substituídas por aparição imediata sob reduce.
  • Nenhuma animação com mais de 3 flashes por segundo existe no produto (WCAG 2.3.1).

9.11 SEO técnico #

9.11.1 Metadados por rota #

Metadados são declarados com a API Metadata do App Router. Um helper central evita divergência:

// apps/web/src/lib/seo/build-metadata.ts
import type { Metadata } from 'next';

const SITE_NAME = 'Palavra Diária';
const BASE_URL = process.env.PUBLIC_SITE_URL!; // https://palavradiaria.com.br

export function buildMetadata(input: {
  title: string;            // sem sufixo; o helper adiciona
  description: string;      // 140 a 160 caracteres
  path: string;             // "/planos"
  ogImagePath?: string;     // default: `${path}/opengraph-image`
  noindex?: boolean;
}): Metadata {
  const url = new URL(input.path, BASE_URL).toString();
  return {
    metadataBase: new URL(BASE_URL),
    title: `${input.title} | ${SITE_NAME}`,
    description: input.description,
    alternates: { canonical: url, languages: { 'pt-BR': url } },
    robots: input.noindex
      ? { index: false, follow: false }
      : { index: true, follow: true,
          googleBot: { index: true, follow: true, 'max-image-preview': 'large',
                       'max-snippet': -1, 'max-video-preview': -1 } },
    openGraph: {
      type: 'website', siteName: SITE_NAME, locale: 'pt_BR',
      url, title: input.title, description: input.description,
      images: [{ url: input.ogImagePath ?? `${input.path}/opengraph-image`,
                 width: 1200, height: 630, alt: input.title }],
    },
    twitter: {
      card: 'summary_large_image',
      title: input.title, description: input.description,
      images: [input.ogImagePath ?? `${input.path}/opengraph-image`],
    },
  };
}

Tabela normativa de metadados. Os textos exatos de título e descrição são propriedade do copy aprovado da Seção 10; a tabela abaixo fixa a estrutura e os limites.

Rota title (≤ 60 chars antes do sufixo) description Indexa Canonical
/ Proposta de valor principal 140–160 chars, com "WhatsApp", "devocional" e "todos os dias" Sim https://palavradiaria.com.br/
/planos Foco em preço e comparação 140–160 chars, com valores Sim /planos
/faq Foco em dúvidas 140–160 chars Sim /faq
/termos "Termos de Uso" Resumo neutro Sim /termos (versões antigas: canonical aponta para a vigente)
/privacidade "Política de Privacidade" Resumo neutro Sim /privacidade
/contato "Fale com a gente" Resumo neutro Sim /contato
/cancelamento "Como cancelar" Resumo neutro Sim /cancelamento
/cadastro, /login, /app/*, /admin/* Não (noindex, nofollow)
/404, /500, /manutencao Não

9.11.2 robots.txt e sitemap.xml #

// apps/web/src/app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  const isProduction = process.env.APP_ENV === 'production';
  if (!isProduction) {
    return { rules: [{ userAgent: '*', disallow: '/' }] };
  }
  return {
    rules: [
      { userAgent: '*', allow: '/',
        disallow: ['/api/', '/app/', '/admin/', '/cadastro', '/login', '/manutencao'] },
    ],
    sitemap: `${process.env.PUBLIC_SITE_URL}/sitemap.xml`,
    host: process.env.PUBLIC_SITE_URL,
  };
}

Regra dura: em staging, robots.txt bloqueia tudo. Um teste E2E (Seção 24) verifica isso antes de qualquer deploy de staging.

// apps/web/src/app/sitemap.ts
import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  const base = process.env.PUBLIC_SITE_URL!;
  const now = new Date();
  return [
    { url: `${base}/`,             lastModified: now, changeFrequency: 'daily',   priority: 1.0 },
    { url: `${base}/planos`,       lastModified: now, changeFrequency: 'monthly', priority: 0.9 },
    { url: `${base}/faq`,          lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
    { url: `${base}/cancelamento`, lastModified: now, changeFrequency: 'yearly',  priority: 0.4 },
    { url: `${base}/contato`,      lastModified: now, changeFrequency: 'yearly',  priority: 0.4 },
    { url: `${base}/termos`,       lastModified: now, changeFrequency: 'yearly',  priority: 0.3 },
    { url: `${base}/privacidade`,  lastModified: now, changeFrequency: 'yearly',  priority: 0.3 },
  ];
}

O sitemap é estático por decisão D9.3: não há URLs públicas por devocional, logo o sitemap tem sete entradas e nunca cresce.

9.11.3 Canonical e hreflang #

  • Toda página emite <link rel="canonical"> absoluto para si mesma, sem query string.
  • hreflang declarado como pt-BR apontando para a própria URL, mais x-default para a mesma URL. Não há outra localidade e não haverá no MVP (multi-idioma está fora de escopo, Seção 2). Declarar mesmo assim evita ambiguidade para o Google em um domínio .com.br.
  • Parâmetros de atribuição (?source=, ?utm_*) nunca criam URL canônica nova.
  • www → apex por 308 no Caddy (Seção 25), antes do Next.js.

9.11.4 Dados estruturados (JSON-LD) #

Três blocos, injetados como <script type="application/ld+json"> renderizados no servidor. Nunca gerados no cliente.

// apps/web/src/lib/seo/json-ld.ts
export function organizationJsonLd() {
  return {
    '@context': 'https://schema.org',
    '@type': 'Organization',
    name: 'Palavra Diária',
    url: 'https://palavradiaria.com.br',
    logo: 'https://palavradiaria.com.br/logo-512.png',
    sameAs: [] as string[],
    contactPoint: [{
      '@type': 'ContactPoint',
      contactType: 'customer support',
      email: 'suporte@palavradiaria.com.br',
      availableLanguage: ['Portuguese'],
      areaServed: 'BR',
    }],
  };
}

export function productJsonLd(plans: readonly PublicPlan[]) {
  return {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: 'Palavra Diária — Plano Completo',
    description: 'Devocional cristão em texto e áudio, todos os dias, no WhatsApp.',
    brand: { '@type': 'Brand', name: 'Palavra Diária' },
    offers: plans
      .filter((p) => p.tier === 'PAID')
      .map((p) => ({
        '@type': 'Offer',
        price: (p.priceCents / 100).toFixed(2),
        priceCurrency: 'BRL',
        availability: 'https://schema.org/InStock',
        url: `https://palavradiaria.com.br/cadastro?plan=${p.code}`,
        priceValidUntil: '2027-12-31',
      })),
  };
}

export function faqPageJsonLd(items: readonly { question: string; answer: string }[]) {
  return {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map((i) => ({
      '@type': 'Question',
      name: i.question,
      acceptedAnswer: { '@type': 'Answer', text: i.answer },
    })),
  };
}

Onde cada um é emitido:

JSON-LD Rotas
Organization / apenas
Product + Offer / e /planos
FAQPage /faq apenas
BreadcrumbList Não emitido: a hierarquia é rasa demais para agregar valor

Regra dura contra penalidade: não emitir AggregateRating nem Review enquanto não houver avaliações reais coletadas e verificáveis. Depoimentos de marketing na página não autorizam marcação de review.

9.11.5 Outros sinais técnicos #

  • <html lang="pt-BR">; <meta name="theme-color"> com valor claro e escuro.
  • URLs em português, minúsculas, com hífen, sem acento e sem barra final.
  • Imagem OG por rota gerada em build com ImageResponse (fonte local, sem rede).
  • next-sitemap não é usado; a API nativa basta.
  • Nenhum conteúdo importante depende de JavaScript: todos os blocos textuais da home são Server Components e aparecem no HTML inicial.

9.12 Performance #

9.12.1 Orçamento #

Medido no P75 de campo (CrUX/RUM próprio) e verificado em CI com Lighthouse em perfil móvel emulado (Moto G Power, 4G lento, 4× CPU throttling).

Métrica Alvo Falha o CI acima de
LCP ≤ 2,0 s 2,5 s
CLS ≤ 0,05 0,10
INP ≤ 150 ms 200 ms
TTFB (edge/HTML cacheado) ≤ 300 ms 600 ms
FCP ≤ 1,4 s 1,8 s
Peso total da home (comprimido, primeira visita) ≤ 350 KB 500 KB
JavaScript da home (comprimido) ≤ 120 KB 170 KB
Requisições na primeira visita ≤ 25 35
Lighthouse Performance (mobile) ≥ 92 < 85
Lighthouse Accessibility 100 < 100
Lighthouse SEO ≥ 95 < 90
Lighthouse Best Practices ≥ 95 < 90

O alvo de LCP < 2,0 s em 4G é a meta canônica de desempenho da superfície pública, e esta subseção é a dona dela. Nenhuma outra seção redefine esse número; a capacidade do motor de envio é assunto separado, na Seção 18.14. O alvo não pode ser relaxado.

9.12.2 Estratégia de renderização #

Rota Estratégia Revalidação
/ ISR export const revalidate = 300 + revalidação sob demanda quando um devocional é publicado (Seção 15.4)
/planos SSG Sob demanda ao salvar plano (Seção 15.12.16)
/faq, /termos, /privacidade, /cancelamento SSG Somente por deploy
/contato SSG + Server Action Somente por deploy
/api/public/* Dinâmico Cache-Control específico por rota (9.16)

O bloco B6 é o único da home que depende de dado que muda diariamente. Ele é renderizado no servidor durante a revalidação, então o visitante nunca espera por I/O. Se a revalidação falhar, o Next.js serve a versão anterior (stale-while-revalidate) e um alerta é emitido (Seção 23).

9.12.3 Imagens #

  • next/image para tudo, com formats: ['image/avif', 'image/webp'].
  • Todas as imagens são locais, versionadas no repositório ou servidas pelo bucket próprio. Nenhum hotlink de terceiro no caminho crítico.
  • width e height sempre declarados. sizes obrigatório em imagens responsivas.
  • Apenas o mockup do herói tem priority e fetchPriority="high". Todo o resto é loading="lazy" com decoding="async".
  • Fotos de depoimento: 96 × 96, servidas em AVIF, com placeholder="blur" e blurDataURL gerado em build.
  • Ilustrações e ícones: SVG inline, otimizado com SVGO em build, sem sprite externo.
  • Limite: nenhuma imagem individual acima de 120 KB após otimização. Um script de CI falha o build se o diretório de imagens públicas ultrapassar 1,5 MB no total.

9.12.4 Fontes #

  • Uma única família variável, auto-hospedada via next/font/local, subconjunto latin + latin-ext (necessário para acentuação portuguesa).
  • display: 'swap', preload: true para o peso usado no herói, preload: false para os demais.
  • adjustFontFallback ligado, para eliminar deslocamento no swap.
  • Zero requisição a fonts.googleapis.com ou fonts.gstatic.com. Isso também evita transferência internacional de IP sem base legal (Seção 22).
  • Nenhuma fonte de ícone. Ícones são SVG.

9.12.5 JavaScript #

  • Server Components por padrão. 'use client' apenas nos blocos B2, B3a, B6, B9 e no banner de consentimento.
  • next/dynamic com ssr: false para o carrossel de depoimentos e para o modal de preferências de cookies (só carregam sob interação/visibilidade).
  • Nenhuma biblioteca de animação pesada. Transições em CSS puro.
  • TanStack Query não é usado na landing: não há dado de usuário para sincronizar. Ele entra só nos painéis (Seções 14 e 15).
  • Recharts nunca entra no bundle público.
  • Script de terceiro (se houver consentimento) carrega com next/script e strategy="lazyOnload", nunca beforeInteractive.
  • Orçamento verificado por @next/bundle-analyzer em CI, com limite por rota configurado.

9.12.6 Cabeçalhos e cache #

Definidos no Caddy (Seção 25) e complementados em next.config.ts:

HTML de rota SSG/ISR: Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Assets com hash:      Cache-Control: public, max-age=31536000, immutable
/api/public/plans:    Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
/api/public/devotional-preview: Cache-Control: public, max-age=0, s-maxage=120, stale-while-revalidate=600
/api/public/events:   Cache-Control: no-store

Segurança de cabeçalhos (CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) é canônica na Seção 22 e aplicada globalmente; a landing não sobrescreve nada. Três exigências que a superfície pública impõe à política da Seção 22.3.1 e que precisam estar satisfeitas para as telas desta seção funcionarem:

  1. media-src precisa incluir o domínio de mídia, senão o elemento <audio> do player de amostra (9.6) não toca.
  2. connect-src precisa incluir o mesmo domínio de mídia, além do próprio domínio. media-src governa o elemento <audio>; a requisição que o player faz antes de criar o blob é governada por connect-src. Sem as duas, o áudio falha silenciosamente.
  3. As rotas com formulário — /cadastro, /contato e a tela de pedido de código — usam a variante de política para páginas com formulário, que libera https://challenges.cloudflare.com em script-src, em frame-src e em connect-src. Sem frame-src explícito, o default-src 'none' bloqueia o widget anti-bot e a taxa de cadastro cai a zero. O mesmo host precisa constar da lista de destinos permitidos do cliente HTTP com proteção contra SSRF (Seção 22.2.6), porque a verificação do token é feita pelo servidor.

9.13 Analytics e eventos de conversão #

9.13.1 Arquitetura #

Decisão D9.4 detalhada: o cliente chama um único helper, que faz navigator.sendBeacon para POST /api/public/events. O endpoint valida com Zod, descarta o que não estiver no catálogo e registra um log estruturado (event: 'analytics') consumido pela stack de observabilidade da Seção 23. Esses eventos agregados são consolidados em daily_metrics pelo job de rollup das 03:10, sobre o dia D−1 já fechado (Seção 21.5); a rota pública nunca escreve nessa tabela. Nenhuma tabela nova é criada.

// apps/web/src/lib/analytics/track.ts
import { ANALYTICS_EVENTS, type AnalyticsEventName, type AnalyticsPayload } from './catalog';

export function track<E extends AnalyticsEventName>(name: E, props: AnalyticsPayload<E>): void {
  if (typeof window === 'undefined') return;
  const body = JSON.stringify({
    name,
    props,
    path: window.location.pathname,
    ts: new Date().toISOString(),
    // anonymousId: ULID em sessionStorage, expira ao fechar a aba. NUNCA em cookie.
    anonymousId: getOrCreateAnonymousId(),
  });
  const url = '/api/public/events';
  if (navigator.sendBeacon) navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
  else void fetch(url, { method: 'POST', body, keepalive: true, headers: { 'content-type': 'application/json' } });
}

Propriedades comuns injetadas pelo servidor, nunca pelo cliente: country (do cabeçalho do proxy), deviceType (derivado de User-Agent, apenas mobile|tablet|desktop) e referrerHost (apenas o host, nunca a URL completa). O IP não é armazenado; ele é usado só para rate limit em memória do Redis, com chave hasheada e TTL de 1 hora.

9.13.2 Catálogo completo de eventos #

Nomes em snake_case, em inglês (identificador técnico). Nenhum evento fora desta lista é aceito pelo endpoint; envio desconhecido retorna 422 com code: UNKNOWN_EVENT.

Evento Quando dispara Propriedades
page_view Ao montar cada rota pública e a cada navegação client-side path, referrerHost, deviceType
scroll_depth Uma vez por marco de 25/50/75/100% de rolagem da página path, depth
hero_cta_clicked Clique no CTA primário do herói label, destination
nav_cta_clicked Clique no CTA do cabeçalho label, destination, sticky (bool)
lead_form_focused Primeiro foco no campo de telefone de qualquer instância source, variant
lead_form_validation_failed Submit bloqueado por validação source, reason (empty, invalid_format, foreign_ddi, too_short)
lead_form_submitted Submit válido, antes do router.push source, planCode | null
lead_form_honeypot_triggered Honeypot preenchido source
plan_cycle_toggled Troca no alternador mensal/anual from, to, page
plan_cta_clicked Clique no CTA de um cartão/coluna de plano planCode, cycle, page, position
plan_comparison_viewed Bloco B8 atinge 50% de visibilidade por ≥ 1 s page
audio_sample_play Primeiro play() do player devotionalId
audio_sample_progress Marcos de 25/50/75/100% da amostra devotionalId, milestone
audio_sample_error Qualquer erro do player devotionalId, reason
faq_item_opened Abertura de item do acordeão questionId, page
faq_search_used Busca no /faq com ≥ 3 caracteres, com debounce de 500 ms resultCount
testimonial_advanced Avanço manual no carrossel index, method (button | swipe | keyboard)
pricing_page_viewed Montagem de /planos referrerHost
cancellation_page_viewed Montagem de /cancelamento referrerHost
contact_form_submitted Envio bem-sucedido em /contato subject
cookie_consent_shown Banner exibido
cookie_consent_decided Decisão registrada decision (all, essential_only, custom), categories
outbound_link_clicked Clique em link externo (rodapé, selo de pagamento) host
error_page_viewed Montagem de 404 ou 500 status, path

Eventos de conversão profunda (signup_started, otp_verified, optin_confirmed, checkout_started, subscription_activated) não pertencem a esta seção: são disparados pelo servidor nos fluxos das Seções 11 e 12, com o assinante já identificado. A landing só produz eventos anônimos.

9.13.3 Funil de conversão declarado #

O funil oficial, usado no dashboard da Seção 21, é:

page_view (/)                     100%
  → lead_form_focused
    → lead_form_submitted
      → signup_started        (Seção 11)
        → otp_verified        (Seção 11)
          → optin_confirmed   (Seção 11)  ← conversão FREE
            → checkout_started    (Seção 12)
              → subscription_activated (Seção 12) ← conversão PAID

A junção entre o mundo anônimo e o identificado acontece uma única vez: no signup_started, o servidor recebe o anonymousId do sessionStorage e o registra no log estruturado junto ao subscriberId. O anonymousId não é persistido em nenhuma tabela — a correlação vive apenas nos logs, com a retenção da Seção 23.

9.13.4 Amostragem e volume #

  • scroll_depth e audio_sample_progress são amostrados a 25% em produção (ANALYTICS_SAMPLE_RATE=0.25), decidido no cliente com Math.random(). Todos os demais eventos são enviados integralmente.
  • Rate limit de 60 eventos por anonymousId por minuto. Excedente é descartado com 429 e não é reenviado.
  • Falha de rede em analytics nunca bloqueia navegação: sendBeacon é fire-and-forget e o fetch de fallback usa keepalive sem await.

9.14 Consentimento de cookies e LGPD na superfície pública #

9.14.1 Inventário de cookies e armazenamento #

Nome Tipo Categoria Finalidade Duração Precisa de consentimento
__Host-session Cookie Essencial Sessão autenticada (Seção 8). Path=/, Secure, sem Domain 30 dias (assinante) / 12 h (admin) Não
__Host-refresh Cookie Essencial Renovação da sessão (Seção 8). Path=/, Secure, sem Domain — o prefixo __Host- exige Path=/, e qualquer outro caminho faz o navegador descartar o cookie 30 dias (assinante) / 12 h (admin) Não
__Host-csrf Cookie Essencial Token anti-CSRF, nome único em todo o documento; enviado de volta no header X-CSRF-Token Sessão Não
pd_consent Cookie Essencial Guarda a própria decisão de consentimento 180 dias Não
pd_anonymous_id sessionStorage Essencial/analítico anônimo Deduplicação de eventos na sessão Até fechar a aba Não (não é cookie, não persiste, não identifica)
pd_announcement_dismissed localStorage Essencial Não repetir barra de anúncio 30 dias Não
_ga, _ga_* Cookie Analítico de terceiro Google Analytics 4, se habilitado 13 meses Sim
_fbp, _fbc Cookie Publicidade Meta Pixel, se habilitado 90 dias Sim

Decisão explícita: GA4 e Meta Pixel são opcionais e vêm desligados por padrão (NEXT_PUBLIC_GA_ID e NEXT_PUBLIC_META_PIXEL_ID vazios, Seção 26). O produto funciona inteiro com analytics first-party. Se o operador ligar um deles, o banner passa a exibir a categoria correspondente.

9.14.2 Banner e modal #

  • O banner aparece na primeira visita, ancorado no rodapé, sem escurecer a página e sem bloquear a leitura. Não é um muro.
  • Três botões, com peso visual igual entre aceitar e rejeitar (exigência de consentimento livre): Aceitar todos, Rejeitar não essenciais, Personalizar.
  • Personalizar abre um Dialog com uma chave por categoria: Essenciais (fixa em ligado e desabilitada), Análise, Publicidade. Cada categoria explica em uma frase o que faz e quais cookies usa.
  • Esc no banner equivale a Rejeitar não essenciais. Fechar sem escolher não é tratado como aceite.
  • A decisão é gravada em pd_consent com o formato:
{
  "version": "2026-08-25",
  "decidedAt": "2026-08-25T12:31:02.000Z",
  "categories": { "essential": true, "analytics": false, "advertising": false }
}
  • Mudança na versão do aviso de cookies invalida o consentimento anterior e reexibe o banner.
  • O link "Preferências de cookies" no rodapé reabre o modal em qualquer momento, com os valores atuais.
  • Revogar é tão fácil quanto consentir: um clique em Rejeitar não essenciais dentro do modal apaga os cookies de terceiro via document.cookie com expires no passado e recarrega a página.

9.14.3 Comportamento sem consentimento #

Este é o estado padrão e precisa funcionar perfeitamente:

  1. Nenhum script de terceiro é injetado. next/script só monta depois de ler pd_consent.categories.analytics === true.
  2. O analytics first-party continua funcionando, porque:
    • não usa cookie;
    • o identificador vive em sessionStorage e morre ao fechar a aba;
    • o IP não é armazenado;
    • os dados agregados não permitem reidentificar ninguém. Base legal declarada: legítimo interesse (LGPD, Art. 7º, IX) para medição agregada de audiência própria, com minimização de dados. A Seção 22 registra a avaliação.
  3. Todas as funcionalidades da landing permanecem disponíveis. Nenhum bloco é escondido, nenhum CTA é desabilitado. Recusar consentimento não degrada o serviço.
  4. O banner não reaparece na mesma sessão depois de uma decisão; reaparece após 180 dias ou se a versão mudar.

9.14.4 Outras exigências LGPD na superfície pública #

  • Link para /privacidade no rodapé de todas as páginas e ao lado de todo formulário.
  • Nome e e-mail do encarregado de dados visíveis em /privacidade e no rodapé.
  • O formulário de contato informa, acima do botão, a finalidade do tratamento e o prazo de retenção do e-mail.
  • Nenhuma transferência internacional de dados no caminho público quando GA4/Pixel estão desligados: fontes locais, imagens locais, API própria.

9.15 Páginas de erro e manutenção #

9.15.1 404 #

app/not-found.tsx, estático, noindex. Conteúdo:

  • <h1> "Página não encontrada".
  • Frase curta explicando que o endereço não existe ou mudou.
  • Quatro links úteis: início, /planos, /faq, /contato.
  • Campo de busca do FAQ embutido (mesmo componente de 9.8.3), porque a origem mais comum de 404 é link antigo de conteúdo.
  • Emite error_page_viewed com status: 404 e o path tentado.
  • Responde com HTTP 404 de verdade, não 200 com corpo de erro.

9.15.2 500 #

Dois arquivos:

  • app/error.tsx: erro de renderização dentro do layout. Mantém cabeçalho e rodapé, mostra <h1> "Algo deu errado", botão Tentar de novo (chama reset()), link para /contato e o requestId em texto pequeno e selecionável.
  • app/global-error.tsx: falha no próprio layout raiz. HTML mínimo, CSS inline, sem dependência de fonte ou de componente. Exibe a mesma mensagem e o requestId.

Regras:

  • O requestId mostrado é o mesmo ULID do envelope de erro da Seção 7 e do cabeçalho X-Request-Id, permitindo que o suporte localize o log exato (Seção 23).
  • Nunca exibir stack trace, nome de arquivo, versão de dependência ou mensagem crua de exceção.
  • Emite error_page_viewed com status: 500.
  • Responde com HTTP 500.

9.15.3 Página de manutenção #

  • Ativada pela variável MAINTENANCE_MODE=true (Seção 26), lida pelo middleware.
  • O middleware responde 503 com Retry-After: 900 para todas as rotas, exceto: /manutencao, /api/internal/health, /api/webhooks/* (webhooks de Asaas e Meta nunca entram em manutenção; eles continuam sendo aceitos e enfileirados, conforme Seções 12 e
    1. e os assets estáticos.
  • Conteúdo: título, previsão de retorno lida de MAINTENANCE_UNTIL (ISO 8601, exibida em horário de Brasília) e o e-mail de suporte.
  • A página é 100% estática e não depende de banco nem de Redis, para funcionar mesmo com a infraestrutura parcialmente indisponível.
  • Um administrador com sessão válida e papel OWNER pode passar pela manutenção enviando o cabeçalho X-Maintenance-Bypass com o valor de MAINTENANCE_BYPASS_TOKEN, para validar o deploy antes de reabrir.

9.16 Endpoints públicos da landing #

Todos seguem o envelope de sucesso e de erro definido na Seção 7. Todos ecoam X-Request-Id. Nenhum exige autenticação.

9.16.1 GET /api/public/devotional-preview #

  • Método/caminho: GET /api/public/devotional-preview
  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Request: sem corpo. Sem parâmetros de query (não é permitido pedir outra data — decisão D9.3).
  • Efeitos colaterais: nenhum. Operação de leitura pura, portanto naturalmente idempotente e segura para repetição.
  • Cache: public, max-age=0, s-maxage=120, stale-while-revalidate=600.

Regra de seleção: o devocional cujo scheduled_for é o dia corrente em America/Sao_Paulo e cujo status é PUBLISHED ou SENT. Se não houver, cai para o mais recente SENT dos últimos 7 dias. Se ainda assim não houver, responde 404.

Resposta 200:

{
  "data": {
    "id": "dev_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
    "date": "2026-08-25",
    "title": "O Senhor é o meu pastor",
    "bibleReference": "Salmos 23:1-3",
    "bibleVersion": "Almeida 1911",
    "teaser": "Quando o pastor guia, a ovelha não precisa saber o caminho inteiro. Ela precisa saber de quem seguir.",
    "sampleAudioUrl": "https://media.palavradiaria.com.br/devotionals/2026/08/dev_01HZX.../sample.mp3?X-Amz-Expires=900&X-Amz-Signature=...",
    "sampleAudioDurationSec": 58,
    "sampleAudioMimeType": "audio/mpeg"
  },
  "meta": { "requestId": "req_01HZXA2M4P8K6D0F2G4H6J8L0N", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros:

HTTP code Quando Mensagem
404 DEVOTIONAL_NOT_FOUND Nenhum devocional publicado hoje nem nos 7 dias anteriores "Nenhum devocional publicado no momento."
429 RATE_LIMITED Mais de 120 requisições por IP por minuto "Muitas requisições. Tente novamente em instantes."
500 INTERNAL_ERROR Falha ao assinar a URL ou ao consultar o banco "Não foi possível carregar o devocional."

Observações de segurança: o campo reflection_md, o bible_text completo e o prayer não são retornados. O público recebe apenas o teaser. Isso é intencional e é o que preserva o valor da assinatura.

9.16.2 GET /api/public/plans #

  • Método/caminho: GET /api/public/plans
  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Request: sem corpo, sem parâmetros.
  • Efeitos colaterais: nenhum. Idempotente.
  • Cache: public, max-age=60, s-maxage=300, stale-while-revalidate=600.

O item plan_free é sintetizado no handler e não corresponde a nenhuma linha da tabela plans. Ser gratuito é a ausência de assinatura vigente (Seção 13.1). O handler lê plans para os planos pagos ativos, ordenados por sort_order, e prepende o item gratuito com valores fixos { code: 'plan_free', tier: 'FREE', name: 'Gratuito', cycle: null, priceCents: 0 }. Nenhuma escrita do sistema aceita esse código.

Resposta 200 (valores conforme os padrões de preço da Seção 1):

{
  "data": {
    "plans": [
      { "code": "plan_free", "tier": "FREE", "name": "Gratuito", "cycle": null,
        "priceCents": 0, "currency": "BRL", "priceLabel": "R$ 0",
        "monthlyEquivalentLabel": null, "savingsLabel": null, "isRecommended": false },
      { "code": "plan_monthly", "tier": "PAID", "name": "Mensal", "cycle": "MONTHLY",
        "priceCents": 1990, "currency": "BRL", "priceLabel": "R$ 19,90/mês",
        "monthlyEquivalentLabel": null, "savingsLabel": null, "isRecommended": false },
      { "code": "plan_annual", "tier": "PAID", "name": "Anual", "cycle": "YEARLY",
        "priceCents": 19900, "currency": "BRL", "priceLabel": "R$ 199,00/ano",
        "monthlyEquivalentLabel": "R$ 16,58/mês", "savingsLabel": "economize 16%",
        "isRecommended": true }
    ],
    "matrix": {
      "version": "2026-08-25",
      "rows": [
        { "key": "send_frequency", "label": "Frequência do devocional",
          "free": { "kind": "text", "value": "1x por semana, aos domingos" },
          "paid": { "kind": "text", "value": "Todos os dias" } },
        { "key": "audio", "label": "Áudio narrado",
          "free": { "kind": "boolean", "value": false },
          "paid": { "kind": "boolean", "value": true } }
      ]
    }
  },
  "meta": {
    "requestId": "req_01HZXA3N5Q9L7E1G3H5J7K9M1P",
    "timestamp": "2026-08-25T09:00:00.000Z",
    "stats": { "publishedDevotionals": 412, "activeSubscribers": 2300 }
  }
}

meta.stats.activeSubscribers é arredondado para baixo à centena e só aparece quando o valor real é ≥ 500 (regra do bloco B4). Abaixo disso, a chave é omitida.

Erros:

HTTP code Quando Mensagem
429 RATE_LIMITED > 120 req/IP/min "Muitas requisições. Tente novamente em instantes."
500 INTERNAL_ERROR Falha de banco "Não foi possível carregar os planos."

Não existe 404: mesmo que a tabela plans esteja vazia, a resposta sempre traz o item plan_free, que é montado pelo handler e não depende do banco.

9.16.3 POST /api/public/events #

  • Método/caminho: POST /api/public/events
  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Cache: no-store.
  • Efeitos colaterais: escreve apenas log estruturado. Não escreve dado pessoal e não escreve em daily_metrics — a consolidação é do job de rollup das 03:10 sobre o dia D−1 fechado (Seção 21.5).
  • Idempotência: não idempotente por natureza (é um contador). Reenvio duplicado é aceitável e absorvido pela amostragem. O cliente nunca faz retry automático.

Schema de request:

import { z } from 'zod';

export const analyticsEventSchema = z.object({
  name: z.enum(ANALYTICS_EVENT_NAMES),                  // catálogo de 9.13.2
  props: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
          .refine((p) => Object.keys(p).length <= 12, 'Muitas propriedades.')
          .default({}),
  path: z.string().max(200).startsWith('/'),
  ts: z.iso.datetime(),
  anonymousId: z.string().length(26).regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
});

export const analyticsBatchSchema = z.object({
  events: z.array(analyticsEventSchema).min(1).max(20),
}).or(analyticsEventSchema.transform((e) => ({ events: [e] })));

Regras de saneamento no servidor, obrigatórias:

  1. Qualquer chave de props que case com /phone|email|cpf|token|password|name/i é descartada antes de logar.
  2. path tem sua query string removida.
  3. ts fora da janela de ±10 minutos em relação ao relógio do servidor é substituído pelo horário do servidor.
  4. O IP nunca é gravado; é usado apenas para a chave de rate limit rl:events:{sha256(ip)} com TTL de 60 s.

Resposta 200 (mínima, para não desperdiçar banda):

{ "data": { "accepted": 3, "rejected": 0 },
  "meta": { "requestId": "req_01HZXA4P6R0M8F2H4J6K8L0N2Q", "timestamp": "2026-08-25T09:00:00.000Z" } }

Erros:

HTTP code Quando Mensagem
400 INVALID_JSON Corpo não é JSON válido "Requisição inválida."
422 VALIDATION_ERROR Falha de schema; details lista campo e problema "Dados inválidos."
422 UNKNOWN_EVENT name fora do catálogo "Evento desconhecido."
429 RATE_LIMITED > 60 eventos por anonymousId por minuto "Limite de eventos atingido."
413 PAYLOAD_TOO_LARGE Corpo acima de 8 KB "Requisição grande demais."

9.16.4 POST /api/public/contact #

  • Método/caminho: POST /api/public/contact
  • Autenticação: nenhuma. Papel exigido: nenhum. Exige token Turnstile válido.
  • Efeitos colaterais: envia e-mail transacional pelo Resend. Não persiste no banco.
  • Idempotência: protegida por chave Idempotency-Key opcional enviada pelo cliente (ULID gerado no primeiro submit). Se a mesma chave chegar em até 10 minutos, o servidor responde 200 com o mesmo corpo e não reenvia o e-mail. Chave em Redis (idem:contact:{key}), TTL 600 s.

Schema:

export const contactSchema = z.object({
  name: z.string().trim().min(2, 'Informe seu nome.').max(80, 'Nome muito longo.'),
  email: z.email('Informe um e-mail válido.').max(160),
  subject: z.enum(['duvida', 'cobranca', 'cancelamento', 'privacidade', 'outro']),
  message: z.string().trim().min(10, 'Escreva pelo menos 10 caracteres.')
            .max(2000, 'Máximo de 2000 caracteres.'),
  turnstileToken: z.string().min(1),
  company: z.string().max(0).optional(),   // honeypot: precisa estar vazio
});

Resposta 200:

{ "data": { "sent": true, "expectedReplyBusinessDays": 1 },
  "meta": { "requestId": "req_01HZXA5Q7S1N9G3J5K7L9M1P3R", "timestamp": "2026-08-25T09:00:00.000Z" } }

Erros:

HTTP code Quando Mensagem
422 VALIDATION_ERROR Falha de schema "Dados inválidos."
422 CAPTCHA_FAILED Turnstile inválido ou expirado "Não conseguimos confirmar que você não é um robô. Recarregue a página."
422 HONEYPOT_TRIGGERED Campo company preenchido "Dados inválidos." (resposta genérica, deliberadamente)
429 RATE_LIMITED > 3 envios por IP por hora "Muitas mensagens enviadas. Tente novamente em uma hora."
502 EMAIL_PROVIDER_ERROR Resend indisponível após 2 tentativas "Não foi possível enviar sua mensagem agora. Escreva para suporte@palavradiaria.com.br."

O 502 inclui o endereço de suporte na própria mensagem, para que o usuário nunca fique sem caminho.

9.16.5 GET /api/public/config #

  • Método/caminho: GET /api/public/config
  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Request: sem corpo, sem parâmetros.
  • Efeitos colaterais: nenhum. Leitura pura, idempotente.
  • Cache: public, max-age=300, s-maxage=900.

É a rota que a landing e o painel consultam para não embutir no build o número de WhatsApp do negócio, o e-mail de suporte, o e-mail do encarregado e a versão vigente das políticas. Todos os campos vêm de settings e de feature_flags (Seções 6 e 26). Nenhum segredo é devolvido: chaves com is_secret = true nunca entram nesta resposta, e um teste de contrato falha o build se alguma entrar.

Resposta 200:

{
  "data": {
    "whatsappNumberDisplay": "(11) 99999-9999",
    "supportEmail": "suporte@palavradiaria.com.br",
    "dpoEmail": "privacidade@palavradiaria.com.br",
    "policyVersion": "2026-08-01",
    "termsVersion": "2026-08-01",
    "pixEnabled": true,
    "cardEnabled": true,
    "maintenanceMode": false
  },
  "meta": { "requestId": "req_01HZXA6R8T2P0H4K6L8M0N2P4S", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros:

HTTP code Quando Mensagem
500 INTERNAL_ERROR Falha ao ler settings ou feature_flags "Não foi possível carregar a configuração."

10. Copy Aprovado — Página de Vendas e Mensagens ao Assinante #

Este capítulo entrega o copy final, aprovado para publicação, de todos os textos voltados ao assinante e ao visitante: página de vendas, microcopy de interface, e-mails transacionais e divulgação. Nada aqui é rascunho. Onde há mais de uma opção (headlines, por exemplo), a opção recomendada está marcada e justificada. Toda regra de produto citada no texto é consistente com a Seção 13 (entitlements e ciclo de vida da assinatura) e a Seção 18 (motor de envio diário): horário fixo às 6h, sem carência em inadimplência, sem período de teste do plano pago, plano gratuito permanente, sem aplicativo, sem comunidade, sem pedido de oração.

10.1 Princípios de voz e tom #

O que dizer:

  • Falar como alguém que também luta para manter o hábito devocional — não como uma autoridade espiritual.
  • Ser concreto sobre o que o produto faz. Frases curtas, verbos no presente.
  • Nomear o benefício prático (constância, simplicidade) antes de qualquer benefício emocional.
  • Ser honesto sobre limitações do produto sempre que perguntado ou sempre que a omissão criar expectativa falsa (voz sintética, horário fixo, ausência de app).

O que nunca dizer:

  • Nunca prometer transformação espiritual, milagre, resposta de oração ou resultado na vida do assinante. O produto entrega um devocional; o que a pessoa faz com ele é dela.
  • Nunca usar culpa ("você está falhando com Deus", "por que você ainda não fez isso?").
  • Nunca fingir que a voz do áudio é humana quando é gerada por computador.
  • Nunca prometer horário flexível, aplicativo, comunidade ou qualquer recurso fora do escopo declarado na Seção 2.
  • Nunca usar urgência artificial ("só hoje", "últimas vagas") — não há campanha promocional no MVP (Seção 2).

Três frases boas:

  1. "Todos os dias, às 6h, uma palavra chega no seu WhatsApp antes do resto do mundo acordar." — concreto, sem promessa vaga, ancorado no mecanismo real (horário fixo).
  2. "Você não precisa lembrar. A gente lembra por você." — nomeia o problema real (esquecimento) sem culpar o leitor.
  3. "O áudio é narrado por uma voz gerada por inteligência artificial, treinada para soar natural em português." — honesto sobre a tecnologia, sem esconder nem se desculpar.

Três frases ruins e o motivo:

  1. "Sua vida vai mudar em 7 dias." — promessa de resultado que o produto não controla. Viola a regra de não prometer transformação.
  2. "Se você realmente ama a Deus, não vai deixar de ler a Palavra hoje." — usa culpa como gatilho. Proibido pelo tom definido acima.
  3. "Uma voz amiga vai te acompanhar todos os dias." — insinua companhia humana quando a voz é sintética. Omissão desonesta sobre a natureza do áudio.

10.2 Hero da home #

Headlines (5 opções):

1. Um devocional por dia, direto no seu WhatsApp, às 6 da manhã.
2. A Palavra de Deus chega antes do seu primeiro café.
3. Comece o dia com Deus sem precisar lembrar disso.
4. Seu devocional diário, em texto e áudio, no WhatsApp que você já usa.
5. Todo dia, às 6h, uma pausa com Deus antes da correria começar.

Subheadlines (5 opções):

1. Texto e áudio narrado, entregues automaticamente todos os dias. Sem aplicativo para
   instalar, sem senha para lembrar.
2. Receba reflexão bíblica, oração e áudio narrado no WhatsApp, no mesmo horário, todos
   os dias. Cancele quando quiser.
3. Um devocional curto, com base bíblica e oração, em texto e em áudio, direto no seu
   WhatsApp. Todos os dias às 6h.
4. Experimente grátis com um devocional por semana, ou assine e receba todos os dias, com
   áudio narrado incluso.
5. Sem app. Sem grupo. Sem enrolação. Só o devocional, todos os dias, no WhatsApp.

Botão principal: Quero receber grátis Botão secundário: Ouvir um devocional de exemplo

Recomendação: usar a headline 1 com a subheadline 1 — a combinação nomeia o mecanismo concreto (WhatsApp, 6h, texto e áudio) sem depender de metáfora, o que reduz ambiguidade para um público que decide rápido em mobile.

10.3 Bloco "o problema" #

Você já decidiu começar a ler a Bíblia todo dia. Mais de uma vez.

Funciona bem na primeira semana. Depois vem um dia corrido, ou um dia ruim, e a leitura
fica pra depois. Depois vira dois dias. Depois você nem lembra em que capítulo parou.

O problema não é falta de vontade. É que manter um hábito sozinho, sem ninguém pra
lembrar, é difícil pra qualquer pessoa — com qualquer hábito.

O Palavra Diária resolve essa parte: a lembrança. Todos os dias, no mesmo horário, o
devocional chega até você. Você só precisa abrir o WhatsApp que já abre de qualquer jeito.

10.4 Bloco "como funciona" #

Passo 1 — Cadastre seu WhatsApp
Você informa seu número, confirma com um código e pronto. Não precisa criar senha nem
baixar nada.

Passo 2 — Escolha seu plano
Continue no plano gratuito, com um devocional por semana, ou assine o plano completo, com
devocional em texto e áudio todos os dias.

Passo 3 — Receba todos os dias às 6h
Todo dia, no mesmo horário, o devocional chega no seu WhatsApp. Você lê, ouve, e segue com
o seu dia.

10.5 Bloco "o que você recebe" #

Texto completo todos os dias
Reflexão bíblica com referência, texto do versículo e uma oração curta para fechar.

Áudio narrado (plano assinante)
O mesmo devocional narrado em áudio, para ouvir enquanto se arruma ou dirige.

Sempre no mesmo horário
Todos os dias às 6h da manhã, horário de Brasília. Sem variação, sem imprevisto.

Direto no WhatsApp
Sem aplicativo novo para instalar. Chega no WhatsApp que você já tem aberto.

Acervo dos devocionais anteriores
No seu painel web você reencontra os devocionais já enviados, para reler quando quiser.

Cancele quando quiser
Sem fidelidade, sem multa. Você cancela pelo painel em menos de um minuto.

10.6 Bloco de amostra #

Quer ouvir antes de assinar?

Preparamos um devocional de demonstração, igual ao que você receberia num dia comum: um
texto bíblico, uma reflexão curta e uma oração — narrado com a mesma voz que você vai
ouvir todos os dias, caso assine o plano completo.

[Ouvir devocional de exemplo]

Sem cadastro. Sem compromisso. Só para você saber exatamente o que vai receber.

10.7 Comparativo de planos #

Título do bloco: Escolha como quer receber

Plano Nome comercial Posicionamento em uma linha
FREE Plano Gratuito Para experimentar o devocional aos domingos, sem custo, para sempre.
PAID Plano Assinante Para quem quer o hábito diário completo, em texto e áudio, todos os dias.

Nota obrigatória de implementação (não é copy): o "Plano Gratuito" desta tabela é um item sintético, montado pelo handler. Ele não corresponde a nenhuma linha da tabela plans: ser gratuito é a ausência de assinatura vigente (Seção 13.1). O handler lê os planos pagos ativos e prepende o item gratuito com valores fixos (code: 'plan_free', tier: 'FREE', name: 'Gratuito', cycle: null, priceCents: 0). Nenhuma escrita usa esse código. Contrato da resposta em 9.16.2.

Texto de cada linha do comparativo:

Frequência do envio
Gratuito: um devocional por semana, aos domingos.
Assinante: um devocional todos os dias, inclusive domingo.

Formato
Gratuito: texto completo.
Assinante: texto completo e áudio narrado.

Acervo no painel
Gratuito: últimos 7 dias.
Assinante: acervo completo desde a primeira vez que você assinou.

Reenvio do devocional do dia
Gratuito: 1 vez por dia, pelo painel.
Assinante: até 3 vezes por dia, pelo painel.

Suporte
Gratuito: central de ajuda e e-mail.
Assinante: e-mail com resposta em até 1 dia útil.

Custo
Gratuito: R$ 0, sem prazo de expiração.
Assinante: R$ 19,90 por mês ou R$ 199,00 por ano (economize 16%).

O rótulo de economia exibido é sempre "economize 16%", arredondado para baixo a partir de 16,66% pela regra da Seção 9.5.3. O número exato de 16,7% só aparece em documentação interna; a página nunca promete mais do que entrega.

10.8 Bloco de preço #

Plano Assinante

R$ 19,90 por mês
ou R$ 199,00 por ano — equivalente a R$ 16,58 por mês. Economize 16%.

Pagamento por cartão de crédito (renovação automática) ou PIX.

Cancele quando quiser, direto pelo painel. Sem fidelidade, sem multa de cancelamento.
Sem aplicativo para instalar — tudo funciona pelo WhatsApp e por este painel, no navegador.

Nota de honestidade obrigatória a incluir sempre que preço aparecer junto de condições: não existe período de teste gratuito do plano pago. Quem quer experimentar antes de pagar usa o plano gratuito (Seção 10.6 e 10.7) ou ouve a amostra (Seção 10.6).

10.9 Prova social #

Estrutura de depoimento, repetida 4 vezes: foto ou iniciais, nome e cidade (ou "assinante verificado" quando o nome não puder ser publicado), tempo de assinatura, texto do depoimento.

[PLACEHOLDER — substituir por depoimento real antes de publicar]

"Depoimento de exemplo 1: descreva aqui, com as palavras do próprio assinante, o que
mudou na rotina dele — não na vida espiritual dele — desde que começou a receber o
devocional. Ex.: forma como incorporou o hábito no dia a dia."
— Nome do assinante, cidade — assinante há X meses

[PLACEHOLDER — substituir por depoimento real antes de publicar]

"Depoimento de exemplo 2: foque em um detalhe concreto do produto (horário, áudio, não
precisar lembrar) em vez de elogio genérico."
— Nome do assinante, cidade — assinante há X meses

[PLACEHOLDER — substituir por depoimento real antes de publicar]

"Depoimento de exemplo 3: se possível, cite a situação em que o áudio foi útil (dirigindo,
se arrumando, etc.)."
— Nome do assinante, cidade — assinante há X meses

[PLACEHOLDER — substituir por depoimento real antes de publicar]

"Depoimento de exemplo 4: se possível, cite a transição do plano gratuito para o
assinante e o motivo da troca."
— Nome do assinante, cidade — assinante há X meses

Regra de queda, obrigatória. Os quatro blocos acima são gabaritos de redação e nunca são renderizados. O bloco de prova social só aparece quando existir ao menos um depoimento real cadastrado. Com zero depoimentos, o componente <TestimonialCarousel> não é montado, a seção inteira some da página, e nada a substitui — nem texto de espera, nem esqueleto, nem depoimento genérico. A nota legal abaixo só aparece junto com depoimentos reais.

Nota legal obrigatória, exibida junto ao bloco de depoimentos:

Todos os depoimentos publicados nesta página são de assinantes reais e foram publicados
com autorização expressa de cada pessoa. Nenhum depoimento é pago, encenado ou gerado
artificialmente.

10.10 FAQ #

1. Como eu cancelo minha assinatura?
Pelo seu painel, em Configurações > Assinatura > Cancelar assinatura. O cancelamento é
imediato no sistema, mas o acesso ao plano completo continua até o fim do período que
você já pagou. Depois disso, você volta automaticamente para o plano gratuito — não perde
o devocional, só passa a recebê-lo aos domingos.

2. E se eu não gostar?
Como não existe período de teste do plano pago, recomendamos usar o plano gratuito ou
ouvir o devocional de exemplo antes de assinar (veja a seção "Quer ouvir antes de
assinar?" nesta página). Se depois de assinar você decidir que não é para você, cancela a
qualquer momento pelo painel e não é cobrado novamente.

3. Preciso instalar algum aplicativo?
Não. Tudo funciona pelo WhatsApp que você já usa e por este painel, que abre direto no
navegador do celular ou computador. Não existe aplicativo do Palavra Diária.

4. Posso mudar o horário de envio?
Não. O envio acontece sempre às 6h da manhã, horário de Brasília, para todos os
assinantes, e esse horário não é personalizável. Se você não abrir a mensagem na hora, ela
continua disponível no WhatsApp e também no seu painel.

5. Qual versão da Bíblia vocês usam?
Usamos a Almeida Revista e Corrigida na edição de 1911, identificada nas mensagens como
"Almeida 1911", com apoio da Bíblia Livre em alguns trechos. Escolhemos exatamente essas
edições porque estão em domínio público e podem ser reproduzidas livremente, com
fidelidade ao texto. A atribuição impressa em toda entrega é sempre o nome completo da
edição — nunca a sigla "ARC", que no mercado brasileiro identifica uma edição revisada e
protegida por direitos autorais, e que por isso é proibida como código e como atribuição
neste produto.

6. Quem escreve os devocionais?
Uma equipe editorial humana escreve, revisa e programa cada devocional. Não usamos
inteligência artificial para gerar o conteúdo do texto — apenas para narrar o áudio (veja
a próxima pergunta).

7. A voz do áudio é humana ou gerada por computador?
É gerada por computador, com uma tecnologia de síntese de voz de alta qualidade treinada
para soar natural em português. Não é a voz de uma pessoa real gravando todos os dias. Você
pode ouvir uma amostra antes de assinar.

8. Meus dados estão seguros?
Sim. Seus dados são armazenados de forma criptografada e usados só para entregar o
serviço e processar o pagamento. Você pode pedir a exportação ou a exclusão dos seus dados
a qualquer momento pelo painel. Tratamos essas informações de acordo com a Lei Geral de
Proteção de Dados (LGPD).

9. Posso presentear a assinatura para outra pessoa?
No momento, cada assinatura é vinculada ao número de WhatsApp de quem vai recebê-la. Para
presentear alguém, cadastre a assinatura com o número de WhatsApp da pessoa presenteada e
finalize o pagamento com os seus próprios dados de cobrança.

10. Funciona em qualquer celular?
Sim. Se o seu celular tem WhatsApp instalado e funcionando, você recebe o devocional
normalmente, seja Android ou iPhone. O painel do assinante funciona em qualquer navegador
de celular ou computador.

11. Como eu pago?
Por cartão de crédito, com renovação automática mensal ou anual, ou por PIX, com uma nova
cobrança gerada a cada ciclo. Você escolhe a forma de pagamento no momento da assinatura e
pode alterá-la depois pelo painel.

12. O que acontece se meu pagamento falhar?
O acesso ao plano completo é encerrado imediatamente quando um pagamento falha — não há
tolerância nem prazo extra de acesso. Você continua recebendo o devocional gratuito aos
domingos normalmente. Assim que o pagamento for regularizado, o acesso ao plano completo
volta a valer a partir do novo pagamento confirmado.

10.11 CTA final da home #

Título: Comece hoje. É grátis para experimentar.
Frase: Cadastre seu WhatsApp em menos de um minuto e receba seu primeiro devocional no
próximo domingo, às 6h.
Botão: Quero receber grátis

10.12 Rodapé #

Links: Como funciona · Planos · Perguntas frequentes · Termos de uso · Política de
privacidade · Fale com a gente

Aviso de LGPD: Tratamos seus dados pessoais conforme a Lei Geral de Proteção de Dados —
veja nossa Política de Privacidade para saber como acessar, corrigir ou excluir suas
informações.

Contato: Dúvidas, sugestões ou problemas com sua assinatura? Escreva para
contato@palavradiaria.com.br — respondemos todos os dias úteis.

10.13 Microcopy da interface #

Convenção de chave: screen.componente.estado. Textos abaixo são literais, prontos para uso direto em componentes de UI.

Chave Contexto Texto
signup.phone.label Rótulo do campo de telefone no cadastro Seu WhatsApp
signup.phone.placeholder Placeholder do campo de telefone (11) 91234-5678
signup.phone.help Texto de ajuda abaixo do campo Vamos enviar um código de confirmação por WhatsApp para este número.
signup.phone.error.invalid Erro de validação de telefone Digite um número de celular brasileiro válido, com DDD.
signup.consent.label Checkbox de consentimento Aceito receber devocionais no WhatsApp e concordo com os Termos de uso e a Política de privacidade.
signup.consent.error.required Erro quando o checkbox não é marcado Para continuar, você precisa aceitar o envio de mensagens no WhatsApp.
signup.submit.button Botão de envio do formulário de cadastro Enviar código de confirmação
signup.submit.loading Estado de carregamento do botão de cadastro Enviando...
otp.title Título da tela de código OTP Confirme seu número
otp.subtitle Subtítulo da tela de código OTP Enviamos um código de 6 dígitos para o seu WhatsApp.
otp.input.label Rótulo do campo de código Código de 6 dígitos
otp.resend.button Botão de reenvio de código Reenviar código
otp.resend.cooldown Texto de espera antes de poder reenviar Você poderá pedir um novo código em {seconds}s.
otp.error.invalid Erro de código incorreto Código incorreto. Você ainda tem {attemptsLeft} tentativa(s).
otp.error.expired Erro de código expirado Esse código expirou. Peça um novo código para continuar.
otp.error.too_many_requests Erro de limite de reenvios excedido Você atingiu o limite de códigos por hora. Tente novamente mais tarde.
whatsapp_optin.pending.title Tela aguardando confirmação no WhatsApp Falta um passo
whatsapp_optin.pending.body Instrução para confirmar opt-in no WhatsApp Abra o WhatsApp e toque em "Confirmar" na mensagem que acabamos de te enviar.
plans.free.badge Selo do plano gratuito Grátis para sempre
plans.paid.badge Selo do plano pago Mais escolhido
plans.cta.free Botão de seleção do plano gratuito Continuar no plano gratuito
plans.cta.paid Botão de seleção do plano pago Assinar plano completo
checkout.name.label Rótulo do campo de nome no checkout Nome completo
checkout.cpf.label Rótulo do campo de CPF no checkout CPF
checkout.cpf.help Texto de ajuda sobre o CPF no checkout Pedimos o CPF porque é uma exigência da instituição que processa o pagamento.
checkout.cpf.error.invalid Erro de CPF inválido Digite um CPF válido.
checkout.payment_method.label Rótulo do seletor de forma de pagamento Como você quer pagar?
checkout.payment_method.card Opção de pagamento por cartão Cartão de crédito
checkout.payment_method.pix Opção de pagamento por PIX PIX
checkout.cycle.monthly Opção de ciclo mensal Mensal — R$ 19,90/mês
checkout.cycle.yearly Opção de ciclo anual Anual — R$ 199,00/ano (economize 16%)
checkout.submit.button Botão de finalizar pagamento Confirmar assinatura
checkout.submit.loading Estado de carregamento do botão de checkout Processando pagamento...
checkout.pix.waiting Tela de espera de pagamento PIX Escaneie o QR Code ou copie o código para pagar. Assim que o pagamento for confirmado, você recebe uma mensagem no WhatsApp.
checkout.error.payment_declined Erro de pagamento recusado Não conseguimos confirmar o pagamento. Verifique os dados do cartão ou tente outra forma de pagamento.
checkout.error.generic Erro genérico de checkout Algo deu errado ao processar seu pagamento. Tente novamente em alguns instantes.
checkout.success.title Título de sucesso pós-checkout Assinatura confirmada
dashboard.today.empty Estado vazio do devocional do dia no painel O devocional de hoje ainda não foi enviado. Ele chega às 6h.
dashboard.today.resend.button Botão de reenvio manual do devocional Reenviar para meu WhatsApp
dashboard.today.resend.limit_reached Erro de limite de reenvio atingido Você já usou os reenvios disponíveis para hoje no seu plano.
dashboard.archive.title Título da seção de acervo Devocionais anteriores
dashboard.archive.locked Aviso de acervo limitado no plano gratuito No plano gratuito, você vê os últimos 7 dias. Assine o plano completo para ver o acervo inteiro.
dashboard.subscription.status.active Rótulo de status de assinatura ativa Assinatura ativa
dashboard.subscription.status.canceled_scheduled Rótulo de cancelamento agendado Cancelamento confirmado — acesso completo até {date}
dashboard.subscription.status.expired Rótulo de assinatura expirada por falha de pagamento Pagamento não identificado — você está no plano gratuito
dashboard.subscription.cancel.button Botão de cancelamento de assinatura Cancelar assinatura
dashboard.subscription.cancel.confirm_title Título do modal de confirmação de cancelamento Tem certeza que quer cancelar?
dashboard.subscription.cancel.confirm_body Corpo do modal de confirmação de cancelamento Você continua com o plano completo até {date}. Depois disso, passa a receber só o devocional gratuito, aos domingos.
dashboard.subscription.cancel.confirm_button Botão de confirmação final de cancelamento Sim, cancelar assinatura
dashboard.subscription.cancel.success Mensagem de sucesso após cancelar Cancelamento confirmado. Seu plano completo continua ativo até {date}.
dashboard.subscription.reactivate.button Botão para reativar assinatura cancelada dentro do período pago Reativar assinatura
dashboard.export.button Botão de exportação de dados (LGPD) Baixar meus dados
dashboard.export.success Mensagem de sucesso de exportação solicitada Preparamos seu arquivo. Ele fica disponível para download aqui no painel, e avisamos por e-mail quando estiver pronto.
dashboard.export.download Botão de download do arquivo pronto, dentro do painel Baixar arquivo agora
dashboard.export.download_limit Erro de limite de downloads do mesmo arquivo Você já baixou este arquivo 3 vezes. Peça uma nova exportação se precisar dele de novo.
dashboard.pause.active Aviso de pausa ativa Pausado até {lastPausedDay}; você volta a receber em {resumeDay}.
dashboard.delete_account.button Botão de exclusão de conta Excluir minha conta
dashboard.delete_account.confirm_body Aviso do modal de exclusão de conta Isso encerra sua assinatura e remove seus dados pessoais em até 30 dias, conforme nossa Política de privacidade.
whatsapp.optout.confirmation Confirmação enviada no WhatsApp quando o opt-out foi pedido pelo WhatsApp e não há assinatura paga vigente Você não vai mais receber devocionais por aqui. Se mudar de ideia, responda VOLTAR a qualquer momento.
whatsapp.optout.subscription_notice Confirmação enviada no WhatsApp quando o opt-out foi pedido pelo WhatsApp por quem tem assinatura paga vigente Paramos de enviar. Sua próxima cobrança está suspensa. Se quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel.
dashboard.subscription.status.optout_suspended Rótulo no painel enquanto o opt-out mantém a assinatura suspensa Envios pausados, cobrança suspensa
errors.generic.retry Mensagem genérica de erro com opção de nova tentativa Não foi possível concluir agora. Tente novamente em instantes.

Regra de canal das duas chaves de opt-out acima. Elas são mutuamente exclusivas e valem apenas para o opt-out pedido pelo WhatsApp: nesse caso o sistema envia exatamente uma mensagem no WhatsApp — whatsapp.optout.subscription_notice quando há assinatura paga vigente, whatsapp.optout.confirmation nos demais casos — e nada mais. Opt-out pedido pelo painel ou pelo link de descadastro não produz mensagem no WhatsApp: a confirmação aparece na tela e, quando houver e-mail verificado, também por e-mail (Seções 14.7.3 e 20.4.1). O motivo é direto: quem pediu para parar de receber mensagens no WhatsApp por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp.

10.14 Página de obrigado (pós-assinatura) #

Título: Pronto! Sua assinatura está ativa.
Texto: A partir de amanhã às 6h, você recebe seu devocional em texto e áudio, todos os
dias, no WhatsApp cadastrado. Enquanto isso, dá uma olhada no seu painel — lá você
encontra os devocionais já publicados e pode ajustar seus dados quando quiser.
Próximo passo: [Ir para o meu painel]

10.15 Copy dos e-mails transacionais #

E-mail 1 — Confirmação de assinatura

Assunto: Sua assinatura do Palavra Diária está confirmada

Olá, {{firstName}},

Sua assinatura do Palavra Diária foi confirmada com sucesso.

Plano: {{planName}}
Valor: {{formattedPrice}}
Próxima cobrança: {{nextDueDate}}

A partir de agora, você recebe devocional em texto e áudio todos os dias às 6h, no
WhatsApp cadastrado no número {{phoneMasked}}.

Para acompanhar sua assinatura, acessar o acervo completo de devocionais ou fazer
alterações, acesse seu painel:
{{dashboardUrl}}

Qualquer dúvida, é só responder este e-mail.

Equipe Palavra Diária

E-mail 2 — Lembrete de cobrança

Assunto: Sua próxima cobrança do Palavra Diária é em {{daysUntilDue}} dias

Olá, {{firstName}},

Sua assinatura do plano {{planName}} será renovada automaticamente em {{nextDueDate}}, no
valor de {{formattedPrice}}.

Forma de pagamento: {{paymentMethodLabel}}

Se estiver tudo certo, você não precisa fazer nada. Se quiser alterar a forma de
pagamento ou cancelar antes da renovação, acesse seu painel:
{{dashboardUrl}}

Equipe Palavra Diária

E-mail 3 — Falha de pagamento

Assunto: Não conseguimos confirmar seu pagamento

Olá, {{firstName}},

Não conseguimos confirmar o pagamento da sua assinatura do Palavra Diária.

Como consequência, seu acesso ao plano completo foi encerrado agora. Você continua
recebendo o devocional gratuito aos domingos, normalmente.

Para voltar a receber o devocional completo todos os dias, atualize sua forma de
pagamento e finalize uma nova assinatura pelo painel:
{{dashboardUrl}}

Se você acredita que isso é um engano, responda este e-mail que a gente verifica com
você.

Equipe Palavra Diária

E-mail 4 — Cancelamento confirmado

Assunto: Seu cancelamento foi confirmado

Olá, {{firstName}},

Confirmamos o cancelamento da sua assinatura do Palavra Diária.

Seu plano completo continua ativo até {{currentPeriodEnd}}, já que esse período já está
pago. Depois dessa data, você passa a receber automaticamente o devocional gratuito, aos
domingos.

Mudou de ideia? Você pode reativar a assinatura a qualquer momento antes de
{{currentPeriodEnd}} pelo painel, sem perder o período já pago:
{{dashboardUrl}}

Sentiremos sua falta nos outros dias da semana. Esperamos te ver de volta.

Equipe Palavra Diária

E-mail 5 — Exportação de dados pronta

Assunto: Seus dados estão prontos para download

Olá, {{firstName}},

O arquivo com os seus dados pessoais, conforme solicitado no seu painel, está pronto.

Entre no seu painel, em Meus dados, e clique em "Baixar arquivo agora":
{{dashboardUrl}}

Por segurança, o download acontece apenas dentro do painel, com você autenticado. Este
e-mail é só um aviso: ele não contém link de download. O arquivo fica disponível por 7
dias e pode ser baixado até 3 vezes.

O arquivo contém seus dados de cadastro, histórico de assinatura e registros de
consentimento, no formato JSON.

Qualquer dúvida sobre o conteúdo do arquivo ou sobre seus direitos de titular de dados,
responda este e-mail.

Equipe Palavra Diária

10.16 Anúncios e divulgação orgânica #

Textos curtos para redes sociais (5):

1. Todo dia às 6h, um devocional chega no seu WhatsApp — texto e áudio, sem precisar
   lembrar. Comece grátis: palavradiaria.com.br

2. Você não esquece de ver o WhatsApp. Também não vai esquecer do seu devocional. Todo
   dia, às 6h. Experimente grátis: palavradiaria.com.br

3. Um devocional por semana, de graça, para sempre. Ou todo dia, em texto e áudio, no
   plano completo. Você escolhe: palavradiaria.com.br

4. Sem aplicativo novo. Sem senha para lembrar. Só o devocional, no WhatsApp que você já
   usa, às 6h da manhã. palavradiaria.com.br

5. Quer ouvir antes de assinar? Temos um devocional de exemplo, narrado, esperando por
   você. Sem cadastro: palavradiaria.com.br

Mensagens de indicação entre amigos pelo WhatsApp (3):

1. Oi! Comecei a receber um devocional todo dia às 6h no WhatsApp, com texto e áudio.
   Achei bem prático porque não preciso lembrar de nada. Se quiser conhecer:
   palavradiaria.com.br

2. Tô usando um serviço que manda um devocional bíblico todo dia de manhã, direto no
   WhatsApp. Tem uma versão gratuita também, um por semana. Passa lá: palavradiaria.com.br

3. Achei que você ia gostar disso: um devocional em texto e áudio, todo dia às 6h, sem
   precisar instalar nada. Dá uma olhada: palavradiaria.com.br

11. Cadastro, Opt-in e Onboarding do Assinante #

11.1 Visão geral do fluxo #

O cadastro tem quatro etapas e produz um assinante apto a receber devocionais. As etapas são sequenciais e cada uma tem um estado persistido. Nenhuma mensagem de devocional sai antes da Etapa 3 concluída.

Etapa Nome Onde acontece Estado resultante
1 Identificação e consentimento Web, /cadastro PENDING_VERIFICATION
2 Verificação de posse do número (OTP) WhatsApp + web VERIFIED_PENDING_OPTIN
3 Confirmação ativa de opt-in WhatsApp ACTIVE_FREE
4 Escolha entre continuar gratuito ou assinar Web, /cadastro/plano ACTIVE_FREE ou ACTIVE_PAID

Decisões desta seção:

# Decisão Justificativa
D11.1 A Etapa 4 é opcional e não bloqueante. Quem fecha o navegador depois da Etapa 3 já é assinante gratuito ativo e começa a receber. Maximiza a conversão para FREE, que é o topo do funil pago.
D11.2 O consentimento web (Etapa 1) e a confirmação no WhatsApp (Etapa 3) são dois registros distintos em consent_events, com channel diferente. Opt-in em duas etapas é exigência de conformidade; provar as duas exige dois registros.
D11.3 A sessão do assinante é criada no fim da Etapa 2, não da Etapa 3. Ele precisa navegar em /cadastro/plano e no painel mesmo antes de confirmar o opt-in no WhatsApp.
D11.4 Um número em PENDING_VERIFICATION há mais de 24 horas é apagado por hard delete pelo job de manutenção. Ele nunca ocupou o índice único de forma legítima. Evita que abandono no meio bloqueie o número para sempre.
D11.5 welcome_backfill entrega o devocional do dia uma única vez a quem confirma depois das 06:00, inclusive no tier FREE em dia que não é domingo. Isso é uma exceção de onboarding, declarada e limitada a um envio por assinante na vida. Sem isso, quem se cadastra numa segunda-feira espera seis dias para ver o produto.
D11.6 O CPF não é pedido no cadastro. Ele é pedido apenas no checkout (Seção 12), porque só a criação de cliente na Asaas o exige. Minimização de dados (LGPD). Assinante gratuito nunca informa CPF.

11.2 Diagrama de estados do assinante #

                        ┌──────────────────────────────────────────────┐
                        │              (não existe ainda)              │
                        └───────────────────┬──────────────────────────┘
                                            │ POST /api/signups
                                            │ (nome + telefone + consentimento)
                                            ▼
                             ┌────────────────────────────┐
                    ┌───────▶│   PENDING_VERIFICATION     │
                    │        │  OTP enviado, aguardando   │
                    │        └──────┬──────────────┬──────┘
                    │               │              │
     reenvio de OTP │               │ OTP ok       │ 24 h sem verificar
     (máx. 3/hora)  │               │              │ → job de limpeza
                    └───────────────┤              ▼
                                    │        (hard delete, número liberado)
                                    ▼
                       ┌─────────────────────────────┐
                       │  VERIFIED_PENDING_OPTIN     │◀────────┐
                       │  sessão criada; mensagem de │         │ reenvio da
                       │  boas-vindas no WhatsApp    │         │ mensagem de
                       └──────┬───────────────┬──────┘         │ boas-vindas
                              │               │                │ (máx. 3, 1/hora)
             toca "Quero      │               │  7 dias sem    │
             receber" ou      │               │  confirmar     │
             responde SIM     │               ▼                │
                              │        ┌─────────────┐         │
                              │        │  OPTED_OUT  │         │
                              │        │ (expirado)  │         │
                              │        └──────┬──────┘         │
                              │               │ VOLTAR /       │
                              │               │ painel         │
                              │               └────────────────┘
                              ▼
                    ┌───────────────────┐   webhook PAYMENT_CONFIRMED    ┌──────────────────┐
                    │    ACTIVE_FREE    │───────────────────────────────▶│   ACTIVE_PAID    │
                    │  tier = FREE      │◀───────────────────────────────│   tier = PAID    │
                    │  domingo 06:00    │  eventos de cobrança da 11.8.1 │  todo dia 06:00  │
                    └───┬───────────┬───┘  ou fim do período pago        └───┬──────────┬───┘
                        │           │      fim do período pago            │          │
             SAIR/PARAR │           │                                     │ SAIR     │
             /painel    │           │ pausa (1 a 30 dias)                 │ /painel  │ pausa
                        ▼           ▼                                     ▼          ▼
                 ┌─────────────┐  ┌──────────────┐                 ┌─────────────┐  ┌──────────────┐
                 │  OPTED_OUT  │  │    PAUSED    │                 │  OPTED_OUT  │  │    PAUSED    │
                 │ envios off  │  │ envios off   │                 │ envios off  │  │ envios off   │
                 │ cobrança    │  │ até          │                 │ cobrança    │  │ até          │
                 │ suspensa    │  │ paused_until │                 │ suspensa    │  │ paused_until │
                 └──────┬──────┘  └──────┬───────┘                 └──────┬──────┘  └──────┬───────┘
                        │  VOLTAR ou reativação no painel                 │                │
                        └────────────────▶ (volta ao estado de tier atual) ◀────────┴────────┘

  Fora do fluxo acima, dois estados de exceção:

    BLOCKED       entra por bloqueio administrativo ou por erro permanente da Meta;
                  sai por desbloqueio administrativo. Envios cessam; cobrança inalterada.

    deleted_at    NÃO é um status: é a coluna preenchida pelo pedido de eliminação (LGPD).
                  Ela vence qualquer status e é terminal: nenhuma transição sai dela.

Leitura obrigatória do diagrama: OPTED_OUT interrompe os envios na hora e suspende a cobrança do ciclo seguinte — sem reativação em 30 dias, a assinatura é cancelada ao fim do período já pago (regra canônica na Seção 13.4.6). A eliminação por LGPD não é um status: é deleted_at preenchido, e é terminal e irreversível fora da janela de 72 h da Seção 14.8.2. As duas regras reaparecem em 11.8.

11.3 Etapa 1 — Formulário web de cadastro #

11.3.1 Rota e layout #

  • Rota: /cadastro (route group (marketing), mas com noindex, nofollow).
  • Sem cabeçalho de navegação completo: apenas logotipo, link "Voltar ao início" e o indicador de progresso 1 de 3.
  • O indicador de progresso mostra três passos: Seus dadosCódigoConfirmação no WhatsApp. A Etapa 4 não aparece no indicador porque é opcional (D11.1).
  • Se o visitante chegou pelo formulário de captura (Seção 9.7.3), o campo de telefone já vem preenchido e o foco inicial vai para o campo name.

11.3.2 Campos #

Campo Rótulo visível Tipo Obrigatório autoComplete
name "Seu nome" text Sim given-name
phone "Seu WhatsApp" tel Sim tel-national
email "E-mail (opcional)" email Não email
consent Texto de consentimento versionado checkbox Sim

Não há campo de senha. Não há confirmação de e-mail. Não há campo de CPF (D11.6).

11.3.3 Validação campo a campo #

Schema compartilhado entre cliente e servidor, em packages/core:

// packages/core/src/schemas/signup.ts
import { z } from 'zod';
import { phoneInputSchema } from './phone';

const NAME_ALLOWED = /^[\p{L}\p{M}][\p{L}\p{M}'’\- ]*[\p{L}\p{M}.]$/u;

export const signupSchema = z.object({
  name: z.string()
    .trim()
    .min(2, 'Informe seu nome com pelo menos 2 letras.')
    .max(80, 'Use no máximo 80 caracteres.')
    .regex(NAME_ALLOWED, 'Use apenas letras, espaços, hífen e apóstrofo.')
    .refine((v) => !/(https?:|www\.|\d{4,})/i.test(v), 'Esse nome não parece válido.')
    .transform(collapseSpaces),

  phone: phoneInputSchema,                       // ver 11.4

  email: z.union([z.literal(''), z.email('Informe um e-mail válido.').max(160)])
    .optional()
    .transform((v) => (v ? v.trim().toLowerCase() : undefined)),

  consent: z.literal(true, {
    error: 'É necessário aceitar para receber as mensagens.',
  }),

  policyVersion: z.string().min(1),              // preenchido pelo servidor, não pelo usuário
  source: z.enum(['hero', 'final_cta', 'plans_page', 'faq_page', 'direct']).default('direct'),
  planCode: z.enum(['plan_monthly', 'plan_annual']).optional(),
  turnstileToken: z.string().min(1),
  company: z.string().max(0).optional(),         // honeypot
});

Regras exatas por campo, com as mensagens de erro em português que a interface exibe:

name

Situação Mensagem
Vazio "Informe seu nome."
Menos de 2 caracteres após trim "Informe seu nome com pelo menos 2 letras."
Mais de 80 caracteres "Use no máximo 80 caracteres."
Contém dígito, URL, emoji ou caractere de controle "Use apenas letras, espaços, hífen e apóstrofo."
Contém 4 ou mais dígitos consecutivos "Esse nome não parece válido."

Normalização aplicada: trim, colapso de espaços múltiplos em um só, remoção de caracteres de controle e de zero-width. Acentos são preservados. O nome é usado como parâmetro {{1}} de templates do WhatsApp em algumas mensagens (Seção 19), e parâmetro de template não aceita quebra de linha nem 4 espaços consecutivos — a normalização já garante isso na entrada, e o motor de envio revalida (Seção 18).

Exemplo concreto: entrada " maria das graças " → armazenado "maria das graças" em name, exibido com capitalização original (o sistema não recapitaliza: "d'Ávila" e "MARIA" são escolhas do usuário).

phone

Situação Mensagem
Vazio "Informe seu número de WhatsApp."
Menos de 10 dígitos após limpeza "Número incompleto. Inclua o DDD."
Não normalizável para E.164 "Não reconhecemos esse número. Use o formato (11) 91234-5678."
DDI diferente de 55 "Por enquanto atendemos apenas números do Brasil (+55)."
DDD inexistente na lista oficial "DDD inválido. Confira os dois primeiros dígitos."
Número fixo (não celular) "Informe um número de celular com WhatsApp."

email

Situação Mensagem
Preenchido e inválido "Informe um e-mail válido."
Mais de 160 caracteres "E-mail muito longo."
Já usado por outro assinante ativo Ver 11.11, caso E8 — não bloqueia o cadastro.

consent

Situação Mensagem
Desmarcado ao submeter "É necessário aceitar para receber as mensagens."

O checkbox nunca vem pré-marcado. Consentimento pré-marcado é inválido sob a LGPD.

Comportamento de validação na interface:

  1. Validação onBlur no primeiro contato com o campo e onChange depois que o campo já errou uma vez (modo onTouched do react-hook-form). Nunca validar onChange desde o primeiro caractere: isso mostra erro enquanto a pessoa ainda digita.
  2. Ao submeter com erros, o foco vai para o primeiro campo inválido e um role="alert" anuncia "Há N campos para corrigir".
  3. Erros de servidor voltam no envelope de erro da Seção 7 com details[].field e são mapeados de volta para o campo correspondente via setError.

11.3.4 Texto de consentimento e seu versionamento #

O rótulo do checkbox é o texto de consentimento versionado. O texto exato aprovado é propriedade do copy da Seção 10; esta seção fixa a mecânica:

  • A constante CURRENT_CONSENT_VERSION vive em packages/core (ex.: "2026-08-25").
  • O servidor ignora qualquer policyVersion enviado pelo cliente e grava sempre a versão vigente no servidor, na coluna consent_events.policy_version. Isso impede que um cliente forjado registre consentimento para uma versão antiga.
  • No submit bem-sucedido, o servidor cria um registro em consent_events (append-only, Seção 6) com: type = 'OPT_IN_WEB', policy_version, o texto integral exibido, ip, user_agent, channel = 'WEB', created_at. Os nomes de tipo e de coluna são os do enum e do schema da Seção 6; nenhuma grafia alternativa é aceita.
  • Guardar o texto integral, e não só a versão, é deliberado: em disputa, o que vale é o que a pessoa leu.

11.3.5 Proteções #

Proteção Regra Resposta ao estourar
Turnstile invisível Token obrigatório e verificado no servidor 422 CAPTCHA_FAILED
Honeypot company Precisa estar vazio 422 HONEYPOT_TRIGGERED (mensagem genérica)
Rate limit por IP 5 cadastros por IP por hora 429 RATE_LIMITED
Rate limit por número 3 pedidos de OTP por número por hora (Seção 8.2.1) 429 OTP_RATE_LIMITED
Rate limit global de OTP 500 pedidos por hora no sistema, com reserva de 20% para números já cadastrados; acima disso, alerta e fila 503 SERVICE_BUSY

Esta tabela é réplica informativa: os limites de OTP são canônicos na Seção 8.2.1 e não podem ser redefinidos aqui. Dois pontos que decorrem dela e valem para o cadastro: os contadores contam pedidos, não envios, e são incrementados antes de qualquer consulta ao banco, para número existente e inexistente igualmente — contar apenas envios reais transformaria a diferença de comportamento no quarto pedido em um oráculo de enumeração de assinantes. E a resposta é idêntica em código HTTP, corpo e error.code para número cadastrado e não cadastrado.

O widget do Turnstile roda em modo invisível nesta tela (decisão de acessibilidade da Seção 9.10.1), e a rota /cadastro é servida com a variante de CSP para páginas com formulário, que libera https://challenges.cloudflare.com em script-src, frame-src e connect-src (Seção 22.3.1). O mesmo host precisa constar da lista de destinos permitidos do cliente HTTP com proteção contra SSRF (Seção 22.2.6), porque a verificação do token é feita pelo servidor. Sem qualquer uma das duas liberações, nenhum cadastro é possível.

11.4 Normalização de telefone brasileiro #

Esta subseção é a única fonte da normalização de telefone. A função normalizePhoneBR() — exportada como normalizeBrazilPhone() em packages/core/src/phone.ts — é a única autorizada a produzir um phone_e164, e nenhuma outra seção reimplementa a regra. A identidade do assinante é esse telefone normalizado; a Seção 8.1 define apenas como essa identidade vira sessão.

A validação de formato E.164 acontece exclusivamente aqui, na borda, com Zod, antes de qualquer escrita. O banco não valida formato de telefone: a coluna phone_e164 guarda um envelope cifrado (Seção 22.5.3) e um CHECK sobre ela não teria o valor em claro para comparar. Qualquer CHECK de formato E.164 sobre coluna cifrada é proibido.

11.4.1 O problema do nono dígito #

Desde 2016 todos os celulares brasileiros têm nove dígitos após o DDD, começando com 9. O problema prático é que o ecossistema não é consistente:

  1. Pessoas digitam o número dos dois jeitos: (11) 91234-5678 e (11) 1234-5678.
  2. O WhatsApp, para números brasileiros antigos (anteriores à migração), historicamente propaga um wa_id sem o nono dígito, mesmo quando o número real o tem. O identificador que a Meta devolve em um webhook de entrada pode, portanto, ser 551112345678 enquanto o número que a pessoa cadastrou é 5511912345678.
  3. Números do DDD 11 ao 28 (região Sudeste, onde a migração começou) são os mais afetados.
  4. Consequência: buscar o assinante apenas por igualdade exata falha silenciosamente. A mensagem de entrada não encontra dono, a janela de 24 h não é registrada e o assinante parece "não responder" para o sistema.

11.4.2 A regra #

Implementada em packages/core/src/phone.ts, usada por web, worker e CLI. Ninguém mais normaliza telefone.

// packages/core/src/phone.ts
import parsePhoneNumberFromString from 'libphonenumber-js/max';

export type PhoneNormalizationFailure =
  | 'empty' | 'unparseable' | 'foreign_ddi' | 'invalid_ddd'
  | 'not_mobile' | 'too_short' | 'too_long';

export type PhoneNormalizationResult =
  | { ok: true; e164: string; ddd: string; national: string }
  | { ok: false; reason: PhoneNormalizationFailure };

const VALID_DDDS = new Set([
  '11','12','13','14','15','16','17','18','19',
  '21','22','24','27','28',
  '31','32','33','34','35','37','38',
  '41','42','43','44','45','46','47','48','49',
  '51','53','54','55',
  '61','62','63','64','65','66','67','68','69',
  '71','73','74','75','77','79',
  '81','82','83','84','85','86','87','88','89',
  '91','92','93','94','95','96','97','98','99',
]);

export function normalizeBrazilPhone(input: string): PhoneNormalizationResult {
  const raw = (input ?? '').trim();
  if (!raw) return { ok: false, reason: 'empty' };

  const parsed = parsePhoneNumberFromString(raw, 'BR');
  if (!parsed) return { ok: false, reason: 'unparseable' };
  if (parsed.countryCallingCode !== '55') return { ok: false, reason: 'foreign_ddi' };

  const national = parsed.nationalNumber;          // "11912345678"
  const ddd = national.slice(0, 2);
  if (!VALID_DDDS.has(ddd)) return { ok: false, reason: 'invalid_ddd' };

  let subscriber = national.slice(2);              // "912345678" ou "12345678"
  if (subscriber.length === 8) {
    // Aceita a forma sem o nono dígito e CANONIZA adicionando o 9,
    // desde que o primeiro dígito indique celular (6, 7, 8 ou 9).
    if (!/^[6-9]/.test(subscriber)) return { ok: false, reason: 'not_mobile' };
    subscriber = `9${subscriber}`;
  } else if (subscriber.length === 9) {
    if (!subscriber.startsWith('9')) return { ok: false, reason: 'not_mobile' };
  } else if (subscriber.length < 8) {
    return { ok: false, reason: 'too_short' };
  } else {
    return { ok: false, reason: 'too_long' };
  }

  return { ok: true, e164: `+55${ddd}${subscriber}`, ddd, national: `${ddd}${subscriber}` };
}

/** Variante sem o nono dígito. `null` quando não se aplica. */
export function withoutNinthDigit(e164: string): string | null {
  const m = /^\+55(\d{2})9(\d{8})$/.exec(e164);
  return m ? `+55${m[1]}${m[2]}` : null;
}

/** Variante com o nono dígito. `null` quando não se aplica. */
export function withNinthDigit(e164: string): string | null {
  const m = /^\+55(\d{2})([6-9]\d{7})$/.exec(e164);
  return m ? `+55${m[1]}9${m[2]}` : null;
}

Mensagens exibidas por reason (usadas pelo formulário de captura da Seção 9.7 e pelo cadastro):

export const PHONE_ERROR_MESSAGES: Record<PhoneNormalizationFailure, string> = {
  empty:       'Informe seu número de WhatsApp.',
  unparseable: 'Não reconhecemos esse número. Use o formato (11) 91234-5678.',
  foreign_ddi: 'Por enquanto atendemos apenas números do Brasil (+55).',
  invalid_ddd: 'DDD inválido. Confira os dois primeiros dígitos.',
  not_mobile:  'Informe um número de celular com WhatsApp.',
  too_short:   'Número incompleto. Inclua o DDD.',
  too_long:    'Número com dígitos demais. Confira e tente de novo.',
};

Exemplos concretos de normalização:

Entrada e164 resultante Observação
11912345678 +5511912345678 Caso comum.
(11) 91234-5678 +5511912345678 Máscara removida.
11 91234 5678 +5511912345678 Espaços removidos.
+55 11 91234-5678 +5511912345678 DDI explícito.
1112345678 erro not_mobile 8 dígitos começando com 1: é telefone fixo, não celular.
1181234567 +5511981234567 8 dígitos começando com 8 → nono dígito adicionado na canonização.
11981234567 +5511981234567 Mesmo número, já com o nono dígito. Colide com a linha anterior, como deve.
1131234567 erro not_mobile Fixo de São Paulo; não recebe WhatsApp de negócio no MVP.
+351912345678 erro foreign_ddi Portugal.
00912345678 erro invalid_ddd DDD 00 não existe.

11.4.3 Persistência dos quatro campos #

Quatro colunas em subscribers (Seção 6 é a dona do schema e do tipo físico; aqui só declaramos o que a normalização produz e como a busca acontece):

  • phone_e164 — valor canônico, sempre com o nono dígito, guardado como envelope cifrado (Seção 22.5.3). Nunca indexado, nunca comparado por igualdade.
  • phone_hmac — HMAC-SHA-256 determinístico do E.164 canônico. É a coluna de identidade e de unicidade, com índice único parcial (WHERE deleted_at IS NULL). Toda busca por telefone é feita por ela.
  • wa_id — identificador devolvido pela Meta no primeiro contato bem-sucedido, também cifrado e sem índice.
  • wa_id_hmac — HMAC do wa_id, índice único parcial (WHERE wa_id_hmac IS NOT NULL). Nulo até o primeiro webhook.

O wa_id é preenchido de duas origens: (a) o campo contacts[0].wa_id da resposta de envio da Cloud API e (b) o campo contacts[0].wa_id do webhook de entrada. Quando os dois divergirem, vale o do webhook de entrada, porque é o identificador com o qual a Meta vai falar conosco dali em diante.

Regra de chip reciclado, obrigatória. Preencher um wa_id que estava nulo é o caminho normal. Trocar um wa_id já preenchido por outro valor não é: números brasileiros são reemitidos pelas operadoras, e quem recebe o número reemitido não é o titular. Quando o wa_id_hmac calculado divergir do já gravado para aquele telefone, o sistema, na mesma transação: grava o novo valor, revoga todas as sessões do assinante e marca a conta como pendente de reverificação. A partir daí, qualquer rota que devolva dado pessoal — conta, CPF, histórico de pagamentos, acervo, exportação — responde 403 REVERIFICATION_REQUIRED até uma nova verificação por código ser concluída. Responder no WhatsApp nunca é, por si só, prova de titularidade.

11.4.4 Busca em cascata #

Toda vez que o sistema recebe uma mensagem ou um status da Meta, precisa achar o assinante. A busca é uma cascata de quatro tentativas, na ordem, parando na primeira que encontrar exatamente um resultado:

// packages/core/src/phone-lookup.ts
import { blindIndex } from './crypto/blind-index';   // HMAC determinístico (Seção 22.5.4)

export async function findSubscriberByWhatsApp(db: Db, incomingWaId: string) {
  const e164 = incomingWaId.startsWith('+') ? incomingWaId : `+${incomingWaId}`;

  // Nenhuma comparação abaixo toca a coluna cifrada. Toda busca é por índice cego.

  // 1. wa_id exato — o caminho mais rápido e o mais confiável.
  const byWaId = await db.subscriber.findFirst({
    where: { waIdHmac: blindIndex(incomingWaId), deletedAt: null },
  });
  if (byWaId) return { subscriber: byWaId, matchedBy: 'wa_id' as const };

  // 2. phone_e164 exato.
  const byPhone = await db.subscriber.findFirst({
    where: { phoneHmac: blindIndex(e164), deletedAt: null },
  });
  if (byPhone) return { subscriber: byPhone, matchedBy: 'phone_exact' as const };

  // 3. variante SEM o nono dígito (o número cadastrado tem 9, a Meta mandou sem).
  const stripped = withoutNinthDigit(e164);
  if (stripped) {
    const bySt = await db.subscriber.findFirst({
      where: { phoneHmac: blindIndex(stripped), deletedAt: null },
    });
    if (bySt) return { subscriber: bySt, matchedBy: 'without_ninth' as const };
  }

  // 4. variante COM o nono dígito (a Meta mandou sem, nós guardamos com).
  const added = withNinthDigit(e164);
  if (added) {
    const byAd = await db.subscriber.findFirst({
      where: { phoneHmac: blindIndex(added), deletedAt: null },
    });
    if (byAd) return { subscriber: byAd, matchedBy: 'with_ninth' as const };
  }

  return { subscriber: null, matchedBy: 'none' as const };
}

Regras que acompanham a cascata:

  1. Auto-cura: quando o resultado vem de without_ninth ou with_ninth, o sistema grava imediatamente wa_id (cifrado) e wa_id_hmac naquele assinante. A próxima busca acerta no passo 1. Um log info com matchedBy registra a correção. A auto-cura só é silenciosa quando wa_id_hmac estava nulo; se havia outro valor, aplica-se a regra de chip reciclado de 11.4.3 — sessões revogadas e reverificação obrigatória.
  2. matchedBy é registrado em inbound_messages (Seção 6) para permitir medir quantos contatos precisaram de cascata. Se a taxa passar de 5% ao mês, é sinal de problema na normalização de entrada e gera alerta (Seção 23).
  3. Se a cascata devolver none, a mensagem de entrada é persistida em inbound_messages com subscriber_id = NULL e ignorada pelo motor conversacional, exceto se o texto for uma palavra-chave de saída — nesse caso ela é respondida com a confirmação padrão para evitar reclamação de spam. Regra detalhada na Seção 19.
  4. A cascata nunca casa dois assinantes. Como phone_hmac é único e as variantes são derivações determinísticas, o único cenário de ambiguidade é existirem simultaneamente +5511912345678 e +551112345678 no banco. Isso é impedido na escrita: o cadastro sempre canoniza para a forma com nono dígito (11.4.2), então a forma sem nove nunca gera phone_hmac.

11.5 Etapa 2 — Verificação por OTP no WhatsApp #

O mecanismo do código (geração, hash, TTL de 10 minutos, 5 tentativas, 3 pedidos por hora por número, template codigo_acesso_v1 de categoria AUTHENTICATION, fallback por e-mail) é canônico na Seção 8.2. Esta seção especifica a experiência.

11.5.1 A tela /cadastro/codigo #

Elementos, em ordem:

  1. Título: "Digite o código que enviamos".
  2. Subtítulo com o número mascarado: (11) 9****-5678. A máscara mostra DDD e os quatro últimos dígitos.
  3. Link "Número errado? Corrigir" → volta para a Etapa 1 com os campos preservados e invalida o OTP em aberto (endpoint em 11.12.5).
  4. Campo de código: seis caixas de um dígito (<input inputMode="numeric" autoComplete="one-time-code" maxLength={1}> × 6) agrupadas em um role="group" aria-label="Código de 6 dígitos".
    • Colar o código completo em qualquer caixa distribui os dígitos automaticamente.
    • Backspace em caixa vazia move o foco para a anterior.
    • Setas esquerda/direita navegam.
    • Ao preencher a sexta caixa, o formulário submete sozinho.
    • Em leitores de tela, o rótulo do grupo e o aria-describedby do erro cobrem o conjunto; cada caixa tem aria-label="Dígito 1 de 6".
  5. Contador regressivo de validade: "O código expira em 09:47". Atualiza a cada segundo, dentro de aria-live="off"; aos 60 segundos restantes, um aria-live="polite" anuncia uma única vez "O código expira em um minuto".
  6. Botão "Reenviar código", desabilitado por 60 segundos após cada envio, com contador "Reenviar em 47s". Depois do terceiro envio na hora, o botão fica desabilitado com o texto "Limite de envios atingido. Tente novamente em X minutos."
  7. Link "Não recebi no WhatsApp" → abre um bloco explicativo com três verificações (número correto, WhatsApp instalado, checar arquivadas) e, se houver e-mail verificado no cadastro, o botão "Receber por e-mail" (fallback da Seção 8).

11.5.2 Estados da tela #

Estado Gatilho UI
awaiting Entrada na tela Caixas vazias, foco na primeira, contador correndo.
submitting Sexta caixa preenchida Caixas desabilitadas, spinner sobre o botão.
invalid 422 OTP_INVALID Caixas em vermelho, tremor de 200 ms (desligado sob prefers-reduced-motion), mensagem "Código incorreto. Você tem N tentativas." Caixas são limpas e o foco volta à primeira.
expired 410 OTP_EXPIRED Mensagem "Esse código expirou." + botão "Enviar novo código" habilitado imediatamente.
locked 429 OTP_ATTEMPTS_EXCEEDED Caixas desabilitadas, mensagem "Muitas tentativas. Pedimos um novo código para você." e um novo OTP é enviado automaticamente após 60 s, se ainda houver cota horária. Se não houver: "Tente novamente às 15:20."
rate_limited 429 OTP_RATE_LIMITED no reenvio Botão desabilitado com horário exato de liberação.
success 200 Transição imediata para /cadastro/confirmacao.

Contagem de tentativas exibida ao usuário: mostra o restante, nunca o total. Após a segunda falha, a mensagem acrescenta "Confira se você está lendo o código mais recente."

11.5.3 O que acontece no sucesso #

Em uma única transação:

  1. subscribers.status passa de PENDING_VERIFICATION para VERIFIED_PENDING_OPTIN.
  2. subscribers.phone_verified_at recebe now().
  3. otp_codes do número são invalidados (consumed_at preenchido no usado, os demais marcados como superseded).
  4. Uma sessions é criada e os cookies __Host-session e __Host-refresh são emitidos, ambos com Path=/ (D11.3, mecânica na Seção 8).
  5. O envio da mensagem de boas-vindas é enfileirado na fila send.dispatch (Etapa 3). A lista de filas é canônica na Seção 18.
  6. Um evento otp_verified é logado com subscriberId e o anonymousId recebido (Seção 9.13.3).

11.6 Etapa 3 — Boas-vindas e confirmação ativa de opt-in #

11.6.1 A mensagem no WhatsApp #

Enviada pelo template boas_vindas_v1 (categoria UTILITY), um dos oito templates canônicos da Seção 17.5. O texto aprovado é propriedade da Seção 10 e o catálogo de mensagens é a Seção 19. A estrutura fixa é:

  • Header TEXT estático.
  • Body com um parâmetro: {{1}} = primeiro nome do assinante (extraído de name até o primeiro espaço, truncado em 20 caracteres, sanitizado para não conter quebra de linha nem 4 espaços consecutivos).
  • Footer estático com a instrução de saída.
  • Dois botões QUICK_REPLY: Quero receber (payload OPTIN_CONFIRM) e Agora não (payload OPTIN_DECLINE).

Alternativas aceitas para confirmar, todas equivalentes:

Entrada do assinante Efeito
Toque no botão Quero receber Opt-in confirmado.
Texto SIM, S, QUERO, CONFIRMO, OK (sem acento, sem caixa) Opt-in confirmado.
Toque no botão Agora não Nada muda; estado segue VERIFIED_PENDING_OPTIN. Resposta curta explicando que ele pode confirmar quando quiser.
Palavra-chave de saída (SAIR, PARAR, ...) Vai direto para OPTED_OUT.
Qualquer outro texto Resposta padrão pedindo a confirmação, no máximo uma vez a cada 6 horas para não virar loop.

11.6.2 O que acontece na confirmação #

Em uma única transação:

  1. subscribers.opt_in_confirmed_at = now().
  2. subscribers.status = 'ACTIVE_FREE' (ou ACTIVE_PAID, se o pagamento já tiver sido confirmado antes do opt-in — cenário raro, mas possível quando alguém paga rápido e confirma depois; a regra é: o estado reflete o tier corrente).
  3. subscribers.service_window_expires_at = now() + 24h — porque o toque no botão é uma mensagem de entrada e abre a janela de atendimento (Seção 17).
  4. Registro em consent_events com type = 'OPT_IN_WHATSAPP', channel = 'WHATSAPP', policy_version vigente, e o wa_id de origem. Este é o segundo dos dois registros exigidos por D11.2.
  5. wa_id e wa_id_hmac são gravados se ainda estiverem nulos (11.4.3).
  6. Avaliação da regra welcome_backfill (11.10).
  7. Evento optin_confirmed logado.

11.6.3 A tela de espera na web #

A tela /cadastro/confirmacao fica visível enquanto o assinante ainda não confirmou:

  • Título: "Confirme no WhatsApp para começar".
  • Instrução com o número mascarado e o texto exato do botão que ele deve tocar.
  • Botão primário "Abrir o WhatsApp" (https://wa.me/<PHONE_NUMBER> — abre a conversa com o número do negócio).
  • Botão secundário "Reenviar mensagem", desabilitado por 60 s, limitado a 3 reenvios com intervalo mínimo de 1 hora entre eles.
  • Polling de GET /api/signups/current a cada 5 segundos nos primeiros 2 minutos e a cada 15 segundos depois disso, com parada total em 10 minutos e um botão "Verificar agora". Sem WebSocket, coerente com a decisão da Seção 14.11.
  • Quando o polling detecta optInConfirmed: true, a tela transiciona para a Etapa 4.
  • Link discreto "Pular por enquanto" → leva direto ao painel (/app), que mostra um aviso persistente de que o opt-in ainda não foi confirmado.

11.6.4 Expiração do opt-in #

Se o assinante não confirmar em 7 dias, o job diário de manutenção — fila maintenance.cleanup, Seção 18 — move o registro para OPTED_OUT com opt_out_reason = 'OPTIN_EXPIRED'. Ele não é apagado: o número fica bloqueado para novo cadastro e o caminho de volta é VOLTAR no WhatsApp ou o login no painel, que reoferece a confirmação. A mensagem de boas-vindas não é reenviada automaticamente depois da expiração.

11.7 Etapa 4 — Continuar gratuito ou assinar #

Rota: /cadastro/plano. Exige sessão válida. Exibida logo após a confirmação de opt-in e acessível depois pelo painel.

Conteúdo:

  1. Confirmação positiva: "Pronto. Você já está recebendo." com o resumo do que o plano gratuito entrega, derivado da matriz de entitlements (Seção 13), nunca escrito à mão.
  2. <PlanComparison> (o mesmo componente da Seção 9.4), com defaultCycle vindo do parâmetro plan da URL quando ele existir. O cartão "Plano Gratuito" exibido aqui é o item sintético montado pelo handler (Seção 9.16.2): ele não existe na tabela plans, porque ser gratuito é a ausência de assinatura vigente (Seção 13.1).
  3. Dois caminhos:
    • "Continuar no plano gratuito" → navega para /app com um toast de boas-vindas. Nenhuma escrita no banco: o assinante já está ACTIVE_FREE.
    • "Assinar o plano completo" → handoff para o checkout, que é território da Seção 12. O destino é /app/assinatura/checkout?plan=<code>, e é lá que o CPF é pedido (D11.6).
  4. Aviso de transparência abaixo dos botões: renovação automática, cancelamento a qualquer momento, sem multa. Link para /cancelamento.

Se o assinante chegou com plan=plan_annual na URL desde o formulário de captura da landing (Seção 9.7.3), o cartão anual vem pré-selecionado e o botão principal já diz "Assinar o plano anual". A escolha nunca é aplicada automaticamente: sempre exige clique.

11.8 Estados e transições do assinante #

Enum SubscriberStatus, coluna subscribers.status (Seção 6 é a dona da definição física). O enum tem sete valores, e apenas estes: PENDING_VERIFICATION, VERIFIED_PENDING_OPTIN, ACTIVE_FREE, ACTIVE_PAID, PAUSED, OPTED_OUT, BLOCKED.

A eliminação não é um status. Conta excluída é deleted_at IS NOT NULL. Essa coluna vence qualquer valor de status em toda leitura de fluxo, e não existe o valor DELETED no enum. Escrever status = 'DELETED' é erro de implementação.

Invariantes obrigatórias, garantidas por CHECK no banco e por teste de propriedade:

status = 'ACTIVE_FREE'  ⇔  tier = 'FREE'  AND opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND blocked_at IS NULL AND (paused_until IS NULL OR paused_until <= now()) AND deleted_at IS NULL
status = 'ACTIVE_PAID'  ⇔  tier = 'PAID'  AND opt_in_confirmed_at IS NOT NULL AND opt_out_at IS NULL AND blocked_at IS NULL AND (paused_until IS NULL OR paused_until <= now()) AND deleted_at IS NULL
status = 'PAUSED'       ⇔  paused_until > now() AND opt_out_at IS NULL AND blocked_at IS NULL AND deleted_at IS NULL
status = 'OPTED_OUT'    ⇔  opt_out_at IS NOT NULL AND blocked_at IS NULL AND deleted_at IS NULL
status = 'BLOCKED'      ⇔  blocked_at IS NOT NULL AND opt_out_at IS NULL AND deleted_at IS NULL

PAUSED é derivado de paused_until e volta sozinho: quando paused_until <= now(), o assinante é novamente ACTIVE_FREE ou ACTIVE_PAID conforme o tier corrente, sem que nenhum job precise ter rodado. O tier não muda durante a pausa e a cobrança não é alterada (Seção 20.6).

11.8.1 Tabela de transições permitidas #

De Para Gatilho Efeitos colaterais
PENDING_VERIFICATION POST /api/signups Cria subscribers, cria consent_events (web), enfileira OTP.
PENDING_VERIFICATION PENDING_VERIFICATION Reenvio de OTP ou correção de número Novo otp_codes; se o número mudou, atualiza phone_e164 e reinicia a cota horária.
PENDING_VERIFICATION VERIFIED_PENDING_OPTIN OTP correto Cria sessão, marca phone_verified_at, enfileira boas-vindas.
PENDING_VERIFICATION (removido) Job de limpeza após 24 h Hard delete. Libera o número.
VERIFIED_PENDING_OPTIN ACTIVE_FREE Botão Quero receber ou SIM opt_in_confirmed_at, janela de 24 h, consent_events (WhatsApp), avaliação de welcome_backfill.
VERIFIED_PENDING_OPTIN ACTIVE_PAID Idem, com pagamento já confirmado Mesmos efeitos; tier já era PAID.
VERIFIED_PENDING_OPTIN OPTED_OUT Palavra-chave de saída, ou 7 dias sem confirmar opt_out_at, opt_out_reason.
ACTIVE_FREE ACTIVE_PAID Webhook PAYMENT_CONFIRMED/PAYMENT_RECEIVED (Seção 12) tier = PAID na mesma transação da assinatura.
ACTIVE_PAID ACTIVE_FREE PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED, PAYMENT_CHARGEBACK_REQUESTED, ou fim do período pago após cancelamento tier = FREE imediatamente, sem carência (Seção 13). O tier é relido no momento do disparo de cada mensagem, então a revogação alcança inclusive um lote já planejado (Seção 18.5).
ACTIVE_FREE | ACTIVE_PAID PAUSED Pausa de 1 a 30 dias pelo painel (Seção 14.7.2) ou PAUSAR no WhatsApp (Seção 20.6) paused_until; envios param; tier e cobrança inalterados; consent_events com type = 'PAUSE_STARTED'.
PAUSED ACTIVE_FREE | ACTIVE_PAID paused_until <= now(), ou "Retomar agora" no painel paused_until = NULL; volta ao estado correspondente ao tier; consent_events com type = 'PAUSE_ENDED'.
ACTIVE_FREE | ACTIVE_PAID | PAUSED OPTED_OUT Palavra-chave de saída, ou opt-out no painel opt_out_at; envios param na hora; a cobrança do ciclo seguinte é suspensa e, sem reativação em 30 dias, a assinatura é cancelada ao fim do período pago (Seção 13.4.6).
OPTED_OUT ACTIVE_FREE VOLTAR ou reativação no painel, com tier = FREE opt_out_at = NULL, novo consent_events com type = 'RE_OPT_IN'.
OPTED_OUT ACTIVE_PAID Idem, com tier = PAID Mesmos efeitos.
ACTIVE_FREE | ACTIVE_PAID | PAUSED | OPTED_OUT BLOCKED Erro 131026 da Meta em 3 dias consecutivos, bloqueio pelo assinante detectado por webhook, ou bloqueio administrativo Grava blocked_at e blocked_reason; envios cessam; cobrança não é alterada.
BLOCKED ACTIVE_FREE | ACTIVE_PAID Qualquer mensagem recebida do assinante, ou desbloqueio administrativo blocked_at = NULL, blocked_reason = NULL; volta ao tier resolvido por resolveEntitlements.
VERIFIED_PENDING_OPTIN | ACTIVE_FREE | ACTIVE_PAID | PAUSED | OPTED_OUT | BLOCKED deleted_at preenchido Exclusão por solicitação LGPD (Seção 14.8.2) ou por administrador (Seção 15.7.3) deleted_at; envios param; assinatura ativa é cancelada na Asaas; pseudonimização em até 30 dias, alcançando todas as tabelas com identificador pessoal, inclusive os registros de mensagem (Seção 22.9). O status não vira DELETED: ele permanece com o último valor válido e passa a ser irrelevante.
deleted_at preenchido qualquer Proibido. Terminal fora da janela de reversão de 72 h da Seção 14.8.2. Um novo cadastro com o mesmo número cria um registro novo; ver caso E3 em 11.11.

11.8.2 Transições proibidas e como o código as impede #

  • PENDING_VERIFICATION → ACTIVE_* sem passar por VERIFIED_PENDING_OPTIN: impossível, porque a única escrita de opt_in_confirmed_at exige phone_verified_at IS NOT NULL.
  • Qualquer transição a partir de uma conta com deleted_at IS NOT NULL: bloqueada por guarda na função applySubscriberTransition() e por WHERE deleted_at IS NULL em toda leitura de fluxo. A única exceção é a reversão administrativa dentro de 72 h (Seção 15.7.3), que zera deleted_at e restaura o status anterior.
  • Qualquer transição não listada em 11.8.1 lança IllegalStateTransitionError, que vira 409 ILLEGAL_STATE_TRANSITION na API e um log error com o par de estados.
  • A função de transição vive em packages/core/src/subscriber-state.ts e é a única autorizada a escrever status, tier, opt_in_confirmed_at, opt_out_at, paused_until, blocked_at e deleted_at. Regra de revisão de código: nenhum prisma.subscriber.update fora dela pode tocar essas sete colunas.

11.9 Onboarding pós-confirmação #

Sequência exata do que o assinante recebe no WhatsApp logo após confirmar. Os textos aprovados são propriedade da Seção 10; a ordem, os intervalos e as condições são desta seção.

# Quando Conteúdo Condição
1 Imediatamente (< 5 s) Confirmação de opt-in: "Tudo certo, {primeiro nome}." + o horário de envio (06:00, horário de Brasília) + a frequência do tier + como sair (SAIR). Sempre.
2 Imediatamente após a #1 welcome_backfill: o devocional do dia completo. Áudio junto, só para PAID. Só se a regra de 11.10 disser que sim.
3 +24 h, às 09:00 Dica de uso: como pedir o devocional a qualquer momento (HOJE), como pausar (PAUSAR), como ver o acervo (link para /app/acervo). Só se o assinante ainda estiver ativo e não tiver dado opt-out.
4 +3 dias, às 09:00 Só para ACTIVE_FREE: convite de upgrade, com um único CTA para /app/assinatura. Só para FREE; uma única vez na vida do assinante. Não é enviado se ele já tiver visitado o checkout.

Regras que protegem o assinante de excesso:

  • As mensagens 3 e 4 são enviadas como template se a janela de 24 h estiver fechada, e como texto livre se estiver aberta. O motor decide (Seção 18).
  • Nenhuma mensagem de onboarding é enviada entre 21:00 e 08:00, horário de Brasília. Se o gatilho cair nessa faixa, ela é adiada para as 09:00 seguintes.
  • Se o assinante der opt-out a qualquer momento, todas as mensagens de onboarding pendentes são canceladas na fila.
  • A expectativa de horário é declarada em texto: "Todo dia às 6h da manhã, horário de Brasília." Nunca "todo dia cedo".

11.10 Regra do welcome_backfill #

Enunciado. Quem confirma o opt-in depois das 06:00 do dia corrente recebe o devocional daquele dia imediatamente, uma única vez, e entra no ciclo normal no dia seguinte.

Algoritmo, executado dentro da transação de confirmação (11.6.2):

// packages/core/src/onboarding/welcome-backfill.ts
export function shouldSendWelcomeBackfill(input: {
  confirmedAtLocal: Date;          // já convertido para America/Sao_Paulo
  dailySendHour: number;           // 6
  todaysDevotional: { id: string; status: DevotionalStatus } | null;
  alreadyBackfilled: boolean;      // subscribers.welcome_backfill_sent_at IS NOT NULL
}): { send: boolean; reason: string } {
  if (input.alreadyBackfilled) return { send: false, reason: 'already_backfilled' };
  if (!input.todaysDevotional)  return { send: false, reason: 'no_devotional_today' };
  if (!['PUBLISHED', 'SENT'].includes(input.todaysDevotional.status)) {
    return { send: false, reason: 'devotional_not_published' };
  }
  if (input.confirmedAtLocal.getHours() < input.dailySendHour) {
    // Confirmou antes das 06:00: o envio normal do dia ainda vai acontecer.
    return { send: false, reason: 'before_daily_send' };
  }
  return { send: true, reason: 'ok' };
}

Regras complementares, todas obrigatórias:

  1. Uma vez na vida. A coluna subscribers.welcome_backfill_sent_at (Seção 6) é preenchida no envio. Reativação após opt-out não dá direito a novo backfill.
  2. Idempotência com o motor de envio. O backfill grava em delivery_attempts a mesma chave única do envio diário, send:{subscriberId}:{devotionalDate}, com reason = 'WELCOME_BACKFILL'. Se o motor diário tentar enviar o mesmo devocional para o mesmo assinante mais tarde, o índice único bloqueia e o envio é pulado. Isso resolve sozinho o caso de alguém confirmar às 05:58 e o lote das 06:00 pegá-lo.
  3. FREE também recebe (D11.5), mesmo em dia que não é domingo, e mesmo que o próximo envio regular dele seja só no domingo seguinte. O backfill não conta como o envio semanal, exceto se cair em um domingo — nesse caso ele é o envio da semana, pela idempotência da chave.
  4. Conteúdo por tier. FREE recebe texto completo. PAID recebe texto completo + áudio. A decisão vem de resolveEntitlements() (Seção 13), nunca de condicional local.
  5. Janela aberta garantida. O toque no botão de confirmação é uma mensagem de entrada, então a janela de 24 h está aberta e o backfill vai como texto livre, sem custo de template e sem limite de 1024 caracteres.
  6. Faixa noturna. O backfill ignora a restrição de 21:00–08:00 da Seção 11.9, porque foi o próprio assinante que acabou de agir. Ele pediu; ele recebe.
  7. Sem devocional publicado. Se todaysDevotional for nulo ou não publicado, o assinante recebe apenas a mensagem #1 do onboarding e o primeiro devocional chega no ciclo normal. Nada de erro visível.

Exemplos concretos:

Cenário Confirmação Tier Resultado
Cadastro na terça 14:30 14:32 FREE Recebe o devocional de terça na hora. Próximo envio: domingo 06:00.
Cadastro na terça 05:20 05:22 PAID Não recebe backfill. Recebe o envio normal às 06:00, com áudio.
Cadastro no domingo 10:00 10:03 FREE Recebe o devocional de domingo na hora. Próximo envio: domingo seguinte.
Cadastro na quinta 23:50 23:52 PAID Recebe texto + áudio na hora, apesar do horário.
Reativação depois de opt-out, sexta 15:00 15:00 PAID Não recebe backfill (already_backfilled). Volta ao ciclo no sábado 06:00.
Cadastro na quarta 08:00, sem devocional publicado para quarta 08:01 PAID Só a mensagem de confirmação. Primeiro devocional: quinta 06:00.

11.11 Casos de borda #

Cada caso tem gatilho, comportamento definido, resposta de API e o que o usuário vê.

E1 — Número já cadastrado e ativo (ACTIVE_FREE ou ACTIVE_PAID) O cadastro não cria duplicata e não revela publicamente que o número existe de forma explorável. Resposta 200 com { "data": { "next": "LOGIN" } } e a interface navega para /login?phone=<mascarado> com a mensagem "Esse número já tem cadastro. Vamos te enviar um código para entrar." Um OTP de login é enviado (mesma cota horária). O consentimento não é regravado. Justificativa: o número não é segredo — quem digita já sabe que é dele — mas transformar o cadastro em oráculo de "esse número é cliente?" é evitado ao usar exatamente a mesma latência e o mesmo formato de resposta do caminho normal.

E2 — Número já cadastrado e com opt-out (OPTED_OUT) Resposta 200 com { "data": { "next": "REACTIVATE" } }. Envia OTP. Depois de verificar, a tela mostra "Você tinha cancelado o recebimento em 12/03/2026. Quer voltar a receber?" com um botão de reativação que exige novo consentimento e grava consent_events com type = 'RE_OPT_IN'. Se havia assinatura paga ativa e não cancelada, a tela informa isso e o assinante volta direto para ACTIVE_PAID.

E3 — Número já cadastrado e excluído (deleted_at IS NOT NULL) O registro antigo é intocável. O novo cadastro cria um assinante novo, com novo ULID. Isso exige que o índice único de phone_hmac seja parcial: WHERE deleted_at IS NULL (a Seção 6 define assim). Nada do histórico anterior é restaurado, nem acervo, nem consentimento, nem assinatura. Um log info registra a recriação, correlacionando os dois ULIDs para auditoria, sem expor isso ao usuário.

E4 — OTP expirado 410 OTP_EXPIRED. A tela mostra "Esse código expirou." e habilita "Enviar novo código" imediatamente, ignorando o cooldown de 60 s (mas não a cota de 3 por hora). Se a cota estiver esgotada, mostra o horário exato de liberação.

E5 — Usuário abandona no meio Três subcasos:

  • Abandona antes de verificar: registro PENDING_VERIFICATION é apagado por hard delete em 24 h (D11.4). O número volta a estar livre.
  • Abandona depois de verificar e antes de confirmar: fica VERIFIED_PENDING_OPTIN. Nenhum devocional é enviado. Após 7 dias vira OPTED_OUT (11.6.4).
  • Abandona depois de confirmar, na Etapa 4: já é ACTIVE_FREE e recebe normalmente. Nenhuma ação necessária.

E6 — Usuário bloqueia o número do negócio no WhatsApp Detectado pelo código de erro 131026 ("Message undeliverable") ou por falhas consecutivas de entrega. Comportamento: após 3 falhas consecutivas com 131026 em dias diferentes, o assinante é movido para BLOCKED, com blocked_at = now() e blocked_reason = 'UNDELIVERABLE_131026' (transição de 11.8.1), e um e-mail é enviado (se houver e-mail verificado) explicando o que aconteceu e como voltar. BLOCKED tem saída: qualquer mensagem recebida do assinante, ou um desbloqueio administrativo, zera blocked_at e devolve o assinante ao tier corrente. Não se usa OPTED_OUT aqui, porque não houve manifestação de vontade do titular. Se ele tiver assinatura paga ativa, um alerta operacional é gerado (Seção 23) para atendimento humano, porque cobrar sem entregar é inaceitável. O tratamento completo dos códigos da Meta é canônico na Seção 27.

E7 — Número inválido ou DDI estrangeiro 422 VALIDATION_ERROR com details: [{ "field": "phone", "issue": "foreign_ddi" }]. A interface exibe a mensagem correspondente de PHONE_ERROR_MESSAGES (11.4.2). Para DDI estrangeiro, o texto acrescenta um link para /contato com o assunto pré-selecionado outro, para registrar interesse. Não há lista de espera no MVP.

E8 — E-mail já usado por outro assinante O e-mail não é identidade (a identidade é o telefone), então isso não bloqueia o cadastro. Comportamento: o cadastro prossegue, o e-mail é salvo como não verificado e nenhum e-mail é disparado para ele. Um alerta interno de baixa severidade é logado. Se o assinante quiser usar aquele e-mail como fallback de login (Seção 8), precisa verificá-lo em /app/perfil — e a verificação falha com 409 EMAIL_ALREADY_IN_USE enquanto o outro assinante o mantiver verificado. Justificativa: famílias compartilham e-mail, e bloquear o cadastro por isso perde assinante sem ganho de segurança.

E9 — Corrida entre duas abas Cenário: a pessoa abre /cadastro em duas abas e submete nas duas com o mesmo número.

  • A criação usa INSERT ... ON CONFLICT (phone_hmac) WHERE deleted_at IS NULL DO NOTHING seguido de leitura. A segunda aba encontra o registro existente em PENDING_VERIFICATION e reaproveita, sem erro.
  • O OTP é único por número: a segunda submissão não gera código novo se houver um ativo com menos de 60 s de vida; ela devolve 200 com otpAlreadySent: true e o mesmo expiresAt. Assim, o código que chegou no WhatsApp continua válido, evitando o clássico "recebi dois códigos e nenhum funciona".
  • Se a primeira aba verificar e a segunda tentar verificar com o mesmo código já consumido, a segunda recebe 409 OTP_ALREADY_USED e a interface simplesmente navega para o estado atual do cadastro, sem mensagem de erro.
  • Bloqueio pessimista não é usado. A garantia vem do índice único e do ON CONFLICT.

E10 — Número com WhatsApp Business de outra empresa Não é distinguível pela API e não é tratado de forma especial. Se a entrega falhar, cai em E6.

E11 — Nome com caracteres inválidos Emoji, sequências de zero-width e caracteres de controle são removidos silenciosamente na normalização. Se, depois da remoção, sobrar menos de 2 caracteres, retorna 422 VALIDATION_ERROR com a mensagem de nome curto. Dígitos e URLs não são removidos: eles disparam erro de validação, porque quase sempre indicam preenchimento automático de robô.

E12 — Consentimento marcado e depois desmarcado antes do submit O submit é bloqueado no cliente e, se burlado, no servidor com 422 CONSENT_REQUIRED. Nenhum registro é criado. Nenhum OTP é enviado.

E13 — Telefone correto, mas a pessoa não tem WhatsApp O envio do OTP falha com erro da Meta indicando destinatário inválido. Após a primeira falha, a tela de código mostra um aviso adicional: "Não conseguimos enviar pelo WhatsApp. Confira se esse número tem WhatsApp ativo." e oferece "Corrigir número". Se o cadastro tinha e-mail, oferece o fallback por e-mail (Seção 8). O registro fica PENDING_VERIFICATION e é limpo em 24 h se nada acontecer.

E14 — Relógio do cliente adiantado ou atrasado O contador de expiração do OTP é calculado a partir do expiresAt retornado pelo servidor menos o horário do servidor recebido no mesmo payload, aplicado ao relógio local como um delta. Nunca comparar expiresAt com Date.now() diretamente. Isso evita contador negativo em dispositivos com relógio errado.

E15 — Assinante confirma opt-in duas vezes (toque duplo no botão) A segunda confirmação é idempotente: opt_in_confirmed_at só é escrito se estiver nulo. Nenhum consent_events duplicado é criado (índice único em (subscriber_id, type, policy_version) para os tipos de opt-in). Nenhum backfill duplicado é enviado (welcome_backfill_sent_at).

E16 — Cadastro durante janela de manutenção /cadastro responde 503 com a página de manutenção (Seção 9.15.3). Nenhum estado parcial é criado. Webhooks continuam sendo aceitos, então quem já estava no meio do fluxo consegue confirmar o opt-in pelo WhatsApp normalmente e o processamento acontece quando a manutenção termina.

11.12 Endpoints do cadastro #

Todos seguem o envelope de sucesso e de erro da Seção 7. Todos ecoam X-Request-Id.

11.12.1 POST /api/signups #

  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Idempotência: sim, por número. Reenvio com o mesmo phone em PENDING_VERIFICATION reaproveita o registro e, se houver OTP ativo com menos de 60 s, não gera outro (caso E9).
  • Efeitos colaterais: pode criar subscribers, cria consent_events, cria otp_codes, enfileira o envio do código na fila send.dispatch (Seção 18).

Request (schema signupSchema de 11.3.3):

{
  "name": "Maria das Graças",
  "phone": "(11) 91234-5678",
  "email": "maria@exemplo.com.br",
  "consent": true,
  "source": "hero",
  "planCode": "plan_annual",
  "turnstileToken": "0.abc..."
}

Resposta 200:

{
  "data": {
    "next": "VERIFY_OTP",
    "signupId": "sgn_01HZXB1C3D5E7F9G1H3J5K7M9N",
    "phoneMasked": "(11) 9****-5678",
    "otpAlreadySent": false,
    "otpExpiresAt": "2026-08-25T12:41:00.000Z",
    "resendAvailableAt": "2026-08-25T12:32:00.000Z",
    "serverTime": "2026-08-25T12:31:00.000Z"
  },
  "meta": { "requestId": "req_01HZXB2D4E6F8G0H2J4K6L8M0N", "timestamp": "2026-08-25T12:31:00.000Z" }
}

next pode ser VERIFY_OTP (fluxo normal), LOGIN (caso E1) ou REACTIVATE (caso E2). serverTime existe para o cálculo de delta do caso E14.

Erros:

HTTP code Quando
422 VALIDATION_ERROR Qualquer falha de signupSchema; details traz field e issue.
422 CONSENT_REQUIRED consent diferente de true.
422 HONEYPOT_TRIGGERED Campo company preenchido.
422 CAPTCHA_FAILED Turnstile inválido.
429 RATE_LIMITED Mais de 5 cadastros por IP por hora.
429 OTP_RATE_LIMITED Mais de 3 pedidos de OTP para o número na hora, contados antes de qualquer consulta ao banco; details traz retryAfterSeconds.
503 SERVICE_BUSY Cota global de OTP estourada.
502 WHATSAPP_SEND_FAILED A Meta rejeitou o envio do OTP de forma definitiva (caso E13).

11.12.2 POST /api/signups/verifications #

  • Autenticação: nenhuma (é o que cria a sessão). Papel exigido: nenhum.
  • Idempotência: o código é de uso único. Reenvio do mesmo código já consumido devolve 409 OTP_ALREADY_USED.
  • Efeitos colaterais: transição de estado, criação de sessions, emissão dos cookies de sessão, enfileiramento da mensagem de boas-vindas na fila send.dispatch.

Request:

export const verifySignupSchema = z.object({
  signupId: z.string().length(30).startsWith('sgn_'),
  code: z.string().regex(/^\d{6}$/, 'O código tem 6 dígitos.'),
});

Resposta 200:

{
  "data": {
    "next": "AWAIT_OPTIN",
    "subscriberId": "sub_01HZXB3E5F7G9H1J3K5L7M9N1P",
    "status": "VERIFIED_PENDING_OPTIN",
    "whatsappDeepLink": "https://wa.me/5511999999999",
    "welcomeSentAt": "2026-08-25T12:33:10.000Z"
  },
  "meta": { "requestId": "req_01HZXB4F6G8H0J2K4L6M8N0P2Q", "timestamp": "2026-08-25T12:33:10.000Z" }
}

Os cookies __Host-session e __Host-refresh acompanham a resposta, ambos com Path=/ (Seção 8).

Erros:

HTTP code Quando Efeito adicional
422 VALIDATION_ERROR Código fora do formato
422 OTP_INVALID Código errado; details.remainingAttempts Incrementa tentativas.
410 OTP_EXPIRED Passou de 10 minutos Invalida o código.
409 OTP_ALREADY_USED Código já consumido Nenhum.
429 OTP_ATTEMPTS_EXCEEDED 5 tentativas erradas Invalida o código e agenda novo envio em 60 s.
404 SIGNUP_NOT_FOUND signupId inexistente ou já limpo pelo job de 24 h
409 ILLEGAL_STATE_TRANSITION Registro não está em PENDING_VERIFICATION

11.12.3 POST /api/signups/otp-resends #

  • Autenticação: nenhuma. Papel exigido: nenhum.
  • Idempotência: não. Cada chamada bem-sucedida gera um código novo e invalida o anterior.
  • Efeitos colaterais: cria otp_codes, enfileira envio, consome cota horária.

Request: { "signupId": "sgn_01H..." }.

Resposta 200:

{
  "data": {
    "otpExpiresAt": "2026-08-25T12:45:00.000Z",
    "resendAvailableAt": "2026-08-25T12:36:00.000Z",
    "remainingResendsThisHour": 1,
    "serverTime": "2026-08-25T12:35:00.000Z"
  },
  "meta": { "requestId": "req_01HZXB5G7H9J1K3L5M7N9P1Q3R", "timestamp": "2026-08-25T12:35:00.000Z" }
}

Erros: 404 SIGNUP_NOT_FOUND, 429 OTP_COOLDOWN (antes dos 60 s, com details.retryAfterSeconds), 429 OTP_RATE_LIMITED (cota horária), 409 ILLEGAL_STATE_TRANSITION, 502 WHATSAPP_SEND_FAILED.

11.12.4 GET /api/signups/current #

  • Autenticação: sessão do assinante ou signupId em query, aceito apenas enquanto o cadastro estiver em PENDING_VERIFICATION ou VERIFIED_PENDING_OPTIN.
  • Papel exigido: SUBSCRIBER quando autenticado por sessão.
  • Idempotência: leitura pura.
  • Cache: no-store.
  • Uso: polling da tela de espera (11.6.3).

Resposta 200:

{
  "data": {
    "status": "VERIFIED_PENDING_OPTIN",
    "phoneMasked": "(11) 9****-5678",
    "optInConfirmed": false,
    "welcomeSentAt": "2026-08-25T12:33:10.000Z",
    "welcomeResendAvailableAt": "2026-08-25T13:33:10.000Z",
    "remainingWelcomeResends": 2,
    "tier": "FREE",
    "nextStep": "AWAIT_OPTIN",
    "serverTime": "2026-08-25T12:34:20.000Z"
  },
  "meta": { "requestId": "req_01HZXB6H8J0K2L4M6N8P0Q2R4S", "timestamp": "2026-08-25T12:34:20.000Z" }
}

Quando o opt-in é confirmado, optInConfirmed vira true, status vira ACTIVE_FREE ou ACTIVE_PAID e nextStep vira CHOOSE_PLAN.

Erros: 401 UNAUTHENTICATED, 404 SIGNUP_NOT_FOUND, 429 RATE_LIMITED (acima de 30 chamadas por minuto por signupId, que já é o dobro do necessário para o polling especificado).

11.12.5 POST /api/signups/phone-corrections #

  • Autenticação: nenhuma; exige signupId válido em PENDING_VERIFICATION.
  • Idempotência: enviar o mesmo número novamente é no-op e devolve 200.
  • Efeitos colaterais: atualiza phone_e164, invalida todos os otp_codes do número antigo, gera novo OTP para o novo número, consome cota horária do novo número.

Request:

export const phoneCorrectionSchema = z.object({
  signupId: z.string().length(30).startsWith('sgn_'),
  phone: phoneInputSchema,
});

Resposta 200: mesmo formato de POST /api/signups, com next: "VERIFY_OTP".

Erros: 422 VALIDATION_ERROR, 404 SIGNUP_NOT_FOUND, 409 ILLEGAL_STATE_TRANSITION (já verificado), 409 PHONE_ALREADY_REGISTERED (o novo número já pertence a um assinante ativo — a interface então oferece o caminho de login do caso E1), 429 OTP_RATE_LIMITED, 429 PHONE_CORRECTION_LIMIT (máximo de 3 correções por signupId).

11.12.6 POST /api/signups/welcome-resends #

  • Autenticação: sessão do assinante. Papel exigido: SUBSCRIBER.
  • Idempotência: não; cada chamada envia de novo, respeitando o limite.
  • Efeitos colaterais: enfileira a mensagem de boas-vindas na fila send.dispatch.
  • Limites: mínimo de 60 minutos entre reenvios, máximo de 3 reenvios por assinante.

Resposta 200:

{
  "data": { "sentAt": "2026-08-25T13:35:00.000Z", "remainingResends": 1,
            "nextResendAvailableAt": "2026-08-25T14:35:00.000Z" },
  "meta": { "requestId": "req_01HZXB7J9K1L3M5N7P9Q1R3S5T", "timestamp": "2026-08-25T13:35:00.000Z" }
}

Erros: 401 UNAUTHENTICATED, 409 ILLEGAL_STATE_TRANSITION (opt-in já confirmado), 429 WELCOME_RESEND_COOLDOWN, 429 WELCOME_RESEND_LIMIT, 502 WHATSAPP_SEND_FAILED.

11.12.7 Endpoints referenciados, não definidos aqui #

Endpoint Dono
POST /api/auth/otp/verify (login por OTP) Seção 8
POST /api/auth/logout (sair) Seção 8
POST /api/webhooks/whatsapp (recebe a confirmação de opt-in) Seção 17
POST /api/me/subscription (checkout) Seção 12

12. Integração de Pagamentos — Asaas #

Esta seção é a dona de tudo que toca a Asaas: autenticação, cliente HTTP, chamadas de saída, checkout, webhooks, reconciliação, reembolso e chargeback. Nenhuma outra seção emite chamadas à Asaas diretamente; todas passam pelo cliente tipado descrito em 12.3. O ciclo de vida da assinatura e a matriz de entitlements que consomem estes eventos são da Seção 13. O envelope de sucesso/erro e o catálogo de códigos das rotas /api/* são da Seção 7. O schema das tabelas citadas aqui é da Seção 6.

12.1 Papel da Asaas no produto e escopo da integração #

A Asaas é o único provedor de pagamentos do produto. Ela é responsável por:

  • guardar o cadastro fiscal do pagador (customer);
  • manter a assinatura recorrente (subscription) e gerar uma cobrança (payment) por ciclo;
  • capturar o cartão de crédito e renovar a cobrança automaticamente;
  • emitir e liquidar cobranças PIX;
  • notificar mudanças de estado por webhook;
  • ser a fonte da verdade financeira.

O sistema não implementa: cálculo de imposto, antifraude próprio, carteira, saldo, transferências, split de recebíveis ou emissão fiscal. Ver 12.16.

Três invariantes governam a integração inteira:

  1. A Asaas vence. Sempre que o nosso banco e a Asaas discordarem sobre o estado de uma cobrança ou assinatura, o estado da Asaas é copiado para o nosso banco, nunca o contrário. A reconciliação de 12.13 existe exatamente para isso.
  2. Nenhum efeito de negócio acontece no handler do webhook. O handler persiste e enfileira. O efeito acontece no worker, de forma idempotente. Ver 12.10 e 12.12.
  3. Não há período de carência. A revogação de acesso é imediata e ocorre na mesma transação do processamento do evento. A regra completa, com a distinção entre inadimplência e cancelamento voluntário, está na Seção 13.3 e 13.4.

12.2 Ambientes, base URLs, autenticação e variáveis #

Ambiente Base URL da API Painel Chave usada
local https://api-sandbox.asaas.com/v3 sandbox ASAAS_API_KEY (sandbox)
staging https://api-sandbox.asaas.com/v3 sandbox ASAAS_API_KEY (sandbox)
production https://api.asaas.com/v3 produção ASAAS_API_KEY (produção)

A base URL nunca é escrita inline no código. Ela vem de ASAAS_API_BASE_URL, e o registro completo das variáveis de ambiente é da Seção 26. As variáveis próprias desta seção:

Variável Exemplo Obrigatória Significado
ASAAS_API_BASE_URL https://api-sandbox.asaas.com/v3 sim Base da API v3
ASAAS_API_KEY $aact_... sim Chave privada, header access_token
ASAAS_WEBHOOK_TOKEN string aleatória de 48 bytes sim Segredo do header asaas-access-token
ASAAS_TIMEOUT_MS 10000 não (default 10000) Timeout por tentativa
ASAAS_MAX_RETRIES 4 não (default 4) Tentativas extras após a primeira
ASAAS_RATE_LIMIT_RPS 8 não (default 8) Teto local de requisições por segundo
PIX_CHARGE_LEAD_DAYS 3 não (default 3) Antecedência de geração da cobrança PIX

O corpo máximo aceito no webhook não é variável desta seção e não tem nome próprio de provedor: é WEBHOOK_MAX_BODY_BYTES, único para todas as rotas de webhook, registrado na Seção 26.3 e com o valor fixado na Seção 7.14. Não existe, e não pode ser criada, uma variável ASAAS_WEBHOOK_MAX_BODY_BYTES.

12.2.1 Autenticação #

Toda requisição de saída leva:

access_token: <ASAAS_API_KEY>
Content-Type: application/json
User-Agent: palavra-diaria/1.0 (+https://palavradiaria.com.br)

Regras duras:

  • ASAAS_API_KEY nunca aparece em log, em mensagem de erro, em resposta de API, em stack trace ou em artefato de build. O redator de logs da Seção 23 mascara qualquer valor que case com /\$?aact_[A-Za-z0-9+/=_-]+/ e qualquer header cujo nome case, sem diferenciar maiúsculas, com access_token, authorization ou asaas-access-token.
  • A chave de produção só existe no ambiente production. Um teste de fumaça no boot chama GET /v3/customers?limit=1; se o ambiente for production e ASAAS_API_BASE_URL apontar para sandbox (ou vice-versa), o processo aborta com FATAL asaas_environment_mismatch.
  • A chave é rotacionável sem deploy: ela é lida do ambiente a cada boot e o container web e o container worker são reiniciados em sequência.

12.2.2 Verificação de ambiente no boot #

// packages/integrations/src/asaas/assert-environment.ts
const SANDBOX_HOST = 'api-sandbox.asaas.com';
const PRODUCTION_HOST = 'api.asaas.com';

export function assertAsaasEnvironment(appEnv: 'local' | 'staging' | 'production', baseUrl: string): void {
  const host = new URL(baseUrl).host;
  const expected = appEnv === 'production' ? PRODUCTION_HOST : SANDBOX_HOST;
  if (host !== expected) {
    throw new Error(`asaas_environment_mismatch: appEnv=${appEnv} host=${host} expected=${expected}`);
  }
}

12.3 Cliente HTTP, limites de taxa e política de retry #

O cliente vive em packages/integrations/src/asaas/ e expõe métodos tipados (createCustomer, createSubscription, getPixQrCode, refundPayment, …). Nenhum fetch direto para a Asaas é permitido fora deste pacote; a regra é verificada por um lint customizado descrito na Seção 5.

12.3.1 Limites de taxa #

A Asaas aplica limitação por conta e responde 429 Too Many Requests quando o teto é excedido. O sistema não depende de descobrir o teto exato em tempo de execução: ele impõe um teto local mais conservador e trata 429 como sinal de recuo.

  • Teto local: ASAAS_RATE_LIMIT_RPS=8 requisições por segundo, aplicado por um token bucket compartilhado no Redis (chave rl:asaas), de modo que web e worker somados nunca ultrapassem o teto.
  • Concorrência máxima simultânea: 4 conexões (maxSockets: 4 no agente HTTP).
  • Jobs em lote (reconciliação de 12.13) usam um limitador do BullMQ com limiter: { max: 8, duration: 1000 }, o que garante o mesmo teto sem competir com o tráfego de checkout. O checkout não passa por fila: ele é síncrono, executado no processo web dentro da própria requisição do assinante, e por isso consome o token bucket com prioridade natural. As três filas de cobrança são billing.webhook, billing.reconcile e billing.lifecycle, exatamente com esses nomes, conforme o catálogo de filas da Seção 18.8; não existem filas billing.interactive, billing.batch nem billing.events.

12.3.2 Classificação de falhas #

Classe Condição Retry?
RETRIABLE_NETWORK ECONNRESET, ETIMEDOUT, EAI_AGAIN, ECONNREFUSED, abort por timeout sim
RETRIABLE_SERVER HTTP 500, 502, 503, 504 sim
RETRIABLE_THROTTLE HTTP 429 sim, respeitando Retry-After se presente
CLIENT_ERROR HTTP 400, 404, 422 não
AUTH_ERROR HTTP 401, 403 não, alerta imediato asaas_auth_failed
UNKNOWN qualquer outro status não

Requisições GET são idempotentes por natureza e podem ser repetidas sem cuidado extra. Requisições POST que criam objetos (customers, subscriptions, payments) só são repetidas quando a falha é RETRIABLE_NETWORK antes de resposta ou RETRIABLE_SERVER; nesses casos a repetição é precedida por uma checagem de existência via externalReference (ver 12.3.4), para não criar duplicata.

12.3.3 Backoff exponencial com jitter #

// packages/integrations/src/asaas/retry.ts
const BASE_DELAY_MS = 400;
const MAX_DELAY_MS = 15_000;

/** Full jitter: delay = random(0, min(MAX, BASE * 2^attempt)). */
export function backoffDelayMs(attempt: number, random: () => number = Math.random): number {
  const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
  return Math.floor(random() * ceiling);
}

export async function withRetry<T>(
  fn: (attempt: number) => Promise<T>,
  opts: { maxRetries: number; isRetriable: (e: unknown) => boolean; retryAfterMs?: (e: unknown) => number | null },
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
    try {
      return await fn(attempt);
    } catch (error) {
      lastError = error;
      if (attempt === opts.maxRetries || !opts.isRetriable(error)) throw error;
      const explicit = opts.retryAfterMs?.(error) ?? null;
      const delay = explicit ?? backoffDelayMs(attempt);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

Com ASAAS_MAX_RETRIES=4 e BASE_DELAY_MS=400, os tetos de espera por tentativa são 0–400 ms, 0–800 ms, 0–1600 ms e 0–3200 ms. O pior caso soma 6 s de espera sobre 5 tentativas de 10 s de timeout: 56 s no limite absoluto. Por isso o checkout interativo usa maxRetries: 2 (teto de 1,2 s de espera) e o processamento em fila usa o valor completo.

Quando 429 traz Retry-After em segundos, o valor é respeitado e limitado a 30 s. Se Retry-After ausente, aplica-se o jitter normal.

12.3.4 Idempotência das criações #

A Asaas não expõe um header de chave de idempotência. O sistema resolve isso com externalReference, que é sempre o ULID do nosso registro correspondente:

Objeto Asaas externalReference
customer subscribers.id
subscription subscriptions.id
payment avulso payments.id

Antes de repetir um POST de criação após falha ambígua, o cliente consulta GET /v3/{recurso}?externalReference={id}&limit=1. Se vier totalCount >= 1, adota o objeto existente e não cria outro. Esta checagem é obrigatória e está encapsulada em createIdempotent():

async function createIdempotent<T extends { id: string }>(
  resource: 'customers' | 'subscriptions' | 'payments',
  externalReference: string,
  body: unknown,
): Promise<T> {
  const existing = await client.get<{ data: T[]; totalCount: number }>(
    `/${resource}`, { externalReference, limit: 1 },
  );
  if (existing.totalCount >= 1) return existing.data[0];
  return client.post<T>(`/${resource}`, body);
}

12.3.5 Observabilidade do cliente #

Cada chamada emite um log estruturado asaas_request com requestId, method, path, attempt, status, durationMs, asaasErrorCode. O corpo da requisição é logado apenas em rotas sem dado sensível; as rotas de tokenização e de criação de assinatura com cartão têm o corpo suprimido por completo (ver 12.9). As métricas expostas ao Prometheus da Seção 23 são asaas_request_duration_seconds (histograma por path e status) e asaas_request_failures_total (contador por class).

12.4 Modelo de objetos da Asaas e mapeamento para o nosso schema #

Três objetos da Asaas são usados. Nenhum outro.

        Asaas                                   Palavra Diária
  ┌───────────────────┐                    ┌───────────────────────┐
  │ customer          │ 1                1 │ subscribers           │
  │ id: cus_000...    │◄──────────────────►│ asaas_customer_id     │
  │ cpfCnpj           │                    │ (CPF em profile)      │
  └────────┬──────────┘                    └───────────┬───────────┘
           │ 1                                          │ 1
           │                                            │
           │ N                                          │ 0..1 (ACTIVE)
  ┌────────▼──────────┐                    ┌───────────▼───────────┐
  │ subscription      │ 1                1 │ subscriptions         │
  │ id: sub_000...    │◄──────────────────►│ asaas_subscription_id │
  │ cycle, value      │                    │ plan_id, status       │
  └────────┬──────────┘                    └───────────┬───────────┘
           │ 1                                          │ 1
           │ N                                          │ N
  ┌────────▼──────────┐                    ┌───────────▼───────────┐
  │ payment           │ 1                1 │ payments              │
  │ id: pay_000...    │◄──────────────────►│ asaas_payment_id      │
  │ status, dueDate   │                    │ status, due_date      │
  └───────────────────┘                    └───────────────────────┘
                                            ┌───────────────────────┐
             webhook event ────────────────►│ payment_events (raw)  │
                                            └───────────────────────┘

Colunas, tipos, índices e comportamento ON DELETE de subscribers, subscriptions, payments e payment_events são definidos na Seção 6. Aqui está apenas o mapeamento de campos, que é responsabilidade desta seção.

12.4.1 customersubscribers e subscriber_profiles #

Campo Asaas Origem no nosso lado Observação
name subscriber_profiles.full_name obrigatório na Asaas
cpfCnpj subscriber_profiles.cpf (envelope cifrado) obrigatório na Asaas; só CPF no MVP. A busca por CPF usa o índice cego subscriber_profiles.cpf_hmac; a exibição usa cpf_last4. Colunas declaradas na Seção 6.4
email subscribers.email enviado quando verificado; opcional
mobilePhone subscribers.phone_e164 sem +55 formato DDNNNNNNNNN
externalReference subscribers.id ULID
notificationDisabled constante true quem fala com o assinante somos nós
id (retorno) subscribers.asaas_customer_id char(20), único

notificationDisabled: true é decisão canônica: a Asaas não envia e-mail nem SMS ao assinante. Toda comunicação sobre cobrança sai pelos nossos canais, com o texto aprovado na Seção 10 e o catálogo de mensagens da Seção 19. Isso evita mensagens em duplicidade e mantém a marca consistente.

12.4.2 subscriptionsubscriptions #

Campo Asaas Nossa coluna Observação
id asaas_subscription_id único, parcial WHERE ... IS NOT NULL
customer derivado de subscriber_id
billingType billing_type CREDIT_CARD | PIX
cycle derivado de plans.interval MONTHLY | YEARLY
value (decimal na Asaas) subscriptions.amount_cents (inteiro) Conversão exclusiva de packages/integrations/src/asaas/mapper.ts; nenhum outro arquivo converte. Entrada 19.9, 19.90 e 199 produzem 1990, 1990 e 19900
nextDueDate next_due_date date
status (ACTIVE/INACTIVE/EXPIRED) não copiado ver nota
externalReference subscriptions.id ULID
description constante Palavra Diária — plano mensal | — plano anual

Nota deliberada: o status da assinatura na Asaas não é copiado para subscriptions.status. Os dois vocabulários são diferentes: o nosso estado é derivado do histórico de cobranças e das ações do assinante, conforme a máquina de estados da Seção 13.2. A Asaas mantém uma assinatura ACTIVE mesmo com cobrança vencida, o que colidiria com a regra de revogação imediata. O status da Asaas é guardado no evento bruto e usado apenas pela reconciliação (12.13) para detectar assinaturas removidas do lado da Asaas.

12.4.3 paymentpayments #

Campo Asaas Nossa coluna Observação
id asaas_payment_id único
subscription resolvido para subscription_id pode ser nulo em cobrança avulsa
customer resolvido para subscriber_id
value (decimal na Asaas) payments.amount_cents (inteiro) Mesmo conversor de 12.4.2
netValue (decimal) payments.net_amount_cents (inteiro) pode ser nulo antes da liquidação
value − netValue payments.fee_cents (inteiro) Taxa de adquirência retida, gravada no momento da liquidação. Nulo enquanto net_amount_cents for nulo
billingType billing_type
status status (enum próprio, ver 12.4.4) traduzido
dueDate due_date date
confirmedDate confirmed_at timestamptz, 12:00 local se só data
paymentDate / clientPaymentDate confirmed_at, quando confirmedDate vier vazio O instante canônico do pagamento é coalesce(confirmed_at, received_at, due_date) (Seção 6.13); não existe coluna paid_at
invoiceUrl invoice_url link da fatura hospedada
transactionReceiptUrl receipt_url comprovante
externalReference payments.id quando criado por nós nulo quando gerado pelo ciclo
deleted reflete em status = DELETED

Quando a Asaas gera a cobrança do ciclo automaticamente, ela não recebe o nosso ULID em externalReference. Nesse caso a linha em payments é criada pelo processador do evento PAYMENT_CREATED, com id gerado por nós e asaas_payment_id vindo do evento.

Fronteira de unidade monetária, declarada aqui porque é aqui que ela existe. A Asaas representa dinheiro como decimal (19.90); o nosso banco representa dinheiro como inteiro em centavos (amount_cents = 1990), conforme a Seção 6 e a convenção de unidades da Seção 21.2. A conversão acontece em um único arquivo, packages/integrations/src/asaas/mapper.ts, nas duas direções:

// packages/integrations/src/asaas/mapper.ts
/** Decimal da Asaas -> centavos inteiros. Arredonda meio para cima e nunca usa float como saída. */
export function toCents(value: number | string): number {
  const normalized = typeof value === 'string' ? value.trim().replace(',', '.') : String(value);
  const [whole, frac = ''] = normalized.split('.');
  const cents = Number(whole) * 100 + Number((frac + '00').slice(0, 2));
  if (!Number.isSafeInteger(cents)) throw new AppError('BILLING_AMOUNT_UNPARSEABLE', 502);
  return cents;
}

/** Centavos inteiros -> decimal com duas casas, para o corpo enviado à Asaas. */
export function toAsaasValue(amountCents: number): number {
  return Number((amountCents / 100).toFixed(2));
}

Nenhum outro arquivo do repositório multiplica ou divide valor monetário por 100. Uma regra de lint (Seção 5) reprova a expressão / 100 e * 100 aplicada a identificador terminado em _cents ou Cents fora desse módulo. Motivo: cada lugar que converte é um lugar que pode converter errado, e erro de unidade em dinheiro só aparece na conciliação do fim do mês.

Custos unitários — mensagem de WhatsApp e caractere narrado — não passam por aqui: eles são gravados em micros de real (*_cost_micros, divisor 1.000.000) pelas Seções 17 e 16, e a diferença de unidade está declarada na Seção 21.2. Nenhuma fórmula soma centavos com micros.

12.4.4 Enum PaymentStatus e ranque de progressão #

O enum próprio, declarado na Seção 6, tem os valores abaixo. O ranque é o mecanismo que torna o processamento imune a eventos fora de ordem (ver 12.12.3).

payments.status Ranque Origem típica
PENDING 0 PAYMENT_CREATED
OVERDUE 10 PAYMENT_OVERDUE
CONFIRMED 20 PAYMENT_CONFIRMED
RECEIVED 30 PAYMENT_RECEIVED
DELETED 80 PAYMENT_DELETED
REFUNDED 90 PAYMENT_REFUNDED
CHARGEBACK 95 PAYMENT_CHARGEBACK_REQUESTED

Regra: uma transição só é aplicada se novoRanque > ranqueAtual. OVERDUE (10) chegando depois de CONFIRMED (20) é descartado como evento tardio. REFUNDED (90) sobre RECEIVED (30) é aplicado normalmente. A única exceção deliberada é PENDINGPENDING com dueDate alterada, tratada por PAYMENT_UPDATED, que altera campos sem mexer no ranque.

12.5 Cliente Asaas: criação, CPF e LGPD #

12.5.1 Quando o customer é criado #

O customer é criado no início do checkout, antes da assinatura, e apenas uma vez por assinante. Se subscribers.asaas_customer_id já estiver preenchido, ele é reutilizado; se o CPF ou o nome mudarem no painel, o customer é atualizado (POST /v3/customers/{id}), nunca recriado. Um assinante nunca tem dois customer na Asaas.

12.5.2 Campos obrigatórios e validação #

name e cpfCnpj são obrigatórios pela Asaas. O checkout, cuja especificação de formulário é da Seção 11, coleta ambos. Regras de validação aplicadas antes de qualquer chamada:

  • name: 3 a 100 caracteres, ao menos duas palavras separadas por espaço, apenas letras (incluindo acentuadas), espaço, apóstrofo e hífen. Normalizado com trim() e colapso de espaços múltiplos.
  • cpfCnpj: apenas dígitos após remoção de . e -; exatamente 11 dígitos; dígitos verificadores válidos; rejeita as 10 sequências repetidas (0000000000099999999999). CNPJ é aceito pelo schema mas rejeitado no MVP com code: TAX_ID_MUST_BE_CPF, porque o produto é vendido a pessoa física.
// packages/core/src/tax-id.ts
const REPEATED = new Set(Array.from({ length: 10 }, (_, d) => String(d).repeat(11)));

export function normalizeCpf(input: string): string {
  return input.replace(/\D+/g, '');
}

export function isValidCpf(input: string): boolean {
  const cpf = normalizeCpf(input);
  if (cpf.length !== 11) return false;
  if (REPEATED.has(cpf)) return false;

  const digits = cpf.split('').map(Number);

  // Primeiro dígito verificador: pesos 10..2 sobre os 9 primeiros dígitos.
  let sum = 0;
  for (let i = 0; i < 9; i++) sum += digits[i] * (10 - i);
  let check = (sum * 10) % 11;
  if (check === 10) check = 0;
  if (check !== digits[9]) return false;

  // Segundo dígito verificador: pesos 11..2 sobre os 10 primeiros dígitos.
  sum = 0;
  for (let i = 0; i < 10; i++) sum += digits[i] * (11 - i);
  check = (sum * 10) % 11;
  if (check === 10) check = 0;
  return check === digits[10];
}

/** Máscara para exibição e log: 123.***.***-01 */
export function maskCpf(input: string): string {
  const cpf = normalizeCpf(input);
  if (cpf.length !== 11) return '***';
  return `${cpf.slice(0, 3)}.***.***-${cpf.slice(9)}`;
}

Verificação manual do algoritmo com o CPF 529.982.247-25: primeiro dígito — 5·10 + 2·9 + 9·8 + 9·7 + 8·6 + 2·5 + 2·4 + 4·3 + 7·2 = 295; 295 · 10 mod 11 = 2, igual ao 10º dígito. Segundo dígito — 5·11 + 2·10 + 9·9 + 9·8 + 8·7 + 2·6 + 2·5 + 4·4 + 7·3 + 2·2 = 348; 348 · 10 mod 11 = 5, igual ao 11º dígito. CPF válido.

O schema Zod usado no checkout:

import { z } from 'zod';

export const taxIdSchema = z
  .string()
  .trim()
  .transform(normalizeCpf)
  .refine((v) => v.length === 11, { message: 'O CPF deve ter 11 dígitos.' })
  .refine(isValidCpf, { message: 'CPF inválido. Confira os números digitados.' });

12.5.3 Tratamento de CPF inválido #

Cenário HTTP code message ao assinante
Menos ou mais de 11 dígitos 422 TAX_ID_INVALID_LENGTH "O CPF deve ter 11 dígitos."
Dígitos verificadores errados 422 TAX_ID_INVALID "CPF inválido. Confira os números digitados."
Sequência repetida 422 TAX_ID_INVALID "CPF inválido. Confira os números digitados."
14 dígitos (CNPJ) 422 TAX_ID_MUST_BE_CPF "No momento aceitamos apenas CPF de pessoa física."
A Asaas recusa o CPF (invalid_cpfCnpj) 422 TAX_ID_REJECTED_BY_PROVIDER "Não conseguimos validar este CPF. Confira os dados e tente novamente."

A validação local roda antes da chamada à Asaas, para não gastar requisição nem expor a recusa do provedor. A recusa do provedor ainda é tratada porque a Asaas faz verificações adicionais que o algoritmo local não cobre.

12.5.4 CPF e LGPD #

O CPF é dado pessoal e identificador fiscal. A coleta existe por uma razão única e declarada: a Asaas exige cpfCnpj para criar o customer, sem o qual não há cobrança. Isso caracteriza execução de contrato (base legal do Art. 7º, V). Consequências operacionais obrigatórias, cuja política completa é da Seção 22:

  • O CPF é armazenado uma única vez, na coluna subscriber_profiles.cpf, como envelope cifrado em AES-256-GCM com a chave ENCRYPTION_KEY, nunca em subscribers. A busca por igualdade usa a coluna de índice cego subscriber_profiles.cpf_hmac, um HMAC-SHA-256 calculado com PHONE_INDEX_KEY. As colunas, os índices e o algoritmo do envelope são declarados na Seção 6.4 e na Seção 22.5; esta seção apenas as consome. O CPF é cifrado com ENCRYPTION_KEY e indexado com PHONE_INDEX_KEY, como toda outra coluna de dado pessoal (Seção 22.5.3): não há chave de cifra específica para identificador fiscal, e não existe variável TAX_ID_ENCRYPTION_KEY no registro da Seção 26.3.
  • cpf_hmac não é único. Dois cadastros podem legitimamente carregar o mesmo CPF — um titular anonimizado que volta a assinar dentro da retenção fiscal, e o caso corriqueiro de alguém contratar para um familiar com o próprio CPF. O CPF repetido em mais de duas contas ativas alimenta a pontuação de risco antifraude e aparece como sinal ao operador, em vez de produzir um erro de banco que ninguém consegue interpretar.
  • Nunca é exibido inteiro no painel do assinante nem no painel administrativo: sempre maskCpf(), alimentado por cpf_last4.
  • Nunca aparece em log, em evento de analytics, em mensagem de WhatsApp ou em e-mail.
  • É retido por 5 anos após o último pagamento, por obrigação fiscal, e por isso não é zerado no primeiro estágio da eliminação por pedido do titular. Esse estágio é pseudonimização, não anonimização: enquanto o CPF cifrado e o asaas_customer_id existirem, a linha continua sendo dado pessoal e permanece integralmente no escopo da LGPD. A anonimização de fato — zerar cpf, cpf_hmac, asaas_customer_id, asaas_subscription_id e asaas_payment_id, e solicitar à Asaas a exclusão do cadastro correspondente — acontece quando a retenção fiscal vence. Os dois estágios estão descritos na Seção 22.9 e o item retido é explicado ao titular na Seção 20.9.
  • O texto de consentimento do checkout diz, literalmente: "Usamos seu CPF apenas para emitir a cobrança junto ao nosso processador de pagamentos." O texto versionado é registrado em consent_events conforme a Seção 11 e a Seção 22.
  • Assinantes do plano gratuito nunca informam CPF. A coleta acontece só no checkout pago — e, sendo o plano gratuito a simples ausência de assinatura vigente (Seção 13.1), não existe nenhum fluxo gratuito que passe por esta seção.

12.6 Checkout com cartão de crédito #

12.6.1 Visão geral #

O plano de cartão usa recorrência automática da Asaas: uma assinatura é criada com creditCardToken e, a cada ciclo, a Asaas cobra o cartão sozinha e emite os webhooks. O sistema não guarda cartão, não guarda token de cartão por conta própria além do identificador retornado, e não inicia cobranças manuais de cartão.

12.6.2 Diagrama de sequência — cartão de crédito #

Assinante        web (Next.js)        Asaas API v3         Postgres          worker         WhatsApp
    │                  │                     │                  │               │              │
    │ 1. abre /assinar │                     │                  │               │              │
    ├─────────────────►│                     │                  │               │              │
    │                  │ 2. sessão válida?   │                  │               │              │
    │                  ├────────────────────────────────────────►│              │              │
    │                  │◄────────────────────────────────────────┤              │              │
    │ 3. nome, CPF,    │                     │                  │               │              │
    │    plano, cartão │                     │                  │               │              │
    ├─────────────────►│                     │                  │               │              │
    │                  │ 4. valida CPF local │                  │               │              │
    │                  │ 5. POST /customers  │                  │               │              │
    │                  ├────────────────────►│                  │               │              │
    │                  │◄─ cus_000006241...──┤                  │               │              │
    │                  │ 6. grava asaas_customer_id             │               │              │
    │                  ├────────────────────────────────────────►│              │              │
    │                  │ 7. POST /creditCard/tokenize            │               │              │
    │                  ├────────────────────►│                  │               │              │
    │                  │◄─ creditCardToken ──┤                  │               │              │
    │                  │ 8. cria subscriptions (PENDING_PAYMENT)│               │              │
    │                  ├────────────────────────────────────────►│              │              │
    │                  │ 9. POST /subscriptions (token)          │               │              │
    │                  ├────────────────────►│                  │               │              │
    │                  │◄─ sub_000000123... ─┤                  │               │              │
    │                  │ 10. grava asaas_subscription_id        │               │              │
    │                  ├────────────────────────────────────────►│              │              │
    │ 11. 200 + tela   │                     │                  │               │              │
    │     "processando"│                     │                  │               │              │
    │◄─────────────────┤                     │                  │               │              │
    │                  │                     │                  │               │              │
    │                  │  12. webhook PAYMENT_CREATED            │               │              │
    │                  │◄────────────────────┤                  │               │              │
    │                  │  13. grava payment_events + enfileira ──────────────────►│             │
    │                  │  14. webhook PAYMENT_CONFIRMED          │               │              │
    │                  │◄────────────────────┤                  │               │              │
    │                  │  15. grava + enfileira ─────────────────────────────────►│             │
    │                  │                     │                  │  16. tx: ACTIVE, tier=PAID    │
    │                  │                     │                  │◄──────────────┤              │
    │                  │                     │                  │  17. msg de boas-vindas paga  │
    │                  │                     │                  │               ├─────────────►│
    │ 18. painel mostra "Plano ativo" (polling/TanStack Query)   │               │              │
    │◄─────────────────┤                     │                  │               │              │

O passo 11 devolve 200 com a assinatura ainda em PENDING_PAYMENT. A confirmação vem por webhook (passo 15). O painel faz polling de GET /api/me/subscription a cada 2 s por até 60 s; passado esse tempo, mostra "Estamos confirmando seu pagamento. Avisamos no WhatsApp assim que estiver tudo certo." O texto exato é da Seção 10.

12.6.3 Passo 5 — criar o customer #

POST /v3/customers
access_token: <ASAAS_API_KEY>
Content-Type: application/json
{
  "name": "Maria Aparecida de Souza",
  "cpfCnpj": "52998224725",
  "email": "maria@exemplo.com.br",
  "mobilePhone": "11987654321",
  "externalReference": "01K3Q8R7V2N4B6D8F0H2J4L6M8",
  "notificationDisabled": true
}

Resposta 200:

{
  "object": "customer",
  "id": "cus_000006241382",
  "dateCreated": "2026-09-06",
  "name": "Maria Aparecida de Souza",
  "email": "maria@exemplo.com.br",
  "cpfCnpj": "52998224725",
  "personType": "FISICA",
  "deleted": false,
  "notificationDisabled": true,
  "externalReference": "01K3Q8R7V2N4B6D8F0H2J4L6M8"
}

Erros possíveis:

Situação Resposta Asaas Nosso code HTTP
CPF recusado 400 com errors[].code = invalid_cpfCnpj TAX_ID_REJECTED_BY_PROVIDER 422
Nome ausente 400 invalid_name CUSTOMER_NAME_INVALID 422
Chave inválida 401 PAYMENT_PROVIDER_AUTH_ERROR 502
Excesso de requisições 429 retry interno; se persistir PAYMENT_PROVIDER_UNAVAILABLE 503
Indisponibilidade 500/502/503/504 retry interno; se persistir PAYMENT_PROVIDER_UNAVAILABLE 503

Efeitos colaterais: grava subscribers.asaas_customer_id, grava subscriber_profiles.full_name, cpf (envelope cifrado), cpf_hmac e cpf_last4, e registra admin_audit_log apenas se a alteração partir do painel administrativo. Idempotência: reexecutar o passo com o mesmo assinante devolve o customer existente sem criar outro (12.3.4).

12.6.4 Passo 7 — tokenizar o cartão #

POST /v3/creditCard/tokenize
{
  "customer": "cus_000006241382",
  "creditCard": {
    "holderName": "MARIA A DE SOUZA",
    "number": "5162306219378829",
    "expiryMonth": "05",
    "expiryYear": "2031",
    "ccv": "318"
  },
  "creditCardHolderInfo": {
    "name": "Maria Aparecida de Souza",
    "email": "maria@exemplo.com.br",
    "cpfCnpj": "52998224725",
    "postalCode": "01310930",
    "addressNumber": "1578",
    "phone": "11987654321"
  },
  "remoteIp": "189.45.12.90"
}

Resposta 200:

{
  "creditCardNumber": "8829",
  "creditCardBrand": "MASTERCARD",
  "creditCardToken": "a75a1d98-c52d-4a6b-a413-71e00b193c99"
}

Persistimos apenas creditCardNumber (últimos 4), creditCardBrand e creditCardToken em subscriptions.card_last4, card_brand e asaas_card_token. O PAN, o CVV e a validade não são persistidos em lugar nenhum. Ver 12.9.

remoteIp é o IP real do assinante, extraído do header X-Forwarded-For deixado pelo Caddy, tomando o primeiro endereço da lista e validando que é um IP público. Ele é exigido pela análise antifraude da Asaas; enviar o IP do servidor degrada a aprovação.

Erros possíveis:

Situação Resposta Asaas Nosso code HTTP
Número inválido 400 invalid_creditCard CARD_INVALID 422
Validade expirada 400 expired_creditCard CARD_EXPIRED 422
Recusa do emissor na validação 400 credit_card_not_authorized CARD_DECLINED 422
Endereço/CEP inconsistente 400 invalid_postalCode CARD_HOLDER_INFO_INVALID 422
Falha de rede PAYMENT_PROVIDER_UNAVAILABLE 503

Mensagens ao assinante (texto de UI, Seção 10 é a dona do copy definitivo): CARD_DECLINED → "Seu cartão foi recusado. Tente outro cartão ou pague com PIX."

12.6.5 Passo 9 — criar a assinatura #

POST /v3/subscriptions
{
  "customer": "cus_000006241382",
  "billingType": "CREDIT_CARD",
  "value": 19.90,
  "nextDueDate": "2026-09-06",
  "cycle": "MONTHLY",
  "description": "Palavra Diária — plano mensal",
  "externalReference": "01K3Q9A1C3E5G7J9L1N3P5R7T9",
  "creditCardToken": "a75a1d98-c52d-4a6b-a413-71e00b193c99",
  "remoteIp": "189.45.12.90"
}

Resposta 200:

{
  "object": "subscription",
  "id": "sub_000000123456",
  "dateCreated": "2026-09-06",
  "customer": "cus_000006241382",
  "value": 19.90,
  "nextDueDate": "2026-10-06",
  "cycle": "MONTHLY",
  "billingType": "CREDIT_CARD",
  "status": "ACTIVE",
  "externalReference": "01K3Q9A1C3E5G7J9L1N3P5R7T9",
  "deleted": false
}

nextDueDate enviado é sempre hoje em America/Sao_Paulo, para que a primeira cobrança seja imediata. A Asaas devolve nextDueDate já apontando para o ciclo seguinte.

Erros possíveis:

Situação Nosso code HTTP Efeito
Token de cartão inválido/expirado CARD_TOKEN_INVALID 422 assinatura local vira CANCELED, checkout reinicia
Cartão recusado na primeira cobrança CARD_DECLINED 422 assinatura local vira CANCELED, sem efeito no tier
Valor divergente do plano PLAN_PRICE_MISMATCH 409 bloqueio, alerta operacional
Assinante já tem assinatura ativa SUBSCRIPTION_ALREADY_ACTIVE 409 nada é criado (ver Seção 13.12.5)
Indisponibilidade PAYMENT_PROVIDER_UNAVAILABLE 503 assinatura local fica PENDING_PAYMENT, reconciliação resolve

Efeitos colaterais: cria subscriptions em PENDING_PAYMENT antes da chamada, dentro de uma transação que também toma um advisory lock por assinante (pg_advisory_xact_lock(hashtext('subscription:' || subscriber_id))), o que impede dois checkouts simultâneos. Idempotência: garantida por externalReference (12.3.4) e pelo índice único parcial que permite no máximo uma assinatura ACTIVE ou PENDING_PAYMENT por assinante (Seção 6, detalhado em 13.12.5).

12.6.6 Renovação automática do cartão #

A cada ciclo a Asaas gera a cobrança e tenta o cartão. O sistema recebe PAYMENT_CREATED e, em seguida, PAYMENT_CONFIRMED (autorização) e PAYMENT_RECEIVED (liquidação). Se a tentativa falhar, a Asaas pode repetir internamente por alguns dias; o sistema não espera essas tentativas. O acesso só cai quando PAYMENT_OVERDUE chega. Se uma tentativa posterior for aprovada, PAYMENT_CONFIRMED restaura o acesso e recalcula o período. Ver 13.12.6.

Cartão expirado é um caso previsto: subscriptions.card_exp_month e subscriptions.card_exp_year alimentam o job billing.card_expiry_notice, executado na fila billing.lifecycle às 09:00 do dia 1º de cada mês, que avisa por WhatsApp quem tem cartão vencendo no mês corrente, com link para trocar o cartão no painel (endpoint em 13.13.5).

12.7 Checkout com PIX #

12.7.1 Modelo adotado #

PIX não tem débito recorrente. A assinatura PIX é criada normalmente com billingType: "PIX", e a Asaas emite uma cobrança nova por ciclo. O assinante paga cada cobrança manualmente. O sistema gera QR Code e código copia-e-cola, envia lembretes antes do vencimento (13.5) e revoga o acesso quando PAYMENT_OVERDUE chega.

A cobrança do ciclo seguinte é gerada pela Asaas com antecedência. O sistema busca a próxima cobrança pendente PIX_CHARGE_LEAD_DAYS=3 dias antes do vencimento, para conseguir enviar o lembrete D-3 já com o código copia-e-cola dentro da mensagem.

12.7.2 Diagrama de sequência — PIX #

Assinante        web (Next.js)       Asaas API v3      Postgres      worker      WhatsApp
    │                  │                   │               │            │            │
    │ 1. escolhe PIX   │                   │               │            │            │
    ├─────────────────►│                   │               │            │            │
    │                  │ 2. POST /customers (se necessário)│            │            │
    │                  ├──────────────────►│               │            │            │
    │                  │ 3. cria subscriptions PENDING_PAYMENT          │            │
    │                  ├──────────────────────────────────►│            │            │
    │                  │ 4. POST /subscriptions (PIX)      │            │            │
    │                  ├──────────────────►│               │            │            │
    │                  │◄─ sub_00000012... ┤               │            │            │
    │                  │ 5. GET /subscriptions/{id}/payments│           │            │
    │                  ├──────────────────►│               │            │            │
    │                  │◄─ pay_00000098... ┤               │            │            │
    │                  │ 6. GET /payments/{id}/pixQrCode   │            │            │
    │                  ├──────────────────►│               │            │            │
    │                  │◄─ encodedImage, payload, expiration┤           │            │
    │ 7. QR + copia-e-cola + validade      │               │            │            │
    │◄─────────────────┤                   │               │            │            │
    │                  │                   │               │            │            │
    │ 8. paga no banco │                   │               │            │            │
    ├──────────────────────────────────────►               │            │            │
    │                  │ 9. webhook PAYMENT_RECEIVED       │            │            │
    │                  │◄──────────────────┤               │            │            │
    │                  │ 10. persiste + enfileira ──────────────────────►│           │
    │                  │                   │  11. tx: ACTIVE, tier=PAID │            │
    │                  │                   │◄──────────────┤            │            │
    │                  │                   │  12. confirmação de pagamento           │
    │                  │                   │               │            ├───────────►│
    │ 13. painel: "Plano ativo até 06/10/2026"             │            │            │
    │◄─────────────────┤                   │               │            │            │

12.7.3 Passo 4 — assinatura PIX #

{
  "customer": "cus_000006241382",
  "billingType": "PIX",
  "value": 199.00,
  "nextDueDate": "2026-09-06",
  "cycle": "YEARLY",
  "description": "Palavra Diária — plano anual",
  "externalReference": "01K3QB2D4F6H8K0M2P4R6T8V0X"
}

12.7.4 Passo 6 — obter o QR Code #

GET /v3/payments/pay_000000098765/pixQrCode

Resposta 200:

{
  "encodedImage": "iVBORw0KGgoAAAANSUhEUgAAAS...",
  "payload": "00020126580014BR.GOV.BCB.PIX0136a1b2c3d4-...5204000053039865802BR5913PALAVRA DIARIA6009SAO PAULO62070503***6304AB12",
  "expirationDate": "2026-09-06 23:59:59",
  "success": true
}

Tratamento:

  • encodedImage é PNG em base64. É renderizado como data:image/png;base64,.... Não é salvo em storage nem em banco: é volátil e obtido sob demanda.
  • payload é o copia-e-cola. O painel oferece botão "Copiar código". A mensagem de WhatsApp envia o payload em uma mensagem separada, sem texto ao redor, para o assinante conseguir copiar com um toque.
  • expirationDate vem em horário de Brasília, sem fuso explícito. É convertido para UTC com date-fns-tz assumindo America/Sao_Paulo e guardado em payments.pix_expires_at.
  • Se expirationDate já passou, o sistema não exibe o QR: chama POST /v3/payments/{id} atualizando dueDate para hoje e busca o QR novamente. Se ainda assim falhar, responde PIX_CODE_UNAVAILABLE e instrui o assinante a usar o invoiceUrl da cobrança.

Erros possíveis:

Situação code HTTP Comportamento
Cobrança inexistente PAYMENT_NOT_FOUND 404 painel oferece recriar checkout
Cobrança já paga PAYMENT_ALREADY_PAID 409 painel mostra estado atual
Cobrança removida PAYMENT_DELETED 409 painel oferece recriar checkout
PIX indisponível na conta PIX_NOT_ENABLED 503 alerta operacional imediato
Falha transitória PIX_CODE_UNAVAILABLE 503 botão "Tentar de novo"

12.7.5 Endpoint interno de QR Code #

GET /api/me/subscription/pix-code

  • Autenticação: sessão de assinante (Seção 8). Papel: SUBSCRIBER.
  • Request: sem corpo. Query opcional ?paymentId=pay_... restrita a cobranças do próprio assinante.
export const pixCodeQuerySchema = z.object({
  paymentId: z.string().regex(/^pay_[0-9]{12,}$/).optional(),
});
  • Resposta 200:
{
  "data": {
    "paymentId": "pay_000000098765",
    "amountCents": 19900,
    "dueDate": "2026-09-06",
    "expiresAt": "2026-09-07T02:59:59.000Z",
    "qrCodeImage": "data:image/png;base64,iVBORw0KGgo...",
    "copyPasteCode": "00020126580014BR.GOV.BCB.PIX...6304AB12",
    "invoiceUrl": "https://www.asaas.com/i/098765"
  },
  "meta": { "requestId": "req_01K3QC5F7H9K1M3P5R7T9V1X3Z", "timestamp": "2026-09-06T12:04:11.220Z" }
}
  • Erros: UNAUTHENTICATED (401), SUBSCRIPTION_NOT_FOUND (404), PAYMENT_NOT_FOUND (404), PAYMENT_ALREADY_PAID (409), BILLING_TYPE_NOT_PIX (409), PIX_CODE_UNAVAILABLE (503), RATE_LIMITED (429).
  • Efeitos colaterais: atualiza payments.pix_expires_at e payments.invoice_url.
  • Idempotência: leitura pura do ponto de vista do negócio; repetível à vontade. Limite de taxa de 10 requisições por minuto por assinante para não abusar da API da Asaas.

12.7.6 Expiração da cobrança PIX #

Uma cobrança PIX vence no fim do dia de dueDate. Depois disso, a Asaas emite PAYMENT_OVERDUE e o acesso é revogado (Seção 13.3). O QR antigo deixa de ser exibido; o painel mostra o botão "Reativar assinatura", que gera uma nova cobrança conforme 13.10.

Exemplo numérico: plano mensal PIX, dueDate = 2026-09-10 (quinta-feira). Lembrete D-3 em 07/09 às 06:30, D-1 em 09/09 às 06:30, D0 em 10/09 às 09:00. Sem pagamento, PAYMENT_OVERDUE chega na madrugada de 11/09 e o assinante já não recebe o devocional de 11/09 às 06:00 — ele volta a ser FREE e só receberá o texto no domingo 13/09.

12.8 Catálogo de chamadas HTTP à Asaas #

Todas as chamadas de saída usadas pelo sistema, sem exceção. Qualquer necessidade fora desta lista exige alteração desta seção.

# Método Caminho Quando Retry
1 POST /v3/customers início do checkout rede/5xx, com checagem de externalReference
2 POST /v3/customers/{id} assinante altera nome ou CPF rede/5xx
3 GET /v3/customers?externalReference={ulid} idempotência e reconciliação sim
4 POST /v3/creditCard/tokenize checkout com cartão e troca de cartão não
5 POST /v3/subscriptions criação da assinatura rede/5xx com checagem prévia
6 GET /v3/subscriptions/{id} reconciliação e painel admin sim
7 POST /v3/subscriptions/{id} troca de ciclo, troca de cartão, mudança de valor rede/5xx
8 DELETE /v3/subscriptions/{id} cancelamento rede/5xx (404 é sucesso)
9 GET /v3/subscriptions/{id}/payments obter cobrança do ciclo sim
10 GET /v3/payments?subscription={id}&offset=&limit= reconciliação sim
11 GET /v3/payments/{id} conferência pontual sim
12 POST /v3/payments/{id} reagendar vencimento PIX rede/5xx
13 GET /v3/payments/{id}/pixQrCode exibir QR e montar lembrete sim
14 POST /v3/payments/{id}/refund reembolso pelo admin não
15 DELETE /v3/payments/{id} remover cobrança pendente órfã rede/5xx

A chamada 4 não é repetida porque uma repetição implicaria reter o PAN além do necessário; em falha, o assinante é convidado a tentar de novo. A chamada 14 não é repetida automaticamente para evitar reembolso duplo; falha vira tarefa manual no painel administrativo com alerta.

12.8.1 Cancelar assinatura #

DELETE /v3/subscriptions/sub_000000123456
{ "deleted": true, "id": "sub_000000123456" }

404 é tratado como sucesso idempotente: a assinatura já não existe do lado da Asaas, que é exatamente o estado desejado. 500 entra em retry. A semântica de negócio do cancelamento (quando o acesso termina) é da Seção 13.4 e 20.7.

12.8.2 Reembolsar #

POST /v3/payments/pay_000000098765/refund
{ "value": 19.90, "description": "Reembolso solicitado pelo assinante" }

Resposta 200 traz status: "REFUNDED". O reembolso parcial é aceito pela API mas não é usado no MVP: reembolso é sempre integral. Decisão registrada aqui; motivo: cálculo de valor parcial exigiria política de prorrateio, que o MVP não tem (13.7.2).

12.9 Segurança de dados de cartão e posicionamento PCI-DSS #

12.9.1 O que trafega, o que fica e o que nunca é registrado #

Dado Trafega pelo nosso servidor Persistido Aparece em log
PAN (número do cartão) sim, uma vez, só em memória nunca nunca
CVV sim, uma vez, só em memória nunca nunca
Validade (mês/ano) sim, uma vez apenas mês/ano em subscriptions.card_exp_month/year nunca
Nome impresso sim não nunca
Últimos 4 dígitos sim subscriptions.card_last4 permitido
Bandeira sim subscriptions.card_brand permitido
creditCardToken sim subscriptions.asaas_card_token nunca
CPF do titular sim envelope cifrado em subscriber_profiles.cpf, com cpf_hmac e cpf_last4 apenas mascarado
IP do assinante sim consent_events.ip permitido

12.9.2 Controles obrigatórios #

  1. A rota POST /api/checkout/card-tokens é a única que aceita dados de cartão. Ela é marcada com export const dynamic = 'force-dynamic' e com um flag interno SENSITIVE_ROUTE = true que instrui o middleware de log a não gravar corpo, nem query, nem headers além de x-request-id.
  2. O corpo da requisição nunca é serializado para log, nem em caso de erro. O handler de erro dessa rota registra apenas { requestId, code, asaasErrorCode }.
  3. Nenhum campo de cartão entra em cache de servidor, em sessionStorage, em localStorage, em cookie ou em URL. O formulário usa autocomplete="cc-number" e inputmode="numeric", com name ausente para não ser capturado por extensões de preenchimento genérico.
  4. Os campos de cartão são limpos do DOM (form.reset() e sobrescrita das refs) assim que a tokenização retorna.
  5. O relatório de erro do cliente (Seção 23) tem beforeSend que descarta qualquer evento originado na rota de checkout de cartão e remove qualquer string com 13 a 19 dígitos consecutivos.
  6. TLS 1.2 no mínimo, TLS 1.3 preferido, imposto pelo Caddy; HSTS com max-age=31536000 e includeSubDomains.
  7. Content-Security-Policy estrita na página de checkout, sem unsafe-inline e sem scripts de terceiros — nenhuma tag de analytics, pixel ou chat é carregada nessa rota. Isso é requisito, não preferência.
  8. Backups e dumps não podem conter PAN porque ele nunca é gravado; ainda assim, o script de verificação ops scan:pan roda semanalmente contra um dump de teste procurando padrões de PAN válidos por Luhn e falha o pipeline se encontrar algo.
  9. A rota de tokenização, como toda rota que altera estado sob sessão de assinante, é recusada em sessão de impersonação administrativa. A impersonação é integralmente somente leitura, sem exceção, e a recusa acontece no invólucro de API antes de chegar ao handler (Seção 8.13). O critério é a guarda de autenticação da rota, não o prefixo do caminho: POST /api/checkout/card-tokens, POST /api/me/subscription e POST /api/me/subscription/card estão todos cobertos.

12.9.3 Decisão sobre PCI-DSS: SAQ A-EP não se aplica; adotamos SAQ D-Merchant #

O produto usa checkout transparente: o formulário é nosso e os dados do cartão são transmitidos ao nosso servidor, que os repassa imediatamente à Asaas para tokenização e os descarta. Essa arquitetura é a exigida pelo modelo de tokenização da Asaas, que autentica por chave privada e portanto não pode ser chamada diretamente pelo navegador.

Consequência de conformidade, decidida e registrada:

  • SAQ A não se aplica: ele exige que todas as funções de pagamento sejam terceirizadas e que a página do comerciante não receba dado de cartão. Não é o nosso caso.
  • SAQ A-EP também não se aplica: ele cobre comerciantes cuja página influencia a transação mas não recebe dado de titular de cartão. Nós recebemos.
  • Decisão: o questionário aplicável é o SAQ D — Merchant, com escopo restrito ao contêiner web, ao proxy caddy e ao segmento de rede entre eles. worker, postgres e redis ficam fora do escopo de dados de cartão porque nenhum dado de titular chega até eles — o que é assegurado pelos controles de 12.9.2.

Obrigações que essa decisão cria. Esta subseção é a dona da lista; a Seção 22.5.6 é a responsável por executá-las, agendá-las e registrar a evidência de cada uma. A lista é normativa e nenhuma das cinco pode ser omitida do plano de conformidade:

# Obrigação Periodicidade Evidência exigida
1 Varredura por fornecedor autorizado (ASV) do domínio de checkout Trimestral Relatório ASV aprovado, arquivado com data
2 Revisão e reassinatura do SAQ D — Merchant Anual Questionário assinado pelo responsável
3 Política de segurança da informação escrita, revisada e divulgada Anual Documento versionado, com registro de leitura
4 Correção de vulnerabilidades classificadas critical e high Em até 30 dias da descoberta Registro de cada item, com data de descoberta e de correção
5 Revisão de acessos a sistemas no escopo de cartão Trimestral Lista de contas revisada e assinada

Rota de redução de escopo, já decidida como evolução: migrar a captura do cartão para uma página hospedada da Asaas, acessada por redirecionamento completo. Isso tira a nossa página inteiramente do fluxo do dado de cartão e reclassifica a operação para SAQ A.

A distinção entre os dois questionários de redução importa e fica registrada para não ser decidida duas vezes: redirecionamento para página hospedada pelo processador → SAQ A; componente embutido que a nossa página carrega (iframe ou script do processador dentro do nosso HTML) → SAQ A-EP, porque a nossa página continua influenciando a transação. A rota decidida para este produto é o redirecionamento, com alvo SAQ A; qualquer proposta de componente embutido é uma decisão nova e muda o alvo para SAQ A-EP.

A troca é isolada atrás da interface PaymentProvider em packages/integrations, e nenhum código de domínio muda. O gatilho para executar essa migração é qualquer um destes: exigência da adquirente, volume mensal acima de 20.000 transações, ou a primeira release após o MVP — o que vier primeiro. Enquanto a migração não acontecer, as cinco obrigações da tabela acima continuam valendo integralmente.

12.10 Webhook da Asaas — recepção #

12.10.1 Contrato do endpoint #

POST /api/webhooks/asaas

  • Autenticação: header asaas-access-token, comparado em tempo constante com ASAAS_WEBHOOK_TOKEN. Não há sessão, não há cookie, não há CSRF. Papel: nenhum (rota pública autenticada por segredo compartilhado).
  • Limites: corpo máximo WEBHOOK_MAX_BODY_BYTES, o mesmo teto de 7.14 para toda rota de webhook; acima disso, 413 com code: PAYLOAD_TOO_LARGE. O proxy reverso da Seção 25.7.2 aplica o teto externo com o mesmo número, e o teto externo nunca é menor que o interno.
  • Resposta de sucesso: 200 com corpo mínimo. Esta rota não usa o envelope padrão da Seção 7, por decisão explícita: a Asaas espera apenas um 2xx, e o corpo é irrelevante para ela. Corpo devolvido: {"received":true}.
// Schema de validação de superfície. O corpo bruto é preservado independentemente disso.
export const asaasWebhookEnvelopeSchema = z.object({
  id: z.string().min(1),
  event: z.string().min(1),
  dateCreated: z.string().optional(),
  payment: z.record(z.unknown()).optional(),
  subscription: z.record(z.unknown()).optional(),
});

Exemplo de corpo recebido:

{
  "id": "evt_05b708f961d739ea7eba7e4db318f621&368604",
  "event": "PAYMENT_CONFIRMED",
  "dateCreated": "2026-09-06 09:14:02",
  "payment": {
    "object": "payment",
    "id": "pay_000000098765",
    "customer": "cus_000006241382",
    "subscription": "sub_000000123456",
    "value": 19.90,
    "netValue": 18.91,
    "billingType": "CREDIT_CARD",
    "status": "CONFIRMED",
    "dueDate": "2026-09-06",
    "confirmedDate": "2026-09-06",
    "invoiceUrl": "https://www.asaas.com/i/098765",
    "externalReference": null
  }
}

12.10.2 Comparação em tempo constante #

import { timingSafeEqual } from 'node:crypto';

export function isValidWebhookToken(received: string | null, expected: string): boolean {
  if (!received) return false;
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual exige comprimentos iguais; comparar tamanhos antes vazaria informação,
  // então normalizamos por hash de comprimento fixo.
  const ha = createHash('sha256').update(a).digest();
  const hb = createHash('sha256').update(b).digest();
  return timingSafeEqual(ha, hb);
}

Falha de token: responde 401 com {"received":false}, registra WARN asaas_webhook_unauthorized com requestId e IP, incrementa a métrica asaas_webhook_unauthorized_total. Cinco ocorrências em 5 minutos disparam alerta, porque isso indica varredura ou token desatualizado após rotação.

12.10.3 Por que a resposta precisa ser rápida #

A Asaas mantém uma fila de sincronização por integração. Quando o endpoint responde erro ou demora demais, o evento é reenfileirado; após falhas consecutivas, a Asaas interrompe a fila e passa a acumular eventos, exigindo reativação manual no painel. Uma fila interrompida significa assinantes pagos que não são ativados e inadimplentes que não são revogados — falha silenciosa e cara.

Por isso o handler faz exatamente três coisas, sempre nessa ordem, e nada mais:

  1. mede o tamanho do corpo antes de lê-lo e valida o token;
  2. grava o evento bruto em payment_events com ON CONFLICT DO NOTHING sobre o índice único de asaas_event_id;
  3. enfileira o job billing.webhook, na fila billing.webhook, com jobId igual ao asaas_event_id.

Orçamento de tempo: p99 abaixo de 2 s, alvo prático de 150 ms. O handler tem um timeout interno de 1,5 s; se a gravação não concluir nesse prazo, ele ainda responde 200 e registra ERROR asaas_webhook_persist_timeout, porque perder um evento é recuperável pela reconciliação de 12.13, enquanto travar a fila da Asaas não é. Essa é uma decisão consciente de trocar completude imediata por disponibilidade da fila.

Corpo inválido nunca produz resposta não-2xx. A validação do token é a única condição que autoriza 401, e ela vem primeiro. Autenticado o remetente, qualquer falha posterior — corpo que não é JSON, schema reprovado, evento desconhecido, assinante inexistente, indisponibilidade do banco ou da fila — responde 200 com corpo {"received":true} e persiste o ocorrido:

Falha após a autenticação payment_events.processing_status O que é guardado Resposta
Corpo não é JSON PARSE_ERROR Corpo cru, íntegro, em payment_events.payload; o corpo cru como texto vai para webhook_deliveries.body 200
JSON válido, schema reprovado SCHEMA_REJECTED Corpo cru e o erro do Zod 200
Banco ou fila indisponível PERSIST_FAILED Corpo cru em arquivo de recuperação, relido pela reconciliação 200
Evento fora da lista de 12.11 IGNORED_UNKNOWN_EVENT Corpo cru 200

Acima de 3 ocorrências de PARSE_ERROR ou SCHEMA_REJECTED em 15 minutos dispara o alerta webhook_payload_rejected, severidade alta, para o plantonista (Seção 23.8).

Motivo, registrado para que ninguém "conserte" isso depois: 4xx e 5xx levam o provedor a retentar e, em seguida, a desativar a assinatura do webhook. Perder o canal de eventos é catastroficamente pior do que engolir um corpo malformado, porque é por esse canal que a revogação imediata de acesso pago acontece — sem ele, todo inadimplente continua recebendo conteúdo pago por dias, e o único detector seria a reconciliação das 04:00.

O único caminho de 4xx além do 401 é 413, para corpo acima de WEBHOOK_MAX_BODY_BYTES, medido antes da leitura do corpo. Um corpo grande demais não é evento legítimo da Asaas, e recusá-lo protege a memória do processo.

Detector de canal morto. Um webhook desativado pelo provedor é silencioso por natureza: o painel continua verde, o envio continua funcionando, e ninguém percebe até a primeira reclamação. Por isso existe o alerta webhook_silence (Seção 23.8), severidade crítica, para o plantonista: nenhum evento de cobrança processado com sucesso nos últimos 90 minutos entre 08:00 e 22:00. É o único detector de assinatura de webhook desativada pelo provedor.

12.10.4 Persistência bruta e deduplicação #

O corpo cru é gravado como jsonb em payment_events.payload, sem transformação, junto com asaas_event_id, event_type, received_at e processed_at NULL. O índice único em asaas_event_id (Seção 6) é o mecanismo de deduplicação: um reenvio do mesmo evento não cria segunda linha e não enfileira segundo job, porque o jobId do BullMQ também é o asaas_event_id e o BullMQ descarta duplicatas de jobId enquanto o job existir.

Quando o corpo não pode ser interpretado, ainda assim ele é guardado. A linha nasce com asaas_event_id = 'unparseable:' || sha256(corpo cru), o que preserva a deduplicação sem depender de um campo que o corpo talvez não tenha, e com processing_status conforme a tabela de 12.10.3. Um corpo guardado é reprocessável; um corpo descartado é perda definitiva.

Retenção: payment_events é retido por 5 anos por obrigação fiscal, conforme a política da Seção 22.

export async function handleAsaasWebhook(rawBody: string, headers: Headers) {
  if (!isValidWebhookToken(headers.get('asaas-access-token'), env.ASAAS_WEBHOOK_TOKEN)) {
    return json({ received: false }, { status: 401 });
  }
  let parsed: z.infer<typeof asaasWebhookEnvelopeSchema> | null = null;
  let failure: 'PARSE_ERROR' | 'SCHEMA_REJECTED' | null = null;
  try {
    parsed = asaasWebhookEnvelopeSchema.parse(JSON.parse(rawBody));
  } catch (error) {
    failure = error instanceof SyntaxError ? 'PARSE_ERROR' : 'SCHEMA_REJECTED';
    logger.error({ event: 'asaas_webhook_rejected', result: failure });
    metrics.webhookPayloadRejectedTotal.inc({ provider: 'asaas', result: failure });
  }

  try {
    await withTimeout(1_500, async () => {
      const eventId = parsed?.id ?? `unparseable:${sha256Hex(rawBody)}`;
      await db.paymentEvent.createMany({
        data: [{
          id: ulid(),
          asaasEventId: eventId,
          eventType: parsed?.event ?? 'UNPARSEABLE',
          payload: (parsed ?? {}) as unknown as Prisma.JsonObject,
          payloadRaw: rawBody,
          receivedAt: new Date(),
          processedAt: failure ? new Date() : null,
          processingResult: failure,
        }],
        skipDuplicates: true,
      });
      // Corpo irrecuperável não vira job: não há o que processar, e a linha já guarda a prova.
      if (failure) return;
      await billingWebhookQueue.add('billing.webhook', { asaasEventId: eventId }, {
        jobId: eventId,
        attempts: 8,
        backoff: { type: 'exponential', delay: 2_000 },
        removeOnComplete: 1_000,
        removeOnFail: false,
      });
    });
  } catch {
    // Banco ou fila indisponível: o corpo cru vai para o arquivo de recuperação e a
    // reconciliação de 12.13 o relê. Responder erro aqui desativaria a fila da Asaas.
    logger.error({ event: 'asaas_webhook_persist_failed' });
    await spillToRecoveryFile('asaas', rawBody);
  }

  // 200 em todo caminho autenticado, sem exceção. Ver 12.10.3.
  return json({ received: true }, { status: 200 });
}

12.10.5 Configuração do webhook na Asaas #

Configurado uma vez por ambiente, no painel da Asaas ou via API de webhooks:

Campo Valor
URL https://api.palavradiaria.com.br/api/webhooks/asaas
E-mail de notificação de falha ops@palavradiaria.com.br
Token de autenticação valor de ASAAS_WEBHOOK_TOKEN
Versão da API v3
Fila ativada, com envio sequencial
Eventos os 12 de 12.11, apenas

Rotação do token: gerar novo valor, atualizar a variável, reiniciar web, atualizar o painel da Asaas. Durante a janela de rotação o sistema aceita dois tokens, lendo ASAAS_WEBHOOK_TOKEN e ASAAS_WEBHOOK_TOKEN_PREVIOUS; a segunda variável é removida em até 24 horas. O runbook está na Seção 27.

12.11 Eventos tratados — tabela completa #

Coluna "fora de ordem" descreve o que fazer se o evento chegar depois de um evento mais avançado do mesmo payment ou subscription. O mecanismo é o ranque de 12.4.4 combinado com a recomputação de estado de 12.12.4. As mensagens citadas têm o texto definitivo na Seção 19.

Esta tabela é dona de duas coisas apenas: quais eventos são tratados e qual estado de pagamento cada um grava. O efeito sobre o acesso do assinante nunca é descrito aqui. Ele é sempre e apenas o de duas funções, definidas na Seção 13.3, chamadas dentro da mesma transação do processamento do evento:

  • revokePaidAccess(tx, subscriberId, reason) — retira o acesso pago, sem carência.
  • grantPaidAccess(tx, subscriberId, periodEnd) — concede ou prorroga o acesso pago.

Descrever por extenso o efeito de revogação em oito linhas de tabela criaria oito lugares que podem divergir da Seção 13.3 na primeira mudança de regra. Por isso a coluna "Efeito no acesso" abaixo diz qual função é chamada, e nada mais.

Recência de evento: não existem colunas payments.last_event_at nem subscriptions.last_event_at. Onde a tabela abaixo fala em comparar a idade do evento, a comparação é feita contra o received_at do evento já processado mais recente daquele payment ou subscription, lido de payment_events — que é append-only e já é a fonte da verdade dessa ordenação.

# Evento Significado Efeito no nosso estado Efeito no acesso Mensagem ao assinante Fora de ordem
1 PAYMENT_CREATED Cobrança gerada para um ciclo payments criado/atualizado com status=PENDING, due_date, amount_cents; vincula subscription_id Nenhuma função de acesso é chamada PIX: nenhuma no ato; o lembrete D-3 (13.5) usará esta cobrança. Cartão: nenhuma Ranque 0. Se a linha já existe com ranque maior, apenas completa campos ausentes e não rebaixa o status
2 PAYMENT_UPDATED Valor, vencimento ou descrição mudaram Atualiza amount_cents, due_date, invoice_url, billing_type; não altera status Nenhuma função de acesso é chamada Só se due_date mudou e é PIX: "A data da sua cobrança mudou para DD/MM." Aplica sempre os campos, comparando dateCreated do evento com o received_at do último evento processado do mesmo payment; eventos mais antigos são ignorados
3 PAYMENT_CONFIRMED Pagamento autorizado (cartão) ou compensado; ainda pode não estar liquidado payments.status=CONFIRMED, confirmed_at; assinatura vai a ACTIVE; current_period_start/end recalculados Chama grantPaidAccess() na mesma transação (Seção 13.3) Confirmação de pagamento com data de término do ciclo Ranque 20. Se já RECEIVED (30) ou terminal, ignora o status mas ainda recomputa a assinatura (idempotente)
4 PAYMENT_RECEIVED Valor efetivamente creditado payments.status=RECEIVED, received_at, net_amount_cents, fee_cents; assinatura ACTIVE Chama grantPaidAccess() na mesma transação (Seção 13.3) Nenhuma se PAYMENT_CONFIRMED já foi processado; caso contrário, envia a confirmação Ranque 30. Chegando sem CONFIRMED prévio, faz o trabalho dos dois
5 PAYMENT_OVERDUE Vencimento passou sem pagamento payments.status=OVERDUE; subscriptions.status=EXPIRED, ended_at=now(), end_reason=NON_PAYMENT Chama revokePaidAccess() na mesma transação (Seção 13.3) Aviso de perda de acesso com link de reativação Ranque 10. Se o pagamento já está CONFIRMED/RECEIVED, o evento é descartado e registra WARN asaas_overdue_after_paid
6 PAYMENT_DELETED Cobrança removida na Asaas payments.status=DELETED; se era a cobrança do ciclo vigente, subscriptions.status=CANCELED, end_reason=PAYMENT_DELETED Chama revokePaidAccess() na mesma transação (Seção 13.3) "Sua cobrança foi cancelada e seu acesso ao plano pago foi encerrado." Ranque 80, sempre aplicado. Se o pagamento estava RECEIVED, aplica a revogação assim mesmo e emite alerta billing_deleted_after_paid para revisão manual
7 PAYMENT_REFUNDED Estorno concluído payments.status=REFUNDED, refunded_at; subscriptions.status=REFUNDED, end_reason=REFUND Chama revokePaidAccess() na mesma transação (Seção 13.3) Confirmação de estorno com prazo bancário Ranque 90, sempre aplicado, mesmo chegando antes de RECEIVED
8 PAYMENT_CHARGEBACK_REQUESTED Titular contestou a compra payments.status=CHARGEBACK; subscriptions.status=REFUNDED, end_reason=CHARGEBACK; marca subscribers.billing_blocked_at Chama revokePaidAccess() na mesma transação (Seção 13.3) Mensagem neutra de encerramento com canal de contato Ranque 95, sempre aplicado
9 PAYMENT_CHARGEBACK_DISPUTE Contestação em disputa com documentação Grava payments.chargeback_stage=DISPUTE e cria tarefa no painel administrativo Nenhuma função de acesso é chamada; o acesso já foi retirado pelo evento 8 Nenhuma Sem ranque; só anota. Ignorado se o pagamento não existir
10 SUBSCRIPTION_CREATED Assinatura criada na Asaas Confirma asaas_subscription_id, billing_type, next_due_date na nossa linha localizada por externalReference Nenhuma função de acesso é chamada Nenhuma Se a nossa linha não existir, cria uma órfã em PENDING_PAYMENT e sinaliza para a reconciliação (13.12.4)
11 SUBSCRIPTION_UPDATED Ciclo, valor ou vencimento mudaram Atualiza next_due_date, amount_cents, billing_type; se o cycle mudou, atualiza plan_id para o plano correspondente Nenhuma função de acesso é chamada Só quando o ciclo mudou: confirmação de troca de plano Compara dateCreated com o received_at do último evento processado da mesma subscription; mais antigo é ignorado
12 SUBSCRIPTION_DELETED Assinatura removida na Asaas Se a nossa já está CANCELED/EXPIRED/REFUNDED, apenas confirma. Se está ACTIVE, encerra: status=CANCELED, end_reason=PROVIDER_DELETED, acesso mantido até current_period_end Nenhuma chamada imediata; revokePaidAccess() é chamada pelo job billing.lifecycle quando current_period_end vence (Seção 13.4) Confirmação de cancelamento com a data exata de término Idempotente: reprocessar não muda nada

Qualquer evento fora desta lista é gravado em payment_events, marcado processed_at = now() com processing_status = 'IGNORED_UNKNOWN_EVENT', e registrado como INFO asaas_event_ignored. Nunca causa erro nem retry.

12.12 Processamento assíncrono e idempotente #

12.12.1 O job #

Job billing.webhook, na fila billing.webhook, concorrência 4, attempts: 8, backoff exponencial iniciando em 2 s (2 s, 4 s, 8 s, … até ~4 min).

Não existe fila morta separada. Após 8 falhas, o job fica no estado failed da própria fila billing.webhook e grava uma linha em job_runs com status = 'DEAD', job_name, queue, bull_job_id, attempt e error. job_runs é a dead-letter do sistema. O painel administrativo lista os jobs mortos a partir dessa tabela, com botão de reprocessar, e o alerta billing_event_dead_letter dispara na primeira linha DEAD da fila billing.webhook. A retenção do estado failed desta fila é de 30 dias.

12.12.2 Fluxo do processador #

export async function processAsaasEvent(asaasEventId: string): Promise<void> {
  await db.$transaction(async (tx) => {
    // 1. Trava o evento e garante processamento único.
    const event = await tx.$queryRaw`
      SELECT * FROM payment_events WHERE asaas_event_id = ${asaasEventId} FOR UPDATE`;
    if (!event) throw new RetriableError('event_not_persisted_yet');
    if (event.processed_at !== null) return; // já processado: sai em silêncio

    // 2. Trava o agregado do assinante para serializar eventos concorrentes.
    const link = await resolveLinks(tx, event);           // subscriber, subscription, payment
    if (link.subscriberId) {
      await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'sub:' + link.subscriberId}))`;
    }

    // 3. Aplica o efeito bruto do evento (upsert em payments/subscriptions).
    const outcome = await applyEvent(tx, event, link);

    // 4. Recomputa o estado derivado a partir dos fatos, nunca por delta.
    if (link.subscriptionId) await recomputeSubscriptionState(tx, link.subscriptionId);

    // 5. Marca como processado.
    await tx.paymentEvent.update({
      where: { asaasEventId },
      data: { processedAt: new Date(), processingResult: outcome.result },
    });

    // 6. Enfileira efeitos externos APÓS o commit.
    outcome.sideEffects.forEach((e) => tx.afterCommit(() => enqueue(e)));
  }, { isolationLevel: 'ReadCommitted', timeout: 15_000 });
}

Pontos de projeto que valem explicação:

  • O FOR UPDATE sobre payment_events mais o processed_at funcionam como exactly-once lógico: duas execuções concorrentes do mesmo evento serializam, e a segunda sai no passo 1.
  • O advisory lock por assinante impede que dois eventos diferentes (por exemplo, PAYMENT_CONFIRMED de uma cobrança e PAYMENT_OVERDUE de outra) se intercalem e produzam estado incoerente.
  • Mensagens de WhatsApp e e-mails nunca são disparados dentro da transação. Eles entram em afterCommit. Se o processo morrer entre o commit e o enfileiramento, o job é repetido, cai no passo 1 e sai — e a mensagem se perde. Para cobrir isso, a mensagem de confirmação de pagamento também é reconstruída pelo job de reconciliação (12.13.4), que compara subscription_events com message_logs.
  • RetriableError('event_not_persisted_yet') cobre a corrida em que o job é consumido antes do commit da gravação do webhook. O backoff resolve em milissegundos.

12.12.3 Idempotência em três camadas #

  1. Recepção: índice único em payment_events.asaas_event_id e jobId do BullMQ igual ao id do evento.
  2. Aplicação: ranque de payments.status (12.4.4) e comparação de dateCreated do evento com o received_at do último evento já processado do mesmo objeto, lido de payment_events, para os campos que não têm ranque.
  3. Derivação: recomputeSubscriptionState é uma função pura do estado persistido; rodar duas vezes produz o mesmo resultado.

12.12.4 recomputeSubscriptionState #

Esta função é o coração da resistência a eventos fora de ordem. Ela não olha o evento; olha as cobranças gravadas e deriva o estado.

export async function recomputeSubscriptionState(tx: Tx, subscriptionId: string): Promise<void> {
  const sub = await tx.subscription.findUniqueOrThrow({ where: { id: subscriptionId } });
  // O intervalo do ciclo vem do plano; subscriptions não tem coluna de ciclo própria.
  const plan = await tx.plan.findUniqueOrThrow({ where: { id: sub.planId } });
  const payments = await tx.payment.findMany({
    where: { subscriptionId }, orderBy: { dueDate: 'desc' },
  });

  const terminal = payments.find((p) => p.status === 'REFUNDED' || p.status === 'CHARGEBACK');
  const latestPaid = payments.find((p) => p.status === 'RECEIVED' || p.status === 'CONFIRMED');
  const latestOverdue = payments.find((p) => p.status === 'OVERDUE');

  let next: SubscriptionStatus;
  let periodEnd: Date | null = sub.currentPeriodEnd;

  if (terminal) {
    next = 'REFUNDED';
    periodEnd = terminal.refundedAt ?? new Date();
  } else if (latestPaid && (!latestOverdue || latestOverdue.dueDate < latestPaid.dueDate)) {
    next = sub.cancelRequestedAt ? 'CANCELED' : 'ACTIVE';
    periodEnd = addCycle(latestPaid.confirmedAt ?? latestPaid.paidAt!, plan.interval);
  } else if (latestOverdue) {
    next = 'EXPIRED';
    periodEnd = latestOverdue.dueDate;
  } else {
    next = sub.status === 'PENDING_PAYMENT' ? 'PENDING_PAYMENT' : sub.status;
  }

  if (next !== sub.status || +periodEnd! !== +(sub.currentPeriodEnd ?? 0)) {
    await tx.subscription.update({
      where: { id: subscriptionId },
      data: { status: next, currentPeriodEnd: periodEnd },
    });
    await tx.subscriptionEvent.create({
      data: { id: ulid(), subscriptionId, fromStatus: sub.status, toStatus: next, source: 'WEBHOOK' },
    });
  }
  await syncSubscriberTier(tx, sub.subscriberId);
}

syncSubscriberTier materializa subscribers.tier a partir de resolveEntitlements (Seção 13.6). O cache existe por desempenho no motor de envio; a fonte da verdade continua sendo a função. A função dona da recomputação, e o nome pelo qual as outras seções devem referenciá-la, é recomputeSubscriptionState; syncSubscriberTier é uma etapa interna dela e nunca é chamada de fora.

Consequência para o motor de envio, e é a razão pela qual esta materialização é segura. subscribers.tier é relido pelo motor imediatamente antes de cada chamada ao provedor de mensagens, e não apenas no planejamento do lote das 05:40 (Seção 18.5). Um assinante cujo PAYMENT_OVERDUE for processado às 05:52 — depois do planejamento, antes do disparo das 06:00 — já está com tier = 'FREE' gravado quando o motor relê, e por isso recebe apenas o texto do plano gratuito, sem áudio, naquele mesmo dia. Sem essa releitura, o congelamento das 05:40 concederia na prática um dia de carência a todo inadimplente, o que a regra de revogação imediata proíbe.

12.12.5 Falhas e casos de borda do processamento #

Caso Comportamento
Evento cita subscription que não existe no nosso banco Cria assinatura órfã em PENDING_PAYMENT sem subscriber_id só se houver externalReference reconhecível; caso contrário grava processing_status = 'ORPHAN_SUBSCRIPTION' e cria tarefa administrativa. Nunca falha o job
Evento cita customer desconhecido processing_status = 'UNKNOWN_CUSTOMER', alerta billing_unknown_customer, sem retry
Assinante com deleted_at preenchido Aplica o efeito financeiro (cobrança/estorno) mas não envia mensagem
Falha ao enviar mensagem de confirmação Não afeta o estado financeiro; o job de mensagem tem retry próprio (Seção 18)
Postgres indisponível Job falha e é repetido; a Asaas não é afetada porque o webhook já respondeu 200
Valor do evento diverge do plano Aplica o valor da Asaas, grava payments.amount_cents real, e emite alerta billing_amount_mismatch com expected e received
Evento chega duas vezes com ids diferentes e mesmo payment O ranque impede regressão; o segundo evento vira NO_OP

12.13 Reconciliação diária #

12.13.1 Quando e como roda #

Job repetível billing.reconcile, todos os dias às 04:00 America/Sao_Paulo, registrado no BullMQ com tz: 'America/Sao_Paulo' e jobId: 'billing-reconcile-daily'. Roda antes do planejamento de envio das 05:40, de propósito: qualquer divergência de tier é corrigida antes de o motor de envio decidir quem recebe áudio.

Duração alvo: menos de 10 minutos para 3.000 assinaturas pagas. Com o teto de 8 requisições por segundo e 2 chamadas por assinatura, o pior caso é ~12,5 minutos; por isso a varredura usa consultas em lote (GET /v3/payments?subscription=... só para os divergentes) e um filtro prévio que reduz o conjunto.

12.13.2 O que compara #

Universo examinado a cada execução:

  1. Todas as subscriptions com status IN ('ACTIVE','PENDING_PAYMENT','CANCELED').
  2. Todas as subscriptions que mudaram de estado nas últimas 48 horas, em qualquer status.
  3. Todos os payments com due_date >= now() - interval '35 days' e status IN ('PENDING','OVERDUE','CONFIRMED').
  4. payment_events com processed_at IS NULL há mais de 15 minutos — indício de job perdido.

Para cada assinatura do conjunto, busca GET /v3/subscriptions/{id} e GET /v3/payments?subscription={id}&limit=100, e compara campo a campo:

Campo nosso Campo Asaas Divergência típica
payments.status payment.status webhook perdido
payments.due_date payment.dueDate vencimento reagendado
payments.amount_cents payment.value valor alterado no painel da Asaas
subscriptions.next_due_date subscription.nextDueDate ciclo avançou sem evento
subscriptions.amount_cents subscription.value mudança de preço aplicada manualmente
existência da assinatura 404 ou deleted: true removida na Asaas
subscribers.tier derivado tier materializado desatualizado

12.13.3 Como corrige #

A regra é única e absoluta: a Asaas vence. O procedimento por divergência:

  1. Atualiza a linha local com o valor da Asaas.
  2. Sintetiza um payment_events local com asaas_event_id = 'recon:' + <paymentId> + ':' + <status> e event_type correspondente, marcado source = 'RECONCILIATION'. O prefixo recon: garante que não colida com um id real e mantém a idempotência: se o evento verdadeiro chegar atrasado, o ranque impede reaplicação.
  3. Chama recomputeSubscriptionState (12.12.4).
  4. Registra em subscription_events com source = 'RECONCILIATION'.
  5. Envia a mensagem ao assinante apenas quando o efeito é ganho ou perda de acesso e nenhuma mensagem equivalente foi registrada em message_logs nas últimas 24 horas. Isso evita mensagem duplicada quando o webhook chegou mas a mensagem falhou, e garante a mensagem quando o webhook nunca chegou.

Casos especiais:

Situação Ação
Assinatura existe aqui e retorna 404 na Asaas Trata como SUBSCRIPTION_DELETED: encerra no fim do ciclo pago
Assinatura existe na Asaas e não aqui Cria linha órfã, tenta vincular por externalReference e, se não achar, por customersubscribers.asaas_customer_id; se ainda assim não achar, gera tarefa administrativa ORPHAN_SUBSCRIPTION
Pagamento RECEIVED na Asaas e OVERDUE aqui Restaura acesso: ACTIVE, tier PAID, mensagem "Recebemos seu pagamento"
Pagamento OVERDUE na Asaas e RECEIVED aqui Revoga: EXPIRED, tier FREE, mensagem de perda de acesso, alerta billing_recon_downgrade (é o caso mais grave e precisa de olho humano)
Nosso tier é PAID sem assinatura ativa nem cortesia vigente Rebaixa para FREE e registra TIER_DRIFT_FIXED
Nosso tier é FREE com assinatura ACTIVE Promove a PAID e registra TIER_DRIFT_FIXED
payment_events pendente há mais de 15 min Reenfileira o job billing.webhook, na fila billing.webhook, com o mesmo jobId
A API da Asaas está fora do ar O job falha, é repetido às 04:30, 05:00 e 05:30; se as três falharem, alerta billing_recon_failed e o motor de envio usa o tier materializado como está

12.13.4 Reparo de mensagens perdidas #

Além do estado, a reconciliação repara comunicação. Para cada subscription_events das últimas 72 horas cujo tipo exige mensagem (ativação, perda de acesso, estorno), verifica se existe message_logs correspondente com template_name esperado e status diferente de failed. Não existindo, enfileira o envio com a data original citada no texto. Limite: no máximo uma reparação por assinante por dia, para não transformar uma falha sistêmica em enxurrada de mensagens.

12.13.5 Relatório gerado #

Cada execução grava uma linha em job_runs com job_name = 'billing.reconcile', started_at, finished_at, status e um result jsonb:

{
  "scannedSubscriptions": 3128,
  "scannedPayments": 3402,
  "asaasRequests": 6289,
  "divergences": {
    "paymentStatus": 7,
    "dueDate": 2,
    "amount": 0,
    "subscriptionMissingLocally": 1,
    "subscriptionMissingRemotely": 0,
    "tierDrift": 3
  },
  "actions": {
    "upgradedToPaid": 5,
    "downgradedToFree": 2,
    "messagesRepaired": 4,
    "eventsRequeued": 1,
    "adminTasksCreated": 1
  },
  "durationMs": 418_220,
  "errors": []
}

O relatório aparece no painel administrativo em "Operação → Reconciliação", com histórico de 30 dias, e é enviado por e-mail a ops@palavradiaria.com.br apenas quando downgradedToFree > 0, subscriptionMissingLocally > 0 ou errors.length > 0. Execuções limpas não geram e-mail, para que o e-mail continue significando alguma coisa.

12.14 Reembolso e chargeback #

12.14.1 Reembolso solicitado pelo assinante #

O assinante não reembolsa sozinho pelo painel. Ele abre o pedido pelo canal de suporte, e um administrador executa. Decisão registrada: reembolso automático abriria espaço para abuso do padrão "assina, recebe o conteúdo do mês, pede reembolso" sem nenhuma barreira.

Política, decidida aqui e publicada na página de vendas (Seção 9): reembolso integral em até 7 dias corridos da primeira cobrança, conforme o direito de arrependimento do Art. 49 do Código de Defesa do Consumidor. Fora dessa janela, o reembolso é discricionário e depende de aprovação de um ADMIN.

Fluxo:

  1. Admin abre a assinatura no painel e aciona "Reembolsar cobrança".
  2. O sistema exige justificativa de 10 a 500 caracteres e confirmação por senha do admin.
  3. Chama POST /v3/payments/{id}/refund com o valor integral.
  4. Grava admin_audit_log com action = 'PAYMENT_REFUND', entity_type = 'payment', entity_id, justificativa.
  5. A Asaas emite PAYMENT_REFUNDED; o processamento normal (12.11, evento 7) revoga o acesso imediatamente e cancela a assinatura na Asaas com DELETE /v3/subscriptions/{id}.
  6. O assinante recebe a mensagem de confirmação de estorno com o prazo bancário.

Não esperamos o webhook para saber que deu certo: a resposta da chamada 14 já traz status: REFUNDED, e o processador é idempotente, então o efeito é aplicado na hora e o webhook posterior vira NO_OP.

Prazo bancário informado ao assinante: cartão de crédito, até duas faturas; PIX, até 1 dia útil. Esses prazos são do arranjo de pagamento, não nossos, e o texto deixa isso claro.

12.14.2 Chargeback #

Chargeback é iniciado pelo titular junto ao emissor. O sistema descobre pelo evento PAYMENT_CHARGEBACK_REQUESTED.

Efeito imediato, sem exceção: payments.status = CHARGEBACK, subscriptions.status = REFUNDED com end_reason = CHARGEBACK, tier FREE, e subscribers.billing_blocked_at = now().

billing_blocked_at bloqueia novo checkout com cartão para aquele assinante. Ele ainda pode assinar por PIX. O bloqueio é removido manualmente por um ADMIN, com justificativa registrada. Motivo da decisão: um assinante com chargeback aberto que reassina por cartão gera um segundo chargeback com alta probabilidade, e taxa de chargeback elevada ameaça a conta de recebimento inteira.

A mensagem enviada é neutra e sem acusação: informa que o acesso foi encerrado e oferece o canal de contato. Não afirmamos fraude, não cobramos explicação.

Disputa: ao receber PAYMENT_CHARGEBACK_DISPUTE, o sistema cria uma tarefa administrativa com prazo, listando as evidências que devem ser reunidas: registro de consent_events do opt-in, message_logs das entregas do período, e o receipt_url da cobrança. O sistema não envia documentação automaticamente; o envio é feito por uma pessoa pelo painel da Asaas.

Métrica de vigilância: chargeback_rate_30d = chargebacks nos últimos 30 dias dividido por cobranças confirmadas no mesmo período. Alerta em 0,5% e alerta crítico em 0,9%, porque as bandeiras costumam agir em torno de 1%. A definição vive junto das demais na Seção 21.

12.14.3 Comparação dos dois fluxos #

Aspecto Reembolso Chargeback
Quem inicia Assinante via suporte, executado por admin Titular junto ao emissor
Como o sistema sabe Resposta da chamada + webhook Somente webhook
Custo Valor devolvido Valor devolvido + taxa + risco de conta
Acesso Revogado imediatamente Revogado imediatamente
Assinatura na Asaas Removida por nós Removida por nós
Novo checkout com cartão Permitido Bloqueado até liberação manual
Mensagem Confirmação de estorno com prazo Aviso neutro de encerramento

12.15 Modo sandbox e roteiro de testes #

12.15.1 Preparação #

O ambiente local e o staging apontam para https://api-sandbox.asaas.com/v3 com uma chave de sandbox. O webhook do sandbox precisa de URL pública; em desenvolvimento, usa-se um túnel HTTP e a URL é registrada no painel sandbox. Alternativamente, o CLI de operação reproduz eventos sem túnel:

pnpm ops asaas:replay --event PAYMENT_CONFIRMED --payment pay_000000098765
pnpm ops asaas:replay --file ./fixtures/asaas/payment-overdue.json

asaas:replay monta o corpo, assina com ASAAS_WEBHOOK_TOKEN e faz POST no endpoint local. É o mesmo caminho de código do webhook real, inclusive a validação de token.

12.15.2 Cartões de teste #

Valores publicados pela Asaas para o ambiente de sandbox. O executor confere a lista vigente no painel sandbox antes de escrever os testes, porque ela pode ganhar novos casos.

Cenário Número Validade CVV Resultado esperado
Aprovado 5162306219378829 05/2031 318 PAYMENT_CONFIRMED
Recusado pelo emissor 5184019740373151 05/2031 318 CARD_DECLINED na criação
Número inválido (falha Luhn) 4000000000000001 05/2031 123 CARD_INVALID
Validade expirada 5162306219378829 01/2020 318 CARD_EXPIRED

Em sandbox, o titular deve ter CPF válido; o CPF 52998224725 usado nos exemplos serve.

12.15.3 Como simular cada cenário #

Cenário Como provocar
Pagamento confirmado (cartão) Checkout com o cartão aprovado
Pagamento recebido (PIX) POST /v3/payments/{id}/receiveInCash com paymentDate de hoje
Vencimento / inadimplência POST /v3/payments/{id} alterando dueDate para ontem e aguardar o processamento noturno do sandbox; ou pnpm ops asaas:replay --event PAYMENT_OVERDUE
Cobrança removida DELETE /v3/payments/{id}
Estorno POST /v3/payments/{id}/refund
Chargeback Sem gatilho na API do sandbox: usar asaas:replay --event PAYMENT_CHARGEBACK_REQUESTED
Assinatura removida DELETE /v3/subscriptions/{id}
Troca de ciclo POST /v3/subscriptions/{id} com cycle: "YEARLY" e value: 199.00
Webhook fora de ordem asaas:replay de PAYMENT_OVERDUE depois de PAYMENT_CONFIRMED
Webhook duplicado Repetir o mesmo asaas:replay duas vezes; a segunda deve virar NO_OP
Token de webhook errado curl manual com header inválido, esperando 401
Indisponibilidade da Asaas Variável ASAAS_API_BASE_URL apontando para uma porta fechada; valida o backoff e o PAYMENT_PROVIDER_UNAVAILABLE

Cada cenário desta tabela tem um teste automatizado correspondente. A estratégia de testes, os utilitários de fixture e a política de cobertura são da Seção 24.

12.15.4 Dados sintéticos e limpeza #

O ambiente staging roda pnpm ops seed:billing --subscribers 50 que cria assinantes sintéticos com telefones da faixa de teste e CPFs válidos gerados por algoritmo. A limpeza (pnpm ops purge:sandbox) remove assinaturas e clientes criados pelo seed usando o prefixo externalReference 01SEED, e nunca roda quando APP_ENV=production — o comando aborta com erro se detectar a base URL de produção.

12.16 Fora de escopo declarado #

Os itens abaixo não existem no produto e não devem ser implementados, nem parcialmente, nem "preparados para o futuro":

  • Split de pagamento entre recebedores. A conta Asaas é única.
  • Antecipação de recebíveis. O fluxo de caixa segue o prazo padrão do arranjo.
  • Emissão de nota fiscal, de serviço ou de consumidor, automática ou manual pelo sistema.
  • Boleto bancário como forma de pagamento. O prazo de compensação é incompatível com a regra de revogação imediata e com a entrega diária.
  • Cupons, descontos e campanhas promocionais. Não existe tabela de cupons, não existe campo de desconto no checkout e o preço vem sempre de plans.
  • Carteira, saldo, transferências, cobrança avulsa fora de assinatura e link de pagamento compartilhável.
  • Assinatura com múltiplos itens, upgrade com prorrateio e cobrança por uso.

Se algum desses itens for pedido depois, ele entra como escopo novo, com desenho próprio.

13. Assinaturas: Ciclo de Vida, Inadimplência e Entitlements #

Esta seção é a dona da máquina de estados da assinatura e da matriz de entitlements. Toda decisão de "este assinante pode receber áudio?", "quantos reenvios ele tem hoje?", "ele recebe todo dia ou só domingo?" resolve-se aqui, por uma única função. Nenhuma outra seção recalcula entitlement por conta própria — o motor de envio (Seção 18), o painel do assinante (Seção 14), o painel administrativo (Seção 15) e o catálogo de mensagens (Seção 19) chamam a função definida em 13.6 e usam o resultado.

As chamadas à Asaas citadas aqui pertencem à Seção 12. O schema das tabelas é da Seção 6. O envelope de resposta e o catálogo global de códigos de erro são da Seção 7.

13.1 Planos: preço, ciclo e representação #

Dois planos pagos. Não há plano intermediário, não há trial, não há cupom.

plans.code Nome exibido Preço Ciclo plans.interval amount_cents Valor mensal equivalente
plan_monthly Plano mensal R$ 19,90 mensal MONTHLY 1990 R$ 19,90
plan_annual Plano anual R$ 199,00 anual YEARLY 19900 R$ 16,58

A coluna que guarda o ciclo chama-se plans.interval, com esse nome exato e nenhum outro. Não existem plans.cycle nem plans.billing_cycle em lugar algum do documento.

O plano gratuito não é uma linha em plans. Ser gratuito é a ausência de assinatura paga vigente. Decisão registrada: modelar o gratuito como plano criaria uma assinatura fantasma sem cobrança, sem ciclo e sem contraparte na Asaas, e obrigaria toda consulta a distinguir "assinatura real" de "assinatura fictícia". A ausência é mais simples e não perde nenhuma informação.

Consequência para a interface, declarada aqui para não ser reinventada: onde a tela precisar exibir um "plano gratuito" — a tabela comparativa da página de vendas, o seletor de plano no painel —, esse item é sintetizado pelo handler e não corresponde a nenhuma linha da tabela plans. O handler lê plans para os planos pagos ativos, ordenados por sort_order, e prepende um item fixo { code: 'plan_free', tier: 'FREE', name: 'Gratuito', interval: null, amountCents: 0 }. O código plan_free existe apenas na camada de apresentação: nenhuma escrita o usa, nenhuma consulta o procura em plans, e criar essa linha no banco para "fazer a rota funcionar" é erro, não atalho.

Economia do plano anual: R$ 238,80 − R$ 199,00 = R$ 39,80 por ano, ou 16,7% de desconto. O rótulo exibido ao público é arredondado para baixo — "economize 16%" — para nunca prometer mais do que se entrega; o número exato de 16,7% aparece só em documentação interna. O cálculo é feito a partir de plans.amount_cents, nunca digitado no texto.

13.1.2 Representação no nosso banco #

plans guarda code, display_name, amount_cents, currency (sempre BRL), interval, active e sort_order. Colunas, tipos e índices na Seção 6.

subscriptions guarda plan_id, amount_cents (cópia do preço no momento da contratação, não referência viva), billing_type, status, current_period_start, current_period_end, next_due_date, cancel_requested_at, cancel_at_period_end, ended_at, end_reason, pending_plan_id, pending_plan_effective_at, asaas_subscription_id, asaas_card_token, card_last4, card_brand, card_exp_month, card_exp_year.

subscriptions não tem coluna própria de ciclo. O ciclo de uma assinatura é sempre lido de plans.interval através de plan_id. Onde uma resposta de API expõe billingCycle, o valor é derivado dessa leitura, não de uma coluna. Motivo: um plano nunca muda de intervalo — trocar de ciclo é trocar de plano (13.8) —, então copiar o intervalo criaria uma segunda verdade que só poderia divergir.

Dinheiro é sempre inteiro em centavos nas colunas *_amount_cents, com divisor 100 para reais, conforme a convenção de unidades da Seção 21.2. Nenhum valor monetário desta seção é representado em ponto flutuante, e a conversão para o decimal que a Asaas espera acontece em um único lugar (Seção 12.4.3).

A cópia do preço em subscriptions.amount_cents é deliberada e é a base da regra de 13.12.7: mudar plans.amount_cents não altera o que um assinante existente paga.

13.1.3 Representação na Asaas #

Nosso conceito Objeto/campo na Asaas
plan_monthly subscription.cycle = "MONTHLY", value = 19.90
plan_annual subscription.cycle = "YEARLY", value = 199.00
assinatura subscription com externalReference = subscriptions.id
cobrança do ciclo payment gerado pela Asaas

A Asaas não tem catálogo de produtos: cada assinatura carrega o próprio valor e ciclo. Por isso o nosso plans é a única fonte de preço, e o valor é enviado a cada criação.

13.2 Máquina de estados #

13.2.1 Estados #

subscriptions.status Significado Concede acesso PAID?
PENDING_PAYMENT Criada, aguardando a primeira confirmação Não
ACTIVE Ciclo pago e vigente Sim, até current_period_end
CANCELED Cancelamento pedido pelo assinante ou pelo admin; ciclo pago segue valendo Sim, até current_period_end
EXPIRED Encerrada por falta de pagamento ou por fim de ciclo sem renovação Não
REFUNDED Encerrada por estorno ou chargeback Não

Não existe PAST_DUE, GRACE, SUSPENDED nem TRIALING. A ausência de PAST_DUE é a tradução direta da regra de 13.3: não há estado intermediário entre "pagou" e "não tem acesso".

CANCELED concede acesso porque representa um ciclo já pago cujo término foi antecipadamente solicitado. EXPIRED não concede porque representa um ciclo não pago ou já terminado. Essa é a distinção inteira do produto, e ela está detalhada em 13.4.

13.2.2 Diagrama #

                       POST /api/me/subscription
                                │
                                ▼
                      ┌──────────────────────┐
       PAYMENT_DELETED│   PENDING_PAYMENT    │ PAYMENT_OVERDUE / abandono 24h
       ┌──────────────┤  (sem acesso PAID)   ├───────────────┐
       │              └──────────┬───────────┘               │
       │                         │ PAYMENT_CONFIRMED         │
       │                         │ ou PAYMENT_RECEIVED       │
       │                         ▼                           │
       │              ┌──────────────────────┐               │
       │              │       ACTIVE         │◄──────────┐   │
       │              │   (acesso PAID)      │           │   │
       │              └───┬────┬────┬────────┘           │   │
       │                  │    │    │                    │   │
       │  cancelamento    │    │    │ PAYMENT_REFUNDED   │   │ PAYMENT_CONFIRMED
       │  voluntário      │    │    │ ou CHARGEBACK      │   │ (retentativa aprovada)
       │  (painel)        │    │    │                    │   │
       │                  ▼    │    ▼                    │   │
       │       ┌──────────────┐│ ┌──────────────┐        │   │
       │       │  CANCELED    ││ │  REFUNDED    │        │   │
       │       │ acesso PAID  ││ │ sem acesso   │        │   │
       │       │ até o fim do ││ │ (terminal)   │        │   │
       │       │ ciclo pago   ││ └──────────────┘        │   │
       │       └──────┬───────┘│                         │   │
       │              │        │ PAYMENT_OVERDUE         │   │
       │  fim do ciclo│        │ PAYMENT_DELETED         │   │
       │  (job 00:05) │        │ (revogação imediata)    │   │
       │              ▼        ▼                         │   │
       │       ┌───────────────────────────┐             │   │
       └──────►│         EXPIRED           │─────────────┘   │
               │      (sem acesso)         │◄────────────────┘
               └───────────┬───────────────┘
                           │ nova assinatura (13.10)
                           ▼
                  volta para PENDING_PAYMENT

Estados terminais: REFUNDED é definitivo para aquela linha. EXPIRED e CANCELED são terminais para a linha, mas o assinante pode criar uma nova assinatura. Uma linha nunca é reaproveitada: reativação sempre cria linha nova, o que preserva o histórico e simplifica a auditoria.

13.2.3 Tabela de transições #

# De Gatilho Para Efeitos colaterais Mensagem Quem causa
T1 POST /api/me/subscription aceito PENDING_PAYMENT Cria linha; cria customer e subscription na Asaas; subscription_events CREATED Nenhuma Assinante
T2 PENDING_PAYMENT PAYMENT_CONFIRMED ou PAYMENT_RECEIVED ACTIVE current_period_start = confirmação, current_period_end = +1 ciclo; chama grantPaidAccess() na mesma transação (13.3.1) Confirmação de pagamento com data de término Asaas (webhook)
T3 PENDING_PAYMENT PAYMENT_OVERDUE EXPIRED end_reason = NON_PAYMENT; tier permanece FREE Aviso de cobrança não concluída com link para tentar de novo Asaas
T4 PENDING_PAYMENT PAYMENT_DELETED EXPIRED end_reason = PAYMENT_DELETED Nenhuma Asaas ou admin
T5 PENDING_PAYMENT Job billing.abandon-pending, fila billing.lifecycle, após 24 h sem evento EXPIRED end_reason = ABANDONED; DELETE /v3/subscriptions/{id} PIX: lembrete único com o código; cartão: nenhuma Sistema
T6 ACTIVE PAYMENT_CONFIRMED do ciclo seguinte ACTIVE current_period_end avança um ciclo; next_due_date atualizado; grantPaidAccess() Recibo de renovação Asaas
T7 ACTIVE PAYMENT_OVERDUE EXPIRED ended_at = now(), end_reason = NON_PAYMENT; chama revokePaidAccess() na mesma transação (13.3.1) Aviso de perda de acesso com botão de reativar Asaas
T8 ACTIVE PAYMENT_DELETED CANCELED end_reason = PAYMENT_DELETED; chama revokePaidAccess() na mesma transação (13.3.1) Aviso de encerramento Asaas ou admin
T9 ACTIVE PAYMENT_REFUNDED REFUNDED ended_at = now(); chama revokePaidAccess() (13.3.1); DELETE /v3/subscriptions/{id} Confirmação de estorno Admin (12.14.1)
T10 ACTIVE PAYMENT_CHARGEBACK_REQUESTED REFUNDED chama revokePaidAccess() (13.3.1); subscribers.billing_blocked_at = now(); DELETE na Asaas Aviso neutro de encerramento Titular do cartão
T11 ACTIVE POST /api/me/subscription/cancel CANCELED cancel_requested_at = now(), cancel_at_period_end = true; DELETE /v3/subscriptions/{id}; nenhuma função de acesso é chamada — o tier segue PAID até o fim do período pago Confirmação com a data exata de término Assinante
T12 ACTIVE Admin cancela pelo painel CANCELED Igual a T11 + admin_audit_log Igual a T11 Admin
T13 ACTIVE SUBSCRIPTION_DELETED sem pedido nosso CANCELED end_reason = PROVIDER_DELETED; acesso até current_period_end; alerta operacional Confirmação de cancelamento Asaas
T14 CANCELED Job billing.lifecycle às 00:05 e current_period_end <= now() EXPIRED end_reason mantido; chama revokePaidAccess() (13.3.1) Mensagem de despedida com oferta de retorno Sistema
T15 CANCELED PAYMENT_REFUNDED REFUNDED chama revokePaidAccess() (13.3.1), antecipando T14 Confirmação de estorno Admin
T16 CANCELED PAYMENT_OVERDUE (cobrança do próximo ciclo que não deveria existir) EXPIRED chama revokePaidAccess() (13.3.1); alerta billing_canceled_but_charged Aviso de perda de acesso Asaas
T17 EXPIRED PAYMENT_CONFIRMED tardio da mesma cobrança ACTIVE Recalcula período a partir da confirmação; chama grantPaidAccess() (13.3.1) "Recebemos seu pagamento. Seu acesso foi restaurado." Asaas
T18 EXPIRED | CANCELED | REFUNDED POST /api/me/subscription (reativação) nova linha em PENDING_PAYMENT Linha antiga imutável; subscription_events REACTIVATION_STARTED Nenhuma até T2 Assinante
T19 qualquer Reconciliação detecta divergência conforme a Asaas source = 'RECONCILIATION' em subscription_events Só se houver ganho/perda de acesso (12.13.3) Sistema

Transições proibidas, rejeitadas com code: INVALID_SUBSCRIPTION_TRANSITION (409) e registro ERROR subscription_invalid_transition: REFUNDED → qualquer coisa; EXPIREDCANCELED; CANCELEDACTIVE (a reativação cria linha nova, T18); PENDING_PAYMENTCANCELED (usa T4/T5).

A guarda é implementada como tabela de adjacência em packages/core, e toda escrita em subscriptions.status passa por applyTransition():

const ALLOWED: Record<SubscriptionStatus, SubscriptionStatus[]> = {
  PENDING_PAYMENT: ['ACTIVE', 'EXPIRED'],
  ACTIVE: ['ACTIVE', 'CANCELED', 'EXPIRED', 'REFUNDED'],
  CANCELED: ['EXPIRED', 'REFUNDED'],
  EXPIRED: ['ACTIVE'],
  REFUNDED: [],
};

export function assertTransition(from: SubscriptionStatus, to: SubscriptionStatus): void {
  if (!ALLOWED[from].includes(to)) {
    throw new DomainError('INVALID_SUBSCRIPTION_TRANSITION', `${from} -> ${to}`);
  }
}

13.3 A regra dura: não existe período de carência #

Não há período de carência. Em nenhuma circunstância. Para nenhum plano. Para nenhuma forma de pagamento. Para nenhum assinante.

O que isso significa, sem ambiguidade:

  1. Quando o pagamento não acontece, o acesso pago acaba no instante em que o sistema descobre — ou seja, no processamento do webhook PAYMENT_OVERDUE. Não há 1 dia, 3 dias, 7 dias, "até o fim do mês" ou "até o próximo domingo".
  2. A revogação acontece na mesma transação de banco que processa o evento. Não há job noturno de rebaixamento, não há fila de "a rebaixar", não há janela em que o assinante esteja inadimplente e ainda pago.
  3. Não existe estado PAST_DUE, GRACE_PERIOD ou SUSPENDED no enum. Se alguém precisar de um, a resposta é não.
  4. Não existe variável de ambiente, campo em settings ou flag em feature_flags que configure carência. A ausência é estrutural, não configurável.
  5. O mesmo vale para PAYMENT_DELETED, PAYMENT_REFUNDED e PAYMENT_CHARGEBACK_REQUESTED: revogação imediata, na transação.

13.3.1 As duas funções que mudam o acesso — e são as únicas #

Todo ganho e toda perda de acesso pago passam por duas funções, e apenas duas, definidas aqui. Nenhuma outra seção descreve por extenso o que acontece quando o acesso muda: as Seções 12.11, 13.2.3, 13.9, 13.11 e 20 dizem qual função é chamada e param por aí. A razão é concreta: a lista de efeitos aparecia por extenso em oito linhas de tabela da Seção 12.11, em sete transições da 13.2.3 e em duas subseções da 20; eram dezessete lugares que divergiriam na primeira mudança de regra, e nenhum teste pegaria a divergência.

// packages/core/src/subscription/access.ts

/**
 * Retira o acesso pago. Sem carência, sem agendamento, sem janela de tolerância.
 * Chamada SEMPRE dentro da transação que processa o fato que causou a perda.
 */
export async function revokePaidAccess(
  tx: Tx,
  subscriberId: string,
  reason: EndReason,
): Promise<void> {
  await tx.subscriber.update({ where: { id: subscriberId }, data: { tier: 'FREE' } });
  await tx.subscriptionEvent.create({
    data: { id: ulid(), subscriberId, type: 'PAID_ACCESS_REVOKED', reason },
  });
  await invalidateEntitlementCache(tx, subscriberId);
  tx.afterCommit(() => enqueueAccessRevokedMessage(subscriberId, reason));
}

/**
 * Concede ou prorroga o acesso pago até `periodEnd`.
 * Idempotente: chamar duas vezes com o mesmo `periodEnd` não produz efeito adicional.
 */
export async function grantPaidAccess(
  tx: Tx,
  subscriberId: string,
  periodEnd: Date,
): Promise<void> {
  await tx.subscriber.update({ where: { id: subscriberId }, data: { tier: 'PAID' } });
  await tx.subscriptionEvent.create({
    data: { id: ulid(), subscriberId, type: 'PAID_ACCESS_GRANTED', paidAccessUntil: periodEnd },
  });
  await invalidateEntitlementCache(tx, subscriberId);
}

Efeitos de revokePaidAccess(), na ordem exata, e esta lista é a única do documento:

# Efeito Momento
1 subscribers.tier = 'FREE' Mesma transação
2 Linha em subscription_events com type = 'PAID_ACCESS_REVOKED' e o motivo Mesma transação
3 Cache de entitlements invalidado Mesma transação
4 Frequência cai para o dia único do plano gratuito, áudio desligado, acervo limitado a 7 dias, reenvios limitados a 1 por dia Imediato, porque são derivados da função de 13.6.2
5 Envios do lote de hoje ainda não disparados deixam de levar áudio No disparo, pela revalidação obrigatória descrita abaixo
6 Mensagem de perda de acesso enfileirada Após o commit

O passo 4 não precisa de escrita nenhuma: resolveEntitlements deriva tudo do estado, então mudar o estado muda o comportamento no mesmo instante. É por isso que a lista é curta.

13.3.2 A revogação alcança o lote já planejado #

O motor de envio planeja o lote do dia às 05:40 e dispara a partir das 06:00 (Seção 18). Entre esses dois momentos há de vinte minutos a mais de meia hora, e o plano diário congela o tier de cada assinante em delivery_attempts.tier_at_send.

tier_at_send é registro histórico, nunca autoridade. Imediatamente antes de chamar o provedor de mensagens, e dentro do mesmo job, o motor relê subscribers.tier, opt_out_at, deleted_at e blocked_at por chave primária e aplica o resultado na hora (Seção 18.5). Um assinante planejado como PAID às 05:40 cujo PAYMENT_OVERDUE for processado às 05:52 recebe, às 06:00, apenas o texto do plano gratuito, sem áudio, com delivery_attempts.downgraded_at preenchido e tier_at_send_effective = 'FREE'.

Isso não é um detalhe de implementação do motor: é o que torna a regra desta seção verdadeira. Sem a releitura, o congelamento das 05:40 concederia na prática um dia de carência a todo inadimplente, todos os dias — exatamente o que os cinco itens acima proíbem. A mesma revalidação vale para as varreduras de reenvio que rodam até 23:55.

13.3.3 O caminho de código da inadimplência #

// packages/core/src/subscription/apply-overdue.ts
import { revokePaidAccess } from './access';

export async function applyOverdue(tx: Tx, payment: Payment): Promise<void> {
  // Sem carência: a revogação é parte da MESMA transação do processamento do evento.
  await tx.subscription.update({
    where: { id: payment.subscriptionId! },
    data: { status: 'EXPIRED', endedAt: new Date(), endReason: 'NON_PAYMENT', cancelAtPeriodEnd: false },
  });
  await revokePaidAccess(tx, payment.subscriberId, 'NON_PAYMENT');
  // Não existe agendamento de rebaixamento futuro. Não existe janela de tolerância.
}

E o teste que impede a regra de ser afrouxada por engano:

it('revoga o acesso na mesma transação, sem qualquer carência', async () => {
  const { subscriber, subscription } = await seedActivePaidSubscriber();
  await processAsaasEvent(await seedEvent('PAYMENT_OVERDUE', subscription));

  const after = await db.subscriber.findUniqueOrThrow({ where: { id: subscriber.id } });
  expect(after.tier).toBe('FREE');
  expect(resolveEntitlements(await loadSubject(subscriber.id), new Date()).effectiveTier).toBe('FREE');
  // Nenhum job de rebaixamento agendado: a lista precisa estar vazia nas três filas de cobrança.
  for (const queue of [billingWebhookQueue, billingReconcileQueue, billingLifecycleQueue]) {
    expect(await queue.getDelayed()).toHaveLength(0);
  }
});

E o teste que prova que a revogação alcança o lote já planejado, que é o caso que o teste acima não cobre:

it('rebaixa no disparo quem perdeu o acesso depois do planejamento', async () => {
  const { subscriber, subscription } = await seedActivePaidSubscriber();
  await planDailyBatch({ at: at('05:40') });                       // congela tier_at_send = PAID
  await processAsaasEvent(await seedEvent('PAYMENT_OVERDUE', subscription), { at: at('05:52') });
  await dispatchDailyBatch({ at: at('06:00') });

  const attempt = await db.deliveryAttempt.findFirstOrThrow({ where: { subscriberId: subscriber.id } });
  expect(attempt.tierAtSend).toBe('PAID');                          // histórico preservado
  expect(attempt.tierAtSendEffective).toBe('FREE');                 // o que de fato saiu
  expect(attempt.downgradedAt).not.toBeNull();
  expect(providerSpy.calls.filter((c) => c.type === 'audio')).toHaveLength(0);
});

O que é permitido e não contradiz a regra: lembretes de cobrança antes do vencimento (13.5). Lembrar alguém de pagar não é dar prazo depois do vencimento.

13.4 Inadimplência versus cancelamento voluntário #

Estes dois casos são confundidos com frequência e produzem decisões erradas. Eles são diferentes na causa, no efeito e na data em que o acesso termina.

13.4.1 Definições #

(a) Inadimplência — o pagamento do ciclo não ocorreu. O assinante não tem crédito adquirido para o período à frente. O acesso cai imediatamente, no momento do PAYMENT_OVERDUE. Estado: EXPIRED.

(b) Cancelamento voluntário — o assinante já pagou o ciclo corrente e pede para não renovar. Ele comprou aquele período e tem direito a ele. O acesso continua até current_period_end e só então cai para FREE. Estado: CANCELED até o fim do período, depois EXPIRED.

O ponto que precisa ficar claro: (b) não é carência. Carência seria dar acesso a um período não pago. Em (b) o período está pago; entregar o que foi vendido é obrigação contratual, não tolerância.

13.4.2 Exemplo numérico (a) — inadimplência PIX #

Assinante mensal PIX, R$ 19,90, ciclo vigente de 10/08/2026 a 10/09/2026. Cobrança do ciclo seguinte: dueDate = 2026-09-10 (quinta-feira). Ele não paga.

 2026-09-07 (seg)   2026-09-09 (qua)  2026-09-10 (qui)   2026-09-11 (sex)      2026-09-13 (dom)
        │                  │                 │                  │                     │
   lembrete D-3       lembrete D-1      lembrete D0        00:07 webhook          06:00 envio
   06:30 WhatsApp     06:30 WhatsApp    09:00 WhatsApp     PAYMENT_OVERDUE        SEMANAL (texto,
        │                  │                 │             → EXPIRED               sem áudio)
        │                  │                 │             → tier = FREE                │
 ═══════╪══════════════════╪═════════════════╪══════════════════╪═════════════════════╪══════
 ACESSO PAGO (diário + áudio) ───────────────────────────────►  │  ACESSO GRATUITO ──►
                                                                 06:00 de 11/09 (sex):
                                                                 NADA é enviado.

Números: o acesso pago termina em 11/09 às 00:07, não em 10/09 às 23:59 e não em 17/09. O devocional de 11/09 (sexta) não é enviado. O primeiro devocional que ele recebe depois disso é o de domingo, 13/09, apenas texto. Ele não recebe áudio a partir de 11/09.

13.4.3 Exemplo numérico (b) — cancelamento voluntário no cartão #

Assinante mensal no cartão, R$ 19,90, cobrança confirmada em 10/08/2026, ciclo de 10/08 a 10/09. Em 22/08/2026 às 21:14 ele cancela pelo painel.

 2026-08-10        2026-08-22 21:14         2026-09-10 23:59:59      2026-09-11 00:05
      │                   │                          │                      │
 pagamento          cancelamento pedido        fim do período pago     job billing.lifecycle
 confirmado         status = CANCELED          (current_period_end)    → EXPIRED, tier FREE
      │             cancel_at_period_end=true         │                      │
      │             DELETE na Asaas                   │                      │
 ═════╪═══════════════════╪══════════════════════════╪══════════════════════╪═══════════
 ACESSO PAGO: diário + áudio, sem interrupção ──────────────────────────►   │ GRATUITO ►
                                                                             próximo envio:
                                                                             domingo 13/09

Números: ele continua recebendo devocional diário com áudio de 22/08 até 10/09, inclusive — 20 dias após pedir o cancelamento. Não há nova cobrança em 10/09 porque a assinatura foi removida na Asaas em 22/08. Em 11/09 às 00:05 ele passa a FREE.

13.4.4 Comparação lado a lado #

(a) Inadimplência (b) Cancelamento voluntário
Causa Pagamento não ocorreu Assinante pediu para não renovar
Ciclo corrente Não pago Pago
Estado EXPIRED CANCELED, depois EXPIRED
Tier no ato FREE imediato PAID até current_period_end
Quem dispara Webhook PAYMENT_OVERDUE Ação no painel
Data de término Momento do webhook current_period_end
Cobrança futura Não existe Removida na Asaas no ato
É carência? Não — não há tolerância nenhuma Não — é período já comprado
Mensagem Perda de acesso + reativar Confirmação com a data de término
end_reason NON_PAYMENT USER_REQUEST

13.4.5 O caso híbrido #

Um assinante CANCELED cujo cartão sofre estorno antes do fim do período pago perde o acesso na hora (T15). O crédito deixou de existir, então o direito ao período também. Isso reforça a regra: o acesso em CANCELED existe porque o dinheiro foi pago, não porque o assinante pediu gentilmente.

13.4.6 Opt-out não cancela a assinatura, mas suspende a cobrança #

Terceiro caso, distinto dos dois anteriores e frequentemente confundido com eles: o assinante pago escreve SAIR no WhatsApp, ou desliga o recebimento pelo painel. Os envios param na hora (Seção 20.3). A pergunta que precisa de resposta escrita é: a cobrança continua?

A regra completa, e ela é obrigatória:

  1. O opt-out não cancela a assinatura de imediato. Um SAIR acidental — e eles acontecem, porque a palavra chega por engano, por dedo errado, por criança com o telefone — não pode destruir uma contratação.
  2. O opt-out suspende a cobrança do ciclo seguinte. Na mesma transação que grava opt_out_at, a assinatura recebe cancel_at_period_end = true e billing_suspended_at = now(), e a cobrança futura é removida na Asaas com DELETE /v3/subscriptions/{id}. O período já pago segue valendo, como em qualquer cancelamento voluntário (13.4.1 caso b). As duas colunas não são redundantes e a relação entre elas é a declarada na Seção 6.11: cancel_at_period_end é o sinalizador booleano, verdadeiro em qualquer cancelamento voluntário e também no opt-out; billing_suspended_at é o carimbo de quando o sinalizador foi ligado por opt-out, e permanece nulo quando a origem foi cancelamento voluntário.
  3. O assinante recebe, no ato, uma confirmação com o conteúdo obrigatório: "Paramos de enviar. Sua próxima cobrança está suspensa. Se quiser voltar, é só responder VOLTAR; se quiser encerrar de vez, cancele no painel." O canal dessa confirmação é o da Seção 20.4.1 e não admite variação: opt-out pedido pelo WhatsApp é confirmado por uma única mensagem no WhatsApp e nada mais; opt-out pedido pelo painel ou pelo link de descadastro é confirmado na tela e por e-mail, quando houver e-mail verificado, e não gera mensagem no WhatsApp — quem pediu silêncio no WhatsApp por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp.
  4. Se em 30 dias não houver reativação do recebimento, a assinatura é encerrada ao fim do período já pago, com aviso, e o acesso ao acervo permanece até essa data. Quem executa é o job billing.lifecycle.
  5. O painel exibe, nesse intervalo, o estado "envios pausados, cobrança suspensa", com as duas ações possíveis lado a lado: voltar a receber, ou cancelar de vez.
  6. Reativar o recebimento dentro dos 30 dias não ressuscita sozinho a cobrança: o assinante volta a receber com o acesso pago que já comprou, e o painel pergunta explicitamente se ele quer retomar a renovação automática. Retomar é uma ação dele, com um clique registrado.

Motivo registrado, porque a decisão parece burocrática e não é: a base legal da entrega é o consentimento específico do titular para tratamento de dado sensível. Revogado o consentimento, não é lícito prestar o serviço — e cobrar por um serviço que a lei nos proíbe de prestar é, ao mesmo tempo, infração ao Código de Defesa do Consumidor e o tipo de caso que vira reclamação pública. Manter a cobrança rodando enquanto os envios estão parados foi o buraco que esta regra fecha.

Isto não é carência e não contradiz a Seção 13.3: nada aqui concede acesso a período não pago. O que a regra faz é parar de cobrar por período futuro que não poderá ser entregue.

13.5 Cobrança e lembretes antes do vencimento #

Lembretes existem para reduzir inadimplência involuntária. Eles acontecem antes do vencimento, nunca depois. Nenhum lembrete concede acesso, adia revogação ou altera estado.

Momento Quando dispara Público Canal Conteúdo
D-3 3 dias antes de due_date, 06:30 PIX WhatsApp (janela aberta) ou template Valor, data, código copia-e-cola em mensagem separada
D-1 1 dia antes, 06:30 PIX WhatsApp Valor, data ("amanhã"), código copia-e-cola
D0 dia do vencimento, 09:00 PIX WhatsApp Aviso de vencimento hoje + aviso de que o acesso termina amanhã
D-5 5 dias antes, 10:00 Cartão, só quando o cartão vence no mês WhatsApp + e-mail se verificado Pedido de atualização do cartão com link do painel
D-3 anual 3 dias antes da renovação anual Cartão e PIX, plano anual WhatsApp + e-mail Aviso de renovação com valor e data

Textos (a redação final e as variações são da Seção 19; aqui está o conteúdo obrigatório):

  • D-3 PIX: "Sua assinatura do Palavra Diária vence em 10/09. Valor: R$ 19,90. Pague pelo PIX com o código da próxima mensagem." Seguida de uma mensagem contendo apenas o copia-e-cola.
  • D-1 PIX: "Sua assinatura vence amanhã, 10/09. Valor: R$ 19,90. Código PIX na próxima mensagem."
  • D0 PIX: "Hoje é o último dia para pagar sua assinatura (R$ 19,90). Sem o pagamento, seu acesso ao plano pago termina amanhã." Seguida do copia-e-cola.
  • D-5 cartão vencendo: "Seu cartão final 8829 vence este mês. Atualize em app.palavradiaria.com.br/assinatura para não perder o acesso."
  • D-3 renovação anual: "Sua assinatura anual do Palavra Diária renova em 10/09 por R$ 199,00. Se quiser mudar ou cancelar, acesse app.palavradiaria.com.br/assinatura."

Regras de execução:

  • O job billing.payment-reminders roda na fila billing.lifecycle, todos os dias às 06:25 e às 08:55, consultando payments com status = 'PENDING' e due_date nos deslocamentos previstos.
  • Existe um único template de lembrete de pagamento: lembrete_pagamento_v1. Os cinco momentos da tabela acima são o mesmo template com parâmetros diferentes, distinguidos pela coluna message_logs.message_key (dunning_d3, dunning_d1, dunning_d0, card_expiry_d5, annual_renewal_d3). Não se cria um template por estágio: cada template novo exige aprovação da Meta, amplia a superfície de recategorização e é mais uma coisa que pode ficar PAUSED na véspera do vencimento. Os oito templates do produto estão na Seção 17.5 e nenhuma seção acrescenta um nono.
  • Idempotência: chave reminder:{paymentId}:{offset} gravada em delivery_attempts, com índice único. Um reprocessamento não reenvia.
  • Um assinante recebe no máximo 3 lembretes por cobrança. Cobranças reagendadas (PAYMENT_UPDATED mudando dueDate) recalculam os deslocamentos, mas a chave de idempotência inclui o offset, então D-3 já enviado não repete.
  • Lembretes respeitam opt-out e pausa: quem fez opt-out (Seção 20.3) não recebe lembrete pelo WhatsApp. Recebe por e-mail se tiver e-mail verificado; se não tiver, não recebe nada e a cobrança segue seu curso. Isso é consequência aceita do opt-out.
  • Lembretes não são enviados para assinaturas CANCELED, porque não haverá nova cobrança.

Declaração explícita, para não restar dúvida: lembretes não são carência. Eles antecedem o vencimento. Depois do vencimento não há mensagem de cobrança nem prazo extra — há a mensagem de perda de acesso, e o acesso já foi revogado quando ela é enviada.

13.6 Matriz de entitlements e a função que a resolve #

13.6.1 A matriz #

Esta é a fonte única da verdade sobre o que cada tier recebe. Toda outra seção referencia esta subseção e não redefine nenhuma linha. Onde outra seção precisar do limite de reenvios, da frequência de envio ou do tamanho do acervo, ela chama resolveEntitlements() e usa o campo correspondente.

Capacidade Campo FREE PAID
Frequência do devocional sendFrequency WEEKLY_SUNDAY — domingo, 06:00 DAILY — todos os dias, 06:00
Texto completo do devocional fullTextEnabled true true
Áudio narrado audioEnabled false true
Acervo no painel web archiveWindowDays 7 (últimos 7 dias) null (completo, desde a assinatura)
Reenvio manual do devocional do dia manualResendsPerDay 1 3
Suporte supportChannel FAQ_EMAIL EMAIL_SLA_1_BUSINESS_DAY

Notas que evitam interpretação livre:

  • WEEKLY_SUNDAY usa o dia configurado na chave de configuração send.free_tier_weekday, cujo valor padrão é 0 (domingo). A chave segue o formato grupo.chave do catálogo da Seção 26.8.1 e é lida de settings, nunca de variável de ambiente — FREE_TIER_SEND_WEEKDAY não existe. A chave existe para permitir mudança operacional sem deploy; o padrão é domingo.
  • archiveWindowDays = 7 significa devocionais com scheduled_for >= hoje − 7 dias, contados em America/Sao_Paulo, independentemente de terem sido enviados a ele. O limite é imposto no servidor, na própria consulta do acervo, e não na montagem da tela: um assinante gratuito que chame a rota de listagem diretamente, com o maior limite aceito e percorrendo todos os cursores, recebe no máximo os 7 dias mais recentes, e a leitura de um devocional fora da janela devolve 403. Filtrar só na interface deixaria o acervo pago inteiro acessível a qualquer pessoa com um cliente HTTP.
  • archiveWindowDays = null significa desde a data da primeira assinatura paga do assinante, não desde o cadastro. Quem foi pago, deixou de ser e voltou, vê tudo desde a primeira vez.
  • manualResendsPerDay conta reenvios por dia de calendário local, zerando à meia-noite de America/Sao_Paulo.

13.6.2 A função #

// packages/core/src/entitlements.ts
export type Tier = 'FREE' | 'PAID';
export type SendFrequency = 'DAILY' | 'WEEKLY_SUNDAY';
export type SupportChannel = 'FAQ_EMAIL' | 'EMAIL_SLA_1_BUSINESS_DAY';
export type TierSource = 'SUBSCRIPTION' | 'COURTESY' | 'NONE';

/** Tudo de que a resolução precisa. Nenhuma consulta acontece dentro da função. */
export interface EntitlementSubject {
  subscriberId: string;
  optInConfirmedAt: Date | null;
  optOutAt: Date | null;
  pausedUntil: Date | null;
  deletedAt: Date | null;
  courtesyUntil: Date | null;
  subscription: {
    status: 'PENDING_PAYMENT' | 'ACTIVE' | 'CANCELED' | 'EXPIRED' | 'REFUNDED';
    currentPeriodEnd: Date | null;
  } | null;
}

export interface Entitlements {
  effectiveTier: Tier;
  tierSource: TierSource;
  paidAccessUntil: Date | null;
  sendFrequency: SendFrequency;
  fullTextEnabled: boolean;
  audioEnabled: boolean;
  archiveWindowDays: number | null;
  manualResendsPerDay: number;
  supportChannel: SupportChannel;
  canReceiveMessages: boolean;
  blockedReason: 'NOT_OPTED_IN' | 'OPTED_OUT' | 'PAUSED' | 'DELETED' | null;
}

const FREE: Omit<Entitlements, 'effectiveTier' | 'tierSource' | 'paidAccessUntil' | 'canReceiveMessages' | 'blockedReason'> = {
  sendFrequency: 'WEEKLY_SUNDAY',
  fullTextEnabled: true,
  audioEnabled: false,
  archiveWindowDays: 7,
  manualResendsPerDay: 1,
  supportChannel: 'FAQ_EMAIL',
};

const PAID: typeof FREE = {
  sendFrequency: 'DAILY',
  fullTextEnabled: true,
  audioEnabled: true,
  archiveWindowDays: null,
  manualResendsPerDay: 3,
  supportChannel: 'EMAIL_SLA_1_BUSINESS_DAY',
};

/**
 * Fonte única da verdade dos entitlements. Pura e determinística:
 * mesma entrada + mesmo `now` produzem sempre a mesma saída.
 */
export function resolveEntitlements(subject: EntitlementSubject, now: Date): Entitlements {
  const sub = subject.subscription;

  // Acesso pago por assinatura: ACTIVE, ou CANCELED com período pago ainda vigente.
  const subscriptionGrants =
    !!sub &&
    (sub.status === 'ACTIVE' || sub.status === 'CANCELED') &&
    !!sub.currentPeriodEnd &&
    sub.currentPeriodEnd.getTime() > now.getTime();

  // Acesso pago por cortesia concedida pelo admin (13.11).
  const courtesyGrants =
    !!subject.courtesyUntil && subject.courtesyUntil.getTime() > now.getTime();

  const isPaid = subscriptionGrants || courtesyGrants;
  const base = isPaid ? PAID : FREE;

  const tierSource: TierSource = subscriptionGrants ? 'SUBSCRIPTION' : courtesyGrants ? 'COURTESY' : 'NONE';

  const paidAccessUntil = subscriptionGrants && courtesyGrants
    ? new Date(Math.max(sub!.currentPeriodEnd!.getTime(), subject.courtesyUntil!.getTime()))
    : subscriptionGrants ? sub!.currentPeriodEnd
    : courtesyGrants ? subject.courtesyUntil
    : null;

  // Elegibilidade de envio é ortogonal ao tier: um pagante em opt-out não recebe nada.
  let blockedReason: Entitlements['blockedReason'] = null;
  if (subject.deletedAt) blockedReason = 'DELETED';
  else if (subject.optOutAt) blockedReason = 'OPTED_OUT';
  else if (subject.pausedUntil && subject.pausedUntil.getTime() > now.getTime()) blockedReason = 'PAUSED';
  else if (!subject.optInConfirmedAt) blockedReason = 'NOT_OPTED_IN';

  return {
    ...base,
    effectiveTier: isPaid ? 'PAID' : 'FREE',
    tierSource,
    paidAccessUntil,
    canReceiveMessages: blockedReason === null,
    blockedReason,
  };
}

13.6.3 Como as outras seções usam #

Regras de uso, obrigatórias:

  • Nunca leia subscribers.tier para decidir comportamento. Essa coluna é um cache materializado, mantido por recomputeSubscriptionState (Seção 12.12.4), e existe apenas para filtrar consultas em massa no motor de envio (WHERE tier = 'PAID'). A decisão final de cada envio chama resolveEntitlements.
  • Nunca escreva if (subscription.status === 'ACTIVE') fora de packages/core. Isso esquece cortesia e esquece CANCELED com período vigente.
  • Nunca replique os números da tabela de 13.6.1 em outra seção, em outro arquivo, em outra query ou em um componente de UI. Leia do resultado da função.
  • O now é sempre injetado. Testes usam relógio fixo. Nenhuma chamada a new Date() dentro da função.

Consumidores diretos, para referência: motor de envio diário (Seção 18) usa sendFrequency, audioEnabled e canReceiveMessages; painel do assinante (Seção 14) usa archiveWindowDays, manualResendsPerDay, effectiveTier e paidAccessUntil; catálogo de mensagens (Seção 19) usa effectiveTier para escolher variantes de texto; dashboard (Seção 21) agrega effectiveTier para métricas.

13.6.4 Exemplos resolvidos #

Cenário subscription courtesyUntil Saída
Cadastro sem assinatura null null FREE, NONE, domingo, sem áudio, acervo 7 dias, 1 reenvio
Pago em dia, ciclo até 10/09 ACTIVE, fim 10/09 null PAID, SUBSCRIPTION, paidAccessUntil = 10/09
Cancelou em 22/08, ciclo até 10/09, hoje 05/09 CANCELED, fim 10/09 null PAID, SUBSCRIPTION — ainda tem acesso
Mesmo caso, hoje 11/09 CANCELED, fim 10/09 null FREE, NONE
Inadimplente hoje EXPIRED, fim 10/09 null FREE, NONE — mesmo com currentPeriodEnd no futuro, EXPIRED nunca concede
Cortesia de 30 dias, sem assinatura null 25/09 PAID, COURTESY, paidAccessUntil = 25/09
Pago até 10/09 e cortesia até 25/09 ACTIVE, fim 10/09 25/09 PAID, SUBSCRIPTION, paidAccessUntil = 25/09 (o maior)
Pagante que fez opt-out ACTIVE null PAID, mas canReceiveMessages = false, blockedReason = OPTED_OUT
Pagante em pausa até 30/09 ACTIVE null PAID, canReceiveMessages = false, blockedReason = PAUSED

A quinta linha merece destaque: EXPIRED nunca concede acesso, mesmo que currentPeriodEnd esteja no futuro. Isso é o que torna a regra de 13.3 inviolável por acidente — a função nem consulta a data quando o status é EXPIRED.

13.7 Upgrade de gratuito para pago #

13.7.1 Fluxo #

  1. Assinante FREE autenticado acessa app.palavradiaria.com.br/assinar.
  2. Escolhe plano (plan_monthly ou plan_annual) e forma de pagamento (CREDIT_CARD ou PIX).
  3. Informa nome completo e CPF (Seção 12.5), aceita os termos, e o consentimento é gravado em consent_events.
  4. POST /api/me/subscription cria a linha em PENDING_PAYMENT e a assinatura na Asaas.
  5. Cartão: confirmação em segundos, T2 dispara, tier vira PAID. PIX: o assinante paga, o webhook chega, T2 dispara.
  6. Mensagem de boas-vindas do plano pago é enviada.

13.7.2 Prorrateio: não existe #

Decisão registrada: o MVP não faz prorrateio. O ciclo começa na confirmação do pagamento, não na data do pedido nem em um dia fixo do mês.

Justificativa: o assinante FREE não paga nada, então não há valor a compensar. E como não há mudança de plano com preços diferentes dentro do mesmo ciclo (ver 13.8), não há o segundo caso clássico de prorrateio. Implementar o cálculo custaria complexidade sem beneficiar ninguém.

Consequência concreta: quem confirma o pagamento em 22/08/2026 às 14:37 tem current_period_start = 2026-08-22T17:37:00Z e current_period_end = 2026-09-22T17:37:00Z no plano mensal. O ciclo é ancorado no instante da confirmação, com precisão de segundo, e a soma usa date-fns addMonths / addYears, que tratam corretamente meses curtos: confirmação em 31/01 gera término em 28/02 (ou 29/02 em ano bissexto).

13.7.3 O devocional do dia #

Pergunta concreta: o assinante virou PAID às 14:37; o devocional das 06:00 de hoje já foi enviado como FREE, ou nem foi enviado porque hoje é terça. O que ele recebe?

Decisão: ao confirmar o primeiro pagamento, o sistema entrega o devocional do dia imediatamente, com áudio, fora do horário normal. Regras:

  • Só acontece na primeira ativação de uma assinatura (PENDING_PAYMENTACTIVE de uma linha nova), nunca em renovação.
  • Só acontece se existir devocional PUBLISHED com scheduled_for igual à data local de hoje.
  • Se o assinante já recebeu o texto hoje (era domingo, tier FREE), o sistema envia apenas o áudio e a mensagem de fechamento, sem repetir o texto.
  • Se ele não recebeu nada hoje, envia o pacote completo: texto, áudio e fechamento.
  • Se a janela de atendimento de 24 h não estiver aberta, o envio segue a mesma mecânica de template e janela usada pelo motor de envio (Seção 18 e Seção 17); a confirmação de pagamento no painel já pede que o assinante responda no WhatsApp.
  • Se o áudio do dia ainda não estiver pronto (AUDIO_PENDING), o texto vai na hora e o áudio é enfileirado para assim que ficar pronto, com prazo máximo de 2 horas; passado isso, desiste e o assinante recebe normalmente no dia seguinte.
  • Idempotência: chave welcome-send:{subscriptionId} em delivery_attempts.

Exemplo: assinante FREE paga na terça-feira, 25/08/2026, às 14:37. Terça não é dia de envio FREE, então ele não recebeu nada. Às 14:39 ele recebe texto + áudio do devocional de 25/08. No dia 26/08 às 06:00 entra no fluxo diário normal.

13.7.4 Endpoint #

POST /api/me/subscription

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const createSubscriptionSchema = z.object({
  planCode: z.enum(['plan_monthly', 'plan_annual']),
  billingType: z.enum(['CREDIT_CARD', 'PIX']),
  holderName: z.string().trim().min(3).max(100)
    .regex(/^[\p{L}][\p{L}'\- ]+ [\p{L}'\- ]+$/u, 'Informe nome e sobrenome.'),
  taxId: taxIdSchema,
  cardToken: z.string().uuid().optional(),
  consentVersion: z.string().min(1),
}).refine((v) => v.billingType !== 'CREDIT_CARD' || !!v.cardToken, {
  path: ['cardToken'], message: 'Token do cartão é obrigatório para pagamento com cartão.',
});
  • Resposta 200 (PIX):
{
  "data": {
    "subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
    "status": "PENDING_PAYMENT",
    "planCode": "plan_monthly",
    "billingType": "PIX",
    "amountCents": 1990,
    "nextDueDate": "2026-08-25",
    "pix": {
      "paymentId": "pay_000000098765",
      "copyPasteCode": "00020126580014BR.GOV.BCB.PIX...6304AB12",
      "qrCodeImage": "data:image/png;base64,iVBORw0KGgo...",
      "expiresAt": "2026-08-26T02:59:59.000Z"
    }
  },
  "meta": { "requestId": "req_01K3QB4H6K8M0P2R4T6V8X0Z2B", "timestamp": "2026-08-25T17:37:04.881Z" }
}
  • Erros: UNAUTHENTICATED (401), SUBSCRIPTION_ALREADY_ACTIVE (409), SUBSCRIBER_NOT_OPTED_IN (409), PLAN_NOT_FOUND (404), PLAN_INACTIVE (409), TAX_ID_INVALID (422), TAX_ID_MUST_BE_CPF (422), TAX_ID_REJECTED_BY_PROVIDER (422), CARD_TOKEN_INVALID (422), CARD_DECLINED (422), CARD_BILLING_BLOCKED (403, chargeback anterior — ver 12.14.2), CONSENT_VERSION_MISMATCH (409), RATE_LIMITED (429, 5 tentativas por hora por assinante), PAYMENT_PROVIDER_UNAVAILABLE (503).
  • Efeitos colaterais: cria/atualiza customer na Asaas; cria subscriptions; cria subscription_events CREATED; grava consent_events; grava subscriber_profiles.
  • Idempotência: o advisory lock por assinante mais o índice único parcial de assinatura vigente garantem que duas chamadas simultâneas produzam uma criação e um 409. Uma chamada repetida depois de sucesso devolve 409 SUBSCRIPTION_ALREADY_ACTIVE com o id existente em error.details, e o cliente redireciona para o painel.

13.8 Troca entre mensal e anual #

13.8.1 Regra exata #

A troca de ciclo é sempre agendada para o fim do ciclo pago. Nunca é imediata. Nunca gera cobrança extra. Nunca gera crédito.

O assinante pede a troca a qualquer momento. O sistema grava a intenção em subscriptions.pending_plan_id e pending_plan_effective_at = current_period_end, e não altera nada na Asaas até a virada. No fim do ciclo:

  • Cartão: o job billing.apply-plan-change, na fila billing.lifecycle, às 00:10, chama POST /v3/subscriptions/{id} com o novo cycle e value. A próxima cobrança automática já sai no valor novo.
  • PIX: mesma chamada; a próxima cobrança gerada pela Asaas já vem com o novo valor.

O assinante pode cancelar a troca agendada enquanto ela não for aplicada.

Justificativa da decisão: troca imediata exigiria prorrateio (crédito do período não usado do plano antigo contra o novo), que o MVP não tem (13.7.2). E a Asaas não faz esse cálculo por nós. Agendar para a virada resolve com zero aritmética e zero risco de cobrança errada.

13.8.2 Exemplos numéricos #

Mensal → anual. Ciclo mensal de 10/08 a 10/09, R$ 19,90 já pago. Em 22/08 ele pede o anual. Nada muda em agosto. Em 10/09 a Asaas cobra R$ 199,00 e o novo ciclo vai de 10/09/2026 a 10/09/2027. Ele não paga R$ 19,90 + R$ 199,00; paga só R$ 199,00.

Anual → mensal. Ciclo anual de 10/09/2026 a 10/09/2027, R$ 199,00 pago. Em 03/01/2027 ele pede o mensal. Ele continua com acesso pago até 10/09/2027 — os 8 meses restantes que já comprou. Em 10/09/2027 a cobrança sai R$ 19,90 e o ciclo passa a ser mensal. Não há devolução proporcional dos meses restantes, porque não há troca antecipada.

Assinante quer o anual agora, não em setembro. Resposta do produto: ele cancela a assinatura mensal (T11) e contrata a anual imediatamente. O acesso pago continua sem interrupção até current_period_end da mensal; a anual começa na confirmação do pagamento e os dois períodos se sobrepõem. Nesse caso ele paga por uma sobreposição de até 30 dias. O painel exibe esse aviso, literalmente, antes de confirmar: "Você já tem acesso pago até 10/09. Se contratar agora, o plano anual começa hoje e você paga pelo período que se sobrepõe." Decisão consciente: transparência em vez de cálculo de crédito.

Observação técnica: essa sobreposição é a única situação em que uma segunda assinatura é criada com uma anterior ainda em CANCELED. O índice único de 13.12.5 permite isso porque cobre apenas ACTIVE e PENDING_PAYMENT; a CANCELED não bloqueia.

13.8.3 Endpoint #

POST /api/me/subscription/plan-change

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const planChangeSchema = z.object({
  targetPlanCode: z.enum(['plan_monthly', 'plan_annual']),
});
  • Resposta 200:
{
  "data": {
    "subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
    "currentPlanCode": "plan_monthly",
    "pendingPlanCode": "plan_annual",
    "effectiveAt": "2026-09-10T13:37:00.000Z",
    "newAmountCents": 19900,
    "message": "Sua troca para o plano anual acontece em 10/09/2026. Até lá, nada muda."
  },
  "meta": { "requestId": "req_01K3QC7K9M1P3R5T7V9X1Z3B5D", "timestamp": "2026-08-22T21:14:03.101Z" }
}
  • Erros: UNAUTHENTICATED (401), SUBSCRIPTION_NOT_FOUND (404), SUBSCRIPTION_NOT_ACTIVE (409, quando o status não é ACTIVE), PLAN_CHANGE_SAME_PLAN (409), PLAN_CHANGE_ALREADY_PENDING (409, com o pedido existente em details), PLAN_NOT_FOUND (404), PLAN_INACTIVE (409), RATE_LIMITED (429).
  • Efeitos colaterais: grava pending_plan_id e pending_plan_effective_at; subscription_events PLAN_CHANGE_SCHEDULED; envia mensagem de confirmação.
  • Idempotência: repetir com o mesmo targetPlanCode devolve 200 com o mesmo agendamento, sem criar novo evento. Com plano diferente, devolve 409 PLAN_CHANGE_ALREADY_PENDING; para trocar, o assinante primeiro remove o agendamento.

DELETE /api/me/subscription/plan-change — remove o agendamento. Papel SUBSCRIBER. Resposta 200 com { "data": { "canceled": true } }. Erros: UNAUTHENTICATED (401), PLAN_CHANGE_NOT_PENDING (404). Idempotente: chamar duas vezes devolve 404 na segunda, o que o cliente trata como sucesso.

13.9 Downgrade e rebaixamento #

Rebaixamento é a transição de PAID para FREE, por qualquer causa. O que muda, exatamente, no instante em que ocorre:

Item Antes (PAID) Depois (FREE)
Frequência Diária Domingo apenas
Áudio Sim Não
Acervo Completo Últimos 7 dias
Reenvios 3/dia 1/dia
Suporte E-mail com SLA FAQ e e-mail

Regras de tratamento do que já existe:

  • O acervo não é apagado. Os devocionais continuam no banco; a consulta do painel passa a filtrar por archiveWindowDays. Se o assinante voltar a ser PAID, o acervo inteiro reaparece, incluindo o período em que ele esteve FREE. Nada é perdido.
  • Áudios já entregues no WhatsApp continuam no aparelho do assinante. Não há como revogá-los e não tentamos.
  • O player de áudio no painel deixa de funcionar para devocionais fora da janela. Tentar acessar devolve 403 AUDIO_REQUIRES_PAID_PLAN, com mensagem "O áudio faz parte do plano pago." e botão para assinar.
  • Envios já enfileirados para hoje são cancelados se ainda não saíram. O motor de envio (Seção 18) revalida entitlements no momento do envio, imediatamente antes de chamar a API do WhatsApp. Um job enfileirado às 05:40 com o assinante PAID que é rebaixado às 05:52 não envia áudio às 06:00.
  • subscribers.tier é atualizado na mesma transação, para que a consulta em massa do motor de envio já veja o valor novo.
  • Contadores de reenvio do dia não são zerados. Quem já usou 2 reenvios como PAID e é rebaixado fica sem reenvio até a virada do dia, porque 2 ≥ 1.

Mensagem enviada no rebaixamento por inadimplência (texto definitivo na Seção 19): informa a perda, informa o que ele continua recebendo (domingo, texto) e oferece o link de reativação. Sem tom de cobrança e sem culpa.

13.10 Reativação depois de expirar #

13.10.1 Fluxo #

Reativar é contratar de novo. A linha antiga permanece imutável, com o histórico intacto, e uma linha nova é criada (T18). O assinante usa a mesma rota POST /api/me/subscription de 13.7.4; o painel apenas apresenta o botão como "Reativar assinatura" quando existe assinatura anterior encerrada.

O que é reaproveitado:

Item Reaproveitado?
asaas_customer_id Sim, sempre. Nunca criamos segundo customer
Nome e CPF Sim, pré-preenchidos; o assinante pode corrigir
Cartão salvo (asaas_card_token) Não. O token fica preso à assinatura antiga; o assinante informa o cartão de novo
Opt-in do WhatsApp Sim, se opt_in_confirmed_at estiver preenchido e não houver opt-out
Histórico de devocionais recebidos Sim, integralmente
Acervo Sim; volta a ser completo, incluindo o período FREE
Assinatura antiga Não é alterada; fica como registro histórico

13.10.2 O histórico é preservado #

Sim, e isso é explícito: nenhuma linha de subscriptions, payments, subscription_events, payment_events, message_logs ou consent_events é apagada por causa de reativação. A consulta de histórico no painel do assinante lista todas as assinaturas, com período e motivo de encerramento. O painel administrativo mostra o mesmo, com mais detalhe.

13.10.3 Caso de borda: reativação com opt-out ativo #

Se o assinante fez opt-out de mensagens (Seção 20.3) e depois reativa a assinatura, o pagamento é processado normalmente e o tier vira PAID, mas canReceiveMessages continua false. O checkout exige que ele reative o recebimento antes de concluir: a rota devolve 409 SUBSCRIBER_OPTED_OUT com a mensagem "Você pediu para parar de receber mensagens. Reative o recebimento para assinar." e o painel mostra o botão de reativação de mensagens. Decisão: cobrar por um serviço que não pode ser entregue seria erro grave.

13.11 Concessão manual de acesso pago (cortesia) #

13.11.1 Como funciona #

Um ADMIN ou OWNER concede acesso pago por N dias sem cobrança. Casos de uso reais: compensação por falha de entrega, parceria com liderança de igreja, teste de imprensa, resolução de suporte.

Mecanismo: subscribers.courtesy_until timestamptz NULL. A função de 13.6.2 já considera esse campo. Não há assinatura na Asaas, não há payments, não há cobrança, e nunca haverá renovação automática.

Limites: 1 <= days <= 365. Concessões sucessivas estendem a data: conceder 30 dias a quem tem courtesy_until daqui a 10 dias resulta em 40 dias a partir de hoje. Isso é deliberado — o admin não precisa calcular.

13.11.2 Como expira #

Sem job. A expiração é uma consequência natural de courtesyUntil > now na função de entitlements. O job billing.lifecycle das 00:05 apenas atualiza o cache subscribers.tier e envia a mensagem de encerramento, mas mesmo que ele não rode, o acesso já terá acabado na prática, porque toda decisão de envio chama a função.

Aviso ao assinante: 3 dias antes de courtesy_until, o job billing.courtesy-expiry-notice, na fila billing.lifecycle, envia uma mensagem informando o fim e oferecendo assinatura. Só é enviada se o assinante não tiver assinatura paga vigente, porque nesse caso o fim da cortesia não muda nada para ele.

13.11.3 Auditoria #

Toda concessão e toda revogação gravam:

  • admin_audit_log: admin_user_id, actor_type = 'ADMIN', actor_role (papel efetivo no momento da ação), action (COURTESY_GRANT | COURTESY_REVOKE), entity_type = 'subscriber', entity_id, metadata com days, previousCourtesyUntil, newCourtesyUntil e reason. Os nomes das colunas são os da Seção 6.9, que é a dona do esquema de admin_audit_log; esta seção apenas os consome.
  • subscription_events: type (COURTESY_GRANTED | COURTESY_REVOKED), source = 'ADMIN'.

reason é obrigatório, de 10 a 500 caracteres. Sem justificativa, a rota devolve 422.

O painel administrativo tem um relatório "Cortesias vigentes" com total de assinantes, valor equivalente não faturado (dias × R$ 19,90 ÷ 30, arredondado) e ranking de admins que mais concederam nos últimos 90 dias. Isso existe porque cortesia é receita não realizada e precisa ser visível.

13.11.4 Interação com assinatura real #

Cortesia e assinatura paga são independentes e podem coexistir. Regras:

  1. Conceder cortesia não cancela assinatura ativa e não interrompe cobrança. Se o objetivo for parar de cobrar, cancele a assinatura — a rota de cortesia avisa isso na resposta quando detecta assinatura ativa (campo warning).
  2. paidAccessUntil é o maior entre current_period_end e courtesy_until (13.6.2).
  3. Se a assinatura for revogada por inadimplência (T7) enquanto a cortesia está vigente, o assinante continua PAID por cortesia, com tierSource = 'COURTESY'. A mensagem enviada nesse caso é diferente: informa a falha de pagamento mas esclarece que o acesso segue até a data da cortesia. Isso não é carência — é um acesso concedido por decisão comercial explícita, registrado e auditado.
  4. Revogar a cortesia de quem tem assinatura ativa não tira o acesso: a assinatura continua concedendo.

13.11.5 Endpoints #

POST /api/admin/subscribers/{id}/courtesy

  • Autenticação: sessão de admin com TOTP validado (Seção 8). Papel: ADMIN ou OWNER. EDITOR recebe 403.
  • Request:
export const grantCourtesySchema = z.object({
  days: z.number().int().min(1).max(365),
  reason: z.string().trim().min(10).max(500),
});
  • Resposta 200:
{
  "data": {
    "subscriberId": "sub_01K3Q8R7V2N4B6D8F0H2J4L6M8",
    "previousCourtesyUntil": null,
    "courtesyUntil": "2026-09-24T03:00:00.000Z",
    "daysGranted": 30,
    "effectiveTier": "PAID",
    "tierSource": "COURTESY",
    "warning": null
  },
  "meta": { "requestId": "req_01K3QD1M3P5R7T9V1X3Z5B7D9F", "timestamp": "2026-08-25T14:02:55.310Z" }
}

Quando o assinante já tem assinatura ativa, warning vem preenchido: "Este assinante tem assinatura ativa até 10/09/2026. A cortesia não interrompe a cobrança."

  • Erros: UNAUTHENTICATED (401), FORBIDDEN_ROLE (403), SUBSCRIBER_NOT_FOUND (404), SUBSCRIBER_DELETED (409), COURTESY_DAYS_OUT_OF_RANGE (422), COURTESY_REASON_REQUIRED (422), COURTESY_LIMIT_EXCEEDED (429, quando o resultado passaria de 365 dias a partir de hoje), IDEMPOTENCY_KEY_REQUIRED (400), IDEMPOTENCY_KEY_REUSED (422), RATE_LIMITED (429, 20 concessões por hora por admin).
  • Efeitos colaterais: atualiza courtesy_until, subscribers.tier; grava admin_audit_log e subscription_events; agenda o aviso de D-3; envia mensagem de concessão ao assinante quando ele não era PAID antes.
  • Idempotência: não é idempotente por natureza — cada chamada estende o prazo. Por isso o header Idempotency-Key é obrigatório aqui (Seção 7.7.1), como em toda escrita cara: chamada sem a chave devolve 400 IDEMPOTENCY_KEY_REQUIRED; replay da mesma chave devolve a resposta gravada com Idempotent-Replay: true, sem estender o prazo de novo; a mesma chave com corpo diferente devolve 422 IDEMPOTENCY_KEY_REUSED. As demais escritas caras desta seção — criação de assinatura, troca de plano e troca de cartão — seguem exatamente a mesma regra, pelo mesmo motivo: todas propagam uma chamada de escrita para a Asaas, e um duplo clique cobra duas vezes.

DELETE /api/admin/subscribers/{id}/courtesy

  • Papel: ADMIN ou OWNER. Request: { "reason": string(10..500) }.
  • Resposta 200: { "data": { "courtesyUntil": null, "effectiveTier": "FREE", "tierSource": "NONE" } } (ou PAID/SUBSCRIPTION se houver assinatura ativa).
  • Erros: UNAUTHENTICATED (401), FORBIDDEN_ROLE (403), SUBSCRIBER_NOT_FOUND (404), COURTESY_NOT_ACTIVE (404), COURTESY_REASON_REQUIRED (422).
  • Efeitos: zera courtesy_until, recalcula tier, grava auditoria. Envia mensagem ao assinante apenas se ele perder o acesso pago com isso.
  • Idempotência: a segunda chamada devolve 404 COURTESY_NOT_ACTIVE, tratado como sucesso.

13.12 Casos de borda #

13.12.1 Webhook duplicado #

A Asaas reenvia o mesmo evento quando não recebe 2xx a tempo, e reenvios podem chegar depois de o primeiro ter sido processado. Defesas, em três camadas (detalhe em 12.12.3): índice único em payment_events.asaas_event_id, jobId do BullMQ igual ao id do evento, e processed_at verificado sob FOR UPDATE.

Exemplo: PAYMENT_CONFIRMED do pay_000000098765 chega às 09:14:02 e de novo às 09:14:19. O primeiro ativa a assinatura e envia a confirmação. O segundo grava nada (conflito no índice), o job é descartado pelo jobId, e se ainda assim executar, sai no passo 1 do processador. O assinante recebe uma mensagem, current_period_end é gravado uma vez, e o período não avança duas vezes.

13.12.2 Webhook fora de ordem #

Exemplo real e desagradável: PAYMENT_OVERDUE (gerado 00:07) chega às 00:09, e PAYMENT_CONFIRMED (gerado 00:05, de uma retentativa aprovada) chega às 00:11. Processados na ordem de chegada, o resultado ingênuo seria EXPIRED e depois ACTIVE — correto por acaso. Invertendo a chegada, o resultado ingênuo seria ACTIVE e depois EXPIRED — errado.

O sistema resolve com duas travas: o ranque de 12.4.4 impede que OVERDUE (10) sobrescreva CONFIRMED (20); e recomputeSubscriptionState (12.12.4) deriva o estado dos fatos gravados, não do último evento. O resultado é ACTIVE nas duas ordens de chegada.

Quando o OVERDUE é descartado por ranque, o sistema registra WARN asaas_overdue_after_paid com os dois ids. Se esse aviso aparecer mais de 10 vezes por dia, o alerta operacional dispara, porque pode indicar cobrança duplicada na Asaas.

13.12.3 Pagamento confirmado depois do cancelamento #

Cenário: assinante cancela em 22/08 (T11, CANCELED, DELETE na Asaas). Em 23/08 chega PAYMENT_CONFIRMED de uma cobrança PIX que ele havia pago minutos antes do cancelamento.

Tratamento: o pagamento é legítimo e o dinheiro entrou. O sistema aplica PAYMENT_CONFIRMED normalmente, avança current_period_end em um ciclo a partir da confirmação, e mantém status = CANCELED com cancel_at_period_end = true. O assinante fica com acesso pago até o novo current_period_end e depois cai para FREE. Ele recebe: "Recebemos seu pagamento de R$ 19,90. Seu acesso segue até 23/09/2026, e sua assinatura não será renovada depois disso."

Isso é coerente com 13.4: ele pagou aquele ciclo, então tem direito a ele. Não é carência.

Se o assinante não quiser o período pago, ele pede reembolso pelo suporte, dentro da política de 12.14.1.

13.12.4 Assinatura na Asaas sem assinante correspondente #

Causas: assinatura criada manualmente no painel da Asaas; migração malfeita; nossa linha apagada por erro operacional.

Tratamento em três tentativas de vínculo, nesta ordem:

  1. externalReference da assinatura casa com um subscriptions.id existente → vincula.
  2. customer casa com subscribers.asaas_customer_id → cria a linha local vinculada àquele assinante, em PENDING_PAYMENT, e deixa a recomputação decidir o status real.
  3. Nada casa → grava payment_events.processing_status = 'ORPHAN_SUBSCRIPTION', cria tarefa administrativa com o payload completo e emite alerta billing_orphan_subscription.

Em nenhuma hipótese o sistema cria um subscribers a partir de dados da Asaas. Assinante nasce do cadastro com telefone e opt-in (Seção 11), não de um evento financeiro. Um assinante criado sem telefone e sem opt-in não poderia receber nada, e violaria a regra de opt-in.

O painel administrativo permite vincular manualmente a assinatura órfã a um assinante existente, com auditoria.

13.12.5 Assinante com duas assinaturas ativas: proibido #

Invariante: um assinante tem no máximo uma assinatura em ACTIVE ou PENDING_PAYMENT por vez. Três defesas independentes:

  1. Índice único parcial no banco (declarado na Seção 6):
CREATE UNIQUE INDEX subscriptions_one_live_per_subscriber
  ON subscriptions (subscriber_id)
  WHERE status IN ('PENDING_PAYMENT', 'ACTIVE');
  1. Advisory lock transacional por assinante no checkout, que serializa dois cliques simultâneos: o segundo espera, vê a linha do primeiro e recebe 409.
  2. Verificação de negócio no início de POST /api/me/subscription, que devolve 409 SUBSCRIPTION_ALREADY_ACTIVE com o id da assinatura vigente e a data de término.

Se, apesar disso, o estado impossível aparecer — por reconciliação criando linha a partir da Asaas, por exemplo —, o job billing.reconcile detecta e resolve deterministicamente:

  • Mantém a assinatura com current_period_end mais distante; se empatarem, mantém a de created_at mais antiga.
  • A outra vai para CANCELED com end_reason = 'DUPLICATE' e é removida na Asaas com DELETE /v3/subscriptions/{id}.
  • Se as duas tiverem cobranças pagas no mesmo período, nenhuma é removida sem intervenção: gera tarefa administrativa DUPLICATE_PAID_SUBSCRIPTION com alerta crítico, porque há cobrança em duplicidade e a decisão correta é reembolsar uma delas — decisão humana, com registro.
  • Alerta billing_duplicate_subscription sempre é emitido, mesmo quando a resolução é automática.

13.12.6 Retentativa de cartão aprovada após revogação #

A Asaas pode repetir a cobrança de cartão por conta própria depois do vencimento. O sistema não espera por isso (13.3): revoga no PAYMENT_OVERDUE. Se a retentativa for aprovada dias depois, chega PAYMENT_CONFIRMED, T17 dispara, o acesso é restaurado e o período é recalculado a partir da data da confirmação, não da data de vencimento original.

Exemplo: vencimento 10/09, revogação em 11/09 às 00:07, aprovação em 13/09 às 04:22. O novo ciclo vai de 13/09 a 13/10. O assinante perdeu 11/09 e 12/09, e o sistema não compensa nem cobra a diferença. Mensagem: "Recebemos seu pagamento. Seu acesso foi restaurado e vai até 13/10/2026."

13.12.7 Mudança de preço para assinantes existentes #

Ciclos já contratados não são afetados. subscriptions.amount_cents é a cópia do preço no momento da contratação (13.1.2), e é ele que a Asaas cobra, porque o valor foi enviado na criação da assinatura.

Ao alterar plans.amount_cents pelo painel administrativo:

  • Novos checkouts usam o preço novo, imediatamente.
  • Assinaturas existentes continuam no preço antigo indefinidamente, até que o assinante troque de plano por conta própria (13.8) ou cancele e reative (13.10).
  • O sistema não propaga o preço novo com POST /v3/subscriptions/{id}, nem em massa nem individualmente. Não existe rota para isso. Decisão registrada: aumento silencioso de preço em assinatura recorrente é prática abusiva e gera chargeback.
  • A alteração exige papel OWNER, justificativa de 10 a 500 caracteres e confirmação por senha, e grava admin_audit_log com action = 'PLAN_PRICE_CHANGE', valor anterior e novo.
  • O painel exibe, antes de confirmar, quantas assinaturas vigentes seguem no preço antigo.

Exemplo: em 01/10/2026 o mensal passa de R$ 19,90 para R$ 24,90. Um assinante que contratou em 10/08/2026 continua pagando R$ 19,90 em 10/10, 10/11 e assim por diante. Se ele cancelar em janeiro e reativar em março, paga R$ 24,90.

13.12.8 Outros estados impossíveis e o que o sistema faz #

Estado impossível Detecção Ação
ACTIVE com current_period_end no passado Reconciliação e recomputeSubscriptionState Vira EXPIRED, tier recalculado, evento DRIFT_FIXED
tier = 'PAID' sem assinatura vigente nem cortesia Reconciliação (12.13.3) Rebaixa e registra TIER_DRIFT_FIXED
tier = 'FREE' com assinatura ACTIVE vigente Reconciliação Promove e registra TIER_DRIFT_FIXED
PENDING_PAYMENT há mais de 24 h sem nenhum evento Job billing.abandon-pending, fila billing.lifecycle, de hora em hora T5: EXPIRED com end_reason = ABANDONED, DELETE na Asaas
subscriptions sem asaas_subscription_id há mais de 10 min Job de saneamento Tenta localizar por externalReference; não achando, marca EXPIRED/ABANDONED
courtesy_until no passado com tier = 'PAID' Job billing.lifecycle Recalcula tier e envia mensagem
Dois payments RECEIVED para o mesmo ciclo Reconciliação Alerta crítico billing_double_charge e tarefa administrativa de reembolso
Assinatura ativa de assinante com deleted_at Reconciliação Cancela na Asaas, encerra a linha, alerta billing_active_for_deleted_subscriber

13.13 Endpoints do ciclo de vida #

Os endpoints de cancelamento e de opt-out estão na Seção 20.12, porque pertencem àquele fluxo. O endpoint de criação está em 13.7.4 e os de troca de plano em 13.8.3.

Convenção de caminho, seguida sem exceção nesta seção: toda ação sobre a própria conta do assinante vive sob /api/me/, com nome de ação no singular/api/me/subscription, /api/me/subscription/cancel, /api/me/subscription/plan-change, /api/me/subscription/card, /api/me/payments. Coleções administrativas são substantivos no plural em kebab-case — /api/admin/subscriptions, /api/admin/subscribers. Não existe /api/subscriptions/current.

Todas as rotas de escrita desta seção são recusadas em sessão de impersonação administrativa, sem exceção: criação de assinatura, troca de plano, troca de cartão, cancelamento e opt-out. A impersonação é integralmente somente leitura, e a recusa acontece no invólucro de API, antes do handler, pelo critério da guarda de autenticação e não pelo prefixo do caminho (Seção 8.13). Um operador de suporte dentro da conta de um assinante não cancela a assinatura dele nem por engano.

13.13.1 GET /api/me/subscription #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request: sem corpo, sem query.
  • Resposta 200 (assinante pago com cancelamento agendado):
{
  "data": {
    "hasSubscription": true,
    "subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
    "status": "CANCELED",
    "planCode": "plan_monthly",
    "planName": "Plano mensal",
    "amountCents": 1990,
    "billingCycle": "MONTHLY",
    "billingType": "CREDIT_CARD",
    "currentPeriodStart": "2026-08-10T13:37:00.000Z",
    "currentPeriodEnd": "2026-09-10T13:37:00.000Z",
    "nextDueDate": null,
    "cancelAtPeriodEnd": true,
    "canceledAt": "2026-08-22T21:14:03.101Z",
    "endReason": "USER_REQUEST",
    "pendingPlanCode": null,
    "card": { "brand": "MASTERCARD", "last4": "8829", "expMonth": 5, "expYear": 2031 },
    "entitlements": {
      "effectiveTier": "PAID",
      "tierSource": "SUBSCRIPTION",
      "paidAccessUntil": "2026-09-10T13:37:00.000Z",
      "sendFrequency": "DAILY",
      "audioEnabled": true,
      "archiveWindowDays": null,
      "manualResendsPerDay": 3,
      "canReceiveMessages": true,
      "blockedReason": null
    }
  },
  "meta": { "requestId": "req_01K3QE3P5R7T9V1X3Z5B7D9F1H", "timestamp": "2026-08-25T11:02:07.004Z" }
}

Para assinante FREE sem histórico, hasSubscription é false, os campos de assinatura vêm null e entitlements traz os valores de FREE. O campo planCode vem null, e não "plan_free": o plano gratuito não é uma linha de plans e não tem código no banco (13.1.1). Quando a tela precisa exibir o rótulo "Gratuito", ela o monta a partir de entitlements.effectiveTier, não de um código de plano.

O campo billingCycle é derivado de plans.interval através de plan_id, e não lido de uma coluna de subscriptions (13.1.2).

  • Erros: UNAUTHENTICATED (401), SUBSCRIBER_DELETED (410).
  • Efeitos colaterais: nenhum. Idempotência: leitura pura.
  • Cache: Cache-Control: private, no-store. O painel usa TanStack Query com staleTime: 15_000 e refetch ao focar a aba.

13.13.2 GET /api/me/entitlements #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Resposta 200: o objeto entitlements de 13.13.1, isolado, com meta.resolvedAt.
  • Erros: UNAUTHENTICATED (401), SUBSCRIBER_DELETED (410).
  • Existe separado porque componentes de UI precisam só disso e a resposta é pequena o bastante para ser buscada com frequência.

13.13.3 GET /api/me/subscription/history #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Query: ?limit=<1..100>&cursor=<opaque>, paginação por cursor conforme a Seção 7.
  • Resposta 200: lista de assinaturas com subscriptionId, planCode, amountCents, status, currentPeriodStart, currentPeriodEnd, endReason, e um resumo de pagamentos (paidCount, totalPaidCents).
  • Erros: UNAUTHENTICATED (401), INVALID_CURSOR (400), LIMIT_OUT_OF_RANGE (422).
  • Idempotência: leitura pura.

13.13.4 GET /api/me/payments #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER. Retorna somente os próprios pagamentos; o subscriber_id vem da sessão e nunca de parâmetro.
  • Query: ?limit=<1..100>&cursor=<opaque>&status=<PENDING|OVERDUE|CONFIRMED|RECEIVED|REFUNDED>.
  • Resposta 200: paymentId, amountCents, status, dueDate, paidAt, billingType, invoiceUrl, receiptUrl.
  • Erros: UNAUTHENTICATED (401), INVALID_CURSOR (400), INVALID_STATUS_FILTER (422).

13.13.5 POST /api/me/subscription/card #

Troca do cartão de uma assinatura ativa.

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const changeCardSchema = z.object({
  cardToken: z.string().uuid(),
});

O cardToken vem de POST /api/checkout/card-tokens (Seção 12.6.4). O cartão em si nunca chega a esta rota.

  • Resposta 200:
{
  "data": { "card": { "brand": "VISA", "last4": "4321", "expMonth": 11, "expYear": 2030 },
            "updatedAt": "2026-08-25T11:20:44.512Z" },
  "meta": { "requestId": "req_01K3QF5R7T9V1X3Z5B7D9F1H3K", "timestamp": "2026-08-25T11:20:44.512Z" }
}
  • Erros: UNAUTHENTICATED (401), SUBSCRIPTION_NOT_FOUND (404), SUBSCRIPTION_NOT_ACTIVE (409), BILLING_TYPE_NOT_CARD (409), CARD_TOKEN_INVALID (422), CARD_DECLINED (422), CARD_BILLING_BLOCKED (403), PAYMENT_PROVIDER_UNAVAILABLE (503), RATE_LIMITED (429, 5 por hora).
  • Efeitos colaterais: POST /v3/subscriptions/{id} com o novo creditCardToken; atualiza asaas_card_token, card_last4, card_brand, card_exp_month, card_exp_year; grava subscription_events CARD_UPDATED; envia confirmação ao assinante.
  • Idempotência: enviar o mesmo cardToken duas vezes é seguro — a segunda chamada detecta que asaas_card_token já é aquele e devolve 200 sem chamar a Asaas.

13.13.6 GET /api/admin/subscriptions #

  • Autenticação: sessão de admin. Papel: EDITOR (somente leitura), ADMIN ou OWNER.
  • Query: ?limit=<1..100>&cursor=<opaque>&status=<...>&billingType=<...>&planCode=<...>&q=<busca>. q busca por telefone, nome ou asaas_subscription_id. A busca por telefone não compara a coluna cifrada: o servidor normaliza o termo para E.164, calcula o índice cego e consulta subscribers.phone_hmac = $1 (Seção 6.3). Nunca há busca por CPF em texto aberto; quando o operador precisar localizar por CPF, o mesmo mecanismo é usado sobre subscriber_profiles.cpf_hmac.
  • Resposta 200: lista paginada por cursor com os campos de 13.13.1 mais subscriberId, telefone mascarado (+55 11 9****-8829) e tierSource. A resposta não traz total, page nem totalPages: contagem agregada é responsabilidade das rotas de métricas (Seção 21.9.3).
  • Erros: UNAUTHENTICATED (401), FORBIDDEN_ROLE (403), INVALID_CURSOR (400), LIMIT_OUT_OF_RANGE (422).
  • Efeitos colaterais: grava admin_audit_log com action = 'SUBSCRIPTION_LIST_VIEW' apenas quando q é usado, para rastrear busca por assinante específico.

14. Painel do Assinante #

14.1 Por que existe um painel próprio #

A Asaas é o meio de pagamento, não o produto. Ela não oferece um portal do cliente whitelabel onde o assinante possa ver a própria assinatura, trocar forma de pagamento, consultar histórico e cancelar sob a nossa marca. As telas que a Asaas expõe são a fatura avulsa e a página de pagamento; não há uma área logada do consumidor final vinculada ao nosso negócio.

Consequências diretas, que justificam cada tela desta seção:

  1. Cancelamento precisa ser possível pelo mesmo canal da contratação. Se o assinante contratou pela web, ele precisa cancelar pela web, com o mesmo número de cliques. Sem painel próprio, o único caminho seria e-mail ou WhatsApp, o que é atrito artificial e fonte de reclamação.
  2. Histórico de pagamentos e comprovantes precisam ser visíveis sem que o assinante procure e-mails antigos. O painel lê payments (Seção 6) e expõe o link do comprovante da própria Asaas.
  3. O acervo de devocionais é um entitlement (Seção 13). Ele só faz sentido em uma área logada, com autorização por tier.
  4. Direitos LGPD (acesso, portabilidade, eliminação) precisam de um canal self-service. Delegar isso a atendimento humano não escala e não cumpre prazo.
  5. Preferências de recebimento (pausar, reativar, opt-out) precisam de um lugar auditável e explícito, distinto do cancelamento de cobrança.

Decisão registrada: o painel é a única interface de autoatendimento do assinante. O WhatsApp oferece atalhos (HOJE, PAUSAR, SAIR, VOLTAR, catálogo na Seção 19), mas nunca substitui o painel para operações que envolvem dinheiro ou dados pessoais.

14.2 Rotas, layout e navegação #

Todas as rotas vivem no route group (app) de apps/web, sob /app, protegidas por middleware que exige sessão de assinante válida (Seção 8). Todas são noindex, nofollow.

Rota Título da aba Propósito Requer opt-in confirmado
/app Redireciona 307 para /app/hoje. Não
/app/hoje Hoje Devocional do dia, player, reenvio no WhatsApp. Não (mostra aviso)
/app/acervo Acervo Lista paginada dos devocionais disponíveis pelo tier. Não
/app/assinatura Assinatura Plano, cobrança, histórico, upgrade, cancelamento. Não
/app/perfil Perfil Nome, e-mail, troca de número. Não
/app/preferencias Preferências Pausa, reativação, opt-out. Não
/app/dados Meus dados Exportação, exclusão, histórico de consentimentos. Não

Sub-rotas:

Rota Propósito
/app/acervo/[devotionalId] Detalhe de um devocional do acervo.
/app/assinatura/checkout Handoff para o checkout (Seção 12 é a dona).
/app/perfil/telefone Fluxo de troca de número (ver 14.6.2).

14.2.1 Layout #

  • >= lg: barra lateral fixa de 240 px à esquerda com a navegação, conteúdo em coluna única de no máximo 720 px, centralizado na área restante. Leitura confortável é prioridade sobre densidade.
  • < lg: cabeçalho fixo com logotipo e avatar; navegação em barra inferior com cinco itens (Hoje, Acervo, Assinatura, Perfil, Mais). Mais abre um Sheet com Preferências e Meus dados. Barra inferior porque o painel é usado majoritariamente no celular, logo após a mensagem do WhatsApp.
  • O item ativo é indicado por cor e por ícone preenchido e por aria-current="page".
  • O cabeçalho exibe um <StatusBadge> com o tier corrente: Gratuito ou Completo. O texto vem de resolveEntitlements() (Seção 13), nunca de literal.
  • Alvo de toque de 44 px em toda a navegação (Seção 9.9.3 vale para o painel).

14.2.2 Avisos globais persistentes #

Renderizados no topo do conteúdo, em ordem de prioridade, no máximo dois por vez:

Prioridade Condição Texto Ação
1 status = 'VERIFIED_PENDING_OPTIN' "Confirme no WhatsApp para começar a receber." Botão "Reenviar mensagem" (Seção 11.12.6).
2 optOutAt IS NOT NULL e assinatura paga ainda dentro do período pago "Envios pausados, cobrança suspensa. Se você não voltar em 30 dias, a assinatura é encerrada ao fim do período já pago." Dois botões: "Voltar a receber" e "Cancelar assinatura agora".
3 Pausa ativa "Seus envios estão pausados até 11/09/2026; você volta a receber em 12/09/2026." Botão "Retomar agora".
4 Pagamento pendente (PENDING_PAYMENT há mais de 1 h) "Seu pagamento ainda não foi confirmado." Link para a fatura/PIX.
5 Assinatura cancelada com acesso até o fim do ciclo "Sua assinatura foi cancelada. Você continua com acesso completo até 30/09/2026." Botão "Reativar".

14.2.3 Estados de carregamento, vazio e erro #

Padrão único, aplicado em todas as telas:

Estado Componente Regra
Carregando (primeira vez) <Skeleton> com a forma exata do conteúdo final Nunca spinner de página inteira. Skeleton reserva altura, então CLS ≈ 0.
Carregando (revalidação em segundo plano) Nada visível, exceto um indicador discreto de 2 px no topo O conteúdo antigo permanece legível (keepPreviousData do TanStack Query).
Vazio <EmptyState> com ícone, título, uma frase e no máximo um CTA Texto específico por tela, listado em cada subseção.
Erro recuperável <ErrorState> com mensagem do envelope da Seção 7, botão "Tentar de novo" e o requestId em texto pequeno Nunca exibe stack nem code cru; o code vira mensagem em português pelo mapa da Seção 7.
Erro de autorização Redireciona para /login?next=<rota> Só para 401. Para 403, mostra <ErrorState> com "Você não tem acesso a este conteúdo."
Offline Faixa fixa no topo: "Você está sem conexão." Detectado por navigator.onLine + falha de rede. Some ao voltar.

14.3 Tela "Hoje" (/app/hoje) #

14.3.1 Conteúdo #

Ordem dos elementos:

  1. Cabeçalho da data: "Segunda-feira, 25 de agosto" — formatado com date-fns em America/Sao_Paulo, primeira letra maiúscula.
  2. Cartão do devocional:
    • Título (h1).
    • Referência bíblica e versão (Salmos 23:1-3 · Almeida 1911).
    • Texto bíblico em bloco destacado (<blockquote>).
    • Reflexão renderizada de reflection_md (markdown restrito: parágrafo, ênfase, forte, lista, citação; sem HTML bruto, sem imagem, sem link externo — sanitizado no servidor).
    • Oração em bloco final destacado.
  3. Player de áudio — apenas para tier PAID. Ver 14.3.2.
  4. Barra de ações: "Reenviar no WhatsApp" e "Copiar texto".
  5. Rodapé do cartão: "Enviado às 06:02" ou "Ainda não enviado hoje", conforme message_logs (Seção 6).

14.3.2 Player e estados do áudio #

O player reutiliza o mesmo componente da Seção 9.6, com variant="full" (áudio completo, não a amostra de 60 s) e recursos adicionais:

  • Controle de velocidade: 1x, 1.25x, 1.5x (<select> acessível, valor persistido em localStorage).
  • Retomar de onde parou: posição salva em localStorage por devotionalId, restaurada se a última reprodução foi nas últimas 24 h e passou de 10 s.
  • Botão "Baixar" com URL assinada (14.4.4).

Estados específicos desta tela:

Condição O que aparece
Tier FREE Bloco bloqueado no lugar do player: ícone de cadeado, texto "O áudio narrado faz parte do plano completo." e botão "Conhecer o plano completo" → /app/assinatura. Nunca um player quebrado.
Tier PAID, audio_assets com status READY Player normal.
Tier PAID, devocional em AUDIO_PENDING Aviso: "O áudio de hoje está sendo preparado. Ele chega em alguns minutos." + skeleton do player. A consulta revalida a cada 30 s enquanto esse estado durar (ver 14.11).
Tier PAID, geração do áudio falhou (audio_assets.status = 'FAILED') Aviso: "Não conseguimos gerar o áudio de hoje. Nossa equipe já foi avisada." Sem botão de retry para o assinante; o retry é operacional (Seção 27).
Devocional do dia ainda não publicado Estado vazio: "O devocional de hoje ainda não foi publicado." + link para o mais recente do acervo.

14.3.3 Reenvio no WhatsApp #

Ação mais usada da tela. Regras:

  • O limite diário vem de resolveEntitlements(): 1 por dia no FREE, 3 por dia no PAID (Seção 13). O painel nunca codifica esses números.
  • O botão exibe o saldo: "Reenviar no WhatsApp (2 restantes hoje)". Com saldo zero, fica desabilitado com o texto "Limite de hoje atingido. Volta amanhã às 00:00."
  • O contador reseta à meia-noite de America/Sao_Paulo, não em UTC.
  • Confirmação em um passo: clique → estado enviando no próprio botão → toast de sucesso "Enviamos no seu WhatsApp." Sem modal.
  • Se a janela de atendimento de 24 h estiver fechada, o reenvio usa o template diário (Seção 17). O toast então diz: "Enviamos. Toque no botão da mensagem para receber o texto completo." Essa distinção é importante para não gerar a impressão de que o reenvio falhou.
  • Se o assinante estiver em OPTED_OUT ou com pausa ativa, o botão fica desabilitado com explicação e link para /app/preferencias.
  • O pacote enviado é decidido no momento do disparo, não no do clique: o motor relê tier, opt_out_at, blocked_at e deleted_at imediatamente antes de chamar o provedor (Seção 18.5). Quem perdeu o acesso pago entre o clique e o disparo recebe apenas o texto do plano gratuito, sem áudio. A revogação é imediata, sem carência.
  • Contabilizado em delivery_attempts com reason = 'MANUAL_RESEND', o que o mantém visível na tela de envios do administrador (Seção 15.8).

14.3.4 "Copiar texto" #

Copia título, referência, texto bíblico, reflexão em texto puro e oração, com uma linha final de crédito e o endereço do site. Usa navigator.clipboard.writeText; se a API não existir ou for negada, abre um Dialog com o texto selecionável e a instrução de copiar manualmente. Emite evento de log devotional_copied.

14.3.5 Estados vazios #

Situação Título Frase CTA
Nenhum devocional publicado hoje "Ainda não há devocional para hoje" "Assim que for publicado, ele aparece aqui e chega no seu WhatsApp." "Ver o acervo"
Assinante ainda sem opt-in "Confirme no WhatsApp" "Você ainda não confirmou o recebimento. Sem isso, não podemos enviar." "Reenviar mensagem"
Domingo já passou e o assinante é FREE Não é estado vazio: FREE vê o devocional do dia normalmente no painel. O limite semanal vale para o envio, não para a leitura no painel.

Essa última linha é uma decisão explícita: o entitlement de frequência restringe o push no WhatsApp, não a leitura no painel do devocional corrente. O acervo, sim, é limitado por tier (14.4.2).

14.4 Tela "Acervo" (/app/acervo) #

14.4.1 Lista #

  • Lista de cartões, um por devocional, em ordem decrescente de scheduled_for.
  • Cada cartão: data, título, referência bíblica, teaser em uma linha truncada com reticências por CSS, um ícone de áudio quando existir e um indicador "Ouvido" quando a posição salva localmente passar de 90%.
  • Paginação por cursor, conforme a Seção 7. Nunca offset. limit padrão 20, máximo
  • Carregamento incremental com botão "Carregar mais" e IntersectionObserver como atalho. O botão existe porque rolagem infinita sem controle quebra navegação por teclado e acessibilidade.
  • O cursor é opaco (ULID do último item, codificado em base64url). O cliente nunca o interpreta.

14.4.2 Limite por tier #

Tier Escopo do acervo Origem da regra
FREE Últimos 7 dias resolveEntitlements() (Seção 13)
PAID Completo, desde a primeira assinatura paga resolveEntitlements() (Seção 13)

O limite é imposto no servidor, não na interface. O predicado de janela entra na própria consulta SQL de listagem e na leitura de item, antes de qualquer serialização. Um assinante gratuito que chame a rota de listagem diretamente, com o maior limit aceito, e percorra todos os cursores, recebe no máximo os 7 dias mais recentes; e a leitura de um devocional de 30 dias atrás devolve 403 ENTITLEMENT_REQUIRED. Esconder itens só no componente seria um controle inexistente.

Comportamento na borda do limite, para FREE:

  • Os devocionais fora da janela não são omitidos silenciosamente. Após o último item acessível, a lista mostra um cartão de bloqueio: "Há mais 385 devocionais no acervo completo." com o botão "Conhecer o plano completo".
  • A contagem é retornada pela API em meta.lockedCount. Ela é a única informação sobre o conteúdo bloqueado que o cliente recebe — nem título, nem data. lockedCount não é contagem de paginação: a paginação é por cursor e nunca devolve total (Seção 7.8). É um agregado de upsell, calculado por consulta própria e independente da página corrente.
  • Tentar acessar /app/acervo/[devotionalId] de um devocional fora do escopo devolve 403 ENTITLEMENT_REQUIRED e a interface mostra a tela de upsell, não um erro genérico.

Para PAID, o marco inicial é subscriptions.started_at da primeira assinatura paga do assinante, não da assinatura corrente. Quem assina, cancela e volta não perde o acervo já conquistado. Decisão explícita, favorável ao assinante e barata de implementar, e é a mesma âncora declarada na Seção 13.6.1.

14.4.3 Busca e filtros #

Dois controles, ambos combináveis:

Busca por texto

  • Campo único, type="search", com debounce de 400 ms e mínimo de 3 caracteres.
  • Busca no servidor, em title, bible_reference e teaser. Não busca em reflection_md, para manter o índice pequeno e a latência previsível.
  • Implementação: índice GIN com to_tsvector('portuguese', ...) sobre uma coluna gerada, definida na Seção 6. Consulta com plainto_tsquery('portuguese', $1).
  • Acentos são normalizados pelo dicionário portuguese do Postgres, então "oração" e "oracao" encontram o mesmo resultado.
  • Estado vazio: "Nenhum devocional encontrado para paz interior." + botão "Limpar busca".

Filtro por data

  • Dois campos de data (de e até) com <input type="date"> nativo, mais atalhos rápidos: Últimos 30 dias, Este ano, Ano passado.
  • Validação: de <= até; intervalo máximo de 5 anos; datas futuras rejeitadas com "Escolha uma data até hoje."
  • Para FREE, o seletor limita o mínimo a hoje - 7 dias e exibe a explicação abaixo.

Ambos os filtros vão para a query string (?q=&from=&to=), o que torna o estado compartilhável e recuperável no botão "voltar" do navegador. A busca não é enviada para analytics com o termo digitado, apenas com o resultCount (Seção 9.13.2).

14.4.4 Detalhe e download #

/app/acervo/[devotionalId] mostra o mesmo cartão da tela "Hoje" (14.3.1), com navegação "anterior/próximo" dentro do escopo permitido pelo tier.

Download do áudio, apenas PAID:

  • Botão "Baixar áudio (MP3)".
  • O clique chama POST /api/devotionals/{devotionalId}/media-links, que devolve uma URL assinada com TTL de 5 minutos e response-content-disposition: attachment no nome palavra-diaria-2026-08-25.mp3. É POST e não GET porque cada chamada emite uma URL assinada nova; a resposta tem Cache-Control: no-store.
  • TTL menor que o dos players (15 minutos, Seção 16) porque o link de download tende a ser compartilhado. Cinco minutos é suficiente para o navegador iniciar a transferência.
  • A URL nunca é embutida no HTML renderizado no servidor. Ela só existe após uma ação explícita e autorizada.
  • Rate limit: 20 links por assinante por hora. Excedente devolve 429 RATE_LIMITED.
  • O evento é registrado em log com subscriberId e devotionalId, para detectar abuso de compartilhamento (Seção 23).

14.4.5 Estado vazio do acervo #

Situação Título Frase CTA
Assinante novo, nenhum devocional no período "Seu acervo começa agora" "Os devocionais que você receber ficam guardados aqui." "Ver o de hoje"
Busca sem resultado "Nenhum devocional encontrado" "Tente outras palavras ou limpe os filtros." "Limpar filtros"
Filtro de data sem resultado "Nada nesse período" "Escolha outro intervalo de datas." "Últimos 30 dias"

14.5 Tela "Assinatura" (/app/assinatura) #

14.5.1 Cartão do plano atual #

Campos exibidos, todos vindos de GET /api/me/subscription:

Rótulo Origem Exemplo
Plano plans.name + tier "Plano Completo — Anual"
Valor subscriptions.amount_cents formatado por formatBRL "R$ 199,00 por ano"
Situação subscriptions.status traduzido "Ativa"
Forma de pagamento billingType + últimos 4 dígitos ou "PIX" "Cartão final 4242" ou "PIX"
Próxima cobrança next_due_date em America/Sao_Paulo "30 de setembro de 2026"
Início started_at "30 de setembro de 2025"

Todo valor monetário chega da API como inteiro em centavos, no campo com sufixo AmountCents, e é formatado na apresentação por formatBRL (divisor 100). O painel nunca recebe nem produz decimal de dinheiro.

Para tier FREE, o cartão vira um bloco de oferta com o <PlanComparison> (Seção 9.4) e o CTA de assinatura. Nenhum dado de cobrança é exibido, porque não existe. O "Plano Gratuito" mostrado nesse bloco é o item sintético montado pelo handler (Seção 9.16.2): não há linha correspondente em plans, porque ser gratuito é a ausência de assinatura vigente (Seção 13.1).

Tradução de subscriptions.status para o assinante (a máquina de estados é canônica na Seção 13):

Estado técnico Texto exibido Cor semântica
PENDING_PAYMENT "Aguardando pagamento" Atenção
ACTIVE "Ativa" Positiva
CANCELED "Cancelada — acesso até 30/09/2026" Neutra
EXPIRED "Encerrada por falta de pagamento" Negativa
REFUNDED "Reembolsada" Neutra

14.5.2 Histórico de pagamentos #

Tabela em >= md, cartões empilhados em < md. Paginada por cursor, 12 por página.

Coluna Conteúdo
Data coalesce(payments.confirmed_at, payments.received_at, payments.due_date), em America/Sao_Paulo
Valor payments.amount_cents formatado por formatBRL
Forma "Cartão" ou "PIX"
Situação Ver mapa abaixo
Comprovante Link "Ver comprovante" quando payments.receipt_url existir

Mapa de situação exibida:

payments.status Texto Ação disponível
PENDING "Aguardando" "Pagar agora" (abre a fatura/PIX da Asaas em nova aba)
CONFIRMED / RECEIVED "Pago" "Ver comprovante"
OVERDUE "Vencido" "Pagar agora"
REFUNDED "Reembolsado" "Ver comprovante"
CHARGEBACK_REQUESTED "Em contestação" Nenhuma; texto explicativo
DELETED "Cancelado" Nenhuma

O link do comprovante é a URL da própria Asaas, aberta com target="_blank" rel="noopener noreferrer". O painel não hospeda nem gera comprovante.

Estado vazio: "Nenhuma cobrança ainda." com a frase "Quando você assinar, o histórico aparece aqui."

14.5.3 Upgrade #

  • Visível para tier FREE e para quem está em plano mensal.
  • FREE → PAID: botão "Assinar o plano completo" → /app/assinatura/checkout?plan=<code>.
  • Mensal → Anual: botão "Mudar para o plano anual", com um Dialog que mostra, com números concretos, o que acontece: a assinatura mensal é cancelada na Asaas, uma nova anual é criada com nextDueDate igual à data de hoje, e o valor pago do ciclo mensal corrente não é reembolsado nem creditado — o acesso já usufruído não é devolvido. Exemplo exibido: "Você pagou R$ 19,90 em 10/08. Ao mudar hoje, 25/08, você paga R$ 199,00 agora e sua próxima cobrança será em 25/08/2027." O botão só habilita depois que o assinante marca "Entendi".
  • Anual → Mensal: não é oferecido no painel. Decisão explícita: o downgrade seria economicamente irracional no meio de um ciclo anual já pago. Quem quiser, cancela e reassina depois do fim do ciclo; a tela de cancelamento explica isso.
  • Toda a mecânica de criação e cancelamento de assinatura na Asaas é da Seção 12; o painel apenas chama os endpoints dela.

14.5.4 Cancelamento em duas etapas #

Fluxo obrigatório, sem exceção:

Etapa 1 — Confirmação com consequências explícitas. Dialog com título "Cancelar sua assinatura?" e um bloco de texto que declara, nominalmente:

Sua assinatura não será renovada. Você continua com acesso completo — devocional diário com áudio — até 30 de setembro de 2026, que é o fim do período que você já pagou. Depois dessa data, sua conta passa para o plano gratuito e você volta a receber o devocional apenas aos domingos, sem áudio. Nada é cobrado de novo. Você pode reativar quando quiser.

A data exibida é subscriptions.current_period_end formatada por extenso. Se o assinante estiver em PENDING_PAYMENT (nunca pagou), o texto muda para: "Sua assinatura ainda não foi paga. Ao cancelar, ela é encerrada agora e nada é cobrado."

Botões: "Voltar" (padrão, com foco inicial) e "Continuar".

Etapa 2 — Pesquisa de motivo. Obrigatória para concluir, mas respondível em um clique. Campo reason com opções:

Valor Rótulo
TOO_EXPENSIVE "Está caro para mim"
TOO_MANY_MESSAGES "Recebo mensagens demais"
CONTENT_NOT_FOR_ME "O conteúdo não é o que eu esperava"
AUDIO_QUALITY "Não gostei do áudio"
TECHNICAL_ISSUES "Tive problemas técnicos"
TEMPORARY "É só uma pausa, volto depois"
OTHER "Outro motivo"

Campo de comentário livre, opcional, máximo 500 caracteres.

Ofertas contextuais, exibidas uma única vez conforme o motivo escolhido, sem bloquear o botão de cancelar:

Motivo Oferta
TOO_MANY_MESSAGES "Prefere pausar por até 30 dias em vez de cancelar?" → link para /app/preferencias
TEMPORARY Mesma oferta de pausa
TOO_EXPENSIVE Se estiver no mensal: "O plano anual sai por R$ 16,58 por mês." → link para o upgrade
TECHNICAL_ISSUES "Quer que a gente ajude antes?" → link para /contato
Demais Nenhuma oferta

O botão "Cancelar assinatura" fica sempre visível e habilitado. Nenhuma oferta é obrigatória, nenhum passo extra é inserido. Retenção por atrito é proibida.

Resultado. Ao concluir: subscriptions.status = 'CANCELED', canceled_at = now(), cancel_reason e cancel_comment gravados em subscription_events (Seção 6), chamada de cancelamento na Asaas (Seção 12), e-mail de confirmação (se houver e-mail verificado) e mensagem no WhatsApp confirmando a data de fim do acesso. O tier permanece PAID até current_period_end. Isso não é carência: é o período pago (Seção 13).

Como o cancelamento propaga uma escrita para a Asaas, a chamada exige o cabeçalho Idempotency-Key (Seção 7.7.1). O botão gera a chave no primeiro clique e a reutiliza em qualquer nova tentativa da mesma tela.

Tela pós-cancelamento: mensagem de confirmação, a data exata de fim do acesso e um botão "Reativar assinatura" que permanece disponível até aquela data.

14.5.5 Reativação #

  • Enquanto status = 'CANCELED' e now() < current_period_end: o botão "Reativar" simplesmente recria a assinatura na Asaas com nextDueDate = current_period_end, sem nova cobrança imediata. O assinante não perde um dia sequer.
  • Depois de current_period_end (já em FREE): "Reativar" é equivalente a uma assinatura nova e passa pelo checkout completo.
  • A distinção é exibida no rótulo do botão: "Reativar sem nova cobrança agora" no primeiro caso, "Assinar novamente" no segundo.

14.6 Tela "Perfil" (/app/perfil) #

14.6.1 Nome e e-mail #

Formulário simples com dois campos e um botão "Salvar", desabilitado enquanto não houver alteração.

Campo Regras
name Mesmas regras de 11.3.3. Alterar o nome não altera nada retroativo; templates futuros usam o novo.
email Opcional. Alterar dispara verificação: um e-mail com link de confirmação (Resend), válido por 24 h. Até confirmar, o campo mostra "Não verificado" e o e-mail não serve como fallback de login (Seção 8).

Regras do e-mail:

  • Remover o e-mail (campo vazio) é permitido e imediato, sem verificação. Um aviso alerta que o fallback de login por e-mail deixará de funcionar.
  • Trocar para um e-mail já verificado por outro assinante: 409 EMAIL_ALREADY_IN_USE com a mensagem "Esse e-mail já está em uso por outra conta." Ver caso E8 da Seção 11.11.
  • Reenviar o e-mail de verificação: máximo 3 por dia, com 5 minutos de intervalo.

14.6.2 Troca do número de WhatsApp #

Operação sensível: o número é a identidade (Seção 8). Fluxo completo, em rota própria /app/perfil/telefone:

[1] Tela de aviso
    "Trocar o número muda como você entra na conta e para onde enviamos o devocional."
    Botão "Continuar"
        │
        ▼
[2] Verificação do número ATUAL
    Envia OTP para o número atual. 6 dígitos, 10 min, 5 tentativas.
    Motivo: impedir que alguém com a sessão roubada sequestre a conta.
        │  código correto
        ▼
[3] Novo número
    Campo de telefone com as mesmas validações da Seção 11.4.
    Verificação de disponibilidade em tempo real (debounce 600 ms).
        │
        ▼
[4] Verificação do número NOVO
    Envia OTP para o novo número. Mesmos limites.
        │  código correto
        ▼
[5] Aplicação (transação única)
    - phone_e164 = novo número (cifrado) e phone_hmac recalculado
    - wa_id = NULL e wa_id_hmac = NULL   ← redescobertos no próximo contato
    - phone_verified_at = now()
    - service_window_expires_at = NULL
    - opt_in_confirmed_at PRESERVADO   (o consentimento é da pessoa, não do número)
    - registro em admin_audit_log com action = 'SUBSCRIBER_PHONE_CHANGED'
    - TODAS as sessões do assinante são revogadas, INCLUSIVE a atual
    - mensagem no WhatsApp do número novo confirmando a troca
    - mensagem no WhatsApp do número ANTIGO avisando da troca (última mensagem enviada
      para ele; se falhar, ignora)
        │
        ▼
[6] Confirmação e novo login
    "Pronto. Seu devocional agora vai para (21) 9****-4321.
     Por segurança, entre de novo com o número novo."

Regras adicionais:

  • Se o novo número já pertencer a um assinante ativo: 409 PHONE_ALREADY_REGISTERED, com a mensagem "Esse número já tem uma conta. Entre com ele ou escolha outro."
  • Se o novo número pertencer a um assinante com deleted_at preenchido: permitido, porque o índice único de phone_hmac é parcial (Seção 11.11, caso E3).
  • Máximo de 2 trocas por 30 dias. Acima disso, 429 PHONE_CHANGE_LIMIT com a instrução de falar com o suporte.
  • Abandonar no meio não altera nada: cada etapa só persiste no passo 5.
  • Toda a operação gera um registro em admin_audit_log com actor_type = 'SUBSCRIBER', porque é uma mudança de identidade e precisa ser auditável (Seção 15.10).
  • Revogação total das sessões. Trocar o número troca a identidade, e o wa_id associado deixa de valer. Por isso todas as sessões caem, inclusive a que executou a troca: a pessoa acabou de verificar dois códigos, então reentrar custa um código a mais e fecha o caminho pelo qual uma sessão roubada sobreviveria à troca. A mesma regra vale para a troca detectada de fora, quando o wa_id associado a um telefone muda sozinho por reciclagem de chip (Seção 11.4.3): sessões revogadas e nova verificação obrigatória antes de qualquer dado pessoal ser devolvido.
  • A troca não grava consent_events: o enum de tipos de consentimento é fechado na Seção 6 e não tem valor para mudança de número. A prova da operação é o registro de auditoria, que guarda ator, motivo, antes e depois.

14.6.3 Sair da conta #

  • Botão "Sair" encerra a sessão atual (POST /api/auth/logout, Seção 8).
  • Botão secundário "Sair de todos os dispositivos" revoga todas as sessões, inclusive a atual, e redireciona para /login. Útil quando o celular foi perdido.

14.7 Tela "Preferências" (/app/preferencias) #

Esta tela existe para separar, de forma inequívoca, três coisas diferentes que os assinantes costumam confundir.

14.7.1 A distinção, declarada na própria interface #

Um bloco explicativo no topo da tela, sempre visível:

Ação O que para O que continua
Pausar As mensagens no WhatsApp, por um período que você escolhe A cobrança continua. O acervo continua disponível. As mensagens voltam sozinhas no fim da pausa.
Cancelar recebimento (opt-out) As mensagens no WhatsApp, por tempo indeterminado, e a cobrança do próximo ciclo O acesso ao acervo continua até o fim do período já pago. Sem reativação em 30 dias, a assinatura é encerrada nessa data.
Cancelar assinatura A cobrança, de imediato As mensagens continuam até o fim do período já pago.

Quando o assinante tem plano pago e escolhe opt-out, um Dialog obrigatório reforça: "Você vai parar de receber as mensagens e sua próxima cobrança fica suspensa. Se você não voltar em 30 dias, a assinatura é encerrada ao fim do período que você já pagou. Quer encerrar a assinatura agora?" com dois botões: "Só parar as mensagens" e "Parar e cancelar a assinatura agora". Essa dupla escolha é a regra canônica da Seção 20 aplicada à interface; o efeito sobre a cobrança é definido na Seção 13.4.6, e o motivo é direto: revogado o consentimento, não é lícito prestar o serviço, e cobrar por serviço que não se pode prestar não se sustenta.

14.7.2 Pausa temporária #

  • Controle: seletor de duração com opções rápidas (7 dias, 15 dias, 30 dias) e um campo "até a data" com <input type="date">.
  • Limite: 1 a 30 dias. Menos de 1 dia é rejeitado; mais de 30 é rejeitado com "A pausa pode ser de no máximo 30 dias. Para parar por mais tempo, use Cancelar recebimento."
  • Persistência: subscribers.paused_until (timestamptz, Seção 6). NULL significa sem pausa. O valor é sempre 03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, de modo que o retorno caia antes do envio das 06:00 (Seção 20.6.1).
  • Semântica exata: o motor de envio pula o assinante enquanto paused_until > now().
  • A pausa termina sozinha: a função de entitlements para de bloquear quando paused_until <= now. O job messaging.pause das 05:30 é cosmético — limpa paused_until das pausas encerradas e enfileira a saudação de retorno; se falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação (Seção 20.6.2).
  • Enquanto pausado, a tela mostra "Pausado até 11 de setembro de 2026; você volta a receber em 12 de setembro" e um botão "Retomar agora", que zera paused_until e faz o próximo envio acontecer normalmente.
  • Alterar a pausa enquanto ela está ativa é permitido: substitui a anterior, não soma. A rota devolve 200 com a nova data.
  • Exemplo concreto: assinante PAID pausa em 25/08 por 7 dias. paused_until fica 2026-09-02T06:00:00Z (03:00 de 02/09 em Brasília). Ele não recebe de 26/08 a 01/09. Recebe normalmente em 02/09 às 06:00, precedido da saudação de retorno às 05:55. Nenhuma cobrança é alterada.
  • Registro: consent_events com type = 'PAUSE_STARTED', channel = 'WEB', granted = false e evidence = {"days": N, "pausedUntil": "..."}. O fim da pausa grava type = 'PAUSE_ENDED' com granted = true (Seção 20.6.3).
  • Enquanto a pausa está ativa, status = 'PAUSED' (Seção 11.8). O tier não muda.
  • A pausa não afeta o painel: ele continua vendo "Hoje" e o acervo.

14.7.3 Cancelar recebimento (opt-out) #

  • Botão destrutivo, com confirmação em Dialog e o aviso de 14.7.1 quando houver assinatura paga.
  • Efeito: opt_out_at = now(), opt_out_reason = 'PANEL', status = 'OPTED_OUT', envios cessam imediatamente (Seção 20), registro em consent_events com type = 'OPT_OUT', channel = 'WEB' e granted = false, e suspensão da cobrança do ciclo seguinte quando houver assinatura paga vigente (Seção 13.4.6).
  • Nenhuma mensagem é enviada no WhatsApp. Quando a origem do opt-out é o painel, a confirmação aparece na própria tela — informando que a próxima cobrança está suspensa e como voltar (VOLTAR no WhatsApp ou o botão do painel) — e, quando houver e-mail verificado, também por e-mail. Mandar mais uma mensagem no WhatsApp para quem acabou de pedir silêncio, por um canal que não é o WhatsApp, é contraditório (Seção 20.4.1).
  • A tela passa a exibir, no lugar dos controles, um bloco "Você não está recebendo as mensagens" com o botão "Voltar a receber".

14.7.4 Reativar recebimento #

  • Botão "Voltar a receber" → Dialog com o texto de consentimento vigente (não o antigo) e um checkbox obrigatório. A rota é POST /api/me/opt-in (Seção 20.12.2), que é a única forma de desfazer o opt-out.
  • Efeito: opt_out_at = NULL, status volta para ACTIVE_FREE ou ACTIVE_PAID conforme o tier corrente, registro em consent_events com type = 'RE_OPT_IN' e a policy_version vigente. Havendo assinatura paga suspensa por opt-out, a cobrança volta a correr no ciclo seguinte.
  • Não dispara welcome_backfill (regra 1 de 11.10). O próximo devocional chega no ciclo normal.

14.7.5 Outras preferências #

Preferência Controle Padrão Observação
Receber o e-mail de resumo semanal Interruptor Desligado Só habilitado se houver e-mail verificado. Conteúdo definido na Seção 19.
Receber avisos de cobrança por e-mail Interruptor Ligado Não pode ser desligado por quem tem assinatura ativa: são comunicações transacionais obrigatórias. O interruptor aparece desabilitado com explicação.

Não existe preferência de horário de envio: horário personalizado está fora de escopo (Seção 2). A tela declara isso em uma frase, para evitar tíquetes de suporte.

14.8 Tela "Meus dados" (/app/dados) #

Implementa os direitos do titular previstos na LGPD. Os prazos e as bases legais são canônicos na Seção 22; esta seção especifica a interface e os contratos.

14.8.1 Exportação de dados (portabilidade e acesso) #

  • Botão "Exportar meus dados (JSON)".
  • Processamento assíncrono: a solicitação enfileira o job de exportação na fila maintenance.cleanup (Seção 18 é a dona da lista de filas). A tela passa a mostrar "Preparando seu arquivo..." e revalida a cada 10 s.
  • O arquivo nunca é entregue por URL portadora. Ele é gravado cifrado no storage, no prefixo exports/, que fica fora de qualquer domínio público. Quando fica pronto, o assinante recebe apenas um aviso por e-mail (se houver e-mail verificado); o e-mail não contém link de download. O download acontece dentro do painel, sob sessão plena autenticada — nunca sob sessão restrita (Seção 8.15.1.1) —, por uma URL assinada de 15 minutos gerada no momento do clique e vinculada à sessão.
  • Motivo: um link válido por horas é a credencial de acesso ao dossiê completo do titular, e mensagens são encaminhadas o tempo todo. Quem encaminha o aviso não entrega os dados.
  • Cada download é registrado em admin_audit_log com action = 'DATA_EXPORT_DOWNLOADED'.
  • Limite de downloads do mesmo arquivo: 3. Depois disso é preciso pedir nova exportação.
  • Prazo declarado na interface: até 24 horas. Na prática o job leva segundos; o prazo declarado tem folga.
  • Limite de geração: 2 pedidos por assinante por mês. Excedente: 429 EXPORT_RATE_LIMITED com o horário de liberação.
  • O arquivo expira em 7 dias e é apagado do storage por job de manutenção.
  • O pedido grava consent_events com type = 'DATA_EXPORT_REQUESTED'.

Estrutura do JSON exportado (contrato estável, versionado):

{
  "exportVersion": "1",
  "generatedAt": "2026-08-25T14:02:11.000Z",
  "subscriber": {
    "id": "sub_01HZX...", "name": "Maria das Graças",
    "phoneE164": "+5511912345678", "email": "maria@exemplo.com.br",
    "emailVerified": true, "tier": "PAID", "status": "ACTIVE_PAID",
    "createdAt": "2025-09-30T12:00:00.000Z",
    "optInConfirmedAt": "2025-09-30T12:04:00.000Z",
    "optOutAt": null, "pausedUntil": null
  },
  "consents": [
    { "type": "OPT_IN_WEB", "policyVersion": "2025-09-01", "channel": "WEB",
      "text": "Concordo em receber...", "ip": "189.x.x.x",
      "userAgent": "Mozilla/5.0 ...", "createdAt": "2025-09-30T12:00:00.000Z" }
  ],
  "subscriptions": [
    { "id": "sbs_01HZX...", "plan": "plan_annual", "status": "ACTIVE",
      "amountCents": 19900, "currency": "BRL", "billingType": "CREDIT_CARD",
      "startedAt": "2025-09-30T12:10:00.000Z", "currentPeriodEnd": "2026-09-30T12:10:00.000Z" }
  ],
  "payments": [
    { "id": "pay_01HZX...", "amountCents": 19900, "status": "RECEIVED",
      "dueDate": "2025-09-30", "confirmedAt": "2025-09-30T12:11:00.000Z",
      "billingType": "CREDIT_CARD", "receiptUrl": "https://..." }
  ],
  "messages": [
    { "direction": "OUTBOUND", "kind": "DEVOTIONAL_TEXT", "devotionalId": "dev_01HZX...",
      "status": "read", "sentAt": "2026-08-25T09:00:12.000Z" }
  ],
  "devotionalsReceived": [
    { "id": "dev_01HZX...", "date": "2026-08-25", "title": "O Senhor é o meu pastor" }
  ]
}

Regras do conteúdo exportado:

  1. Não inclui dados de cartão. Nem token, nem bandeira, nem últimos dígitos — os últimos dígitos são exibidos no painel mas não exportados, por minimização.
  2. Não inclui o CPF em texto: inclui apenas "cpfCnpj": "***.***.789-**" mascarado, porque o dado original está na Asaas e a exportação não deve criar uma segunda cópia completa de documento.
  3. Inclui o texto integral de cada consentimento, porque é o que dá valor probatório ao registro para o próprio titular.
  4. messages traz metadados, não o conteúdo das mensagens: o conteúdo é o devocional, que já vai em devotionalsReceived por referência.

14.8.2 Solicitação de exclusão #

Fluxo em duas etapas, deliberadamente mais rígido que o cancelamento:

Etapa 1Dialog com a lista do que será perdido, em texto direto:

Ao excluir sua conta: você para de receber os devocionais; seu acervo e seu histórico ficam indisponíveis; sua assinatura paga é cancelada e não haverá reembolso do período em curso; seus dados pessoais são anonimizados em até 30 dias. Registros de pagamento e de consentimento são mantidos pelo prazo legal de 5 anos, sem seus dados de contato. Essa ação não pode ser desfeita.

Etapa 2 — Confirmação por digitação: o assinante precisa digitar EXCLUIR em um campo de texto. Só então o botão destrutivo habilita.

Efeito imediato ao confirmar:

  1. subscribers.deleted_at = now(). O status não vira DELETED: esse valor não existe no enum (Seção 11.8). A conta excluída é reconhecida por deleted_at IS NOT NULL, que vence qualquer status em toda leitura de fluxo.
  2. Todas as sessões revogadas; o navegador é redirecionado para / com uma mensagem.
  3. Assinatura ativa cancelada na Asaas (Seção 12).
  4. Envios cessam na hora.
  5. Job de pseudonimização agendado para now() + 30 dias, na fila maintenance.cleanup, conforme a retenção da Seção 22. Ele alcança todas as tabelas que guardam identificador pessoal do titular, e não apenas subscribers: display_name, phone_e164/phone_hmac, wa_id/wa_id_hmac e email/email_hmac do assinante, e também as colunas de telefone, wa_id e trecho de conteúdo dos registros de mensagem (message_logs, inbound_messages) e das tentativas de entrega. Nenhuma tabela retém telefone, wa_id, CPF ou e-mail em claro depois disso. O que a lei obriga a reter — registros fiscais e de consentimento — é retido de forma pseudonimizada, ligado apenas ao identificador interno. A regra completa, os dois estágios e o script são da Seção 22.9.
  6. Registro em consent_events com type = 'DATA_DELETION_REQUESTED'. O gatilho append-only dessa tabela reconhece o caminho privilegiado e auditado da anonimização exigida por lei (Seção 22.7.3): um controle de integridade não pode impedir o cumprimento de um direito do titular.
  7. E-mail de confirmação, se houver e-mail verificado.

Cancelar a exclusão: possível apenas dentro das primeiras 72 horas, e apenas por contato com o suporte, que executa a reversão pelo painel administrativo (Seção 15.7.3). O painel do assinante não oferece autoatendimento para isso, porque a conta já está com sessões revogadas. A interface declara esse prazo e o caminho.

14.8.3 Histórico de consentimentos #

Lista somente leitura, em ordem decrescente de data. Cada item mostra:

  • Tipo traduzido, um rótulo por valor do enum da Seção 6: "Cadastro no site" (OPT_IN_WEB), "Confirmação no WhatsApp" (OPT_IN_WHATSAPP), "Nova autorização" (RE_OPT_IN), "Cancelamento de recebimento" (OPT_OUT), "Início de pausa" (PAUSE_STARTED), "Fim de pausa" (PAUSE_ENDED), "Pedido de exportação" (DATA_EXPORT_REQUESTED), "Pedido de exclusão" (DATA_DELETION_REQUESTED). A troca de número não aparece aqui: ela é auditada em admin_audit_log (14.6.2).
  • Data e hora em America/Sao_Paulo.
  • Canal: "Site" ou "WhatsApp".
  • Versão do texto e um botão "Ver o texto que você aceitou", que abre um Dialog com o texto integral gravado.

Essa lista é a prova, para o próprio assinante, de que o duplo opt-in aconteceu. Ela nunca é editável nem apagável pela interface: consent_events é append-only (Seção 6).

14.9 Regras de autorização por propriedade #

Regra geral, sem exceção: todo recurso do painel é filtrado pelo subscriberId da sessão, no servidor, dentro da própria consulta ao banco. Nunca por verificação após a leitura.

// apps/web/src/server/authz/subscriber-scope.ts
export async function requireSubscriber(): Promise<SubscriberSession> {
  const session = await getSession();                      // Seção 8
  if (!session || session.role !== 'SUBSCRIBER') throw new UnauthenticatedError();
  if (session.subscriberDeletedAt !== null) throw new UnauthenticatedError();
  return session;
}

/** Toda query do painel usa este predicado. Não existe leitura sem ele. */
export function scopeToSubscriber(subscriberId: string) {
  return { subscriberId, subscriber: { deletedAt: null } };
}

Matriz de autorização das operações do painel:

Operação Regra Violação devolve
Ler devocional do dia Público para qualquer assinante autenticado
Ler devocional do acervo Precisa estar dentro do escopo do tier (14.4.2) 403 ENTITLEMENT_REQUIRED
Gerar link de mídia Tier PAID e devocional dentro do escopo 403 ENTITLEMENT_REQUIRED
Reenviar no WhatsApp Saldo diário do tier > 0, opt-in confirmado, sem pausa, sem opt-out 429 RESEND_LIMIT_REACHED / 409 OPTIN_REQUIRED / 409 SENDING_PAUSED
Ler assinatura subscriptions.subscriber_id = session.subscriberId 404 SUBSCRIPTION_NOT_FOUND (nunca 403, para não confirmar existência)
Ler pagamento payments.subscription.subscriber_id = session.subscriberId 404 PAYMENT_NOT_FOUND
Cancelar assinatura Mesma regra de propriedade e status IN ('ACTIVE','PENDING_PAYMENT') 409 ILLEGAL_STATE_TRANSITION
Trocar telefone Duplo OTP (14.6.2) e sessão plena 403 REVERIFICATION_REQUIRED
Exportar dados Dois pedidos por mês e sessão plena 429 EXPORT_RATE_LIMITED / 403 REVERIFICATION_REQUIRED
Baixar a exportação pronta Sessão plena, propriedade do exportId e saldo de downloads 404 EXPORT_NOT_FOUND / 429 EXPORT_RATE_LIMITED
Excluir conta Confirmação por digitação, validada no servidor, e sessão plena 422 CONFIRMATION_MISMATCH / 403 REVERIFICATION_REQUIRED

Seis regras de defesa adicionais:

  1. Enumeração: acessar um devotionalId que existe mas está fora do escopo devolve 403 ENTITLEMENT_REQUIRED, enquanto um devotionalId inexistente devolve 404 DEVOTIONAL_NOT_FOUND. Essa distinção é intencional e segura: o acervo não é segredo, o que é controlado é o acesso ao conteúdo.
  2. Recursos de terceiros (assinatura, pagamento, sessão, exportação) sempre devolvem 404, nunca 403, para não confirmar existência. Um exportId de outro assinante é indistinguível de um exportId inexistente.
  3. CSRF: todas as mutações exigem o cabeçalho X-CSRF-Token casado com o cookie __Host-csrf (Seção 8), que é o único nome de cookie de CSRF do documento. Requisições sem ele devolvem 403 CSRF_INVALID.
  4. Impersonação é somente leitura, sem exceção. A sessão de suporte que impersona um assinante carrega scope = 'IMPERSONATION_READONLY', e o invólucro withApi recusa qualquer método não seguro nessa sessão antes de chegar ao handler. A trava cobre todas as rotas de escrita executáveis sob a guarda requireSubscriber — o critério é a guarda, não o prefixo do caminho —, incluindo cancelamento de assinatura, troca de forma de pagamento, troca de plano, reativação, reenvio, pausa, opt-out, pedido de exportação e pedido de eliminação. Um teste de arquitetura enumera todo POST, PATCH, PUT e DELETE guardado por requireSubscriber e falha o build se algum não chamar assertNotImpersonating. Critério por prefixo de rota é insuficiente e está proibido.
  5. Sessão restrita por dormência. A posse do número é o único fator do assinante, e números brasileiros são reemitidos pelas operadoras. Quando a conta está dormente — sem mensagem de entrada nem login bem-sucedido nos últimos 90 dias, ou vinda de bloqueio por erro reincidente da Meta, ou com opt-out por palavra-chave sem reativação —, a verificação por código cria uma sessão com scope = 'RESTRICTED' (Seção 8.15.1.1). Essa sessão permite exclusivamente ler o devocional do dia, executar opt-out e cancelar a assinatura. Ela recusa com 403 REVERIFICATION_REQUIRED a leitura da conta, o acervo, a tela de assinatura, a de pagamentos, a troca de telefone, a exportação e a exclusão de conta. A elevação para sessão plena exige o link mágico por e-mail verificado ou a conferência de titularidade pelo suporte.
  6. Troca de wa_id derruba a sessão. Se o wa_id associado ao telefone do assinante mudar (Seção 11.4.3), todas as sessões dele são revogadas e uma nova verificação por código é exigida antes de qualquer dado pessoal ser devolvido. Ter respondido no WhatsApp nunca é, sozinho, prova de titularidade.

14.10 Endpoints do painel #

Todos exigem sessão de assinante e papel SUBSCRIBER, seguem o envelope da Seção 7 e ecoam X-Request-Id. Erros comuns a todos, omitidos das tabelas individuais: 401 UNAUTHENTICATED, 403 CSRF_INVALID (mutações), 403 REVERIFICATION_REQUIRED (sessão restrita, 14.9), 500 INTERNAL_ERROR.

Convenção de caminho desta seção, aplicada sem exceção: coleções são substantivos no plural em kebab-case (/api/devotionals, /api/me/phone-changes); ações sobre a própria conta do assinante ficam sob /api/me/, com nome de ação no singular (/api/me/opt-out, /api/me/pause, /api/me/deletion-request, /api/me/subscription/cancel). Nenhuma rota do painel do assinante vive fora de /api/me/ e de /api/devotionals/.

Nenhuma resposta paginada devolve total. A paginação é por cursor (nextCursor, hasMore, limit); contagens agregadas vêm das rotas de métricas (Seção 21.9).

Toda mutação que propaga escrita para a Asaas ou para a plataforma de mensagens exige o cabeçalho Idempotency-Key (Seção 7.7.1).

14.10.1 GET /api/me #

  • Efeitos colaterais: nenhum. Idempotente. Cache-Control: no-store.
  • Uso: dado base do layout (nome, tier, avisos globais).
{
  "data": {
    "id": "sub_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
    "name": "Maria das Graças",
    "phoneMasked": "(11) 9****-5678",
    "email": "maria@exemplo.com.br",
    "emailVerified": true,
    "tier": "PAID",
    "status": "ACTIVE_PAID",
    "optInConfirmedAt": "2025-09-30T15:04:00.000Z",
    "optOutAt": null,
    "pausedUntil": null,
    "entitlements": {
      "audio": true, "fullText": true,
      "archiveScope": "SINCE_SUBSCRIPTION",
      "manualResendPerDay": 3, "sendFrequency": "DAILY"
    },
    "resendsUsedToday": 1,
    "notices": ["SUBSCRIPTION_CANCELED_ACCESS_UNTIL"],
    "accessUntil": "2026-09-30T03:00:00.000Z"
  },
  "meta": { "requestId": "req_01HZXC1A2B3C4D5E6F7G8H9J0K", "timestamp": "2026-08-25T14:00:00.000Z" }
}

O objeto entitlements é a serialização direta de resolveEntitlements() (Seção 13). O cliente nunca deriva permissão de tier; ele lê entitlements.

Erros: 404 SUBSCRIBER_NOT_FOUND (sessão válida cuja conta foi excluída entre a emissão e o uso).

14.10.2 GET /api/devotionals/today #

  • Efeitos colaterais: nenhum. Idempotente. Cache-Control: private, no-store.

Resposta 200 (PAID):

{
  "data": {
    "id": "dev_01HZX9C7Q0S6M2R4T8V1Y3B5N7",
    "date": "2026-08-25",
    "title": "O Senhor é o meu pastor",
    "bibleReference": "Salmos 23:1-3",
    "bibleVersion": "Almeida 1911",
    "bibleText": "O SENHOR é o meu pastor; nada me faltará...",
    "reflectionHtml": "<p>Quando o pastor guia...</p>",
    "prayer": "Senhor, ensina-me a confiar...",
    "audio": {
      "available": true,
      "state": "READY",
      "durationSec": 214,
      "streamUrl": "https://media.palavradiaria.com.br/...?X-Amz-Expires=900&...",
      "mimeType": "audio/mpeg"
    },
    "delivery": { "sentAt": "2026-08-25T09:00:12.000Z", "status": "read" }
  },
  "meta": { "requestId": "req_01HZXC2B3C4D5E6F7G8H9J0K1L", "timestamp": "2026-08-25T14:00:00.000Z" }
}

Para FREE, audio vem como { "available": false, "state": "LOCKED" } e nenhuma URL é gerada. Estados possíveis de audio.state: READY, PENDING, FAILED, LOCKED, NONE.

Erros: 404 DEVOTIONAL_NOT_FOUND (nada publicado hoje).

14.10.3 GET /api/devotionals #

  • Query: ?limit=<1..100>&cursor=<opaque>&q=<texto>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>
  • Efeitos colaterais: nenhum. Idempotente.
export const archiveQuerySchema = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().max(64).optional(),
  q: z.string().trim().min(3).max(80).optional(),
  from: z.iso.date().optional(),
  to: z.iso.date().optional(),
}).refine((v) => !v.from || !v.to || v.from <= v.to, {
  message: 'A data inicial precisa ser anterior à final.', path: ['from'],
});

Resposta 200:

{
  "data": [
    { "id": "dev_01HZX...", "date": "2026-08-25", "title": "O Senhor é o meu pastor",
      "bibleReference": "Salmos 23:1-3", "teaser": "Quando o pastor guia...",
      "hasAudio": true, "locked": false }
  ],
  "meta": {
    "requestId": "req_01HZXC3C4D5E6F7G8H9J0K1L2M",
    "timestamp": "2026-08-25T14:00:00.000Z",
    "nextCursor": "MDFIWlg5Qzd...", "hasMore": true,
    "lockedCount": 385, "archiveScope": "LAST_7_DAYS"
  }
}

Erros: 422 VALIDATION_ERROR (inclui from > to e limite fora da faixa), 429 RATE_LIMITED (acima de 60 consultas por minuto).

14.10.4 GET /api/devotionals/{devotionalId} #

  • Efeitos colaterais: nenhum. Idempotente.
  • Mesmo formato de GET /api/devotionals/today.

Erros: 404 DEVOTIONAL_NOT_FOUND, 403 ENTITLEMENT_REQUIRED (fora do escopo do tier; details.archiveScope informa o escopo permitido).

14.10.5 POST /api/devotionals/{devotionalId}/media-links #

  • Efeitos colaterais: gera URL assinada. Não escreve no banco; registra log.
  • Idempotência: cada chamada gera uma URL nova. Chamadas repetidas são seguras.

Request: { "purpose": "download" | "stream" }.

Resposta 200:

{
  "data": {
    "url": "https://media.palavradiaria.com.br/devotionals/2026/08/dev_01HZX.../voice.mp3?X-Amz-Expires=300&...",
    "expiresAt": "2026-08-25T14:07:00.000Z",
    "mimeType": "audio/mpeg",
    "sizeBytes": 3421884,
    "fileName": "palavra-diaria-2026-08-25.mp3"
  },
  "meta": { "requestId": "req_01HZXC4D5E6F7G8H9J0K1L2M3N", "timestamp": "2026-08-25T14:02:00.000Z" }
}

TTL: 300 s para download, 900 s para stream.

A resposta é sempre Cache-Control: no-store: cada chamada emite uma URL assinada nova, e uma URL assinada é credencial, não endereço. Ela nunca é registrada em log — os registros referem-se à mídia por mediaKey (Seção 23.1.4).

Erros: 403 ENTITLEMENT_REQUIRED (tier FREE ou fora do escopo), 404 AUDIO_NOT_FOUND (devocional sem áudio pronto), 429 RATE_LIMITED (mais de 20 por hora), 500 STORAGE_UNAVAILABLE.

14.10.6 POST /api/devotionals/{devotionalId}/resends #

  • Efeitos colaterais: enfileira envio no WhatsApp, grava delivery_attempts com reason = 'MANUAL_RESEND', consome uma unidade do saldo diário.
  • Idempotência: protegida por Idempotency-Key obrigatório (ULID gerado pelo cliente no clique). Repetição com a mesma chave em até 10 minutos devolve 200 com o mesmo corpo e não consome saldo nem envia de novo.

Resposta 200:

{
  "data": {
    "queued": true,
    "channel": "FREE_FORM",
    "remainingToday": 1,
    "resetsAt": "2026-08-26T03:00:00.000Z"
  },
  "meta": { "requestId": "req_01HZXC5E6F7G8H9J0K1L2M3N4P", "timestamp": "2026-08-25T14:05:00.000Z" }
}

channel é FREE_FORM quando a janela de 24 h está aberta e TEMPLATE quando fechada. A interface usa esse campo para escolher o texto do toast (14.3.3).

Erros:

HTTP code Quando
429 RESEND_LIMIT_REACHED Saldo diário zerado; details.resetsAt
409 OPTIN_REQUIRED opt_in_confirmed_at nulo
409 SUBSCRIBER_OPTED_OUT opt_out_at preenchido
409 SENDING_PAUSED paused_until > now(); details.pausedUntil
403 ENTITLEMENT_REQUIRED Devocional fora do escopo do tier
404 DEVOTIONAL_NOT_FOUND
400 IDEMPOTENCY_KEY_REQUIRED Cabeçalho ausente

14.10.7 GET /api/me/subscription #

  • Efeitos colaterais: nenhum. Idempotente. no-store.

Resposta 200 (PAID ativo):

{
  "data": {
    "id": "sbs_01HZX...",
    "plan": { "code": "plan_annual", "name": "Anual", "cycle": "YEARLY",
              "amountCents": 19900, "priceLabel": "R$ 199,00/ano" },
    "status": "ACTIVE",
    "statusLabel": "Ativa",
    "billingType": "CREDIT_CARD",
    "cardLast4": "4242",
    "cardBrand": "VISA",
    "startedAt": "2025-09-30T12:10:00.000Z",
    "nextDueDate": "2026-09-30",
    "currentPeriodEnd": "2026-09-30T02:59:59.000Z",
    "canceledAt": null,
    "canUpgradeToAnnual": false,
    "canReactivateWithoutCharge": false
  },
  "meta": { "requestId": "req_01HZXC6F7G8H9J0K1L2M3N4P5Q", "timestamp": "2026-08-25T14:00:00.000Z" }
}

Para FREE: { "data": null, "meta": { ... } } com HTTP 200. Ausência de assinatura não é erro — e é exatamente o que significa ser gratuito (Seção 13.1). A tela monta o cartão do plano gratuito a partir do item sintético de GET /api/public/plans (Seção 9.16.2); esta rota nunca devolve um plan_free.

14.10.8 GET /api/me/payments #

  • Query: ?limit=<1..50>&cursor=<opaque>
  • Efeitos colaterais: nenhum. Idempotente.
{
  "data": [
    { "id": "pay_01HZX...", "amountCents": 19900, "currency": "BRL", "status": "RECEIVED",
      "statusLabel": "Pago", "billingType": "CREDIT_CARD", "dueDate": "2025-09-30",
      "confirmedAt": "2025-09-30T12:11:00.000Z", "receivedAt": "2025-09-30T12:11:00.000Z",
      "receiptUrl": "https://www.asaas.com/comprovante/...", "invoiceUrl": null }
  ],
  "meta": { "requestId": "req_01HZXC7G8H9J0K1L2M3N4P5Q6R",
            "timestamp": "2026-08-25T14:00:00.000Z",
            "nextCursor": null, "hasMore": false }
}

Erros: 422 VALIDATION_ERROR.

14.10.9 POST /api/me/subscription/cancel #

  • Efeitos colaterais: cancela na Asaas, atualiza subscriptions, grava subscription_events, dispara e-mail e mensagem no WhatsApp.
  • Idempotência: Idempotency-Key obrigatório (Seção 7.7.1), porque a operação propaga uma escrita para a Asaas. Replay da mesma chave devolve a resposta gravada com Idempotent-Replay: true. Chamada sem a chave devolve 400 IDEMPOTENCY_KEY_REQUIRED. Repetição com chave nova depois do sucesso devolve 409 SUBSCRIPTION_NOT_ACTIVE, com status: 'CANCELED' e a mesma accessUntil; a Asaas não é chamada de novo.

Request:

export const cancelSubscriptionSchema = z.object({
  reason: z.enum(['TOO_EXPENSIVE','TOO_MANY_MESSAGES','CONTENT_NOT_FOR_ME',
                  'AUDIO_QUALITY','TECHNICAL_ISSUES','TEMPORARY','OTHER']),
  comment: z.string().trim().max(500).optional(),
  confirmed: z.literal(true),
});

Resposta 200:

{
  "data": {
    "status": "CANCELED",
    "accessUntil": "2026-09-30T02:59:59.000Z",
    "accessUntilLabel": "30 de setembro de 2026",
    "canReactivateWithoutCharge": true,
    "refund": null
  },
  "meta": { "requestId": "req_01HZXC8H9J0K1L2M3N4P5Q6R7S", "timestamp": "2026-08-25T14:10:00.000Z" }
}

Erros: 404 SUBSCRIPTION_NOT_FOUND, 409 SUBSCRIPTION_NOT_ACTIVE (já cancelada), 409 ILLEGAL_STATE_TRANSITION (já EXPIRED ou REFUNDED), 422 VALIDATION_ERROR, 400 IDEMPOTENCY_KEY_REQUIRED, 422 IDEMPOTENCY_KEY_REUSED, 502 ASAAS_ERROR (a Asaas recusou; o estado local não é alterado e a interface pede para tentar de novo).

14.10.10 POST /api/me/subscription/reactivate #

  • Efeitos colaterais: recria a assinatura na Asaas com nextDueDate = current_period_end, atualiza estado local.
  • Idempotência: Idempotency-Key obrigatório.

Resposta 200: mesmo formato de GET /api/me/subscription.

Erros: 404 SUBSCRIPTION_NOT_FOUND, 409 REACTIVATION_WINDOW_CLOSED (now() >= current_period_end; a interface então leva ao checkout normal), 502 ASAAS_ERROR.

14.10.11 PATCH /api/me #

  • Efeitos colaterais: atualiza name e/ou email; troca de e-mail dispara verificação.
  • Idempotência: sim; enviar os mesmos valores é no-op.
export const updateMeSchema = z.object({
  name: z.string().trim().min(2).max(80).regex(NAME_ALLOWED).optional(),
  email: z.union([z.literal(''), z.email().max(160)]).optional(),
}).refine((v) => Object.keys(v).length > 0, { message: 'Nada para atualizar.' });

Resposta 200: mesmo corpo de GET /api/me, com emailVerified: false quando o e-mail mudou.

Erros: 422 VALIDATION_ERROR, 409 EMAIL_ALREADY_IN_USE, 429 RATE_LIMITED (mais de 10 alterações por hora).

14.10.12 PUT /api/me/preferences #

  • Efeitos colaterais: grava os interruptores de e-mail. Não grava paused_until: a pausa tem endpoint próprio (14.10.12.1), porque é uma ação de estado com registro de consentimento, e não uma preferência.
  • Idempotência: sim; é um PUT com o estado completo.
export const preferencesSchema = z.object({
  weeklyEmailDigest: z.boolean(),
  billingEmails: z.boolean(),          // travado em true enquanto houver assinatura ativa
});

Resposta 200:

{
  "data": { "weeklyEmailDigest": false, "billingEmails": true },
  "meta": { "requestId": "req_01HZXC9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T14:15:00.000Z" }
}

Erros: 422 VALIDATION_ERROR.

14.10.12.1 POST /api/me/pause e DELETE /api/me/pause #

POST inicia ou substitui a pausa: grava paused_until (03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, 14.7.2), status = 'PAUSED', e consent_events com type = 'PAUSE_STARTED'.

export const pauseSchema = z.object({
  days: z.number().int().min(1).max(30).optional(),
  until: z.iso.date().optional(),
}).refine((v) => Boolean(v.days) !== Boolean(v.until), {
  message: 'Informe a duração em dias ou a data final, nunca as duas.',
}).superRefine((v, ctx) => {
  if (!v.until) return;
  const days = differenceInCalendarDays(parseISO(v.until), todayInSaoPaulo());
  if (days < 1)  ctx.addIssue({ code: 'custom', path: ['until'],
                                message: 'Escolha uma data a partir de amanhã.' });
  if (days > 30) ctx.addIssue({ code: 'custom', path: ['until'],
                                message: 'A pausa pode ser de no máximo 30 dias.' });
});

Resposta 200:

{
  "data": { "pausedUntil": "2026-09-02T06:00:00.000Z",
            "lastPausedDayLabel": "1 de setembro de 2026",
            "resumesOnLabel": "2 de setembro de 2026" },
  "meta": { "requestId": "req_01HZXC9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T14:15:00.000Z" }
}

Pausa sobre pausa substitui a anterior; não soma. A rota devolve 200 com a nova data.

DELETE encerra a pausa agora: paused_until = NULL, status volta ao correspondente ao tier, consent_events com type = 'PAUSE_ENDED'.

Erros de ambos: 422 VALIDATION_ERROR, 429 PAUSE_LIMIT_EXCEEDED, 409 SUBSCRIBER_OPTED_OUT (não faz sentido pausar quem já saiu; a interface oferece reativar primeiro), 409 ILLEGAL_STATE_TRANSITION (DELETE sem pausa ativa).

14.10.13 POST /api/me/opt-out #

POST registra o opt-out: opt_out_at, status = 'OPTED_OUT', consent_events com type = 'OPT_OUT' e channel = 'WEB', suspensão da cobrança do ciclo seguinte quando houver assinatura paga vigente (Seção 13.4.6), e confirmação por e-mail quando houver e-mail verificado — não se envia WhatsApp quando a origem é o painel (Seção 20.4.1).

Request: { "reason": "PANEL", "alsoCancelSubscription": false }. Quando alsoCancelSubscription é true, o mesmo handler executa o cancelamento de 14.10.9 com reason = 'TOO_MANY_MESSAGES' na mesma transação lógica, e aí o Idempotency-Key também é obrigatório.

Resposta 200: { "data": { "optedOutAt": "...", "billingSuspended": true, "subscriptionCanceled": false, "autoCancelAt": "2026-09-24T14:20:00.000Z" }, "meta": {...} }.

Não existe DELETE /api/me/opt-out. A reativação do recebimento é POST /api/me/opt-in (Seção 20.12.2), que exige o consentimento vigente e grava consent_events com type = 'RE_OPT_IN'; a cobrança volta a correr no ciclo seguinte. A escolha é deliberada: reativar consentimento é ato positivo do titular, não remoção de recurso, e a Seção 20 é a dona do opt-in e do opt-out.

Erros: 409 ILLEGAL_STATE_TRANSITION (já em OPTED_OUT), 422 VALIDATION_ERROR, 400 IDEMPOTENCY_KEY_REQUIRED e 502 ASAAS_ERROR — os dois últimos apenas quando alsoCancelSubscription é true; nesse caso o opt-out em si é aplicado e o cancelamento na Asaas é reenfileirado. Falha no envio da confirmação por e-mail não impede a mudança de estado nem aparece na resposta: a confirmação já foi dada na tela e o e-mail é reenfileirado.

14.10.14 POST /api/me/phone-changes (fluxo de três chamadas) #

Três endpoints encadeados, conforme 14.6.2:

Passo Endpoint Request Resposta
1 POST /api/me/phone-changes {} { "changeId": "phc_01H...", "otpSentTo": "(11) 9****-5678", "expiresAt": "..." }
2 POST /api/me/phone-changes/{changeId}/current-verifications { "code": "123456" } { "step": "NEW_PHONE" }
3 POST /api/me/phone-changes/{changeId}/new-phone { "phone": "(21) 98765-4321" } { "otpSentTo": "(21) 9****-4321", "expiresAt": "..." }
4 POST /api/me/phone-changes/{changeId}/new-verifications { "code": "654321" } { "applied": true, "phoneMasked": "(21) 9****-4321", "sessionsRevoked": 3, "reloginRequired": true }
  • Idempotência: cada changeId é de uso único e expira em 30 minutos.
  • Efeitos colaterais: apenas no passo 4, tudo em uma transação. sessionsRevoked conta todas as sessões, inclusive a que executou a troca; reloginRequired é sempre true e a interface leva a pessoa ao login com o número novo (14.6.2).

Erros: 422 VALIDATION_ERROR, 422 OTP_INVALID, 410 OTP_EXPIRED, 429 OTP_ATTEMPTS_EXCEEDED, 409 PHONE_ALREADY_REGISTERED, 429 PHONE_CHANGE_LIMIT, 404 PHONE_CHANGE_NOT_FOUND (expirado), 409 ILLEGAL_STEP (chamar o passo 4 antes do 3).

14.10.15 POST /api/me/data-exports, GET /api/me/data-exports/current e POST /api/me/data-exports/current/download-links #

POST /api/me/data-exports enfileira o job de exportação na fila maintenance.cleanup e grava consent_events com type = 'DATA_EXPORT_REQUESTED'. Resposta 202: { "data": { "exportId": "exp_01H...", "state": "QUEUED", "estimatedSeconds": 30 }, "meta": {...} }.

GET /api/me/data-exports/current consulta o estado. Ele não devolve URL: o arquivo não é entregue por link portador (14.8.1). Resposta 200:

{
  "data": { "exportId": "exp_01H...", "state": "READY",
            "readyAt": "2026-08-25T14:17:00.000Z",
            "expiresAt": "2026-09-01T14:17:00.000Z",
            "downloadsUsed": 0, "downloadsAllowed": 3, "sizeBytes": 18422 },
  "meta": { "requestId": "req_01HZXD1K2L3M4N5P6Q7R8S9T0U", "timestamp": "2026-08-25T14:17:00.000Z" }
}

Estados: QUEUED, PROCESSING, READY, FAILED, EXPIRED.

POST /api/me/data-exports/current/download-links emite a URL assinada no momento do clique, com TTL de 15 minutos, vinculada à sessão, e incrementa downloadsUsed. Exige sessão plena (nunca restrita, 14.9) e registra admin_audit_log com action = 'DATA_EXPORT_DOWNLOADED'. Resposta Cache-Control: no-store.

Resposta 200: { "data": { "url": "https://media.palavradiaria.com.br/exports/...?X-Amz-Expires=900&...", "expiresAt": "2026-08-25T14:32:00.000Z", "downloadsRemaining": 2 }, "meta": {...} }.

Erros: 429 EXPORT_RATE_LIMITED (details.retryAfterSeconds; até 2 pedidos por mês, e até 3 downloads por arquivo), 404 EXPORT_NOT_FOUND (inexistente, expirado ou de outro assinante — a resposta é a mesma nos três casos), 403 REVERIFICATION_REQUIRED (sessão restrita), 500 STORAGE_UNAVAILABLE.

14.10.16 POST /api/me/deletion-request #

  • Efeitos colaterais: tudo o que está em 14.8.2.
  • Idempotência: chamar de novo em conta com deleted_at já preenchido devolve 200 sem novo efeito.

Request:

export const deleteAccountSchema = z.object({
  confirmation: z.literal('EXCLUIR', { error: 'Digite EXCLUIR para confirmar.' }),
  reason: z.string().trim().max(500).optional(),
});

Resposta 200:

{
  "data": { "deletedAt": "2026-08-25T14:20:00.000Z",
            "anonymizationScheduledFor": "2026-09-24T14:20:00.000Z",
            "subscriptionCanceled": true,
            "reversalDeadline": "2026-08-28T14:20:00.000Z" },
  "meta": { "requestId": "req_01HZXD2L3M4N5P6Q7R8S9T0U1V", "timestamp": "2026-08-25T14:20:00.000Z" }
}

Erros: 422 CONFIRMATION_MISMATCH, 502 ASAAS_ERROR (a exclusão prossegue mesmo assim; o cancelamento na Asaas é reenfileirado e um alerta operacional é criado — nunca travar um direito LGPD por falha de terceiro).

14.10.17 GET /api/me/consents #

  • Efeitos colaterais: nenhum. Idempotente.
  • Query: ?limit=<1..100>&cursor=<opaque>
{
  "data": [
    { "id": "cev_01HZX...", "type": "OPT_IN_WHATSAPP", "typeLabel": "Confirmação no WhatsApp",
      "channel": "WHATSAPP", "policyVersion": "2025-09-01",
      "text": "Ao confirmar, você concorda em receber...",
      "createdAt": "2025-09-30T15:04:00.000Z" }
  ],
  "meta": { "requestId": "req_01HZXD3M4N5P6Q7R8S9T0U1V2W",
            "timestamp": "2026-08-25T14:00:00.000Z",
            "nextCursor": null, "hasMore": false }
}

O campo ip não é retornado ao assinante nesta rota, apenas na exportação completa (14.8.1), para reduzir exposição em tela.

14.11 Comportamento em tempo real #

Decisão: não há WebSocket, não há Server-Sent Events, não há long polling. O painel é um leitor de estado que muda poucas vezes por dia. Manter uma conexão persistente por assinante custaria memória e complexidade operacional (sticky sessions no proxy, reconexão, autenticação de socket) sem benefício proporcional.

A atualização usa revalidação do TanStack Query com intervalos explícitos.

14.11.1 Tabela de configuração por consulta #

Chave da query staleTime refetchInterval refetchOnWindowFocus Justificativa
['me'] 60 s Nenhum Sim Muda por ação do próprio usuário.
['devotional','today'] 5 min 30 s somente enquanto audio.state === 'PENDING'; caso contrário nenhum Sim O único estado que muda sozinho é o áudio sendo gerado.
['devotionals', filtros] 5 min Nenhum Não Conteúdo histórico é imutável.
['devotional', id] 30 min Nenhum Não Imutável depois de publicado.
['subscription','current'] 30 s 20 s somente enquanto status === 'PENDING_PAYMENT'; caso contrário nenhum Sim Espera de confirmação de PIX é o único caso de mudança externa observada em tela.
['payments', cursor] 60 s Nenhum Sim
['consents', cursor] 5 min Nenhum Não Append-only e raro.
['dataExport','current'] 0 10 s enquanto state ∈ {QUEUED, PROCESSING} Sim Job curto.
['signup','current'] 0 5 s nos 2 primeiros minutos, 15 s depois, parada total em 10 min Sim Espera do opt-in (Seção 11.6.3).

14.11.2 Regras gerais de revalidação #

  1. Todo refetchInterval é condicional ao estado e desligado assim que o estado alvo é alcançado. Nenhum polling perpétuo existe no produto.
  2. Todo polling para completamente após 10 minutos de tela aberta sem mudança, e a interface passa a exibir um botão "Atualizar". Isso protege contra abas esquecidas abertas por dias.
  3. refetchOnReconnect: true globalmente: voltar da falta de conexão revalida.
  4. Mutações invalidam explicitamente as chaves afetadas. Exemplos normativos:
    • Cancelar assinatura → invalida ['subscription','current'], ['me'], ['payments'].
    • Reenviar no WhatsApp → invalida ['me'] (para atualizar resendsUsedToday).
    • Pausar → invalida ['me'].
    • Trocar telefone → invalida tudo (queryClient.clear()), porque a sessão mudou.
  5. Atualização otimista é usada apenas em PUT /api/me/preferences e no interruptor de e-mail, com rollback em erro. Nunca em operações de dinheiro: cancelamento, upgrade e reativação sempre esperam a resposta do servidor.
  6. retry: 2 tentativas com backoff exponencial (1 s, 3 s) para GET; zero tentativas automáticas para POST/PATCH/PUT/DELETE, para não duplicar efeito. A idempotência por Idempotency-Key existe para o caso de o usuário clicar de novo, não para retry automático.
  7. gcTime de 10 minutos, para que voltar a uma tela recém-visitada seja instantâneo.

15. Painel Administrativo e Calendário Editorial #

15.1 Escopo, rotas e layout #

O painel administrativo é a ferramenta de trabalho da equipe editorial e de operação. Ele cobre cinco atividades: produzir conteúdo, agendar conteúdo, acompanhar envios, atender assinantes e configurar o sistema.

Ele vive no route group (admin) de apps/web, sob /admin, servido em app.palavradiaria.com.br. Exige sessão de administrador com TTL de 12 horas e TOTP obrigatório (Seção 8). Todas as rotas são noindex, nofollow e bloqueadas em robots.txt (Seção 9.11.2).

Rota Propósito Papel mínimo
/admin Painel inicial: contadores do dia, alertas e atalhos. EDITOR
/admin/devocionais Lista e busca de devocionais; criação e edição. EDITOR
/admin/devocionais/[id] Editor de devocional. EDITOR
/admin/devocionais/[id]/revisoes Histórico de revisões, comparação e restauração. EDITOR
/admin/calendario Calendário editorial mensal. EDITOR
/admin/assinantes Busca e gestão de assinantes. ADMIN
/admin/assinantes/[id] Ficha completa do assinante. ADMIN
/admin/envios Lote do dia, progresso, falhas, reprocessamento. EDITOR (leitura) / ADMIN (ações)
/admin/envios/[batchId] Detalhe de um lote. EDITOR (leitura) / ADMIN (ações)
/admin/templates Templates do WhatsApp e status na Meta. ADMIN
/admin/configuracoes Planos, preços, chaves de configuração, feature flags, administradores. ADMIN para abrir; abas de Planos e de Administradores exigem OWNER (15.11)
/admin/auditoria Trilha de auditoria com filtros e exportação. ADMIN (exportar exige OWNER)
/admin/metricas Visão geral das métricas do produto. ADMIN
/admin/metricas/crescimento Métricas de crescimento e funil. ADMIN
/admin/metricas/receita Métricas de receita e assinaturas. ADMIN
/admin/metricas/entrega Métricas de entrega e qualidade do canal. ADMIN
/admin/metricas/conteudo Métricas de conteúdo e engajamento editorial. EDITOR
/admin/metricas/custos Métricas de custo por mensagem, por áudio e por assinante. ADMIN

As telas de métricas são especificadas na Seção 21.8; esta tabela é o mapa completo de rotas do painel e nenhuma seção acrescenta rota fora dela. Os caminhos são em português porque toda a interface, inclusive o painel administrativo, é em português do Brasil (decisão Q20, Seção 1.22).

A matriz completa de permissões por papel (SUBSCRIBER, EDITOR, ADMIN, OWNER) é canônica na Seção 3.8. Esta seção usa os papéis; não os redefine. Onde a tabela acima distingue leitura de ação, a distinção vem da Seção 3.8 e vale também para os endpoints correspondentes em 15.12.

15.1.1 Layout #

  • Barra lateral fixa de 260 px, colapsável para 64 px (ícones), com o estado salvo em localStorage. Em < lg, vira Sheet.
  • Cabeçalho com: busca global (Cmd/Ctrl + K), seletor de ambiente com cor de fundo distinta em staging (faixa amarela com o texto "AMBIENTE DE TESTE"), sino de alertas e menu do usuário.
  • Densidade de informação alta: tabelas com linhas de 40 px, tipografia de 14 px. O painel administrativo é uma ferramenta de uso repetido, não uma superfície de marketing.
  • Atalhos de teclado globais: Cmd/Ctrl + K (busca), g d (devocionais), g c (calendário), g a (assinantes), g e (envios), ? (lista de atalhos).
  • A busca global procura, em paralelo: devocionais por título, assinantes por telefone, nome ou e-mail (apenas para ADMIN+), e lotes por data.

15.1.2 Painel inicial (/admin) #

Cartões, em ordem:

  1. Envio de hoje: status do lote (planejado, em andamento, concluído, falhou), contagem por status e barra de progresso. Link para /admin/envios.
  2. Conteúdo agendado: quantos dias de devocional já existem à frente. Cor negativa quando faltarem menos de 7 dias (15.5.4).
  3. Assinantes: total ativo, novos nos últimos 7 dias, FREE × PAID.
  4. Falhas recentes: últimas 10 falhas de envio com motivo, se houver.
  5. Alertas abertos: qualidade do número do WhatsApp, templates rejeitados, tier de mensagens próximo do limite, job travado. Fonte na Seção 23.

Os gráficos ficam nas telas de métricas /admin/metricas* (Seção 21.8); o painel inicial mostra apenas números e estados operacionais. Os contadores agregados vêm de GET /api/admin/metrics/overview (Seção 21.9), nunca de um total embutido em resposta paginada.

15.2 Editor de devocional #

Rota /admin/devocionais/[id]. Layout em duas colunas em >= xl: formulário à esquerda, pré-visualização do WhatsApp à direita, sticky. Em telas menores, a pré-visualização vira uma aba.

15.2.1 Campos #

Campo Rótulo Tipo Obrigatório para READY Limite Contador
title Título texto Sim 3–80 caracteres Sim, a partir de 60
bible_reference Referência bíblica texto Sim 3–60 caracteres Não
bible_version Versão select Sim
bible_text Texto bíblico textarea Sim 10–1200 caracteres Sim, a partir de 900
reflection_md Reflexão editor markdown Sim 200–3500 caracteres Sim, sempre
prayer Oração textarea Sim 20–800 caracteres Sim, a partir de 600
teaser Teaser texto de uma linha Sim 40–300 caracteres Sim, sempre
scheduled_for Data de envio date Sim
internal_notes Notas internas textarea Não 0–1000 caracteres Não

O select de bible_version lista apenas versões de domínio público ou de uso livre, a partir da lista fechada mantida em settings sob content.allowed_bible_versions, cujo valor padrão é ["ALMEIDA_1911","BIBLIA_LIVRE"]. O padrão do editor é ALMEIDA_1911, com BIBLIA_LIVRE como alternativa. A sigla ARC é proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio público. A atribuição impressa em toda entrega é Salmos 23:1-3 (Almeida 1911) ou Salmos 23:1-3 (Bíblia Livre), nunca uma sigla ambígua. Versões licenciadas não aparecem na lista, e o risco jurídico de usá-las está documentado na Seção 22.10.3. Escolher uma versão fora da lista não é possível pela interface, e o editor nunca cola versículo de fonte externa: ele seleciona a referência e o sistema busca o texto na base importada. O campo internal_notes nunca é enviado ao assinante nem exportado.

15.2.2 Contadores de caracteres e os limites reais do WhatsApp #

Os contadores não são decorativos: cada um corresponde a um limite técnico real da WhatsApp Cloud API. A interface explica o motivo em um tooltip acessível (aria-describedby, não apenas title).

Campo Limite exibido Limite técnico de origem O que acontece se estourar
teaser 40 a 300 Parâmetro de corpo de template; o corpo inteiro tem 1024 caracteres e precisa caber com o título. O mínimo de 40 é o mesmo do CHECK do banco (Seção 6.30), então editor e banco nunca discordam Bloqueia salvar como READY
title 80 Também vai como parâmetro {{1}} do template diário Bloqueia salvar como READY
Mensagem completa montada 4096 Limite de mensagem de texto free-form Bloqueia a transição para READY
reflection_md 3500 Margem para que a mensagem montada caiba em 4096 Aviso em 3500, bloqueio quando a montagem estourar 4096

O contador da mensagem montada é o mais importante e fica em destaque acima da pré-visualização. Ele roda a mesma função de montagem que o motor de envio usa (buildDevotionalMessage() em packages/core), então o número exibido é exatamente o número real. Formato: 3.412 / 4.096 caracteres. Passa a laranja em 3.700 e a vermelho em 4.096.

Contador do teaser com a validação de sanitização junto:

Teaser: 187 / 300   ·   mínimo 40   ·   sem quebras de linha   ·   sem espaços duplicados

Cada uma das quatro condições é um indicador com estado próprio (neutro, ok, erro), com texto e ícone, nunca só cor.

15.2.3 Pré-visualização fiel do WhatsApp #

A pré-visualização reproduz, com fidelidade visual, as três mensagens que o assinante recebe. Ela é a principal ferramenta de revisão editorial.

Aba 1 — Template (janela fechada). Renderiza a bolha do template devocional_diario_v1 com:

  • Header TEXT estático.
  • Corpo com {{1}} = título e {{2}} = teaser, já substituídos.
  • Footer estático.
  • Os dois botões de resposta rápida.
  • Um selo "Template — enviado quando a janela de 24 h está fechada".

Aba 2 — Mensagem completa (janela aberta). Renderiza a bolha de texto livre com o devocional montado, aplicando a formatação real do WhatsApp:

  • *negrito* renderizado em negrito, _itálico_ em itálico, ~riscado~ riscado, ```mono``` em monoespaçado.
  • Quebras de linha e parágrafos exatamente como serão enviados.
  • Contador de caracteres da bolha.
  • Aviso quando a mensagem passar de 4096.

Aba 3 — Áudio. Renderiza a bolha de mensagem de voz com a duração real do audio_assets quando existir, ou um estado "áudio ainda não gerado".

Regras de fidelidade obrigatórias:

  1. A conversão de markdown para a sintaxe do WhatsApp usa a mesma função do motor de envio (markdownToWhatsApp() em packages/core). A pré-visualização nunca reimplementa a conversão.
  2. Elementos de markdown que o WhatsApp não suporta (títulos #, links [x](y), tabelas, imagens) são convertidos de forma determinística e a pré-visualização mostra um aviso inline explicando o que aconteceu. Exemplo: ## Reflexão vira *Reflexão* e o aviso diz "Títulos viram negrito no WhatsApp."
  3. A pré-visualização respeita prefers-color-scheme, com os dois temas do WhatsApp, para que o editor confira contraste.
  4. Um botão "Enviar teste para meu WhatsApp" dispara o pacote completo para o número cadastrado do administrador logado. Limite: 10 testes por administrador por dia. Registrado em auditoria.

15.2.4 Geração assistida do teaser #

Regra determinística, sem IA. A geração de devocional por IA está fora de escopo (Seção 2), e o teaser é conteúdo enviado ao assinante — precisa ser previsível e reproduzível.

Algoritmo, executado no cliente e revalidado no servidor:

// packages/core/src/content/teaser.ts
const MIN_TEASER = 40;
const MAX_TEASER = 300;

/**
 * Devolve `null` quando não é possível produzir um teaser válido a partir da reflexão.
 * `null` obriga o editor a escrever o teaser à mão; nunca devolve texto abaixo do mínimo,
 * porque o banco e o schema de escrita rejeitam menos de 40 caracteres (15.2.5).
 */
export function generateTeaser(reflectionMd: string): string | null {
  // 1. Remover a marcação de markdown, preservando o texto.
  let text = stripMarkdown(reflectionMd);

  // 2. Sanitizar: remover quebras de linha, tabulações, zero-width e
  //    colapsar qualquer sequência de espaços em um único espaço.
  text = text
    .replace(/[\r\n\t…

]+/g, ' ')
    .replace(/[​-‍]/g, '')
    .replace(/ {2,}/g, ' ')
    .trim();

  // 3. Piso duro: reflexão curta demais não produz teaser. Quem escreve é o editor.
  if (text.length < MIN_TEASER) return null;

  if (text.length <= MAX_TEASER) return text;

  // 4. Cortar no último FIM DE FRASE antes do limite.
  const window = text.slice(0, MAX_TEASER);
  const lastSentence = Math.max(
    window.lastIndexOf('. '), window.lastIndexOf('! '),
    window.lastIndexOf('? '), window.lastIndexOf('… '),
  );
  if (lastSentence >= 120) return window.slice(0, lastSentence + 1).trim();

  // 5. Sem fim de frase utilizável: cortar na última PALAVRA e usar reticências.
  const hardLimit = MAX_TEASER - 1;                  // reserva o caractere do "…"
  const cut = text.slice(0, hardLimit);
  const lastSpace = cut.lastIndexOf(' ');
  const base = (lastSpace >= 120 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.\-–—]+$/, '');
  const result = `${base.trim()}…`;
  return result.length >= MIN_TEASER ? result : null;
}

Propriedades garantidas, verificadas por teste de propriedade (Seção 24):

  1. O resultado nunca passa de 300 caracteres.
  2. O resultado, quando não é null, nunca tem menos de 40 caracteres. Esse é o mesmo piso do teaserSchema (15.2.5) e do CHECK do banco (Seção 6.30): a função jamais produz um valor que a escrita rejeitaria em seguida.
  3. O resultado nunca contém \n, \r, \t nem dois espaços seguidos.
  4. O resultado nunca termina em pontuação solta (,, ;, :, -).
  5. A função é pura: a mesma reflexão sempre produz o mesmo teaser, ou sempre null.
  6. O corte prefere fim de frase; só usa reticências quando o fim de frase cairia antes de 120 caracteres, o que produziria um teaser curto demais para atrair.

Interface: botão "Gerar do texto" ao lado do campo teaser. Se o campo já tiver conteúdo, o clique abre uma confirmação ("Substituir o teaser atual?"). Quando a função devolve null — reflexão curta demais —, o botão não escreve nada e a interface explica: "A reflexão ainda é curta demais para gerar um teaser. Escreva o teaser à mão." O teaser gerado é sempre editável à mão: a geração é uma sugestão, não uma imposição.

Exemplo concreto:

Reflexão (início): "## Confiança\n\nO salmo 23 começa com uma declaração de posse.
Davi não diz que o Senhor é *um* pastor. Ele diz que é **o meu** pastor.
A diferença é enorme: uma é teologia, a outra é biografia.\n\nQuando..."

Teaser gerado: "Confiança O salmo 23 começa com uma declaração de posse. Davi não diz
que o Senhor é um pastor. Ele diz que é o meu pastor. A diferença é enorme: uma é
teologia, a outra é biografia."   (198 caracteres, corte em fim de frase)

15.2.5 Validação do teaser #

Validação no servidor, aplicada em toda escrita, não só na geração:

export const teaserSchema = z.string()
  .trim()
  .min(40,  'O teaser precisa de pelo menos 40 caracteres.')
  .max(300, 'O teaser não pode passar de 300 caracteres.')
  .refine((v) => !/[\r\n\t]/.test(v), 'O teaser não pode ter quebras de linha.')
  .refine((v) => !/ {2,}/.test(v),    'O teaser não pode ter espaços duplicados.')
  .refine((v) => !/[​-‍]/.test(v), 'O teaser contém caracteres invisíveis.')
  .refine((v) => !/ {4,}/.test(v), 'O teaser não pode ter 4 ou mais espaços seguidos.');

Na interface, o campo teaser é um <input> de linha única, não um <textarea>, o que impede fisicamente a digitação de Enter. Colar texto com quebras dispara uma normalização automática no onPaste, com um aviso discreto: "Quebras de linha foram removidas."

Por que tanto rigor: parâmetro de template do WhatsApp rejeita quebra de linha, tabulação e 4 ou mais espaços consecutivos. Um teaser inválido faz o envio do dia inteiro falhar com erro de template (faixa 132xxx), afetando todos os assinantes. A validação é barata; a falha é cara.

15.2.6 Salvamento #

  • Rascunho automático a cada 20 segundos quando houver alteração, sem sair de DRAFT. O indicador mostra "Salvo às 14:32".
  • Cmd/Ctrl + S salva imediatamente.
  • Cada salvamento cria uma revisão (15.6).
  • Bloqueio otimista: o cliente envia expectedVersion (o version inteiro do registro). Se o servidor tiver versão maior, devolve 409 STALE_WRITE com o nome de quem editou e quando. A interface oferece "Ver diferenças" e "Sobrescrever mesmo assim".
  • Aviso de edição concorrente: um indicador leve mostra "Ana está editando este devocional" quando outra sessão administrativa abriu o mesmo registro nos últimos 2 minutos. Implementado com uma chave Redis editing:{devotionalId}:{adminId} com TTL de 120 s, atualizada por batida a cada 45 s. Não é bloqueio; é informação.
  • Sair da página com alterações não salvas dispara beforeunload e um Dialog na navegação interna.

15.3 Máquina de estados editorial #

Enum DevotionalStatus: DRAFT, READY, AUDIO_PENDING, AUDIO_READY, PUBLISHED, SENT.

   ┌─────────┐  validação completa   ┌─────────┐
   │  DRAFT  │──────────────────────▶│  READY  │
   └─────────┘                       └────┬────┘
        ▲                                 │ enfileira tts.generate
        │ voltar para edição              │ (automático)
        │ (EDITOR, só antes de PUBLISHED) ▼
        │                          ┌───────────────┐   falha após 3 tentativas
        ├──────────────────────────│ AUDIO_PENDING │──────────────┐
        │                          └───────┬───────┘              │
        │                                  │ áudio gerado e       ▼
        │                                  │ transcodificado   (permanece AUDIO_PENDING,
        │                                  ▼                    alerta operacional)
        │                          ┌───────────────┐
        │                          │  AUDIO_READY  │
        │                          └───────┬───────┘
        │                                  │ publicar (ADMIN)
        │                                  ▼
        │                          ┌───────────────┐
        └──────────────────────────│   PUBLISHED   │
          despublicar (ADMIN,      └───────┬───────┘
          só antes de SENT)                │ motor de envio conclui o lote
                                           ▼
                                   ┌───────────────┐
                                   │     SENT      │  (terminal)
                                   └───────────────┘

15.3.1 Tabela de transições #

De Para Quem pode Guardas Efeitos
DRAFT EDITOR scheduled_for livre naquela data Cria devotionals e a revisão 1.
DRAFT READY EDITOR Todos os campos obrigatórios preenchidos e válidos; teaser válido (15.2.5); mensagem montada ≤ 4096; scheduled_for não está no passado Enfileira tts.generate; transição automática para AUDIO_PENDING no mesmo commit.
READY AUDIO_PENDING Sistema Job enfileirado.
AUDIO_PENDING AUDIO_READY Sistema audio_assets com OGG e MP3 prontos e dentro do limite de tamanho (Seção 16) Notifica o canal editorial (Seção 23).
AUDIO_PENDING AUDIO_PENDING Sistema Falha do provedor Retentativa; após 3 falhas, alerta e audio_assets.status = 'FAILED'.
AUDIO_READY PUBLISHED ADMIN Guarda dura: ver 15.3.2 published_at; revalida / e o cache público (Seção 9.12.2).
READY | AUDIO_PENDING PUBLISHED ADMIN Só se não houver assinantes PAID ativos (15.3.2) Idem.
PUBLISHED SENT Sistema Lote do dia concluído sent_at; send_batches fechado.
DRAFT | READY | AUDIO_PENDING | AUDIO_READY DRAFT EDITOR Não pode estar PUBLISHED nem SENT Cancela jobs de TTS pendentes.
PUBLISHED AUDIO_READY ADMIN sent_at IS NULL e o lote do dia ainda não começou Despublica; revalida cache; auditoria obrigatória com motivo.
SENT qualquer Proibido. Terminal.
qualquer (excluído) EDITOR quando status = 'DRAFT'; OWNER nos demais casos Qualquer estado diferente de DRAFT exige despublicar antes Soft delete (deleted_at). O papel é resolvido pelo estado do recurso, não pela rota (Seção 3.8).

Toda transição é registrada em admin_audit_log com ator, estado de origem, estado de destino e o motivo quando exigido.

15.3.2 A guarda de publicação #

Regra: não é possível publicar sem áudio pronto quando existirem assinantes pagos.

// packages/core/src/editorial/publish-guard.ts
export function canPublish(input: {
  status: DevotionalStatus;
  audioState: 'READY' | 'PENDING' | 'FAILED' | 'NONE';
  activePaidSubscribers: number;
  actorRole: AdminRole;
  overrideReason?: string;
}): { allowed: boolean; code?: string; message?: string } {
  if (!['READY', 'AUDIO_PENDING', 'AUDIO_READY'].includes(input.status)) {
    return { allowed: false, code: 'ILLEGAL_STATE_TRANSITION',
             message: 'Este devocional não pode ser publicado a partir do estado atual.' };
  }
  if (input.audioState === 'READY') return { allowed: true };
  if (input.activePaidSubscribers === 0) return { allowed: true };

  // Existem assinantes pagos e o áudio não está pronto.
  if (input.actorRole === 'OWNER' && input.overrideReason && input.overrideReason.length >= 20) {
    return { allowed: true };                    // exceção registrada, ver abaixo
  }
  return {
    allowed: false, code: 'AUDIO_REQUIRED',
    message: 'Há assinantes do plano completo. Publique somente depois que o áudio estiver pronto.',
  };
}

Detalhes obrigatórios:

  • activePaidSubscribers é contado no momento da tentativa, com tier = 'PAID' AND opt_out_at IS NULL AND deleted_at IS NULL.
  • A única exceção é o papel OWNER, e ela exige um motivo escrito de no mínimo 20 caracteres, que vai para admin_audit_log e para o alerta operacional. Isso existe para o cenário real de o provedor de TTS estar fora do ar e ser preferível entregar só o texto a não entregar nada.
  • Quando a exceção é usada, os assinantes PAID recebem o texto e uma linha adicional na mensagem de fechamento: "O áudio de hoje não ficou pronto a tempo. Pedimos desculpas." Esse texto é do catálogo da Seção 19.
  • A interface mostra a guarda como um bloqueio explicativo no botão "Publicar", não como erro após o clique: o botão fica desabilitado com o texto do motivo ao lado.

15.3.3 Publicação agendada #

O botão "Publicar" tem uma segunda opção no menu: "Publicar automaticamente quando o áudio ficar pronto". Ao marcar, devotionals.auto_publish = true e a transição AUDIO_READY → PUBLISHED passa a ser executada pelo sistema, sem intervenção. A guarda de 15.3.2 continua valendo — só que ela é satisfeita por construção, já que o gatilho é o áudio ficar pronto. É o modo recomendado e o padrão para conteúdo criado pelo calendário.

15.4 Lista de devocionais (/admin/devocionais) #

  • Tabela com colunas: data agendada, título, status (badge com ícone e texto), áudio (ícone com estado), autor da última revisão, atualizado em.
  • Filtros: status (multiseleção), intervalo de datas, autor, "com áudio pendente", "sem agendamento".
  • Busca por título e referência bíblica, mesma implementação do acervo (Seção 14.4.3).
  • Ordenação por data agendada (padrão, decrescente) ou por atualização.
  • Paginação por cursor (Seção 7).
  • Ações em massa, com confirmação e auditoria: mudar status para READY, atribuir data, excluir rascunhos. Limite de 50 itens por operação em massa.
  • Botão "Novo devocional" abre o editor com scheduled_for pré-preenchido com a primeira data livre no futuro.

15.5 Calendário editorial (/admin/calendario) #

15.5.1 Visão mensal #

  • Grade de 7 colunas (segunda a domingo) × 5 ou 6 linhas, cobrindo o mês inteiro com os dias adjacentes em tom apagado.
  • Cada célula mostra: número do dia, um cartão compacto do devocional (título truncado em duas linhas), um ponto colorido do status e um ícone de áudio.
  • Domingos recebem uma marca discreta, porque é o dia de envio do tier FREE. O dia da semana vem da chave de configuração send.free_tier_weekday (Seção 26.8.1), e não de literal no código. Um rótulo "FREE + PAID" aparece no cabeçalho da coluna do dia configurado; os demais dias mostram "PAID".
  • Navegação: mês anterior/próximo, "Hoje", e um seletor de mês/ano. O mês corrente é o padrão. A URL carrega o mês (?mes=2026-08), tornando o estado compartilhável.
  • Legenda fixa com as seis cores de status e seus nomes, sempre com texto (nunca só cor).

15.5.2 Dias vazios #

  • Célula sem devocional exibe um contorno tracejado e um botão "+" que abre o editor com a data pré-preenchida.
  • Dias vazios no futuro dentro dos próximos 14 dias recebem destaque negativo (fundo levemente avermelhado e ícone de atenção).
  • Dias vazios no passado recebem apenas o contorno tracejado, sem alarme: não há o que fazer sobre eles.
  • Um contador no cabeçalho mostra "3 dias sem conteúdo nos próximos 14".

15.5.3 Arrastar para reagendar #

  • Arrastar o cartão de um dia para outro reagenda o devocional (scheduled_for).
  • Implementado com a HTML Drag and Drop API sobre um wrapper acessível.
  • Alternativa obrigatória sem arrasto (WCAG 2.5.7, ver Seção 9.10.1): cada cartão tem um menu de contexto (botão de três pontos, focável) com "Mover para outra data...", que abre um seletor de data. Além disso, com o cartão focado, Espaço entra em modo de movimentação, as setas navegam entre células, Espaço confirma e Esc cancela — com anúncio em aria-live a cada passo ("Movendo para 12 de setembro").
  • Guardas do reagendamento:
Situação Comportamento
Destino já ocupado Rejeita com 409 DATE_ALREADY_TAKEN e a mensagem "Já existe um devocional em 12/09. Troque as datas ou escolha outro dia." Oferece o botão "Trocar as duas datas".
Devocional em SENT Não é arrastável. Cursor not-allowed, aria-disabled, e o menu de contexto explica: "Devocionais já enviados não podem ser reagendados."
Devocional em PUBLISHED com lote do dia iniciado Bloqueado com a mesma mensagem.
Destino no passado Permitido apenas para DRAFT, com confirmação: "Essa data já passou. O devocional não será enviado."
Origem e destino iguais No-op silencioso.
  • Atualização otimista com rollback: o cartão move na hora; se a API falhar, ele volta e um toast de erro aparece. Toda movimentação gera registro em admin_audit_log.

15.5.4 Alerta de cobertura #

Regra: alerta quando faltarem menos de 7 dias de conteúdo agendado à frente.

Definição precisa de "dias de conteúdo à frente": a maior sequência contígua de dias, a partir de amanhã, em que existe um devocional com status diferente de DRAFT. Um rascunho não conta como cobertura, porque não pode ser enviado.

export function computeCoverageDays(input: {
  today: Date;                               // em America/Sao_Paulo
  scheduled: ReadonlyMap<string, DevotionalStatus>;  // "YYYY-MM-DD" → status
}): number {
  let days = 0;
  for (let i = 1; i <= 60; i++) {
    const key = formatISODate(addDays(input.today, i));
    const status = input.scheduled.get(key);
    if (!status || status === 'DRAFT') break;
    days++;
  }
  return days;
}

Onde o alerta aparece:

Local Forma
/admin/calendario Faixa fixa no topo: "Restam 4 dias de conteúdo agendado. Programe até 29/08."
/admin Cartão "Conteúdo agendado" em cor negativa.
E-mail diário às 09:00 Enviado a todos os EDITOR e ADMIN quando a cobertura for < 7.
Alerta operacional Severidade warning em < 7 dias, critical em < 3 dias (Seção 23).

Exemplo concreto: hoje é 25/08. Existem devocionais PUBLISHED em 26, 27, 28 e 29/08, um DRAFT em 30/08 e um READY em 31/08. A cobertura é 4, não 6, porque a sequência contígua quebra no rascunho de 30/08. O alerta dispara.

15.5.5 Duplicar devocional #

  • Ação no menu de contexto de qualquer cartão: "Duplicar".
  • Abre um Dialog pedindo a nova data (padrão: primeira data livre no futuro).
  • O que é copiado: title (com o sufixo " (cópia)"), bible_reference, bible_version, bible_text, reflection_md, prayer, teaser, internal_notes.
  • O que não é copiado: status (a cópia nasce DRAFT), audio_assets, published_at, sent_at, revisões, whatsapp_media_id.
  • Justificativa de não copiar o áudio: o áudio é derivado do texto e o texto quase sempre muda na cópia. Copiar áudio criaria um par texto/áudio divergente, que é a pior falha possível para o assinante.
  • Se a data escolhida estiver ocupada: 409 DATE_ALREADY_TAKEN.

15.5.6 Importação em lote via CSV #

Ferramenta para carregar um mês inteiro de conteúdo de uma vez.

Formato aceito. UTF-8, com ou sem BOM. Separador , ou ; (detectado automaticamente pela primeira linha). Aspas duplas para campos com separador ou quebra de linha. Primeira linha obrigatoriamente o cabeçalho, com estes nomes exatos:

scheduled_for,title,bible_reference,bible_version,bible_text,reflection_md,prayer,teaser
2026-09-01,"O Senhor é o meu pastor","Salmos 23:1-3",ALMEIDA_1911,"O SENHOR é o meu pastor...","## Confiança\n\nO salmo 23 começa...","Senhor, ensina-me...","Quando o pastor guia, a ovelha não precisa saber o caminho inteiro."
  • teaser é opcional: se vier vazio, é gerado por generateTeaser() (15.2.4). Se a geração devolver null, a linha vira erro TEASER_TOO_SHORT e não é importada.
  • bible_version é opcional; o padrão é ALMEIDA_1911. Valores aceitos são apenas os de content.allowed_bible_versions.
  • Quebras de linha dentro de reflection_md podem vir como \n literal ou como quebra real dentro de aspas. As duas formas são aceitas e normalizadas.
  • Limites: máximo de 200 linhas e 2 MB por arquivo.

Fluxo da interface, em três passos:

[1] Enviar arquivo
    Área de arrastar-e-soltar + botão "Escolher arquivo".
    Link "Baixar modelo CSV" com um arquivo de exemplo de 3 linhas.
        │
        ▼
[2] Pré-visualização e validação linha a linha
    Tabela com TODAS as linhas do arquivo, uma por linha, com uma coluna
    "Situação" à esquerda:
      ok        — será importada
      aviso     — será importada com ajuste (ex.: teaser gerado)
      erro      — NÃO será importada
    Cada erro e aviso aparece na própria linha, com o nome do campo.
    Cabeçalho: "182 de 200 linhas prontas · 12 avisos · 18 erros".
    Botões: "Importar as 182 linhas válidas" e "Cancelar".
    Interruptor: "Interromper se houver qualquer erro" (padrão: desligado).
        │
        ▼
[3] Relatório
    "182 devocionais criados. 18 linhas ignoradas."
    Botão "Baixar relatório de erros (CSV)" com as linhas rejeitadas,
    o número da linha original e o motivo, para correção e reenvio.

Catálogo de validações por linha:

Código Gravidade Condição Mensagem no relatório
MISSING_COLUMN erro (arquivo inteiro) Falta uma coluna obrigatória no cabeçalho "Coluna obrigatória ausente: title."
INVALID_DATE erro scheduled_for não é YYYY-MM-DD válida "Data inválida: 2026-13-45."
DATE_IN_PAST erro Data anterior a hoje "A data 2026-08-01 já passou."
DATE_TAKEN_DB erro Já existe devocional não excluído naquela data "Já existe devocional em 2026-09-01."
DATE_DUPLICATE_FILE erro Duas linhas do mesmo arquivo com a mesma data "Data repetida na linha 14 e na linha 37."
TITLE_LENGTH erro Fora de 3–80 "Título com 92 caracteres (máximo 80)."
REFERENCE_LENGTH erro Fora de 3–60 "Referência bíblica muito longa."
BIBLE_TEXT_LENGTH erro Fora de 10–1200 "Texto bíblico com 1340 caracteres (máximo 1200)."
REFLECTION_LENGTH erro Fora de 200–3500 "Reflexão com 120 caracteres (mínimo 200)."
PRAYER_LENGTH erro Fora de 20–800 "Oração muito curta."
MESSAGE_TOO_LONG erro Mensagem montada > 4096 "A mensagem completa ficaria com 4210 caracteres (máximo 4096)."
TEASER_INVALID erro Teaser informado falha em teaserSchema "O teaser tem quebra de linha."
TEASER_TOO_SHORT erro Teaser informado com menos de 40 caracteres, ou teaser vazio e reflexão curta demais para gerar um válido "O teaser precisa de pelo menos 40 caracteres. Escreva-o na planilha."
UNKNOWN_BIBLE_VERSION erro Versão fora de content.allowed_bible_versions "Versão bíblica não permitida. Use ALMEIDA_1911 ou BIBLIA_LIVRE." A mensagem nunca repete de volta o código recusado, para que uma sigla proibida não circule em relatório de erro (15.2.1)
TEASER_GENERATED aviso teaser vazio "Teaser gerado a partir da reflexão."
TEASER_TRUNCATED aviso Teaser informado com mais de 300 e truncável sem perda de sentido "Teaser truncado de 340 para 298 caracteres."
MARKDOWN_UNSUPPORTED aviso Reflexão contém link, imagem ou tabela "Elementos não suportados pelo WhatsApp foram convertidos."
EXTRA_COLUMN aviso Coluna desconhecida no cabeçalho "Coluna ignorada: autor."

Semântica da importação:

  • Todas as linhas válidas são criadas em uma única transação. Se o banco falhar no meio, nada é criado. Não existe importação parcial por falha técnica — apenas por rejeição de validação, que é decidida antes.
  • Todos os registros nascem em DRAFT. A importação nunca publica nem enfileira TTS.
  • Idempotency-Key obrigatório: reenviar o mesmo arquivo com a mesma chave em até 1 hora devolve o relatório original sem criar nada.
  • A importação inteira gera um registro em admin_audit_log com o nome do arquivo, o hash SHA-256 do conteúdo, a contagem de criados e a de rejeitados.
  • Limite: 5 importações por administrador por dia.

15.6 Versionamento e revisões #

15.6.1 Como as revisões são criadas #

Cada salvamento — manual, automático ou por importação — cria uma linha em devotional_revisions (Seção 6) com o snapshot completo dos campos de conteúdo, o autor, o timestamp e o número sequencial da revisão.

Decisões:

  • Snapshot completo, não diff. O conteúdo é pequeno (poucos KB) e snapshot elimina toda uma classe de bugs de reconstrução. A restauração vira uma cópia, não um replay.
  • O salvamento automático não cria revisão nova se nada mudou. A comparação é feita por hash SHA-256 dos campos de conteúdo concatenados; hash igual, nenhuma revisão.
  • Salvamentos automáticos consecutivos do mesmo autor dentro de 5 minutos são coalescidos: a revisão mais recente é atualizada no lugar de criar outra. Isso evita 120 revisões por hora de digitação. Salvamento manual (Cmd/Ctrl + S) nunca é coalescido.
  • Retenção: as 50 revisões mais recentes por devocional, mais a primeira e a última antes de cada PUBLISHED, que são preservadas para sempre. Um job de manutenção poda o excedente.

15.6.2 Tela de revisões #

/admin/devocionais/[id]/revisoes:

  • Lista à esquerda: número da revisão, autor, data e hora, e um selo quando aquela revisão corresponde a uma publicação.
  • Painel à direita: comparação. Dois seletores (De e Até), padrão comparando a revisão anterior com a atual.
  • Comparação em duas visões, alternáveis:
    • Lado a lado: duas colunas, com destaque de linhas alteradas.
    • Inline: uma coluna, com inserções em verde e remoções em vermelho riscado. Cada marcação tem texto acessível (<ins> e <del> reais, com aria-label).
  • Diff por palavra dentro de linhas alteradas, não só por linha, porque as edições editoriais tendem a ser de poucas palavras.
  • Campos comparados: title, bible_reference, bible_version, bible_text, reflection_md, prayer, teaser. internal_notes também é versionado e comparável.
  • Um resumo no topo: "3 campos alterados · +148 −92 caracteres".

15.6.3 Restauração #

  • Botão "Restaurar esta revisão" em cada item da lista.
  • Dialog de confirmação mostrando o diff entre a revisão escolhida e o conteúdo atual.
  • Efeito: os campos de conteúdo do devocional voltam ao valor da revisão, e uma nova revisão é criada representando a restauração (com restored_from_revision preenchido). A revisão original permanece intacta. Nada é apagado.
  • Guarda: restaurar não é permitido quando status = 'SENT'. Quando status = 'PUBLISHED', é permitido apenas com despublicação antes, e a interface encadeia as duas ações em um único fluxo com confirmação única.
  • Se a restauração alterar reflection_md, title ou bible_text em um devocional que já tem áudio gerado, a interface avisa: "O áudio atual foi gerado a partir do texto anterior. Gere o áudio novamente." e oferece o botão para reenfileirar tts.generate (Seção 16). O status volta para READY.
  • Toda restauração é auditada com o número da revisão de origem.

15.7 Gestão de assinantes (/admin/assinantes) #

Papel mínimo: ADMIN. Todo acesso à ficha de um assinante é registrado em admin_audit_log com action = 'SUBSCRIBER_VIEWED' — leitura de dado pessoal é evento auditável.

15.7.1 Busca e lista #

  • Campo de busca único que aceita telefone (em qualquer formato), nome ou e-mail. O tipo é detectado: se contiver 8 ou mais dígitos, é tratado como telefone e normalizado por normalizeBrazilPhone() (Seção 11.4) antes da consulta, com a mesma cascata de variantes com e sem o nono dígito. Isso faz a busca funcionar mesmo quando o operador digita o número do jeito que o assinante mandou.
  • A consulta nunca compara a coluna cifrada. Telefone, wa_id, CPF e e-mail vivem cifrados; a busca por igualdade é feita pelas colunas de índice cego correspondentes — phone_hmac, wa_id_hmac, cpf_hmac, email_hmac (Seções 6.3 e 6.4). A busca por nome usa display_name.
  • Filtros: tier, status, com opt-out, pausados, com assinatura vencida, cadastrados em um intervalo, sem opt-in confirmado.
  • Colunas: nome, telefone mascarado, tier, status, cadastro, último envio, último contato.
  • Mascaramento por padrão: telefone e e-mail aparecem parcialmente ocultos ((11) 9****-5678). Um botão "Revelar" mostra o valor completo por 30 segundos e gera registro em auditoria com action = 'PII_REVEALED'. Reduz exposição em tela compartilhada e cria trilha de quem viu o quê.
  • Exportação da lista em CSV: apenas OWNER, com limite de 5.000 linhas, sempre auditada, e com um aviso na interface sobre a responsabilidade do arquivo gerado.

15.7.2 Ficha do assinante #

/admin/assinantes/[id], organizada em abas:

Aba "Resumo" Identificação, tier, status, datas (cadastro, verificação, opt-in, opt-out, pausa), origem (source do cadastro), entitlements resolvidos, saldo de reenvios do dia, estado da janela de atendimento de 24 h com o horário de expiração.

Aba "Mensagens" Linha do tempo unificada de message_logs e inbound_messages, ordem decrescente, paginada por cursor. Cada item mostra: direção, tipo (template, texto, áudio, entrada), o devocional relacionado quando houver, status (sent/delivered/read/failed) com os respectivos timestamps, e, em falha, o código de erro da Meta com a descrição em português (catálogo na Seção 27). O conteúdo de mensagens de entrada é exibido; o de saída é exibido por referência ao devocional, não duplicado.

Aba "Pagamentos" Assinaturas e pagamentos, com os mesmos dados da Seção 14.5.2 mais os campos internos: asaas_subscription_id, asaas_payment_id, externalReference, e o histórico de payment_events recebidos, com o payload bruto disponível em um Dialog (útil para depurar divergência de webhook).

Aba "Consentimentos" Todos os consent_events, com texto integral, canal, IP e user-agent. Somente leitura.

Aba "Auditoria" Todas as ações administrativas executadas sobre este assinante.

15.7.3 Ações administrativas #

Todas exigem: confirmação em Dialog, motivo escrito obrigatório (mínimo 10 caracteres) e geram registro em admin_audit_log com ator, alvo, ação, motivo, valores antes e depois, IP e user-agent. Sem exceção.

Ação Papel O que faz Guardas Reversível
Reenviar devocional ADMIN Enfileira o envio de um devocional escolhido para este assinante. Não consome o saldo diário do assinante; usa reason = 'ADMIN_RESEND'. Máximo de 5 por assinante por dia. Respeita opt-out (bloqueado) e pausa (avisa e pede confirmação extra). Não
Forçar re-sincronização com a Asaas ADMIN Busca a assinatura e os pagamentos na Asaas e reconcilia o estado local. A Asaas é a fonte de verdade (Seção 12). Se houver divergência, o estado local é corrigido e as diferenças aparecem no resultado. Limite: 10 por hora por administrador. Não se aplica
Conceder acesso pago por N dias (cortesia) ADMIN Define tier = 'PAID' com courtesy_until = now() + N dias (coluna da Seção 6.3), sem criar assinatura na Asaas. N entre 1 e 365. Motivo obrigatório e Idempotency-Key obrigatório. Não cria cobrança. Ao expirar, o job billing.lifecycle das 00:05 rebaixa para FREE, exceto se houver assinatura paga ativa. Sim: revogar cortesia
Revogar cortesia ADMIN Zera courtesy_until e recalcula o tier. Sim
Aplicar opt-out ADMIN Mesmo efeito de 14.7.3, com opt_out_reason = 'ADMIN'. Usado quando o assinante pede por telefone ou e-mail. Suspende a cobrança do ciclo seguinte, como o opt-out do próprio assinante (Seção 13.4.6); a interface avisa e oferece encerrar a assinatura junto. Sim: reativar
Reativar recebimento ADMIN Desfaz o opt-out. Exige registro do consentimento obtido por outro canal, com o campo "como o consentimento foi obtido" preenchido. Sim
Cancelar assinatura ADMIN Executa o mesmo fluxo de 14.10.9. Motivo obrigatório. Sim, dentro do período pago
Excluir por solicitação LGPD OWNER Mesmo efeito de 14.8.2. Confirmação por digitação do telefone completo do assinante. Registra o canal da solicitação e a data. Só nas primeiras 72 h
Reverter exclusão OWNER Restaura deleted_at = NULL e o status anterior. Só dentro de 72 h e só se a pseudonimização ainda não rodou. Não
Trocar telefone OWNER Altera phone_e164 e recalcula phone_hmac, sem duplo OTP. Uso excepcional (assinante perdeu o número). Exige o motivo e a evidência registrada. Revoga todas as sessões e zera wa_id/wa_id_hmac. Limite de 3 por dia no sistema inteiro, com alerta. Não
Impersonar assinante ADMIN Abre uma sessão de suporte somente leitura dentro da conta, para reproduzir o que o assinante vê. Motivo obrigatório. Duração máxima de 30 minutos, com alerta acima disso. Registra ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED. Encerrar a sessão

Três regras transversais:

  1. Nenhuma ação administrativa pode criar consentimento do nada. Reativar recebimento exige declarar como o consentimento foi obtido, e esse texto vai para consent_events com channel = 'ADMIN'. Isso mantém a trilha honesta.
  2. Nenhuma ação administrativa lê ou escreve dados de cartão. O painel nunca exibe número de cartão, token ou CVV; apenas bandeira e últimos quatro dígitos vindos da Asaas.
  3. Impersonação é somente leitura, sem exceção. A sessão de impersonação carrega scope = 'IMPERSONATION_READONLY' e o invólucro de API recusa qualquer método não seguro nela, antes de chegar ao handler. A trava vale para todas as rotas que alteram estado do assinante, independentemente do prefixo do caminho — cancelamento de assinatura, reativação, troca de plano, troca de forma de pagamento, reenvio, pausa, opt-out, pedido de exportação e pedido de eliminação (Seção 14.9). Quando o suporte precisa alterar algo, ele usa as ações desta tabela, em seu próprio nome, com motivo registrado — nunca dentro da conta do assinante. O titular vê essas sessões na própria tela "Meus dados", em uma lista separada de acessos do suporte, com data, duração e justificativa.

15.8 Tela de envios (/admin/envios) #

Papel mínimo: EDITOR para leitura. As ações desta tela — reprocessar falhas e cancelar lote — exigem ADMIN e ficam desabilitadas com explicação para quem tem apenas EDITOR (Seção 3.8).

15.8.1 Lote do dia #

Cabeçalho com o lote corrente (send_batches do dia, Seção 6):

Campo Exemplo
Data 25/08/2026
Devocional "O Senhor é o meu pastor" (link para o editor)
Estado do lote PLANNED, RUNNING, COMPLETED, COMPLETED_WITH_ERRORS, CANCELED, FAILED
Planejados 3.041
Iniciado em 06:00:04
Concluído em 06:07:12
Duração 7 min 8 s

Barra de progresso com segmentos por status e uma tabela de contagem:

Status Contagem %
Enfileirados 3.041 100%
Enviados (sent) 3.041 100%
Entregues (delivered) 2.978 97,9%
Lidos (read) 1.412 46,4%
Falharam (failed) 63 2,1%
Adiados (131049) 0 0%

A contagem de "Adiados" é separada de "Falharam" por decisão canônica: o erro 131049 (limitação de entrega por saúde do ecossistema) não conta como falha de entrega; o assinante entra na lista do dia seguinte (Seção 17).

15.8.2 Progresso ao vivo #

  • Sem WebSocket, coerente com a decisão da Seção 14.11.
  • Enquanto o lote está em RUNNING, a consulta revalida a cada 5 segundos. Fora disso, não há polling.
  • Um indicador de "atualizado há N segundos" fica visível, com botão "Atualizar agora".
  • O polling para automaticamente quando o lote sai de RUNNING ou após 30 minutos de tela aberta.

15.8.3 Falhas #

Tabela de falhas, paginada por cursor:

Coluna Conteúdo
Assinante Nome e telefone mascarado, link para a ficha
Tipo Template, texto livre ou áudio
Código Código de erro da Meta (131026, 132015, 130429, ...)
Motivo Descrição em português, do catálogo da Seção 27
Ação sugerida Texto curto vindo do mesmo catálogo
Tentativas 2 de 3
Horário 06:03:41

Agrupamento: um interruptor "Agrupar por código" resume as falhas por código de erro, com contagem, o que torna óbvio quando 60 das 63 falhas têm a mesma causa.

Taxa de falha ao vivo. Enquanto o lote está em RUNNING, o cabeçalho da tabela mostra a taxa de falha corrente e o error_code dominante, atualizados no mesmo ciclo de 5 segundos de 15.8.2. Esses dois números são os que o plantonista precisa às 06:03, e são os mesmos que alimentam os alertas send.batch_failure_rate e send.batch_error_dominant e a contenção automática em 20% (Seção 18.10 e 18.12). Quando o motor pausa o lote sozinho, o estado exibido é HALTED_BY_GUARD, com o código dominante ao lado e o botão de retomada disponível apenas para ADMIN.

Assinantes com falha crônica. Uma aba "Entrega crônica" lista os assinantes que falharam em 3 dias consecutivos, com o código dominante de cada um. Falha concentrada nas mesmas pessoas é invisível em uma taxa agregada e é a que gera cancelamento; a lista é revisada semanalmente.

15.8.4 Reprocessar falhas #

  • Botão "Reprocessar falhas" no cabeçalho da tabela, com seleção: todas, ou apenas as de um código específico, ou linhas marcadas individualmente.
  • Dialog de confirmação mostrando quantos assinantes serão afetados e uma estimativa de duração com base no limite de taxa configurado.
  • Guardas:
Guarda Comportamento
Códigos não reprocessáveis 131026 (não entregável) e 131050 (usuário optou por não receber) não são reprocessados; ficam desabilitados na seleção com a explicação. Reenviar para quem bloqueou piora a qualidade do número.
Idempotência O reprocessamento respeita a chave única send:{subscriberId}:{devotionalDate} de delivery_attempts (Seção 18). Assinante que já recebeu com sucesso nunca recebe de novo, mesmo que apareça na seleção.
Janela de tempo Reprocessar é permitido até 48 horas após a data do lote. Depois disso, o botão fica desabilitado com a explicação de que o conteúdo do dia perdeu a validade.
Limite de tamanho Máximo de 2.000 destinatários por reprocessamento. Acima disso, a interface pede para reprocessar por código.
  • O reprocessamento cria um novo lote com parent_batch_id apontando para o original e batch_kind = 'RETRY', o que mantém as métricas do lote original intactas.
  • Auditado com o número de destinatários e o filtro usado.

15.8.5 Cancelar lote em andamento #

  • Botão destrutivo, visível apenas quando o lote está em RUNNING.
  • Dialog que declara com números o que já aconteceu e o que não vai acontecer: "1.204 mensagens já foram enviadas e não podem ser recolhidas. 1.837 ainda não saíram e serão canceladas."
  • Efeito: os jobs pendentes daquele lote são removidos da fila BullMQ, send_batches.status = 'CANCELED' e canceled_by/canceled_reason são gravados.
  • Mensagens já enviadas não são afetadas: a WhatsApp Cloud API não permite recolher mensagem entregue. A interface é explícita sobre isso, para não criar expectativa falsa.
  • Motivo obrigatório, mínimo de 10 caracteres. Auditado com severidade alta e alerta imediato para os demais administradores (Seção 23).
  • Após o cancelamento, a tela oferece "Reenviar para os cancelados", que cria um lote RETRY apenas com quem não recebeu.

15.8.6 Histórico de lotes #

Lista dos últimos lotes, paginada por cursor, com data, devocional, estado, planejados, entregues, falhas e duração. Filtros por estado e intervalo de datas. Cada linha leva para /admin/envios/[batchId], que mostra a mesma estrutura de 15.8.1 a 15.8.4 aplicada a um lote histórico, sem os botões de cancelar.

15.9 Templates do WhatsApp (/admin/templates) #

15.9.1 Lista #

Tabela com os oito templates locais (whatsapp_templates, Seção 6) e o estado deles na Meta. Os oito são os da Seção 17.5, semeados pela Seção 6.32.3 com components idênticos aos definidos lá: devocional_diario_v1, devocional_diario_video_v1, codigo_acesso_v1, boas_vindas_v1, lembrete_pagamento_v1, pagamento_confirmado_v1, acesso_encerrado_v1 e reativacao_v1.

Coluna Conteúdo
Nome devocional_diario_v1
Idioma pt_BR
Categoria UTILITY, MARKETING ou AUTHENTICATION
Categoria efetiva A categoria que a Meta aplicou de fato (pode diferir da submetida)
Status na Meta APPROVED, PENDING, REJECTED, PAUSED, DISABLED, IN_APPEAL
Qualidade GREEN, YELLOW, RED ou desconhecida
Última sincronização Data e hora
Uso Onde o sistema usa este template

Quando a categoria efetiva difere da submetida, a linha exibe um aviso: a reclassificação de UTILITY para MARKETING muda a faixa de custo por mensagem, e o dashboard de custos (Seção 21) reflete isso automaticamente.

15.9.2 Sincronizar #

  • Botão "Sincronizar com a Meta" busca todos os templates da conta (GET /{WABA_ID}/message_templates) e atualiza os registros locais: status, categoria efetiva, qualidade, motivo de rejeição e o corpo aprovado.
  • Templates que existem na Meta e não localmente aparecem como "não gerenciado", com a opção de importar.
  • Templates que existem localmente e não na Meta aparecem como "não submetido".
  • Sincronização automática: job diário às 05:00, antes do planejamento do envio das 05:40. Se um template usado pelo envio diário não estiver APPROVED, um alerta crítico é disparado (Seção 23) e o motor usa o fallback definido na Seção 18.
  • Limite manual: 20 sincronizações por hora, para não estourar o rate limit da Graph API.

15.9.3 Criar template #

Formulário que espelha o modelo da Cloud API:

Campo Regras
name snake_case, 1–512 caracteres, sem espaço. Validado no cliente.
language Fixo em pt_BR no MVP.
category UTILITY, MARKETING ou AUTHENTICATION.
Header NONE, TEXT (60 caracteres), IMAGE, DOCUMENT ou VIDEO. AUDIO não é oferecido, porque a API não aceita header de áudio — a interface explica isso em texto ao lado da opção.
Body Até 1024 caracteres, com parâmetros {{1}}, {{2}}, ... numerados em sequência sem lacuna.
Footer Opcional, até 60 caracteres, sem parâmetros.
Botões Até 3 QUICK_REPLY ou até 2 URL/PHONE_NUMBER. Combinações inválidas são bloqueadas.
Exemplos Obrigatórios para cada parâmetro; a Meta rejeita submissão sem eles.

Validações locais antes da submissão, que evitam rejeição:

  1. Parâmetros numerados em sequência a partir de {{1}}, sem pular número.
  2. Nenhum parâmetro no início nem no fim do corpo sem texto ao redor.
  3. Dois parâmetros nunca adjacentes ({{1}} {{2}} é rejeitado pela Meta).
  4. Corpo dentro de 1024 caracteres com os exemplos substituídos, não apenas com os marcadores.
  5. Nenhum exemplo com quebra de linha, tabulação ou 4 espaços consecutivos.

Pré-visualização idêntica à do editor de devocional (15.2.3), com os exemplos preenchidos.

Submissão: POST /{WABA_ID}/message_templates. O registro local nasce com status PENDING. A aprovação costuma levar de minutos a 24 horas; a tela informa isso.

15.9.4 Rejeição #

Quando o status é REJECTED, a tela mostra:

  • O motivo devolvido pela Meta (rejected_reason), traduzido para português quando for um dos motivos conhecidos: INVALID_FORMAT, ABUSIVE_CONTENT, INCORRECT_CATEGORY, SCAM, PROMOTIONAL, TAG_CONTENT_MISMATCH.
  • Uma explicação prática do que costuma causar aquele motivo e o que mudar.
  • Botão "Duplicar e corrigir", que cria uma cópia editável com sufixo de versão (devocional_diario_v2), porque a Meta não permite editar template rejeitado com o mesmo nome dentro de certos limites.
  • Botão "Excluir da Meta", que remove o template rejeitado da conta.

Nenhum dos oito templates canônicos da Seção 17.5 pode ser excluído pela interface enquanto estiver referenciado na configuração de envio. A tentativa devolve 409 TEMPLATE_IN_USE.

15.10 Auditoria (/admin/auditoria) #

15.10.1 O que é registrado #

admin_audit_log (Seção 6.9) recebe uma linha para cada ação sensível. A Seção 6.9 é a dona única do esquema desta tabela; os nomes abaixo são os dela e nenhuma grafia alternativa existe em lugar nenhum do documento. Campos: id, actor_type (ADMIN | SUBSCRIBER | SYSTEM), admin_user_id, actor_role (EDITOR | ADMIN | OWNER | SYSTEM), action, entity_type, entity_id, reason, before (JSONB), after (JSONB), changed_fields (text[]), metadata (JSONB), ip, user_agent, request_id, record_hash, created_at.

Três esclarecimentos sobre esses nomes, porque eles são a origem de confusão frequente:

  • Não existe coluna actor_email. O e-mail do autor é resolvido a partir de admin_user_id no momento da exportação e da leitura de tela (15.10.3), nunca persistido.
  • admin_user_id é nulo quando actor_type é SUBSCRIBER ou SYSTEM. É exatamente por isso que actor_type existe como coluna própria: admin_user_id nulo, sozinho, não distingue "foi o sistema" de "foi o próprio assinante" — e o painel do assinante grava auditoria com actor_type = 'SUBSCRIBER' na troca de número (14.6.2).
  • actor_role guarda o papel efetivo no momento da ação, gravado em vez de derivado, porque o papel do administrador muda depois e o registro precisa dizer o que ele podia fazer naquele dia. record_hash encadeia cada linha à anterior e é o que detecta adulteração (Seção 6.9).

Catálogo mínimo de ações auditadas:

Grupo Ações
Sessão ADMIN_LOGIN, ADMIN_LOGIN_FAILED, ADMIN_LOGOUT, ADMIN_TOTP_RESET
Conteúdo DEVOTIONAL_CREATED, DEVOTIONAL_UPDATED, DEVOTIONAL_STATUS_CHANGED, DEVOTIONAL_PUBLISHED, DEVOTIONAL_UNPUBLISHED, DEVOTIONAL_DELETED, DEVOTIONAL_RESCHEDULED, DEVOTIONAL_DUPLICATED, REVISION_RESTORED, CSV_IMPORTED, PUBLISH_GUARD_OVERRIDDEN
Assinante SUBSCRIBER_VIEWED, PII_REVEALED, SUBSCRIBER_RESEND, SUBSCRIBER_RESYNCED, MANUAL_ACCESS_GRANTED, MANUAL_ACCESS_REVOKED, SUBSCRIBER_OPTED_OUT, SUBSCRIBER_REACTIVATED, SUBSCRIBER_PHONE_CHANGED, SUBSCRIPTION_CANCELED, SUBSCRIBER_DELETED, SUBSCRIBER_DELETION_REVERTED, SUBSCRIBERS_EXPORTED, ADMIN_IMPERSONATION_STARTED, ADMIN_IMPERSONATION_ENDED, DATA_EXPORT_DOWNLOADED
Envio BATCH_RETRIED, BATCH_CANCELED, TEST_MESSAGE_SENT
Template TEMPLATE_CREATED, TEMPLATE_SYNCED, TEMPLATE_DELETED
Configuração SETTING_CHANGED, PLAN_UPDATED, FEATURE_FLAG_TOGGLED, ADMIN_CREATED, ADMIN_ROLE_CHANGED, ADMIN_DISABLED

Regras de conteúdo do log:

  1. before e after guardam apenas os campos alterados, não o registro inteiro.
  2. Campos sensíveis são mascarados antes de gravar: telefone vira +55119****5678, e-mail vira m****@exemplo.com.br, CPF nunca é gravado. A exceção é PII_REVEALED, que registra que houve revelação, não o valor revelado.
  3. admin_audit_log é append-only: não há UPDATE nem DELETE na aplicação, e o usuário de banco da aplicação não tem essas permissões nessa tabela. O gatilho que impõe isso reconhece dois papéis nomeados e auditados — um para a anonimização exigida por lei e outro para o expurgo por retenção —, verificando coluna a coluna que a operação é exatamente a permitida (Seção 22.7.3). Um controle de integridade não pode impedir o cumprimento de um direito do titular nem o expurgo obrigatório.
  4. Retenção: 5 anos, alinhada à retenção de consent_events e payment_events (Seção 22).
  5. O registro é monitorado, não apenas gravado. Acesso em massa a dado pessoal dispara alerta: mais de 100 fichas abertas ou mais de 20 revelações de dado pessoal em 1 hora pelo mesmo administrador é alerta crítico; abertura de ficha entre 00:00 e 05:00 é alerta de severidade média; impersonação acima de 30 minutos é alerta próprio. As expressões exatas estão no catálogo da Seção 23.8. Controle detectivo sem gatilho é controle inexistente.

15.10.2 Interface #

  • Tabela com: data e hora, ator, ação, alvo, motivo, IP.
  • Filtros combináveis: intervalo de datas, ator, tipo de ação (multiseleção agrupada pelas categorias acima), entity_type, entity_id, request_id, texto livre no motivo.
  • Clique na linha abre um painel lateral com o before/after renderizado como diff e o request_id copiável para correlacionar com os logs da aplicação (Seção 23).
  • Paginação por cursor. Ordenação sempre decrescente por created_at.
  • Estado vazio: "Nenhuma ação encontrada com esses filtros."

15.10.3 Exportação CSV #

  • Botão "Exportar CSV" aplica os filtros correntes.
  • Limite de 50.000 linhas por exportação; acima disso, a interface pede um intervalo de datas menor.
  • Processamento assíncrono quando passar de 5.000 linhas: o job é enfileirado na fila maintenance.cleanup (Seção 18 é a dona da lista de filas), a tela mostra o progresso e o download aparece quando pronto, com URL assinada de 15 minutos gerada no momento do clique. A URL nunca é enviada por e-mail nem registrada em log.
  • Colunas do CSV: created_at_utc, created_at_brt, actor_type, actor_email, action, entity_type, entity_id, reason, ip, request_id, changed_fields. Três dessas colunas são projeções de apresentação da exportação, e não colunas do banco: created_at_utc e created_at_brt são o mesmo created_attimestamptz em UTC, coluna única da Seção 6.9 — renderizado nas duas zonas, e actor_email é resolvido na exportação a partir de admin_user_id. As demais colunas do CSV têm o mesmo nome da coluna correspondente em admin_audit_log. Os objetos before/after completos não vão para o CSV — apenas a lista de campos alterados —, para que o arquivo não vire um vazamento de dados por conveniência.
  • A própria exportação é auditada com action = 'AUDIT_EXPORTED' e a contagem de linhas.
  • Papel exigido: ADMIN para visualizar, OWNER para exportar.

15.11 Configurações (/admin/configuracoes) #

Papel exigido para abrir a tela: ADMIN. A aba Planos e preços e a aba Administradores exigem OWNER e ficam desabilitadas para ADMIN, com a explicação visível. As abas Chaves de configuração e Feature flags exigem ADMIN, e cada chave respeita ainda o editable_by da própria linha (Seção 26.8.2). Quatro abas.

Planos e preços. Papel: OWNER. Edição de plans: nome exibido, amount_cents, ciclo, ativo/inativo. O plano gratuito não aparece aqui, porque não é uma linha em plans: ser gratuito é a ausência de assinatura vigente (Seção 13.1), e o item exibido na página de vendas é sintetizado pelo handler (Seção 9.16.2). Alterar o preço não altera assinaturas existentes: a Asaas mantém o valor contratado até que a assinatura seja alterada explicitamente. A interface declara isso em destaque acima do formulário. Salvar dispara revalidatePath('/') e revalidatePath('/planos') (Seção 9.12.2).

Chaves de configuração. Papel: ADMIN, respeitado o editable_by de cada linha. Edição das chaves tipadas de settings (Seção 6.23), todas no formato grupo.chave. Cada chave tem nome, descrição, tipo, valor atual, valor padrão e a data da última alteração. Chaves que afetam o envio — send.free_tier_weekday, send.daily_hour_local, send.rate_per_second e ops.kill_switch — exibem um aviso de impacto e exigem confirmação. Chaves marcadas com is_secret = true aparecem na lista, mas com o valor mascarado (apenas os quatro últimos caracteres) e sem campo de edição para ADMIN; só OWNER pode editá-las, e o valor nunca é devolvido em texto claro pela API (Seções 3.8 e 6.23). Credenciais de integração e chaves de assinatura não são settings: vivem em variável de ambiente (Seção 26.2), e a interface diz isso explicitamente em vez de mostrar um campo vazio.

Feature flags. Papel: ADMIN. Interruptores de feature_flags (Seção 6), com descrição e a data da última mudança. As chaves de flag não usam prefixo de grupo, ao contrário das de settings. Cada mudança é auditada.

Administradores. Papel: OWNER para a aba inteira; ADMIN pode criar e editar usuários cujo papel alvo seja EDITOR, conforme a matriz da Seção 3.8. Lista de admin_users com papel, último acesso e estado do TOTP. Ações: convidar (cria o registro e envia e-mail de definição de senha), alterar papel, desativar, resetar TOTP. OWNER não pode desativar a si mesmo nem rebaixar o próprio papel; a interface bloqueia e o servidor devolve 409 CANNOT_MODIFY_SELF. É obrigatório existir ao menos um OWNER ativo: a tentativa de desativar o último devolve 409 LAST_OWNER.

15.12 Endpoints administrativos #

Todos exigem sessão de administrador (Seção 8), seguem o envelope da Seção 7 e ecoam X-Request-Id. Erros comuns, omitidos das tabelas individuais: 401 UNAUTHENTICATED, 403 FORBIDDEN (papel insuficiente), 403 CSRF_INVALID (mutações), 500 INTERNAL_ERROR. Toda mutação exige o cabeçalho X-CSRF-Token, casado com o cookie __Host-csrf.

Nenhuma resposta paginada devolve total, page, perPage ou totalPages. A paginação é por cursor: meta traz apenas requestId, timestamp, nextCursor, hasMore e limit (Seção 7.8). Onde a tela precisa de um total — os contadores do painel inicial, por exemplo —, ela usa GET /api/admin/metrics/overview (Seção 21.9), que já entrega contagens agregadas e não paginadas.

15.12.1 GET /api/admin/devotionals #

  • Papel: EDITOR. Idempotente, sem efeitos colaterais.
  • Query: ?limit=<1..100>&cursor=&status=<lista>&from=&to=&q=&author=&hasAudio=

Resposta 200:

{
  "data": [
    { "id": "dev_01HZX...", "scheduledFor": "2026-08-25",
      "title": "O Senhor é o meu pastor", "status": "SENT",
      "audioState": "READY", "lastRevisionBy": "ana@palavradiaria.com.br",
      "updatedAt": "2026-08-24T18:22:00.000Z", "version": 7 }
  ],
  "meta": { "requestId": "req_01HZXE1A2B3C4D5E6F7G8H9J0K",
            "timestamp": "2026-08-25T15:00:00.000Z",
            "nextCursor": "MDFIWlg...", "hasMore": true, "limit": 20 }
}

Erros: 422 VALIDATION_ERROR.

15.12.2 POST /api/admin/devotionals #

  • Papel: EDITOR.
  • Efeitos colaterais: cria devotionals em DRAFT e a revisão 1; auditoria.
  • Idempotência: Idempotency-Key opcional; sem ela, duas chamadas criam dois rascunhos, o que é aceitável para criação manual.
export const createDevotionalSchema = z.object({
  scheduledFor: z.iso.date(),
  title: z.string().trim().min(3).max(80),
  bibleReference: z.string().trim().min(3).max(60).optional(),
  bibleVersion: z.enum(ALLOWED_BIBLE_VERSIONS).default('ALMEIDA_1911'),
  bibleText: z.string().trim().max(1200).optional(),
  reflectionMd: z.string().trim().max(3500).optional(),
  prayer: z.string().trim().max(800).optional(),
  teaser: teaserSchema.optional(),
  internalNotes: z.string().trim().max(1000).optional(),
});

Resposta 201: objeto completo do devocional, com version: 1 e status: "DRAFT".

Erros: 422 VALIDATION_ERROR, 409 DATE_ALREADY_TAKEN (details.devotionalId aponta o existente).

15.12.3 PATCH /api/admin/devotionals/{id} #

  • Papel: EDITOR.
  • Efeitos colaterais: atualiza campos, cria ou coalesce revisão, incrementa version; auditoria.
  • Idempotência: controlada por expectedVersion. Reenviar o mesmo corpo com a versão já aplicada devolve 409 STALE_WRITE.

Request: qualquer subconjunto dos campos de criação, mais:

export const patchDevotionalSchema = createDevotionalSchema.partial().extend({
  expectedVersion: z.number().int().min(1),
  autosave: z.boolean().default(false),   // true permite coalescer a revisão
});

Resposta 200: objeto completo com version incrementado e revisionId.

Erros: 422 VALIDATION_ERROR, 404 DEVOTIONAL_NOT_FOUND, 409 STALE_WRITE (details traz currentVersion, lastEditedBy, lastEditedAt), 409 DATE_ALREADY_TAKEN, 409 ILLEGAL_STATE_TRANSITION (editar conteúdo de um SENT).

15.12.4 POST /api/admin/devotionals/{id}/status-changes #

  • Papel: EDITOR para DRAFT ↔ READY; ADMIN para PUBLISHED e despublicação.
  • Efeitos colaterais: transição de estado, enfileiramento de TTS, revalidação de cache, auditoria.
  • Idempotência: pedir o estado atual devolve 200 sem efeito.
export const statusChangeSchema = z.object({
  to: z.enum(['DRAFT', 'READY', 'PUBLISHED']),
  reason: z.string().trim().min(10).max(500).optional(),   // obrigatório para despublicar
  overrideAudioGuard: z.boolean().default(false),          // só OWNER
  overrideReason: z.string().trim().min(20).max(500).optional(),
});

Resposta 200:

{
  "data": { "id": "dev_01HZX...", "status": "AUDIO_PENDING",
            "audioJobId": "job_01HZX...", "publishedAt": null },
  "meta": { "requestId": "req_01HZXE2B3C4D5E6F7G8H9J0K1L", "timestamp": "2026-08-25T15:05:00.000Z" }
}

Erros:

HTTP code Quando
422 VALIDATION_ERROR Campos obrigatórios do devocional faltando para ir a READY; details lista cada campo
422 MESSAGE_TOO_LONG Mensagem montada acima de 4096; details.length
422 TEASER_INVALID Teaser reprovado em teaserSchema
409 AUDIO_REQUIRED Guarda de publicação (15.3.2); details.activePaidSubscribers
403 OVERRIDE_NOT_ALLOWED overrideAudioGuard usado por quem não é OWNER
422 OVERRIDE_REASON_REQUIRED Override sem motivo de 20+ caracteres
409 ILLEGAL_STATE_TRANSITION Transição fora da tabela 15.3.1
409 BATCH_ALREADY_STARTED Despublicar depois de o lote do dia começar
404 DEVOTIONAL_NOT_FOUND

15.12.5 POST /api/admin/devotionals/{id}/reschedules #

  • Papel: EDITOR. Efeitos colaterais: altera scheduled_for; auditoria.
  • Idempotência: mover para a data atual é no-op com 200.

Request: { "scheduledFor": "2026-09-12", "swapIfTaken": false }.

Quando swapIfTaken é true e a data destino está ocupada, os dois devocionais trocam de data em uma única transação. Ambos precisam estar em estado reagendável.

Resposta 200: { "data": { "id": "...", "scheduledFor": "2026-09-12", "swappedWith": null }, "meta": {...} }.

Erros: 409 DATE_ALREADY_TAKEN, 409 ILLEGAL_STATE_TRANSITION (SENT ou lote iniciado), 422 VALIDATION_ERROR (data no passado para status diferente de DRAFT), 404 DEVOTIONAL_NOT_FOUND.

15.12.6 POST /api/admin/devotionals/{id}/duplications #

  • Papel: EDITOR. Efeitos colaterais: cria novo devocional DRAFT; auditoria.
  • Idempotência: Idempotency-Key obrigatório.

Request: { "scheduledFor": "2026-09-20" }.

Resposta 201: objeto completo do novo devocional.

Erros: 409 DATE_ALREADY_TAKEN, 404 DEVOTIONAL_NOT_FOUND, 422 VALIDATION_ERROR.

15.12.7 GET /api/admin/devotionals/{id}/revisions e POST .../revisions/{rid}/restorations #

GETEDITOR, idempotente, paginado por cursor.

{
  "data": [
    { "id": "rev_01HZX...", "number": 7, "authorEmail": "ana@palavradiaria.com.br",
      "createdAt": "2026-08-24T18:22:00.000Z", "isPublishSnapshot": true,
      "changedFields": ["reflectionMd", "teaser"],
      "charDelta": { "added": 148, "removed": 92 } }
  ],
  "meta": { "requestId": "req_01HZXE3C4D5E6F7G8H9J0K1L2M",
            "timestamp": "2026-08-25T15:10:00.000Z", "nextCursor": null, "hasMore": false }
}

POST /api/admin/devotionals/{id}/revisions/{rid}/restorationsEDITOR. A revisão de origem vem do caminho ({rid}), como sub-recurso aninhado da revisão; não há revisionId no corpo. Request: { "reason": "..." }. Efeitos: cria nova revisão com o conteúdo restaurado e com restored_from_revision apontando para o número da revisão {rid}; se o texto mudou e havia áudio, volta o status para READY e invalida audio_assets; auditoria.

Resposta 200: objeto completo do devocional, com version incrementado e audioInvalidated: true|false.

Erros: 404 REVISION_NOT_FOUND, 409 ILLEGAL_STATE_TRANSITION (SENT), 409 UNPUBLISH_REQUIRED (PUBLISHED sem despublicar antes).

15.12.8 POST /api/admin/devotionals/imports #

  • Papel: EDITOR. Content-Type: multipart/form-data.
  • Efeitos colaterais: cria N devocionais DRAFT em transação única; auditoria.
  • Idempotência: Idempotency-Key obrigatório; repetição em 1 hora devolve o relatório original sem criar nada.

Campos do formulário: file (CSV), dryRun (true para apenas validar), stopOnError (true para abortar tudo se houver qualquer erro).

Resposta 200 (dryRun: true ou importação concluída):

{
  "data": {
    "importId": "imp_01HZX...",
    "dryRun": true,
    "totalRows": 200,
    "valid": 182,
    "warnings": 12,
    "errors": 18,
    "created": 0,
    "rows": [
      { "line": 2, "scheduledFor": "2026-09-01", "title": "O Senhor é o meu pastor",
        "severity": "ok", "issues": [] },
      { "line": 14, "scheduledFor": "2026-09-13", "title": "Descanso",
        "severity": "warning",
        "issues": [{ "code": "TEASER_GENERATED", "field": "teaser",
                     "message": "Teaser gerado a partir da reflexão." }] },
      { "line": 37, "scheduledFor": "2026-09-13", "title": "Repouso",
        "severity": "error",
        "issues": [{ "code": "DATE_DUPLICATE_FILE", "field": "scheduled_for",
                     "message": "Data repetida na linha 14 e na linha 37." }] }
    ],
    "errorReportUrl": "https://media.palavradiaria.com.br/imports/imp_01HZX.../errors.csv?X-Amz-Expires=900&..."
  },
  "meta": { "requestId": "req_01HZXE4D5E6F7G8H9J0K1L2M3N", "timestamp": "2026-08-25T15:20:00.000Z" }
}

Erros:

HTTP code Quando
413 FILE_TOO_LARGE Acima de 2 MB
422 TOO_MANY_ROWS Acima de 200 linhas
422 MISSING_COLUMN Cabeçalho sem coluna obrigatória; details.columns
422 INVALID_ENCODING Arquivo não é UTF-8 decodificável
422 IMPORT_ABORTED stopOnError: true e houve erro; nada foi criado
400 IDEMPOTENCY_KEY_REQUIRED Cabeçalho ausente
429 IMPORT_LIMIT Mais de 5 importações por administrador por dia

15.12.9 GET /api/admin/subscribers e GET /api/admin/subscribers/{id} #

  • Papel: ADMIN nos dois. EDITOR não tem nenhuma permissão sobre assinantes (Seção 3.8). Idempotentes.
  • Efeitos colaterais: o GET da ficha grava SUBSCRIBER_VIEWED em auditoria. É a única leitura do sistema com efeito colateral, e é deliberada.
  • Query da lista: ?limit=&cursor=&q=&tier=&status=&optedOut=&paused=&from=&to=

Resposta da lista 200:

{
  "data": [
    { "id": "sub_01HZX...", "name": "Maria das Graças",
      "phoneMasked": "(11) 9****-5678", "emailMasked": "m****@exemplo.com.br",
      "tier": "PAID", "status": "ACTIVE_PAID",
      "createdAt": "2025-09-30T12:00:00.000Z",
      "lastDeliveryAt": "2026-08-25T09:00:12.000Z",
      "lastInboundAt": "2026-08-25T09:03:44.000Z" }
  ],
  "meta": { "requestId": "req_01HZXE5E6F7G8H9J0K1L2M3N4P",
            "timestamp": "2026-08-25T15:30:00.000Z",
            "nextCursor": "MDFIWlg...", "hasMore": true, "limit": 20 }
}

Erros: 422 VALIDATION_ERROR, 404 SUBSCRIBER_NOT_FOUND.

15.12.10 POST /api/admin/subscribers/{id}/actions #

Endpoint único para as ações administrativas de 15.7.3, com discriminador. Escolha deliberada: uma única superfície facilita garantir que toda ação passe pela mesma auditoria obrigatória.

  • Papel: conforme a tabela de 15.7.3.
  • Idempotência: Idempotency-Key obrigatório para RESEND_DEVOTIONAL, GRANT_MANUAL_ACCESS e CANCEL_SUBSCRIPTION.
export const adminSubscriberActionSchema = z.discriminatedUnion('action', [
  z.object({ action: z.literal('RESEND_DEVOTIONAL'),
             devotionalId: z.string().length(30),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('RESYNC_ASAAS'),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('GRANT_MANUAL_ACCESS'),
             days: z.number().int().min(1).max(365),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('REVOKE_MANUAL_ACCESS'),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('APPLY_OPT_OUT'),
             alsoCancelSubscription: z.boolean().default(false),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('REACTIVATE'),
             consentEvidence: z.string().trim().min(20).max(500),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('CANCEL_SUBSCRIPTION'),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('DELETE_LGPD'),
             phoneConfirmation: z.string().min(8),
             requestChannel: z.enum(['EMAIL', 'WHATSAPP', 'PANEL', 'PHONE', 'LEGAL']),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('REVERT_DELETION'),
             reason: z.string().trim().min(10).max(500) }),
  z.object({ action: z.literal('CHANGE_PHONE'),
             newPhone: z.string().min(8),
             evidence: z.string().trim().min(20).max(500),
             reason: z.string().trim().min(10).max(500) }),
]);

Resposta 200, formato comum:

{
  "data": { "action": "GRANT_MANUAL_ACCESS", "applied": true,
            "auditLogId": "aud_01HZX...",
            "result": { "tier": "PAID", "courtesyUntil": "2026-09-24T02:59:59.000Z" } },
  "meta": { "requestId": "req_01HZXE6F7G8H9J0K1L2M3N4P5Q", "timestamp": "2026-08-25T15:35:00.000Z" }
}

Erros:

HTTP code Quando
422 VALIDATION_ERROR Schema, incluindo motivo curto demais
422 REASON_REQUIRED Motivo ausente
422 PHONE_CONFIRMATION_MISMATCH DELETE_LGPD com telefone que não confere
403 FORBIDDEN Papel insuficiente para a ação pedida
409 ILLEGAL_STATE_TRANSITION Ex.: cancelar assinatura inexistente
409 SUBSCRIBER_OPTED_OUT RESEND_DEVOTIONAL para quem deu opt-out
422 COURTESY_REASON_REQUIRED GRANT_MANUAL_ACCESS sem motivo
429 COURTESY_LIMIT_EXCEEDED Limite de cortesias por administrador
409 DELETION_WINDOW_CLOSED REVERT_DELETION após 72 h
409 PHONE_ALREADY_REGISTERED CHANGE_PHONE para número em uso
429 ACTION_RATE_LIMITED Limites de 15.7.3
502 ASAAS_ERROR Falha em RESYNC_ASAAS ou CANCEL_SUBSCRIPTION

15.12.11 GET /api/admin/batches e GET /api/admin/batches/{batchId} #

  • Papel: EDITOR (leitura de lotes é permitida a EDITOR pela Seção 3.8). Idempotentes.

Resposta do detalhe 200:

{
  "data": {
    "id": "bat_01HZX...", "date": "2026-08-25",
    "devotional": { "id": "dev_01HZX...", "title": "O Senhor é o meu pastor" },
    "kind": "DAILY", "parentBatchId": null, "status": "COMPLETED_WITH_ERRORS",
    "startedAt": "2026-08-25T09:00:04.000Z", "finishedAt": "2026-08-25T09:07:12.000Z",
    "counts": { "planned": 3041, "sent": 3041, "delivered": 2978, "read": 1412,
                "failed": 63, "deferred": 0, "skipped": 12 },
    "failuresByCode": [ { "code": "131026", "label": "Não foi possível entregar", "count": 58 },
                        { "code": "130429", "label": "Limite de taxa", "count": 5 } ]
  },
  "meta": { "requestId": "req_01HZXE7G8H9J0K1L2M3N4P5Q6R", "timestamp": "2026-08-25T15:40:00.000Z" }
}

Erros: 404 BATCH_NOT_FOUND.

15.12.12 POST /api/admin/batches/{batchId}/retries #

  • Papel: ADMIN. Idempotência: Idempotency-Key obrigatório.
  • Efeitos colaterais: cria lote RETRY, enfileira envios, auditoria.
export const batchRetrySchema = z.object({
  scope: z.enum(['ALL_FAILED', 'BY_CODE', 'SELECTED']),
  codes: z.array(z.string().regex(/^\d{4,6}$/)).max(20).optional(),
  deliveryAttemptIds: z.array(z.string().length(30)).max(2000).optional(),
  reason: z.string().trim().min(10).max(500),
}).superRefine((v, ctx) => {
  if (v.scope === 'BY_CODE' && !v.codes?.length)
    ctx.addIssue({ code: 'custom', path: ['codes'], message: 'Informe ao menos um código.' });
  if (v.scope === 'SELECTED' && !v.deliveryAttemptIds?.length)
    ctx.addIssue({ code: 'custom', path: ['deliveryAttemptIds'], message: 'Selecione ao menos uma linha.' });
});

Resposta 202:

{
  "data": { "retryBatchId": "bat_01HZY...", "queued": 58, "skipped": 5,
            "skippedReasons": { "NON_RETRYABLE_CODE": 3, "ALREADY_DELIVERED": 2 },
            "estimatedSeconds": 3 },
  "meta": { "requestId": "req_01HZXE8H9J0K1L2M3N4P5Q6R7S", "timestamp": "2026-08-25T15:45:00.000Z" }
}

Erros: 404 BATCH_NOT_FOUND, 409 RETRY_WINDOW_CLOSED (mais de 48 h), 422 TOO_MANY_RECIPIENTS (acima de 2.000), 422 VALIDATION_ERROR, 409 BATCH_STILL_RUNNING (o lote original ainda não terminou).

15.12.13 POST /api/admin/batches/{batchId}/cancellations #

  • Papel: ADMIN. Idempotência: cancelar lote já cancelado devolve 200 sem efeito.
  • Efeitos colaterais: remove jobs pendentes da fila, fecha o lote, alerta os demais administradores, auditoria de severidade alta.

Request: { "reason": "Erro no texto do devocional publicado por engano." }.

Resposta 200:

{
  "data": { "status": "CANCELED", "alreadySent": 1204, "canceled": 1837,
            "canceledAt": "2026-08-25T09:03:30.000Z" },
  "meta": { "requestId": "req_01HZXE9J0K1L2M3N4P5Q6R7S8T", "timestamp": "2026-08-25T09:03:30.000Z" }
}

Erros: 404 BATCH_NOT_FOUND, 409 BATCH_NOT_RUNNING, 422 REASON_REQUIRED.

15.12.14 GET /api/admin/whatsapp-templates e mutações #

GETADMIN, idempotente. Lista os templates locais com o estado sincronizado.

POST /api/admin/whatsapp-templates/synchronizationsADMIN. Efeitos: consulta a Graph API e atualiza os registros locais. Idempotente por natureza. Resposta 200: { "data": { "synced": 6, "created": 1, "updated": 4, "unmanaged": 1, "syncedAt": "..." }, "meta": {...} }. Erros: 429 RATE_LIMITED (mais de 20 por hora), 502 META_API_ERROR (details.metaCode e details.metaMessage).

POST /api/admin/whatsapp-templatesADMIN. Cria localmente e submete à Meta. Validações locais de 15.9.3 antes da chamada. Resposta 201: { "data": { "id": "wtp_01HZX...", "name": "...", "status": "PENDING", "metaTemplateId": "..." }, "meta": {...} }. Erros: 422 VALIDATION_ERROR, 422 TEMPLATE_PARAM_SEQUENCE (parâmetros fora de ordem ou adjacentes), 409 TEMPLATE_NAME_TAKEN, 502 META_API_ERROR.

DELETE /api/admin/whatsapp-templates/{id}ADMIN. Erros: 409 TEMPLATE_IN_USE (referenciado na configuração de envio), 404 TEMPLATE_NOT_FOUND, 502 META_API_ERROR.

15.12.15 GET /api/admin/audit-logs e POST /api/admin/audit-logs/exports #

GETADMIN, idempotente, cursor. Query: ?limit=&cursor=&from=&to=&adminUserId=&actions=<lista>&entityType=&entityId=&requestId=&q=

Os nomes dos parâmetros de query espelham as colunas de admin_audit_log (15.10.1): adminUserId filtra por admin_user_id, entityType e entityId filtram por entity_type e entity_id. Não existem os parâmetros actorId, targetType nem targetId.

{
  "data": [
    { "id": "aud_01HZX...", "createdAt": "2026-08-25T15:35:00.000Z",
      "actorType": "ADMIN", "adminUserId": "adm_01HZX...", "actorRole": "ADMIN",
      "actorEmail": "ana@palavradiaria.com.br",
      "action": "MANUAL_ACCESS_GRANTED", "entityType": "SUBSCRIBER",
      "entityId": "sub_01HZX...", "reason": "Compensação por falha de áudio em 24/08.",
      "changedFields": ["tier", "courtesyUntil"],
      "ip": "189.x.x.x", "requestId": "req_01HZXE6F..." }
  ],
  "meta": { "requestId": "req_01HZXF1K2L3M4N5P6Q7R8S9T0U",
            "timestamp": "2026-08-25T16:00:00.000Z",
            "nextCursor": "MDFIWlg...", "hasMore": true }
}

actorEmail não é coluna: ele é resolvido a partir de adminUserId na montagem da resposta, pela mesma regra da exportação (15.10.1 e 15.10.3).

POST .../exportsOWNER. Aplica os mesmos filtros e devolve 202 com exportId, ou 200 com downloadUrl quando o resultado tiver menos de 5.000 linhas.

Erros: 422 VALIDATION_ERROR, 422 EXPORT_TOO_LARGE (acima de 50.000 linhas; details.count), 429 RATE_LIMITED.

15.12.16 PUT /api/admin/settings/{key}, PATCH /api/admin/plans/{code} e PATCH /api/admin/feature-flags/{key} #

  • Papel: ADMIN para settings e feature-flags, respeitado ainda o editable_by da própria linha; OWNER para plans e para qualquer chave com is_secret = true (Seção 3.8). Idempotentes: enviar o valor atual é no-op.
  • Método: PUT em settings, porque o corpo substitui integralmente o valor da chave; PATCH em plans e em feature-flags, que aceitam subconjunto de campos.
  • Efeitos colaterais: gravam o novo valor, auditam com before/after e, no caso de plans, revalidam as rotas públicas / e /planos (Seção 9.12.2).

Request de settings: { "value": <tipado conforme a chave>, "reason": "..." }. A chave segue sempre o formato grupo.chave e precisa existir no catálogo semeado (Seção 26.8.1); chave fora do catálogo devolve 404 SETTING_NOT_FOUND. Request de plans: { "name": "Anual", "amountCents": 19900, "active": true, "reason": "..." }. Request de feature-flags: { "enabled": true, "reason": "..." }.

Resposta 200: o registro atualizado mais auditLogId.

Erros: 404 SETTING_NOT_FOUND / PLAN_NOT_FOUND / FLAG_NOT_FOUND, 422 VALIDATION_ERROR (valor incompatível com o tipo declarado da chave), 422 REASON_REQUIRED, 409 IMMUTABLE_SETTING (chaves marcadas como somente leitura, como as que espelham variáveis de ambiente).

15.12.17 POST /api/admin/admin-users e PATCH /api/admin/admin-users/{id} #

  • Papel: ADMIN quando o papel alvo é EDITOR; OWNER nos demais casos — criar ou promover a ADMIN ou a OWNER, e qualquer alteração sobre um OWNER. O papel exigido é resolvido pelo papel alvo da operação, não pela rota (Seção 3.8).
  • Efeitos colaterais: cria admin_users, envia e-mail de definição de senha, audita.

Request de criação: { "email": "novo@palavradiaria.com.br", "name": "Novo Editor", "role": "EDITOR" }. Request de alteração: { "role": "ADMIN" } ou { "active": false } ou { "resetTotp": true }.

Resposta 201/200: o registro do administrador, sem hash de senha e sem segredo TOTP.

Erros: 409 EMAIL_ALREADY_IN_USE, 409 CANNOT_MODIFY_SELF (rebaixar ou desativar a si mesmo), 409 LAST_OWNER (desativar ou rebaixar o último OWNER ativo), 422 VALIDATION_ERROR.


16. Geração de Áudio (TTS) e Pipeline de Mídia #

16.1 Escopo e princípios #

Esta seção especifica como um devocional aprovado vira áudio pronto para entrega no WhatsApp, e como esse áudio é armazenado, transcodificado, enviado à Media API da Meta e invalidado quando o texto muda. Cinco princípios governam o pipeline e explicam quase todas as decisões abaixo:

  1. Uma geração por devocional, nunca por assinante. 3.000 assinantes consomem uma chamada de TTS, uma transcodificação e um upload. O custo é função de 30 devocionais/mês, não da base — por isso a escolha de qualidade de voz é livre (Seção 16.14).
  2. Nada incompleto é publicado. Só há transição para AUDIO_READY após as verificações da Seção 16.13. Áudio truncado é pior que áudio nenhum: o assinante ouve metade de uma oração.
  3. Idempotência. Todo job roda duas vezes sem duplicar arquivo, custo ou estado. Chave: (devotionalId, scriptHash, voiceId).
  4. Dois provedores atrás de uma interface. Falha de fornecedor não é falha do produto (Seção 16.4).
  5. O texto é a verdade; o áudio é derivado. Texto mudou, áudio derivado é inválido por definição (Seção 16.12).

Fora do escopo: edição de texto e transições DRAFT → READY (Seção 15); envio das mensagens (Seções 17 e 18); os textos das mensagens (Seção 19).

16.2 Visão geral do pipeline #

 EDITOR aprova (Seção 15): devotionals.status DRAFT -> READY
   |
   v (1) ENFILEIRAMENTO  status -> AUDIO_PENDING; jobId = tts:{devotionalId}:{scriptHash}
   |
   v (2) buildNarrationScript()                                     [Seção 16.3]
   |     title + reference + bible_text + reflection(sem markdown) + prayer
   |     saída: { plain, ssml, blocks, billableCharacterCount, scriptHash }
   |
   v (3) SÍNTESE via TtsProvider                                    [Seção 16.4]
   |     primário ElevenLabs --ok--> raw.mp3 (44,1 kHz / 128 kbps)
   |        | 3 falhas ou erro definitivo
   |        v fallback Google TTS --ok--> raw.mp3
   |           | falhou também -> job em failed + job_runs.status = 'DEAD'
   |             -> devocional PERMANECE em AUDIO_PENDING
   |             -> audio_assets.status = 'FAILED' -> alerta crítico
   |
   v (4) TRANSCODIFICAÇÃO ffmpeg                                    [Seção 16.7]
   |     4a medição loudnorm | 4b OGG/Opus mono 48 kHz 32 kbps (WhatsApp)
   |     4c MP3 mono 128 kbps (player web) | 4d ffprobe: duração, codec, canais
   |
   v (5) VERIFICAÇÕES DE QUALIDADE                                  [Seção 16.13]
   |     duração em [150 s, 480 s] | sem silêncio/truncamento | <= 12 MB
   |     reprovou -> tenta fallback 1x -> se reprovar: audio_assets.status = 'FAILED',
   |                                      devocional segue em AUDIO_PENDING + alerta
   |
   v (6) MP4 DE FALLBACK, sob demanda: capa 720x720 + áudio         [Seção 16.9]
   v (7) STORAGE S3 privado + registro em audio_assets              [Seção 16.10]
   v (8) UPLOAD Media API (job media.upload) -> whatsapp_media_id   [Seção 16.11]
   |
   v devotionals.status -> AUDIO_READY   (pronto para PUBLISHED e para a Seção 18)

Tempo de ponta a ponta para 4.000 caracteres: 35 a 90 s (síntese 25–70 s, transcodificação 2–5 s, upload 1–4 s). O pipeline roda com dias de antecedência (Seção 18.2), então essa latência nunca fica no caminho crítico das 06:00.

16.3 Montagem do roteiro de narração (buildNarrationScript) #

16.3.1 Entradas e ordem fixa #

buildNarrationScript() vive em packages/core/src/narration/ e é a única função autorizada a produzir texto para TTS. A ordem dos blocos é fixa e reproduz a ordem em que o assinante lê o devocional no WhatsApp.

# Bloco Origem Texto de ligação Pausa após
1 Abertura constante Palavra Diária. Devocional de <data por extenso>. 0,7 s
2 Título devotionals.title 0,7 s
3 Referência devotionals.bible_reference Leitura de hoje: … 0,4 s
4 Texto bíblico devotionals.bible_text 0,9 s
5 Ponte constante Reflexão. 0,5 s
6 Reflexão devotionals.reflection_md 0,9 s
7 Ponte constante Vamos orar. 0,5 s
8 Oração devotionals.prayer 0,7 s
9 Fechamento constante Que Deus abençoe o seu dia. Até amanhã.

Decisões registradas: a versão bíblica não é narrada — ler "Almeida 1911" no meio da leitura quebra o ritmo, e a atribuição continua no texto escrito (Seção 19); a data é narrada por extenso (terça-feira, 8 de setembro de 2026), formatada em pt-BR no fuso America/Sao_Paulo, o que dá contexto a quem ouve pelo acervo dias depois; não existe variante de roteiro por tier, porque o plano gratuito não recebe áudio (Seção 7).

16.3.2 Remoção de markdown #

reflection_md é markdown; a narração precisa de texto puro. A transformação é determinística e própria, não uma biblioteca genérica, porque precisamos decidir o que vira pausa e o que vira silêncio.

Construção Entrada Saída falada Construção Entrada Saída falada
Negrito/itálico **graça**, _graça_ graça (ênfase é papel da voz) Citação > Ele é fiel Ele é fiel, pausa 0,5 s antes e depois
Código inline `Sola fide` Sola fide Link [o Salmo 23](https://…) o Salmo 23 — URL nunca é falada
Bloco de código ``` … ``` removido inteiro Imagem ![alt](url) removido
Título H1–H6 ## O peso da espera texto + pausa 0,7 s Regra horizontal --- pausa 0,9 s
Lista não ordenada - confiar confiar + pausa 0,4 s Entidade HTML &nbsp;, &amp; espaço, e
Lista ordenada 1. confiar Primeiro: confiar + pausa 0,4 s Tag HTML / nota <br>, [^1] quebra de parágrafo / removido

Pós-processamento: espaços múltiplos colapsam; 3+ quebras viram 2; espaço antes de pontuação é removido; aspas tipográficas viram retas antes da contagem de caracteres, porque provedores cobram por caractere.

16.3.3 Leitura da referência bíblica por extenso #

Regra de maior impacto perceptível: Jo 3.16 sem tratamento é lido como "Jô três ponto dezesseis". A expansão é obrigatória no bloco 3 e também dentro da reflexão, onde referências inline são comuns. Formato de saída: <Livro>, capítulo <N>, versículo <M> / versículos <M> a <P> / versículos <M>, <P> e <Q>.

Entrada Saída falada Entrada Saída falada
Jo 3.16 / Jo 3:16 João, capítulo 3, versículo 16 Fp 4.6,7 Filipenses, capítulo 4, versículos 6 e 7
Sl 23.1-6 Salmos, capítulo 23, versículos 1 a 6 Pv 3.5-6,11 Provérbios, capítulo 3, versículos 5 a 6 e 11
1Co 13.4-7 Primeira carta aos Coríntios, capítulo 13, versículos 4 a 7 Is 40 Isaías, capítulo 40
2Tm 1.7 Segunda carta a Timóteo, capítulo 1, versículo 7 Jd 3 Judas, versículo 3

Separadores aceitos: . ou : entre capítulo e versículo; - ou em intervalo; , em lista. Livros de capítulo único (ob, fm, 2jo, 3jo, jd) tratam Jd 3 como versículo. Abreviação fora do mapa: mantém o texto original, registra warn narration.unknown_book_abbrev e segue — nunca falha o job, porque uma referência lida de forma imperfeita é melhor que nenhum áudio.

16.3.4 Expansão de números, símbolos e abreviações #

Categoria Entrada → saída falada Categoria Entrada → saída falada
Ordinais , primeiro, segunda (até 100) Porcentagem 40%quarenta por cento
Versículo/capítulo v. 7, vv. 7-9, cap. 4versículo 7, versículos 7 a 9, capítulo 4 Moeda R$ 19,90dezenove reais e noventa centavos
Era a.C., d.C.antes/depois de Cristo Intervalo, barra 2-3 vezes2 a 3 vezes; 24/724 por 7
Século séc. XIXséculo dezenove (romanos até XXX) Reticências, travessão ... + 0,4 s; → vírgula falada
Tratamentos Sr., Sra., Dr., Pe., Pr. → por extenso Aspas, parênteses aspas removidas; parênteses viram aposto
Genéricos etc.etcétera; p. ex.por exemplo Sigla, e comercial AT/NTAntigo/Novo Testamento; &e
URL, e-mail, emoji removidos — nunca ler URL em voz Controle removidos, registrados em warnings

Cardinais em dígitos não são expandidos: tanto o modelo multilíngue primário quanto as vozes Neural2 leem 3.000 e 2026 corretamente, e expandir manualmente introduz erros de concordância. As exceções são as categorias acima e o contexto de versículo da Seção 16.3.3.

16.3.5 Pausas e prosódia #

O roteiro é produzido em duas representações simultâneas: plain (texto puro, pausas por pontuação e linha em branco; usado pelo primário) e ssml (<speak>/<p>/<break>; usado pelo fallback quando a voz suporta SSML). É por isso que NarrationScript carrega as duas desde o começo.

No plain, pausas maiores que a pontuação usam <break time="0.9s"/>, reconhecida pelo modelo multilíngue primário. Limite duro de 6 marcas por requisição: acima disso o modelo fica instável e pode pular trechos. Como o roteiro tem 8 pausas estruturais, as duas menores (0,4 s) são rebaixadas para vírgula + nova linha pelo próprio buildNarrationScript, que registra BREAK_LIMIT_DOWNGRADED. Regras adicionais: toda sentença termina com pontuação forte, sem o que o TTS emenda frases; sentenças acima de 220 caracteres são divididas no conectivo mais próximo do meio (, e, , mas, , porque, ; ); o bloco de oração recebe speakingRate 5% menor no fallback e <break time="0.7s"/> no primário, que não expõe controle de velocidade por trecho.

16.3.6 Implementação #

// packages/core/src/narration/types.ts
export type NarrationBlockKind = 'OPENING' | 'TITLE' | 'REFERENCE' | 'BIBLE_TEXT'
  | 'REFLECTION_BRIDGE' | 'REFLECTION' | 'PRAYER_BRIDGE' | 'PRAYER' | 'CLOSING';
export type NarrationWarningCode = 'UNKNOWN_BOOK_ABBREV' | 'SENTENCE_TOO_LONG'
  | 'SCRIPT_TOO_SHORT' | 'SCRIPT_TOO_LONG' | 'BREAK_LIMIT_DOWNGRADED';
export interface NarrationBlock { kind: NarrationBlockKind; text: string; pauseAfterSeconds: number }
export interface NarrationWarning { code: NarrationWarningCode; detail: string }
export interface NarrationScript {
  devotionalId: string; blocks: NarrationBlock[];
  plain: string;                   // provedores de texto puro
  ssml: string;                    // provedores com SSML
  billableCharacterCount: number;  // length de `plain` sem as marcas <break>
  estimatedSeconds: number;        // billable / 14,2 caracteres por segundo
  scriptHash: string;              // sha256 hex de `plain` — chave de invalidação
  warnings: NarrationWarning[];
}
// packages/core/src/narration/bible-books.ts — chaves normalizadas: minúsculas, sem acento, sem ponto/espaço
export const BIBLE_BOOKS_SPOKEN: Record<string, string> = {
  gn: 'Gênesis', ex: 'Êxodo', lv: 'Levítico', nm: 'Números', dt: 'Deuteronômio', js: 'Josué', jz: 'Juízes',
  rt: 'Rute', '1sm': 'Primeiro livro de Samuel', '2sm': 'Segundo livro de Samuel', '1rs': 'Primeiro livro dos Reis',
  '2rs': 'Segundo livro dos Reis', '1cr': 'Primeiro livro das Crônicas', '2cr': 'Segundo livro das Crônicas',
  ed: 'Esdras', ne: 'Neemias', et: 'Ester', job: 'Jó', sl: 'Salmos', pv: 'Provérbios', ec: 'Eclesiastes',
  ct: 'Cânticos dos Cânticos', is: 'Isaías', jr: 'Jeremias', lm: 'Lamentações de Jeremias', ez: 'Ezequiel',
  dn: 'Daniel', os: 'Oseias', jl: 'Joel', am: 'Amós', ob: 'Obadias', jn: 'Jonas', mq: 'Miqueias', na: 'Naum',
  hc: 'Habacuque', sf: 'Sofonias', ag: 'Ageu', zc: 'Zacarias', ml: 'Malaquias', mt: 'Mateus', mc: 'Marcos',
  lc: 'Lucas', jo: 'João', at: 'Atos dos Apóstolos', rm: 'Romanos', '1co': 'Primeira carta aos Coríntios',
  '2co': 'Segunda carta aos Coríntios', gl: 'Gálatas', ef: 'Efésios', fp: 'Filipenses', cl: 'Colossenses',
  '1ts': 'Primeira carta aos Tessalonicenses', '2ts': 'Segunda carta aos Tessalonicenses',
  '1tm': 'Primeira carta a Timóteo', '2tm': 'Segunda carta a Timóteo', tt: 'Tito', fm: 'Filemom', hb: 'Hebreus',
  tg: 'Tiago', '1pe': 'Primeira carta de Pedro', '2pe': 'Segunda carta de Pedro', '1jo': 'Primeira carta de João',
  '2jo': 'Segunda carta de João', '3jo': 'Terceira carta de João', jd: 'Judas', ap: 'Apocalipse',
};
export const SINGLE_CHAPTER_BOOKS = new Set(['ob', '2jo', '3jo', 'jd', 'fm']);
// packages/core/src/narration/expand-reference.ts
const REF = /\b([1-3]?\s?[A-Za-zÀ-ÿ]{2,20})\.?\s*(\d{1,3})(?:\s*[.:]\s*(\d{1,3}(?:\s*[-–]\s*\d{1,3})?(?:\s*,\s*\d{1,3}(?:\s*[-–]\s*\d{1,3})?)*))?\b/g;
export const normalizeBookKey = (raw: string) =>
  raw.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[.\s]/g, '');

function spokenVerses(spec: string): string {                       // "5-6,11" -> "versículos 5 a 6 e 11"
  const parts = spec.split(',').map((p) => p.trim());
  const s = parts.map((p) => (/[-–]/.test(p) ? p.split(/[-–]/).map((n) => n.trim()).join(' a ') : p));
  const label = parts.length > 1 || /[-–]/.test(spec) ? 'versículos' : 'versículo';
  return `${label} ${s.length === 1 ? s[0] : `${s.slice(0, -1).join(', ')} e ${s[s.length - 1]}`}`;
}

export function expandBibleReferences(input: string, warn: (c: 'UNKNOWN_BOOK_ABBREV', d: string) => void) {
  return input.replace(REF, (match, book, chapter, verses) => {
    const key = normalizeBookKey(String(book));
    const name = BIBLE_BOOKS_SPOKEN[key];
    if (!name) { warn('UNKNOWN_BOOK_ABBREV', match); return match; }          // preserva o original
    if (SINGLE_CHAPTER_BOOKS.has(key) && !verses) return `${name}, versículo ${chapter}`;
    return verses ? `${name}, capítulo ${chapter}, ${spokenVerses(String(verses))}`
                  : `${name}, capítulo ${chapter}`;
  });
}
// packages/core/src/narration/build-narration-script.ts
const MAX_BREAK_TAGS = 6, CHARS_PER_SECOND = 14.2;   // 14,2 medido com a voz padrão em pt-BR

export function buildNarrationScript(input: BuildNarrationScriptInput): NarrationScript {
  const warnings: NarrationWarning[] = [];
  const warn = (code: NarrationWarningCode, detail: string) => warnings.push({ code, detail });
  const clean = (raw: string) =>
    splitLongSentences(expandAbbreviations(expandBibleReferences(stripMarkdownForSpeech(raw), warn)), 220, warn);
  const d = formatInTimeZone(input.scheduledFor, 'America/Sao_Paulo', "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR });

  const blocks: NarrationBlock[] = [
    { kind: 'OPENING', text: `Palavra Diária. Devocional de ${d}.`, pauseAfterSeconds: 0.7 },
    { kind: 'TITLE', text: clean(input.title), pauseAfterSeconds: 0.7 },
    { kind: 'REFERENCE', text: `Leitura de hoje: ${clean(input.bibleReference)}.`, pauseAfterSeconds: 0.4 },
    { kind: 'BIBLE_TEXT', text: clean(input.bibleText), pauseAfterSeconds: 0.9 },
    { kind: 'REFLECTION_BRIDGE', text: 'Reflexão.', pauseAfterSeconds: 0.5 },
    { kind: 'REFLECTION', text: clean(input.reflectionMd), pauseAfterSeconds: 0.9 },
    { kind: 'PRAYER_BRIDGE', text: 'Vamos orar.', pauseAfterSeconds: 0.5 },
    { kind: 'PRAYER', text: clean(input.prayer), pauseAfterSeconds: 0.7 },
    { kind: 'CLOSING', text: 'Que Deus abençoe o seu dia. Até amanhã.', pauseAfterSeconds: 0 },
  ];
  for (const b of blocks) if (!b.text.trim()) throw new NarrationError('NARRATION_BLOCK_EMPTY', b.kind);

  // Orçamento de <break>: só as maiores pausas viram tag; as demais viram pontuação.
  const ranked = blocks.map((b, i) => ({ i, p: b.pauseAfterSeconds })).filter((x) => x.p > 0).sort((a, b) => b.p - a.p);
  const withBreak = new Set(ranked.slice(0, MAX_BREAK_TAGS).map((x) => x.i));
  if (ranked.length > MAX_BREAK_TAGS) warn('BREAK_LIMIT_DOWNGRADED', `${ranked.length - MAX_BREAK_TAGS} pausas`);

  const plain = blocks.map((b, i) => b.pauseAfterSeconds === 0 ? b.text
      : withBreak.has(i) ? `${b.text}\n<break time="${b.pauseAfterSeconds.toFixed(1)}s"/>\n` : `${b.text}\n`)
    .join('\n').replace(/\n{3,}/g, '\n\n').trim();
  const ssml = `<speak>${blocks.map((b) => `<p>${escapeSsml(b.text)}</p>` +
    (b.pauseAfterSeconds > 0 ? `<break time="${b.pauseAfterSeconds.toFixed(1)}s"/>` : '')).join('')}</speak>`;

  const billable = plain.replace(/<break[^>]*\/>/g, '').length;
  const estimatedSeconds = Math.round(billable / CHARS_PER_SECOND);
  if (estimatedSeconds < 150) warn('SCRIPT_TOO_SHORT', `${estimatedSeconds}s`);
  if (estimatedSeconds > 480) warn('SCRIPT_TOO_LONG', `${estimatedSeconds}s`);
  if (billable > 20_000) throw new NarrationError('NARRATION_SCRIPT_TOO_LARGE', String(billable));

  return { devotionalId: input.devotionalId, blocks, plain, ssml, billableCharacterCount: billable,
    estimatedSeconds, scriptHash: createHash('sha256').update(plain, 'utf8').digest('hex'), warnings };
}

16.3.7 Limites do roteiro #

Verificação Limite Ação
Mínimo faturável 900 caracteres Aviso SCRIPT_TOO_SHORT; job segue; alerta amarelo no painel do editor
Máximo faturável 9.000 caracteres Aviso SCRIPT_TOO_LONG; job segue com divisão em partes (Seção 16.5)
Máximo absoluto 20.000 caracteres NARRATION_SCRIPT_TOO_LARGE; o devocional permanece em AUDIO_PENDING e audio_assets.status passa a FAILED
Bloco vazio após limpeza qualquer dos 5 campos narrados NARRATION_BLOCK_EMPTY; é defeito editorial, não de infraestrutura
Marcas <break> 6 Excedentes viram pontuação

Exemplo de referência usado em toda esta seção: título 90, referência 40, texto bíblico 380, reflexão 2.900, oração 480, blocos fixos 140 → 4.030 caracteres faturáveis → 4.030 / 14,2 = 284 s (4 min 44 s), dentro do alvo de 3 a 6 minutos da Seção 16.8.

16.4 Interface TtsProvider #

Os provedores ficam atrás de uma interface única em packages/integrations/src/tts/. Worker, painel e CLI conhecem apenas a interface; trocar ou adicionar provedor não toca em nenhuma outra parte do sistema.

export type TtsProviderName = 'ELEVENLABS' | 'GOOGLE';
export type TtsErrorCode = 'TTS_UNAUTHORIZED' | 'TTS_QUOTA_EXCEEDED' | 'TTS_RATE_LIMITED' | 'TTS_INVALID_VOICE'
  | 'TTS_INVALID_INPUT' | 'TTS_TIMEOUT' | 'TTS_UPSTREAM_ERROR' | 'TTS_EMPTY_AUDIO' | 'TTS_NETWORK_ERROR';

export class TtsError extends Error {
  constructor(readonly code: TtsErrorCode, readonly provider: TtsProviderName, readonly retryable: boolean,
    message: string, readonly httpStatus?: number, readonly retryAfterSeconds?: number) { super(message); }
}
export interface TtsSynthesizeInput {
  devotionalId: string; script: NarrationScript; voiceId: string; requestId: string; signal: AbortSignal;
}
export interface TtsSynthesizeResult {
  provider: TtsProviderName; audio: Buffer; mimeType: 'audio/mpeg';   // formato intermediário único
  voiceId: string; modelId: string; billedCharacters: number;
  costMicroUsd: number;   // unidade do provedor; convertido para BRL em audio_assets.cost_micros
  latencyMs: number; providerRequestId: string | null; chunkCount: number;
}
export interface TtsProvider {
  readonly name: TtsProviderName;
  synthesize(input: TtsSynthesizeInput): Promise<TtsSynthesizeResult>;   // faz o chunking internamente
  healthCheck(): Promise<{ healthy: boolean; detail: string }>;
  estimateCostMicroUsd(billableCharacters: number): number;
  defaultVoiceId(): string;
  usesSsml(): boolean;      // true -> consome script.ssml; false -> consome script.plain
}
// packages/integrations/src/tts/synthesize-with-failover.ts — primaryAttempts 3, backoff [2s, 8s, 20s]
export async function synthesizeWithFailover([primary, fallback]: [TtsProvider, TtsProvider],
  input: TtsSynthesizeInput, opts: { primaryAttempts: number; backoffMs: number[]; logger: Logger },
): Promise<TtsSynthesizeResult> {
  for (let attempt = 1; attempt <= opts.primaryAttempts; attempt++) {
    try { return await primary.synthesize(input); }
    catch (err) {
      const e = err as TtsError;
      opts.logger.warn({ provider: primary.name, attempt, code: e?.code }, 'tts.primary_attempt_failed');
      if (e instanceof TtsError && !e.retryable) break;       // voz inválida/quota não melhora com retry
      if (attempt < opts.primaryAttempts)
        await sleep((e?.retryAfterSeconds ?? 0) * 1000 || opts.backoffMs[attempt - 1] || 20_000, input.signal);
    }
  }
  opts.logger.error({ from: primary.name, to: fallback.name }, 'tts.failover_engaged');
  return fallback.synthesize({ ...input, voiceId: fallback.defaultVoiceId() });
}

Regra registrada: o failover troca a voz. Não existe voz "equivalente" entre provedores. O sistema aceita a diferença audível, registra em audio_assets.provider e não avisa o assinante — ele não é notificado de troca de provedor em nenhum canal.

Registro do provedor usado. Toda síntese bem-sucedida grava em audio_assets (Seção 6): provider, voice_id, model_id, narration_script_hash, char_count, cost_micros, chunk_count, status e duration_seconds — este último medido por ffprobe após a transcodificação, nunca estimado. O painel administrativo (Seção 15) mostra ao editor um rótulo neutro ("Voz A" / "Voz B") ao lado do player de pré-escuta; o nome técnico aparece só para ADMIN e OWNER. O dashboard de custos (Seção 21) soma cost_micros por mês e por provedor.

Unidade de custo, obrigatória. audio_assets.cost_micros é inteiro em milionésimos de BRL, nunca em dólar e nunca em ponto flutuante. O provedor cobra em dólar; a conversão para BRL acontece uma única vez, no momento da gravação, pelo câmbio de referência billing.usd_brl_reference_rate em settings, e o valor em dólar não é persistido. O divisor para exibição é 1.000.000 (Seção 21). Valores de assinatura e pagamento usam a outra unidade do sistema, *_amount_cents (centavos de BRL, divisor 100); as duas unidades nunca entram na mesma fórmula sem conversão explícita.

16.5 Provedor primário — ElevenLabs #

O cliente usa o SDK oficial declarado na Seção 4, mas o contrato do qual o sistema depende é o HTTP abaixo.

POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}?output_format=mp3_44100_128
xi-api-key: <ELEVENLABS_API_KEY>
Content-Type: application/json
Accept: audio/mpeg
{
  "text": "Palavra Diária. Devocional de terça-feira, 8 de setembro de 2026.\n<break time=\"0.7s\"/>\n\nO peso da espera\n…",
  "model_id": "eleven_multilingual_v2", "language_code": "pt",
  "voice_settings": { "stability": 0.45, "similarity_boost": 0.75, "style": 0.15, "use_speaker_boost": true },
  "apply_text_normalization": "auto", "previous_request_ids": []
}
Parâmetro Valor Justificativa
model_id eleven_multilingual_v2 (via TTS_MODEL_ID) Melhor prosódia em pt-BR entre os multilíngues; sucessor entra por variável de ambiente
voice_id TTS_VOICE_ID (Seção 26) Voz pt-BR da curadoria; sem hardcode
output_format mp3_44100_128 Formato intermediário; toda conversão final é do ffmpeg (Seção 16.7), nunca do provedor
stability 0.45 Abaixo de 0,35 varia demais entre parágrafos; acima de 0,60 fica monótona
similarity_boost 0.75 Timbre consistente entre dias diferentes
style 0.15 Expressividade baixa e deliberada; devocional não é locução publicitária
use_speaker_boost true Clareza em fone de ouvido de celular, o cenário real
apply_text_normalization auto Rede de segurança para números que escaparam da Seção 16.3.4
language_code pt Trava o idioma; sem isso, nomes hebraicos recebem fonética errada

Limites operacionais: 5.000 caracteres por requisição; timeout de 120 s (AbortSignal; excedido vira TTS_TIMEOUT retentável); 2 requisições simultâneas, alinhado à concorrência da fila (Seção 16.14); resposta acima de 25 MB vira TTS_UPSTREAM_ERROR, porque indica defeito e não conteúdo.

Divisão em partes. Roteiros acima de 5.000 caracteres são divididos em limites de bloco (Seção 16.3.1), nunca no meio de frase: acumulam-se blocos até que o próximo estoure o limite; um bloco isolado maior que 5.000 (tipicamente a reflexão) divide-se em limites de parágrafo, e um parágrafo isolado maior, em limites de sentença. Da segunda requisição em diante envia-se previous_request_ids com os 3 identificadores mais recentes, o que instrui o modelo a manter entonação e ritmo — sem isso a emenda é audível, com a voz "reiniciando" com energia diferente. As partes são unidas pelo demuxer concat do ffmpeg (Seção 16.7), nunca por concatenação binária de MP3, que produz estouros nas junções. Exemplo: roteiro de 8.400 caracteres com reflexão de 7.100 → reflexão dividida em 3.600 + 3.500 → 3 requisições, chunkCount = 3, custo idêntico ao de uma requisição única de 8.400 caracteres, porque a cobrança é por caractere e não por chamada.

HTTP / condição TtsErrorCode Retentável Ação
401 invalid_api_key TTS_UNAUTHORIZED Não Alerta crítico; failover na mesma execução
402/401 quota_exceeded TTS_QUOTA_EXCEEDED Não Alerta crítico; failover; painel mostra saldo esgotado
422 voice_not_found TTS_INVALID_VOICE Não Falha o job; é erro de configuração
422 invalid_content TTS_INVALID_INPUT Não Falha o job; roteiro vai para o log em debug
429 TTS_RATE_LIMITED Sim Respeita retry-after; senão backoff [2s, 8s, 20s]
500/502/503/504 TTS_UPSTREAM_ERROR Sim Backoff padrão
ECONNRESET, ETIMEDOUT, DNS TTS_NETWORK_ERROR Sim Backoff padrão
200 com corpo < 8 KB TTS_EMPTY_AUDIO Sim 8 KB de MP3 é menos de 1 s de áudio; trata como falha de rede

Custo por caractere, mantido em settings sob tts.cost_per_1k_chars_micros para o painel não depender de constante em código: Creator US$ 0,22/1.000 (US$ 0,89 por devocional típico), Pro US$ 0,12 (US$ 0,48), Scale US$ 0,08 (US$ 0,32). Decisão: iniciar no plano Creator — ele cobre 30 devocionais/mês com folga para regerações, e migrar para Pro só se justifica acima de 200.000 caracteres/mês, cerca de 50 devocionais mensais, cenário que não existe com um devocional por dia.

16.6 Provedor de fallback — Google Cloud Text-to-Speech #

Aciona em exatamente três situações: três tentativas retentáveis consecutivas do primário falharam; uma tentativa falhou com TTS_UNAUTHORIZED ou TTS_QUOTA_EXCEEDED, casos em que repetir não adianta; ou o operador ligou tts.force_fallback_provider em feature_flags durante um incidente conhecido. Nunca aciona por preferência de qualidade: existe para que a entrega das 06:00 não dependa de um único fornecedor.

POST https://texttospeech.googleapis.com/v1/text:synthesize
Authorization: Bearer <token OAuth2 da service account>
Content-Type: application/json
{
  "input": { "ssml": "<speak><p>Palavra Diária. Devocional de terça-feira, 8 de setembro de 2026.</p><break time=\"0.7s\"/><p>O peso da espera</p>…</speak>" },
  "voice": { "languageCode": "pt-BR", "name": "pt-BR-Neural2-C" },
  "audioConfig": { "audioEncoding": "MP3", "sampleRateHertz": 44100, "speakingRate": 0.95,
    "pitch": -1.0, "volumeGainDb": 0, "effectsProfileId": ["headphone-class-device"] }
}
Voz Gênero SSML Decisão
pt-BR-Neural2-C Feminino Sim Padrão do fallback — SSML preserva as pausas estruturais do roteiro
pt-BR-Neural2-B / -A Masc. / Fem. Sim Alternativas por TTS_FALLBACK_VOICE_ID
pt-BR-Chirp3-HD-* Ambos Não Qualidade superior, mas não aceita SSML. Se configurada, envia-se input.text com script.plain e removem-se as marcas <break> por regex, senão elas seriam faladas

usesSsml() resolve isso: true para Neural2/Studio/Wavenet, false para Chirp — é a razão de NarrationScript carregar plain e ssml simultaneamente. speakingRate: 0.95 porque as vozes Neural2 pt-BR falam rápido demais para conteúdo devocional; pitch: -1.0 para timbre mais sereno; effectsProfileId otimiza para fone de ouvido; audioEncoding: MP3 mantém um único caminho de ffmpeg no sistema, idêntico ao do primário.

Aspecto Primário Fallback Consequência
Limite por requisição 5.000 caracteres 5.000 bytes, incluindo SSML O chunking conta bytes do ssml; o SSML infla ~12%, então o corte prático fica em ~4.300 caracteres úteis
Continuidade entre partes previous_request_ids Não existe Emenda levemente audível; mitigada cortando só em limite de bloco, onde já há pausa
Controle de pausa <break>, máximo 6 <break> SSML, sem limite prático As 8 pausas estruturais são preservadas
Naturalidade Superior Boa, mais "sintética" Registrada em audio_assets.provider; sem comunicação ao assinante
Custo por 1M caracteres US$ 80 a 220 US$ 16 (Neural2) / US$ 30 (Chirp3-HD) O fallback é mais barato e ainda assim não vira padrão: a voz é o diferencial do plano pago
Erro TtsErrorCode Retentável Ação
401 / 403 TTS_UNAUTHORIZED Não Ambos fora: job falha, audio_assets.status = 'FAILED', devocional segue em AUDIO_PENDING, alerta crítico com plantonista
400 INVALID_ARGUMENT TTS_INVALID_INPUT Não Quase sempre SSML malformado; reenvia uma vez como input.text com script.plain
429 RESOURCE_EXHAUSTED TTS_RATE_LIMITED Sim Backoff [2s, 8s, 20s]
500/503 UNAVAILABLE, 504 DEADLINE_EXCEEDED TTS_UPSTREAM_ERROR / TTS_TIMEOUT Sim Backoff padrão

16.7 Transcodificação com ffmpeg #

O binário vem na imagem do container worker (Seção 4) e é executado como subprocesso com argumentos em array, nunca por string de shell, o que elimina injeção via nome de arquivo. Entradas e saídas são arquivos temporários com nomes aleatórios em um diretório de trabalho por job, removido no finally inclusive em falha.

Passo 1 — medição. Duas passagens são obrigatórias: loudnorm em passagem única faz normalização dinâmica e produz "bombeamento" audível nos silêncios longos — exatamente o que o roteiro tem, por causa das pausas estruturais.

# Mede loudness integrado, true peak, faixa dinâmica e offset.
# -f null - descarta a saída de áudio; só interessa o relatório JSON no stderr.
ffmpeg -hide_banner -nostats -i raw.mp3 \
  -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=json -f null - 2> loudnorm-measure.json

Alvos: I=-16 LUFS (padrão de fato para fala em plataformas móveis; o WhatsApp não normaliza o áudio recebido, e sem isso o assinante mexe no volume todo dia); TP=-1.5 dBTP (margem para o Opus não gerar clipping intersample); LRA=11 LU (faixa maior fica inaudível em ônibus ou cozinha, o contexto real das 06:00).

Passo 2 — OGG/Opus para o WhatsApp. Contêiner OGG, codec Opus, nada mais: é essa combinação que faz o WhatsApp renderizar a mensagem como nota de voz, com player inline, forma de onda e controle de velocidade. Qualquer outro contêiner ou codec vira anexo de áudio genérico, com experiência sensivelmente pior.

# OGG/Opus mono 48 kHz 32 kbps, loudness em 2ª passagem, fade-in 0,25 s e fade-out 0,40 s.
# Os measured_* vêm do JSON do Passo 1. st=283.6 é (duração_total - 0.40),
# obtido por ffprobe sobre raw.mp3 — nunca é um valor fixo.
ffmpeg -y -hide_banner -i raw.mp3 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-19.7:measured_TP=-3.2:measured_LRA=8.4:\
measured_thresh=-30.1:offset=0.4:linear=true:print_format=summary,\
afade=t=in:st=0:d=0.25,afade=t=out:st=283.6:d=0.40,aresample=48000:resampler=soxr:precision=28" \
  -ac 1 -ar 48000 -c:a libopus -b:a 32k -vbr on -compression_level 10 \
  -application audio -frame_duration 60 -map_metadata -1 -metadata title="Palavra Diária" \
  -f ogg narration.ogg
Argumento Razão
-ac 1 Voz não se beneficia de estéreo; mono corta o tamanho pela metade
-ar 48000 + aresample=soxr Opus opera internamente a 48 kHz; entregar já em 48 kHz evita reamostragem interna pobre
-c:a libopus Único codec aceito pelo WhatsApp dentro de OGG
-b:a 32k -vbr on Fala mono em Opus a 32 kbps é transparente ao ouvido; 5 min ocupam ~1,2 MB
-compression_level 10 Máxima eficiência; custa CPU (30 execuções/mês, irrelevante) e economiza bytes
-application audio voip otimiza latência e degrada faixas de frequência; aqui não há latência a otimizar
-frame_duration 60 Menos overhead de contêiner por segundo de áudio
-map_metadata -1 Remove tags da origem; evita vazar nome de modelo/voz no arquivo entregue
afade Elimina o clique de início e o corte abrupto de fim, que soam como falha de gravação

Passo 3 — MP3 para o player web. Mesma cadeia de loudnorm/afade, para que web e WhatsApp soem idênticos.

ffmpeg -y -hide_banner -i raw.mp3 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-19.7:measured_TP=-3.2:measured_LRA=8.4:\
measured_thresh=-30.1:offset=0.4:linear=true,afade=t=in:st=0:d=0.25,afade=t=out:st=283.6:d=0.40" \
  -ac 1 -ar 44100 -c:a libmp3lame -b:a 128k -write_xing 1 -id3v2_version 3 -map_metadata -1 \
  -metadata title="O peso da espera" -metadata artist="Palavra Diária" -metadata date="2026-09-08" \
  narration.mp3

-write_xing 1 grava o cabeçalho com a duração exata; sem ele o elemento <audio> estima a duração pelo bitrate e a barra de progresso "pula" durante a reprodução. Os metadados são preenchidos porque este arquivo pode ser baixado pelo assinante a partir do painel.

Concatenação e verificação.

# parts.txt: uma linha "file 'part-000.mp3'" por parte, na ordem. O demuxer concat com -c copy
# junta os quadros sem recodificar; a recodificação única ocorre nos passos 2 e 3.
ffmpeg -y -hide_banner -f concat -safe 0 -i parts.txt -c copy raw.mp3

ffprobe -v error -hide_banner -show_entries format=duration,size,bit_rate,format_name \
  -show_entries stream=codec_name,channels,sample_rate -of json narration.ogg
{ "streams": [{ "codec_name": "opus", "sample_rate": "48000", "channels": 1 }],
  "format": { "format_name": "ogg", "duration": "284.041000", "size": "1187432", "bit_rate": "33443" } }

A saída é validada com Zod. codec_name !== 'opus', channels !== 1 ou sample_rate !== '48000' produzem MEDIA_TRANSCODE_INVALID — sintoma de build de ffmpeg sem libopus, que precisa de intervenção humana, não de retry. Guardas do subprocesso: timeout de 180 s por invocação (SIGKILL, MEDIA_TRANSCODE_TIMEOUT, retentável); entrada máxima de 60 MB (falha antes de invocar); código de saída ≠ 0 vira MEDIA_TRANSCODE_FAILED com as últimas 40 linhas de stderr em nível error; stderr capturado com teto de 64 KB.

16.8 Regras de tamanho e duração #

Regra Valor Origem
Limite duro de mídia no WhatsApp 16 MB Restrição da plataforma: ultrapassar é falha de upload, não degradação
Alvo prático de duração 3 a 6 min (180–360 s) Decisão de produto: abaixo de 3 min soa apressado; acima de 6 min perde o consumo matinal
Faixa aceita sem intervenção 150 a 480 s Fora disso, alerta ao editor; não bloqueia
Limiar de re-encode 12 MB no OGG final Margem de 25% sobre o limite duro
Bitrate de re-encode 24 kbps Reduz ~25% mantendo inteligibilidade
Falha definitiva OGG > 16 MB após re-encode MEDIA_TOO_LARGE; escalonamento abaixo

Dimensionamento real, para deixar claro que 12 MB é defesa e não restrição operacional: a 32 kbps, 12 MB são 50 minutos de áudio; um devocional de 6 minutos ocupa 1,44 MB. O limiar só é alcançado com algo profundamente errado — por exemplo, 40.000 caracteres colados por engano em reflection_md.

Escalonamento acima de 16 MB mesmo a 24 kbps: o job não reduz mais o bitrate, porque abaixo de 24 kbps a fala fica metálica e o produto perde mais do que ganha; o devocional permanece em AUDIO_PENDING e audio_assets.status passa a FAILED — não existe estado AUDIO_FAILED em DevotionalStatus (Seção 15.3.1) —, a guarda de publicação da Seção 15.3.2 impede a publicação enquanto for assim, e o motor de envio trata o devocional como não pronto, acionando a reserva da Seção 18.4; o alerta media.oversized de severidade alta traz devotionalId, duração e tamanho; o painel mostra ao editor "O áudio deste devocional ficou com X minutos. Reduza a reflexão para até 3.500 caracteres e salve novamente." — 3.500 é exatamente o limite de reflection_md já declarado na Seção 15.2.3, e repetir aqui um número diferente daria ao editor uma instrução que não resolve.

16.9 Fallback de vídeo — geração do MP4 #

O caminho do 4º dia sem interação (Seção 17.3, etapa 4) usa um template com header VIDEO, porque header de template não aceita áudio (Seção 17.2). O MP4 é o único jeito de entregar a narração a quem nunca abre a janela.

# Capa 720x720: cor sólida + texto. O título vai por textfile= e não por text=, porque títulos reais
# contêm apóstrofos e dois-pontos, cujo escaping dentro do grafo de filtros é frágil.
ffmpeg -y -hide_banner -f lavfi -i "color=c=0x0F172A:s=720x720:d=1" \
  -vf "drawtext=fontfile=/usr/share/fonts/brand/Inter-SemiBold.ttf:text='PALAVRA DIÁRIA':\
fontcolor=0x94A3B8:fontsize=24:x=(w-tw)/2:y=64,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Bold.ttf:textfile=title.txt:fontcolor=white:\
fontsize=52:line_spacing=14:x=(w-tw)/2:y=(h-th)/2-40,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Regular.ttf:text='Salmos 23.1-6':\
fontcolor=0xCBD5E1:fontsize=30:x=(w-tw)/2:y=(h/2)+90,\
drawtext=fontfile=/usr/share/fonts/brand/Inter-Regular.ttf:text='8 de setembro de 2026':\
fontcolor=0x64748B:fontsize=24:x=(w-tw)/2:y=h-88" -frames:v 1 cover.png

# MP4 720x720: imagem estática em loop + faixa de áudio; -shortest encerra junto com o áudio.
ffmpeg -y -hide_banner -loop 1 -framerate 1 -i cover.png -i narration.mp3 \
  -c:v libx264 -tune stillimage -preset veryfast -pix_fmt yuv420p -profile:v baseline -level 3.1 -r 15 -g 30 \
  -vf "scale=720:720:force_original_aspect_ratio=increase,crop=720:720,format=yuv420p" \
  -c:a aac -b:a 64k -ac 1 -ar 44100 -shortest -movflags +faststart narration.mp4
Escolha Valor Razão
Resolução 720×720 quadrado Ocupa bem a tela em retrato, sem tarjas
Vídeo H.264 baseline nível 3.1, 15 fps, GOP 30 Máxima compatibilidade com Android antigo, parte relevante do público
-tune stillimage / -pix_fmt yuv420p Derruba o bitrate sem movimento; sem yuv420p alguns players não reproduzem
Áudio AAC 64 kbps mono Opus não é aceito dentro de MP4 pelo WhatsApp
-movflags +faststart Reprodução começa antes do download completo

Tamanho medido para 5 minutos: 2,6 a 3,1 MB; alvo declarado ≤ 10 MB. Acima de 14 MB, o áudio cai para 48 kbps e a resolução para 480×480; se ainda exceder 16 MB, o fallback de vídeo é desativado para aquele devocional — envia-se o template de texto normal e registra-se media.video_fallback_unavailable, de modo que o assinante recebe o convite e apenas não recebe o vídeo.

Quando o MP4 é gerado. Não para todos os devocionais, porque gerá-lo custa CPU e armazenamento para algo que só é usado se existirem assinantes no 4º dia sem interação. O job send.plan das 05:40 (Seção 18.3) conta os elegíveis; havendo ao menos um e não existindo MP4, enfileira tts.generate em modo videoOnly com prioridade alta. Fica pronto em menos de 60 s, dentro da folga de 20 minutos até as 06:00. Se não ficar pronto até 05:57, os elegíveis recebem o template de texto padrão naquele dia e continuam elegíveis no dia seguinte. Uma vez gerado, é reutilizado como qualquer outro asset.

16.10 Armazenamento #

Bucket privado S3-compatível (palavra-diaria-media), sem política de leitura pública, sem website hosting, com bloqueio de acesso público no nível da conta e versionamento habilitado.

devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.raw.mp3   síntese bruta do provedor
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.ogg       WhatsApp (nota de voz)
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.mp3       player web
devotionals/{YYYY}/{MM}/{devotionalId}/{voiceId}.mp4       fallback de vídeo
devotionals/{YYYY}/{MM}/{devotionalId}/cover.png           capa do vídeo
exemplo: devotionals/2026/09/dev_01K5T8QW3M9Z2X4R7B6C1D0E/voice_pt_br_ana.ogg

{YYYY}/{MM} vêm de devotionals.scheduled_for em America/Sao_Paulo, não da data de geração: isso agrupa o acervo por mês editorial e torna previsível a navegação manual pelo bucket durante um atendimento. {voiceId} é o identificador lógico da voz (voice_pt_br_ana), não o opaco do provedor — chaves legíveis que sobrevivem à troca de provedor mantendo a mesma persona.

await s3.send(new PutObjectCommand({
  Bucket: env.S3_BUCKET, Key: key, Body: buffer,
  ContentType: contentType,                       // audio/ogg | audio/mpeg | video/mp4 | image/png
  CacheControl: 'private, max-age=31536000, immutable',
  ContentDisposition: `inline; filename="palavra-diaria-${scheduledFor}.${ext}"`,
  ChecksumAlgorithm: 'SHA256', ServerSideEncryption: 'AES256',
  Metadata: { 'devotional-id': devotionalId, 'script-hash': scriptHash, 'tts-provider': provider,
    'tts-voice-id': providerVoiceId, 'duration-seconds': String(durationSeconds), 'generated-at': generatedAtIso },
}));

immutable é seguro porque o conteúdo de uma chave nunca muda: regeração produz novo scriptHash e nova versão lógica (Seção 16.12).

Objeto Transição Expiração
*.raw.mp3 7 dias. Insumo intermediário sem uso após a validação
*.ogg / *.mp3 Classe infrequente após 90 dias Nunca. É o acervo do produto
*.mp4 / cover.png 60 dias. Regeneráveis a partir do MP3 e da capa
Versões não correntes 30 dias após deixarem de ser correntes

Volume: 30 × (1,4 MB OGG + 4,8 MB MP3) = 186 MB/mês permanentes (~2,2 GB/ano), mais ~300 MB de temporários em regime, com expiração automática.

URLs assinadas. O player web nunca recebe URL pública: toda reprodução usa URL pré-assinada com TTL de 15 minutos, emitida por rota autenticada que antes verifica o entitlement (Seção 7) e a propriedade do devocional.

export const SIGNED_URL_TTL_SECONDS = 900;
export const createPlaybackUrl = (key: string, filename: string) =>
  getSignedUrl(s3, new GetObjectCommand({ Bucket: env.S3_BUCKET, Key: key,
    ResponseContentDisposition: `inline; filename="${filename}"`,
    ResponseCacheControl: 'private, max-age=900' }), { expiresIn: SIGNED_URL_TTL_SECONDS });

Nunca públicas, por três razões concretas: entitlement — o acervo completo é o principal diferencial pago (Seção 7), e bastaria alguém publicar o link em um grupo para vazá-lo; custo — um link público compartilhado em grupo grande gera tráfego de saída sem relação com a base e sem teto; revogação — quem cancela perde o acervo, e não há como revogar um link público já distribuído, enquanto com TTL de 15 minutos a revogação é automática. O TTL é maior que a duração máxima do áudio (8 min), então nenhuma reprodução iniciada é interrompida; o cliente renova a URL ao dar play em um áudio cuja URL foi emitida há mais de 10 minutos.

16.11 Upload para a Media API da Meta #

O arquivo precisa estar hospedado na Meta. Há duas formas — link público ou upload prévio referenciado por id. O sistema usa exclusivamente id: não exige URL pública (o que contradiria a Seção 16.10), é mais rápido no disparo e não depende da disponibilidade do nosso storage às 06:00.

POST https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/media
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: multipart/form-data; boundary=----X

------X
Content-Disposition: form-data; name="messaging_product"

whatsapp
------X
Content-Disposition: form-data; name="type"

audio/ogg
------X
Content-Disposition: form-data; name="file"; filename="narration.ogg"
Content-Type: audio/ogg

<bytes>
------X--
{ "id": "1234567890123456" }
Uso type Limite Observação
Nota de voz audio/ogg 16 MB Somente codec Opus. OGG/Vorbis é rejeitado
Fallback de vídeo video/mp4 16 MB H.264 + AAC, uma única faixa de áudio
Capa image/png 5 MB Fica só no nosso storage; não é enviada à Meta

Validade de 30 dias. Todo upload grava em media_uploads (Seção 6): whatsapp_media_id, uploaded_at, expires_at = uploaded_at + 30 dias, sha256, byte_size, mime_type e a referência a audio_assets. Antes de cada uso — no send.plan das 05:40, não no disparo — verifica-se expires_at > now + 48 horas; a margem de 48 h evita expiração no meio de um lote. Dentro da margem, media.upload é reenfileirado com prioridade alta, o arquivo é reenviado do storage e o novo media_id substitui o antigo na mesma transação, com superseded_at na linha anterior.

GET https://graph.facebook.com/v26.0/{MEDIA_ID}
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
{ "url": "https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=…", "mime_type": "audio/ogg",
  "sha256": "9f2c…", "file_size": 1187432, "id": "1234567890123456", "messaging_product": "whatsapp" }

Essa consulta é barata e evita reupload desnecessário quando o relógio local diverge: 404 ou erro 100 confirmam expiração. O sha256 retornado é comparado com media_uploads.sha256; divergência significa que o media_id aponta para outro arquivo — estado impossível em operação normal, tratado como corrupção, com invalidação do registro e novo upload.

A regra dura: um upload por devocional. O upload acontece exatamente uma vez por devocional e por tipo de mídia, nunca por assinante. Violar isso significaria, para 3.000 assinantes, 3.000 uploads de 1,2 MB por dia — 3,6 GB de saída diária, minutos somados ao lote e rate limit garantido; com a regra respeitada, o custo é de 1,2 MB por dia. Três mecanismos garantem: jobId determinístico media:{devotionalId}:{kind}:{sha256}, e o BullMQ descarta jobs com jobId já existente; índice único em media_uploads (audio_asset_id, kind) WHERE superseded_at IS NULL, de modo que um segundo upload concorrente viola a constraint e relê a linha vencedora; e o worker de envio não tem permissão de chamar upload — ele lê o whatsapp_media_id já resolvido no lote planejado, e se estiver ausente o item falha com MEDIA_NOT_READY e o áudio daquele assinante é adiado, nunca resolvido ali. A separação é arquitetural e deliberada.

Erro do upload Retentável Ação
400 code: 100 Não Falha o job; quase sempre type incompatível com o arquivo
400 code: 131053 Sim, 3× Backoff [5s, 30s, 120s]
401 code: 190 Não Alerta crítico: token de sistema expirado ou revogado (Seção 17.7)
413 Não Devia ter sido pego na Seção 16.8. Falha e alerta
429 code: 130429 Sim Backoff exponencial; upload às 05:40 não é urgente
5xx / timeout (60 s) Sim, 5× Backoff exponencial até 10 min

16.12 Cache, regeneração e invalidação #

A identidade de um áudio é (devotionalId, scriptHash, voiceId). Consequências diretas: reenfileirar tts.generate para um texto não alterado encontra o asset com o mesmo scriptHash e retorna imediatamente, com custo zero; uma correção de vírgula muda o hash e força regeração, o que é intencional, porque não existe "mudança pequena demais" que justifique áudio dessincronizado do texto; trocar TTS_VOICE_ID gera um novo asset mantendo o anterior, o que permite comparar vozes lado a lado no painel.

Editor salva alteração em title | bible_reference | bible_text | reflection_md | prayer
              |
   hook recalcula scriptHash com buildNarrationScript()
              |
     +--------+--------+
     |                 |
 hash igual       hash diferente
     |                 |
     v                 v
nada acontece   status é AUDIO_READY, PUBLISHED ou SENT?
(mudou só            |              |
 teaser ou          não            sim
 campo não           |              |
 narrado)            v              v
          status segue READY   1. audio_assets corrente -> invalidated_at = now
          (áudio nem           2. media_uploads correspondente -> superseded_at = now
           começou)            3. status -> AUDIO_PENDING
                               4. enfileira tts.generate
                               5. registra devotional_revisions (Seção 15)
                               6. se era SENT: NÃO reenvia nada. O áudio novo vale
                                  para o acervo e para reenvios manuais.

Regras registradas: alterar texto de devocional já enviado não dispara reenvio — corrigir um erro de digitação não justifica mandar tudo de novo para 3.000 pessoas às 14h, e o painel confirma isso com um diálogo que informa quantos assinantes já receberam. O teaser não é narrado, então editá-lo não invalida áudio: é por isso que o scriptHash cobre exclusivamente os cinco campos narrados. Asset invalidado não é apagado na hora — fica 30 dias no storage e o painel permite ouvir a versão anterior, o que salva o operador quando a "correção" foi um erro. Durante a regeração, o player do assinante mostra o áudio anterior com o rótulo discreto "versão anterior"; remover o áudio seria uma regressão visível para quem já pagou.

Regeração forçada. A ação "Gerar áudio novamente" no painel, para EDITOR, ADMIN e OWNER, existe para quando o texto está certo mas a narração saiu ruim. Enfileira tts.generate com force: true, ignorando o cache; a chave vira tts:{devotionalId}:{scriptHash}:r{n}, com n = número de assets existentes para aquele hash mais um. Limite: 5 regerações forçadas por devocional, depois disso a ação é bloqueada com "Limite de regerações atingido. Ajuste o texto ou fale com o administrador." O contador reinicia quando o scriptHash muda.

16.13 Fila tts.generate e verificações de qualidade #

A configuração geral das filas está na Seção 18.8; aqui ficam os parâmetros específicos e sua justificativa.

export interface TtsGenerateJobData {
  devotionalId: string;
  mode: 'full' | 'videoOnly';   // 'full' gera OGG + MP3; 'videoOnly' só monta o MP4
  force: boolean;               // ignora o cache por scriptHash
  voiceIdOverride?: string;     // usado na comparação de vozes pelo painel
  requestId: string;
}
export const ttsGenerateWorker = new Worker<TtsGenerateJobData>('tts.generate', processTtsGenerate, {
  connection, concurrency: 2, limiter: { max: 10, duration: 60_000 },
  lockDuration: 300_000, lockRenewTime: 60_000,      // 5 min: o job dura minutos legitimamente
});
Parâmetro Valor Justificativa
concurrency 2 ffmpeg com -compression_level 10 é intensivo em CPU; dois jobs cabem no VPS e o volume é de 1 a 3 devocionais/dia
limiter 10/min Teto contra loop de regeração que estouraria a cota do provedor
attempts / backoff 3 / exponencial 30 s 30 s, 60 s, 120 s — falhas de TTS são transitórias de minutos
lockDuration 300 s Cobre síntese com chunking + duas passagens de ffmpeg + MP4
removeOnComplete / removeOnFail 7 dias / 30 dias Falhas são raras e valiosas para investigação

Trabalho morto. Não existe fila de dead-letter separada. Um job que esgota as tentativas fica no estado failed da própria fila tts.generate e um listener de failed que, quando attemptsMade >= opts.attempts, insere uma linha em job_runs (Seção 6) com status = 'DEAD', job_name, queue, bull_job_id, attempt, payload, error_code, error e finished_at. job_runs é a dead-letter do sistema (Seção 18.8). O devocional permanece em AUDIO_PENDING e o audio_assets da tentativa recebe status = 'FAILED' com qc_failed_at preenchido quando a causa foi verificação de qualidade. Alertas desta fila: tts.job_dead (alta), tts.failover_engaged (média), tts.both_providers_down (crítica, plantonista), tts.audio_not_ready_by_deadline (crítica, Seção 18.4), tts.quality_check_failed (alta), tts.cost_spike acima de 150% da média de 3 meses (média).

Verificações automáticas, executadas sobre o OGG final antes de qualquer upload. Todas usam ffprobe ou filtros de análise; nenhuma depende de julgamento humano.

# Verificação Método Limite Reprovação
1 Duração mínima ffprobe format=duration ≥ 150 s Bloqueia — indica roteiro truncado
2 Duração máxima ffprobe format=duration ≤ 480 s Bloqueia (Seção 16.8)
3 Coerência com a estimativa |real − estimatedSeconds| / estimado ≤ 25% Bloqueia — provedor pulou ou repetiu trecho
4 Codec e canais ffprobe streams opus, 1 canal, 48 kHz Bloqueia — erro de imagem/ambiente
5 Tamanho ffprobe format=size ≤ 12 MB Re-encode a 24 kbps; > 16 MB bloqueia
6 Silêncio total astatsOverall.RMS_level > −60 dB Bloqueia — arquivo mudo
7 Silêncio no final silencedetect=n=-45dB:d=3 na cauda nenhum ≥ 3 s nos últimos 5 s Bloqueia — assinatura de resposta truncada
8 Silêncio no começo silencedetect nos primeiros 5 s nenhum ≥ 2 s Bloqueia
9 Clipping astatsPeak_count < 0,01% das amostras Aviso
10 Loudness final 2ª passagem loudnorm −16 ± 1,5 LUFS Aviso
11 Silêncio interno silencedetect=n=-45dB:d=5 nenhum ≥ 5 s Bloqueia — trecho não sintetizado
# Verificações 6, 9 e 10 em uma passagem.
ffmpeg -hide_banner -nostats -i narration.ogg -af "astats=metadata=1:reset=0,ametadata=print:file=-" \
  -f null - 2>/dev/null | grep -E "Overall|RMS_level|Peak_count"
# Verificações 7, 8 e 11: silêncio com limiar e duração mínima.
ffmpeg -hide_banner -nostats -i narration.ogg -af "silencedetect=noise=-45dB:d=3" -f null - 2>&1 | grep silence_

Reprovação bloqueante: o asset não vira corrente e recebe qc_failed_at com o código; o job tenta uma vez com o fallback, mesmo que o primário tenha respondido 200 — isso cobre o caso de áudio truncado devolvido sem sinalizar erro, que é o modo de falha mais comum e mais perigoso de TTS; se o fallback também reprovar, audio_assets.status = 'FAILED', o devocional segue em AUDIO_PENDING e sai o alerta tts.quality_check_failed com a lista de verificações reprovadas.

Revisão humana opcional. A flag audio_human_review em feature_flags controla se AUDIO_READY → PUBLISHED é automático. Desligada (padrão), o pipeline publica sozinho após as verificações — é o que permite operar com dias de antecedência. Ligada, o devocional para na fila "Aguardando aprovação de áudio" do painel; o EDITOR ouve e aprova ou reprova, e reprovar exige motivo registrado e devolve para AUDIO_PENDING com regeração forçada. Decisão: ligar a flag no primeiro mês e desligá-la após 30 devocionais consecutivos sem reprovação humana — critério objetivo, registrado aqui para não virar discussão.

Acessibilidade. O texto completo é sempre enviado antes do áudio (Seção 17.3), então nenhuma informação existe apenas em áudio e assinantes surdos recebem o conteúdo integral; o acervo do painel (Seção 14) exibe o texto ao lado do player; a nota de voz do WhatsApp expõe controle de velocidade nativo (1×, 1,5×, 2×), e é o formato OGG/Opus que habilita isso, o que reforça a escolha da Seção 16.7; e o volume normalizado a −16 LUFS elimina o reajuste diário de volume, barreira real para quem opera o aparelho por toque.

16.14 Estimativa de custo mensal do pipeline #

Premissas: 30 devocionais/mês, 4.030 caracteres faturáveis cada, plano Creator, 10% de regerações, MP4 em 40% dos dias, câmbio de referência R$ 5,40/US$ registrado em settings sob billing.usd_brl_reference_rate e usado apenas para exibição.

Item Cálculo US$/mês R$/mês
TTS — devocionais 120.900 caracteres × US$ 0,00022 26,60 143,64
TTS — regerações 12.090 caracteres × US$ 0,00022 2,66 14,36
TTS — fallback (2% dos jobs) 8.060 caracteres × US$ 0,000016 0,13 0,70
Armazenamento permanente (média ano 1: 1,1 GB) + temporário (0,3 GB) 1,4 × US$ 0,023 0,04 0,20
Requisições S3 ~4.000 0,02 0,11
Saída S3 (30% dos PAID ouvem pelo painel: 900 × 4,8 MB) 4,3 GB × US$ 0,09 0,39 2,11
Upload à Meta 30 × 1,4 MB + 12 × 3 MB = 78 MB 0,00 0,00
CPU do worker (~40 min/mês) parcela do VPS 0,00 0,00
Total do pipeline de mídia 29,84 161,12

Leitura: R$ 161/mês, com 300 ou com 30.000 assinantes. Com 3.000 pagantes a R$ 19,90 (Seção 2.6), o pipeline consome 0,27% da receita — consequência direta do princípio 1 da Seção 16.1. Custo por assinante pago: R$ 161 / 3.000 = R$ 0,054/mês, valor que alimenta a métrica custo_por_assinante_mes em daily_metrics (Seção 21), gravada no formato longo como metric_key = 'custo_por_assinante_mes' com dimension = ''.

Comparação registrada para justificar o provedor primário: operar só com o fallback custaria US$ 2,13/mês em vez de US$ 29,39/mês. A diferença — cerca de R$ 147/mês — compra qualidade de voz no principal artefato do plano pago. A decisão é usar o provedor mais caro como primário.

16.15 Casos de borda #

# Situação Comportamento definido
1 Dois devocionais aprovados para a mesma data Índice único em devotionals.scheduled_for (Seção 6); o segundo salvamento retorna 409 com code: DEVOTIONAL_DATE_TAKEN
2 tts.generate concorrente para o mesmo devocional jobId determinístico impede; se o dedupe expirar, o segundo encontra o mesmo scriptHash e retorna sem sintetizar
3 Provedor devolve 200 com 3 segundos de áudio Verificações 1 e 3 reprovam → tenta fallback → se reprovar, audio_assets.status = 'FAILED' e o devocional segue em AUDIO_PENDING
4 Provedor devolve WAV em vez de MP3 ffprobe identifica; ffmpeg aceita a entrada; sem efeito prático
5 ffmpeg do container sem libopus Verificação 4 → MEDIA_TRANSCODE_INVALID, alerta crítico, não retentável: é defeito de imagem
6 Disco cheio no worker durante a transcodificação ffmpeg sai ≠ 0, job é retentado; alerta worker.disk_pressure em < 15% livre (Seção 23)
7 Worker morto no meio do job Lock expira em 5 min e o job volta à fila; temporários órfãos com mais de 6 h são removidos por maintenance.cleanup (Seção 18.8)
8 Upload à Meta OK, gravação em media_uploads falha O media_id é perdido e um novo upload ocorre na próxima execução. Custo: um upload duplicado. Aceito — transação distribuída não se justifica aqui
9 media_id expira entre 05:40 e 06:00 Impossível pela margem de 48 h (Seção 16.11). Se ocorrer, o erro 100 marca o item como falho e o assinante entra no lote seguinte (Seção 18.10)
10 Revisão humana ligada e ninguém aprovou Às 05:00 o motor detecta status != AUDIO_READY e aciona a reserva da Seção 18.4
11 Texto editado às 05:55 com áudio pronto Invalidação leva a AUDIO_PENDING; às 06:00 o motor usa a reserva. O painel avisa antes de salvar: "Faltam X minutos para o envio. Salvar agora vai adiar este devocional para outro dia."
12 Caractere de controle vindo de colagem Removido na limpeza (Seção 16.3.2), registrado em warnings
13 reflection_md contendo apenas uma imagem NARRATION_BLOCK_EMPTY; painel mostra o motivo ao editor
14 Dois provedores fora do ar simultaneamente tts.both_providers_down, crítica. O operador tem até 05:00 do dia do envio; se não agir, a reserva da Seção 18.4 cobre
15 Regeração forçada durante um envio em andamento O lote já planejado carrega o whatsapp_media_id antigo e segue com ele; o áudio novo vale do próximo lote. Todo assinante daquele lote recebe o mesmo arquivo
16 Assinante abre o painel com asset invalidado Player mostra a versão anterior rotulada (Seção 16.12)
17 Bucket indisponível na hora do upload à Meta media.upload roda logo após a transcodificação, com o buffer ainda em memória; só relê do storage em retentativa, o que reduz a janela de exposição
18 MP4 pedido para devocional sem áudio corrente (audio_assets.status = 'FAILED') O job videoOnly valida a precondição e falha com MEDIA_SOURCE_NOT_READY, sem consumir CPU

17. Integração WhatsApp Business Cloud API #

17.1 Escopo e princípios #

Esta seção é a dona de tudo que toca a API do WhatsApp: conceitos da plataforma, configuração da conta, definição e submissão de templates, payloads de envio, webhooks de entrada, códigos de erro, qualidade do número, rate limiting e custo. O motor que decide quando enviar está na Seção 18; os textos estão na Seção 19.

Provedor: Meta WhatsApp Business Cloud API direta, sem BSP. A camada WhatsAppProvider (Seção 17.13) permite trocar por um BSP sem tocar no motor de envio. Base de API: https://graph.facebook.com/v26.0, com a versão declarada na Seção 4 e injetada por WHATSAPP_GRAPH_VERSION — nenhum caminho de código embute a versão literalmente.

Três princípios: a plataforma dita o desenho — as restrições da Seção 17.2 não são contornáveis, e todo o produto foi desenhado em volta delas; webhook responde 200 e enfileira, nunca processa em linha, porque a Meta desativa a assinatura de endpoints que falham repetidamente; todo envio é registrado em message_logs antes de sair, de modo que nenhuma mensagem existe sem rastro.

17.2 Conceitos e restrições reais da plataforma #

Janela de atendimento de 24 horas. Qualquer mensagem enviada pelo assinante abre uma janela de 24 horas contada daquela mensagem. Dentro dela, o negócio envia mensagens free-form de qualquer tipo — texto longo, áudio, vídeo, imagem, interativa — sem template e sem custo por mensagem. Fora dela, a única forma de iniciar contato é um template aprovado; texto livre retorna erro 131047. A janela é renovada, não somada: cada nova mensagem do assinante zera o contador. O sistema persiste isso em subscribers.service_window_expires_at (Seção 6), atualizado no processamento de todo webhook de mensagem recebida.

Exemplo: assinante toca no botão às 06:04 de terça, logo service_window_expires_at = 06:04 de quarta. Na quarta às 06:00 a janela ainda está aberta por 4 minutos, e o sistema usa o atalho da etapa 3 (Seção 17.3) — envia o pacote completo direto, sem template e sem custo. Se ele não interagir na quarta, às 06:00 de quinta a janela já expirou e o envio volta a usar template.

Categorias de template e custo.

Categoria Uso permitido Custo/mensagem no Brasil (referência) Onde usamos
UTILITY Confirmação e atualização de conta ou assinatura solicitada pelo usuário US$ 0,0080 Devocional diário, cobrança, confirmações, encerramento
AUTHENTICATION Somente códigos de verificação de uso único US$ 0,0315 Login por OTP
MARKETING Promoção, oferta, reengajamento, convite US$ 0,0625 Reativação de ex-assinante
SERVICE Não é categoria de template: é a conversa iniciada pelo assinante US$ 0,00 Todo o pacote completo entregue dentro da janela

Os valores ficam em settings sob whatsapp.price_per_message_micros por categoria — inteiro em milionésimos de BRL, conforme a regra de unidade da Seção 17.12 —, porque a Meta ajusta a tabela periodicamente e o dashboard (Seção 21) não pode depender de constante em código. A diferença entre UTILITY e MARKETING é de 7,8 vezes, e é a razão econômica central do desenho da Seção 17.3: submeter o devocional como UTILITY e entregar tudo o que for possível dentro da janela, onde o custo é zero. Se a Meta reclassificar devocional_diario_v1 para MARKETING, o sistema continua funcionando sem alteração de código — muda apenas o custo: a categoria efetiva retornada pela API é gravada em whatsapp_templates.effective_category na sincronização (Seção 17.6), o dashboard recalcula e o alerta whatsapp.template_recategorized é emitido.

As três restrições de formato que definem o desenho.

(a) O cabeçalho de template não aceita áudio. Tipos aceitos: TEXT, IMAGE, DOCUMENT, VIDEO, LOCATION. Não existe AUDIO. Consequência inescapável: o áudio nunca pode ser a primeira mensagem do dia, porque a primeira mensagem do dia é sempre um template (a janela está fechada às 06:00). O áudio só existe dentro da janela, ou empacotado dentro de um MP4 no header VIDEO (Seção 16.9).

(b) Parâmetros de template não podem conter quebra de linha, tabulação, nem 4 ou mais espaços consecutivos. Isso vale para o valor do parâmetro no envio, não para o texto fixo da definição — o corpo definido pode ter parágrafos à vontade. Violar retorna 132000 ou 132012. Consequência: um devocional completo, com parágrafos, não cabe em um parâmetro de template. Daí a existência do teaser, sanitizado explicitamente (Seção 17.7).

(c) Limites de caracteres.

Elemento Limite Consequência prática
Corpo de template (BODY) 1.024 O devocional completo (2.000 a 4.000 caracteres) não cabe
Cabeçalho de texto / rodapé 60 cada Cabeçalho é rótulo, não conteúdo; rodapé cabe o aviso de opt-out
Texto de botão 25 "Ler e ouvir agora" tem 17
Mensagem de texto livre (type: text) 4.096 Cabe o devocional inteiro com parágrafos
Legenda de imagem/vídeo/documento 1.024 Usada no MP4 do fallback
Parâmetros por template 10 Usamos no máximo 3
Mídia de áudio/vídeo 16 MB Ver Seção 16.8

Mensagens de áudio não aceitam legenda. É por isso que o pacote completo tem uma mensagem de fechamento separada (Seção 17.3, etapa 2): não há como anexar texto ao áudio.

17.3 O desenho "Template + Janela" #

Quatro etapas travadas. Cada uma existe por uma restrição concreta da Seção 17.2.

Etapa 1 — Convite por template (janela fechada).

06:00  SISTEMA                                              ASSINANTE
  |-- POST /{PHONE_NUMBER_ID}/messages ------------------------>|
  |   type: template, name: devocional_diario_v1                |
  |   HEADER TEXT "Devocional de hoje"                          |
  |   BODY {{1}} = título   {{2}} = teaser (<=300, 1 linha)     |
  |   FOOTER "Responda SAIR para cancelar"                      |
  |   BUTTONS [Ler e ouvir agora] [Depois]                      |
  |<-- 200 { messages:[{ id: "wamid.XXX" }] } -------------------|
  |   message_logs: status=sent, category=UTILITY, custo 0,0080  |
  |<== webhook status: delivered / read ========================|

Por que assim. Às 06:00 a janela está fechada para praticamente toda a base, então template é a única opção legal; e como o corpo tem 1.024 caracteres e o parâmetro não aceita quebra de linha, o que cabe ali é um convite com título e teaser. O que aconteceria de outro jeito: enviar type: text com o devocional completo falharia com 131047 para 100% da base, e colocar o devocional no parâmetro {{2}} falharia com 132000/132012 por causa das quebras de linha — e, mesmo sanitizado, estouraria o limite de 1.024 do corpo.

Etapa 2 — Entrega completa free-form (janela aberta pela interação).

06:04  ASSINANTE                                            SISTEMA
  |-- toca em [Ler e ouvir agora] ----------------------------->|
  |   webhook: messages[0].type = "button"                      |
  |   button.payload = OPEN_DEVOTIONAL:{devotionalId}           |
  |                    200 OK em < 200 ms, enfileira            |
  |                    service_window_expires_at = +24 h        |
  |<-- (1) type: text ------------------------------------------|
  |        devocional completo, até 4.096 caracteres, com        |
  |        parágrafos e *negrito* moderado (Seção 19)           |
  |<-- (2) type: audio  [somente tier PAID] --------------------|
  |        { id: whatsapp_media_id } -> nota de voz OGG/Opus,    |
  |        com player e forma de onda (Seção 16.7)              |
  |<-- (3) type: text ------------------------------------------|
  |        fechamento curto com a data do próximo envio         |
  |   custo das três: US$ 0,00 (dentro da janela de atendimento) |

Ordem obrigatória: texto antes do áudio, por duas razões. Acessibilidade: quem não ouve recebe tudo mesmo assim (Seção 16.13). Uso real: a maioria abre às 06:04 no transporte ou na cozinha e lê primeiro, deixando o áudio para o momento seguinte. Latência alvo entre a interação e a primeira mensagem: p95 < 5 segundos (Seção 18.6). Por que assim: é a única janela em que áudio e texto longo são permitidos, e é gratuita — todo o valor do produto é entregue aqui.

Etapa 3 — Atalho quando a janela já está aberta.

05:40  send.plan avalia cada assinante
         service_window_expires_at > (06:00 de hoje) ?
                    |                          |
                   sim                        não
                    v                          v
        route = WINDOW_DIRECT          route = TEMPLATE_INVITE
06:00               v                          v
        (1) texto (2) áudio (3) fechamento     envia devocional_diario_v1
        custo US$ 0,00                         e espera a interação
        zero atrito, zero cliques              custo US$ 0,0080, um clique de atrito

Por que existe. É o caminho preferencial: reduz custo a zero e elimina o atrito de um toque para o assinante engajado. Quem responde ao devocional todo dia mantém a janela permanentemente aberta e nunca mais recebe template. Efeito colateral desejado: o produto ganha um incentivo econômico direto para conversar com o assinante, que é exatamente o comportamento que queremos. Com 40% da base engajada assim, o custo mensal de mensagens cai 40% (Seção 17.12).

Etapa 4 — Fallback de vídeo no 4º dia sem interação.

Dia 1..3  template ENTREGUE, sem interação até 23:55 -> consecutive_window_misses = 1, 2, 3
Dia 4     assinante PAID com consecutive_window_misses >= 3
            v send.plan marca route = TEMPLATE_VIDEO e garante MP4 pronto (Seção 16.9)
              (se não estiver pronto até 05:57, cai para TEMPLATE_INVITE)
06:00       v envia devocional_diario_video_v1
              HEADER VIDEO { id: whatsapp_media_id do MP4 }
              BODY {{1}} título, {{2}} teaser | FOOTER e BUTTONS iguais ao de texto
            v o assinante recebe o ÁUDIO (dentro do MP4) sem precisar interagir
              interagiu?  -> zera consecutive_window_misses, volta ao caminho normal
              não?        -> mantém no caminho de vídeo por até 7 dias, depois volta a
                             TEMPLATE_INVITE e entra na régua de reengajamento (Seção 20)

Por que existe. Um assinante PAID que nunca abre a janela nunca receberia áudio — pagaria por um benefício que não recebe. Header VIDEO é a única forma de entregar áudio sem interação, pela restrição (a) da Seção 17.2. Por que é fallback e não padrão: o MP4 pesa cerca de 3 MB contra 1,2 MB do OGG, consome dados móveis sem que o assinante tenha pedido, e a experiência de nota de voz (velocidade 1,5×, forma de onda, retomada) é superior à de um vídeo estático — usar vídeo como padrão pioraria o produto para os 40% engajados a fim de resolver o problema dos que não interagem.

Regra de contagem: o contador é subscribers.consecutive_window_misses (Seção 6) — este é o único nome; não existe no_interaction_days. Ele incrementa em 1 uma vez por dia, no fechamento das 23:55 (Seção 18.2), apenas para o assinante que teve naquele dia ao menos uma mensagem com estado delivered ou read e nenhuma mensagem de entrada. Dias cuja única saída foi adiada (131049), suprimida (131048) ou falha não incrementam, porque o assinante não teve oportunidade de interagir — incrementá-los empurraria para a rota de vídeo exatamente quem a plataforma já está suprimindo, e o MP4 seria suprimido do mesmo jeito, custando mais para gerar e ocupando o caminho crítico das 06:00. Esses dias alimentam um contador separado, subscribers.undelivered_days, que é o que aciona a regra de entrega mínima do plano pago (Seção 13). O contador zera no instante em que qualquer mensagem de entrada é processada, inclusive SAIR. O limiar de troca de rota é send.window_miss_threshold em settings (padrão 3), ou seja, o quarto dia consecutivo sem interação. Assinante FREE não entra neste caminho, porque não recebe áudio de todo modo (Seção 7).

17.4 Configuração da conta #

Item Valor / procedimento Onde vive
WABA Criada no Business Manager, vinculada à empresa verificada WHATSAPP_BUSINESS_ACCOUNT_ID
App da Meta Tipo Business, produto WhatsApp adicionado META_APP_ID, META_APP_SECRET
Número Dedicado, nunca usado no app WhatsApp comum. Portar um número em uso exige apagá-lo do app antes WHATSAPP_PHONE_NUMBER_ID
Verificação de negócio Obrigatória para sair do limite de teste: CNPJ, comprovante de endereço, site ativo. Prazo típico 2 a 10 dias úteis
Nome de exibição "Palavra Diária". Precisa refletir a marca real; rejeições comuns são nome genérico ou de pessoa física
Foto e perfil comercial 640×640; definidos via POST /{PHONE_NUMBER_ID}/whatsapp_business_profile. Aumentam confiança e ajudam na avaliação de qualidade
PIN de verificação em duas etapas Obrigatório no registro; guardado no cofre de segredos, nunca em código WHATSAPP_2FA_PIN
Token Usuário de sistema, longa duração (Seção 17.7) WHATSAPP_SYSTEM_USER_TOKEN

Tiers de mensagens. O tier limita quantos clientes únicos o número pode iniciar conversa em 24 horas móveis: Teste 250 (antes da verificação); 1K (após verificação e registro); 10K, 100K e Ilimitado, cada um alcançado ao atingir 50% do tier atual em 24 h com quality_rating ≥ MEDIUM, em janela de 7 dias.

O tier sobe automaticamente e a Meta notifica por webhook account_update com event: "PHONE_NUMBER_TIER_UPDATE". O sistema lê o tier em GET /{PHONE_NUMBER_ID}?fields=messaging_limit_tier, quality_rating, grava em settings sob whatsapp.messaging_tier e alerta quando o lote planejado excede 80% do tier (Seção 18.12) — é esse alerta que evita descobrir o teto no meio do disparo. Planejamento concreto: 10.000 assinantes com envio FREE no domingo significam 10.000 clientes únicos naquele dia, exatamente no teto do tier 10K, o que torna o tier 100K pré-requisito operacional antes de passar de 8.000 assinantes.

17.5 Templates do sistema #

São oito templates, todos em pt_BR — este número é fixo e vale em todo o documento. Nomes com sufixo de versão: alterar o corpo de um template aprovado exige nova submissão, então uma mudança de texto cria _v2 e o _v1 continua servindo até a aprovação. whatsapp_templates (Seção 6) guarda nome, categoria submetida, categoria efetiva, status, template_id e o corpo submetido.

Esta subseção é a dona da definição dos oito templates. O seed obrigatório do banco (Seção 6.32.3) cria os oito, com o bloco components idêntico, caractere por caractere, aos JSON abaixo; nenhuma outra seção define componente de template. param_count conta apenas os parâmetros do corpo: parâmetros de botão URL são numerados em sequência própria e não entram na contagem.

// 1) devocional_diario_v1 — convite diário. Hidratado no exemplo: 261 caracteres (limite 1.024).
// {{1}} até 120 caracteres (título); {{2}} até 300 (teaser). Pior caso hidratado: 555.
{ "name": "devocional_diario_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "TEXT", "text": "Devocional de hoje" },
  { "type": "BODY",
    "text": "Bom dia. O devocional de hoje já está pronto para você.\n\n*{{1}}*\n\n{{2}}\n\nToque no botão abaixo para receber o texto completo e o áudio.",
    "example": { "body_text": [["O peso da espera", "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que fazer no intervalo entre o pedido e a resposta."]] } },
  { "type": "FOOTER", "text": "Responda SAIR para cancelar" },
  { "type": "BUTTONS", "buttons": [
    { "type": "QUICK_REPLY", "text": "Ler e ouvir agora" }, { "type": "QUICK_REPLY", "text": "Depois" } ] } ] }
// 2) devocional_diario_video_v1 — convite com áudio embutido no MP4 do header.
// O header_handle vem da API de upload retomável (POST /{APP_ID}/uploads), diferente da Media API
// usada no envio (Seção 16.11): serve apenas para a Meta revisar o template.
{ "name": "devocional_diario_video_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "VIDEO", "example": { "header_handle": ["4::aW1hZ2UvcG5n:ARZ..."] } },
  { "type": "BODY",
    "text": "Bom dia. O devocional de hoje está no vídeo acima, com o áudio completo.\n\n*{{1}}*\n\n{{2}}\n\nPara receber o texto completo, toque no botão.",
    "example": { "body_text": [["O peso da espera", "Quando Deus demora, Ele não esqueceu."]] } },
  { "type": "FOOTER", "text": "Responda SAIR para cancelar" },
  { "type": "BUTTONS", "buttons": [
    { "type": "QUICK_REPLY", "text": "Quero o texto" }, { "type": "QUICK_REPLY", "text": "Depois" } ] } ] }
// 3) codigo_acesso_v1 — OTP de login. Corpo fixo definido e traduzido pela Meta:
// "{{1}} é seu código de verificação. Por segurança, não compartilhe este código."
// message_send_ttl_seconds faz a Meta descartar a mensagem em vez de entregar código já expirado.
{ "name": "codigo_acesso_v1", "language": "pt_BR", "category": "AUTHENTICATION",
  "message_send_ttl_seconds": 600, "components": [
  { "type": "BODY", "add_security_recommendation": true },
  { "type": "FOOTER", "code_expiration_minutes": 10 },
  { "type": "BUTTONS", "buttons": [{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copiar código" }] } ] }
// 4) boas_vindas_v1 — confirmação de opt-in.
{ "name": "boas_vindas_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "TEXT", "text": "Bem-vindo à Palavra Diária" },
  { "type": "BODY",
    "text": "Olá, {{1}}. Seu cadastro foi criado.\n\nPara começar a receber o devocional, confirme abaixo. Só depois da sua confirmação enviaremos qualquer conteúdo.",
    "example": { "body_text": [["Ana"]] } },
  { "type": "FOOTER", "text": "Você pode cancelar quando quiser" },
  { "type": "BUTTONS", "buttons": [
    { "type": "QUICK_REPLY", "text": "Sim, quero receber" }, { "type": "QUICK_REPLY", "text": "Agora não" } ] } ] }
// 5) lembrete_pagamento_v1 — serve às três réguas PIX (D-3, D-1, D0) e ao aviso de cartão vencendo.
// Um único template evita três aprovações separadas e três pontos de manutenção.
{ "name": "lembrete_pagamento_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "TEXT", "text": "Sua assinatura" },
  { "type": "BODY",
    "text": "Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.\n\nValor: {{3}}\n\nPague pelo link abaixo para manter o áudio diário ativo.",
    "example": { "body_text": [["Ana", "11/09/2026", "R$ 19,90"]] } },
  { "type": "FOOTER", "text": "Pagamento processado pela Asaas" },
  { "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "Pagar agora",
    "url": "https://app.palavradiaria.com.br/pagar/{{1}}",
    "example": ["https://app.palavradiaria.com.br/pagar/pay_01K5T8QW3M"] } ] } ] }
// 6) pagamento_confirmado_v1
{ "name": "pagamento_confirmado_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "TEXT", "text": "Pagamento confirmado" },
  { "type": "BODY",
    "text": "Pronto, {{1}}. Recebemos seu pagamento de {{2}}.\n\nA partir de amanhã, às 6h, você recebe o devocional em texto e áudio todos os dias. Sua próxima cobrança é em {{3}}.",
    "example": { "body_text": [["Ana", "R$ 19,90", "08/10/2026"]] } },
  { "type": "FOOTER", "text": "Obrigado por assinar" },
  { "type": "BUTTONS", "buttons": [{ "type": "QUICK_REPLY", "text": "Ver minha conta" }] } ] }
// 7) acesso_encerrado_v1 — implementa a regra sem carência da Seção 13.3: revogação no mesmo dia,
// dita sem rodeios. Não há promessa de prazo extra, porque não existe prazo extra.
{ "name": "acesso_encerrado_v1", "language": "pt_BR", "category": "UTILITY", "components": [
  { "type": "HEADER", "format": "TEXT", "text": "Sua assinatura foi encerrada" },
  { "type": "BODY",
    "text": "Olá, {{1}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao áudio diário foi encerrado hoje.\n\nVocê continua recebendo o devocional em texto aos domingos, sem custo. Para voltar a receber todos os dias com áudio, reative abaixo.",
    "example": { "body_text": [["Ana"]] } },
  { "type": "FOOTER", "text": "Responda SAIR para não receber mais nada" },
  { "type": "BUTTONS", "buttons": [ { "type": "URL", "text": "Reativar assinatura",
    "url": "https://app.palavradiaria.com.br/reativar",
    "example": ["https://app.palavradiaria.com.br/reativar"] } ] } ] }
// 8) reativacao_v1 — ÚNICO template MARKETING do sistema. É o único sujeito ao erro 131050 e à faixa
// de custo 7,8x maior. Cadência máxima: 1 vez a cada 30 dias por assinante, no máximo 3 na vida (Seção 20).
{ "name": "reativacao_v1", "language": "pt_BR", "category": "MARKETING", "components": [
  { "type": "BODY",
    "text": "Olá, {{1}}. Faz {{2}} dias que você não recebe o devocional em áudio.\n\nSe quiser voltar, é só tocar abaixo. Sua conta e seu histórico continuam salvos.",
    "example": { "body_text": [["Ana", "30"]] } },
  { "type": "FOOTER", "text": "Responda SAIR para não receber mais" },
  { "type": "BUTTONS", "buttons": [
    { "type": "QUICK_REPLY", "text": "Quero voltar" }, { "type": "QUICK_REPLY", "text": "Não, obrigado" } ] } ] }

17.6 Submissão, aprovação e sincronização de status #

POST https://graph.facebook.com/v26.0/{WHATSAPP_BUSINESS_ACCOUNT_ID}/message_templates
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: application/json
{ "id": "1234567890", "status": "PENDING", "category": "UTILITY" }

A submissão é feita pelo comando ops templates:sync do CLI, que lê as definições versionadas no repositório e submete as que ainda não existem. Templates não são criados pelo painel administrativo: são artefatos de código, revisados em pull request, porque um erro de texto aprovado leva dias para corrigir. Prazos típicos: a maioria é avaliada em até 1 hora; casos em revisão manual levam até 24 horas, com 48 h como limite declarado. Templates AUTHENTICATION costumam ser mais rápidos por terem corpo padronizado.

Motivo comum de rejeição Como o nosso desenho evita
Parâmetro no começo ou no fim do corpo, sem texto ao redor Todos os nossos parâmetros são cercados por texto fixo
Parâmetros adjacentes ({{1}} {{2}}) Nunca usamos parâmetros adjacentes
example ausente ou incoerente Todo template da Seção 17.5 traz example realista
Conteúdo promocional submetido como UTILITY O devocional é entrega de assinatura pedida; o único conteúdo promocional é reativacao_v1, submetido como MARKETING
URL encurtada ou domínio não verificado Domínio próprio, sem encurtador
Erro de português, caixa alta, excesso de emoji Revisão editorial obrigatória (Seção 19.1)

Sincronização. Uma vez por dia, às 00:20, maintenance.cleanup varre GET /v26.0/{WABA_ID}/message_templates?fields=name,status,category,quality_score,rejected_reason&limit=100 e atualiza whatsapp_templates. Transições com ação: PENDING → APPROVED (template utilizável, alerta informativo); PENDING → REJECTED (alerta alto com rejected_reason; o motor pula qualquer rota que dependa dele); APPROVED → PAUSED/DISABLED (alerta crítico, ver 132015/132016 na Seção 17.9); categoria efetiva diferente da submetida (alerta whatsapp.template_recategorized, dashboard recalcula).

A Meta também envia o webhook message_template_status_update, processado em tempo real; a varredura diária existe como rede de segurança para webhook perdido — mesma lógica de reconciliação usada em pagamentos (Seção 12). Guarda final: antes de qualquer disparo, send.plan verifica que o template da rota está APPROVED; se não estiver, o lote não é criado com aquela rota e o alerta sai às 05:40, 20 minutos antes do envio, tempo suficiente para uma decisão humana.

17.7 Envio de mensagens #

POST https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages
Authorization: Bearer <WHATSAPP_SYSTEM_USER_TOKEN>
Content-Type: application/json
{ "messaging_product": "whatsapp",
  "contacts": [{ "input": "5511987654321", "wa_id": "5511987654321" }],
  "messages": [{ "id": "wamid.HBgNNTUxMTk4NzY1NDMyMRUCABEYEjc2M0E5RDk2...", "message_status": "accepted" }] }

messages[0].id (o wamid) é gravado em message_logs.wamid e é a chave que correlaciona o envio com os webhooks de status posteriores. contacts[0].wa_id é gravado em subscribers.wa_id apenas quando a coluna está vazia, no primeiro envio bem-sucedido, conforme a regra de normalização de telefone da Seção 11 — é ele que resolve a divergência do nono dígito. A coluna é cifrada e a busca por igualdade usa subscribers.wa_id_hmac (Seção 6).

Mudança de wa_id para um telefone já cadastrado nunca é silenciosa. Se o valor devolvido pela plataforma diferir do wa_id_hmac gravado, o sistema não sobrescreve: registra o evento, invalida todas as sessões daquele assinante e exige nova verificação por código antes de liberar qualquer dado pessoal (Seção 8). O motivo é concreto: operadoras brasileiras reciclam números, e entregar conta, CPF ou histórico a um aparelho apenas porque ele respondeu no WhatsApp transforma um evento comum e fora do nosso controle em vazamento de dado sensível de outra pessoa. Enquanto a reverificação não acontece, o envio continua pelo wa_id novo — parar de entregar seria pior —, mas nenhuma informação de conta é revelada em mensagem.

// (a) Template com dois parâmetros de corpo e payloads de botão.
// O payload do botão NÃO faz parte da definição do template: é definido no envio. Incluir o
// devotionalId permite saber a qual dia o assinante respondeu, mesmo que toque no botão de ontem.
{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "5511987654321",
  "type": "template", "template": { "name": "devocional_diario_v1", "language": { "code": "pt_BR" },
  "components": [
    { "type": "body", "parameters": [
      { "type": "text", "text": "O peso da espera" },
      { "type": "text", "text": "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que fazer no intervalo entre o pedido e a resposta." } ] },
    { "type": "button", "sub_type": "quick_reply", "index": "0",
      "parameters": [{ "type": "payload", "payload": "OPEN_DEVOTIONAL:dev_01K5T8QW3M9Z2X4R7B6C1D0E" }] },
    { "type": "button", "sub_type": "quick_reply", "index": "1",
      "parameters": [{ "type": "payload", "payload": "SNOOZE:dev_01K5T8QW3M9Z2X4R7B6C1D0E" }] } ] } }
// (b) Template com header de vídeo (fallback do 4º dia).
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "template",
  "template": { "name": "devocional_diario_video_v1", "language": { "code": "pt_BR" }, "components": [
    { "type": "header", "parameters": [{ "type": "video", "video": { "id": "1234567890123456" } }] },
    { "type": "body", "parameters": [
      { "type": "text", "text": "O peso da espera" },
      { "type": "text", "text": "Quando Deus demora, Ele não esqueceu." } ] } ] } }
// (c) Texto livre, dentro da janela. Até 4.096 caracteres, com parágrafos.
// preview_url: false é obrigatório — com true, uma referência textual a um site geraria
// um cartão de pré-visualização dentro do devocional, poluindo a leitura.
{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "5511987654321", "type": "text",
  "text": { "preview_url": false,
    "body": "*O peso da espera*\n_Salmos 27.13-14 (Almeida 1911)_\n\n\"Pensei: Se eu não cresse que veria os bens do Senhor na terra dos viventes! Espera no Senhor, anima-te, e ele fortalecerá o teu coração; espera, pois, no Senhor.\"\n\nDavi não escreveu isso depois da resposta. Escreveu no meio da espera...\n\n*Oração*\nSenhor, ensina-me a esperar sem desistir. Amém." } }
// (d) Áudio como nota de voz. Sem legenda: o tipo audio não aceita caption.
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "audio",
  "audio": { "id": "1234567890123456" } }

// (e) Vídeo free-form com legenda (usado apenas em reenvio manual dentro da janela).
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "video",
  "video": { "id": "1234567890123456", "caption": "Devocional de 8 de setembro — O peso da espera" } }
// (f) Resposta interativa com botões (menu de ajuda, dentro da janela).
// Limites: 3 botões, 20 caracteres por título, 1.024 no corpo. Acima de 3 opções usa-se
// interactive.type: "list", com até 10 itens.
{ "messaging_product": "whatsapp", "to": "5511987654321", "type": "interactive",
  "interactive": { "type": "button", "body": { "text": "Como posso ajudar?" },
    "action": { "buttons": [
      { "type": "reply", "reply": { "id": "MENU_RESEND", "title": "Reenviar hoje" } },
      { "type": "reply", "reply": { "id": "MENU_BILLING", "title": "Minha assinatura" } },
      { "type": "reply", "reply": { "id": "MENU_HUMAN", "title": "Falar com alguém" } } ] } } }

Token e rotação. O token é de usuário de sistema, criado no Business Manager com escopos whatsapp_business_messaging e whatsapp_business_management, atribuído ao app e à WABA; tokens de usuário comum expiram em 60 dias e não são aceitáveis em produção.

Regra Detalhe
Armazenamento Somente em variável de ambiente injetada pelo cofre. Nunca em banco, log, mensagem de erro ou resposta de API
Verificação Job diário às 03:50 chama GET /{PHONE_NUMBER_ID}?fields=id; erro 190 dispara alerta crítico
Rotação programada A cada 180 dias, pelo runbook (Seção 27): gerar o novo token, atualizar o segredo, reiniciar worker e web, validar no health check, revogar o antigo
Rotação de emergência Mesmo procedimento sem a espera; a revogação do antigo vem primeiro
Escopo Apenas os dois acima; business_management amplo não é concedido

A ordem do runbook (atualizar, validar, revogar) garante zero janela de indisponibilidade: mensagens em voo com o token antigo continuam válidas até a revogação.

Rotação do segredo do aplicativo, com janela dupla. META_APP_SECRET é a chave do HMAC que valida todo webhook de entrada, e trocá-la de uma vez rejeita webhooks legítimos entre a troca no painel do provedor e o reinício dos processos. Por isso a rotação usa duas chaves aceitas simultaneamente, META_APP_SECRET e META_APP_SECRET_PREVIOUS: a verificação de assinatura aceita qualquer uma das duas e registra qual validou; só depois de 100% das validações de uma hora usarem a nova é que a anterior é removida do ambiente. Sem essa janela, toda rotação produz exatamente o cenário da Seção 17.8 — canal de entrada morto com o envio aparentemente saudável.

Sanitização de parâmetro. Função obrigatória aplicada a todo valor de parâmetro antes do envio. Sem ela, um título com dois-pontos seguido de quebra de linha derruba o lote inteiro com 132000.

// packages/integrations/src/whatsapp/sanitize-param.ts
const MAX_TEMPLATE_PARAM = 300;
export function sanitizeTemplateParam(raw: string, maxLength = MAX_TEMPLATE_PARAM): string {
  const flat = raw
    .replace(/[\r\n\t  \v\f]+/g, ' ')     // proibidos: quebra de linha, tabulação
    .replace(/\s{2,}/g, ' ')                        // proibido: 4+ espaços consecutivos
    .replace(/[ -]/g, '')          // caracteres de controle
    .trim();
  if (flat.length <= maxLength) return flat;
  const cut = flat.slice(0, maxLength - 1);          // corta em limite de palavra, fecha com reticências
  const lastSpace = cut.lastIndexOf(' ');
  return `${(lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}

Exemplo: teaser de 340 caracteres com duas quebras de linha vira uma única linha de 300 caracteres terminada em , cortada na última palavra completa. O valor final é validado por Zod (z.string().min(1).max(300).regex(/^[^\n\r\t]*$/)) e violação lança TEMPLATE_PARAM_INVALID antes da chamada HTTP — falhar localmente é sempre melhor que gastar uma chamada para receber 132000.

17.8 Webhooks de entrada #

// apps/web/src/app/api/webhooks/whatsapp/route.ts
export async function GET(req: Request) {                       // verificação de assinatura do endpoint
  const url = new URL(req.url);
  const [mode, token, challenge] = ['hub.mode', 'hub.verify_token', 'hub.challenge']
    .map((k) => url.searchParams.get(k));
  // O desafio da plataforma é sempre numérico. Validar o formato antes de ecoar impede
  // que o endpoint sirva de refletor caso o token de verificação vaze.
  if (mode === 'subscribe' && challenge && /^[0-9]{1,32}$/.test(challenge)
      && safeEqual(token ?? '', env.WHATSAPP_VERIFY_TOKEN)) {
    return new Response(challenge, { status: 200, headers: { 'content-type': 'text/plain' } });
  }
  return new Response('Forbidden', { status: 403 });
}

export async function POST(req: Request) {
  const raw = await req.text();                    // corpo CRU: o HMAC é sobre os bytes exatos
  if (raw.length > 512 * 1024) {                   // único caminho de 4xx depois do remetente autenticado
    return new Response(null, { status: 413 });
  }
  const signature = req.headers.get('x-hub-signature-256') ?? '';
  if (!verifyMetaSignature(raw, signature)) {
    logger.warn({ signaturePresent: Boolean(signature) }, 'whatsapp.webhook_invalid_signature');
    return new Response('Forbidden', { status: 403 });
  }
  // Autenticado o remetente, NADA depois disto produz resposta não-2xx.
  try {
    await persistAndEnqueue(JSON.parse(raw));      // grava webhook_deliveries + enfileira whatsapp.inbound
  } catch (err) {
    const result = err instanceof SyntaxError ? 'PARSE_ERROR' : 'PERSIST_FAILED';
    await recordWebhookFailure({ source: 'META', rawBody: raw, processingResult: result, err });
  }
  return new Response('EVENT_RECEIVED', { status: 200 });
}

function verifyMetaSignature(rawBody: string, header: string): boolean {
  if (!header.startsWith('sha256=')) return false;
  const received = Buffer.from(header.slice('sha256='.length), 'hex');
  // Janela dupla de rotação do segredo do aplicativo (Seção 17.7).
  for (const secret of [env.META_APP_SECRET, env.META_APP_SECRET_PREVIOUS].filter(Boolean)) {
    const expected = createHmac('sha256', secret as string).update(rawBody, 'utf8').digest();
    if (expected.length === received.length && timingSafeEqual(expected, received)) return true;
  }
  return false;
}

Corpo inválido nunca produz resposta não-2xx. A validação da assinatura vem primeiro e é a única condição que autoriza recusar o evento. Autenticado o remetente, qualquer falha posterior — corpo que não é JSON, schema reprovado, evento desconhecido, indisponibilidade do banco ou da fila — responde 200 com corpo vazio e persiste o ocorrido: webhook_deliveries.processing_result recebe SCHEMA_REJECTED, PARSE_ERROR ou PERSIST_FAILED, o corpo cru é guardado para reprocessamento, e o alerta webhook_payload_rejected (severidade alta, plantonista) dispara acima de 3 ocorrências em 15 minutos. O motivo é operacional e caro: 4xx e 5xx levam a plataforma a retentar e, em seguida, a desativar a assinatura do webhook; perder o canal de entrada é catastroficamente pior do que engolir um corpo malformado. O único caminho de 4xx depois do remetente autenticado é 413, para corpo acima de 512 KB, medido antes da leitura.

A resposta do GET é o hub.challenge em texto puro, sem o envelope de API da Seção 7 — este endpoint é um contrato externo da Meta, e a exceção está registrada aqui. Regras duras do POST: o HMAC é calculado sobre o corpo cru, antes de qualquer parse, porque reserializar o JSON muda bytes e invalida a assinatura; a comparação usa timingSafeEqual, nunca ===; a verificação retorna false quando os comprimentos diferem, porque timingSafeEqual lança com buffers de tamanhos diferentes; e a resposta 200 sai em menos de 200 ms, com todo o trabalho real na fila whatsapp.inbound (Seção 18.8) — a Meta reenvia por até 7 dias e pode desativar a assinatura de um endpoint que falha persistentemente.

O motor de envio e o processador de entrada são caminhos independentes. O envio pode estar 100% saudável enquanto a entrada está morta, e nesse estado todos os painéis de envio mostram sucesso enquanto nenhum assinante pagante recebe áudio (porque a janela nunca abre) e nenhum pedido de SAIR é honrado. Três falhas plausíveis produzem exatamente esse sintoma: assinatura de webhook desativada pela plataforma, segredo do aplicativo dessincronizado (Seção 17.7) e recategorização de template com supressão em massa. É por isso que os alertas whatsapp.no_inbound, whatsapp.webhook_invalid_signature e whatsapp.window_direct_collapse da Seção 18.12 existem: sem eles, um fim de semana inteiro passa com "100% enviado" e zero entregas úteis.

// Mensagem de texto recebida
{ "object": "whatsapp_business_account", "entry": [{ "id": "<WABA_ID>", "changes": [{ "field": "messages",
  "value": { "messaging_product": "whatsapp",
    "metadata": { "display_phone_number": "551140028922", "phone_number_id": "<PHONE_NUMBER_ID>" },
    "contacts": [{ "profile": { "name": "Ana" }, "wa_id": "5511987654321" }],
    "messages": [{ "from": "5511987654321", "id": "wamid.HBgNNTUxMTk4NzY1NDMyMRUCABIYFDNBMEE...",
      "timestamp": "1789020240", "type": "text", "text": { "body": "AJUDA" } }] } }] }] }
// Toque em botão de template (quick reply)
"messages": [{ "from": "5511987654321", "id": "wamid.HBgN...", "timestamp": "1789020480", "type": "button",
  "context": { "from": "551140028922", "id": "wamid.<id do template enviado>" },
  "button": { "payload": "OPEN_DEVOTIONAL:dev_01K5T8QW3M9Z2X4R7B6C1D0E", "text": "Ler e ouvir agora" } }]

// Status de mensagem. O bloco pricing é a fonte de verdade de custo: billable e category alimentam
// message_logs.is_billable e message_logs.conversation_category, e o dashboard soma dali
// em vez de estimar (Seção 17.12).
"statuses": [{ "id": "wamid.HBgN...", "status": "delivered", "timestamp": "1789020255",
  "recipient_id": "5511987654321", "conversation": { "id": "b4a...", "origin": { "type": "utility" } },
  "pricing": { "billable": true, "pricing_model": "PMP", "category": "utility" } }]

// Falha de entrega
"statuses": [{ "id": "wamid.HBgN...", "status": "failed", "timestamp": "1789020260",
  "recipient_id": "5511987654321", "errors": [{ "code": 131049,
    "title": "Message failed to send because of an unknown error",
    "message": "This message was not delivered to maintain healthy ecosystem engagement." }] }]
field Conteúdo Ação
messagesmessages[] Mensagem do assinante (texto, botão, interativa, áudio, imagem, localização, reação) Abre a janela, grava em inbound_messages, aciona o roteador de palavras-chave (Seção 19.5) e a entrega pendente (Seção 18.6)
messagesstatuses[] sent, delivered, read, failed Atualiza message_logs
message_template_status_update Aprovação, rejeição, pausa, desativação Atualiza whatsapp_templates, alerta (Seção 17.6)
phone_number_quality_update Mudança de quality_rating ou de tier Atualiza settings, alerta (Seção 17.10)
account_update Restrição de conta, ban, mudança de tier Alerta crítico; pausa automática de envio se event = "ACCOUNT_RESTRICTION"
account_review_update Resultado de revisão da conta Alerta informativo
Qualquer outro Registrado em webhook_deliveries e ignorado, sem erro

Idempotência. webhook_deliveries tem índice único em (source, dedup_key), conforme a Seção 6.26.1, onde source é META e dedup_key é o wamid para mensagens e wamid + ':' + status para status. Não existem colunas provider nem external_id nessa tabela. Reentrega encontra a chave existente e é descartada com contagem em duplicate_count — isso importa, porque a Meta reenvia com frequência real quando há lentidão.

Estados de mensagem. accepted (resposta síncrona) → sentdeliveredread, com failed possível a partir de qualquer ponto. Cada estado tem coluna de timestamp própria em message_logs (sent_at, delivered_at, read_at, failed_at, mais error_code e error_title). Não existe coluna accepted_at: o instante da resposta síncrona é sent_at, gravado no momento em que a plataforma devolve o wamid, e é esse o marco usado em toda medição de latência (Seção 18.6). Os estados só avançam, nunca retrocedem: um webhook sent que chega depois de delivered — o que acontece — é ignorado por comparação de ordinal, mas seu timestamp é gravado se ainda estiver nulo. A transição usa UPDATE ... WHERE status_ordinal < :novo, o que torna a operação idempotente e imune a corrida entre workers. read não é garantido, porque uma fração relevante dos assinantes desativa confirmação de leitura: por isso taxa_entrega (Seção 21) conta delivered ou read, e nenhuma decisão de produto depende de read.

17.9 Códigos de erro da Meta — tabela completa de tratamento #

R = retentável. "Ação" é o que o worker faz; "Efeito" é o que muda no estado do assinante.

Código Significado R Ação do sistema Efeito no assinante
0/3/10 Erro de autenticação ou permissão do app Não Alerta crítico; pausa a fila de envio Nenhum envio até resolver
100 Parâmetro inválido (inclui media_id expirado ou inexistente) Não Marca item como falho; se for mídia, invalida media_uploads e reenfileira media.upload Recebe no lote do dia seguinte
190 Token expirado, inválido ou revogado Não Alerta crítico com plantonista; pausa send.dispatch Envio do dia suspenso até rotação (Seção 17.7)
368 Conta temporariamente bloqueada por violação de política Não Alerta crítico; pausa toda a fila; abre runbook de contingência (Seção 17.10) Sem envios até liberação
130429 Rate limit da API atingido Sim Backoff exponencial [1s, 4s, 16s, 60s]; o limitador do BullMQ já deveria impedir Atraso de minutos, sem perda
130472 Usuário em experimento; mensagem não entregue Não Registra e trata como 131049 Entra no lote do dia seguinte
131000 Erro genérico do servidor Sim Retry padrão (3 tentativas) Atraso
131005 Acesso negado ao recurso Não Alerta alto; falha o item Sem envio
131008 Parâmetro obrigatório ausente Não Falha o item; é defeito de código, não de dados Sem envio; alerta alto
131009 Valor de parâmetro inválido Não Falha o item; registra o payload sanitizado no log Sem envio
131016 Serviço temporariamente indisponível Sim Retry com backoff longo [30s, 120s, 300s] Atraso
131021 Destinatário igual ao remetente Não Falha o item; marca invalid_recipient Não recebe até correção manual
131026 Mensagem não entregável (número sem WhatsApp, aparelho incompatível, número inexistente) Não Incrementa subscribers.undeliverable_count; em 3 dias consecutivos grava subscribers.blocked_at e blocked_reason = 'META_131026', levando status a BLOCKED (Seção 11.8), e envia e-mail se houver e-mail verificado Para de receber; a cobrança não é alterada; painel mostra "Não conseguimos entregar no seu WhatsApp"
131031 Conta comercial bloqueada Não Alerta crítico; pausa geral Sem envios
131042 Problema de método de pagamento da conta comercial Não Alerta crítico financeiro; pausa geral Sem envios até regularizar
131045 Erro de certificado ou registro do número Não Alerta crítico; runbook de re-registro Sem envios
131047 Fora da janela de 24 h — free-form recusada Não Estado esperado, não falha de infraestrutura: reclassifica a rota para TEMPLATE_INVITE e reenfileira uma vez Recebe o template em vez do pacote
131048 Limite de spam para o par negócio/usuário Não Suspende envios para esse assinante por 24 h; não conta como falha Pula um dia
131049 Meta optou por não entregar para preservar o engajamento do ecossistema Não Esperado e recorrente no Brasil. Registra deferred; não conta como falha, não incrementa contador de erro, não alerta; assinante entra no lote do dia seguinte Pula o dia, sem qualquer efeito de estado
131050 Usuário desativou mensagens de marketing Não Marca subscribers.marketing_blocked_at; templates MARKETING deixam de ir para ele, UTILITY continua Continua recebendo o devocional; para de receber reativação
131051 Tipo de mensagem não suportado Não Falha o item; defeito de código Sem envio
131052 Erro ao baixar mídia Sim Retry; persistindo, invalida e reenvia a mídia Recebe só o texto naquele dia
131053 Erro ao subir mídia Sim Retry do media.upload (Seção 16.11) Recebe só o texto naquele dia
131056 Limite de taxa do par (negócio, usuário) Sim Backoff de 60 s para aquele destinatário Atraso de minutos
132000 Número de parâmetros não corresponde ao template Não Falha o lote inteiro para aquela rota e alerta crítico: é defeito de código que afeta todos Ninguém recebe; operador corrige e reprocessa
132001 Template não existe no nome/idioma informado Não Alerta crítico; verifica sincronização (Seção 17.6) Ninguém recebe naquela rota
132005 Texto hidratado do template excede o limite Não Falha o item; indica falha da sanitização (Seção 17.7); alerta alto Sem envio; corrigido por reprocessamento no mesmo dia
132007 Violação de política de formato do template Não Falha o item; alerta alto Sem envio
132012 Formato de parâmetro inválido (quebra de linha, 4+ espaços) Não Falha o item; alerta alto; a sanitização deveria ter impedido Sem envio
132015 Template pausado por baixa qualidade Não Alerta crítico; rota indisponível, troca para a alternativa quando existir (vídeo → texto). O template volta sozinho após a pausa Recebe pela rota alternativa ou pula o dia
132016 Template desativado por qualidade persistentemente baixa Não Alerta crítico; exige criar _v2 com novo texto e resubmeter Rota indisponível até novo template
132068 Fluxo (Flow) bloqueado Não Não usamos Flows; registra e ignora Nenhum
133000 Falha ao cancelar registro do número Não Runbook de operação Nenhum imediato
133004 Servidor temporariamente indisponível Sim Retry com backoff longo Atraso
133005 PIN de verificação em duas etapas incorreto Não Alerta crítico: PIN errado no cofre Bloqueia re-registro
133006 Número precisa ser verificado novamente Não Alerta crítico; runbook de re-verificação Envios param
133008 Muitas tentativas de PIN Não Aguardar o bloqueio expirar; runbook Envios param
133009 Tentativas de PIN rápidas demais Sim Aguardar e repetir uma vez Nenhum
133010 Número não registrado Não Alerta crítico; executar registro Envios param
133015 Número em processo de remoção Não Alerta crítico Envios param
133016 Número apagado recentemente; aguardar antes de re-registrar Não Alerta crítico; runbook informa o tempo de espera Envios param
135000 Erro genérico do usuário Não Falha o item, registra payload Entra no lote seguinte

Regra explícita sobre 131049. É o erro mais frequente do sistema no Brasil e não é um defeito: a Meta limita quantas mensagens de baixo engajamento um usuário recebe por dia, para preservar a experiência da plataforma. O tratamento é registrar message_logs.status = 'failed' com error_code = 131049 e deferred = true; não incrementar undeliverable_count; não contar em taxa_entrega como falha; não emitir alerta; e reincluir o assinante no lote do dia seguinte. O painel de operação mostra 131049 em uma coluna separada de "adiados", nunca junto com falhas reais. Um dia com 8% de 131049 é operação normal; alerta só acima de 25% do lote, o que indicaria degradação de qualidade do número.

17.10 Qualidade do número e contingência #

quality_rating assume GREEN, YELLOW, RED ou UNKNOWN, com base em bloqueios, denúncias e taxa de leitura nas últimas 24 horas. O sistema lê o valor no webhook phone_number_quality_update e, diariamente às 03:55, em GET /{PHONE_NUMBER_ID}?fields=quality_rating,messaging_limit_tier,status.

Estado Resposta automática do sistema Resposta humana
GREEN Nenhuma Nenhuma
YELLOW Suspende automaticamente reativacao_v1 (o único MARKETING); mantém o devocional; alerta médio Revisar teasers dos últimos 7 dias e taxa de opt-out; verificar se algum texto soou promocional
RED Alerta crítico; suspende todo MARKETING; reduz send.rate_per_second para 5; envia devocional apenas a quem interagiu nos últimos 14 dias Auditoria de conteúdo em 24 h; pausar aquisição paga; revisar o fluxo de opt-in
UNKNOWN Nenhuma (volume insuficiente) Nenhuma
FLAGGED Alerta crítico; o tier não sobe enquanto durar Runbook de qualidade
RESTRICTED Alerta crítico; para de planejar lotes acima do limite Runbook de restrição

Plano de contingência para número banido, o pior cenário operacional do produto (procedimento detalhado no runbook da Seção 27):

  1. Número reserva já provisionado. Um segundo número é registrado na mesma WABA desde o primeiro mês, com os mesmos templates aprovados. Custo: apenas o número. Manter templates aprovados em dois números é o que torna a migração uma troca de variável de ambiente.
  2. Chaveamento. WHATSAPP_PHONE_NUMBER_ID aponta para o reserva; worker e web reiniciam. Tempo alvo: menos de 15 minutos.
  3. Consequência aceita e registrada: as janelas de atendimento abertas não migram, porque pertencem ao par (número do negócio, usuário). No primeiro dia após a migração, 100% da base recebe por template, e o custo daquele dia é o de um dia sem atalho de janela.
  4. Tier do reserva começa em 1K. Acima de 1.000 assinantes o envio é escalonado em blocos diários até o tier subir, na ordem: PAID com janela aberta, PAID sem janela, FREE.
  5. Comunicação por e-mail a quem tem e-mail verificado, informando o novo número, e aviso no painel. Nenhum assinante recebe mensagem do número novo sem opt-in já registrado.
  6. Apelação pelo Business Manager em paralelo, em até 24 h do banimento.

17.11 Rate limiting #

Camada Limite Onde é aplicado
Plataforma 80 mensagens/s por número Meta
Nosso teto 20 mensagens/s (send.rate_per_second em settings, padrão 20) Limitador do BullMQ na fila send.dispatch
Por destinatário 1 mensagem a cada 500 ms Espaçamento interno do pacote free-form
Tier Clientes únicos por 24 h (Seção 17.4) Verificado no planejamento, às 05:40
// O teto é uma chave de settings, não uma variável de ambiente: precisa ser ajustável
// em incidente, sem deploy e sem editar arquivo no servidor (Seção 27.6, R-03 e R-04).
const ratePerSecond = await settings.getInt('send.rate_per_second');   // padrão 20
export const sendDispatchWorker = new Worker('send.dispatch', processSendDispatch, {
  connection, concurrency: 12,
  limiter: { max: ratePerSecond, duration: 1000 },                     // 20/s no grupo inteiro
});

Mudar send.rate_per_second exige reiniciar o worker para o limitador assumir o valor novo; o comando de operação faz as duas coisas em sequência e essa é a razão de o reinício aparecer nos runbooks logo depois do ajuste.

O limitador do BullMQ é por grupo de workers que compartilham o Redis, não por processo: subir uma segunda réplica não dobra a taxa, que é exatamente o comportamento desejado, porque o limite é da plataforma e não da nossa capacidade. Por que 20/s e não 80/s: o desenho conservador deixa margem para os envios free-form disparados por interação ao longo do dia, que competem pelo mesmo teto; 20/s entrega 3.000 mensagens em 2,5 minutos, muito dentro da janela de 20 minutos exigida; e picos próximos do teto aumentam a incidência de 130429 sem trazer benefício. Em RED, o valor cai para 5/s automaticamente (Seção 17.10).

17.12 Custos e o efeito econômico do desenho #

Modelo: cobrança por mensagem, por categoria; conversas iniciadas pelo usuário não são cobradas. Cenário: 3.000 PAID + 7.000 FREE, 30 dias, 40% dos PAID com janela permanentemente aberta, 10% dos PAID no caminho de vídeo, 55% de abertura de janela entre os que recebem template.

Fluxo Mensagens/mês Categoria Unitário US$/mês
PAID com janela aberta (1.200 × 30) 36.000 Serviço 0,0000 0,00
PAID via template de texto (1.500 × 30) 45.000 UTILITY 0,0080 360,00
PAID via template de vídeo (300 × 30) 9.000 UTILITY 0,0080 72,00
Pacote free-form após interação (990 × 30 × 3) 89.100 Serviço 0,0000 0,00
FREE, domingo (7.000 × 4,3 semanas) 30.100 UTILITY 0,0080 240,80
Pacote free-form dos FREE que interagem (55% × 2) 33.110 Serviço 0,0000 0,00
OTP de login 2.500 AUTHENTICATION 0,0315 78,75
Cobrança e confirmações (D-3, D-1, D0, confirmações) 4.500 UTILITY 0,0080 36,00
Reativação 300 MARKETING 0,0625 18,75
Total 249.610 806,30

Ao câmbio de referência de R$ 5,40 (Seção 16.14): R$ 4.354/mês, ou R$ 1,45 por assinante pago por mês — 7,3% da mensalidade de R$ 19,90.

O que o desenho economiza. Se tudo fosse entregue por template em vez de usar a janela, as 158.210 mensagens hoje gratuitas custariam US$ 1.265,68 adicionais por mês como UTILITY, ou US$ 9.888 como MARKETING. O desenho "Template + Janela" corta 61% do custo de mensageria no cenário UTILITY e 92% no cenário em que a Meta reclassificasse o devocional como MARKETING. É por isso que a etapa 3 é o caminho preferencial e não uma otimização opcional: cada ponto percentual de engajamento que move um assinante para a janela aberta remove US$ 0,24 por mês do custo dele. O dashboard (Seção 21) não estima: soma message_logs.cost_micros filtrando por message_logs.is_billable e agrupando por message_logs.conversation_category, todos preenchidos pelo bloco pricing do webhook de status.

Unidade de custo, obrigatória. As tabelas acima estão em dólar porque é assim que a plataforma publica o preço. O que o sistema persiste é message_logs.cost_micros, inteiro em milionésimos de BRL, convertido uma única vez na gravação pelo câmbio de referência billing.usd_brl_reference_rate (Seção 16.14). O divisor para exibição é 1.000.000. Os preços unitários por categoria ficam em settings sob whatsapp.price_per_message_micros, também em milionésimos de BRL — o nome carrega a unidade justamente para que ninguém some esse valor a um campo *_amount_cents, que é centavo de BRL e tem divisor 100. Nenhum valor monetário do sistema é ponto flutuante.

17.13 Camada de abstração WhatsAppProvider #

// packages/integrations/src/whatsapp/types.ts
export interface SendTemplateInput {
  to: string;                    // E.164 sem "+", conforme a API
  templateName: string; languageCode: 'pt_BR';
  bodyParams: string[];          // já sanitizados (Seção 17.7)
  headerMedia?: { kind: 'VIDEO' | 'IMAGE' | 'DOCUMENT'; mediaId: string };
  buttonPayloads?: string[];     // por índice de botão quick reply
  idempotencyKey: string;        // send:{subscriberId}:{devotionalDate}:{step}
}
export interface SendTextInput { to: string; body: string; previewUrl: false; idempotencyKey: string }
export interface SendMediaInput {
  to: string; kind: 'AUDIO' | 'VIDEO' | 'IMAGE'; mediaId: string; caption?: string; idempotencyKey: string }
export interface SendResult { wamid: string; acceptedAt: Date; waId: string | null }

export type SubscriberEffect = 'NONE' | 'DEFER_TO_TOMORROW' | 'SUPPRESS_24H'
  | 'MARK_UNDELIVERABLE' | 'MARK_MARKETING_BLOCKED' | 'HALT_ALL_SENDS';
export class WhatsAppError extends Error {
  constructor(readonly code: number, readonly retryable: boolean, readonly title: string,
    readonly subscriberEffect: SubscriberEffect, readonly details?: string) { super(title); }
}

export interface WhatsAppProvider {
  readonly name: string;                                    // 'META_CLOUD_API' | 'BSP_<nome>'
  sendTemplate(input: SendTemplateInput): Promise<SendResult>;
  sendText(input: SendTextInput): Promise<SendResult>;
  sendMedia(input: SendMediaInput): Promise<SendResult>;
  sendInteractive(input: SendInteractiveInput): Promise<SendResult>;
  uploadMedia(buffer: Buffer, mimeType: string, filename: string): Promise<{ mediaId: string; expiresAt: Date }>;
  getMediaMetadata(mediaId: string): Promise<{ sha256: string; fileSize: number } | null>;
  listTemplates(): Promise<TemplateStatus[]>;
  getPhoneNumberHealth(): Promise<{ qualityRating: string; messagingTier: string; status: string }>;
  verifyWebhookSignature(rawBody: string, headers: Headers): boolean;
  parseWebhook(rawBody: string): NormalizedWebhookEvent[];
}

O ponto da abstração é que o motor de envio (Seção 18) só conhece esta interface e o enum SubscriberEffect. A tradução de 131049 para DEFER_TO_TOMORROW, ou de 131050 para MARK_MARKETING_BLOCKED, acontece dentro do adaptador; um BSP com códigos próprios implementa a mesma tradução e o motor não muda uma linha. parseWebhook normaliza o payload da Meta (entry[].changes[].value.messages[]) para eventos planos { kind, subscriberPhone, wamid, timestamp, payload }, que é o que a fila whatsapp.inbound consome — trocar de provedor troca o parser, não o consumidor.

17.14 Casos de borda #

# Situação Comportamento definido
1 Assinante responde no exato instante em que o template está sendo enviado O envio prossegue; a interação abre a janela e dispara a entrega completa. Ele recebe o template e, segundos depois, o pacote. Aceito
2 Assinante toca no botão de um devocional de 3 dias atrás O payload carrega o devotionalId; o sistema entrega aquele devocional, não o de hoje, e registra message_logs.late_open_days (Seção 6.19)
3 Assinante toca duas vezes em "Ler e ouvir agora" delivery_attempts é única em (subscriber_id, devotional_date); o segundo toque encontra delivered e responde apenas com o fechamento
4 Webhook com assinatura válida mas JSON malformado Devolve 200 mesmo assim, grava webhook_deliveries.processing_result = 'PARSE_ERROR' com o corpo cru preservado e emite webhook_payload_rejected acima de 3 em 15 min. Responder 4xx levaria a plataforma a desativar a assinatura do endpoint (Seção 17.8)
5 Webhooks de status fora de ordem (read antes de delivered) Comparação de ordinal impede retrocesso; ambos os timestamps são gravados
6 wa_id diferente do phone_e164 cadastrado (nono dígito) Resolvido pela busca em quatro passos da Seção 11; wa_id é gravado no primeiro contato
7 Template aprovado é pausado às 05:50 send.plan já rodou; send.dispatch recebe 132015 e para a rota inteira após 3 ocorrências consecutivas, evitando 3.000 falhas. Alerta crítico
8 Assinante bloqueia o número 131026/131049 aumentam para ele; após 3 dias consecutivos de 131026, blocked_at e blocked_reason são preenchidos e o status passa a BLOCKED; qualquer mensagem recebida dele limpa os dois e devolve o status resolvido por resolveEntitlements (Seção 11.8)
9 Áudio enviado a tier FREE por defeito Impossível pelo desenho: o tier é relido do banco no instante do disparo, dentro do mesmo job e imediatamente antes da chamada ao provedor (Seção 18.5). tier_at_send é registro histórico, nunca autoridade; quem perdeu o acesso pago entre o planejamento das 05:40 e o disparo recebe apenas o texto, e o passo de áudio nem é criado
10 media_id de vídeo expirado no momento do template Erro 100; item falha, media_uploads é invalidado, media.upload reenfileirado, e o assinante recebe o template de texto na mesma execução, como degradação
11 Assinante muda de número, ou o número é reciclado pela operadora Toda mudança do wa_id associado a um telefone invalida as sessões do assinante e exige nova verificação por código antes de qualquer dado pessoal ser exibido ou enviado (regra da Seção 17.7). Só depois disso a Seção 20 migra o histórico. A janela do número antigo é irrelevante
12 Meta retorna 200 sem messages[0].id WHATSAPP_NO_WAMID, retentável uma vez: sem wamid não há como correlacionar status
13 Volume planejado excede o tier send.plan detecta às 05:40, alerta e trunca o lote priorizando PAID; os FREE excedentes são adiados para o domingo seguinte, com registro explícito
14 Assinante envia áudio, imagem ou localização A janela abre normalmente; o conteúdo é registrado em inbound_messages sem download da mídia, e o roteador responde com a mensagem de conteúdo não suportado (Seção 19.6)

18. Motor de Envio Diário #

18.1 Princípios #

O motor de envio é o processo que decide quem recebe o quê, quando e por qual rota. Ele vive em apps/worker e é governado por cinco regras:

  1. Planejar antes de disparar. Às 05:40 o lote inteiro é materializado em banco, com rota e mídia já resolvidas por assinante. Às 06:00 o disparo apenas executa uma lista pronta. Nenhuma decisão de negócio acontece durante o disparo.
  2. Idempotência por chave persistida, não por memória. plan:{devotionalDate} e send:{subscriberId}:{devotionalDate} são chaves persistidas, garantidas por índice único em send_batches e em delivery_attempts. Reprocessar é sempre seguro.
  3. Falha de um destinatário nunca afeta os outros. Cada item é uma unidade de trabalho isolada.
  4. Nunca enviar conteúdo incompleto. Se o devocional do dia não está pronto, usa-se a reserva; se não há reserva, não se envia (Seção 18.4).
  5. Fuso único. Todo horário desta seção é America/Sao_Paulo. O agendamento usa date-fns-tz e job repetível com tz: 'America/Sao_Paulo', nunca offset fixo.

18.2 Cronograma diário #

Horário Job O que faz Duração típica
00:05 maintenance.cleanup Retenção (Seção 22), remoção de temporários órfãos > 6 h, poda de job_runs e webhook_deliveries 30 s
00:05 billing.lifecycle Expira períodos pagos e cortesias vencidos (Seção 13.2) 20 s
00:20 maintenance.cleanup (etapa 2) Sincroniza status de templates e do número (Seções 17.6 e 17.10) 5 s
03:10 metrics.rollup Consolida daily_metrics de D−1 e reprocessa D−2, D−3, D−4, D−7 e D−14 (Seção 21.5) 20 a 90 s
03:50 maintenance.cleanup (etapa 3) Health check do token e da conta da Meta; GET /{PHONE_NUMBER_ID} 2 s
04:00 billing.reconcile Reconciliação diária com a Asaas (Seção 12); corrige divergência por webhook perdido 1 a 4 min
04:30 tts.generate (varredura) Garante áudio pronto para os próximos 3 dias; enfileira o que faltar 1 a 3 min
05:00 send.plan modo readiness Verificação de prontidão do devocional do dia (Seção 18.4). Alerta imediato se não estiver pronto 3 s
05:30 messaging.pause Limpa pausas encerradas e enfileira a saudação de retorno (Seção 20.6.2) 5 s
05:40 send.plan modo plan Monta a coorte, resolve rota e mídia, grava send_batches + delivery_attempts 10 a 60 s
05:57 send.plan modo verify Confere que o lote existe, que a mídia está válida e que o template está APPROVED; corrige rotas de vídeo sem MP4 5 s
06:00 send.dispatch Dispara o lote a 20 msg/s 2,5 a 15 min
06:05 Alerta Dispara se send_batches.started_at ainda é nulo
06:30 Alerta Dispara se o lote não está COMPLETED
06:00 → 23:59 whatsapp.inbound + send.followup Processa interações, abre janelas, entrega pacotes completos (Seção 18.6) contínuo
09:00, 12:00, 18:00 send.followup (varredura) Reprocessa itens RETRYING cuja janela de backoff venceu 10 s
23:55 send.followup modo close Fecha entregas pendentes, incrementa consecutive_window_misses, marca o lote como CLOSED. É o último job do dia civil; nenhuma métrica do dia é consolidada antes dele 15 s

Os jobs marcados em negrito são os que, se falharem, degradam o produto no mesmo dia. Todos têm alerta próprio na Seção 18.12.

Por que o rollup roda às 03:10 e não no fim do dia. O fechamento das entregas acontece às 23:55; consolidar métricas antes disso mediria o dia antes de o dia acabar, e messages_delivered, window_opens e as entregas pendentes sairiam sistematicamente subestimados. Às 03:10 o dia alvo (D−1) já está fechado, a reconciliação de pagamentos das 04:00 ainda não começou e o planejamento das 05:40 está longe — é a faixa de menor tráfego do dia. A divergência de receita que a reconciliação das 04:00 corrigir entra no reprocessamento de D−2 do dia seguinte. Não existe rollup às 23:00.

18.3 Job send.plan #

Roda em três modos, sempre para uma devotionalDate (data local). O jobId é plan:{devotionalDate}:{mode}, o que torna o job repetível idempotente por natureza.

18.3.1 Seleção do devocional do dia #

SELECT id, title, teaser, status
FROM devotionals
WHERE scheduled_for = :devotionalDate
  AND deleted_at IS NULL
  AND is_evergreen = false
LIMIT 1;

devotionals.scheduled_for tem índice único parcial WHERE deleted_at IS NULL AND is_evergreen = false, o que garante no máximo um devocional programado por data. Decisão registrada: scheduled_for é nullable exatamente para permitir o acervo de reserva — devocionais evergreen existem sem data (Seção 18.4).

Prontidão exigida para o disparo: status IN ('PUBLISHED') e, para a rota de áudio, um audio_assets corrente (invalidated_at IS NULL, qc_failed_at IS NULL) com media_uploads válido.

18.3.2 Montagem da coorte #

SELECT s.id, s.phone_e164, s.wa_id, s.tier, s.service_window_expires_at,
       s.consecutive_window_misses, s.marketing_blocked_at, s.blocked_at
FROM subscribers s
WHERE s.deleted_at IS NULL
  AND s.opt_in_confirmed_at IS NOT NULL          -- nunca envia sem opt-in confirmado (regra dura de opt-in, Seção 11)
  AND s.opt_out_at IS NULL                        -- exclui quem saiu
  AND (s.paused_until IS NULL OR s.paused_until <= now())   -- exclui pausa vigente
  AND s.blocked_at IS NULL                        -- exclui bloqueados (131026 recorrente ou bloqueio administrativo)
  AND (
        s.tier = 'PAID'                                              -- PAID: todos os dias
     OR (s.tier = 'FREE' AND :weekday = :freeTierSendWeekday)        -- FREE: só no dia configurado
      )
  AND NOT EXISTS (                                                    -- exclui quem já recebeu
        SELECT 1 FROM delivery_attempts da
        WHERE da.subscriber_id = s.id AND da.devotional_date = :devotionalDate
      )
ORDER BY s.tier DESC, s.id                        -- PAID primeiro: se o tier truncar, PAID é preservado
FOR UPDATE SKIP LOCKED;

:freeTierSendWeekday vem de settings sob a chave send.free_tier_weekday (padrão 0 = domingo, conforme a matriz de entitlements da Seção 7); o nome da chave segue o formato grupo.chave do catálogo da Seção 26.8.1 e não é uma variável de ambiente. O tier de cada assinante não é recalculado aqui: a coluna é a projeção mantida por resolveEntitlements() (Seção 7). O valor lido nesta consulta é congelado em delivery_attempts.tier_at_send como registro histórico, e não como autoridade — a decisão que vale é a releitura do disparo (Seção 18.5).

phone_e164 e wa_id são colunas cifradas (Seção 6): o repositório as devolve decifradas para o motor, e qualquer busca por igualdade usa phone_hmac ou wa_id_hmac, nunca a coluna cifrada.

A leitura é paginada em blocos de 1.000 com cursor por id, para não carregar 50.000 linhas em memória.

18.3.3 Resolução de rota #

function resolveRoute(s: Subscriber, ctx: PlanContext): Route {
  if (s.service_window_expires_at && s.service_window_expires_at > ctx.dispatchAt) {
    return 'WINDOW_DIRECT';                       // etapa 3 da Seção 17.3 — preferencial, custo zero
  }
  if (s.tier === 'PAID' && s.consecutive_window_misses >= ctx.windowMissThreshold && ctx.videoMediaId) {
    return 'TEMPLATE_VIDEO';                      // etapa 4 da Seção 17.3
  }
  return 'TEMPLATE_INVITE';                       // etapa 1 da Seção 17.3
}

ctx.windowMissThreshold vem de settings sob send.window_miss_threshold (padrão 3).

O lote é gravado em duas tabelas, na mesma transação:

Tabela Conteúdo
send_batches Uma linha por (devotional_date, batch_kind): devotional_id, planned_count, contagens por rota, tier_limit_at_plan, status, planned_at, started_at, completed_at
delivery_attempts Uma linha por assinante: idempotency_key = send:{subscriberId}:{devotionalDate}, subscriber_id, devotional_id, devotional_date, route, status = 'PENDING', attempt_count = 0, whatsapp_media_id resolvido

A inserção usa INSERT ... ON CONFLICT (idempotency_key) DO NOTHING, e o número de linhas afetadas é comparado com o tamanho da coorte. Divergência é registrada como plan.duplicates_skipped — informação, não erro: significa que o job rodou duas vezes, e a segunda foi corretamente absorvida.

Verificação de tier. Antes de confirmar, planned_count + mensagens já enviadas nas últimas 24 h é comparado com whatsapp.messaging_tier. Acima de 80%, alerta médio. Acima de 100%, o lote é truncado pela ordem de ORDER BY tier DESC — PAID inteiro, FREE até o limite —, e os excedentes ficam registrados em send_batches.truncated_count com alerta alto. FREE truncado é adiado para o próximo dia de envio FREE, nunca enviado fora do dia configurado.

18.4 Regra de prontidão e mecanismo de reserva #

A prontidão é avaliada em três marcos, nesta ordem, e nenhum outro: 05:00 (send.plan modo readiness) alerta e abre o runbook R-11 se não houver devocional publicado ou se o áudio estiver ausente; 05:40 (send.plan modo plan) congela a rota e a mídia de cada assinante, usando a reserva desta subseção se o áudio ainda não existir; 05:57 (send.plan modo verify) confere que a mídia está válida e que o template está APPROVED, e corrige rotas de vídeo sem MP4. Depois das 05:57 nada muda no lote. O que continua mudando depois das 05:57 é o estado do assinante, e é por isso que existe a revalidação no disparo da Seção 18.5: a prontidão do conteúdo congela, a elegibilidade da pessoa não.

Às 05:00, send.plan em modo readiness avalia o devocional do dia:

devocional de hoje existe e status = PUBLISHED?
   |                                    |
  não                                  sim
   |                                    |
   |                          áudio corrente + media_id válido?
   |                              |                    |
   |                             sim                  não (só para PAID)
   |                              |                    |
   |                              v                    v
   |                        TUDO PRONTO        alerta tts.audio_not_ready_by_deadline
   |                                           PAID recebe SÓ TEXTO neste dia
   |                                           (degradação, não bloqueio)
   v
alerta content.not_ready_by_deadline (CRÍTICA, plantonista, 05:00)
   |
   v
existe reserva evergreen elegível?
   |                        |
  sim                      não
   |                        |
   v                        v
usa a reserva          NÃO ENVIA NADA
marca fallback_used    alerta content.no_evergreen_available (CRÍTICA)
                       send_batches.status = 'SKIPPED_NO_CONTENT'
                       painel e e-mail ao operador às 05:01

Decisão sobre a degradação parcial: se o texto está pronto e o áudio não, o envio acontece com texto apenas para todos, inclusive PAID. Segurar o devocional inteiro por causa do áudio prejudicaria toda a base para proteger um benefício de parte dela. O assinante PAID recebe, no fechamento do pacote, a mensagem devotional.audio_unavailable (Seção 19), e o áudio fica disponível no acervo do painel assim que ficar pronto.

18.4.1 Mecanismo de reserva (evergreen) #

Especificação completa, porque não pode haver ambiguidade num caminho que só é exercitado em emergência.

Aspecto Decisão
Marcação devotionals.is_evergreen = true e scheduled_for IS NULL. São devocionais atemporais: sem menção a data, estação, feriado ou notícia
Estado exigido status = 'PUBLISHED' com áudio corrente e media_uploads válido, exatamente como um devocional normal. Reserva sem áudio não é reserva
Estoque mínimo 5 evergreen prontos. O job das 04:30 verifica e alerta content.evergreen_stock_low quando cai abaixo de 5; abaixo de 2, severidade alta
Seleção O evergreen com last_used_as_fallback_at mais antigo (ou nulo), desempate por id. Rodízio simples, sem aleatoriedade — reprodutível em investigação
Reuso Um evergreen não pode ser usado duas vezes em 180 dias. Se todos os 5 estiverem dentro da janela de 180 dias, o mais antigo é usado assim mesmo e o alerta é elevado a crítico
Registro send_batches.fallback_used = true, send_batches.fallback_reason, devotionals.last_used_as_fallback_at atualizado. O painel mostra o dia marcado como "conteúdo de reserva"
Texto ao assinante Nenhuma diferença. O assinante não é avisado de que recebeu conteúdo de reserva. A abertura do roteiro de narração não menciona a data para os evergreen (Seção 16.3.1 usa a data do envio, e para evergreen o bloco 1 é substituído por "Palavra Diária. Devocional de hoje.")
Recuperação O devocional que deveria ter saído continua em scheduled_for original e o painel oferece "reagendar para a próxima data livre" em um clique

Nunca enviar conteúdo incompleto é regra absoluta: se não há devocional pronto nem reserva elegível, o lote é marcado SKIPPED_NO_CONTENT e nenhuma mensagem sai. Um dia sem devocional é um problema; um devocional pela metade é uma quebra de confiança.

18.5 Job send.dispatch #

Às 06:00, um job por lote enfileira um job filho por item de delivery_attempts com status = 'PENDING'. O jobId do filho é a própria idempotency_key, o que impede duplicação mesmo se o pai for reexecutado.

async function processDispatchItem(job: Job<DispatchItemData>) {
  const attempt = await claimAttempt(job.data.idempotencyKey);   // UPDATE ... WHERE status IN ('PENDING','RETRYING')
  if (!attempt) return { skipped: 'already_processed' };          // idempotência: outro worker pegou

  // REVALIDAÇÃO NO DISPARO. Leitura por chave primária, no mesmo job, imediatamente
  // antes da chamada ao provedor. tier_at_send é histórico, nunca autoridade.
  const sub = await loadSubscriberForUpdate(attempt.subscriber_id);   // SELECT ... WHERE id = $1
  if (sub.deleted_at || sub.blocked_at) {
    return finalize(attempt, 'SKIPPED_INELIGIBLE', 'deleted_or_blocked');   // sem chamada ao provedor
  }
  if (sub.opt_out_at) {
    return finalize(attempt, 'SKIPPED_OPTED_OUT', 'opt_out_after_plan');
  }
  if (sub.paused_until && sub.paused_until > new Date()) {
    return finalize(attempt, 'SKIPPED_PAUSED', 'paused_after_plan');
  }
  const ent = resolveEntitlements(sub);                           // fonte única (Seção 7)
  if (attempt.tier_at_send === 'PAID' && ent.tier === 'FREE') {   // rebaixamento na hora
    await downgradeAttempt(attempt, { tierAtSendEffective: 'FREE', downgradedAt: new Date() });
  }

  switch (attempt.route) {
    case 'WINDOW_DIRECT':   return deliverFullPackage(sub, ent, attempt);
    case 'TEMPLATE_VIDEO':  return ent.audioEnabled
                                 ? sendTemplate(sub, attempt, 'devocional_diario_video_v1')
                                 : sendTemplate(sub, attempt, 'devocional_diario_v1');
    case 'TEMPLATE_INVITE': return sendTemplate(sub, attempt, 'devocional_diario_v1');
  }
}

deliverFullPackage envia, com 500 ms entre mensagens: (1) texto completo; (2) áudio, somente se ent.audioEnabled; (3) fechamento. Cada mensagem grava sua própria linha em message_logs com o step, e o progresso é persistido em delivery_attempts.steps_completed — se o worker morrer entre a mensagem 2 e a 3, a retomada envia apenas a 3.

18.5.1 Revalidação no disparo #

delivery_attempts.tier_at_send é registro histórico, nunca autoridade. Imediatamente antes de chamar o provedor, e dentro do mesmo job, o motor relê subscribers.tier, subscribers.opt_out_at, subscribers.deleted_at, subscribers.blocked_at e subscribers.paused_until por chave primária e aplica, nesta ordem:

Condição relida Desfecho do item Chamada ao provedor
deleted_at ou blocked_at preenchido SKIPPED_INELIGIBLE Nenhuma
opt_out_at preenchido SKIPPED_OPTED_OUT Nenhuma
paused_until vigente SKIPPED_PAUSED Nenhuma
tier = 'FREE' em item planejado como PAID Rebaixa o pacote na hora: entrega apenas o texto do plano gratuito, grava delivery_attempts.downgraded_at = now() e tier_at_send_effective = 'FREE' Sim, sem o passo de áudio
Nenhuma das anteriores Segue a rota planejada Sim

As colunas delivery_attempts.tier_at_send, tier_at_send_effective e downgraded_at são declaradas na Seção 6.20; esta subseção define apenas quando cada uma é escrita.

A leitura extra é uma consulta por chave primária, abaixo de 1 ms, sobre um lote de no máximo 12.400 itens: no pior caso do domingo, 12 segundos de CPU de banco distribuídos por 10 minutos de disparo. Essa revalidação é o que torna verdadeira a regra de carência zero também dentro dos 15 minutos de disparo e das varreduras de send.followup ao longo do dia. Sem ela, o congelamento das 05:40 concederia na prática um dia inteiro de carência a todo inadimplente: um PAYMENT_OVERDUE processado às 05:52 grava tier = FREE corretamente (Seção 13.3), e às 06:00 o disparo entregaria mesmo assim o texto completo e o áudio narrado, todos os dias, para todo mundo que perdeu o acesso naquela madrugada. A revogação é imediata por decisão de produto, e imediata significa também "no meio do lote".

A mesma revalidação vale para send.followup: um pacote disparado por interação às 19:00 relê o estado no instante do envio, e não o que foi congelado às 05:40.

O caso simétrico também é real: um assinante que envia SAIR às 05:58 está no lote planejado às 05:40 e, sem a revalidação, receberia a mensagem depois de pedir para sair — o que é, ao mesmo tempo, descumprimento do direito de revogação e a causa direta da denúncia que derruba a reputação do número.

18.6 Processamento de mensagens de entrada #

Assinante envia mensagem ou toca em botão
        |
        v
POST /api/webhooks/whatsapp  -- valida HMAC, grava webhook_deliveries, 200 em < 200 ms
        |
        v
fila whatsapp.inbound (concurrency 8)
        |
        +-- resolve o assinante pela busca em 4 passos da Seção 11
        +-- grava inbound_messages
        +-- UPDATE subscribers SET service_window_expires_at = msg_ts + 24h,
        |          consecutive_window_misses = 0, last_inbound_at = msg_ts
        +-- roteador de palavras-chave (Seção 19.5) -- palavras de SAÍDA primeiro, sempre
        |      |
        |      +-- é palavra-chave? executa a ação e responde
        |      |
        |      +-- não é? existe entrega pendente hoje?
        |                 |                        |
        |                sim                      não
        |                 |                        |
        |                 v                        v
        |      enfileira send.followup      resposta de conteúdo
        |      (prioridade 1)               não reconhecido (Seção 19.6)
        v
send.followup entrega o pacote completo (texto, áudio se PAID, fechamento)

Precedência absoluta das palavras-chave de saída. O reconhecimento das sete palavras de saída (Seção 19.5) é a primeira operação executada sobre toda mensagem de entrada, antes da proteção anti-loop, antes do escalonamento humano, antes da entrega pendente e independentemente do estado do assinante. Nenhum caminho deste diagrama pode consumir uma mensagem antes desse teste.

Latência alvo: da chegada do webhook à primeira mensagem do pacote, p50 < 2 s, p95 < 5 s, p99 < 12 s. É medida com inbound_messages.received_at como marco zero e message_logs.sent_at do passo 1 como marco final — sent_at é o instante da resposta síncrona da plataforma (Seção 17.8) e não existe coluna accepted_at —, exposta como histograma followup_latency_seconds (Seção 23). Alerta médio se o p95 de uma hora ultrapassar 8 s.

Três decisões que sustentam a latência: send.followup tem priority: 1 (a fila do BullMQ atende antes dos jobs de lote); o worker de inbound não faz nenhuma chamada HTTP externa antes de enfileirar; e o pacote não espera confirmação de delivered entre os passos, apenas o accepted síncrono.

Reação a botões. OPEN_DEVOTIONAL:{devotionalId} entrega aquele devocional específico. SNOOZE:{devotionalId} abre a janela (toda interação abre) mas não entrega: responde com a mensagem devotional.snoozed e mantém a entrega pendente até 23:59. CONFIRM_OPTIN conclui o opt-in (Seção 11) e dispara onboarding.optin_confirmed.

18.7 Entrega pendente e contador de dias sem interação #

Um assinante que recebeu o template e não interagiu tem uma entrega pendente: delivery_attempts.status = 'AWAITING_INTERACTION'.

Regra Valor
Duração da pendência Até 23:59 do mesmo dia local. Uma interação a qualquer momento nesse intervalo dispara a entrega completa daquele devocional
Após 23:59 status = 'EXPIRED_NO_INTERACTION'. O devocional daquele dia não é mais entregue por essa via — o de amanhã já está a caminho, e empilhar dois devocionais confunde
Acesso posterior O devocional continua disponível no acervo do painel (respeitando o entitlement da Seção 7) e pode ser pedido pela palavra-chave HOJE no mesmo dia
Contador subscribers.consecutive_window_misses incrementa em 1 às 23:55, apenas para quem teve naquele dia ao menos uma mensagem em estado delivered ou read e nenhuma mensagem de entrada. Zera no instante em que qualquer mensagem de entrada é processada, inclusive SAIR
Dias que não contam Dia cuja única saída foi adiada (131049), suprimida (131048) ou falha não incrementa: o assinante não teve oportunidade de interagir. Esses dias alimentam subscribers.undelivered_days, contador separado que aciona a regra de entrega mínima do plano pago (Seção 13)
Efeito do contador >= send.window_miss_threshold (padrão 3) e tier PAID → rota TEMPLATE_VIDEO no dia seguinte, ou seja, no quarto dia consecutivo sem interação (Seção 17.3, etapa 4)
Teto do caminho de vídeo 7 dias consecutivos. Depois disso, volta a TEMPLATE_INVITE e o assinante entra na régua de reengajamento (Seção 20)

O nome do contador é consecutive_window_misses em todo o documento; no_interaction_days não existe e não pode ser reintroduzido.

Exemplo: assinante PAID recebe template segunda, terça e quarta, com os três delivered e sem interagir. Quinta às 05:40, consecutive_window_misses = 3 e a rota vira TEMPLATE_VIDEO. Ele recebe o MP4 com o áudio embutido sem precisar tocar em nada. Se interagir na quinta, o contador zera e sexta volta a TEMPLATE_INVITE. Se, em vez disso, os três dias tivessem sido 131049, o contador continuaria em zero e ele não entraria no caminho de vídeo — porque a plataforma já estava suprimindo a entrega, e o MP4 seria suprimido do mesmo jeito.

18.8 Catálogo de filas BullMQ #

Esta subseção é a dona da lista de filas. São treze filas, com estes nomes exatos, no formato dominio.acao. Nenhuma outra seção do documento cria fila, renomeia fila ou usa apelido: os nomes abaixo aparecem sem variação em todo o documento, e um nome com hífen (whatsapp-send, email-send, media-upload) ou um sufixo .dlq é sempre erro de escrita, não uma fila diferente.

Fila Payload Conc. Rate limit Tentativas Backoff Timeout O que o worker faz
send.plan { devotionalDate, mode } 1 6/h 3 fixo 60 s 300 s Monta a coorte, resolve rotas, grava lote (Seção 18.3)
send.dispatch { idempotencyKey, subscriberId, route } 12 20/s 4 exp. 2 s, fator 3 60 s Envia template ou pacote completo (Seção 18.5)
send.followup { subscriberId, devotionalId, trigger } 8 20/s 3 exp. 15 s 60 s Entrega o pacote após interação; varreduras de retry e fechamento
whatsapp.inbound { webhookDeliveryId } 8 5 exp. 5 s 30 s Resolve assinante, abre janela, roteia palavra-chave, dispara followup
tts.generate { devotionalId, mode, force } 2 10/min 3 exp. 30 s 300 s Pipeline de áudio (Seção 16.13)
media.upload { audioAssetId, kind } 3 30/min 5 exp. 10 s 120 s Upload à Media API, grava media_uploads (Seção 16.11)
billing.webhook { paymentEventId } 4 8 exp. 10 s 30 s Processa evento da Asaas de forma idempotente (Seção 12)
billing.reconcile { date } 1 4/h 3 fixo 300 s 900 s Varre assinaturas e pagamentos contra a Asaas (Seção 12)
billing.lifecycle { date } 1 4/h 3 fixo 120 s 300 s Expira períodos pagos vencidos e cortesias vencidas (Seção 13.2, transição T14)
metrics.rollup { date } 1 4/h 3 fixo 120 s 300 s Consolida daily_metrics de D−1 no formato longo (Seção 21.5)
maintenance.cleanup { stage } 1 8/h 2 fixo 300 s 600 s Retenção, temporários órfãos, sync de templates e do número
email.send { template, subscriberId, params } 4 60/min 3 exp. 20 s 30 s E-mails transacionais da Seção 19.3
messaging.pause { date } 1 4/h 2 fixo 60 s 120 s Limpa pausas encerradas e enfileira a saudação de retorno (Seção 20.6.2)

A coluna "Backoff" dá o atraso da primeira repetição e o tipo de progressão; a fórmula completa, com jitter total, é a da Seção 27.2.1, e o valor de "Tentativas" desta tabela é o que vale para cada fila — a Seção 27.2 descreve a curva do atraso, não o número de tentativas.

Configuração comum a todas: removeOnComplete: { age: 604800, count: 5000 }, removeOnFail conforme a retenção por fila da Seção 27.5, lockRenewTime = metade do timeout, e um listener de failed que grava job_runs com status = 'DEAD' quando as tentativas esgotam.

Não existe fila de dead-letter. O BullMQ não tem DLQ nativa e nós não criamos uma: trabalho morto é um job no estado failed da própria fila mais uma linha em job_runs com status = 'DEAD', job_name, queue, bull_job_id, attempt, error_code e payload redigido. job_runs é a dead-letter do sistema, e o painel de operação lista os jobs mortos com um botão de reprocessar. Nenhuma seção pode citar send.dispatch.dlq, email.send.dlq ou qualquer outro nome com esse sufixo — eles não existem. O procedimento de inspeção e reprocessamento está na Seção 27.5.

Jobs repetíveis são registrados na subida do worker com tz: 'America/Sao_Paulo' e jobId estável, de modo que reiniciar o processo não duplica o agendamento. O nome do agendador do lote diário é plan-daily-batch, e é esse o nome esperado pelo runbook R-01 (Seção 27.6) e pela verificação de integração da Seção 24.5:

await queue.upsertJobScheduler('plan-daily-batch', { pattern: '40 5 * * *', tz: 'America/Sao_Paulo' },
  { name: 'send.plan', data: { mode: 'plan' }, opts: { jobId: 'plan-daily-batch' } });

O fuso é parte da asserção, não um detalhe: sem tz, o padrão 40 5 * * * dispara às 05:40 UTC, ou seja, 02:40 em Brasília.

18.9 Idempotência de ponta a ponta #

Nível Chave Onde é garantida
Planejamento do dia plan:{devotionalDate} jobId do BullMQ + única em send_batches (devotional_date, batch_kind)
Envio por assinante send:{subscriberId}:{devotionalDate} Única em delivery_attempts.idempotency_key
Passo dentro do pacote send:{subscriberId}:{devotionalDate}:{step} delivery_attempts.steps_completed (bitmask) atualizado após cada accepted
Geração de áudio tts:{devotionalId}:{scriptHash} jobId + única em audio_assets (devotional_id, narration_script_hash, voice_id)
Upload de mídia media:{devotionalId}:{kind}:{sha256} jobId + única parcial em media_uploads (Seção 16.11)
Webhook da Meta wamid / wamid:status Única em webhook_deliveries (source, dedup_key) (Seção 6.26.1)
Webhook da Asaas event.id Única em payment_events (Seção 12)

A garantia de que nenhum assinante recebe o mesmo devocional duas vezes repousa em três camadas, nesta ordem: (1) delivery_attempts.idempotency_key é única, então só existe uma tentativa por assinante por dia; (2) claimAttempt faz UPDATE ... SET status='SENDING', locked_by=:worker WHERE idempotency_key=:k AND status IN ('PENDING','RETRYING') RETURNING *, e a linha só é retornada para um worker; (3) steps_completed impede repetir um passo já aceito na retomada.

Prova por cenário: o worker envia o texto, grava steps_completed = 0b001, e é morto antes do áudio. O lock expira em 60 s, o job volta à fila, claimAttempt encontra status = 'SENDING' com locked_at expirado, retoma, lê steps_completed e envia apenas áudio e fechamento. O assinante recebe o texto uma única vez.

18.10 Tratamento de falha por destinatário #

Toda falha passa pela tradução do adaptador (Seção 17.13), que devolve um SubscriberEffect. O motor conhece apenas o efeito.

SubscriberEffect Origem típica Ação em delivery_attempts Ação em subscribers
NONE 5xx, 131000, 131016, timeout status='RETRYING', attempt_count++, next_retry_at
DEFER_TO_TOMORROW 131049, 130472 status='DEFERRED', deferred=true Nenhuma. Não conta como falha (Seção 17.9)
SUPPRESS_24H 131048 status='SUPPRESSED' suppressed_until = now + 24 h
MARK_UNDELIVERABLE 131026 por 3 dias seguidos status='FAILED' blocked_at = now, blocked_reason = 'META_131026', status = 'BLOCKED'; e-mail se houver e-mail verificado
MARK_MARKETING_BLOCKED 131050 status='FAILED' marketing_blocked_at = now; continua recebendo UTILITY
HALT_ALL_SENDS 190, 368, 131031, 131042 Lote pausado Nenhuma; alerta crítico e pausa da fila

Retry. Máximo de 4 tentativas por item, com backoff exponencial de base 2 s e fator 3 e jitter total (Seção 27.2.1): os atrasos máximos são 2 s, 6 s, 18 s e 54 s, cada um sorteado em [0, máximo]. O tempo esperado até a última tentativa fica em torno de 40 segundos, o que cabe folgadamente na janela de 20 minutos do lote. Esgotadas as tentativas, status = 'FAILED' com error_code, e o item entra no relatório do lote. A varredura de send.followup às 09:00, 12:00 e 18:00 recolhe itens RETRYING cuja janela venceu — isso cobre o caso em que o backoff ultrapassa o fim do disparo.

Entrada no lote seguinte. Nada precisa ser feito: a coorte do dia seguinte (Seção 18.3.2) não exclui quem falhou hoje, porque a cláusula NOT EXISTS filtra por devotional_date, não por sucesso. Um assinante que falhou segunda participa normalmente do lote de terça. A única exclusão persistente é blocked_at.

Contenção automática. Se a taxa de falha ultrapassar 20% nas primeiras 200 tentativas de um lote, o motor pausa o lote sozinho: grava send_batches.status = 'HALTED_BY_GUARD' com o error_code dominante, para de consumir a fila daquele lote preservando os itens PENDING e RETRYING, e emite alerta crítico. A retomada é sempre humana, pelo runbook R-03 (Seção 27.6). O motivo é aritmético: às 06:03 pode não haver ninguém acordado, e continuar disparando contra um erro sistêmico — um token expirado, um template pausado — multiplica o dano à reputação do número sem entregar uma única mensagem a mais. Com 4 tentativas por item, um lote de 8.000 contra um erro sistêmico queima 32.000 chamadas antes de qualquer alerta de horário disparar.

18.11 Reenvio manual #

Dois caminhos, ambos sujeitos ao limite diário da matriz de entitlements (Seção 7): 1 reenvio/dia para FREE, 3/dia para PAID.

Caminho Como Contabilização
Painel do assinante Botão "Reenviar no WhatsApp" no devocional de hoje (Seção 14) delivery_attempts.manual_resend_count do dia
WhatsApp Palavra-chave HOJE (Seção 19.5) Mesmo contador
Painel administrativo Ação "Reenviar para este assinante", papéis ADMIN/OWNER Não incrementa manual_resend_count e não tem limite; fica registrado apenas em admin_audit_log, com operador e motivo

O reenvio respeita a rota corrente: com janela aberta, envia o pacote free-form completo (custo zero); com janela fechada, envia o template de convite (custo de uma mensagem UTILITY). Não existe reenvio de áudio isolado fora da janela — é impossível pela restrição da restrição (a) da Seção 17.2.

Excedido o limite, a resposta é a mensagem devotional.resend_limit (Seção 19), que informa o limite e a hora em que ele reinicia (00:00 local). Reenvio manual nunca cria uma nova delivery_attempts: incrementa o contador da existente e grava novas linhas em message_logs. Isso preserva a garantia de unicidade da Seção 18.9.

18.12 Observabilidade do lote e alertas #

send_batches expõe, ao vivo, o progresso do dia. O painel administrativo consulta a cada 5 segundos durante a janela de disparo:

SELECT status, route, COUNT(*) AS total
FROM delivery_attempts
WHERE devotional_date = CURRENT_DATE
GROUP BY status, route;
Métrica exibida Fonte
Planejados / enviados / entregues / lidos / falhos / adiados Contagem por status
Progresso percentual (SENT + FAILED + DEFERRED) / planned_count
Taxa de envio instantânea message_logs da última janela de 60 s
Tempo decorrido e projeção de término started_at + taxa corrente
Distribuição por rota WINDOW_DIRECT / TEMPLATE_INVITE / TEMPLATE_VIDEO
Custo estimado do lote Contagem por categoria × preço de settings (Seção 17.12)
Alerta Condição Severidade Destino
send.batch_not_started 06:05 e started_at IS NULL Crítica Plantonista
send.batch_not_completed 06:30 e status != 'COMPLETED' Crítica Plantonista
send.failure_rate_high falhas reais / planejados > 5%, avaliado a cada 60 s após 10% de progresso Alta Canal de operação
send.batch_failure_rate taxa de falha acima de 10% avaliada durante o disparo, a partir de 300 tentativas registradas Crítica Plantonista
send.batch_error_dominant um único error_code responde por mais de 60% das falhas, a partir de 100 falhas Crítica Plantonista — indica erro sistêmico, não falhas individuais
send.batch_halted_by_guard send_batches.status = 'HALTED_BY_GUARD' (contenção automática de 18.10) Crítica Plantonista
whatsapp.no_inbound nenhum webhook de entrada válido processado nos últimos 60 min entre 06:00 e 22:00 Crítica Plantonista — único detector de canal de entrada morto (Seção 17.8)
whatsapp.webhook_invalid_signature mais de 10 rejeições de assinatura em 5 min Crítica Plantonista — segredo do aplicativo dessincronizado (Seção 17.7)
whatsapp.window_direct_collapse participação da rota WINDOW_DIRECT no lote abaixo de 50% da média dos 7 dias anteriores Crítica Plantonista
webhook_payload_rejected mais de 3 corpos rejeitados em 15 min (Seção 17.8) Alta Plantonista
send.deferred_rate_high 131049 > 25% do lote Média Canal de operação (Seção 17.10)
send.tier_threshold planejado > 80% do tier Média Canal de operação
send.tier_exceeded lote truncado Alta Canal + e-mail
content.not_ready_by_deadline 05:00 sem devocional PUBLISHED Crítica Plantonista
content.no_evergreen_available reserva vazia no momento da necessidade Crítica Plantonista
content.evergreen_stock_low estoque < 5 Média (< 2: Alta) Canal de operação
followup.latency_high p95 de 1 h > 8 s Média Canal de operação
queue.depth_high qualquer fila com > 5.000 aguardando por mais de 10 min Alta Canal de operação
queue.dead_jobs qualquer linha nova em job_runs com status='DEAD' Alta Canal de operação

A taxa de falha exclui explicitamente DEFERRED: 131049 é operação normal no Brasil (Seção 17.9) e incluí-lo faria o alerta de 5% disparar quase todo dia, o que treinaria o operador a ignorá-lo.

Falha concentrada nas mesmas pessoas. Uma taxa agregada saudável esconde o pior padrão de churn: assinantes que falham sempre. Todo assinante com falha de entrega em 3 dias consecutivos entra na lista entrega_cronica do painel de envios, revisada semanalmente. Três por cento de falha distribuída é ruído; três por cento concentrada nas mesmas 300 pessoas por seis semanas é a base cancelando em silêncio, com o painel mostrando 97% de entrega.

18.13 Backfill e reprocessamento pelo CLI de operação #

apps/ops expõe comandos idempotentes. Todos aceitam --dry-run, imprimem o plano e exigem --confirm para executar; todos registram em admin_audit_log com o operador identificado.

# Replaneja um dia. Não recria itens existentes (ON CONFLICT DO NOTHING).
pnpm ops send:plan --date 2026-09-08 --dry-run

# Dispara o que ficou pendente. Só toca em PENDING e RETRYING.
pnpm ops send:dispatch --date 2026-09-08 --confirm

# Reprocessa apenas os itens falhos de um dia, com limite de segurança.
pnpm ops send:retry-failed --date 2026-09-08 --max 500 --confirm

# Reenvia para um único assinante (suporte). Ignora o limite diário do assinante.
pnpm ops send:one --subscriber sub_01K5T8QW3M --date 2026-09-08 --confirm

# Backfill de áudio para um intervalo (após troca de voz, por exemplo).
pnpm ops tts:backfill --from 2026-09-01 --to 2026-09-30 --force --confirm

# Revalida e reenvia mídias com media_id perto de expirar.
pnpm ops media:refresh --within-days 3 --confirm

# Reprocessa jobs mortos de uma fila (estado failed + linhas DEAD em job_runs, Seção 27.5).
pnpm ops queue:dead:replay --queue send.dispatch --since 2026-09-08 --confirm

# Pausa e retoma o lote do dia durante um incidente (Seção 27.6, R-03).
pnpm ops send:halt --date 2026-09-08 --reason "190" --confirm
pnpm ops send:resume --date 2026-09-08 --confirm

# Recalcula métricas de um intervalo. Alvo padrão do job diário é D-1, às 03:10.
pnpm ops metrics:rollup --from 2026-09-01 --to 2026-09-08 --confirm

Três guardas obrigatórias em todo backfill: (1) nunca reenvia para quem já tem status='SENT' naquela data, o que é garantido pelo próprio filtro de status e pela unicidade de delivery_attempts; (2) --max limita o alcance de um comando, com padrão de 1.000, para que um erro de digitação em --date não produza um disparo em massa; (3) backfill de datas anteriores a 7 dias exige --allow-old, porque reenviar o devocional da semana passada quase nunca é a intenção.

18.14 Capacidade #

Premissas: 20 msg/s de teto (Seção 17.11), 12 workers concorrentes, latência média de 180 ms por chamada à Meta, 40% dos PAID em WINDOW_DIRECT (que custam 3 mensagens cada, contra 1 do template).

Base Composição do dia útil Mensagens no disparo Tempo a 20/s Tempo com folga de retry (+30%) Dentro da janela de 20 min?
3.000 assinantes (900 PAID) 360 WINDOW_DIRECT × 3 + 540 template 1.620 1 min 21 s 1 min 46 s Sim
10.000 (3.000 PAID) 1.200 × 3 + 1.800 5.400 4 min 30 s 5 min 51 s Sim
50.000 (15.000 PAID) 6.000 × 3 + 9.000 27.000 22 min 30 s 29 min 15 s Não
Base Domingo (pico: PAID + FREE) Mensagens Tempo a 20/s A 60/s
3.000 (2.100 FREE) 1.620 + 2.100 3.720 3 min 6 s 1 min 2 s
10.000 (7.000 FREE) 5.400 + 7.000 12.400 10 min 20 s 3 min 27 s
50.000 (35.000 FREE) 27.000 + 35.000 62.000 51 min 40 s 17 min 13 s

Conclusões operacionais registradas:

  • Até 10.000 assinantes, a configuração padrão de 20 msg/s cumpre a janela de 20 minutos com folga, inclusive no pico de domingo.
  • A partir de cerca de 25.000 assinantes, send.rate_per_second precisa subir para 60. É uma mudança de chave de configuração, não de arquitetura, e exige tier ilimitado e quality_rating GREEN.
  • Em 50.000, mesmo a 60 msg/s o domingo leva 17 minutos. A mitigação registrada é escalonar o envio FREE em duas ondas (06:00 e 06:30) por faixa de id, mantendo o PAID inteiramente na primeira onda. O campo send_batches.batch_kind é criado para isso (Seção 6.22).
  • O gargalo nunca é o nosso processo: 12 workers a 180 ms sustentam 66 msg/s. O limite é sempre a plataforma.
  • Os envios free-form disparados por interação ao longo do dia (89.100/mês no cenário de 10.000, Seção 17.12) chegam a menos de 4 msg/s no pico das 06:05 às 06:20 e cabem no mesmo teto sem contenção perceptível.

18.15 Casos de borda #

# Situação Comportamento definido
1 send.plan roda duas vezes às 05:40 jobId determinístico + ON CONFLICT DO NOTHING; a segunda execução registra plan.duplicates_skipped e não cria nada
2 Worker cai às 06:07 com o lote pela metade Locks expiram em 60 s; os jobs voltam à fila; claimAttempt e steps_completed impedem duplicidade (Seção 18.9)
3 Redis reinicia durante o disparo Jobs em voo são perdidos, mas delivery_attempts continua em SENDING com locked_at velho; a varredura das 09:00 os recolhe. Nenhum assinante recebe duas vezes
4 Assinante vira PAID às 05:50 O lote de hoje já o classificou como FREE. Se hoje não é domingo, ele não está no lote: o painel oferece "Receber o devocional de hoje agora", que cria a tentativa sob demanda. A partir de amanhã, fluxo normal
5 Assinante vira FREE às 05:52 (falha de pagamento) A revalidação da Seção 18.5.1 relê o tier no disparo: o item é rebaixado na hora, com downgraded_at e tier_at_send_effective = 'FREE'; ele recebe o texto e não recebe o áudio. Sem carência, conforme a Seção 13.3
6 Devocional publicado às 05:59 O lote das 05:40 usou a reserva. O painel avisa e oferece reagendar. Não há disparo duplo
7 Horário de verão O Brasil não tem horário de verão desde 2019, mas o job usa tz: 'America/Sao_Paulo' e date-fns-tz, então uma reintrodução não exigiria mudança de código
8 Dois workers processam o mesmo item claimAttempt é um UPDATE ... RETURNING condicional; apenas um recebe a linha, o outro retorna already_processed
9 Assinante interage às 23:58 A entrega dispara; a janela abre; o pacote sai. O fechamento das 23:55 já rodou, então consecutive_window_misses foi incrementado indevidamente — corrigido pelo próprio processamento do inbound, que zera o contador
10 Lote com 0 destinatários (sábado sem PAID) send_batches.status = 'COMPLETED' com planned_count = 0. Não é erro e não alerta
11 Tier cai de 100K para 10K por restrição send.plan lê o tier antes de confirmar e trunca priorizando PAID; alerta alto (Seção 18.3.3)
12 Reenvio manual pedido enquanto o lote roda Permitido. O contador é independente e a delivery_attempts do dia já existe; o reenvio só acrescenta message_logs
13 Assinante pausado com paused_until no passado A cláusula do planejamento aceita paused_until <= now(), então ele já volta ao lote sem depender de job nenhum. O messaging.pause das 05:30 é cosmético: limpa a coluna e enfileira a saudação de retorno; se falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação
14 Relógio do container com deriva Os jobs repetíveis usam o relógio do Redis para agendamento e now() do banco para as janelas, o que elimina divergência entre réplicas
15 Assinante envia SAIR às 06:07, com o lote em execução e o item dele ainda não processado A revalidação do disparo (Seção 18.5.1) encontra opt_out_at preenchido e encerra o item como SKIPPED_OPTED_OUT, sem nenhuma chamada ao provedor. Ele não recebe nada daquele lote
16 Evento de inadimplência processado às 05:52, depois do planejamento O item é rebaixado no disparo: texto do plano gratuito, sem áudio, com downgraded_at preenchido. É a regra de revogação imediata alcançando o lote já planejado
17 Pedido de eliminação concluído entre 05:40 e 06:00 deleted_at preenchido; a revalidação encerra como SKIPPED_INELIGIBLE e nenhuma chamada ao provedor acontece com o telefone daquele titular
18 Lote atinge 20% de falha nas primeiras 200 tentativas Contenção automática (Seção 18.10): status = 'HALTED_BY_GUARD', alerta crítico, itens pendentes preservados. A retomada é humana, pelo runbook R-03

19. Catálogo de Mensagens e Fluxos Conversacionais #

19.1 Princípios e tom de voz #

Todo texto que o sistema envia por WhatsApp ou e-mail está catalogado aqui. Nenhuma string voltada ao assinante é escrita fora deste catálogo: as mensagens vivem em packages/core/src/messages/ como funções tipadas que recebem variáveis e devolvem texto pronto, e o worker nunca concatena texto à mão.

Tom de voz. Direto, respeitoso, adulto. O assinante é uma pessoa que acorda cedo e escolheu receber isso.

Princípio O que significa
Frases curtas Uma ideia por frase. WhatsApp é lido em tela pequena, em movimento
Sem venda dentro do conteúdo O devocional nunca menciona plano, preço ou upgrade. Isso vive em mensagens próprias
Sem urgência artificial Nada de "última chance", "só hoje", "não perca"
Sem emoji Zero. Reduz risco de reclassificação de template e combina com o conteúdo
Formatação com moderação *negrito* só no título e no rótulo "Oração". _itálico_ só na referência bíblica. Nunca em blocos inteiros
Segunda pessoa "você", nunca "o assinante" ou "o usuário"
Erro sem culpa "Não conseguimos entregar" e não "você não recebeu"
Saída sempre visível Toda mensagem iniciada pelo sistema deixa claro como sair
Ruim Por quê Bom
"[emoji] BOM DIA, GUERREIRO! [emoji] Seu devocional CHEGOU!" Caixa alta, emoji, tom de campanha "Bom dia. O devocional de hoje já está pronto para você."
"Ops! Algo deu errado :( tenta de novo mais tarde" Infantil, sem informação, sem próximo passo "Não conseguimos processar agora. Tente de novo em alguns minutos ou responda AJUDA."
"Você não pagou e perdeu o acesso." Acusatório "Não conseguimos confirmar seu pagamento, e o acesso ao áudio foi encerrado hoje."
"Aproveite! Assine já e receba 30 devocionais + BÔNUS!" Venda dentro do conteúdo "Você recebe o devocional em texto aos domingos. No plano pago, todos os dias com áudio."
"Clique aqui: bit.ly/pd2026" Encurtador reprova template e reduz confiança "Acesse app.palavradiaria.com.br/conta"
  • Chave: familia.evento em snake_case, estável, usada em logs e em message_logs.message_key.
  • Tipo: TEMPLATE (fora da janela, exige aprovação — Seção 17.5) ou FREE_FORM (dentro da janela) ou EMAIL.
  • Variáveis: em templates são posicionais ({{1}}, {{2}}) e passam obrigatoriamente por sanitizeTemplateParam (Seção 17.7). Em free-form são nomeadas ({{nome}}) e admitem quebra de linha.
  • Limites: template body 1.024; parâmetro de template sem quebra de linha, sem tabulação e sem 4+ espaços; texto livre 4.096; teaser 300 em uma única linha (Seção 17.2).
  • Falha: toda mensagem declara o que acontece se não for entregue. O padrão é retry pelo motor (Seção 18.10); quando há caminho alternativo, ele está explícito.
  • Fila de saída: mensagens de WhatsApp saem por send.dispatch ou send.followup; mensagens de e-mail saem exclusivamente pela fila email.send. As treze filas do sistema estão na Seção 18.8 e este catálogo não cria nenhuma outra.
Chave Gatilho Canal Tipo Variáveis Se falhar
otp.code Pedido de login WhatsApp TEMPLATE codigo_acesso_v1 código Após 30 s sem delivered, oferece magic link por e-mail
otp.rate_limited 4º pedido de OTP na hora Web minutos Exibido na tela; sem envio
auth.magic_link_email Fallback de login E-mail EMAIL link, validade Exibe erro na tela e sugere tentar o WhatsApp
onboarding.welcome_optin Cadastro concluído WhatsApp TEMPLATE boas_vindas_v1 nome Reenvia 1× em 6 h; depois e-mail
onboarding.optin_confirmed Toque em "Sim, quero receber" ou SIM WhatsApp FREE_FORM nome, primeiroEnvio Retry padrão
onboarding.optin_declined Toque em "Agora não" WhatsApp FREE_FORM Retry padrão
onboarding.optin_pending_reminder 48 h sem confirmar WhatsApp TEMPLATE boas_vindas_v1 nome Uma única vez; depois só e-mail
devotional.invite Disparo 06:00, janela fechada WhatsApp TEMPLATE devocional_diario_v1 título, teaser Seção 18.10
devotional.invite_video 4º dia sem interação (PAID) WhatsApp TEMPLATE devocional_diario_video_v1 título, teaser, mídia Cai para devotional.invite
devotional.full_text Janela aberta WhatsApp FREE_FORM conteúdo completo Retry 3×; depois entra no lote de amanhã
devotional.closing Após texto e áudio WhatsApp FREE_FORM próximoEnvio Silencioso: não repete
devotional.free_weekly Domingo, tier FREE WhatsApp FREE_FORM conteúdo completo Retry padrão
devotional.audio_unavailable PAID, áudio não pronto WhatsApp FREE_FORM Silencioso
devotional.snoozed Toque em "Depois" WhatsApp FREE_FORM Silencioso
devotional.resend_today Palavra HOJE ou botão do painel WhatsApp FREE_FORM ou TEMPLATE conteúdo Retry padrão
devotional.resend_limit Limite diário atingido WhatsApp FREE_FORM limite, tier Silencioso
devotional.late_open Botão de devocional antigo WhatsApp FREE_FORM data Retry padrão
devotional.none_today Pedido de HOJE sem lote WhatsApp FREE_FORM próximoEnvio Silencioso
billing.subscription_confirmed PAYMENT_CONFIRMED WhatsApp TEMPLATE pagamento_confirmado_v1 nome, valor, próximaCobrança Fallback e-mail email.receipt
billing.pix_charge_created Cobrança PIX gerada WhatsApp FREE_FORM (ou template se fora da janela) valor, vencimento, link Fallback e-mail
billing.reminder_d3 3 dias antes do vencimento WhatsApp TEMPLATE lembrete_pagamento_v1 nome, data, valor, link Fallback e-mail
billing.reminder_d1 1 dia antes WhatsApp TEMPLATE lembrete_pagamento_v1 idem Fallback e-mail
billing.reminder_d0 Dia do vencimento, 09:00 WhatsApp TEMPLATE lembrete_pagamento_v1 idem Fallback e-mail
billing.card_expiring Cartão vence em 15 dias WhatsApp TEMPLATE lembrete_pagamento_v1 nome, mês/ano Fallback e-mail
billing.access_revoked PAYMENT_OVERDUE / DELETED WhatsApp TEMPLATE acesso_encerrado_v1 nome Fallback email.access_revoked
billing.refund_processed PAYMENT_REFUNDED WhatsApp TEMPLATE acesso_encerrado_v1 (variante) nome, valor, prazo Fallback e-mail
billing.chargeback_opened PAYMENT_CHARGEBACK_REQUESTED E-mail EMAIL valor Registro interno; sem WhatsApp
billing.cancellation_requested Cancelamento no painel WhatsApp FREE_FORM fimDoCiclo Fallback e-mail
billing.cancellation_effective Fim do ciclo pago WhatsApp TEMPLATE acesso_encerrado_v1 nome Fallback e-mail
optout.confirmed Qualquer uma das sete palavras de saída da Seção 19.5, ou o payload OPT_OUT WhatsApp FREE_FORM Retry 2×; depois registra e para. O opt_out_at já está gravado: a falha do aviso nunca desfaz a saída
optout.already SAIR de quem já saiu WhatsApp FREE_FORM Silencioso
optout.paid_warning SAIR com assinatura ativa WhatsApp FREE_FORM fimDoCiclo, link Retry padrão
reactivation.campaign 30 dias sem receber áudio WhatsApp TEMPLATE reativacao_v1 nome, dias Sem retry: é MARKETING
reactivation.confirmed VOLTAR ou botão WhatsApp FREE_FORM próximoEnvio Retry padrão
pause.started PAUSAR ou painel WhatsApp FREE_FORM retornoEm Retry padrão
pause.ended Fim da pausa WhatsApp FREE_FORM Silencioso
phone.change_started Troca de número no painel WhatsApp (número novo) TEMPLATE codigo_acesso_v1 código Bloqueia a troca
phone.change_completed Troca confirmada WhatsApp (ambos) FREE_FORM númeroNovo Retry padrão
help.menu AJUDA/MENU WhatsApp FREE_FORM (interativa) tier Retry 1×; depois versão em texto
help.unrecognized Texto não reconhecido WhatsApp FREE_FORM Silencioso; limite anti-loop
help.unsupported_content Áudio, imagem, vídeo, localização WhatsApp FREE_FORM Silencioso
support.human_business_hours FALAR COM ALGUÉM, 9h–18h úteis WhatsApp FREE_FORM protocolo Retry padrão
support.human_after_hours Fora do horário WhatsApp FREE_FORM protocolo, retorno Retry padrão
system.generic_error Exceção não tratada no roteador WhatsApp FREE_FORM Silencioso
system.maintenance_notice Manutenção programada WhatsApp TEMPLATE boas_vindas_v1 (variante) janela Sem retry
survey.monthly Dia 15, PAID há 30+ dias, dentro da janela WhatsApp FREE_FORM (interativa) Sem retry; tenta no mês seguinte
survey.thanks Resposta da pesquisa WhatsApp FREE_FORM nota Silencioso
email.welcome Cadastro com e-mail E-mail EMAIL nome, link Registra bounce
email.receipt Pagamento confirmado E-mail EMAIL valor, período, link Registra bounce
email.access_revoked Revogação de acesso E-mail EMAIL nome, link Registra bounce
email.data_export_ready Export LGPD pronto E-mail EMAIL link, validade Reemite sob demanda

19.4 Textos exatos #

19.4.1 Autenticação e onboarding #

[otp.code] TEMPLATE codigo_acesso_v1 (corpo padronizado pela Meta, traduzido para pt_BR)
{{1}} é seu código de verificação. Por segurança, não compartilhe este código.
Rodapé: Este código expira em 10 minutos.
Botão: Copiar código

[otp.rate_limited] tela web
Você pediu vários códigos seguidos. Aguarde {{minutos}} minutos e tente de novo. Se o
código anterior chegou, ele ainda é válido.

[auth.magic_link_email] e-mail — assunto: Seu acesso à Palavra Diária
Olá. Use o link abaixo para entrar na sua conta. Ele vale por 15 minutos e só pode ser
usado uma vez.
{{link}}
Se não foi você quem pediu, ignore este e-mail. Nada muda na sua conta.

[onboarding.welcome_optin] TEMPLATE boas_vindas_v1
Cabeçalho: Bem-vindo à Palavra Diária
Olá, {{1}}. Seu cadastro foi criado.

Para começar a receber o devocional, confirme abaixo. Só depois da sua confirmação
enviaremos qualquer conteúdo.
Rodapé: Você pode cancelar quando quiser
Botões: Sim, quero receber | Agora não

[onboarding.optin_confirmed] FREE_FORM
Confirmado, {{nome}}. Você começa a receber o devocional {{primeiroEnvio}}, às 6h.

Guarde este número nos seus contatos para não perder as mensagens.

A qualquer momento: responda AJUDA para ver as opções, ou SAIR para cancelar.

[onboarding.optin_declined] FREE_FORM
Tudo bem. Não vamos enviar nada.

Se mudar de ideia, responda VOLTAR e começamos no dia seguinte.

[onboarding.optin_pending_reminder] TEMPLATE boas_vindas_v1
Olá, {{1}}. Seu cadastro está pronto, mas ainda falta confirmar para começarmos a
enviar o devocional. É só tocar no botão abaixo.

19.4.2 Devocional #

[devotional.invite] TEMPLATE devocional_diario_v1
Cabeçalho: Devocional de hoje
Bom dia. O devocional de hoje já está pronto para você.

*{{1}}*

{{2}}

Toque no botão abaixo para receber o texto completo e o áudio.
Rodapé: Responda SAIR para cancelar
Botões: Ler e ouvir agora | Depois

  {{1}} = título, até 120 caracteres, uma linha.
  {{2}} = teaser, até 300 caracteres, uma linha, sanitizado (Seção 17.7).
  Exemplo de teaser: "Quando Deus demora, Ele não esqueceu. Salmos 27 mostra o que
  fazer no intervalo entre o pedido e a resposta." (114 caracteres)

[devotional.invite_video] TEMPLATE devocional_diario_video_v1
Cabeçalho: vídeo MP4 com a capa do dia e o áudio completo
Bom dia. O devocional de hoje está no vídeo acima, com o áudio completo.

*{{1}}*

{{2}}

Para receber o texto completo, toque no botão.
Rodapé: Responda SAIR para cancelar
Botões: Quero o texto | Depois

[devotional.full_text] FREE_FORM — até 4.096 caracteres
*{{titulo}}*
_{{referencia}} ({{versao}})_

{{textoBiblico}}

{{reflexao}}

*Oração*
{{oracao}}

[devotional.closing] FREE_FORM
O próximo devocional chega {{proximoEnvio}}, às 6h.

Responda AJUDA para ver as opções.

  proximoEnvio para PAID: "amanhã". Para FREE: "no próximo domingo".

[devotional.free_weekly] FREE_FORM — enviado aos domingos ao tier FREE
*{{titulo}}*
_{{referencia}} ({{versao}})_

{{textoBiblico}}

{{reflexao}}

*Oração*
{{oracao}}

Você recebe o devocional em texto todo domingo. No plano pago, ele chega todos os dias,
com áudio narrado: app.palavradiaria.com.br/assinar

[devotional.audio_unavailable] FREE_FORM
O áudio de hoje ainda está sendo preparado. Assim que ficar pronto, ele aparece no seu
acervo em app.palavradiaria.com.br/acervo. O texto acima está completo.

[devotional.snoozed] FREE_FORM
Sem problema. O devocional de hoje fica guardado até as 23h59.

Quando quiser, responda HOJE e eu envio na hora.

[devotional.resend_today] FREE_FORM
Aqui está o devocional de hoje.

  (seguido de devotional.full_text e, para PAID, do áudio)

[devotional.resend_limit] FREE_FORM
Você já pediu o devocional de hoje {{limite}} vezes, que é o limite do seu plano. O
contador reinicia à meia-noite.

Ele continua disponível em app.palavradiaria.com.br/acervo.

[devotional.late_open] FREE_FORM
Este é o devocional de {{data}}. O de hoje já foi enviado — se quiser, responda HOJE.

[devotional.none_today] FREE_FORM
Ainda não há devocional para hoje. O próximo chega {{proximoEnvio}}, às 6h.

19.4.3 Cobrança e assinatura #

[billing.subscription_confirmed] TEMPLATE pagamento_confirmado_v1
Cabeçalho: Pagamento confirmado
Pronto, {{1}}. Recebemos seu pagamento de {{2}}.

A partir de amanhã, às 6h, você recebe o devocional em texto e áudio todos os dias.
Sua próxima cobrança é em {{3}}.
Rodapé: Obrigado por assinar
Botão: Ver minha conta

[billing.pix_charge_created] FREE_FORM
Sua cobrança PIX de {{valor}} está pronta e vence em {{vencimento}}.

Pague pelo link: {{link}}

Assim que o pagamento cair, o áudio diário é liberado automaticamente.

[billing.reminder_d3 | billing.reminder_d1 | billing.reminder_d0] TEMPLATE lembrete_pagamento_v1
Cabeçalho: Sua assinatura
Olá, {{1}}. Sua assinatura da Palavra Diária vence em {{2}}.

Valor: {{3}}

Pague pelo link abaixo para manter o áudio diário ativo.
Rodapé: Pagamento processado pela Asaas
Botão: Pagar agora

  D-3: {{2}} = "11/09/2026". D-1: "amanhã, 11/09". D0: "hoje, 11/09".

[billing.card_expiring] TEMPLATE lembrete_pagamento_v1 (variante)
Olá, {{1}}. O cartão da sua assinatura vence em {{2}}. Atualize antes da próxima
cobrança para não perder o áudio diário.

[billing.access_revoked] TEMPLATE acesso_encerrado_v1
Cabeçalho: Sua assinatura foi encerrada
Olá, {{1}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao
áudio diário foi encerrado hoje.

Você continua recebendo o devocional em texto aos domingos, sem custo. Para voltar a
receber todos os dias com áudio, reative abaixo.
Rodapé: Responda SAIR para não receber mais nada
Botão: Reativar assinatura

[billing.refund_processed] TEMPLATE acesso_encerrado_v1 (variante)
Olá, {{1}}. Seu reembolso de {{2}} foi processado e chega em até {{3}} dias úteis,
conforme o prazo do seu banco.

O acesso ao áudio diário foi encerrado. Você continua recebendo o devocional em texto
aos domingos.

[billing.chargeback_opened] e-mail — assunto: Contestação recebida
Recebemos uma contestação de {{valor}} referente à sua assinatura. O acesso ao áudio
diário foi suspenso enquanto o caso é analisado pelo seu banco. Se foi um engano,
responda a este e-mail.

[billing.cancellation_requested] FREE_FORM
Cancelamento registrado. Você continua com acesso ao áudio diário até {{fimDoCiclo}},
que é o período que você já pagou. Depois disso, volta a receber o devocional em texto
aos domingos.

Mudou de ideia? Responda VOLTAR antes de {{fimDoCiclo}}.

[billing.cancellation_effective] TEMPLATE acesso_encerrado_v1 (variante)
Olá, {{1}}. Seu período pago terminou hoje. A partir de agora você recebe o devocional
em texto aos domingos.

Para voltar a receber todos os dias com áudio, reative abaixo.

A distinção entre billing.access_revoked (falha de pagamento, efeito imediato) e billing.cancellation_effective (cancelamento pedido pelo assinante, efeito no fim do ciclo já pago) é obrigatória e está refletida nos dois textos. Não há carência em nenhum dos casos — no segundo, o que existe é o período já pago, conforme a Seção 13.

19.4.4 Saída, retorno, pausa e troca de número #

[optout.confirmed] FREE_FORM
Pronto. Você não vai mais receber o devocional.

Se mudar de ideia, responda VOLTAR a qualquer momento.

[optout.already] FREE_FORM
Você já está fora da lista e não recebe mais o devocional.

Para voltar, responda VOLTAR.

[optout.paid_warning] FREE_FORM
Você não vai mais receber mensagens.

Sua próxima cobrança foi suspensa. O período que você já pagou continua valendo até
{{fimDoCiclo}} e, sem reativação em 30 dias, a assinatura é encerrada. Para ver os
detalhes ou voltar antes disso, acesse {{link}} ou responda VOLTAR.

[reactivation.campaign] TEMPLATE reativacao_v1
Olá, {{1}}. Faz {{2}} dias que você não recebe o devocional em áudio.

Se quiser voltar, é só tocar abaixo. Sua conta e seu histórico continuam salvos.
Rodapé: Responda SAIR para não receber mais
Botões: Quero voltar | Não, obrigado

[reactivation.confirmed] FREE_FORM
Que bom ter você de volta. O próximo devocional chega {{proximoEnvio}}, às 6h.

[pause.started] FREE_FORM
Pausado. Você não recebe nada até {{ultimoDiaPausado}}, e volta a receber em {{retornoEm}}, às 6h.

Para voltar antes, responda VOLTAR.

  ultimoDiaPausado é o último dia SEM envio; retornoEm é o primeiro dia COM envio.
  Dizer só uma das duas datas é o que faz o assinante achar que perdeu um dia
  (Seção 20.6.1).

[pause.ended] FREE_FORM
Sua pausa terminou. Amanhã, às 6h, o devocional volta.

[phone.change_started] TEMPLATE codigo_acesso_v1
  (enviado ao NÚMERO NOVO; o código confirma a posse antes da troca)

[phone.change_completed] FREE_FORM
  (ao número novo)
Número atualizado. A partir de agora o devocional chega aqui.
  (ao número antigo)
O número da sua conta foi alterado para {{numeroNovo}}. Se não foi você, responda
AJUDA agora.

Canal da confirmação de saída. optout.confirmed, optout.already e optout.paid_warning só saem pelo WhatsApp quando o opt-out foi pedido pelo WhatsApp, e nesse caso é uma única mensagem de confirmação. Quando a origem é o painel do assinante ou o link de descadastro, a confirmação aparece na tela e por e-mail, quando houver e-mail verificado, e nenhuma mensagem de WhatsApp é enviada: quem pediu para parar de receber mensagens por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp (Seção 20.4.1).

19.4.5 Ajuda, suporte, sistema e pesquisa #

[help.menu] FREE_FORM interativa (3 botões) + texto de apoio
Como posso ajudar?
Botões: Reenviar hoje | Minha assinatura | Falar com alguém

Você também pode responder:
HOJE - receber o devocional de hoje
PAUSAR - parar por 7 dias
SAIR - cancelar o envio
VOLTAR - voltar a receber
ASSINAR - conhecer o plano com áudio diário

[help.unrecognized] FREE_FORM
Não entendi. Responda AJUDA para ver o que eu consigo fazer.

[help.unsupported_content] FREE_FORM
Recebi sua mensagem, mas não consigo abrir áudios, imagens ou arquivos por aqui.

Se precisa de ajuda, responda AJUDA. Para falar com uma pessoa, responda FALAR COM ALGUÉM.

[support.human_business_hours] FREE_FORM
Certo. Uma pessoa da equipe assume esta conversa em instantes.

Seu protocolo é {{protocolo}}. Pode escrever sua dúvida aqui.

[support.human_after_hours] FREE_FORM
Nosso atendimento funciona de segunda a sexta, das 9h às 18h.

Registrei seu pedido com o protocolo {{protocolo}}. Alguém responde {{retorno}}.

Se for sobre cobrança, você também pode resolver agora em
app.palavradiaria.com.br/conta.

[system.generic_error] FREE_FORM
Não consegui processar isso agora. Tente de novo em alguns minutos.

Se continuar, responda FALAR COM ALGUÉM.

[system.maintenance_notice] TEMPLATE
Manutenção programada: {{1}}. Durante esse período o painel pode ficar indisponível.
O devocional das 6h não será afetado.

[survey.monthly] FREE_FORM interativa
De 0 a 10, qual a chance de você indicar a Palavra Diária para alguém?

Responda com um número. Leva 5 segundos e ajuda muito.

[survey.thanks] FREE_FORM
Obrigado pela nota {{nota}}. Se quiser contar o motivo, é só escrever aqui.

19.4.6 E-mails #

[email.welcome] assunto: Sua conta na Palavra Diária está pronta
Olá, {{nome}}. Sua conta foi criada com o número {{telefone}}.
Acesse o painel: {{link}}
O devocional chega pelo WhatsApp, todos os dias às 6h (ou aos domingos, no plano gratuito).

[email.receipt] assunto: Recibo — Palavra Diária
Pagamento de {{valor}} confirmado.
Período: {{inicio}} a {{fim}}. Método: {{metodo}}.
Segunda via e histórico: {{link}}

[email.access_revoked] assunto: Seu acesso ao áudio diário foi encerrado
Olá, {{nome}}. Não conseguimos confirmar o pagamento da sua assinatura, e o acesso ao
áudio diário foi encerrado hoje. Você continua recebendo o devocional em texto aos
domingos. Para reativar: {{link}}

[email.data_export_ready] assunto: Seus dados estão prontos para download
O arquivo com todos os seus dados está disponível em {{link}}. O link vale por
{{validade}} e depois expira por segurança.

19.5 Palavras-chave de entrada #

Precedência absoluta das palavras-chave de saída. O reconhecimento de SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR e REMOVER é a primeira operação executada sobre toda mensagem de entrada: antes da proteção anti-loop (Seção 19.6), antes do escalonamento humano (Seção 19.7), antes da entrega pendente (Seção 18.6), antes de qualquer classificação de intenção, e independentemente do estado do assinante — inclusive BLOCKED, pausado, em atendimento humano, em modo silencioso ou já com deleted_at preenchido. Nenhum caminho de código pode consumir uma mensagem de entrada antes desse teste. Um teste de integração envia cada uma das sete palavras em cada estado possível do assinante e falha se opt_out_at não for gravado em qualquer um deles. O motivo: uma palavra de saída não honrada é, ao mesmo tempo, descumprimento do Art. 8º, § 5º da LGPD e a causa direta da denúncia dentro do aplicativo que derruba a reputação do número (Seção 17.10).

Normalização antes de qualquer comparação: trim, maiúsculas, remoção de acentos (NFD + remoção de diacríticos), colapso de espaços internos, remoção de pontuação final. " Sair!! " e "SAIR" produzem a mesma chave. A implementação canônica é normalizeKeyword() da Seção 20.3.2, que termina em .toUpperCase(); nenhuma seção reimplementa a normalização, e as tabelas de palavras-chave deste documento estão em maiúsculas porque é essa a forma normalizada. O reconhecimento exige correspondência exata da mensagem inteira ou de uma das variantes listadas — nunca includes, porque "não quero sair do plano" não é um pedido de opt-out.

Palavra-chave (e variantes) Ação Resposta
AJUDA, MENU, OPCOES, ? Abre o menu help.menu
HOJE, DEVOCIONAL, DEVOCIONAL DE HOJE Reenvia o devocional do dia, respeitando o limite (Seção 18.11) devotional.resend_today, devotional.resend_limit ou devotional.none_today
SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER opt_out_at = now; envios cessam imediatamente; consent_events recebe registro com type = 'OPT_OUT' optout.confirmed, optout.already ou optout.paid_warning
Payload OPT_OUT Mesmo efeito de SAIR, sem passar pela normalização — é payload de botão, não texto optout.confirmed
VOLTAR, RETORNAR, QUERO VOLTAR, REATIVAR Limpa opt_out_at e paused_until; registra consent_events com type = 'RE_OPT_IN' reactivation.confirmed
PAUSAR, PAUSA, FERIAS, FÉRIAS Pausa de 7 dias, o padrão do canal. Um número de 1 a 30 na mesma mensagem (ex.: PAUSAR 15) define a duração pause.started
ASSINAR, PLANO, PRECO, AUDIO Envia o link de checkout Texto curto com app.palavradiaria.com.br/assinar
FALAR COM ALGUEM, ATENDENTE, HUMANO, SUPORTE, AJUDA HUMANA Abre atendimento (Seção 19.7) support.human_business_hours ou support.human_after_hours
SIM, CONFIRMO, QUERO RECEBER Confirma opt-in, se pendente onboarding.optin_confirmed
CANCELAR ASSINATURA Inicia cancelamento da cobrança (Seção 20) billing.cancellation_requested
Número de 0 a 10, com pesquisa aberta Registra a nota survey.thanks
Payload OPEN_DEVOTIONAL:{id} Entrega o devocional daquele id Pacote completo
Payload SNOOZE:{id} Mantém pendente até 23:59 devotional.snoozed
Payload CONFIRM_OPTIN Confirma opt-in onboarding.optin_confirmed
Payload MENU_RESEND / MENU_BILLING / MENU_HUMAN Atalhos do menu Conforme a ação

As sete palavras de saída são exatamente estas, em todo o documento e na chave send.optout_keywords de settings: SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER. Nenhuma lista mais curta é válida.

Regra de desempate entre CANCELAR e CANCELAR ASSINATURA. CANCELAR sozinho é opt-out de mensagens. CANCELAR ASSINATURA é pedido de cancelamento da cobrança. Como o reconhecimento exige correspondência exata da mensagem inteira, a mensagem mais longa nunca cai na regra mais curta — não existe ambiguidade a resolver por heurística. Quando o assinante enviar CANCELAR e tiver assinatura paga vigente, a resposta é optout.paid_warning, que confirma a parada dos envios e informa o efeito financeiro: a próxima cobrança é suspensa, o período já pago continua valendo e, em 30 dias sem reativação, a assinatura é encerrada (Seção 13.4.6).

Precedência. SAIR e suas variantes têm prioridade máxima e são avaliadas antes de qualquer outra regra, inclusive antes da entrega pendente, do modo silencioso e do atendimento humano. Um assinante que responde SAIR ao template das 06:00 não recebe o pacote completo: recebe apenas a confirmação de saída. Isso é conformidade, não preferência.

19.6 Mensagem não reconhecida e proteção anti-loop #

Regra Valor
Resposta por janela Uma única help.unrecognized por janela de atendimento de 24 h
Contador subscribers.unrecognized_replies_in_window, zerado ao abrir nova janela
Após a primeira Mensagens não reconhecidas seguintes são registradas em inbound_messages e não recebem resposta
Exceção Palavras-chave sempre respondem, quantas vezes forem enviadas
Teto absoluto Máximo de 5 mensagens automáticas enviadas ao mesmo assinante por hora, contando tudo
Conteúdo não textual help.unsupported_content, sujeito ao mesmo limite de uma por janela

Nenhuma regra desta subseção alcança as palavras-chave de saída: elas são avaliadas antes de tudo o que está aqui (Seção 19.5). O modo silencioso silencia a resposta automática, nunca o opt-out.

Nunca responder a mensagem automática. Três guardas, aplicadas em ordem: (1) se o telefone de origem — inbound_messages.phone_e164, decifrado na aplicação (Seção 22.5) e normalizado pela Seção 11.4 — consta da lista whatsapp.no_reply_numbers em settings, ignora; (2) se a mensagem tem context.forwarded = true ou context.frequently_forwarded = true, registra e não responde — encaminhamento em massa não é conversa; (3) se o mesmo texto exato chegou 3 vezes em 60 segundos, ativa subscribers.autoreply_suppressed_until = now + 1 h e emite inbound.loop_suspected.

Sem essas guardas, dois sistemas automáticos conversando produzem milhares de mensagens em minutos, com custo real e dano à qualidade do número (Seção 17.10).

19.7 Escalonamento para atendimento humano #

Aspecto Decisão
Gatilho Palavra-chave de suporte, botão MENU_HUMAN, ou 3 help.unrecognized na mesma janela
Horário Segunda a sexta, 9h às 18h (America/Sao_Paulo), exceto feriados nacionais
Efeito imediato subscribers.human_handoff_until = now + 2 h; todas as respostas automáticas ficam suspensas nesse período, exceto o devocional das 06:00
O que o atendimento humano não suspende As palavras-chave de saída. Uma mensagem que seja exatamente uma das sete palavras da Seção 19.5 é processada como opt-out mesmo com atendimento humano aberto, e a confirmação optout.confirmed é enviada. Segurar um SAIR porque a conversa está com uma pessoa é o modo mais fácil de perder o número por denúncia
Protocolo ULID curto com prefixo PD-, exibido ao assinante e gravado em inbound_messages.support_protocol
Ferramenta O painel administrativo tem a caixa "Atendimento", com as conversas abertas ordenadas por tempo de espera, e permite responder free-form dentro da janela
SLA 1 dia útil para PAID (matriz da Seção 7); melhor esforço para FREE
Fora do horário support.human_after_hours, com o próximo horário útil calculado e informado ("na segunda, a partir das 9h")
Janela expirada Se a janela de 24 h fechar antes da resposta humana, o atendente vê o aviso no painel e o contato passa a e-mail, quando houver e-mail verificado
Encerramento O atendente marca a conversa como resolvida, o que limpa human_handoff_until e devolve o controle ao roteador automático

19.8 Fluxos conversacionais #

19.8.1 Cadastro e opt-in #

WEB                                    SISTEMA                        WHATSAPP
 |                                        |                              |
 |-- telefone + CPF + checkbox ---------->|                              |
 |   (consentimento versionado)           |-- consent_events (append) --> |
 |                                        |-- otp.code (TEMPLATE) ------->|
 |                                        |                              |
 |<-- tela "digite o código" -------------|                    [Ana digita 4 8 2 9 1 7]
 |-- código ----------------------------->|                              |
 |                                        |  válido? TTL 10 min, 5 tentativas
 |                                        |                              |
 |<-- conta criada, tier FREE ------------|-- onboarding.welcome_optin -->|
 |                                        |    [Sim, quero receber] [Agora não]
 |                                        |                              |
 |                                        |<-- CONFIRM_OPTIN ------------|
 |                                        |  opt_in_confirmed_at = now
 |                                        |  service_window = +24 h
 |                                        |-- onboarding.optin_confirmed >|
 |                                        |                              |
 |                          NENHUM DEVOCIONAL É ENVIADO ANTES DESTE PONTO
 |                                        |
 |                          48 h sem confirmar -> onboarding.optin_pending_reminder (1x)
 |                          7 dias sem confirmar -> cadastro fica inativo, sem envio

19.8.2 Entrega diária #

05:40  send.plan resolve a rota de cada assinante (Seção 18.3.3)
06:00  |
       +-- rota WINDOW_DIRECT (janela aberta) ------------------------------+
       |     -> devotional.full_text                                        |
       |     -> áudio (só PAID)                                             |
       |     -> devotional.closing                        custo US$ 0,00    |
       |                                                                    |
       +-- rota TEMPLATE_INVITE --------------------------------------------+
       |     -> devotional.invite                          custo UTILITY    |
       |            |                                                       |
       |            +-- [Ler e ouvir agora] --> janela abre                 |
       |            |     -> full_text -> áudio (PAID) -> closing           |
       |            |     latência p95 < 5 s               custo US$ 0,00   |
       |            |                                                       |
       |            +-- [Depois] --> devotional.snoozed                     |
       |            |     pendente até 23:59; HOJE entrega na hora          |
       |            |                                                       |
       |            +-- sem interação até 23:59                             |
       |                  -> EXPIRED_NO_INTERACTION                         |
       |                  -> consecutive_window_misses++ (só se entregue)   |
       |                                                                    |
       +-- rota TEMPLATE_VIDEO (PAID, 3 dias sem interação) ----------------+
             -> devotional.invite_video (MP4 com áudio no header)
             -> assinante recebe o áudio SEM precisar interagir
             -> interagiu? contador zera, volta ao fluxo normal

19.8.3 Saída, retorno e ajuda #

ASSINANTE escreve                     ROTEADOR (Seção 19.5)
    |                                       |
    |  normaliza com normalizeKeyword(): trim, MAIÚSCULAS, sem acento, sem pontuação final
    |                                       |
    +-- "SAIR"/"PARAR"/"PARE"/"CANCELAR"/-->| PRECEDÊNCIA MÁXIMA, antes de tudo
    |   "STOP"/"DESCADASTRAR"/"REMOVER"     | (inclusive de anti-loop e atendimento humano)
    |                                       | opt_out_at = now
    |                                       | envios cessam IMEDIATAMENTE
    |                                       | consent_events registra a revogação
    |                                       |
    |                          tem assinatura ativa?
    |                                |            |
    |                              não           sim
    |                                |            |
    |<-- optout.confirmed -----------+            |
    |<-- optout.paid_warning ---------------------+
    |     (a assinatura não é cancelada de imediato, mas a cobrança do ciclo
    |      seguinte é suspensa — Seção 13.4.6)
    |
    +-- "VOLTAR" -------------------------->| opt_out_at = NULL, paused_until = NULL
    |<-- reactivation.confirmed ------------|
    |
    +-- "PAUSAR" / "PAUSAR N" ------------->| pausa de 7 dias (padrão) ou de N dias,
    |                                       | com N de 1 a 30 (Seção 20.6.1)
    |<-- pause.started ---------------------|
    |
    +-- "AJUDA" / "MENU" ------------------>| monta menu conforme o tier
    |<-- help.menu (3 botões + texto) ------|
    |
    +-- "HOJE" ---------------------------->| verifica limite do tier (Seção 18.11)
    |<-- resend_today | resend_limit | none_today
    |
    +-- "FALAR COM ALGUEM" ---------------->| human_handoff_until = now + 2 h
    |                                       | automações suspensas
    |<-- human_business_hours | after_hours -|
    |
    +-- texto qualquer -------------------->| primeira da janela?
    |                                       |     sim -> help.unrecognized
    |                                       |     não -> registra e SILENCIA
    |
    +-- áudio/imagem/arquivo -------------->| help.unsupported_content (1x por janela)

19.9 Casos de borda #

# Situação Comportamento definido
1 SAIR respondido ao template das 06:00 Precedência máxima: recebe só optout.confirmed; o pacote completo é cancelado. Se o lote ainda estiver disparando, a revalidação do disparo (Seção 18.5.1) encerra o item como SKIPPED_OPTED_OUT sem chamar o provedor
2 SAIR de quem tem assinatura ativa optout.paid_warning. O opt-out não cancela a assinatura de imediato, mas suspende a cobrança do ciclo seguinte na mesma transação; a regra completa está na Seção 13.4.6
3 VOLTAR de quem nunca saiu Tratado como confirmação de opt-in se pendente; senão, help.unrecognized
4 Texto com "sair" no meio da frase Não aciona opt-out: a correspondência é da mensagem inteira
5 HOJE enviado a um FREE numa terça devotional.none_today, informando o próximo domingo
6 Teaser gerado com 340 caracteres Truncado para 300 na última palavra, com (Seção 17.7)
7 Devocional cujo texto passa de 4.096 caracteres Enviado em duas mensagens, quebradas em limite de parágrafo, com o áudio depois da segunda
8 Assinante responde com emoji apenas Abre a janela; conta como interação; entrega o pacote pendente; sem help.unrecognized
9 Pesquisa respondida com "10!!!" Normalização remove pontuação; nota 10 registrada
10 Assinante em human_handoff_until recebe o devocional das 06:00 Entregue normalmente. Só as respostas automáticas do roteador ficam suspensas
11 Duas palavras-chave na mesma mensagem ("AJUDA SAIR") Não há correspondência exata; responde help.unrecognized — nunca adivinha intenção em pedido ambíguo de saída
12 Mensagem encaminhada em massa Não recebe resposta automática (Seção 19.6), mas abre a janela normalmente
13 SAIR enviado por quem está em atendimento humano ou em modo silencioso Processado normalmente: as sete palavras de saída passam antes da proteção anti-loop e do handoff (Seção 19.5). opt_out_at é gravado e a confirmação sai
14 SAIR enviado por quem já tem blocked_at preenchido Também é processado: opt_out_at é gravado. O estado BLOCKED diz que não conseguimos entregar, não que deixamos de ouvir
15 PAUSAR 45 Fora da faixa de 1 a 30: aplica o padrão de 7 dias e a resposta informa a faixa aceita, sem tratar como erro

20. Cancelamento, Opt-out e Retenção #

20.1 Os dois caminhos, que nunca se confundem #

Existem duas saídas diferentes, com efeitos diferentes, e o sistema jamais trata uma como se fosse a outra:

(1) Opt-out de mensagens. O assinante quer parar de receber no WhatsApp. Ele diz "SAIR" no WhatsApp ou desliga o recebimento no painel. Efeito: os envios cessam imediatamente. A assinatura não é cancelada de imediato, mas a cobrança do ciclo seguinte é suspensa (Seção 13.4.6): o período já pago continua valendo, nenhuma cobrança nova é gerada, e se em 30 dias não houver reativação a assinatura é encerrada ao fim desse período. O acervo continua acessível no painel.

A razão de não cancelar de imediato é proteger contra o SAIR acidental — a palavra chega por engano com frequência. A razão de suspender a cobrança é que a base legal da entrega é o consentimento do titular: revogado o consentimento, o serviço não pode ser prestado, e cobrar por serviço que não se pode prestar é infração. As duas coisas convivem, e é por isso que este caminho tem duas consequências e não uma.

(2) Cancelamento da assinatura. O assinante quer parar de pagar. Ele cancela pelo painel. Efeito: não haverá nova cobrança, e o acesso pago vai até o fim do ciclo já pago (Seção 13.4). A conta não é apagada, as mensagens não param — ele continua recebendo o devocional, no tier que tiver: pago até o fim do período, gratuito depois.

O erro que essa separação evita, dos dois lados:

  • Cancelar a cobrança de quem só pediu silêncio: perda de receita e de um assinante que talvez quisesse apenas uma pausa.
  • Continuar enviando para quem cancelou a cobrança: nenhum, na verdade — quem cancela a cobrança continua sendo assinante gratuito, e continuar enviando é o comportamento correto. O erro simétrico seria parar de enviar para quem só cancelou a cobrança, o que transformaria um downgrade em desaparecimento.

Por isso, sempre que o sistema detectar um dos dois pedidos vindo de alguém em quem o outro também se aplica, ele avisa e oferece, mas nunca executa os dois por conta própria. Especificamente:

  • Assinante pagante que faz opt-out recebe, na confirmação, o aviso explícito de que a próxima cobrança foi suspensa, de até quando o acesso já pago vale, e um link para cancelar de vez ou para voltar a receber (20.3.5).
  • Assinante que cancela a assinatura pelo painel vê, na tela de confirmação, a informação de que continuará recebendo o devocional gratuito aos domingos, com um link para desligar o recebimento se for isso que ele quer (20.7.5).

20.2 Matriz cruzada dos dois caminhos #

Quatro combinações possíveis. Assinante de exemplo: plano mensal, ciclo pago de 10/08/2026 a 10/09/2026, ação tomada em 22/08/2026.

Cobrança ativa Cobrança suspensa ou cancelada
Recebendo mensagens Estado normal do pagante. Envios: diários, com áudio. Cobrança: renova em 10/09. Acervo: completo. Dados: intactos. Cancelou a assinatura, continua recebendo. Envios: diários com áudio até 10/09, depois só domingo sem áudio. Cobrança: nenhuma nova. Acervo: completo até 10/09, depois últimos 7 dias. Dados: intactos.
Opt-out ativo Combinação impossível por construção: o opt-out suspende a cobrança na mesma transação em que grava opt_out_at (Seção 13.4.6). Nenhum assinante fica em silêncio pagando. Pediu silêncio. Envios: nenhum, desde 22/08. Cobrança: suspensa; nenhuma cobrança em 10/09. Acesso pago segue até 10/09 e depois cai para FREE. Acervo: acessível pelo painel, conforme o tier. Dados: intactos até pedir exclusão. Em 30 dias sem reativação, a assinatura é encerrada.

A célula superior direita e a inferior direita descrevem o mesmo estado financeiro por caminhos diferentes: um pelo pedido de cancelamento, outro pelo pedido de silêncio. A diferença entre elas é só se as mensagens continuam.

Detalhamento por dimensão:

Dimensão Só opt-out Só cancelamento Ambos Exclusão de conta (20.9)
Envio no WhatsApp Para imediatamente Continua, no tier vigente Para imediatamente Para imediatamente
Cobrança recorrente Suspensa no ato; encerrada em 30 dias sem reativação Para na virada do ciclo Para na virada do ciclo Cancelada no ato
Acesso pago Mantido até o fim do ciclo Mantido até o fim do ciclo Mantido até o fim do ciclo Encerrado no ato
Login no painel Funciona Funciona Funciona Bloqueado
Acervo no painel Acessível conforme o tier Acessível conforme o tier Acessível conforme o tier Inacessível
Dados pessoais Mantidos Mantidos Mantidos Anonimizados (20.9.3)
Histórico de pagamentos Mantido Mantido Mantido Retido por obrigação fiscal
Reversível pelo assinante Sim, VOLTAR ou painel Sim, nova assinatura Sim, os dois Não, após a anonimização

20.3 Opt-out pelo WhatsApp #

20.3.1 Palavras-chave reconhecidas #

São sete, e esta é a lista completa em todo o documento: SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR, REMOVER.

A lista é lida da chave de configuração send.optout_keywords (JSON, formato grupo.chave do catálogo da Seção 26.8.1), semeada com exatamente esses sete valores. Nenhuma seção lista seis, oito ou uma variação.

Além do texto, o payload de botão OPT_OUT (enviado quando o assinante toca no botão de descadastro de uma mensagem interativa) tem o mesmo efeito, sem passar pela normalização.

Precedência absoluta das palavras-chave de saída. O reconhecimento dessas sete palavras é a primeira operação executada sobre toda mensagem de entrada — antes da proteção anti-laço, antes do escalonamento para atendimento humano, antes de qualquer classificação de intenção, e independentemente do estado do assinante, inclusive BLOCKED, excluído, pausado, em atendimento humano ou em modo silencioso. Nenhum caminho de código pode consumir uma mensagem de entrada antes desse teste.

Motivo, registrado porque a consequência é dupla: uma palavra de saída não honrada é ao mesmo tempo descumprimento do direito de revogar o consentimento e a causa direta da denúncia dentro do aplicativo, que é o que derruba a reputação do número (Seção 17). Um assinante que escreve SAIR, recebe de novo no dia seguinte e escreve SAIR outra vez não reclama: ele denuncia.

Um teste de integração envia cada uma das sete palavras em cada estado possível do assinante e falha se opt_out_at não for gravado em qualquer um deles.

Regra de desempate entre saída de mensagens e cancelamento de cobrança: CANCELAR sozinho é opt-out. CANCELAR ASSINATURA é pedido de cancelamento de cobrança. Como o reconhecimento exige correspondência exata da mensagem inteira (20.3.3), a mensagem mais longa nunca cai na regra mais curta. Quando o assinante enviar CANCELAR e tiver assinatura paga vigente, a resposta é a variante de pagante de 20.3.5, que confirma a parada dos envios, informa a suspensão da cobrança e pergunta, com botão, se ele também quer encerrar a assinatura de vez.

20.3.2 Normalização #

Toda mensagem recebida passa por normalizeKeyword() antes da comparação:

// packages/core/src/keywords.ts
export function normalizeKeyword(raw: string): string {
  return raw
    .normalize('NFD')                    // separa acentos dos caracteres base
    .replace(/[̀-ͯ]/g, '')     // remove os diacríticos
    .replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, '') // remove emoji e VS16
    .replace(/[^\p{L}\p{N}\s]/gu, ' ')   // pontuação vira espaço
    .replace(/\s+/g, ' ')                // colapsa espaços, tabs e quebras de linha
    .trim()
    .toUpperCase();
}

// Valores padrão. Em execução, as duas listas vêm de `send.optout_keywords` e
// `send.optin_keywords` em `settings`; estas constantes são o seed e o fallback de boot.
export const OPT_OUT_KEYWORDS = new Set([
  'SAIR', 'PARAR', 'PARE', 'CANCELAR', 'STOP', 'DESCADASTRAR', 'REMOVER',
]);
export const OPT_IN_KEYWORDS = new Set(['VOLTAR', 'RETORNAR', 'QUERO VOLTAR', 'REATIVAR']);

SIM não é palavra de reativação, deliberadamente. É a resposta afirmativa mais comum do português e chegaria como resposta a qualquer pergunta que o produto fizesse — inclusive à própria confirmação de saída de 20.3.3, o que reverteria um opt-out recém-pedido. Reativar exige uma palavra que só faça sentido reativando.

Esta é a implementação canônica da normalização de mensagem de entrada em todo o documento. Nenhuma outra seção reimplementa a regra, e a saída é sempre em maiúsculas — qualquer texto que descreva a normalização como "minúsculas" está errado e contradiz este bloco de código.

Exemplos de normalização:

Recebido Normalizado Casa?
sair SAIR sim
Sair. SAIR sim
SAIR!!! SAIR sim
Sair seguido de um emoji (\u{1F64F}) SAIR sim
cancelar CANCELAR sim
Descadastrar DESCADASTRAR sim
REMOVER-ME REMOVER ME não (duas palavras)
quero sair QUERO SAIR não
não quero cancelar NAO QUERO CANCELAR não
Posso cancelar depois? POSSO CANCELAR DEPOIS não

20.3.3 Decisão: correspondência exata, não contida #

Decisão registrada: a correspondência é EXATA sobre a mensagem inteira normalizada. Uma mensagem só dispara opt-out se, depois de normalizada, for idêntica a uma das palavras-chave.

Justificativa, com os casos reais que a decisão evita:

  • "não quero cancelar" contém CANCELAR. Correspondência contida descadastraria alguém que disse exatamente o oposto.
  • "preciso cancelar meu cartão no banco, mas quero continuar recebendo" contém CANCELAR.
  • "vou parar de reclamar e começar a orar" contém PARAR.
  • "quero remover só o áudio" contém REMOVER.
  • "que mensagem linda, não pare nunca" — normaliza para QUE MENSAGEM LINDA NAO PARE NUNCA, que contém a palavra-chave PARE. Com correspondência contida, o elogio mais entusiasmado da base descadastraria quem o escreveu. É o exemplo mais eloquente de por que a correspondência é exata sobre a mensagem inteira.

O custo da decisão é o inverso: alguém que escreve "quero sair" não é atendido automaticamente. Esse custo é coberto pelo caminho de confirmação, que é obrigatório:

Quando a mensagem contém uma palavra-chave mas não é igual a ela, e tem no máximo 120 caracteres, o sistema responde com uma confirmação interativa em vez de ignorar:

"Você quer parar de receber o Palavra Diária no WhatsApp? Responda SAIR para confirmar. Se foi engano, é só ignorar esta mensagem."

Essa resposta é enviada no máximo uma vez a cada 24 horas por assinante, controlada pela chave optout-hint:{subscriberId}:{YYYY-MM-DD} em delivery_attempts. Mensagens acima de 120 caracteres não recebem a confirmação, porque textos longos com CANCELAR no meio quase nunca são pedido de saída — são conversa.

Resultado: precisão alta no gatilho automático, sem deixar ninguém preso. Quem quer sair, sai em no máximo duas mensagens.

20.3.4 Efeito imediato #

Ao reconhecer a correspondência exata, dentro de uma única transação:

  1. subscribers.opt_out_at = now(), opt_out_reason = 'WHATSAPP', opt_out_keyword = <palavra normalizada>.
  2. subscribers.paused_until = NULL — opt-out sobrepõe pausa; não faz sentido manter as duas.
  3. Grava consent_events com type = 'OPT_OUT', granted = false, o canal WHATSAPP, e evidence com o texto recebido e o wa_id. Registro imutável (Seção 22.7.3).
  4. Cancela todos os jobs de envio pendentes do assinante nas filas send.plan, send.dispatch e send.followup, por jobId prefixado com send:{subscriberId}:.
  5. Suspende a cobrança do ciclo seguinte, quando houver assinatura vigente: cancel_at_period_end = true, billing_suspended_at = now() e, após o commit, DELETE /v3/subscriptions/{id} na Asaas. O período já pago não é tocado e o tier segue PAID até current_period_end. A regra completa, com o prazo de 30 dias para reativar e o encerramento automático, é da Seção 13.4.6.

Nenhuma das duas funções de acesso da Seção 13.3.1 é chamada aqui: opt-out não retira acesso pago, apenas silencia o canal e para a cobrança futura.

O motor de envio (Seção 18.5) relê opt_out_at por chave primária imediatamente antes de cada chamada à API do WhatsApp, então mesmo um job que escapou do cancelamento termina como SKIPPED_OPTED_OUT, sem tocar no provedor. É essa releitura, e não o cancelamento de jobs, que torna o efeito verdadeiramente imediato: o cancelamento de fila é otimização, a releitura é a garantia.

Latência real: o webhook de entrada da Meta chega em menos de 1 segundo, o processamento é enfileirado e executa em menos de 2 segundos. Um assinante que envia SAIR às 06:00:03, enquanto o lote diário está rodando, não recebe a mensagem das 06:00:05.

20.3.5 Resposta de confirmação, uma única vez #

A confirmação é enviada apenas na transição de opt_out_at IS NULL para preenchido. A guarda é a própria transação: se a linha já tinha opt_out_at, a atualização não muda nada e nenhuma mensagem é enfileirada. O instante do envio fica em subscribers.opt_out_confirmation_sent_at, verificado dentro da mesma transação que grava opt_out_at, para que uma reentrega de webhook não produza duas mensagens de despedida.

Quando a origem é o WhatsApp, esta mensagem única é toda a confirmação: não há e-mail de confirmação, não há segunda mensagem, não há aviso posterior. A regra de canal completa, com as três origens possíveis, está em 20.4.1.

Texto para assinante gratuito (redação final na Seção 19):

"Pronto. Você não vai mais receber o Palavra Diária no WhatsApp. Se mudar de ideia, é só responder VOLTAR."

Texto para assinante pagante — obrigatoriamente diferente, com o efeito sobre a cobrança:

"Pronto. Você não vai mais receber o Palavra Diária no WhatsApp. Sua próxima cobrança está suspensa: nada será cobrado em 10/09/2026. Seu acesso ao plano pago, que você já pagou, vale até 10/09/2026. Se quiser voltar, é só responder VOLTAR. Se quiser encerrar de vez, acesse app.palavradiaria.com.br/assinatura."

O valor e a data vêm de subscriptions.amount_cents, formatado por formatBRL a partir de centavos inteiros, e de current_period_end. O aviso é obrigatório e não é configurável: silenciar alguém sem dizer o que acontece com o dinheiro dele é prática abusiva, e continuar cobrando por um serviço que não pode mais ser entregue é infração (Seção 13.4.6).

Repetições de SAIR depois do opt-out: nenhuma mensagem é enviada. O sistema registra a mensagem em inbound_messages e não responde. Motivo: responder criaria um laço com quem mandou a palavra por engano várias vezes, e o assinante já foi informado.

20.3.6 Casos de borda do opt-out por WhatsApp #

Caso Comportamento
Mensagem de número não cadastrado Registra em inbound_messages com subscriber_id = NULL; responde uma única vez com "Não encontramos um cadastro para este número." e ignora depois
Mensagem de assinante com deleted_at Ignora silenciosamente
SAIR durante uma pausa vigente Aplica opt-out e limpa paused_until
SAIR em mensagem com mídia e legenda SAIR A legenda é tratada como texto; casa normalmente
SAIR em áudio de voz Não há transcrição no MVP; o sistema responde uma única vez pedindo que envie por texto
SAIR e VOLTAR em sequência rápida Cada um é aplicado na ordem de chegada; a última mensagem vence. inbound_messages preserva a sequência
Mensagem duplicada pela Meta (mesmo wamid) Índice único em inbound_messages.wamid descarta a repetição antes do processamento
wa_id diferente do phone_e164 cadastrado Resolução por wa_id_hmacphone_hmac → variantes com e sem nono dígito, conforme a regra de normalização de telefone da Seção 11.4. A comparação é sempre pelo índice cego, nunca pela coluna cifrada
Mudança do wa_id associado a um telefone Tratada como troca de aparelho ou chip reciclado, e não como detalhe técnico: todas as sessões daquele assinante são invalidadas e é exigida nova verificação por código antes de liberar qualquer dado pessoal. Ver o parágrafo abaixo
Opt-out durante o envio do pacote da janela aberta O texto já enviado permanece; áudio e fechamento são cancelados

Chip reciclado e troca de número. Operadoras brasileiras reemitem números desativados, e a posse do número é o único fator de autenticação do assinante. Por isso, qualquer mudança do wa_id associado a um phone_hmac já cadastrado invalida todas as sessões daquele assinante e exige nova verificação por código antes de qualquer leitura de dado pessoal.

O que a mudança de wa_id não faz: não entrega a conta, o CPF, o histórico de pagamentos nem o acervo a quem respondeu no WhatsApp. Uma palavra-chave de saída continua sendo honrada sem verificação nenhuma — parar de enviar para um número nunca expõe dado de ninguém, e é justamente o que o novo dono do número quer. Mas ler, exportar ou alterar dados exige a verificação. A regra completa de sessão restrita por dormência é da Seção 8.15.1.1.

Motivo registrado: entregar sessão plena por posse de número transforma a reciclagem de chip — evento comum e inteiramente fora do nosso controle — em vazamento de CPF e de histórico financeiro de outra pessoa.

20.4.1 No painel #

app.palavradiaria.com.br/preferencias tem um controle "Receber o devocional no WhatsApp", ligado por padrão. Desligar abre um diálogo de confirmação que mostra, quando aplicável, o aviso de que a próxima cobrança será suspensa, o período já pago continua valendo e, sem reativação em 30 dias, a assinatura é encerrada (Seção 13.4.6), com valor e data. Confirmado, chama POST /api/me/opt-out com source = 'DASHBOARD'.

Canal da confirmação de opt-out — regra única do produto, sem exceção. O canal da confirmação é determinado exclusivamente pela origem do pedido:

Origem do opt-out Confirmação no WhatsApp Confirmação na tela Confirmação por e-mail
WhatsApp (palavra-chave) Sim, exatamente uma mensagem, e nada além dela Não se aplica Não
Painel do assinante Não Sim Sim, quando houver e-mail verificado
Link de descadastro (20.4.2) Não Sim Sim, quando houver e-mail verificado

O motivo é simples e vale como justificativa escrita da regra: quem pediu para parar de receber mensagens no WhatsApp por um canal que não é o WhatsApp não deve receber mais uma mensagem no WhatsApp. O assinante já viu a confirmação na tela, e mandar uma mensagem para quem acabou de pedir silêncio é contraditório. Pelo caminho inverso, quem pediu pelo próprio WhatsApp precisa de uma resposta ali, senão fica sem saber se foi atendido — por isso a mensagem única de 20.3.5, e apenas ela.

Toda mensagem de e-mail transacional traz um link de descadastro no rodapé. As mensagens de WhatsApp trazem o rodapé de template "Responda SAIR para cancelar", que é a via nativa do canal.

Formato do link: https://app.palavradiaria.com.br/descadastrar/{token}.

O token é opaco, com 32 bytes aleatórios codificados em base64url. Ele não é derivado do telefone nem do id do assinante, para que não seja adivinhável nem enumerável.

Persistência: a tabela unsubscribe_tokens, definida pela Seção 6 — que é a dona do modelo de dados e a única seção que cria tabela. Esta seção não a define e não introduz tabela nova: apenas a consome, pelas colunas que a Seção 6 declara (subscriber_id, token_hash, created_at, expires_at, used_at). Só o hash é gravado; o token em claro existe apenas dentro do link enviado. Validade de 180 dias, rotação a cada uso bem sucedido. Os tipos, os índices, a chave estrangeira, o ON DELETE e a retenção estão na Seção 6.

O token não vive em settings. As chaves de settings seguem o formato grupo.chave, são configuração do produto e precisam existir no seed; uma chave por assinante não é configuração, não caberia no seed, e transformaria a tabela de configuração em armazenamento de credencial.

A página do link:

  • Não executa o opt-out no GET. Prefetchers de e-mail e antivírus corporativos abrem links automaticamente; um GET destrutivo descadastraria gente sem intenção nenhuma.
  • Mostra o número mascarado (+55 11 9****-8829), a confirmação e um botão. O POST do formulário executa o opt-out.
  • Funciona sem login. É um link autenticado pelo próprio token.
  • Após a execução, mostra o resultado, o aviso de que a próxima cobrança será suspensa quando aplicável, e um botão "Voltar a receber" que desfaz na hora. A confirmação fica na tela e segue por e-mail quando houver e-mail verificado; não se envia WhatsApp, pela regra de canal de 20.4.1.
  • Token inválido, expirado ou já rotacionado: página neutra "Este link não é mais válido" com link para o login. Não revela se o assinante existe.

20.5 Reativação do recebimento #

20.5.1 Pelo WhatsApp #

Palavras-chave: VOLTAR, RETORNAR, SIM. Mesma normalização e mesma regra de correspondência exata de 20.3.2 e 20.3.3.

Efeito, em uma transação: opt_out_at = NULL, opt_out_reason = NULL, opt_out_keyword = NULL, paused_until = NULL, e consent_events com type = 'RE_OPT_IN', channel = 'WHATSAPP' e granted = true.

opt_in_confirmed_at não é reescrito: ele marca a confirmação original do opt-in em duas etapas (Seção 11) e é prova de consentimento inicial. A reativação é um evento novo em consent_events, não uma reescrita do passado.

Resposta enviada:

"Que bom ter você de volta. Você vai receber o Palavra Diária [todos os dias / aos domingos] às 6h."

O trecho entre colchetes vem de sendFrequency resolvido pela função da Seção 13.6.2 — nunca de um if local.

20.5.2 O que é restaurado #

Item Restaurado?
Envios futuros Sim, a partir do próximo horário programado
Devocionais do período em silêncio Não. Não há reenvio retroativo pelo WhatsApp
Acesso ao acervo Nunca foi perdido; segue conforme o tier
Tier e assinatura Nunca foram alterados pelo opt-out
Contadores de reenvio manual Zerados na virada do dia, como sempre
Pausa vigente Cancelada; reativar encerra a pausa

Decisão sobre não reenviar o histórico: despejar 40 devocionais atrasados no WhatsApp de quem volta é a maneira mais rápida de provocar um bloqueio por spam e derrubar o quality_rating do número (Seção 17). O acervo web existe exatamente para isso, e a mensagem de boas-vindas traz o link.

20.5.3 Pelo painel #

O controle de /preferencias volta a ser ligado, chamando POST /api/me/opt-in. O efeito é idêntico. Confirmação exibida na tela; nenhuma mensagem de WhatsApp é enviada, porque o próximo devocional já é a confirmação prática.

20.6 Pausa temporária #

20.6.1 Para que serve e como funciona #

Viagem, retiro, luto, semana difícil. O assinante quer silêncio por alguns dias, sem sair e sem cancelar. A pausa é a alternativa suave ao opt-out, e é também a oferta de retenção de 20.7.4.

Parâmetro: 1 a 30 dias. subscribers.paused_until = now() + N dias, com hora ajustada para 03:00 de America/Sao_Paulo do dia seguinte ao último dia pausado, de modo que o retorno caia antes do envio das 06:00.

Pelo painel, a duração é sempre escolhida em um seletor. Pelo WhatsApp, a duração padrão do canal é de 7 dias: PAUSAR, PAUSA, FERIAS ou FÉRIAS sozinhos pausam por 7 dias, e o assinante pode dizer PAUSAR N, com N de 1 a 30, para escolher — por exemplo PAUSAR 15. Um número fora da faixa recebe a resposta explicando os limites, sem pausar nada.

Durante a pausa:

  • canReceiveMessages é false com blockedReason = 'PAUSED' (Seção 13.6.2).
  • Nenhum devocional, nenhum lembrete de cobrança pelo WhatsApp, nenhuma mensagem de marketing.
  • Exceção única: mensagens transacionais críticas de cobrança continuam — confirmação de pagamento, falha de pagamento e perda de acesso. Elas informam sobre dinheiro e sobre o contrato, e omiti-las prejudicaria o assinante. Essa exceção não vale para o opt-out, que bloqueia tudo (20.10).
  • A cobrança continua normalmente. Pausa não é congelamento de assinatura.
  • O acesso ao acervo continua, integral, conforme o tier.
  • O painel exibe o estado com a data de retorno e um botão "Voltar agora".

20.6.2 Como termina #

Naturalmente: a função de entitlements para de bloquear quando paused_until <= now. Nenhum job é necessário para o efeito.

O job messaging.pause, na fila messaging.pause, roda às 05:30 e faz duas coisas cosméticas, mas úteis: limpa paused_until das pausas encerradas e enfileira a mensagem de retorno. Se o job falhar, o assinante recebe o devocional normalmente e apenas não recebe a saudação de volta — a pausa termina sozinha porque a função de entitlements para de bloquear quando paused_until <= now, e nada depende do job para isso.

Antecipadamente: VOLTAR no WhatsApp ou o botão no painel encerram a pausa na hora (paused_until = NULL).

Mensagem de retorno, enviada às 05:55 do primeiro dia ativo, antes do devocional:

"Sua pausa terminou. A partir de hoje você volta a receber o Palavra Diária. Que essa retomada seja boa."

Se o assinante encerrou a pausa manualmente, a mensagem de retorno não é enviada — a ação dele já foi a confirmação.

20.6.3 Regras e limites #

Regra Valor
Duração mínima 1 dia
Duração máxima 30 dias
Pausas por assinante Máximo 3 por período de 90 dias
Pausa sobre pausa Substitui a anterior; não soma. A rota aceita e devolve 200 com a nova data
Pausa com opt-out ativo Rejeitada com 409 SUBSCRIBER_OPTED_OUT: quem já está em silêncio não precisa de pausa
Efeito na cobrança Nenhum
Efeito no tier Nenhum
Registro do início consent_events com type = 'PAUSE_STARTED', channel conforme a origem, granted = false e evidence = {"days": N, "pausedUntil": "..."}
Registro do fim consent_events com type = 'PAUSE_ENDED' e granted = true

Os dois tipos são valores do enum ConsentType declarado na Seção 6, e a coluna de metadados chama-se evidence — não metadata. Não existe o valor 'PAUSE' isolado.

Exemplo: assinante pausa 10 dias em 25/08/2026 às 22:10. paused_until fica 2026-09-05T06:00:00Z (03:00 de 05/09 em Brasília). Ele não recebe nada de 26/08 a 04/09. Em 05/09 às 05:55 recebe a mensagem de retorno e às 06:00 o devocional. A cobrança de 10/09 sai normalmente.

20.7 Cancelamento da assinatura pelo painel #

20.7.1 Fluxo em duas etapas #

O cancelamento nunca acontece em um clique. Duas etapas, com uma tela entre elas:

  Painel /assinatura
        │
        │ clique em "Cancelar assinatura"
        ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ ETAPA 1 — Pesquisa de motivo                                 │
 │ GET /api/me/subscription/cancellation-preview          │
 │                                                              │
 │ Mostra: data exata de término do acesso, valor economizado,  │
 │         o que ele perde, o que continua recebendo.           │
 │ Pede:   motivo (lista fechada) + comentário livre opcional.  │
 └───────────────────────────┬──────────────────────────────────┘
                             │ "Continuar"
                             ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ ETAPA 2 — Oferta de retenção (uma única vez por assinatura)  │
 │                                                              │
 │ "Que tal pausar por 30 dias em vez de cancelar?"             │
 │  [ Pausar 30 dias ]        [ Cancelar mesmo assim ]          │
 │                                                              │
 │ Se já houve oferta antes: esta etapa é PULADA.               │
 └──────┬──────────────────────────────────┬────────────────────┘
        │ Pausar                           │ Cancelar mesmo assim
        ▼                                  ▼
  POST /api/me/pause                POST /api/me/subscription/cancel
  { days: 30 }                      { reason, comment, confirm: true }
        │                                  │
        ▼                                  ▼
  Assinatura intacta                DELETE /v3/subscriptions/{id} na Asaas
  Envios pausados 30 dias           status = CANCELED, cancel_at_period_end = true
                                    Acesso PAID até current_period_end
                                    Mensagem com a data exata

20.7.2 Etapa 1 — o que a tela mostra #

Dados concretos, calculados, nunca genéricos:

  • "Seu acesso ao plano pago vai até 10/09/2026." A data vem de current_period_end convertida para America/Sao_Paulo.
  • "Até lá, nada muda: você continua recebendo o devocional todos os dias, com áudio."
  • "Depois dessa data, você continua conosco no plano gratuito: devocional em texto todos os domingos, sem áudio, com os últimos 7 dias no acervo."
  • "Não haverá nova cobrança."
  • Para plano anual, acrescenta os meses restantes: "Você tem mais 7 meses de acesso já pagos."

20.7.3 Pesquisa de motivo #

Lista fechada, seleção única, obrigatória:

Valor Rótulo exibido
TOO_EXPENSIVE Está caro para mim agora
NOT_USING Não estou lendo/ouvindo
TOO_MANY_MESSAGES Mensagens demais
CONTENT_NOT_RELEVANT O conteúdo não me atende
AUDIO_QUALITY Não gostei do áudio
TECHNICAL_PROBLEMS Tive problemas técnicos
FOUND_ALTERNATIVE Encontrei outra opção
TEMPORARY_FINANCIAL Aperto financeiro temporário
OTHER Outro motivo

Campo livre opcional, de 0 a 500 caracteres, com contador. Quando o motivo é OTHER, o campo livre passa a ser obrigatório, com mínimo de 5 caracteres — "Outro" sem explicação não informa nada.

Armazenamento: subscription_events com type = 'CANCELLATION_SURVEY' e metadata { reason, comment }. O comentário é texto livre do titular e entra no escopo de anonimização da Seção 22 quando a conta é excluída.

A oferta de retenção é adaptada ao motivo:

Motivo Oferta apresentada
TOO_MANY_MESSAGES Pausa de 30 dias, com destaque para "você escolhe quando voltar"
TEMPORARY_FINANCIAL Pausa de 30 dias, com o texto "sua assinatura continua, mas você pode cancelar depois se precisar"
Todos os demais Pausa de 30 dias, texto padrão

A oferta é sempre a mesma coisa — pausa de 30 dias. Só o texto muda. Decisão registrada: uma única oferta de retenção, sem desconto, sem plano alternativo, sem "espere, temos uma proposta". Motivo: desconto de retenção corrói o preço, exige tabela de cupons que o produto não tem (Seção 12.16), e sequências de ofertas empilhadas são hostis. Uma oferta, honesta, e o botão de cancelar sempre visível e com o mesmo peso visual.

20.7.4 Etapa 2 — a oferta, uma única vez #

A oferta é exibida no máximo uma vez por assinatura. O controle é subscription_events com type = 'RETENTION_OFFER_SHOWN': se já existir um registro para aquela subscription_id, a etapa 2 é pulada e o assinante vai direto para a confirmação.

Isso é verificado no servidor, em GET /api/me/subscription/cancellation-preview, que devolve retentionOffer: { available: boolean, type: 'PAUSE_30_DAYS' }. O cliente não decide isso.

Os dois botões têm o mesmo tamanho e o mesmo contraste. O botão de cancelar não é escondido, não é cinza-claro e não exige rolagem. Requisito de interface, não sugestão.

20.7.5 Confirmação e efeito #

Ao confirmar, dentro de uma transação:

  1. assertTransition('ACTIVE', 'CANCELED') (Seção 13.2.3).
  2. subscriptions.status = 'CANCELED', cancel_requested_at = now(), cancel_at_period_end = true, end_reason = 'USER_REQUEST'.
  3. Remove pending_plan_id se houver troca de plano agendada.
  4. subscription_events com type = 'CANCELED', source = 'SUBSCRIBER'.
  5. Após o commit: DELETE /v3/subscriptions/{asaas_subscription_id} (Seção 12.8.1). 404 é sucesso. Falha persistente enfileira retry e, esgotado, cria tarefa administrativa com alerta billing_cancel_not_propagated — porque uma assinatura não removida na Asaas voltaria a cobrar.
  6. Envia a confirmação.

O tier não muda. subscribers.tier continua PAID, e a função de entitlements continua devolvendo PAID porque CANCELED com currentPeriodEnd no futuro concede acesso (Seção 13.6.2). Em 11/09 às 00:05 o job billing.lifecycle faz a transição T14.

Tela de confirmação e mensagem enviada, ambas com a data exata:

"Sua assinatura foi cancelada. Você continua com o plano pago — devocional diário e áudio — até 10/09/2026. Depois dessa data, você continua recebendo o devocional em texto aos domingos, no plano gratuito. Não haverá nova cobrança. Se quiser parar de receber as mensagens também, acesse app.palavradiaria.com.br/preferencias."

O último parágrafo é o cruzamento obrigatório entre os dois caminhos (20.1).

20.7.6 Cancelamento pelo administrador #

Um ADMIN ou OWNER cancela pelo painel administrativo, com justificativa de 10 a 500 caracteres. O efeito é idêntico ao do assinante (T12), com end_reason = 'ADMIN_REQUEST' e registro em admin_audit_log. A mensagem enviada é a mesma, sem menção ao admin.

EDITOR não pode cancelar assinatura: recebe 403 FORBIDDEN_ROLE.

20.8 Cancelamento por inadimplência #

Não é cancelamento; é expiração. A diferença importa e aparece em tudo.

Aspecto Cancelamento voluntário Inadimplência
Estado final CANCELEDEXPIRED na virada EXPIRED direto
end_reason USER_REQUEST NON_PAYMENT
Quando o acesso acaba current_period_end Imediatamente (Seção 13.3)
Quem inicia Assinante ou admin Ninguém; é a ausência de pagamento
Pesquisa de motivo Sim Não
Oferta de retenção Sim, uma vez Não
Mensagem Confirmação com data futura Aviso de perda de acesso, no passado imediato
Remoção na Asaas Sim, no ato Sim, após o PAYMENT_OVERDUE
Aparece em churn_voluntario Sim Não — entra em churn_involuntario

O que o assinante recebe, no momento do PAYMENT_OVERDUE (redação final na Seção 19):

"Não conseguimos confirmar o pagamento da sua assinatura, que vencia em 10/09. Seu acesso ao plano pago foi encerrado hoje. Você continua com a gente: vai receber o devocional em texto aos domingos, no plano gratuito. Para voltar ao plano completo, com áudio todos os dias, acesse app.palavradiaria.com.br/assinar."

Regras da mensagem:

  • Enviada uma única vez por cobrança vencida, com chave de idempotência overdue-notice:{paymentId}.
  • Tom informativo, sem cobrança emocional, sem "você foi bloqueado", sem urgência artificial.
  • Não há segunda mensagem, terceira mensagem, nem sequência de recuperação pelo WhatsApp. Um assinante inadimplente que não reagiu ao primeiro aviso não recebe insistência — ele passa a receber o conteúdo gratuito de domingo, que é a melhor recuperação possível.
  • Se o assinante estiver em opt-out, a mensagem não é enviada pelo WhatsApp. Vai por e-mail, se houver e-mail verificado. Se não houver, o painel mostra o estado no próximo login e nada mais é feito.

Não há remarketing agressivo, não há "última chance", não há contagem regressiva.

20.9 Exclusão de conta (LGPD) #

20.9.1 Fluxo #

O titular tem direito à eliminação dos dados pessoais (LGPD, Art. 18, VI). O fluxo é autosserviço, com confirmação forte.

 Painel /preferencias → "Excluir minha conta"
        │
        ▼
 ┌────────────────────────────────────────────────────────────────┐
 │ TELA 1 — Consequências, em texto claro                         │
 │  • Você para de receber o devocional imediatamente.            │
 │  • Sua assinatura paga é cancelada agora, sem reembolso do     │
 │    período restante.  (mostra o período que será perdido)      │
 │  • Você perde acesso ao acervo e ao painel.                    │
 │  • Seus dados pessoais são anonimizados em até 30 dias.        │
 │  • Guardamos registros de pagamento e de consentimento por     │
 │    obrigação legal (veja abaixo).                              │
 │  • Esta ação não pode ser desfeita depois da anonimização.     │
 │  [ Cancelar ]                    [ Quero excluir minha conta ] │
 └───────────────────────────┬────────────────────────────────────┘
                             ▼
 ┌────────────────────────────────────────────────────────────────┐
 │ TELA 2 — Confirmação por OTP                                   │
 │  POST /api/me/deletion-request  → envia OTP de 6 dígitos no    │
 │  WhatsApp (template codigo_acesso_v1)                          │
 │  Campo de 6 dígitos + [ Confirmar exclusão ]                   │
 │  POST /api/me/deletion-request/confirm { code }                │
 └───────────────────────────┬────────────────────────────────────┘
                             ▼
              EFEITO IMEDIATO (mesma transação)
   deleted_at = now() · opt_out_at = now() · sessões revogadas
   assinatura cancelada e removida na Asaas · envios cancelados
                             │
                             ▼
              JANELA DE ARREPENDIMENTO — 7 dias
   Login bloqueado; contato com o suporte pode reverter.
                             │
                             ▼
       ANONIMIZAÇÃO — job privacy.anonymize, fila maintenance.cleanup,
       diário às 03:20
         (processa pedidos com deletion_requested_at <= now() - 7 dias)

20.9.2 Confirmação por OTP #

O OTP é o mesmo mecanismo de login da Seção 8: 6 dígitos, TTL de 10 minutos, 5 tentativas, 3 envios por hora por número. Um OTP de exclusão é marcado com purpose = 'ACCOUNT_DELETION' e não serve para login, nem o de login serve para exclusão.

Se o assinante estiver em opt-out, o OTP ainda é enviado pelo WhatsApp: é uma mensagem transacional de autenticação pedida por ele naquele instante, dentro da janela aberta pela própria interação. Se não houver janela aberta, o template de autenticação é usado. Essa é a única mensagem que atravessa o opt-out, e ela existe porque sem ela o titular não consegue exercer o direito.

20.9.3 O que é anonimizado #

Regra de alcance, e ela é a parte que costuma faltar. A operação percorre todas as tabelas que contenham telefone, wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não apenas subscribers. A lista é derivada da coluna, não da tabela: qualquer coluna cujo nome esteja em ('phone_e164','wa_id','email','cpf','payload_excerpt','text_body') e cuja linha se relacione ao assinante é zerada. Deixar o telefone em claro em message_logs por 18 meses depois de dizer ao titular que a conta foi eliminada é, em uma perícia, indefensável — e message_logs é a maior tabela do sistema.

O job privacy.anonymize, na fila maintenance.cleanup, atua em transação única por assinante:

Tabela Campo Ação
subscribers phone_e164 Recebe o envelope cifrado da constante '[removido]', o que satisfaz o CHECK de formato de envelope sem guardar dado nenhum
subscribers phone_hmac Recebe 64 caracteres hexadecimais de crypto.randomBytes(32). Preserva o NOT NULL e a unicidade, sem qualquer relação com o número original, e sem exigir uma chave de pseudonimização nova
subscribers wa_id, wa_id_hmac NULL
subscribers email, email_hmac NULL
subscribers display_name 'Assinante removido'
subscribers anonymized_at now()
subscriber_profiles full_name 'Assinante removido'
subscriber_profiles cpf, cpf_hmac, cpf_last4 Retidos até o fim da retenção fiscal (ver 20.9.4 e o estágio 2 abaixo)
sessions todas as linhas Removidas
otp_codes todas as linhas Removidas
message_logs phone_e164, wa_id NULL. O roteamento histórico não precisa do número; a coluna deixa de ser NOT NULL para permitir isso (Seção 6.19)
message_logs payload_excerpt NULL. Pode conter o nome do titular em parâmetro de template e texto digitado por ele
message_logs demais colunas Metadados de entrega mantidos: status, sent_at, error_code, conversation_category, cost_micros
inbound_messages phone_e164, wa_id NULL
inbound_messages text_body '[removido]'; wamid mantido
delivery_attempts colunas de destino, quando houver NULL
subscription_events cancel_comment NULL — é texto livre escrito pelo titular
consent_events ip, user_agent NULL; tipo, policy_version, consent_text_hash, granted e timestamp mantidos
daily_metrics Agregado sem dado pessoal; intocada

Trecho normativo da transação, para que o alcance não dependa de memória de quem implementa:

await tx.$executeRaw`UPDATE message_logs
   SET phone_e164 = NULL, wa_id = NULL, payload_excerpt = NULL
   WHERE subscriber_id = ${subscriberId}`;
await tx.$executeRaw`UPDATE inbound_messages
   SET phone_e164 = NULL, wa_id = NULL, text_body = '[removido]'
   WHERE subscriber_id = ${subscriberId}`;

Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a anonimização e falha se uma consulta pelo telefone original, pelo wa_id ou pelo e-mail original devolver qualquer linha em qualquer tabela.

Escrita em consent_events sob gatilho append-only. A tabela é protegida por um gatilho que recusa UPDATE e DELETE. Zerar ip e user_agent é exatamente um UPDATE, e um controle de integridade não pode impedir o cumprimento de um direito do titular. A saída não é desabilitar o gatilho: é um caminho privilegiado explícito e auditado. A atualização é executada pela conexão do papel privacy_operator, cuja exceção está codificada dentro do próprio gatilho e verifica, coluna a coluna, que ip e user_agent mudaram e que type, granted, policy_version, consent_text_hash e created_at continuam idênticos (Seção 22.7.3). Nenhum gatilho é desabilitado em momento algum — desabilitar exigiria ser dono da tabela e abriria uma janela em que qualquer escrita passa.

O resultado é irreversível na prática e mantém a integridade referencial: nenhuma linha filha vira órfã, nenhum relatório histórico quebra.

Os dois estágios, e a distinção não é semântica. O que 20.9.3 descreve é o estágio 1 — pseudonimização, executado em até 30 dias do pedido. Enquanto cpf, cpf_hmac e asaas_customer_id existirem, a linha continua sendo dado pessoal: o identificador do processador recupera um cadastro completo com nome, CPF, e-mail e telefone em um clique. Nesse estágio a linha permanece integralmente no escopo da LGPD, conta na apuração de qualquer incidente, e está sujeita ao mesmo controle de acesso das linhas ativas.

O estágio 2 — anonimização acontece quando a retenção fiscal vence, 5 anos após o último pagamento: cpf, cpf_hmac, cpf_last4, asaas_customer_id, asaas_subscription_id e asaas_payment_id são zerados, e é enviada à Asaas a solicitação de exclusão do cadastro correspondente. O job maintenance.enforce_retention, na fila maintenance.cleanup, executa o estágio 2 diariamente sobre as linhas cuja retenção venceu, e registra a transição em admin_audit_log com action = 'SUBSCRIBER_FULLY_ANONYMIZED'.

Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares em uma comunicação de incidente — que é precisamente o erro que a autoridade procura.

20.9.4 O que é retido, e por quê #

Dado Retenção Base
payments e payment_events completos 5 anos Obrigação fiscal e contábil; prova em disputa financeira e chargeback
subscriber_profiles.cpf / cpf_hmac / cpf_last4 (cifrado) 5 anos após o último pagamento Vinculado ao registro fiscal da cobrança; sem ele o registro de pagamento perde valor probatório. Zerado no estágio 2
consent_events (tipo, policy_version, consent_text_hash, timestamp, canal) 5 anos Prova de consentimento e de opt-out, exigível pela autoridade e em disputa
message_logs (metadados de entrega, sem telefone, sem wa_id e sem conteúdo) 18 meses Prova de entrega em disputa e apuração de qualidade do canal. O que a lei permite reter é o metadado, não o identificador pessoal
admin_audit_log 5 anos Trilha de auditoria de ações administrativas
Backups 35 dias Ciclo de retenção de backup; a anonimização se propaga naturalmente ao expirarem

Fundamento da retenção: a LGPD, no Art. 16, permite a conservação de dados para cumprimento de obrigação legal ou regulatória e para exercício regular de direitos. A eliminação a pedido do titular não anula essas hipóteses. O que a lei obriga a reter é retido de forma pseudonimizada, ligado apenas ao identificador interno do assinante — nunca ao telefone, ao wa_id, ao e-mail ou ao CPF em claro. A política completa, com prazos e responsáveis, é da Seção 22; esta subseção descreve apenas o comportamento do fluxo de exclusão.

A tela de exclusão informa isso ao titular, em português direto, antes da confirmação:

"Mesmo depois da exclusão, guardamos por 5 anos os registros de pagamento e de consentimento, porque a lei exige. Eles ficam desvinculados do seu perfil e não são usados para nenhuma comunicação."

20.9.5 O que acontece com a assinatura ativa #

Decisão registrada: a exclusão de conta cancela a assinatura imediatamente e não devolve o período restante. O acesso acaba no ato, não em current_period_end.

Justificativa: manter o acesso exigiria manter a conta funcionando, o que contraria o próprio pedido. E a exclusão é ato deliberado do titular, com aviso explícito na tela. O valor do período perdido é mostrado antes da confirmação:

"Você tem acesso pago até 10/09/2026. Se excluir a conta agora, perde esses 16 dias e não há reembolso."

Se o titular quiser o reembolso, ele deve pedir pelo suporte antes de excluir, dentro da política de 12.14.1. Depois da exclusão, a conta não existe para receber a devolução do serviço, embora o reembolso financeiro ainda seja tecnicamente possível pela Asaas usando o registro de pagamento retido.

A assinatura é removida na Asaas com DELETE /v3/subscriptions/{id} na mesma execução. O customer da Asaas não é removido, porque ele carrega o histórico fiscal das cobranças; ele é atualizado com notificationDisabled: true, já o padrão nosso, e nada mais.

20.9.6 Janela de arrependimento e casos de borda #

Caso Comportamento
Titular se arrepende em 3 dias Suporte reverte: deleted_at = NULL, opt_out_at = NULL, sessões continuam revogadas. A assinatura não volta; ele precisa contratar de novo
Titular se arrepende no dia 9 Anonimização já ocorreu. Não há reversão. Ele pode cadastrar-se de novo, como assinante novo, com histórico zerado
Webhook de pagamento chega após a exclusão Processado normalmente para o registro financeiro; nenhuma mensagem é enviada (Seção 12.12.5)
Exclusão pedida durante PENDING_PAYMENT A assinatura pendente é removida na Asaas e marcada EXPIRED com end_reason = 'ABANDONED'
Exclusão pedida com chargeback em aberto Permitida. Os registros financeiros são retidos e a disputa continua com base neles
Segundo pedido de exclusão 409 DELETION_ALREADY_REQUESTED, com a data prevista da anonimização
Cadastro novo com o mesmo telefone após a anonimização Permitido: o phone_e164 antigo foi pseudonimizado e o número real está livre. O assinante novo não vê nada do antigo
Cadastro novo com o mesmo telefone dentro dos 7 dias Bloqueado com 409 PHONE_PENDING_DELETION, orientando a contatar o suporte para reverter

20.10 Retenção e win-back #

20.10.1 A regra dura #

Quem fez opt-out não recebe absolutamente nada pelo WhatsApp. Nem devocional, nem lembrete, nem promoção, nem "sentimos sua falta", nem pesquisa, nem aviso de novidade.

A única exceção é o OTP de autenticação solicitado pelo próprio titular naquele instante (20.9.2 e Seção 8), porque ele é resposta a uma ação em curso, não iniciativa nossa.

Isso não é preferência de tom: é a condição para manter o número de WhatsApp saudável. Mensagem de marketing para quem pediu para sair gera bloqueio e denúncia, o quality_rating cai, e a capacidade de entrega do produto inteiro é comprometida (Seção 17).

Implementação: o guarda canReceiveMessages da função de entitlements (Seção 13.6.2) é verificado imediatamente antes de toda chamada à API do WhatsApp, sem exceção de caminho. Não há flag para pular a verificação, não há rota administrativa que force envio, e o enfileiramento em massa também filtra por ele.

// packages/core/src/messaging/guard.ts
export function assertCanSendWhatsApp(ent: Entitlements, kind: MessageKind): void {
  if (kind === 'AUTH_OTP') return;               // única exceção, iniciada pelo titular
  if (!ent.canReceiveMessages) {
    throw new BlockedRecipientError(ent.blockedReason ?? 'UNKNOWN');
  }
}

20.10.2 E-mail #

E-mail é canal separado, com consentimento separado e ativo. Regras:

  • O opt-out do WhatsApp não derruba o consentimento de e-mail, e o opt-out de e-mail não derruba o do WhatsApp. São dois registros distintos em consent_events, com canais distintos.
  • E-mail de marketing e win-back só vai para quem tem email_verified_at preenchido e email_marketing_consent_at preenchido e sem opt-out de e-mail.
  • O consentimento de e-mail marketing não é coletado por checkbox pré-marcado, nunca é implícito no cadastro, e tem texto próprio.
  • Todo e-mail traz link de descadastro de um clique no rodapé, conforme 20.4.2.
  • E-mail transacional (recibo, falha de pagamento, OTP de fallback, confirmação de exclusão) não depende de consentimento de marketing e é enviado a qualquer assinante com e-mail verificado, inclusive em opt-out de WhatsApp.

20.10.3 Campanhas permitidas e proibidas #

Público WhatsApp E-mail
Ativo, recebendo, gratuito Devocional de domingo; convite de upgrade no máximo 1 vez a cada 30 dias, dentro da janela aberta Permitido com consentimento
Ativo, recebendo, pagante Devocional diário; transacional de cobrança Permitido com consentimento
Em pausa Só transacional crítico de cobrança (20.6.1) Permitido com consentimento
Cancelou a assinatura, ainda recebendo Devocional gratuito; 1 convite de retorno, 7 dias após o rebaixamento, e nada mais Permitido com consentimento
Inadimplente, ainda recebendo Devocional gratuito; 1 aviso de perda de acesso (20.8) Permitido com consentimento
Opt-out Nada. Nenhuma mensagem, nunca Só com consentimento de e-mail ativo e separado
Conta excluída Nada Nada
Chargeback em aberto Só a mensagem neutra de encerramento Nada de marketing

Proibições explícitas, que valem como requisito:

  • Nenhuma campanha de win-back pelo WhatsApp para quem fez opt-out — em nenhum prazo, com nenhum texto, sob nenhuma justificativa de "só desta vez".
  • Nenhuma reintrodução automática de quem fez opt-out em qualquer lista de envio, nem por importação, nem por seed, nem por script de operação. O comando ops de importação recusa registros com opt_out_at preenchido e falha com erro claro.
  • Nenhum convite de upgrade dentro da própria mensagem do devocional gratuito de domingo. O convite, quando existe, é mensagem separada e obedece ao limite de 1 a cada 30 dias.
  • Nenhuma pesquisa de satisfação por WhatsApp para quem saiu.
  • Nenhum "estamos com saudade" 90 dias depois. Se ele quiser voltar, VOLTAR funciona para sempre e o painel também.

20.10.4 Win-back permitido, e como é feito #

Para quem continua recebendo e apenas deixou de pagar, existe um único convite de retorno, enviado 7 dias após o rebaixamento, dentro da janela de atendimento aberta ou como parte da mensagem de fechamento do devocional de domingo. Texto:

"Sentimos falta do áudio na sua rotina? O plano completo tem devocional todos os dias, com narração. app.palavradiaria.com.br/assinar"

Limite: uma vez por rebaixamento. Um assinante que assina, cai, volta e cai de novo recebe o convite duas vezes — uma por queda —, nunca em intervalo menor que 30 dias.

O canal principal de win-back é o próprio produto gratuito: quem recebe um bom devocional todo domingo tem motivo para voltar sem que ninguém peça.

20.11 Métricas de cancelamento #

As definições canônicas de métrica são da Seção 21. Esta subseção especifica os recortes que o dashboard exibe sobre cancelamento e opt-out, e de onde vêm os dados.

Indicador Fonte Recorte
Cancelamentos voluntários no período subscription_events type = 'CANCELED', source IN ('SUBSCRIBER','ADMIN') Dia, semana, mês
Expirações por inadimplência subscriptions end_reason = 'NON_PAYMENT' Dia, semana, mês
Churn voluntário vs involuntário Os dois acima sobre a base ativa no início do período Mensal
Opt-outs consent_events type = 'OPT_OUT' Por origem: WHATSAPP, DASHBOARD, UNSUBSCRIBE_LINK
Reativações de recebimento consent_events type = 'RE_OPT_IN' Mensal
Pausas iniciadas e concluídas consent_events type IN ('PAUSE_STARTED','PAUSE_ENDED') Mensal, com duração média
Taxa de aceite da oferta de retenção RETENTION_OFFER_SHOWN vs pausas criadas em até 10 minutos Mensal
Motivos agregados subscription_events type = 'CANCELLATION_SURVEY' Barras horizontais, ordenadas
Sobrevida média da assinatura current_period_end − primeira ativação das encerradas Mensal, em dias
Exclusões de conta subscribers.deleted_at Mensal

Painel "Cancelamento" no dashboard administrativo (Seção 21 é a dona do layout):

  • Card com cancelamentos voluntários, expirações por inadimplência e a razão entre eles.
  • Gráfico de barras dos motivos, com contagem e percentual, período selecionável.
  • Lista dos comentários livres mais recentes, com telefone mascarado, paginada por cursor, visível para ADMIN e OWNER. EDITOR vê os motivos agregados, não os comentários — o comentário é dado pessoal.
  • Taxa de aceite da oferta de retenção, com o número absoluto de assinaturas salvas.
  • Série temporal de opt-outs por origem, para detectar picos ligados a uma mensagem específica. Um pico de opt-out no dia seguinte a um devocional é sinal de conteúdo mal recebido, e essa correlação é o principal uso da métrica.

Alertas: os dois alertas de negócio deste fluxo são definidos no catálogo da Seção 21.12, com esses nomes exatos — biz_optout_spike (opt-outs de D−1 acima de 3× a média de 30 dias, com pelo menos 15 opt-outs absolutos) e biz_cancellation_spike (cancelamentos voluntários de D−1 acima de 3× a média de 30 dias, com pelo menos 10 absolutos). Ambos são derivados de daily_metrics pelo job de alertas de negócio e entregues pelo canal descrito na Seção 23.8. Esta subseção não redefine limiar nenhum: o catálogo da Seção 21 é o dono.

20.12 Endpoints #

Todos usam o envelope e o catálogo de erros da Seção 7. Todos os erros comuns de sessão (UNAUTHENTICATED 401, SESSION_EXPIRED 401, SUBSCRIBER_DELETED 410) valem para as rotas de assinante e não são repetidos linha a linha.

Convenção de caminho seguida aqui: ações sobre a própria conta do assinante ficam sob /api/me/, com nome de ação no singular (/api/me/opt-out, /api/me/pause, /api/me/deletion-request, /api/me/subscription/cancel); coleções administrativas são substantivos no plural em kebab-case (/api/admin/cancellations).

Trava de impersonação. Todas as rotas de escrita desta subseção — opt-out, opt-in, pausa, cancelamento, registro da oferta de retenção e pedido de eliminação — são recusadas em sessão de impersonação administrativa, sem exceção. A impersonação é integralmente somente leitura, e a recusa acontece no invólucro de API antes de chegar ao handler, pelo critério da guarda de autenticação e não pelo prefixo do caminho (Seção 8.13). O caso que essa regra evita é concreto: um operador de suporte dentro da conta de uma assinante clica em "Cancelar assinatura" para entender a reclamação, a assinatura é cancelada de verdade na Asaas, a assinante recebe uma confirmação que nunca pediu, e o registro de auditoria mostra a ação como se fosse dela.

20.12.1 POST /api/me/opt-out #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const optOutSchema = z.object({
  source: z.enum(['DASHBOARD', 'UNSUBSCRIBE_LINK']).default('DASHBOARD'),
  acknowledgeBillingChange: z.boolean().optional(),
});

O campo source é o da requisição e é gravado na coluna subscribers.opt_out_reason (Seção 6.3). Não existe coluna opt_out_source.

acknowledgeBillingChange é obrigatório e igual a true quando o assinante tem assinatura vigente. Sem isso, a rota devolve 422 BILLING_ACKNOWLEDGEMENT_REQUIRED. O campo confirma que o assinante leu o efeito financeiro: a próxima cobrança é suspensa, o período já pago continua valendo e, em 30 dias sem reativação, a assinatura é encerrada (Seção 13.4.6). É a garantia, no servidor, de que ninguém é silenciado sem entender o efeito financeiro.

  • Resposta 200:
{
  "data": {
    "optOutAt": "2026-08-25T14:31:09.220Z",
    "canReceiveMessages": false,
    "billingSuspended": true,
    "nextChargeCanceled": true,
    "paidAccessUntil": "2026-09-10T13:37:00.000Z",
    "subscriptionEndsOn": "2026-09-24",
    "reactivateBeforeToKeepSubscription": "2026-09-24",
    "cancelSubscriptionUrl": "https://app.palavradiaria.com.br/assinatura"
  },
  "meta": { "requestId": "req_01K3QG7T9V1X3Z5B7D9F1H3K5M", "timestamp": "2026-08-25T14:31:09.220Z" }
}

subscriptionEndsOn é o menor entre current_period_end e a data do pedido mais 30 dias; para assinante sem assinatura vigente, os cinco campos de cobrança vêm null e billingSuspended é false.

  • Erros: BILLING_ACKNOWLEDGEMENT_REQUIRED (422), ALREADY_OPTED_OUT (409, com optOutAt em details), RATE_LIMITED (429, 10 por hora).
  • Efeitos colaterais: grava opt_out_at, opt_out_reason; limpa paused_until; grava consent_events com type = 'OPT_OUT' e granted = false; suspende a cobrança do ciclo seguinte quando houver assinatura vigente (Seção 13.4.6); cancela jobs de envio pendentes; mostra a confirmação na tela e envia confirmação por e-mail se houver e-mail verificado. Não envia WhatsApp, qualquer que seja o source, pela regra de canal de 20.4.1.
  • Idempotência: a segunda chamada devolve 409 ALREADY_OPTED_OUT sem efeito e sem nova mensagem.

20.12.2 POST /api/me/opt-in #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request: corpo vazio.
  • Resposta 200:
{
  "data": {
    "optOutAt": null,
    "canReceiveMessages": true,
    "sendFrequency": "WEEKLY_SUNDAY",
    "nextSendAt": "2026-08-30T09:00:00.000Z"
  },
  "meta": { "requestId": "req_01K3QH9V1X3Z5B7D9F1H3K5M7P", "timestamp": "2026-08-25T14:40:02.017Z" }
}
  • Erros: NOT_OPTED_OUT (409), SUBSCRIBER_NOT_OPTED_IN (409, quando nunca houve confirmação de opt-in em duas etapas e é preciso refazer o onboarding da Seção 11), RATE_LIMITED (429).
  • Efeitos colaterais: limpa opt_out_at e paused_until; grava consent_events com type = 'RE_OPT_IN' e granted = true.
  • Idempotência: segunda chamada devolve 409 NOT_OPTED_OUT, tratado como sucesso pelo cliente.

20.12.3 POST /api/me/pause #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const pauseSchema = z.object({
  days: z.number().int().min(1).max(30),
});
  • Resposta 200:
{
  "data": { "pausedUntil": "2026-09-05T06:00:00.000Z", "days": 10,
            "resumesOn": "2026-09-05", "billingUnaffected": true },
  "meta": { "requestId": "req_01K3QJ1X3Z5B7D9F1H3K5M7P9R", "timestamp": "2026-08-25T22:10:44.900Z" }
}
  • Erros: PAUSE_DAYS_OUT_OF_RANGE (422), SUBSCRIBER_OPTED_OUT (409), PAUSE_LIMIT_EXCEEDED (429, mais de 3 pausas em 90 dias), RATE_LIMITED (429).
  • Efeitos colaterais: grava paused_until; grava consent_events com type = 'PAUSE_STARTED' e evidence = {"days": N, "pausedUntil": "..."}; cancela envios do período. Envia confirmação pelo WhatsApp antes de a pausa valer, uma única vez.
  • Idempotência: não é idempotente — cada chamada redefine a data a partir de agora. Uma pausa vigente é substituída, não somada, e a resposta traz a data nova.

20.12.4 DELETE /api/me/pause #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Resposta 200: { "data": { "pausedUntil": null, "canReceiveMessages": true } }.
  • Erros: PAUSE_NOT_ACTIVE (404).
  • Efeitos: limpa paused_until; grava consent_events com type = 'PAUSE_ENDED' e granted = true. Nenhuma mensagem de retorno é enviada, porque a ação foi do próprio assinante.
  • Idempotência: segunda chamada devolve 404, tratado como sucesso.

20.12.5 GET /api/me/subscription/cancellation-preview #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Resposta 200:
{
  "data": {
    "subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
    "planName": "Plano mensal",
    "amountCents": 1990,
    "paidAccessUntil": "2026-09-10T13:37:00.000Z",
    "remainingDays": 19,
    "afterCancellation": {
      "sendFrequency": "WEEKLY_SUNDAY",
      "audioEnabled": false,
      "archiveWindowDays": 7,
      "manualResendsPerDay": 1
    },
    "retentionOffer": { "available": true, "type": "PAUSE_30_DAYS" },
    "reasons": [
      { "value": "TOO_EXPENSIVE", "label": "Está caro para mim agora" },
      { "value": "NOT_USING", "label": "Não estou lendo/ouvindo" },
      { "value": "TOO_MANY_MESSAGES", "label": "Mensagens demais" },
      { "value": "CONTENT_NOT_RELEVANT", "label": "O conteúdo não me atende" },
      { "value": "AUDIO_QUALITY", "label": "Não gostei do áudio" },
      { "value": "TECHNICAL_PROBLEMS", "label": "Tive problemas técnicos" },
      { "value": "FOUND_ALTERNATIVE", "label": "Encontrei outra opção" },
      { "value": "TEMPORARY_FINANCIAL", "label": "Aperto financeiro temporário" },
      { "value": "OTHER", "label": "Outro motivo" }
    ]
  },
  "meta": { "requestId": "req_01K3QK3Z5B7D9F1H3K5M7P9R1T", "timestamp": "2026-08-22T21:12:55.640Z" }
}
  • Erros: SUBSCRIPTION_NOT_FOUND (404), SUBSCRIPTION_NOT_ACTIVE (409).
  • Efeitos colaterais: nenhum. A exibição da oferta só é registrada quando o cliente confirma que a mostrou, no passo seguinte. Idempotência: leitura pura.

20.12.6 POST /api/me/subscription/retention-offer-shown #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request: corpo vazio.
  • Resposta 200: { "data": { "recorded": true } }.
  • Erros: SUBSCRIPTION_NOT_FOUND (404), RETENTION_OFFER_ALREADY_SHOWN (409).
  • Efeitos: grava subscription_events RETENTION_OFFER_SHOWN. É o que faz a oferta ser única (20.7.4).
  • Idempotência: a segunda chamada devolve 409 e não grava segundo evento.

20.12.7 POST /api/me/subscription/cancel #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const cancelSubscriptionSchema = z.object({
  reason: z.enum(['TOO_EXPENSIVE', 'NOT_USING', 'TOO_MANY_MESSAGES', 'CONTENT_NOT_RELEVANT',
                  'AUDIO_QUALITY', 'TECHNICAL_PROBLEMS', 'FOUND_ALTERNATIVE',
                  'TEMPORARY_FINANCIAL', 'OTHER']),
  comment: z.string().trim().max(500).optional(),
  confirm: z.literal(true),
}).refine((v) => v.reason !== 'OTHER' || (v.comment?.length ?? 0) >= 5, {
  path: ['comment'], message: 'Conte rapidamente o motivo.',
});
  • Resposta 200:
{
  "data": {
    "subscriptionId": "sub_01K3QB2D4F6H8K0M2P4R6T8V0X",
    "status": "CANCELED",
    "canceledAt": "2026-08-22T21:14:03.101Z",
    "paidAccessUntil": "2026-09-10T13:37:00.000Z",
    "downgradesToFreeOn": "2026-09-11",
    "nextChargeCanceled": true,
    "stillReceivesMessages": true,
    "message": "Sua assinatura foi cancelada. Você continua com o plano pago até 10/09/2026."
  },
  "meta": { "requestId": "req_01K3QM5B7D9F1H3K5M7P9R1T3V", "timestamp": "2026-08-22T21:14:03.101Z" }
}
  • Erros: SUBSCRIPTION_NOT_FOUND (404), SUBSCRIPTION_NOT_ACTIVE (409, quando já está CANCELED, EXPIRED ou REFUNDED, com o status atual em details), CONFIRMATION_REQUIRED (422, quando confirm não é true), CANCELLATION_REASON_REQUIRED (422), CANCELLATION_COMMENT_REQUIRED (422), IDEMPOTENCY_KEY_REQUIRED (400), IDEMPOTENCY_KEY_REUSED (422), PAYMENT_PROVIDER_UNAVAILABLE (503, quando o DELETE na Asaas falha em todas as tentativas — nesse caso o cancelamento é aplicado localmente e a propagação vira tarefa com retry; a resposta é 200 com nextChargeCanceled: false e a mensagem "Estamos concluindo o cancelamento junto ao provedor de pagamento"), RATE_LIMITED (429).
  • Efeitos colaterais: os seis passos de 20.7.5.
  • Idempotência: o header Idempotency-Key é obrigatório (Seção 7.7.1), porque cancelar propaga uma chamada de escrita para a Asaas. Chamada sem a chave devolve 400 IDEMPOTENCY_KEY_REQUIRED. Replay da mesma chave devolve a resposta gravada, com Idempotent-Replay: true, sem chamar a Asaas de novo. A mesma chave com corpo diferente devolve 422 IDEMPOTENCY_KEY_REUSED. Repetição com chave nova depois do sucesso devolve 409 SUBSCRIPTION_NOT_ACTIVE com status: 'CANCELED' e a mesma paidAccessUntil, o que o cliente trata como sucesso; a Asaas também não é chamada nesse caminho.

20.12.8 POST /api/me/deletion-request #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request: corpo vazio.
  • Resposta 200:
{
  "data": {
    "otpSent": true, "channel": "WHATSAPP", "expiresInSeconds": 600,
    "paidAccessLostUntil": "2026-09-10T13:37:00.000Z", "paidDaysLost": 16
  },
  "meta": { "requestId": "req_01K3QN7D9F1H3K5M7P9R1T3V5X", "timestamp": "2026-08-25T15:02:11.443Z" }
}
  • Erros: DELETION_ALREADY_REQUESTED (409, com anonymizationScheduledFor), OTP_RATE_LIMITED (429, 3 envios por hora por número), WHATSAPP_UNAVAILABLE (503).
  • Efeitos: cria otp_codes com purpose = 'ACCOUNT_DELETION'; envia o OTP. Nada é excluído nesta etapa.
  • Idempotência: dentro do TTL, uma nova chamada reenvia o mesmo código em vez de gerar outro, respeitando o limite de envios.

20.12.9 POST /api/me/deletion-request/confirm #

  • Autenticação: sessão de assinante. Papel: SUBSCRIBER.
  • Request:
export const confirmDeletionSchema = z.object({
  code: z.string().regex(/^[0-9]{6}$/),
});
  • Resposta 200:
{
  "data": {
    "deletedAt": "2026-08-25T15:04:38.702Z",
    "anonymizationScheduledFor": "2026-09-01T06:20:00.000Z",
    "subscriptionCanceled": true,
    "retainedData": ["payments", "payment_events", "consent_events", "cpf"],
    "recoveryUntil": "2026-09-01T06:20:00.000Z"
  },
  "meta": { "requestId": "req_01K3QP9F1H3K5M7P9R1T3V5X7Z", "timestamp": "2026-08-25T15:04:38.702Z" }
}
  • Erros: OTP_INVALID (401), OTP_EXPIRED (410, e nunca 401 — 410 Gone distingue código expirado, que existiu e não existe mais, de código inválido, o que permite ao cliente habilitar o botão de reenvio sem ambiguidade), OTP_ATTEMPTS_EXCEEDED (429, após 5 tentativas o código é invalidado e é preciso pedir outro), DELETION_NOT_REQUESTED (409), RATE_LIMITED (429).
  • Efeitos colaterais, em uma transação: deleted_at, opt_out_at, deletion_requested_at = now(); revoga todas as sessions; cancela jobs de envio; grava consent_events com type = 'DATA_DELETION_REQUESTED' e granted = false. Após o commit: DELETE /v3/subscriptions/{id} e agendamento do privacy.anonymize.
  • Idempotência: a segunda chamada devolve 409 DELETION_NOT_REQUESTED, porque o OTP foi consumido e o pedido já foi executado.

20.12.10 POST /api/public/unsubscribe #

Rota pública do link de descadastro (20.4.2). Sem sessão.

  • Autenticação: o próprio token opaco. Papel: nenhum.
  • Request:
export const publicUnsubscribeSchema = z.object({
  token: z.string().min(32).max(64),
  acknowledgeBillingChange: z.boolean().optional(),
});

acknowledgeBillingChange tem aqui o mesmo significado de 20.12.1: confirma que o assinante leu o efeito financeiro, e não que a cobrança continua.

  • Resposta 200: igual à de 20.12.1, mais maskedPhone.
  • Erros: UNSUBSCRIBE_TOKEN_INVALID (404, mesma resposta para token inexistente, expirado ou já rotacionado — não revela existência), BILLING_ACKNOWLEDGEMENT_REQUIRED (422), RATE_LIMITED (429, 20 por hora por IP; e 10 por hora por endereço na guarda de borda da Seção 7.12.2, por ser rota pública que aceita token).
  • Efeitos: mesmos de 20.12.1; rotaciona o token em unsubscribe_tokens; grava consent_events com source = 'UNSUBSCRIBE_LINK' e o IP. A confirmação aparece na tela e segue por e-mail quando houver e-mail verificado; não se envia WhatsApp (20.4.1).
  • Idempotência: o token é de uso único; a repetição cai em UNSUBSCRIBE_TOKEN_INVALID, e a página exibe "Você já está descadastrado".

20.12.11 GET /api/admin/cancellations #

  • Autenticação: sessão de admin. Papel: ADMIN ou OWNER. EDITOR recebe 403.
  • Query: ?limit=<1..100>&cursor=<opaque>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&reason=<enum>.
  • Resposta 200: lista paginada por cursor com subscriptionId, maskedPhone, planCode, canceledAt, paidAccessUntil, reason, comment, retentionOfferShown, retentionOfferAccepted, lifetimeDays, totalPaidCents.
  • Erros: FORBIDDEN_ROLE (403), INVALID_CURSOR (400), INVALID_DATE_RANGE (422, quando from > to ou o intervalo passa de 366 dias), LIMIT_OUT_OF_RANGE (422).
  • Efeitos colaterais: grava admin_audit_log com action = 'CANCELLATION_REPORT_VIEW', porque a resposta expõe comentários que são dado pessoal.
  • Idempotência: leitura pura.

20.12.12 GET /api/admin/cancellations/reasons #

  • Autenticação: sessão de admin. Papel: EDITOR, ADMIN ou OWNER — agregado não expõe dado pessoal.
  • Query: ?from=<YYYY-MM-DD>&to=<YYYY-MM-DD>.
  • Resposta 200:
{
  "data": {
    "from": "2026-08-01", "to": "2026-08-31", "total": 47,
    "byReason": [
      { "reason": "TOO_EXPENSIVE", "count": 14, "percent": 29.8 },
      { "reason": "NOT_USING", "count": 11, "percent": 23.4 },
      { "reason": "TOO_MANY_MESSAGES", "count": 8, "percent": 17.0 },
      { "reason": "TEMPORARY_FINANCIAL", "count": 6, "percent": 12.8 },
      { "reason": "CONTENT_NOT_RELEVANT", "count": 4, "percent": 8.5 },
      { "reason": "OTHER", "count": 4, "percent": 8.5 }
    ],
    "retention": { "offersShown": 47, "offersAccepted": 9, "acceptRate": 19.1 },
    "involuntary": { "nonPayment": 23 },
    "optOuts": { "whatsapp": 31, "dashboard": 12, "unsubscribeLink": 3 }
  },
  "meta": { "requestId": "req_01K3QR1H3K5M7P9R1T3V5X7Z9B", "timestamp": "2026-09-01T09:00:00.000Z" }
}
  • Erros: FORBIDDEN_ROLE (403), INVALID_DATE_RANGE (422).
  • Efeitos colaterais: nenhum. Idempotência: leitura pura. Resposta cacheada por 5 minutos em Redis, chave admin:cancel-reasons:{from}:{to}.

21. Dashboard de Métricas e Analytics #

Esta seção é a dona das definições de métrica do produto. Toda métrica citada em qualquer outra seção — alertas de negócio, critérios de aceitação, relatórios executivos, painéis de monitoramento — usa a definição registrada aqui, com a fórmula exata registrada aqui. Nenhuma outra seção redefine uma métrica, e nenhuma outra seção inventa uma variante da mesma métrica com denominador diferente.

Os nomes das métricas são identificadores técnicos e, por isso, ficam em inglês: eles viram nomes de coluna, valores da coluna metric_key da tabela daily_metrics, nomes de série no Recharts e nomes de métrica no Prometheus (Seção 23). A prosa que descreve cada métrica é em português do Brasil.

21.1 Princípios de medição #

Sete princípios regem tudo o que está nesta seção. Eles existem porque a maioria dos erros de métrica em produtos de assinatura não é erro de SQL, é erro de definição.

  1. Uma métrica, uma fórmula, um dono. Se dois painéis mostram "conversão", eles mostram o mesmo número, calculado pelo mesmo código, a partir da mesma tabela.
  2. Numerador e denominador são armazenados separadamente. Toda taxa é persistida como três valores: numerator, denominator e value. Isso permite reagregar corretamente (ver Seção 21.13.4). A média de sete taxas diárias não é a taxa da semana.
  3. O dia é o dia civil de America/Sao_Paulo. Nunca UTC. A conversão é explícita em toda query de rollup (ver Seção 21.13.2).
  4. Custo é gravado no momento do fato, nunca recalculado. O preço por mensagem da Meta e o preço por caractere do provedor de TTS mudam ao longo do tempo. Recalcular o custo de janeiro com a tabela de preços de agosto produz um número falso. Ver Seção 21.2.6.
  5. O dashboard nunca lê a tabela transacional. Ele lê daily_metrics. A justificativa está na Seção 21.7.
  6. Dado que chega atrasado é normal e previsto. Confirmação de leitura no WhatsApp e webhook de pagamento podem chegar horas depois. O rollup reprocessa os três dias anteriores todo dia (Seção 21.5.3).
  7. Todo número exibido carrega a data de cálculo. O rodapé de cada tela mostra Calculado em <timestamp> · Dados até <metric_date>. Sem isso, um painel travado parece um painel saudável.

21.2 Catálogo canônico de métricas #

Tabela mestre. Colunas: chave técnica, definição em uma frase, fórmula exata, fonte (tabela e colunas), granularidade e periodicidade de cálculo.

Convenções da coluna "Fórmula": count(X) conta linhas distintas de X; D é o dia civil de America/Sao_Paulo em cálculo; M é o mês civil; [a,b) é intervalo semiaberto.

Convenção obrigatória de unidade monetária, válida para todas as fórmulas desta seção. O banco guarda dinheiro em duas unidades inteiras distintas, e confundi-las erra o resultado por um fator de dez mil:

Unidade Sufixo da coluna O que é Divisor para BRL Onde aparece
Centavo de real *_amount_cents Inteiro, centésimos de BRL 100 subscriptions.amount_cents, payments.amount_cents, payments.net_amount_cents, payments.fee_cents, plans.amount_cents
Micro de real *_cost_micros Inteiro, milionésimos de BRL 1.000.000 message_logs.cost_micros, audio_assets.cost_micros

Custos unitários usam micros porque uma mensagem de WhatsApp custa frações de centavo e uma narração custa frações de centavo por caractere: arredondar para centavo no momento do fato zeraria o custo de quase toda linha. Regras que não admitem exceção:

  1. Toda fórmula que produz um valor com sufixo _brl declara explicitamente o divisor.
  2. Nenhuma fórmula soma centavos com micros sem conversão explícita para BRL antes da soma.
  3. Nenhum valor monetário é representado em ponto flutuante em nenhum ponto do caminho de gravação. A conversão para NUMERIC acontece só no rollup, ao produzir a linha de daily_metrics.
  4. A Seção 6 é a dona dos nomes e dos tipos dessas colunas; esta seção apenas as consome.

21.2.1 Base de assinantes #

Chave Definição Fórmula Fonte Granularidade Periodicidade
active_subscribers Assinantes que ainda recebem mensagens e não foram excluídos. count(subscribers WHERE opt_out_at IS NULL AND deleted_at IS NULL AND opt_in_confirmed_at IS NOT NULL) no fim de D subscribers.opt_out_at, .deleted_at, .opt_in_confirmed_at Diária (snapshot) Diária, 03:10
active_subscribers_free Assinantes ativos no tier FREE. active_subscribers AND tier = 'FREE' subscribers.tier Diária (snapshot) Diária, 03:10
active_subscribers_paid Assinantes ativos no tier PAID. active_subscribers AND tier = 'PAID' subscribers.tier Diária (snapshot) Diária, 03:10
paid_share Fração de assinantes ativos no tier pago. active_subscribers_paid / active_subscribers derivada Diária Diária, 03:10
new_subscribers Cadastros que confirmaram o opt-in no dia. count(subscribers WHERE opt_in_confirmed_at ∈ D) subscribers.opt_in_confirmed_at Diária Diária, 03:10
new_registrations Cadastros iniciados no dia, tenham confirmado ou não. count(subscribers WHERE created_at ∈ D) subscribers.created_at Diária Diária, 03:10
paused_subscribers Assinantes com pausa de retenção vigente, que voltam a receber sozinhos ao fim do prazo. count(subscribers WHERE paused_until > fim de D AND deleted_at IS NULL) subscribers.paused_until, .deleted_at Diária (snapshot) Diária, 03:10
opted_out_subscribers Assinantes que pediram para parar de receber mensagens mas mantêm cadastro. count(subscribers WHERE opt_out_at IS NOT NULL AND deleted_at IS NULL) no fim de D subscribers.opt_out_at, .deleted_at Diária (snapshot) Diária, 03:10
deleted_subscribers Assinantes que pediram exclusão no dia. count(subscribers WHERE deleted_at ∈ D) subscribers.deleted_at Diária Diária, 03:10
reactivated_subscribers Assinantes que voltaram após opt-out no dia. count(consent_events WHERE type = 'RE_OPT_IN' AND created_at ∈ D) consent_events.type, .created_at Diária Diária, 03:10

Exemplo concreto. Em 2026-11-08 o banco tem 9.412 linhas em subscribers com opt_in_confirmed_at preenchido; 318 têm opt_out_at preenchido; 44 têm deleted_at preenchido (das quais 12 também têm opt_out_at). Então active_subscribers = 9412 − 318 − 44 + 12 = 9062, opted_out_subscribers = 318 − 12 = 306. A subtração dupla é o erro clássico aqui; a query real usa predicados combinados, não subtrações encadeadas, exatamente para evitá-lo.

Decisão explícita sobre a pausa de retenção: um assinante em pausa continua contando em active_subscribers. Ele não pediu para sair, não foi excluído e volta a receber sozinho ao fim do prazo. Tratá-lo como perdido faria a base parecer encolher a cada oferta de retenção aceita, invertendo o sinal do indicador. Quem precisa do recorte de quem não está recebendo hoje usa paused_subscribers somado a opted_out_subscribers.

21.2.2 Opt-in, aquisição e conversão #

Chave Definição Fórmula Fonte Granularidade Periodicidade
optin_confirmation_rate Fração dos cadastros web que chegou a confirmar o opt-in no WhatsApp. count(subscribers WHERE created_at ∈ D AND opt_in_confirmed_at IS NOT NULL AND opt_in_confirmed_at < created_at + 72h) / count(subscribers WHERE created_at ∈ D) subscribers.created_at, .opt_in_confirmed_at Diária (coorte de cadastro) Diária, 03:10, e reprocessada por 3 dias
optin_median_minutes Mediana do tempo entre o cadastro web e a confirmação no WhatsApp. percentile_cont(0.5) WITHIN GROUP (ORDER BY opt_in_confirmed_at − created_at) para a coorte de D subscribers Diária Diária, 03:10
free_to_paid_conversion_rate Fração dos assinantes FREE existentes no início do mês que virou PAID durante o mês. count(subscription_events WHERE type = 'TIER_UPGRADED' AND created_at ∈ M AND subscriber estava FREE em M−1 fim) / active_subscribers_free no último dia de M−1 subscription_events, daily_metrics Mensal Diária (acumulado no mês corrente), fechada no dia 1
free_to_paid_conversion_rate_d30 Fração de uma coorte de cadastro que converteu em até 30 dias. count(coorte com primeiro TIER_UPGRADED ≤ opt_in_confirmed_at + 30d) / count(coorte) subscribers, subscription_events Coorte diária Diária, 03:10, coortes fechadas com 30 dias
time_to_conversion_avg_days Tempo médio entre confirmação do opt-in e a primeira conversão para PAID. avg(first_upgrade_at − opt_in_confirmed_at) sobre as conversões ocorridas em D, em dias com 2 casas subscribers, subscription_events Diária Diária, 03:10
time_to_conversion_median_days Mediana do mesmo intervalo. percentile_cont(0.5) da mesma população idem Diária Diária, 03:10
checkout_started Sessões de checkout iniciadas no dia. count(subscriptions WHERE created_at ∈ D) (toda tentativa cria linha em PENDING_PAYMENT) subscriptions.created_at Diária Diária, 03:10
checkout_completion_rate Fração dos checkouts iniciados que virou pagamento confirmado em até 72 h. count(subscriptions criadas em D que atingiram ACTIVE em ≤72h) / checkout_started subscriptions, subscription_events Diária (coorte) Diária, reprocessada por 4 dias

Nota sobre a janela de 72 h em optin_confirmation_rate e checkout_completion_rate: sem janela, a taxa de um dia recente sempre parece pior do que a de um dia antigo, porque o dia antigo teve mais tempo para acumular confirmações. A janela fixa torna os dias comparáveis. É por isso que essas duas métricas só ficam finais depois de 3 e 4 dias, respectivamente (ver Seção 21.5.3).

Exemplo concreto de free_to_paid_conversion_rate. Em 31/10 havia 6.800 assinantes FREE ativos. Durante novembro, 214 deles registraram TIER_UPGRADED. Um assinante que se cadastrou em 12/11 e converteu em 20/11 não entra no numerador desta métrica, porque não estava na base de 31/10; ele aparece em free_to_paid_conversion_rate_d30 da coorte de 12/11. Logo, free_to_paid_conversion_rate de novembro = 214 / 6800 = 3,15%.

21.2.3 Retenção e churn #

Chave Definição Fórmula Fonte Granularidade Periodicidade
active_subscriptions Assinaturas pagas em vigor no fim do dia. count(subscriptions WHERE status = 'ACTIVE') subscriptions.status Diária (snapshot) Diária, 03:10
monthly_churn_rate Fração das assinaturas ativas no dia 1 do mês que deixou de estar ACTIVE até o fim do mês. count(subscriptions que estavam ACTIVE em M dia 1 e não estão ACTIVE em M último dia) / count(ACTIVE em M dia 1) subscriptions, subscription_events Mensal Diária (parcial), fechada no dia 1 do mês seguinte
churn_by_reason Mesma contagem de churn, quebrada pelo motivo da saída. numerador de monthly_churn_rate agrupado por reason subscription_events.reason Mensal, por dimensão Diária
voluntary_churn_rate Churn causado por cancelamento pedido pelo assinante. churn_by_reason['USER_CANCELED'] / ACTIVE em M dia 1 idem Mensal Diária
involuntary_churn_rate Churn causado por falha de pagamento, estorno ou chargeback. (churn_by_reason['PAYMENT_OVERDUE'] + ['PAYMENT_REFUNDED'] + ['PAYMENT_CHARGEBACK'] + ['PAYMENT_DELETED']) / ACTIVE em M dia 1 idem Mensal Diária
retention_curve_mN Fração de uma coorte mensal de assinantes pagos ainda ACTIVE N meses depois. count(coorte ainda ACTIVE em M0+N) / count(coorte em M0) para N ∈ {1..12} subscriptions Coorte mensal × N Mensal, dia 1

Valores possíveis de subscription_events.reason usados no eixo do gráfico de churn_by_reason, com o rótulo exato exibido no painel:

reason Rótulo no painel Origem
USER_CANCELED Cancelou no painel Ação do assinante (Seção 20)
PAYMENT_OVERDUE Falha de pagamento Webhook PAYMENT_OVERDUE (Seção 12)
PAYMENT_REFUNDED Estorno Webhook PAYMENT_REFUNDED
PAYMENT_CHARGEBACK Chargeback Webhook PAYMENT_CHARGEBACK_REQUESTED
PAYMENT_DELETED Cobrança removida Webhook PAYMENT_DELETED
CARD_EXPIRED Cartão expirado Falha de cobrança com código de cartão vencido
ADMIN_ACTION Ação administrativa Cancelamento manual por ADMIN/OWNER
SUBSCRIBER_DELETED Exclusão de conta Pedido de eliminação (Seção 22.9)

Como não há período de carência (regra registrada na Seção 13), a distinção entre churn voluntário e involuntário é operacionalmente relevante: o involuntário é atacável com a régua de cobrança e com payment_recovery_rate (Seção 21.2.7), o voluntário não.

21.2.4 Receita #

Chave Definição Fórmula Fonte Granularidade Periodicidade
mrr_brl Receita recorrente normalizada por mês, em reais. sum(CASE plans.interval WHEN 'MONTHLY' THEN subscriptions.amount_cents WHEN 'YEARLY' THEN subscriptions.amount_cents/12.0 END)/100 sobre subscriptions.status = 'ACTIVE' no fim de D subscriptions.amount_cents, plans.interval Diária (snapshot) Diária, 03:10
arr_brl Receita recorrente anualizada. mrr_brl × 12 derivada Diária Diária, 03:10
new_mrr_brl MRR adicionado por assinaturas que entraram em ACTIVE no dia. sum(valor normalizado das assinaturas que entraram em ACTIVE em D) subscription_events, subscriptions Diária Diária, 03:10
churned_mrr_brl MRR perdido por assinaturas que saíram de ACTIVE no dia. sum(valor normalizado das assinaturas que saíram de ACTIVE em D) idem Diária Diária, 03:10
net_mrr_change_brl Variação líquida do MRR no dia. new_mrr_brl − churned_mrr_brl derivada Diária Diária, 03:10
arpu_brl Receita recorrente média por assinante pagante. mrr_brl / active_subscribers_paid derivada Diária Diária, 03:10
arpu_blended_brl Receita recorrente média por assinante ativo, pagante ou não. mrr_brl / active_subscribers derivada Diária Diária, 03:10
estimated_ltv_brl Valor estimado que um assinante pagante gera antes de sair. arpu_brl / monthly_churn_rate do mês fechado mais recente, com teto de 36 meses de vida derivada Mensal Mensal, dia 1
ltv_cac_ratio Razão entre LTV estimado e custo de aquisição informado. estimated_ltv_brl / cac_brl, com cac_brl = (ops.marketing_spend_monthly_cents / 100) / new_paid_subscriptions do mês settings, derivada Mensal Mensal, dia 1
gross_revenue_brl Dinheiro efetivamente recebido no dia. sum(payments.amount_cents WHERE status IN ('CONFIRMED','RECEIVED') AND confirmed_at ∈ D)/100 payments.amount_cents, .confirmed_at Diária Diária, 03:10
refunded_brl Dinheiro devolvido no dia. sum(payments.amount_cents WHERE status = 'REFUNDED' AND refunded_at ∈ D)/100 payments.amount_cents, .refunded_at Diária Diária, 03:10
net_revenue_brl Receita líquida de estornos. gross_revenue_brl − refunded_brl derivada Diária Diária, 03:10

Decisões explícitas de contabilidade registradas aqui:

  • Só existe MRR de assinatura paga vigente. O plano gratuito não é uma linha da tabela plans e não tem assinatura: ser gratuito é a ausência de assinatura vigente (regra da Seção 13.1). Onde a interface exibe um "plano gratuito", ele é um item sintético montado pelo handler, sem correspondência no banco. Por isso nenhuma fórmula de receita precisa excluir linhas de valor zero: elas não existem.
  • ops.marketing_spend_monthly_cents é a chave de gasto de marketing. É lançada manualmente pelo papel OWNER no mesmo formulário de lançamento de custo da tela de Custos, no formato grupo.chave do catálogo de configuração (Seção 26.8.1), com tipo inteiro em centavos e valor padrão 0. Com valor zero, ltv_cac_ratio é gravada como nula, nunca como infinito.
  • MRR usa o valor contratado, não o valor recebido. Um plano anual de R$ 199,00 contribui com R$ 16,583333 por mês para o MRR desde o dia em que fica ACTIVE, mesmo que o dinheiro inteiro tenha entrado no primeiro dia. gross_revenue_brl mede caixa; mrr_brl mede contrato. Os dois números divergem por construção e a tela de receita explica isso em nota fixa.
  • Assinatura em PENDING_PAYMENT não conta no MRR.ACTIVE conta.
  • Taxa da Asaas não é descontada do MRR. O MRR é bruto. O custo de adquirência entra em total_cost_per_subscriber_brl (Seção 21.2.6) como custo, não como redução de receita.
  • estimated_ltv_brl tem teto de 36 meses. Com churn mensal de 2%, a fórmula ingênua ARPU/churn daria 50 meses de vida, o que é fantasia para um produto novo. O teto é aplicado como min(1/monthly_churn_rate, 36) × arpu_brl. Se monthly_churn_rate for zero no mês (base pequena), a métrica é gravada como nula, não como infinito.

Exemplo numérico de mrr_brl, com as unidades explícitas. Base com 2.400 assinaturas mensais de amount_cents = 1990 e 600 anuais de amount_cents = 19900. A soma normalizada em centavos é 2400 × 1990 + 600 × (19900/12) = 4.776.000 + 995.000 = 5.771.000 centavos. Dividindo por 100: mrr_brl = 57.710,00. arr_brl = 692.520,00. arpu_brl = 57.710,00 / 3.000 = 19,24. Repare que a divisão por 12 acontece em centavos e em numeric, antes da divisão por 100; fazê-la em inteiro truncaria R$ 0,0033 por assinatura anual por mês, o que a 3.000 assinantes seria uma diferença visível no fechamento do ano.

21.2.5 Entrega e engajamento #

Chave Definição Fórmula Fonte Granularidade Periodicidade
messages_sent Mensagens que a API da Meta aceitou no dia. count(message_logs WHERE status <> 'REJECTED' AND sent_at ∈ D) message_logs.sent_at, .status Diária Diária, 03:10
delivery_rate Fração das mensagens enviadas que chegou ao aparelho. count(message_logs WHERE status IN ('delivered','read') AND sent_at ∈ D) / count(message_logs WHERE sent_at ∈ D AND status <> 'REJECTED') message_logs.status Diária Diária, 03:10, reprocessada por 3 dias
read_rate Fração das mensagens entregues que foi lida. count(message_logs WHERE status = 'read' AND sent_at ∈ D) / count(message_logs WHERE status IN ('delivered','read') AND sent_at ∈ D) idem Diária Diária, reprocessada por 3 dias
failure_rate Fração das mensagens enviadas que terminou em falha. count(message_logs WHERE status = 'failed' AND sent_at ∈ D) / messages_sent idem Diária Diária, reprocessada por 3 dias
failure_rate_by_error_code Mesma taxa, quebrada por código de erro da Meta. numerador de failure_rate agrupado por message_logs.error_code message_logs.error_code Diária, por dimensão Diária
window_open_rate Fração dos assinantes que receberam o convite por template e abriram a janela de 24 h no mesmo dia. count(distinct subscriber_id em inbound_messages com created_at ∈ D e created_at > o template daquele dia) / count(distinct subscriber_id que recebeu template em D) inbound_messages, message_logs Diária Diária, 03:10
time_to_window_open_median_minutes Mediana do intervalo entre o envio do template e a primeira resposta do assinante. percentile_cont(0.5) WITHIN GROUP (ORDER BY first_inbound_at − template_sent_at) em minutos message_logs, inbound_messages Diária Diária, 03:10
time_to_window_open_avg_minutes Média do mesmo intervalo, truncada em 24 h. avg(least(first_inbound_at − template_sent_at, 24h)) idem Diária Diária, 03:10
template_skip_rate Fração dos envios diários que dispensou o template porque a janela já estava aberta. count(delivery_attempts WHERE step = 'FREEFORM_DIRECT' AND devotional_date = D) / count(delivery_attempts WHERE devotional_date = D) delivery_attempts.step Diária Diária, 03:10
messages_by_template_category Volume de mensagens por categoria de conversa cobrada pela Meta. count(message_logs WHERE sent_at ∈ D) agrupado por conversation_category message_logs.conversation_category Diária, por dimensão Diária
audio_delivery_rate Fração dos assinantes PAID elegíveis do dia que recebeu o áudio entregue. count(message_logs WHERE message_type = 'audio' AND status IN ('delivered','read') AND sent_at ∈ D) / count(assinantes PAID elegíveis em D) message_logs, delivery_attempts Diária Diária, 03:10
video_fallback_rate Fração dos envios PAID que precisou do template de vídeo por falta de janela aberta. count(delivery_attempts WHERE step = 'TEMPLATE_VIDEO' AND devotional_date = D) / count(delivery_attempts PAID em D) delivery_attempts.step Diária Diária, 03:10
opt_out_rate Fração dos assinantes ativos que pediu para sair no dia. count(subscribers WHERE opt_out_at ∈ D) / active_subscribers no início de D subscribers.opt_out_at Diária Diária, 03:10
opt_out_rate_30d Mesma taxa acumulada em 30 dias corridos, para suavizar ruído. sum(numerator dos 30 dias) / avg(denominator dos 30 dias) daily_metrics Diária (janela móvel) Diária, 03:10
panel_wau Assinantes distintos que abriram o painel web nos últimos 7 dias. count(distinct subscriber_id em sessions WHERE last_seen_at ∈ [D−6, D]) sessions Diária (janela) Diária, 03:10
manual_resend_count Reenvios manuais pedidos pelo painel no dia. count(delivery_attempts WHERE origin = 'MANUAL_RESEND' AND created_at ∈ D) delivery_attempts.origin Diária Diária, 03:10
downgraded_at_send_count Assinantes rebaixados de PAID para FREE entre o planejamento e o disparo do lote. count(delivery_attempts WHERE downgraded_at IS NOT NULL AND devotional_date = D) delivery_attempts.downgraded_at Diária Diária, 03:10

Duas armadilhas de denominador ficam registradas aqui, porque erram com frequência:

  • read_rate tem delivered+read no denominador, não sent. Uma mensagem que nunca foi entregue não podia ser lida; incluí-la no denominador mistura problema de entrega com problema de interesse. Quem quiser a taxa de leitura sobre o enviado usa delivery_rate × read_rate.
  • window_open_rate tem no denominador apenas quem recebeu template, não a base toda. Assinantes atendidos pelo atalho de janela já aberta (estratégia FREEFORM_DIRECT) não recebem template e, portanto, não podem "abrir" nada — incluí-los deprimiria a taxa artificialmente à medida que o produto melhora. O par correto de leitura é window_open_rate junto com template_skip_rate.
  • audio_delivery_rate tem no denominador quem era PAID no instante do disparo, e não quem era PAID no instante do planejamento das 05:40. O tier é reavaliado no momento de cada envio (regra da Seção 18.5), de modo que um assinante rebaixado entre o planejamento e o disparo recebe apenas o texto do plano gratuito e é excluído do denominador desta métrica. Na prática, o coletor lê delivery_attempts.tier_at_send_effective, e não delivery_attempts.tier_at_send: o primeiro é o que de fato foi entregue, o segundo é o registro histórico do que se pretendia entregar. Usar tier_at_send faria a métrica reprovar o motor todo dia em que houvesse rebaixamento, quando o motor está justamente fazendo a coisa certa.
  • Uma métrica de acompanhamento acompanha esse caso: downgraded_at_send_count = count(delivery_attempts WHERE downgraded_at IS NOT NULL AND devotional_date = D), diária, consolidada às 03:10. Ela mede quantos assinantes perderam o acesso pago entre o planejamento e o disparo, e é o número que prova, dia a dia, que a revogação imediata está funcionando.

Exemplo concreto. Em um domingo, 3.000 PAID e 7.000 FREE são elegíveis. 1.850 PAID já tinham janela aberta e foram atendidos direto (template_skip_rate do recorte PAID = 61,7%). Os 1.150 PAID restantes e os 7.000 FREE receberam template: 8.150 templates. 2.720 assinantes responderam no mesmo dia. window_open_rate = 2720 / 8150 = 33,4%.

21.2.6 Custos #

Decisão de arquitetura de custo registrada aqui e obrigatória para as demais seções: o custo é gravado no momento do fato. message_logs grava conversation_category, is_billable e cost_micros no instante em que a Meta confirma a mensagem; audio_assets grava provider, char_count e cost_micros no instante em que o áudio é gerado (pipeline na Seção 16). O rollup soma colunas já gravadas; ele nunca multiplica volume por uma tabela de preços atual. Motivo: os preços por mensagem da Meta e por caractere do provedor de TTS mudam, e recalcular o passado com o preço de hoje produz um histórico falso que não bate com a fatura. As faixas de preço vigentes e a origem do valor unitário estão na Seção 17 (WhatsApp) e na Seção 16 (TTS); a chave de configuração com os preços correntes é lida de settings no momento da gravação.

Unidade dos custos unitários: micros de real, divisor 1.000.000. Tanto message_logs.cost_micros quanto audio_assets.cost_micros são inteiros em milionésimos de BRL, conforme a convenção declarada na abertura da Seção 21.2. Uma mensagem utilitária de R$ 0,04 grava cost_micros = 40000; um caractere narrado a R$ 0,000165 grava 165 micros. Ler essas colunas como se fossem centavos — isto é, dividir por 100 em vez de 1.000.000 — superestima o custo em dez mil vezes e é o erro que esta subseção existe para impedir. Já payments é dinheiro de verdade e usa centavos, divisor 100. As duas unidades nunca são somadas antes de virarem BRL.

Chave Definição Fórmula Fonte Granularidade Periodicidade
whatsapp_cost_brl Custo de mensagens do WhatsApp no dia. sum(message_logs.cost_micros WHERE sent_at ∈ D)/1000000 message_logs.cost_micros Diária Diária, 03:10
whatsapp_cost_by_category_brl Mesmo custo, quebrado por categoria de conversa. idem, agrupado por conversation_category message_logs.conversation_category, .cost_micros Diária, por dimensão Diária, 03:10
whatsapp_cost_per_subscriber_brl Custo médio de WhatsApp por assinante ativo no dia. whatsapp_cost_brl / active_subscribers derivada Diária Diária, 03:10
whatsapp_billable_messages Mensagens que geraram cobrança no dia. count(message_logs WHERE is_billable = TRUE AND sent_at ∈ D) message_logs.is_billable Diária Diária, 03:10
whatsapp_free_messages Mensagens enviadas dentro da janela de atendimento, sem custo. count(message_logs WHERE is_billable = FALSE AND sent_at ∈ D) idem Diária Diária, 03:10
tts_cost_brl Custo de geração de áudio no dia. sum(audio_assets.cost_micros WHERE created_at ∈ D)/1000000 audio_assets.cost_micros Diária Diária, 03:10
tts_cost_monthly_brl Custo de TTS no mês. sum(tts_cost_brl dos dias de M) daily_metrics Mensal Diária (acumulado)
tts_chars_generated Caracteres narrados no dia. sum(audio_assets.char_count WHERE created_at ∈ D) audio_assets.char_count Diária Diária, 03:10
tts_cost_per_devotional_brl Custo médio de áudio por devocional produzido. tts_cost_brl / count(distinct devotional_id em audio_assets criados em D) audio_assets Diária Diária, 03:10
payment_fee_brl Taxas de adquirência retidas pela Asaas no dia. sum(payments.amount_cents − payments.net_amount_cents WHERE confirmed_at ∈ D)/100 payments.amount_cents, .net_amount_cents, .confirmed_at Diária Diária, 03:10
infra_cost_brl Custo diário de infraestrutura, rateado. (ops.infra_cost_monthly_cents / 100) / número de dias do mês settings Diária Diária, 03:10
total_cost_brl Custo total do dia. whatsapp_cost_brl + tts_cost_brl + payment_fee_brl + infra_cost_brl, todos já convertidos para BRL pelos divisores próprios antes da soma derivada Diária Diária, 03:10
total_cost_per_subscriber_brl Custo total por assinante ativo no dia. total_cost_brl / active_subscribers derivada Diária Diária, 03:10
total_cost_per_subscriber_month_brl Custo total por assinante ativo no mês. sum(total_cost_brl em M) / avg(active_subscribers em M) daily_metrics Mensal Diária (acumulado)
gross_margin Margem bruta recorrente. (mrr_brl/30 − total_cost_brl) / (mrr_brl/30) no dia derivada Diária Diária, 03:10

infra_cost_brl é lançado manualmente. A chave ops.infra_cost_monthly_cents guarda o valor mensal do VPS, storage, domínio e serviços auxiliares, em centavos inteiros, no formato grupo.chave do catálogo de configuração (Seção 26.8.1). O painel de custos mostra a data do último lançamento e alerta quando o valor tem mais de 60 dias, para que ninguém confie em um rateio obsoleto.

Exemplo numérico de um dia de operação com 9.000 assinantes ativos (3.000 PAID, 6.000 FREE, dia útil), com cada unidade escrita por extenso para que a conversão fique verificável: 1.150 templates de convite para PAID sem janela aberta, 0 templates FREE (não é domingo), 1.850 pacotes free-form sem custo, 40 conversas de suporte.

Componente Dado gravado Conversão BRL
whatsapp_cost_brl 1.150 linhas com cost_micros = 40000sum = 46.000.000 micros /1.000.000 46,00
whatsapp_free_messages ~5.600 linhas com is_billable = FALSE
tts_cost_brl 1 devocional de 4.200 caracteres a 165 micros/char → cost_micros = 693000 /1.000.000 0,69
payment_fee_brl sum(amount_cents − net_amount_cents) = 7.800 centavos /100 78,00
infra_cost_brl ops.infra_cost_monthly_cents = 90000900,00/30 /100, depois rateio 30,00

total_cost_brl = 46,00 + 0,69 + 78,00 + 30,00 = 154,69 e total_cost_per_subscriber_brl = 154,69 / 9000 ≈ 0,0172.

Contraprova do erro que a convenção de unidade impede: lidos como centavos, os mesmos 46.000.000 micros virariam R$ 460.000,00 de custo de WhatsApp em um único dia — dez mil vezes o valor real, e um número que faria gross_margin ficar negativa e disparar todos os alertas de custo da Seção 21.12 no primeiro dia de operação.

21.2.7 Cobrança e inadimplência #

Chave Definição Fórmula Fonte Granularidade Periodicidade
delinquency_rate Fração das cobranças com vencimento no dia que não foi paga até o fim do dia. count(payments WHERE due_date = D AND status NOT IN ('CONFIRMED','RECEIVED')) / count(payments WHERE due_date = D) payments.due_date, .status Diária Diária, 03:10, reprocessada por 7 dias
overdue_subscriptions Assinaturas revogadas por falta de pagamento no dia. count(subscription_events WHERE type='STATUS_CHANGED' AND to_status='EXPIRED' AND reason='PAYMENT_OVERDUE' AND created_at ∈ D) subscription_events Diária Diária, 03:10
payment_recovery_rate Fração das cobranças vencidas de um dia que acabou paga em até 14 dias. count(payments com due_date = D e status final CONFIRMED/RECEIVED com confirmed_at ≤ due_date+14d e que estiveram vencidas) / count(payments com due_date = D que ficaram vencidas) payments.due_date, .confirmed_at, payment_events Diária (coorte) Diária, fechada em 14 dias
reactivation_after_overdue_rate Fração dos assinantes rebaixados por inadimplência que voltou a PAID em até 30 dias. count(coorte com novo TIER_UPGRADED ≤ 30d) / count(coorte rebaixada em D) subscription_events Coorte diária Diária, fechada em 30 dias
pix_vs_card_share Distribuição das assinaturas ativas por forma de pagamento. count(subscriptions ACTIVE agrupado por billing_type) / active_subscriptions subscriptions.billing_type Diária, por dimensão Diária, 03:10
card_decline_rate Fração das tentativas de cobrança em cartão que foi recusada no dia. count(payment_events WHERE event = 'PAYMENT_OVERDUE' e billing_type='CREDIT_CARD' ∈ D) / count(tentativas de cobrança em cartão em D) payment_events, payments Diária Diária, 03:10
chargeback_rate_30d Contestações abertas nos últimos 30 dias sobre as cobranças confirmadas no mesmo período. count(payments WHERE status = 'CHARGEBACK' AND updated_at ∈ [D−29, D]) / count(payments WHERE status IN ('CONFIRMED','RECEIVED') AND confirmed_at ∈ [D−29, D]) payments.status, .confirmed_at Diária (janela móvel) Diária, 03:10
dunning_reminder_sent Lembretes de vencimento enviados no dia. count(message_logs WHERE template_name = 'lembrete_pagamento_v1' AND sent_at ∈ D) message_logs.template_name Diária Diária, 03:10
dunning_reminder_sent_by_stage Mesmo total, quebrado pelo momento da régua. idem, agrupado por message_logs.message_key (dunning_d3, dunning_d1, dunning_d0) message_logs.message_key Diária, por dimensão Diária, 03:10

Nota sobre os três estágios da régua: existe um único template de lembrete de pagamento, lembrete_pagamento_v1, que é um dos oito templates aprovados do produto (Seção 17.5). Os três momentos da régua — três dias antes, um dia antes e no dia do vencimento — são o mesmo template com parâmetros diferentes, distinguidos pela coluna message_logs.message_key. Não existem, e não podem ser criados, templates separados por estágio: cada template novo exige aprovação da Meta e amplia a superfície de recategorização.

payment_recovery_rate só existe porque PIX gera uma cobrança nova a cada ciclo e o assinante pode pagar com atraso. Como não há carência (Seção 13), o assinante perde o acesso no vencimento e o recupera quando paga. A métrica mede quanto dessa perda é revertida. Exemplo: das 120 cobranças PIX com vencimento em 05/11, 31 não foram pagas no dia; 19 foram pagas até 19/11. delinquency_rate de 05/11 = 31/120 = 25,8%; payment_recovery_rate de 05/11 = 19/31 = 61,3%.

21.2.8 Conteúdo editorial #

Chave Definição Fórmula Fonte Granularidade Periodicidade
devotionals_scheduled_ahead Dias de calendário à frente com devocional já em READY ou posterior. count(devotionals WHERE scheduled_for > D AND status IN ('READY','AUDIO_PENDING','AUDIO_READY','PUBLISHED')) devotionals.status, .scheduled_for Diária (snapshot) Diária, 03:10
content_pipeline_days Quantos dias consecutivos a partir de amanhã têm conteúdo pronto, sem buraco. maior n tal que todos os dias [D+1, D+n] têm devocional com status >= READY devotionals Diária Diária, 03:10 e às 18:00
audio_generation_success_rate Fração das gerações de áudio do dia que terminou em AUDIO_READY sem fallback. count(audio_assets WHERE provider='elevenlabs' AND created_at ∈ D) / count(audio_assets WHERE created_at ∈ D) audio_assets.provider Diária Diária, 03:10
audio_generation_duration_p95_s Percentil 95 do tempo de geração de áudio. percentile_cont(0.95) de job_runs.duration_ms para job_name='tts.generate' em D job_runs Diária Diária, 03:10
devotional_revisions Revisões editoriais registradas no dia. count(devotional_revisions WHERE created_at ∈ D) devotional_revisions Diária Diária, 03:10

21.3 Métricas derivadas e regras de arredondamento #

Regra Decisão
Taxas Armazenadas como fração NUMERIC(18,6) no intervalo [0,1]. A conversão para porcentagem acontece só na apresentação, com 1 casa decimal.
Dinheiro Nas tabelas transacionais: inteiro em centavos (*_amount_cents, divisor 100) para valores monetários e inteiro em micros (*_cost_micros, divisor 1.000.000) para custos unitários. Em daily_metrics, sempre em reais como NUMERIC(18,6), já convertido. Nunca ponto flutuante em nenhuma das etapas. A apresentação usa Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).
Conversão de unidade Acontece uma única vez, dentro do coletor da métrica, ao produzir a linha de daily_metrics. Nenhuma camada acima do rollup — endpoint, gráfico, exportação — divide ou multiplica valor monetário. Um teste de rollup afirma, para cada métrica com sufixo _brl, que o valor gravado bate com o cálculo feito com o divisor declarado no catálogo.
Denominador zero A métrica é gravada com value = NULL, numerator e denominator preenchidos. O gráfico mostra descontinuidade, não zero. Zero é um valor de negócio; nulo é ausência de base.
Divisão de inteiros Sempre com cast explícito para numeric antes da divisão, para evitar truncamento silencioso do Postgres.
Percentis percentile_cont, nunca percentile_disc, para que a mediana de uma amostra par seja interpolada.
Fuso na apresentação Todos os eixos de data são rotulados em America/Sao_Paulo e o painel exibe o fuso no rodapé.

21.4 Tabela daily_metrics #

A Seção 6.25 é a dona da definição física desta tabela. O que fica registrado aqui é a justificativa do formato e a regra de uso da coluna dimension. A reprodução do DDL abaixo existe só para leitura corrida desta seção; se algum dia divergir da Seção 6.25, vale a Seção 6.25.

Decisão: formato longo (uma linha por métrica por dia por dimensão). O formato largo — uma coluna nomeada por métrica — é abolido em todo o documento. Justificativa: várias métricas do catálogo são quebradas por dimensão de cardinalidade aberta — código de erro da Meta, categoria de conversa, motivo de churn, forma de pagamento, estágio da régua de cobrança. Um formato largo exigiria uma migração de schema a cada novo código de erro que a Meta introduzir, e cada migração dessas em uma tabela de histórico é risco puro sem contrapartida. O formato longo absorve dimensões novas sem migração e mantém a leitura eficiente com um índice composto adequado. Consequência obrigatória para as demais seções: nenhuma seção pode citar uma coluna como daily_metrics.mrr_cents ou daily_metrics.delivery_rate_bp; toda leitura é por metric_key e dimension.

CREATE TABLE daily_metrics (
  id             CHAR(26)       PRIMARY KEY,
  metric_date    DATE           NOT NULL,
  metric_key     TEXT           NOT NULL,
  dimension      TEXT           NOT NULL DEFAULT '',
  value          NUMERIC(18,6)  NULL,
  numerator      NUMERIC(18,6)  NULL,
  denominator    NUMERIC(18,6)  NULL,
  rollup_version SMALLINT       NOT NULL DEFAULT 1,
  is_final       BOOLEAN        NOT NULL DEFAULT FALSE,
  computed_at    TIMESTAMPTZ    NOT NULL DEFAULT now(),
  created_at     TIMESTAMPTZ    NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ    NOT NULL DEFAULT now(),
  CONSTRAINT daily_metrics_unique UNIQUE (metric_date, metric_key, dimension),
  CONSTRAINT chk_daily_metrics_date CHECK (metric_date >= DATE '2026-01-01'),
  CONSTRAINT chk_daily_metrics_denominator CHECK (denominator IS NULL OR denominator >= 0)
);

CREATE INDEX daily_metrics_key_date_idx  ON daily_metrics (metric_key, metric_date DESC);
CREATE INDEX daily_metrics_date_idx      ON daily_metrics (metric_date DESC);
CREATE INDEX daily_metrics_not_final_idx ON daily_metrics (metric_date) WHERE is_final = FALSE;
Coluna Tipo Nulo Default Descrição
id CHAR(26) não ULID gerado na aplicação. Chave primária.
metric_date DATE não Dia civil em America/Sao_Paulo ao qual o valor se refere.
metric_key TEXT não Chave do catálogo da Seção 21.2. Validada contra um enum TypeScript no código; não há constraint de enum no banco, para permitir métricas novas sem migração.
dimension TEXT não '' Valor da dimensão quando a métrica é quebrada (por exemplo 131047, UTILITY, USER_CANCELED). String vazia para métricas escalares — string vazia, e não NULL, para que a constraint UNIQUE funcione (em Postgres, NULL não colide com NULL).
value NUMERIC(18,6) sim Valor final da métrica. Nulo quando o denominador é zero.
numerator NUMERIC(18,6) sim Numerador, para métricas de taxa. Nulo para contagens simples.
denominator NUMERIC(18,6) sim Denominador, para métricas de taxa. Nulo para contagens simples.
rollup_version SMALLINT não 1 Versão do algoritmo que produziu a linha. Incrementada quando uma fórmula muda.
is_final BOOLEAN não FALSE TRUE quando a janela de dados atrasados já fechou (Seção 21.5.3).
computed_at TIMESTAMPTZ não now() Quando a linha foi calculada pela última vez.
created_at TIMESTAMPTZ não now() Quando a linha nasceu. Nunca muda em reprocessamento.
updated_at TIMESTAMPTZ não now() Atualizado a cada sobrescrita, junto com computed_at.

Não há chave estrangeira nesta tabela: ela é um agregado desacoplado, e é justamente esse desacoplamento que permite manter histórico depois de o dado transacional ser eliminado por política de retenção (Seção 22.8). Como não há FK, não existe comportamento ON DELETE aplicável a esta tabela. A tabela nunca é apagada por cascade e não participa de soft delete.

Volume. Cerca de 70 chaves escalares mais aproximadamente 40 linhas de dimensão por dia resultam em ~110 linhas/dia, ~40 mil linhas/ano. Cinco anos de histórico cabem em menos de 30 MB. Essa é a razão de a tabela poder ser retida indefinidamente.

Por que dimension usa string vazia em vez de NULL: no Postgres, uma constraint UNIQUE não impede duas linhas com NULL na coluna, porque NULL <> NULL. Com NULL, um rollup executado duas vezes criaria duas linhas para active_subscribers do mesmo dia, e o ON CONFLICT do upsert não dispararia. A string vazia elimina a classe inteira de bug.

21.5 Job de rollup #

21.5.1 Identificação e agendamento #

Item Valor
Nome do job metrics.rollup
Fila metrics.rollup (uma das treze filas do catálogo da Seção 18.8; o nome do job é igual ao nome da fila)
Agendamento Repetível, cron: '10 3 * * *', tz: 'America/Sao_Paulo'
Alvo padrão D−1 já fechado, mais reprocessamento de D−2, D−3, D−4, D−7 e D−14
Concorrência 1. jobId fixo metrics:rollup:{targetDate} garante que duas execuções para o mesmo dia não coexistam.
Duração esperada Menos de 90 s com 50.000 assinantes
Timeout 10 min; além disso o job falha e dispara alerta (Seção 23.8)
Registro Uma linha em job_runs por execução, com job_name, queue, started_at, finished_at, status, duration_ms e metadata contendo os dias processados

Existe um único rollup, às 03:10, e o alvo é sempre o dia D−1 já encerrado. Não há, e não pode haver, consolidação às 23:00 nem em nenhum outro horário dentro do próprio dia medido: um rollup que rodasse antes do fim do dia civil mediria um dia incompleto e subestimaria sistematicamente messages_sent, delivery_rate, window_open_rate e todas as métricas de custo, porque o fechamento das entregas pendentes ocorre às 23:55 (Seção 18.2) e é o último evento do dia civil.

O horário 03:10 foi escolhido por quatro razões, nesta ordem: fica depois do fechamento das entregas das 23:55 do dia alvo, o que garante que o dia medido está completo; fica antes da reconciliação de pagamentos das 04:00; fica bem antes do planejamento do envio diário das 05:40 (Seção 18); e fica em uma faixa de baixíssimo tráfego, quando as consultas pesadas não competem com requisições de assinantes. A divergência de receita que a reconciliação das 04:00 vier a corrigir entra no reprocessamento de D−2 executado no dia seguinte — é exatamente para isso que a lista de reprocessamento existe.

21.5.2 Algoritmo #

// apps/worker/src/jobs/metrics-rollup.ts
import { z } from 'zod'

export const rollupDailyInput = z.object({
  targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
  backfillDays: z.array(z.number().int().min(0).max(400)).default([1, 2, 3, 4, 7, 14]),
  force: z.boolean().default(false),
  onlyKeys: z.array(z.string()).optional(),
})

export async function runRollupDaily(input: z.infer<typeof rollupDailyInput>) {
  const anchor = input.targetDate ?? todayInSaoPaulo()
  const dates = input.backfillDays.map((n) => subDaysCivil(anchor, n))

  for (const date of dates) {
    if (!input.force && (await isFinal(date))) {
      logger.info({ date }, 'metrics.rollup.skip_final')
      continue
    }
    const written = await db.$transaction(async (tx) => {
      const rows: MetricRow[] = []
      for (const collector of COLLECTORS) {
        if (input.onlyKeys && !input.onlyKeys.includes(collector.key)) continue
        rows.push(...(await collector.collect(tx, date)))
      }
      await upsertMetrics(tx, date, rows)
      await markFinalIfDue(tx, date)
      return rows.length
    }, { timeout: 120_000, isolationLevel: 'RepeatableRead' })
    logger.info({ date, count: written }, 'metrics.rollup.done')
  }
}

A contagem de linhas é devolvida pela transação, e não lida de uma variável declarada dentro dela. Escrever rows.length fora do bloco não compila, e é o tipo de detalhe que passa despercebido em revisão e trava o build no primeiro pnpm build.

O upsert é sempre por conflito na chave natural, o que torna a reexecução segura:

INSERT INTO daily_metrics (id, metric_date, metric_key, dimension, value, numerator,
                           denominator, rollup_version, is_final, computed_at,
                           created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, FALSE, now(), now(), now())
ON CONFLICT (metric_date, metric_key, dimension) DO UPDATE
SET value          = EXCLUDED.value,
    numerator      = EXCLUDED.numerator,
    denominator    = EXCLUDED.denominator,
    rollup_version = EXCLUDED.rollup_version,
    computed_at    = now(),
    updated_at     = now()
WHERE daily_metrics.is_final = FALSE OR EXCLUDED.rollup_version > daily_metrics.rollup_version;

A cláusula WHERE no DO UPDATE é o mecanismo de proteção do histórico: uma linha já marcada como final só é sobrescrita se o algoritmo tiver mudado de versão. Um rollup rodado por engano sobre um mês antigo não corrompe nada.

Uma coleta escalar típica, por exemplo active_subscribers, tem esta forma. Note a conversão de fuso explícita, que é a regra da Seção 21.13.2:

SELECT count(*)::numeric AS value
FROM subscribers
WHERE opt_in_confirmed_at IS NOT NULL
  AND opt_in_confirmed_at < ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo'
  AND (opt_out_at IS NULL OR opt_out_at >= ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo')
  AND (deleted_at IS NULL OR deleted_at >= ($1::date + INTERVAL '1 day') AT TIME ZONE 'America/Sao_Paulo');

Reparar no detalhe: o snapshot de "fim do dia D" é reconstruído a partir dos timestamps de transição, e não do estado atual da linha. Sem isso, reprocessar 60 dias atrás retornaria o estado de hoje aplicado a todos os dias, achatando a série inteira. Essa reconstrução só é possível porque subscribers guarda opt_out_at e deleted_at como timestamps, e porque subscription_events é append-only.

Uma coleta com dimensão, por exemplo failure_rate_by_error_code:

SELECT COALESCE(error_code, 'UNKNOWN') AS dimension,
       count(*)::numeric               AS numerator,
       (SELECT count(*)::numeric FROM message_logs m2
         WHERE m2.sent_at >= $1::timestamptz AND m2.sent_at < $2::timestamptz
           AND m2.status <> 'REJECTED')  AS denominator
FROM message_logs m1
WHERE m1.sent_at >= $1::timestamptz AND m1.sent_at < $2::timestamptz
  AND m1.status = 'failed'
GROUP BY 1;

21.5.3 Janela de dados atrasados e marcação de final #

Nem toda métrica fica correta às 03:10 do dia seguinte. Confirmações de leitura do WhatsApp chegam por webhook e podem demorar; pagamentos PIX são conciliados no dia seguinte; o webhook da Asaas pode ficar pausado por horas. Por isso cada métrica tem uma janela própria de maturação, e só depois dela is_final vira TRUE.

Métrica Janela até is_final Motivo
active_subscribers e demais snapshots de base 1 dia Estado derivado de timestamps já gravados.
delivery_rate, read_rate, failure_rate* 3 dias Status de mensagem chega por webhook assíncrono.
optin_confirmation_rate 4 dias Janela de atribuição de 72 h mais folga.
checkout_completion_rate 4 dias Janela de 72 h mais folga.
delinquency_rate 7 dias Conciliação diária pode corrigir status de pagamento.
payment_recovery_rate 15 dias Janela de recuperação de 14 dias.
free_to_paid_conversion_rate_d30, reactivation_after_overdue_rate 31 dias Janela de coorte de 30 dias.
retention_curve_mN Nunca marcada final antes de N+1 meses Coorte por definição aberta.
Métricas de custo 3 dias Custo é gravado no fato, mas mensagens do fim do dia podem ter status pendente.

markFinalIfDue aplica a tabela acima. Em produção, a série de delivery_rate de um dia tipicamente sobe de 91% na primeira apuração para 97% na terceira; o painel sinaliza pontos não finais com marcador aberto no gráfico e legenda "parcial".

21.5.4 Falha do rollup #

Cenário Comportamento
Erro de SQL em um coletor A transação daquele dia é revertida por inteiro. Nenhuma linha parcial é gravada. O job registra status='FAILED' em job_runs e re-tenta com backoff exponencial (3 tentativas: 1 min, 5 min, 25 min).
Timeout de 10 min Mesmo tratamento. Após a terceira falha, dispara o alerta metrics_rollup_failed (Seção 23.8).
Banco indisponível O job falha e a fila re-tenta. Se D−1 não for calculado até 08:00, o alerta metrics_stale dispara.
Rollup pulado por queda prolongada O job do dia seguinte reprocessa D−1..D−4, D−7 e D−14, o que cobre automaticamente até quatro dias consecutivos de indisponibilidade. Além disso, reprocessamento manual (Seção 21.6).
Dois workers pegando o mesmo dia Impossível: jobId determinístico metrics:rollup:{date} faz o BullMQ descartar o duplicado, e a constraint UNIQUE da tabela protege o resto.

21.6 Reprocessamento histórico #

Reprocessar é uma operação normal, não uma emergência. Três situações a exigem: correção de bug em uma fórmula, chegada de dado atrasado fora da janela, e mudança deliberada de definição.

Comando de operação:

# Reprocessa um intervalo fechado, respeitando is_final
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-01-31

# Reprocessa forçando sobrescrita de linhas já finais
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-01-31 --force

# Reprocessa apenas duas métricas, útil após corrigir uma fórmula
pnpm ops metrics:rollup --from 2026-01-01 --to 2026-08-24 --force \
  --only delivery_rate,read_rate

# Mostra o que mudaria, sem gravar
pnpm ops metrics:rollup --from 2026-08-01 --to 2026-08-24 --dry-run

Regras obrigatórias do reprocessamento:

  1. Mudança de fórmula exige incremento de rollup_version. Sem isso, o ON CONFLICT não sobrescreve linhas finais e o histórico fica inconsistente com o presente. A versão vive em packages/core/src/metrics/version.ts e é anotada por métrica.
  2. Todo reprocessamento com --force registra uma linha em admin_audit_log com admin_user_id do operador, actor_type, actor_role, action = 'METRICS_ROLLUP_FORCED' e metadata contendo o intervalo, as chaves afetadas e a justificativa passada em --reason — nos nomes de coluna da Seção 6.9. O parâmetro --reason é obrigatório quando --force está presente; sem ele o comando aborta.
  3. Reprocessamento nunca roda entre 05:30 e 06:30, para não competir com a janela de envio diário. O comando recusa a execução nesse intervalo e sugere --allow-send-window para o caso excepcional.
  4. Reprocessar não recria dado transacional que já foi eliminado. Se message_logs de 2024 já foi purgado pela política de retenção (Seção 22.8), o rollup daquele período produziria zeros. Por isso o comando bloqueia intervalos anteriores ao horizonte de retenção da tabela envolvida e exibe a mensagem de erro correspondente. O histórico antigo em daily_metrics permanece intocado, que é exatamente o motivo de a tabela existir.
  5. --dry-run imprime um diff por métrica, no formato metric_key dimension date: 0.912 -> 0.968 (+6,1%), limitado às 200 maiores divergências.

21.7 Por que o dashboard não consulta a tabela transacional #

Quatro razões, todas verificáveis.

  1. Volume. Com a meta de 10.000 assinantes do primeiro ano, o produto gera algo em torno de 12.000 linhas por dia em message_logs (assinantes PAID recebendo três mensagens diárias, assinantes FREE recebendo três aos domingos, mais status e mensagens de suporte). São ~4,3 milhões de linhas por ano. No cenário dimensionado de 50.000 assinantes, passam de 21 milhões por ano. Um gráfico de "taxa de entrega nos últimos 12 meses" varreria dezenas de milhões de linhas a cada carregamento de tela.
  2. Latência e previsibilidade. O alvo de p95 das rotas de API é o registrado na Seção 9.12. Uma agregação anual sobre message_logs não cabe nesse orçamento em nenhuma máquina razoável, e pior: o tempo varia com o crescimento da base, de modo que a tela fica progressivamente mais lenta sem nenhuma mudança de código.
  3. Contenção. A janela de envio diário é o momento de maior escrita em message_logs. Uma varredura analítica nesse instante compete por I/O exatamente com o processo mais crítico do produto. Ler daily_metrics custa poucos milissegundos e não toca nas tabelas quentes.
  4. Retenção destrói o histórico. message_logs é retido por 18 meses e inbound_messages por prazo semelhante (Seção 22.8). Um gráfico "desde o início" alimentado pela tabela transacional passaria a mentir no dia em que a primeira purga rodasse: a série simplesmente começaria a cair para zero no passado. daily_metrics guarda o agregado para sempre, com custo de armazenamento desprezível, e é imune à purga.

Exceção única e explícita: as telas de detalhe do painel administrativo — por exemplo, a lista de falhas de envio de hoje ou o histórico de mensagens de um assinante específico — leem a tabela transacional diretamente, sempre filtradas por subscriber_id ou por um intervalo de no máximo 7 dias, com paginação por cursor (padrão registrado na Seção 7). Detalhe operacional é consulta pontual e indexada; painel analítico é agregação. São coisas diferentes e usam caminhos diferentes.

21.8 Telas do dashboard administrativo #

Acesso: papéis ADMIN e OWNER veem todas as telas; EDITOR vê apenas Conteúdo e Entrega, sem números de receita nem de custo. A matriz completa de permissões é a da Seção 3.8, que é a dona do assunto.

Os caminhos das seis telas são em português, como toda a interface do produto, inclusive o painel administrativo. As seis constam do mapa de rotas do painel na Seção 15.1, com papel mínimo EDITOR para Conteúdo e Entrega e ADMIN para Visão Geral, Crescimento, Receita e Custos. Nenhuma tela administrativa existe fora daquele mapa.

Componentes comuns a todas as telas:

  • Seletor de período no topo, com atalhos Hoje, 7 dias, 30 dias, 90 dias, Mês atual, Mês anterior, 12 meses, Personalizado. O padrão ao abrir é 30 dias. O período selecionado é persistido em query string (?from=2026-07-01&to=2026-07-31), de modo que a URL é compartilhável.
  • Comparação com período anterior, ligada por padrão. O período de comparação tem o mesmo número de dias e termina no dia anterior ao início do período atual. Cada cartão numérico mostra a variação percentual com seta e cor: verde para variação favorável, vermelho para desfavorável — e "favorável" é definido por métrica, porque queda de churn é boa e queda de MRR é ruim. A definição fica no catálogo, no campo direction: 'up_is_good' | 'down_is_good'.
  • Estado de carregamento: esqueleto com a mesma altura do conteúdo final, para não deslocar o layout. Cartões numéricos mostram barra cinza pulsante; gráficos mostram retângulo com eixos desenhados e área vazia. Nunca spinner de página inteira.
  • Estado vazio: quando não há linhas em daily_metrics para o período, o gráfico é substituído por bloco com o texto "Sem dados para o período selecionado." e, se o produto tem menos de 30 dias de operação, o subtítulo "As séries aparecem conforme os dias são consolidados." Não se desenha um gráfico de zeros — zero e ausência não são a mesma coisa.
  • Estado de erro: bloco com "Não foi possível carregar os indicadores." e botão Tentar de novo, que refaz a consulta via TanStack Query.
  • Rodapé de frescor: Dados consolidados até <metric_date> · Última apuração <timestamp> · Fuso America/Sao_Paulo. Se metric_date for anterior a ontem, o rodapé fica âmbar com o texto "Consolidação atrasada".
  • Marcação de dados parciais: pontos com is_final = false recebem marcador vazado e a legenda ganha o item "parcial". O tooltip do ponto informa "valor ainda pode mudar".

Biblioteca de gráficos: Recharts (linha de versão na Seção 4). Paleta de no máximo 6 cores por gráfico, com rótulos diretos quando houver 3 séries ou menos.

21.8.1 Tela Visão Geral — /admin/metricas #

Objetivo: responder em dez segundos se o produto está saudável hoje.

Cartões numéricos (linha superior, seis cartões):

Cartão Métrica Formato Comparação
Assinantes ativos active_subscribers (último dia do período) inteiro vs. mesmo indicador no fim do período anterior
Assinantes pagos active_subscribers_paid inteiro + paid_share como subtítulo idem
MRR mrr_brl moeda BRL idem
Taxa de entrega delivery_rate (média ponderada do período) percentual, 1 casa idem
Novos assinantes new_subscribers (soma do período) inteiro soma do período anterior
Churn mensal monthly_churn_rate do mês fechado mais recente percentual, 1 casa mês anterior

Gráficos:

  1. Assinantes ativos ao longo do tempo. Área empilhada. Eixo X: metric_date (dia). Eixo Y: contagem, começando em zero. Séries: active_subscribers_free e active_subscribers_paid. Tooltip mostra as duas séries e o total.
  2. Envios e entregas do dia. Barras agrupadas. Eixo X: dia. Eixo Y: contagem. Séries: messages_sent, entregues (delivery_rate × messages_sent) e falhas. Linha secundária no eixo Y direito: delivery_rate em percentual.
  3. Saúde da operação. Painel de sete indicadores em formato de semáforo, sem gráfico: conteúdo pronto para os próximos dias (content_pipeline_days, verde ≥ 7, âmbar 3–6, vermelho ≤ 2), lote do dia concluído (sim/não, a partir de send_batches), fila de envio vazia, webhook da Asaas ativo nas últimas 24 h, qualidade do número do WhatsApp, último rollup, e último backup. Cada item leva ao runbook correspondente na Seção 27.

21.8.2 Tela Crescimento — /admin/metricas/crescimento #

Cartões: new_subscribers (soma), optin_confirmation_rate (ponderada), free_to_paid_conversion_rate (mês corrente, parcial, com selo "parcial"), time_to_conversion_median_days, opt_out_rate_30d, reactivated_subscribers (soma).

Gráficos:

  1. Funil do período. Barras horizontais em cascata, com valor absoluto e taxa de passagem entre etapas: new_registrationsnew_subscribers (opt-in confirmado) → checkout_started → assinaturas ACTIVE. Cada barra mostra a taxa em relação à barra anterior.
  2. Novos assinantes por dia. Barras. Eixo X: dia. Eixo Y: contagem. Duas séries empilhadas: novos FREE e novos PAID diretos. Linha de média móvel de 7 dias sobreposta.
  3. Curva de conversão por coorte. Linhas múltiplas. Eixo X: dias desde a confirmação do opt-in (0 a 60). Eixo Y: free_to_paid_conversion_rate_d30 acumulada. Uma linha por coorte mensal, com as coortes mais antigas em cinza claro e a mais recente em destaque.
  4. Retenção por coorte. Mapa de calor. Linhas: coorte mensal de entrada em PAID. Colunas: meses desde a entrada (M0 a M12). Célula: retention_curve_mN em percentual, com escala de cor sequencial. Células de coortes ainda imaturas ficam hachuradas.
  5. Opt-out. Linha simples. Eixo X: dia. Eixo Y: opt_out_rate em percentual, com banda de opt_out_rate_30d ao fundo.

Filtros adicionais desta tela: tier (Todos, FREE, PAID) e origem de cadastro (utm_source registrado no cadastro), aplicados como dimension quando disponível.

21.8.3 Tela Receita — /admin/metricas/receita #

Cartões: mrr_brl, arr_brl, arpu_brl, net_mrr_change_brl (soma do período), estimated_ltv_brl, gross_revenue_brl (soma do período).

Gráficos:

  1. Evolução do MRR. Linha. Eixo X: dia. Eixo Y: reais. Série única mrr_brl. Anotações verticais nos dias em que houve mudança de preço registrada em settings.
  2. Ponte de MRR. Barras em cascata por mês. Componentes: MRR inicial, new_mrr_brl (positivo, verde), churned_mrr_brl (negativo, vermelho), MRR final. Torna visível se o crescimento vem de aquisição ou de redução de perda.
  3. Caixa versus contrato. Duas linhas no mesmo eixo. Série 1: gross_revenue_brl diário. Série 2: mrr_brl / 30. Nota fixa abaixo do gráfico explicando que planos anuais empurram o caixa para frente do contrato.
  4. Mix de planos. Barras 100% empilhadas por mês. Séries: mensal e anual, a partir da leitura de plans.interval sobre as assinaturas ativas. Não há série de plano gratuito neste gráfico: o plano gratuito não é uma linha de plans e não gera assinatura (Seção 13.1).
  5. Inadimplência e recuperação. Combinação. Barras: cobranças vencidas por dia (delinquency_rate × denominador). Linha no eixo direito: payment_recovery_rate da coorte daquele dia, exibida somente quando a coorte fechou (15 dias).

Nota fixa obrigatória no rodapé desta tela: "MRR é receita contratada e normalizada por mês; receita bruta é dinheiro recebido no dia. Planos anuais fazem os dois números divergirem por construção."

21.8.4 Tela Entrega — /admin/metricas/entrega #

Cartões: delivery_rate, read_rate, window_open_rate, template_skip_rate, time_to_window_open_median_minutes, failure_rate.

Gráficos:

  1. Taxas de entrega e leitura. Duas linhas. Eixo X: dia. Eixo Y: percentual de 0 a 100. Séries: delivery_rate e read_rate. Linha de referência tracejada em 95% para entrega.
  2. Falhas por código de erro. Barras empilhadas. Eixo X: dia. Eixo Y: contagem de falhas. Uma série por dimension de failure_rate_by_error_code, limitada aos 6 códigos mais frequentes do período; o restante entra como "Outros". A legenda traduz o código para o rótulo operacional (por exemplo, 131047 → "Fora da janela de 24 h"), e o clique no segmento abre a lista transacional filtrada daquele dia e daquele código.
  3. Estratégia de envio. Barras 100% empilhadas. Séries, a partir de delivery_attempts.step: FREEFORM_DIRECT (janela aberta), TEMPLATE_INVITE (convite), TEMPLATE_VIDEO (fallback de vídeo). Mostra visualmente se o produto está migrando para o caminho preferencial e barato.
  4. Tempo até abrir a janela. Histograma. Eixo X: faixas de minutos (0–5, 5–15, 15–30, 30–60, 1–3 h, 3–6 h, 6–12 h, 12–24 h, não abriu). Eixo Y: contagem de assinantes. Período agregado.
  5. Duração do lote diário. Linha. Eixo X: dia. Eixo Y: minutos entre o primeiro e o último envio do lote, lido de send_batches. Linha de referência em 20 minutos, que é o alvo de capacidade do motor de envio registrado na Seção 18.14.

Filtro adicional: tier e tipo de mensagem (template, text, audio, video).

21.8.5 Tela Conteúdo — /admin/metricas/conteudo #

Cartões: content_pipeline_days, devotionals_scheduled_ahead, audio_generation_success_rate, audio_generation_duration_p95_s, devotional_revisions (soma), audio_delivery_rate.

Gráficos:

  1. Calendário de prontidão. Grade de calendário dos próximos 60 dias. Cada dia colorido por devotionals.status: cinza (sem devocional), âmbar (DRAFT), azul (READY/AUDIO_PENDING), verde (AUDIO_READY/PUBLISHED), verde escuro (SENT). Clique abre o devocional no editor (Seção 15).
  2. Engajamento por devocional. Tabela ordenável, não gráfico, com uma linha por devocional dos últimos 90 dias: data, título, enviados, taxa de entrega, taxa de leitura, taxa de abertura de janela, opt-outs no dia. Permite ver qual conteúdo prendeu e qual afastou. Ordenação padrão: data decrescente.
  3. Geração de áudio. Barras empilhadas por dia: gerações pelo provedor primário e pelo provedor de fallback, a partir de audio_assets.provider. Linha secundária: audio_generation_duration_p95_s.

21.8.6 Tela Custos — /admin/metricas/custos #

Cartões: total_cost_brl (soma do período), total_cost_per_subscriber_brl (média ponderada), whatsapp_cost_brl (soma), tts_cost_monthly_brl (mês corrente), payment_fee_brl (soma), gross_margin (média ponderada).

Gráficos:

  1. Composição do custo. Área empilhada. Eixo X: dia. Eixo Y: reais. Séries: whatsapp_cost_brl, tts_cost_brl, payment_fee_brl, infra_cost_brl.
  2. Custo por assinante. Linha. Eixo X: dia. Eixo Y: reais com 4 casas. total_cost_per_subscriber_brl, com whatsapp_cost_per_subscriber_brl como segunda linha. Linha de referência no valor de ops.cost_per_subscriber_target_cents / 100 (padrão R$ 0,90/mês, equivalente a R$ 0,03/dia).
  3. Mensagens cobradas versus gratuitas. Barras 100% empilhadas por dia: whatsapp_billable_messages e whatsapp_free_messages. É o gráfico que prova o valor econômico do desenho de janela aberta; quanto maior a fatia gratuita, melhor.
  4. Custo do WhatsApp por categoria. Barras empilhadas por dia, séries de whatsapp_cost_by_category_brl, cuja dimension é o valor de message_logs.conversation_category (UTILITY, MARKETING, AUTHENTICATION, SERVICE). Se a Meta reclassificar o template diário, a mudança fica visível imediatamente neste gráfico — que é o ponto de detecção previsto na Seção 17.
  5. Margem bruta. Linha com faixa. Eixo Y: percentual. gross_margin diária com banda de média móvel de 7 dias.

Bloco fixo abaixo dos gráficos: "Rateio de infraestrutura lançado em <data>: R$ <valor>/mês." Com mais de 60 dias desde o lançamento, o bloco fica âmbar e exibe "Rateio desatualizado — atualize em Configurações".

21.9 API de métricas e exportação #

21.9.1 GET /api/admin/metrics/series #

Item Valor
Método e caminho GET /api/admin/metrics/series
Autenticação Cookie de sessão (__Host-session), conforme Seção 8
Papel exigido ADMIN ou OWNER; EDITOR apenas para chaves do grupo Conteúdo e Entrega
Idempotência Total. Somente leitura, sem efeitos colaterais.
export const metricsSeriesQuery = z.object({
  keys: z.string().min(1).transform((s) => s.split(',')).pipe(z.array(z.string()).min(1).max(12)),
  from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  granularity: z.enum(['day', 'week', 'month']).default('day'),
  dimension: z.string().max(64).optional(),
  compare: z.boolean().default(false),
}).refine((v) => v.from <= v.to, { message: 'Intervalo inválido.', path: ['from'] })
  .refine((v) => diffDays(v.from, v.to) <= 730, { message: 'Máximo de 730 dias.', path: ['to'] })

Resposta 200:

{
  "data": {
    "granularity": "day",
    "series": [
      {
        "key": "delivery_rate",
        "dimension": null,
        "direction": "up_is_good",
        "unit": "ratio",
        "points": [
          { "date": "2026-08-22", "value": 0.968, "numerator": 11623, "denominator": 12007, "isFinal": true },
          { "date": "2026-08-23", "value": 0.971, "numerator": 11701, "denominator": 12051, "isFinal": true },
          { "date": "2026-08-24", "value": 0.934, "numerator": 11209, "denominator": 12001, "isFinal": false }
        ],
        "previousPeriod": null
      }
    ],
    "consolidatedThrough": "2026-08-24",
    "computedAt": "2026-08-25T06:10:44.120Z"
  },
  "meta": { "requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6", "timestamp": "2026-08-25T09:00:00.000Z" }
}

Erros possíveis:

HTTP code Quando
401 UNAUTHENTICATED Sem cookie de sessão válido.
403 FORBIDDEN_ROLE Papel sem acesso ao grupo da métrica pedida.
422 VALIDATION_ERROR Falha do schema Zod; details traz field e issue.
422 UNKNOWN_METRIC_KEY Chave fora do catálogo da Seção 21.2; details[].field = 'keys'.
422 RANGE_TOO_LARGE Mais de 730 dias entre from e to.
429 RATE_LIMITED Mais de 120 requisições por minuto por sessão administrativa.
500 INTERNAL_ERROR Falha inesperada; requestId presente no log.

Regra de agregação por granularidade, implementada no servidor e não no cliente:

-- Semana e mês: reagregar numerador e denominador, nunca a média de value.
SELECT date_trunc($1, metric_date)::date AS bucket,
       CASE WHEN sum(denominator) > 0 THEN sum(numerator) / sum(denominator) END AS value,
       sum(numerator)   AS numerator,
       sum(denominator) AS denominator,
       bool_and(is_final) AS is_final
FROM daily_metrics
WHERE metric_key = $2 AND dimension = $3 AND metric_date BETWEEN $4 AND $5
GROUP BY 1 ORDER BY 1;

Para métricas de contagem (numerator IS NULL), a agregação usa sum(value); para snapshots (active_subscribers, mrr_brl), usa o último valor do bucket, porque somar snapshots diários não tem significado. O catálogo declara o aggregation de cada métrica (rate | sum | last | avg) e a query escolhe o caminho a partir dele. Somar mrr_brl de 30 dias e apresentar como MRR do mês é o erro mais comum em painéis de assinatura; aqui ele é impossível por construção.

21.9.2 GET /api/admin/metrics/export #

Item Valor
Método e caminho GET /api/admin/metrics/export
Autenticação Cookie de sessão
Papel exigido ADMIN ou OWNER
Idempotência Total; somente leitura.
Efeitos colaterais Uma linha em admin_audit_log com admin_user_id, actor_type = 'ADMIN', actor_role, action = 'METRICS_EXPORTED', entity_type = 'metrics_export' e metadata com o relatório, o intervalo e a contagem de linhas. Os nomes das colunas são os da Seção 6.9, dona do esquema.
export const metricsExportQuery = z.object({
  report: z.enum([
    'daily_metrics',        // série longa crua, todas as chaves
    'growth_summary',       // um registro por dia com as métricas de crescimento
    'revenue_summary',      // um registro por dia com receita e MRR
    'delivery_summary',     // um registro por dia com entrega e engajamento
    'cost_summary',         // um registro por dia com custos
    'churn_by_reason',      // um registro por mês e motivo
    'cohort_retention',     // matriz de coorte
  ]),
  from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  format: z.enum(['csv', 'json']).default('csv'),
})

Limites e comportamento, todos decididos aqui:

Limite Valor Comportamento ao exceder
Intervalo máximo 400 dias 422 RANGE_TOO_LARGE
Linhas em resposta síncrona 50.000 Acima disso, 413 EXPORT_TOO_LARGE com instrução de reduzir o intervalo
Exportações por hora por administrador 10 429 RATE_LIMITED
Tamanho máximo do arquivo 25 MB Coberto pelo limite de linhas
Tempo máximo 30 s 504 EXPORT_TIMEOUT

A resposta é transmitida em streaming (Transfer-Encoding: chunked) para não materializar o arquivo em memória. Cabeçalhos: Content-Type: text/csv; charset=utf-8 e Content-Disposition: attachment; filename="delivery_summary_2026-07-01_2026-07-31.csv".

Formato CSV — decisões explícitas, porque exportação brasileira mal formatada é um problema real:

  • Separador vírgula, codificação UTF-8 com BOM ( no início). O BOM faz o Excel em português reconhecer o acento sem passar pelo assistente de importação.
  • Decimal com ponto, não vírgula. Motivo: o arquivo é consumido por planilha e por script; o ponto é inequívoco. O cabeçalho da coluna indica a unidade (delivery_rate_ratio, mrr_brl).
  • Datas em YYYY-MM-DD. Timestamps em ISO 8601 com Z.
  • Valores nulos ficam vazios, nunca 0, nunca null literal.
  • Primeira linha é cabeçalho com nomes de coluna em snake_case inglês.
  • Uma linha final de comentário não é incluída: arquivo limpo, sem rodapé.

Exemplo de saída de delivery_summary:

metric_date,messages_sent,delivered,read,failed,delivery_rate_ratio,read_rate_ratio,window_open_rate_ratio,is_final
2026-07-01,12007,11623,7412,384,0.968000,0.637700,0.334100,true
2026-07-02,12043,11702,7501,341,0.971700,0.641000,0.341200,true

Em format=json, a resposta é um array JSON puro em streaming (Content-Type: application/json), com uma propriedade por coluna em camelCase, seguindo a convenção de campos JSON registrada na Seção 7. O envelope padrão { data, meta } não é usado nas exportações, porque o objetivo é um arquivo consumível diretamente; essa exceção fica registrada aqui e na Seção 7.

Erros possíveis: 401 UNAUTHENTICATED, 403 FORBIDDEN_ROLE, 422 VALIDATION_ERROR, 422 RANGE_TOO_LARGE, 413 EXPORT_TOO_LARGE, 429 RATE_LIMITED, 504 EXPORT_TIMEOUT, 500 INTERNAL_ERROR.

21.9.3 GET /api/admin/metrics/overview #

Endpoint de conveniência que devolve, em uma única chamada, os cartões da tela de Visão Geral já calculados com comparação de período. Existe para evitar seis requisições paralelas no carregamento inicial. Mesma autenticação, mesmos papéis, mesmos erros de series, mais 422 VALIDATION_ERROR. Resposta 200:

{
  "data": {
    "period": { "from": "2026-07-26", "to": "2026-08-24" },
    "previousPeriod": { "from": "2026-06-26", "to": "2026-07-25" },
    "cards": [
      { "key": "active_subscribers", "value": 9062, "previousValue": 8611, "changeRatio": 0.0524, "direction": "up_is_good", "unit": "count" },
      { "key": "mrr_brl", "value": 57710.0, "previousValue": 54120.5, "changeRatio": 0.0663, "direction": "up_is_good", "unit": "brl" },
      { "key": "delivery_rate", "value": 0.9681, "previousValue": 0.9702, "changeRatio": -0.0022, "direction": "up_is_good", "unit": "ratio" }
    ],
    "health": {
      "contentPipelineDays": 12,
      "lastBatchCompletedAt": "2026-08-25T09:04:11.000Z",
      "queueDepth": 0,
      "asaasWebhookHealthy": true,
      "whatsappQualityRating": "GREEN",
      "lastRollupAt": "2026-08-25T06:10:44.120Z",
      "lastBackupAt": "2026-08-25T05:00:12.000Z"
    },
    "consolidatedThrough": "2026-08-24"
  },
  "meta": { "requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6", "timestamp": "2026-08-25T09:00:00.000Z" }
}

21.10 Analytics de produto no site #

21.10.1 Decisão de ferramenta #

Decisão adotada: Umami, auto-hospedado, executando como contêiner adicional no mesmo stack de Docker Compose descrito na Seção 25, atrás do Caddy, no subdomínio analytics.palavradiaria.com.br, usando um banco próprio (umami) na mesma instância PostgreSQL.

Justificativa, em ordem de peso:

  1. Não há transferência internacional de dados de navegação. Todo o processamento ocorre no mesmo VPS brasileiro. Isso elimina uma entrada inteira do inventário de transferência internacional da Seção 22.7.6 e simplifica a Política de Privacidade.
  2. Não usa cookie nem identificador persistente por padrão. O identificador de visitante é derivado de um hash com sal rotacionado diariamente, o que impede rastreamento entre dias e sustenta a classificação de tratamento anonimizado adotada na Seção 21.11.
  3. Custo marginal zero. O contêiner consome poucas centenas de MB de RAM e reaproveita o Postgres existente. Ferramentas hospedadas cobram por evento e ficariam entre R$ 100 e R$ 400 por mês na escala do primeiro ano.
  4. Script leve. Cerca de 2 KB, sem impacto relevante no LCP alvo da landing registrado na Seção 9.12.
  5. Licença permissiva e exportação livre. Os dados ficam em tabelas do nosso banco, e um pg_dump basta para migrar.

Alternativa aceita, sem necessidade de nova decisão de arquitetura: Plausible Community Edition, igualmente auto-hospedado. É intercambiável porque a instrumentação do produto passa por um adaptador único (packages/core/src/analytics/track.ts) que expõe track(event, props); trocar o provedor significa reescrever esse adaptador e o snippet de carregamento, nada além disso. A troca seria justificada se for necessário funil nativo e retenção de eventos por mais de 24 meses.

Alternativas rejeitadas, com motivo: Google Analytics 4, rejeitado por transferência internacional, uso de identificadores persistentes e necessidade de consentimento prévio para qualquer coleta, o que degradaria a amostra; Meta Pixel como ferramenta de analytics, rejeitado pelo mesmo motivo e por conflitar com a postura de privacidade do produto — ele só é carregado se houver campanha paga ativa e consentimento de marketing (Seção 21.11).

21.10.2 Catálogo de eventos de front-end #

Todo evento tem nome em snake_case inglês e propriedades em camelCase. Nenhuma propriedade transporta dado pessoal: proibido enviar telefone, e-mail, CPF, nome, subscriberId ou qualquer identificador que ligue o evento a uma pessoa. Essa proibição é verificada em teste automatizado que falha o build se um payload de track() contiver chave da lista negra (Seção 24).

Evento Quando dispara Propriedades
page_view Automático, a cada navegação path, referrer, utmSource, utmMedium, utmCampaign
landing_hero_cta_click Clique no botão principal do topo variant (primary), plan (monthly|annual|none)
landing_pricing_view Bloco de preços entra 50% na viewport por 1 s plan
landing_faq_open Abertura de um item da FAQ questionId
signup_form_start Primeiro foco em campo do formulário source (hero|pricing|footer)
signup_form_error Erro de validação exibido field, issue (código, nunca o valor digitado)
signup_submitted Envio aceito pelo servidor plan, consentMarketing (booleano)
otp_requested Pedido de código channel (whatsapp|email)
otp_verified Código aceito attempts (1..5)
otp_failed Código recusado reason (expired|invalid|locked)
optin_whatsapp_pending Tela de "confirme no WhatsApp" exibida
checkout_started Abertura da etapa de pagamento plan, billingType
checkout_payment_method_selected Escolha de cartão ou PIX billingType
checkout_submitted Envio do pagamento plan, billingType
checkout_error Erro retornado no checkout code (o code do envelope de erro da Seção 7)
checkout_success Página de sucesso exibida plan, billingType
panel_login_started Início de login no painel channel
panel_devotional_opened Abertura de um devocional no acervo daysAgo (inteiro)
panel_audio_play Play no player web daysAgo
panel_audio_complete Áudio ouvido até 95% daysAgo
panel_resend_requested Pedido de reenvio manual tier
panel_cancel_started Abertura do fluxo de cancelamento tier
panel_cancel_confirmed Cancelamento confirmado reason (motivo escolhido na lista)
panel_cancel_abandoned Saída do fluxo sem confirmar step
cookie_consent_shown Banner exibido
cookie_consent_decision Escolha do usuário analytics (booleano), marketing (booleano)

Propriedades globais anexadas automaticamente a todos os eventos pelo adaptador: env (production|staging), appVersion (o mesmo valor exposto nos logs, Seção 23.1), device (mobile|tablet|desktop, derivado da largura da viewport, não do user-agent completo) e locale.

Relação entre analytics de front-end e daily_metrics: são fontes independentes e servem a perguntas diferentes. Analytics responde "onde o visitante desiste na landing"; daily_metrics responde "quantos assinantes ativos existem". Números de negócio nunca vêm do analytics de front-end, porque bloqueadores de anúncio suprimem parte dos eventos e o número ficaria subestimado. Se os dois discordarem, daily_metrics vence, sempre. Esta regra está escrita na tela de crescimento como nota fixa.

21.11 Consentimento de cookies #

Classificação adotada, em três categorias:

Categoria Conteúdo Base Comportamento
Essenciais Cookie de sessão __Host-session, cookie de proteção CSRF __Host-csrf, cookie de preferência de consentimento pd_consent Necessários para prestar o serviço pedido; não dependem de consentimento Sempre ativos. O banner informa, não pergunta.
Analytics anônimo Instrumentação Umami, sem cookie, sem identificador persistente, sem armazenamento do IP Legítimo interesse, com opt-out disponível Ativo por padrão, desligável no banner e na página de preferências
Marketing Pixel de campanha paga, quando houver campanha ativa Consentimento específico, opt-in Desativado por padrão. Só carrega após aceite explícito.

O que é coletado sem consentimento, de forma exaustiva:

  1. Cookies essenciais, listados acima, com finalidade e prazo declarados na Política de Privacidade.
  2. Logs de servidor (Seção 23.1): endereço IP, user-agent, método, caminho, status, latência e requestId. Base legal: legítimo interesse em segurança e prevenção a fraude. Retenção registrada na Seção 22.8. O IP é dado pessoal e por isso o log de acesso tem retenção curta.
  3. Analytics anônimo: caminho da página, referenciador, parâmetros UTM, categoria de dispositivo, e um identificador de visitante derivado por hash com sal rotacionado a cada 24 h, que impede reidentificação e correlação entre dias. O IP não é armazenado; ele entra apenas no cálculo do hash e é descartado na mesma requisição.

O que não é coletado sem consentimento: pixel de rede de anúncios, mapa de calor, gravação de sessão, fingerprint de dispositivo, dados de formulário antes do envio. Nenhum desses recursos existe no produto, e a página de preferências afirma isso explicitamente.

Banner: aparece na primeira visita, ancorado no rodapé, não bloqueia a leitura da página, com três ações de mesmo peso visual — Aceitar tudo, Somente essenciais e Preferências. Não há padrão escuro: recusar leva o mesmo número de cliques que aceitar. A decisão é gravada no cookie pd_consent (SameSite=Lax, Secure, 12 meses, sem HttpOnly, porque o script precisa lê-lo) com o formato v1|analytics=1|marketing=0|ts=1787635200. Se o assinante estiver autenticado no momento da decisão, ela também é registrada em consent_events com type = 'COOKIE_CONSENT', que é o registro imutável descrito na Seção 22.7.3.

Renovação: o banner reaparece quando a versão da política de cookies muda (prefixo v1 do cookie) ou 12 meses depois da última decisão. Revogação: link permanente Preferências de cookies no rodapé de todas as páginas, que reabre o painel de escolha.

21.12 Alertas de negócio #

Alertas de negócio são derivados de daily_metrics pelo job metrics.check_business_alerts, que roda na fila metrics.rollup logo após o rollup, às 03:25, e usa o mesmo pipeline de entrega dos alertas técnicos descrito na Seção 23.8. A separação é de origem, não de mecanismo: alerta técnico nasce de série Prometheus, alerta de negócio nasce de daily_metrics.

Alerta Condição exata Severidade Canal Destinatário Ação esperada
biz_signups_collapsed new_subscribers de D−1 < 40% da média dos 14 dias anteriores, com média ≥ 10 P2 Slack #alertas-negocio + e-mail Produto e Marketing Verificar landing, checkout e campanhas ativas no mesmo dia útil
biz_optin_rate_drop optin_confirmation_rate (janela de 7 dias) < 60% ou queda > 15 pontos vs. 7 dias anteriores P2 Slack #alertas-negocio Produto Investigar template de boas-vindas e qualidade do número (Seção 27)
biz_conversion_drop free_to_paid_conversion_rate_d30 da coorte fechada mais recente < 60% da mediana das 4 coortes anteriores P3 Slack #alertas-negocio Produto Revisar página de vendas e preço
biz_churn_spike monthly_churn_rate parcial do mês corrente projetado > 6% ao mês P2 Slack + e-mail Produto e Owner Abrir análise de churn_by_reason
biz_involuntary_churn_spike involuntary_churn_rate do mês > 3% ou > 2× o voluntário P2 Slack + e-mail Financeiro Revisar régua de cobrança e taxa de recusa de cartão
biz_mrr_declining net_mrr_change_brl acumulado dos últimos 7 dias < 0 P2 Slack #alertas-negocio Owner Diagnóstico de aquisição versus retenção
biz_optout_spike opt_out_rate de D−1 > 3× a média de 30 dias, com pelo menos 15 opt-outs absolutos P1 Slack #alertas-p1 + e-mail Produto e Conteúdo Ler o devocional enviado e a régua do dia antes do próximo envio
biz_cancellation_spike Cancelamentos voluntários de D−1 > 3× a média de 30 dias, com pelo menos 10 absolutos P2 Slack #alertas-negocio + e-mail Produto Ler os motivos e comentários do dia (Seção 20.11)
biz_delivery_rate_low delivery_rate de D−1 < 90% P1 Slack #alertas-p1 Operação Runbook de falha de entrega (Seção 27)
biz_window_open_rate_drop window_open_rate (7 dias) < 20% ou queda > 10 pontos P3 Slack #alertas-negocio Conteúdo Revisar teaser e botões do template
biz_cost_per_subscriber_high total_cost_per_subscriber_month_brl projetado > (ops.cost_per_subscriber_target_cents / 100) × 1,3 P2 Slack + e-mail Owner Verificar mistura de estratégia de envio e categoria de conversa
biz_video_fallback_high video_fallback_rate de D−1 > 15% P3 Slack #alertas-negocio Produto Fallback de vídeo é caro; revisar engajamento
biz_delinquency_high delinquency_rate de D−1 > 20% com pelo menos 20 cobranças no dia P2 Slack + e-mail Financeiro Conferir integração de cobrança e lembretes
biz_recovery_low payment_recovery_rate da coorte fechada < 40% P3 Slack #alertas-negocio Financeiro Revisar mensagens de lembrete
biz_chargeback_rate_high chargeback_rate_30d > 0,5% P2 Slack + e-mail Financeiro e Owner As bandeiras costumam agir em torno de 1%; investigar antes de chegar lá (Seção 12.14.2)
biz_chargeback_rate_critical chargeback_rate_30d > 0,9% P1 Slack #alertas-p1 + e-mail Owner Risco à conta de recebimento inteira; acionar a Asaas no mesmo dia
biz_content_pipeline_low content_pipeline_days < 3 P1 Slack #alertas-p1 + e-mail Editorial e Owner Produzir conteúdo antes do próximo envio
biz_metrics_stale Nenhuma linha de daily_metrics para D−1 até as 08:00 P2 Slack #alertas-p2 Técnico Runbook de rollup (Seção 27)

Regras de supressão, para que o canal continue confiável:

  • Cada alerta dispara no máximo uma vez por dia. Um alerta que persista por mais de 3 dias consecutivos é reenviado com o prefixo [PERSISTENTE, 4º dia] e sobe uma severidade.
  • Alertas com denominador pequeno são suprimidos: se o denominador da métrica do dia for menor que 20, o alerta não dispara e o job registra suppressed_low_volume no log. Isso evita o ruído dos primeiros meses, quando 2 opt-outs em 30 assinantes viram "600% acima da média".
  • Todo alerta de negócio traz no corpo: valor atual, valor de referência, período comparado, link direto para a tela do dashboard com o período já filtrado, e o link do runbook.

21.13 Precisão e integridade dos números #

21.13.1 Como o sistema evita contagem dupla #

Cinco mecanismos, em camadas:

  1. Chave de idempotência no envio. Cada envio grava delivery_attempts com a chave única send:{subscriberId}:{devotionalDate} (regra registrada na Seção 18). Um replay do job não cria uma segunda tentativa, logo não infla messages_sent.
  2. Identificador da Meta como chave natural de mensagem. message_logs guarda o wamid retornado pela API com índice único. Um webhook de status reentregue atualiza a linha existente em vez de criar outra. A Meta reentrega webhooks com frequência; sem esse índice, a taxa de entrega passaria de 100%.
  3. Idempotência de evento de pagamento. payment_events tem índice único no event.id da Asaas (regra registrada na Seção 12). O mesmo evento processado duas vezes não gera duas transições em subscription_events, logo não conta dois churns.
  4. Contagem por entidade distinta, não por linha, onde faz sentido. window_open_rate conta distinct subscriber_id, e não linhas de inbound_messages — um assinante que manda cinco mensagens abriu uma janela, não cinco.
  5. Upsert por chave natural no rollup. A constraint UNIQUE (metric_date, metric_key, dimension) torna o rollup idempotente por construção. Rodar o job duas vezes para o mesmo dia produz exatamente o mesmo resultado.

Cenário concreto que os cinco mecanismos resolvem juntos: o worker envia 3.000 mensagens, cai antes de confirmar o job, o BullMQ re-executa. Sem idempotência, messages_sent marcaria 6.000 e whatsapp_cost_brl dobraria — e a divergência com a fatura da Meta só apareceria no fim do mês. Com a chave send:{subscriberId}:{devotionalDate}, a segunda execução encontra as tentativas já gravadas, não reenvia e não registra nada novo.

21.13.2 Fuso horário na agregação diária #

O banco guarda timestamptz em UTC (convenção registrada na Seção 6). O dia de negócio é o dia civil de America/Sao_Paulo. A conversão é feita uma vez, na borda da query de rollup, com uma função utilitária compartilhada, e nunca com offset fixo.

// packages/core/src/metrics/day-window.ts
import { fromZonedTime } from 'date-fns-tz'

const TZ = 'America/Sao_Paulo'

/** Retorna [início, fim) em UTC para o dia civil informado em America/Sao_Paulo. */
export function civilDayWindowUtc(civilDate: string): { start: Date; end: Date } {
  const start = fromZonedTime(`${civilDate}T00:00:00`, TZ)
  const end = fromZonedTime(`${addDays(civilDate, 1)}T00:00:00`, TZ)
  return { start, end }
}

Toda query de rollup recebe start e end como parâmetros e usa comparação semiaberta >= start AND < end. Consequências obrigatórias:

  • Nunca BETWEEN com timestamps. BETWEEN é fechado nos dois lados e inclui exatamente a meia-noite do dia seguinte, duplicando eventos na fronteira.
  • Nunca date_trunc('day', sent_at) sem AT TIME ZONE. Isso truncaria em UTC e jogaria todos os eventos entre 21:00 e 23:59 de São Paulo para o dia seguinte. No caso deste produto, isso é catastrófico: um envio das 06:00 é 09:00 UTC e ficaria correto, mas um opt-out às 22:30 apareceria no dia errado, deslocando a correlação entre devocional e opt-out.
  • O horário de verão brasileiro está extinto desde 2019, mas o código usa a base de fusos do sistema por meio de date-fns-tz e nunca -03:00 embutido. Se a regra voltar, os dias de 23 h e 25 h serão tratados corretamente sem mudança de código, e a comparação semiaberta continuará correta. O teste de rollup inclui um caso com data de transição de fuso histórica para garantir isso (Seção 24).
  • A coluna metric_date é o dia civil de São Paulo, sempre. O front-end nunca reconverte: ele exibe metric_date como veio.

Exemplo concreto do erro evitado: no dia 2026-08-24, o envio ocorreu às 06:03 (09:03 UTC) e um assinante deu opt-out às 21:47 (2026-08-25T00:47 UTC). Agregando em UTC, o opt-out apareceria em 25/08 e a análise "o devocional de 24/08 causou opt-outs?" mostraria zero. Agregando com o fuso correto, ele cai em 24/08, junto do devocional que o motivou.

21.13.3 Como um dia recalculado é corrigido #

Procedimento fechado, em cinco passos:

  1. Diagnóstico com --dry-run. O operador roda o comando com --dry-run para o intervalo suspeito e lê o diff por métrica.
  2. Decisão sobre versão. Se a divergência vem de dado atrasado, rollup_version permanece. Se vem de mudança de fórmula, a versão da métrica é incrementada no código e o deploy acontece antes do reprocessamento.
  3. Execução com justificativa. pnpm ops metrics:rollup --from … --to … --force --reason "…". O --reason é obrigatório e vai para admin_audit_log.
  4. Registro visível. Toda linha reescrita atualiza computed_at. A tela afetada passa a exibir, no rodapé, "Série recalculada em <data>" durante 7 dias, para que ninguém compare um relatório impresso antigo com a tela nova sem perceber a mudança.
  5. Comunicação. Se a correção mudar uma métrica publicada externamente (por exemplo, MRR informado a um sócio), a mudança é anunciada no canal #alertas-negocio com o valor antigo, o novo e a causa. Correção silenciosa de número financeiro é proibida.

Regra de imutabilidade prática: daily_metrics não é append-only — ela é sobrescrita sob controle. O registro de auditoria de quem sobrescreveu, quando e por quê é o que fornece a rastreabilidade. A tabela append-only correspondente é admin_audit_log (Seção 23.11).

21.13.4 Reagregação correta de taxas #

Regra única e obrigatória, repetida aqui porque é a fonte mais comum de números errados em painéis: para obter uma taxa de um período de vários dias, some numeradores e denominadores e divida uma vez. Nunca calcule a média das taxas diárias.

Demonstração com números reais do catálogo. Dois dias de delivery_rate:

Dia Numerador (entregues) Denominador (enviadas) Taxa do dia
2026-08-23 11.701 12.051 97,10%
2026-08-24 45 90 50,00%

Média das taxas diárias: (97,10% + 50,00%) / 2 = 73,55% — número sem significado, porque trata um dia de 90 mensagens como igual a um dia de 12.051. Reagregação correta: (11.701 + 45) / (12.051 + 90) = 11.746 / 12.141 = 96,75%.

É por isso que numerator e denominator são colunas de primeira classe em daily_metrics (Seção 21.4) e por isso o endpoint de série faz a reagregação no servidor (Seção 21.9.1). O cliente nunca calcula taxa a partir de valores já divididos.

Para métricas de snapshot (active_subscribers, mrr_brl, active_subscriptions), a agregação de período é o último valor do período, não a soma nem a média. Somar active_subscribers de 30 dias produziria 270 mil assinantes, o que é um número que já apareceu em relatório de produto real e nunca deve aparecer neste.

21.13.5 Verificação cruzada e testes de sanidade #

O job de rollup termina executando um conjunto de invariantes. Qualquer violação grava log em warn, registra a métrica metrics_sanity_violation_total (Seção 23.4) e, quando a violação é grave, falha o job.

Invariante Verificação Gravidade
Soma dos tiers active_subscribers_free + active_subscribers_paid = active_subscribers Grave: falha o job
Taxas no intervalo Toda métrica com unit = 'ratio' tem 0 ≤ value ≤ 1 Grave: falha o job
Denominador consistente numerator ≤ denominator para toda taxa Grave: falha o job
Custo não negativo total_cost_brl ≥ 0 e cada componente ≥ 0 Grave: falha o job
Continuidade da base abs(active_subscribers[D] − active_subscribers[D−1]) ≤ max(50, 5% da base) Aviso: registra e alerta biz_metrics_anomaly
MRR versus assinaturas mrr_brl entre active_subscriptions × 16,58 e active_subscriptions × 19,90 (com folga de 5% para mudanças de preço) Aviso
Cobertura de chaves Todas as chaves escalares do catálogo têm linha no dia Aviso: indica coletor quebrado
Conciliação de receita gross_revenue_brl do mês difere em menos de 1% da soma de payments conciliados com a Asaas Aviso mensal, cruzado com a reconciliação da Seção 12

22. Segurança, Privacidade e LGPD #

O produto trata dados que a Lei nº 13.709/2018 classifica como pessoais sensíveis. A assinatura de um devocional cristão revela convicção religiosa do titular (Art. 5º, II). Essa constatação, registrada aqui e não negociável, muda o regime jurídico de todo o tratamento: a base legal precisa ser consentimento específico e destacado (Art. 11, I), o relatório de impacto deixa de ser opcional, e o padrão de segurança técnica exigido é mais alto do que o de um produto de assinatura comum. Toda decisão desta seção parte daí.

22.1 Modelo de ameaças #

22.1.1 Ativos #

Ativo Por que vale Impacto se comprometido
Base de assinantes (telefone, nome, CPF, e-mail) associada a convicção religiosa Dado sensível, monetizável, com valor para spam e para discriminação Crítico: sanção da ANPD, ação civil, dano reputacional irreversível
Chaves de criptografia de coluna e de índice cego Destravam telefone e CPF em massa Crítico
Token da WhatsApp Cloud API e META_APP_SECRET Permitem enviar mensagens em nome da marca Crítico: banimento do número e perda do canal
Chave de API da Asaas Permite criar, estornar e consultar cobranças Crítico: fraude financeira
Reputação do número do WhatsApp (quality_rating) Não é dado, é ativo operacional: sem número, não há produto Crítico: produto para de funcionar
Sessões administrativas Acesso a base inteira e a exportações Alto
Conteúdo editorial e áudios Propriedade intelectual, custo de produção Médio
Registros de consentimento (consent_events) São a prova de conformidade Alto: sem eles, o opt-in é indefensável

22.1.2 Atores #

Ator Motivação Capacidade típica
Bot de cadastro em massa Esgotar OTP, gerar custo, poluir base Automação simples, IPs residenciais rotativos
Raspador de base Enumerar assinantes por telefone Requisições em volume contra endpoints públicos
Atacante oportunista Exploração de CVE em dependência ou serviço exposto Ferramentas automatizadas de varredura
Fraudador de pagamento Assinar com cartão de terceiro, gerar chargeback Cartões testados, dados de CPF válidos
Ex-administrador Acesso residual após desligamento Credenciais válidas até revogação
Insider com acesso legítimo Exportar base por curiosidade ou venda Painel administrativo e exportação CSV
Comprometimento de terceiro (Meta, Asaas, TTS, storage) Fora do nosso controle Vazamento indireto

22.1.3 Superfícies de ataque #

Landing e formulário de cadastro; endpoint de solicitação e verificação de OTP; checkout com CPF e cartão; webhooks públicos de Asaas e Meta; painel do assinante; painel administrativo; exportações CSV/JSON; URLs assinadas do storage de mídia; endpoint /metrics; acesso SSH e Docker no VPS; cadeia de dependências npm; imagem de contêiner; backups.

22.1.4 Riscos priorizados #

Escala: probabilidade × impacto, ambos de 1 a 5.

# Risco Prob. Imp. Prior. Controle principal
R1 Vazamento da base com telefone, CPF e inferência religiosa 2 5 10 Criptografia de coluna (22.5), menor privilégio (22.12), retenção curta (22.8)
R2 Comprometimento do token da Meta e envio em nome da marca 2 5 10 Segredos fora do repositório e rotação (22.4)
R3 Enumeração de assinantes por telefone em endpoint público 4 3 12 Respostas indistinguíveis e rate limit (22.6.2)
R4 Cadastro em massa por bot esgotando OTP e gerando custo 4 3 12 Rate limit em camadas, anti-bot, bloqueio de descartáveis (22.6)
R5 Sequestro de sessão administrativa 2 5 10 Cookie __Host-, TTL de 12 h, TOTP obrigatório (Seção 8)
R6 Webhook forjado alterando estado de assinatura 3 4 12 HMAC e token comparados em tempo constante (22.6.5)
R7 Injeção de conteúdo malicioso pelo editor (XSS armazenado) 2 4 8 Sanitização de markdown na renderização (22.2.4)
R8 SSRF por URL controlada externamente 2 4 8 Lista fechada de destinos em safeFetch (22.2.6)
R9 Dependência comprometida na cadeia de suprimentos 3 4 12 Lockfile, auditoria em CI, build reprodutível (22.12.4)
R10 Perda de dados por falha de backup 2 5 10 Backup cifrado, teste de restauração mensal (Seção 25)
R11 Exportação indevida da base por insider 2 4 8 Auditoria de exportação, limites, revisão trimestral (22.12.3)
R12 Chargeback em massa 3 3 9 Revogação imediata, CPF obrigatório, limites de cadastro

22.2 Controles de segurança da aplicação #

22.2.1 Validação de entrada #

Toda entrada externa passa por um schema Zod (linha de versão na Seção 4) na borda, antes de qualquer lógica. "Borda" significa: route handler HTTP, consumidor de fila, parser de webhook e argumento de comando da CLI de operação. Nenhuma função de domínio aceita any, unknown ou objeto não validado.

Regras obrigatórias:

  • Schemas usam .strict() por padrão. Campo desconhecido no corpo gera 422 VALIDATION_ERROR, não é ignorado silenciosamente. Motivo: um campo extra aceito é a porta de entrada de poluição de protótipo e de atribuição em massa.
  • Todo string tem .max(). Sem limite explícito, uma requisição com 50 MB de texto chega ao banco. O limite global de corpo é 1 MB, exceto no upload administrativo (22.2.7).
  • Números monetários chegam como inteiro em centavos (*_amount_cents), nunca como float. Custos unitários muito pequenos chegam em milionésimos de real (*_cost_micros). As duas unidades nunca se misturam na mesma fórmula sem conversão explícita (Seção 6).
  • Datas chegam como YYYY-MM-DD ou ISO 8601 com fuso, validadas por regex e depois por parser.
  • Telefone é validado aqui e em nenhum outro lugar. O valor chega como texto livre, é normalizado e validado em E.164 pela regra da Seção 11.4, na borda, antes de ser cifrado. Essa é a única validação de formato que existe: a coluna correspondente guarda um envelope cifrado e o banco não pode inspecioná-la (22.5.3).
  • Identificadores chegam como ULID de 26 caracteres validados por z.string().length(26).regex(/^[0-9A-HJKMNP-TV-Z]{26}$/).
  • O erro devolvido segue o envelope da Seção 7: code, message em português e details com field e issue. details nunca ecoa o valor recebido, apenas o código do problema — ecoar o valor cria refletor de conteúdo controlado pelo atacante.
export const subscriberSignupSchema = z.object({
  name: z.string().trim().min(2).max(80),
  phone: z.string().trim().min(10).max(20),
  email: z.string().trim().toLowerCase().email().max(160).optional(),
  consentService: z.literal(true),          // aceite obrigatório
  consentReligiousData: z.literal(true),    // aceite específico e destacado (22.7.2)
  consentMarketing: z.boolean().default(false),
  consentTextVersion: z.string().regex(/^v\d+$/),
  turnstileToken: z.string().min(10).max(4096),
}).strict()

22.2.2 Injeção de SQL #

Todo acesso a dados passa pelo Prisma (linha de versão na Seção 4), que parametriza as consultas. As três regras que fecham o restante da superfície:

  1. $queryRawUnsafe e $executeRawUnsafe são proibidos. A proibição é imposta por regra de ESLint (no-restricted-properties) que falha o build.
  2. $queryRaw é permitido apenas com template tag e interpolação de parâmetro (Prisma.sql), o que gera consulta parametrizada. As queries de rollup da Seção 21.5 são o caso legítimo.
  3. Identificadores dinâmicos são resolvidos por lista fechada. Ordenação por coluna vinda da query string mapeia uma string do usuário para um valor de um Record<string, Prisma.Sql> definido em código. Nunca há concatenação de nome de coluna.

O usuário de banco da aplicação não tem CREATE, DROP nem ALTER; migrações rodam com um usuário separado (22.12.2). Assim, mesmo uma injeção bem-sucedida não altera o schema.

22.2.3 Escapamento de saída e XSS refletido #

React escapa por padrão toda interpolação em JSX. As regras que sustentam isso:

  • dangerouslySetInnerHTML é proibido, com uma única exceção controlada: a renderização de reflection_md, tratada em 22.2.4. A proibição é imposta por regra de ESLint com exceção nomeada em um único arquivo.
  • URLs vindas de dados nunca vão direto para href ou src. Passam por assertSafeUrl(value, ['https:']), que rejeita javascript:, data: e vbscript:.
  • Nenhum dado do usuário entra em contexto de <script>, <style>, atributo de evento ou eval. Não há uso de eval, new Function nem setTimeout com string em nenhum ponto.
  • Respostas de API são sempre Content-Type: application/json; charset=utf-8, com X-Content-Type-Options: nosniff (22.3), o que impede que um JSON com conteúdo controlado seja interpretado como HTML.

22.2.4 XSS armazenado no conteúdo editorial #

reflection_md é markdown escrito por um editor humano autenticado. É o único conteúdo do sistema que vira HTML. Pipeline obrigatório, tanto na renderização web quanto na conversão para texto de WhatsApp:

// packages/core/src/content/render-markdown.ts
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'

const schema = {
  ...defaultSchema,
  tagNames: ['p', 'br', 'strong', 'em', 'blockquote', 'ul', 'ol', 'li', 'h3', 'h4', 'a'],
  attributes: { a: ['href', 'title'] },
  protocols: { href: ['https', 'mailto'] },
}

export function renderReflection(md: string): string {
  return unified()
    .use(remarkParse)
    .use(remarkRehype)            // sem allowDangerousHtml: HTML cru no markdown é descartado
    .use(rehypeSanitize, schema)
    .use(rehypeStringify)
    .processSync(md)
    .toString()
}

Decisões: a sanitização acontece na renderização, não na gravação — assim, um endurecimento futuro do schema protege retroativamente todo o acervo, e o texto original permanece editável. <img>, <iframe>, <script>, <style> e atributos on* não constam da lista de permitidos, portanto são removidos. Links recebem rel="noopener noreferrer" e target="_blank" injetados na renderização, nunca vindos do markdown.

22.2.5 CSRF #

Modelo de cookie adotado (definido na Seção 8): __Host-session, HttpOnly, Secure, SameSite=Lax, Path=/, sem Domain. Regra de plataforma que vale para todos os cookies do produto e não admite exceção: o prefixo __Host- só é aceito pelo navegador com Secure, Path=/ e sem Domain; um Set-Cookie com prefixo __Host- e qualquer outro Path é descartado silenciosamente. Por isso __Host-session, __Host-refresh e __Host-csrf são todos emitidos com Path=/. Sobre essa base, a estratégia é defesa em três camadas, todas obrigatórias:

  1. SameSite=Lax já bloqueia o envio do cookie em requisições POST, PATCH, PUT e DELETE originadas de outro site. Isso cobre a maior parte dos ataques clássicos.
  2. Verificação de origem em todo método que muda estado. O middleware compara o header Origin com a lista de origens próprias. Se Origin estiver ausente, usa Sec-Fetch-Site; se este também faltar, a requisição é recusada com 403 CSRF_ORIGIN_MISMATCH. Requisições GET e HEAD são isentas porque não mudam estado — e nenhuma rota GET do produto muda estado, o que é verificado em teste.
  3. Token de dupla submissão para rotas autenticadas por cookie. No login, o servidor emite __Host-csrf (Secure, SameSite=Lax, Path=/, sem HttpOnly, 32 bytes aleatórios em base64url). O cliente lê o valor e o envia no header X-CSRF-Token. O servidor compara em tempo constante. Divergência gera 403 CSRF_TOKEN_INVALID. __Host-csrf é o único nome deste cookie em todo o documento; nenhum outro nome, em nenhuma seção, designa o token de dupla submissão. Ele é intencionalmente legível por script e não é credencial: sozinho, não autentica nada.
// apps/web/src/lib/security/csrf.ts
const ALLOWED_ORIGINS = [
  'https://palavradiaria.com.br',
  'https://www.palavradiaria.com.br',
  'https://app.palavradiaria.com.br',
]
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

export function assertCsrf(req: Request): void {
  if (SAFE_METHODS.has(req.method)) return

  // O cookie de CSRF tem um único nome em todo o documento: `__Host-csrf`.
  const cookieToken = readCookie(req, '__Host-csrf')

  const origin = req.headers.get('origin')
  const fetchSite = req.headers.get('sec-fetch-site')
  const originOk = origin
    ? ALLOWED_ORIGINS.includes(origin)
    : fetchSite === 'same-origin' || fetchSite === 'none'
  if (!originOk) throw new AppError('CSRF_ORIGIN_MISMATCH', 403)

  const header = req.headers.get('x-csrf-token')
  if (!cookieToken || !header || !timingSafeEqualStr(cookieToken, header)) {
    throw new AppError('CSRF_TOKEN_INVALID', 403)
  }
}

Isenções explícitas e justificadas: POST /api/webhooks/asaas e POST /api/webhooks/whatsapp não usam cookie de sessão e são autenticados por segredo compartilhado e HMAC (22.6.5); aplicar CSRF a eles seria incoerente. Toda rota isenta é declarada em uma lista literal no middleware, e um teste garante que nenhuma outra rota autenticada por cookie escape da verificação.

22.2.6 SSRF #

Regra estrutural: nenhuma URL fornecida por usuário, assinante, administrador ou webhook é usada como destino de requisição de saída. Não existe funcionalidade de "informe a URL da sua imagem" em nenhum ponto do produto — a ausência dessa funcionalidade é uma decisão de segurança, não um esquecimento.

Todas as chamadas externas passam por safeFetch, que aplica lista fechada de destinos:

// packages/integrations/src/http/safe-fetch.ts
const EGRESS_ALLOWED_HOSTS = new Set([
  'graph.facebook.com',
  'api.asaas.com',
  'api-sandbox.asaas.com',
  'api.elevenlabs.io',
  'texttospeech.googleapis.com',
  'api.resend.com',
  'challenges.cloudflare.com',             // verificação anti-bot (22.6.4)
  new URL(env.S3_ENDPOINT).hostname,       // storage de mídia
])

export async function safeFetch(url: string, init: RequestInit = {}): Promise<Response> {
  const u = new URL(url)
  if (u.protocol !== 'https:') throw new AppError('EGRESS_PROTOCOL_BLOCKED', 500)
  if (!EGRESS_ALLOWED_HOSTS.has(u.hostname)) throw new AppError('EGRESS_HOST_BLOCKED', 500)

  const addresses = await dns.lookup(u.hostname, { all: true })
  const pinned = addresses.find((a) => !isPrivateAddress(a.address))
  if (!pinned || addresses.some((a) => isPrivateAddress(a.address))) {
    throw new AppError('EGRESS_PRIVATE_ADDRESS', 500)
  }
  return fetch(u, {
    ...init,
    redirect: 'manual',
    signal: AbortSignal.timeout(15_000),
    // @ts-expect-error agente com lookup fixado no endereço já validado
    dispatcher: agentPinnedTo(pinned.address, u.hostname),
  })
}

A fixação do endereço é o que efetivamente impede a religação de DNS. Resolver o nome, conferir o resultado e depois entregar o nome ao fetch deixa o cliente resolver de novo: entre as duas resoluções o registro pode mudar, e a verificação vira decoração. Por isso o endereço validado é fixado no agente da requisição. Com a lista fechada de destinos o risco prático é baixo, mas a proteção precisa corresponder ao que o código afirma fazer.

EGRESS_ALLOWED_HOSTS é a lista completa de destinos de saída do produto. Um teste percorre as Seções 12, 16, 17, 22 e 25 procurando nomes de host citados como destino de chamada externa e falha se algum não constar da lista — foi assim que a ausência do verificador anti-bot passaria despercebida até o dia do lançamento. O nome da constante é deliberadamente distinto de ALLOWED_ORIGINS (Seção 26.3), que é a lista de origens de entrada aceitas em CORS: são coisas diferentes e nomes quase iguais produzem o erro de configurar uma pensando na outra.

redirect: 'manual' é deliberado: um redirecionamento aceito automaticamente contorna a lista de destinos. Quando um provedor legítimo responde 3xx — o caso real é o download de mídia da Meta, que devolve uma URL de CDN —, o código trata o redirecionamento explicitamente e revalida o novo destino contra um sufixo de domínio permitido (*.fbcdn.net, *.cdninstagram.com) antes de seguir. isPrivateAddress rejeita 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1 e fc00::/7.

O contêiner do worker roda com egressos restritos no nível do Docker: apenas 443/TCP para fora, sem acesso à rede de gerenciamento do host, o que fecha o caminho para metadados de nuvem.

22.2.7 Upload de arquivo #

Upload existe em um único lugar: painel administrativo, para arte de capa do devocional e para substituição manual de áudio. Assinantes não fazem upload em nenhuma circunstância.

Regra Valor
Papel exigido EDITOR, ADMIN ou OWNER
Tipos aceitos, imagem image/jpeg, image/png, image/webp
Tipos aceitos, áudio audio/mpeg, audio/ogg
Tamanho máximo, imagem 5 MB
Tamanho máximo, áudio 16 MB
Dimensão máxima, imagem 4000 × 4000 px
Duração máxima, áudio 480 s (8 minutos)
Verificação de conteúdo Assinatura binária real com file-type, ignorando Content-Type e extensão declarados
Reprocessamento Imagem re-codificada com sharp para WebP, removendo EXIF e qualquer payload embutido; áudio validado e re-encodado com ffprobe/ffmpeg
Nome no storage media/{YYYY}/{MM}/{ulid}.{ext} — o nome original é descartado, nunca reutilizado
Bucket Privado. Nenhum objeto é público. Entrega por URL assinada com TTL de 15 minutos.
SVG Rejeitado sempre. SVG é um documento executável.
Antivírus Não há varredura antivírus. Justificativa: o conteúdo é sempre re-codificado e nunca executado nem servido do mesmo domínio da aplicação.

Os dois limites de áudio — 16 MB e 480 segundos — são os da plataforma de mensageria, definidos na seção dona do pipeline de áudio (Seção 16.8), e não valores próprios desta subseção. Áudio substituído manualmente segue exatamente as mesmas regras do áudio gerado: um arquivo que a plataforma recusaria não pode entrar pelo painel. Aceitar 20 MB aqui produziria um arquivo impossível de entregar, descoberto só no envio das 06:00.

Erros: 413 FILE_TOO_LARGE, 415 UNSUPPORTED_MEDIA_TYPE (tipo declarado fora da lista), 422 FILE_CONTENT_MISMATCH (assinatura binária não corresponde ao declarado), 422 FILE_DIMENSIONS_EXCEEDED, 422 AUDIO_TOO_LONG, 500 STORAGE_UNAVAILABLE. O upload é idempotente por Idempotency-Key opcional; sem a chave, cada envio cria um objeto novo, e o objeto anterior é removido do storage quando o devocional passa a apontar para o novo.

22.3 Cabeçalhos HTTP de segurança #

Aplicados pelo middleware do Next.js em toda resposta, e reforçados no Caddy para o caso de a aplicação estar fora do ar e o proxy servir página de erro.

Cabeçalho Valor exato
Strict-Transport-Security max-age=63072000; includeSubDomains; preload
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-site
X-DNS-Prefetch-Control off
Cache-Control (rotas /api/* e páginas autenticadas) no-store, no-cache, must-revalidate, private
Content-Security-Policy ver abaixo

Cross-Origin-Embedder-Policy não é enviado. Justificativa registrada: require-corp quebraria o carregamento do áudio a partir do subdomínio de mídia sem trazer benefício, porque o produto não usa SharedArrayBuffer nem medição de alta resolução.

22.3.1 Content-Security-Policy #

Origens realmente usadas: a própria aplicação, o subdomínio de mídia (https://media.palavradiaria.com.br, que serve áudio e imagem por URL assinada) e o subdomínio de analytics (https://analytics.palavradiaria.com.br). Não há CDN de terceiros, não há fonte externa — as fontes são auto-hospedadas justamente para manter a política fechada.

Política de produção, com nonce por requisição:

Content-Security-Policy:
  default-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
  object-src 'none';
  script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';
  style-src 'self' 'nonce-{NONCE}';
  img-src 'self' data: blob: https://media.palavradiaria.com.br;
  media-src 'self' blob: https://media.palavradiaria.com.br;
  font-src 'self';
  connect-src 'self' https://analytics.palavradiaria.com.br https://media.palavradiaria.com.br;
  manifest-src 'self';
  worker-src 'self' blob:;
  upgrade-insecure-requests;
  report-uri /api/csp-report;
  report-to csp-endpoint

Notas de implementação, todas necessárias para que a política funcione de fato:

  • O nonce é gerado por requisição no middleware (crypto.randomUUID() codificado em base64url), injetado no header e propagado ao Next.js. Páginas com nonce não podem ser cacheadas estaticamente; as páginas públicas de marketing, que não têm script inline, usam uma variante da política sem nonce e com script-src 'self', o que preserva o cache estático e o alvo de LCP da Seção 20.
  • 'strict-dynamic' é necessário porque o Next.js carrega os chunks do bundle a partir do script inicial. Com ele, os navegadores modernos ignoram a lista de hosts em script-src e confiam na cadeia de carregamento originada do script com nonce. Por isso nenhum host é listado ali, e o script de analytics é servido do próprio domínio e carregado por uma tag que recebe o nonce da requisição. Listar um host ao lado de 'strict-dynamic' é inócuo e induz ao erro de acreditar que ele está liberado.
  • connect-src inclui o domínio de mídia porque o player faz fetch da URL assinada antes de criar o blob. media-src governa o elemento <audio>, não a requisição — sem o domínio em connect-src, o áudio do plano pago não toca em navegador nenhum.
  • style-src usa nonce e não 'unsafe-inline'. O Tailwind (linha de versão na Seção 4) gera folha de estilo estática, e o <style> crítico emitido pelo Next.js recebe o nonce. Onde um componente de terceiro exigir estilo inline por atributo, o componente é substituído — não se relaxa a política.
  • img-src inclui data: por causa de imagens SVG inline geradas em build e blob: por causa de pré-visualização de upload no painel administrativo.
  • media-src inclui blob: porque o player web pode receber o áudio como blob após download autenticado.
  • frame-ancestors 'none' é o controle real de clickjacking; X-Frame-Options: DENY fica como redundância para clientes antigos.
  • O checkout não usa iframe de terceiro. A tokenização do cartão ocorre pelo cliente tipado da Asaas em chamada server-side (Seção 12), o que evita ter de abrir frame-src.
  • report-uri aponta para uma rota interna que grava as violações como log estruturado em nível warn (Seção 23.1) e incrementa csp_violation_total (Seção 23.4), com rate limit de 100 relatórios por minuto por IP para não virar vetor de inundação de log.

Terceira variante — páginas com formulário (/cadastro, /contato e a tela de pedido de código). Idêntica à política com nonce, com três acréscimos obrigatórios, e usada exclusivamente nessas rotas:

  script-src 'self' 'nonce-{NONCE}' 'strict-dynamic' https://challenges.cloudflare.com;
  frame-src 'self' https://challenges.cloudflare.com;
  connect-src 'self' https://analytics.palavradiaria.com.br
                     https://media.palavradiaria.com.br
                     https://challenges.cloudflare.com;

Três observações que fazem essa variante funcionar de fato:

  • Sem a diretiva frame-src, default-src 'none' bloqueia o widget do verificador anti-bot, que é renderizado dentro de um frame. A diretiva precisa ser declarada, não herdada.
  • Como 'strict-dynamic' faz os navegadores modernos ignorarem a lista de hosts de script-src, o script do widget é carregado por uma tag que recebe o nonce da requisição, e nunca por injeção dinâmica. O host aparece em script-src apenas para navegadores que não entendem 'strict-dynamic'.
  • connect-src recebe o mesmo host porque o widget faz chamadas próprias durante o desafio.

Sem essa variante, o cenário é o pior possível: o widget não renderiza, turnstileToken fica vazio, o cadastro devolve 422 VALIDATION_ERROR e a taxa de conversão do lançamento é zero, com a causa visível apenas no console do navegador. Por isso um teste de ponta a ponta abre cada rota com formulário, coleta as violações de CSP do console e falha se houver qualquer uma — e o item correspondente do checklist de 22.13 cita nominalmente as páginas com formulário, não apenas as "páginas principais".

Em staging, a política é idêntica, mas enviada também como Content-Security-Policy-Report-Only com as diretivas candidatas ao próximo endurecimento, o que permite testar mudanças sem quebrar o ambiente.

22.4 Segredos #

22.4.1 Onde vivem e como são injetados #

Camada Regra
Repositório Nenhum segredo, nunca. .env.example traz apenas nomes e placeholders. .gitignore cobre .env* exceto .env.example.
Servidor Arquivos em /etc/palavra-diaria/secrets/, um arquivo por segredo, modo 0400, dono root.
Contêineres Injetados como Docker secrets, montados em /run/secrets/<nome>. A aplicação lê VAR_FILE quando presente e cai para VAR apenas em desenvolvimento local.
CI GitHub Actions Secrets, escopados por ambiente, sem acesso a partir de pull request de fork.
Desenvolvimento local .env.local com credenciais de sandbox exclusivamente. Nenhum segredo de produção em máquina de desenvolvedor.

A leitura de configuração acontece uma única vez, na inicialização, por um schema Zod que valida presença e formato de todos os segredos. Se faltar um segredo obrigatório, o processo não sobe — falha alta e imediata, em vez de erro obscuro na primeira chamada. O registro completo das variáveis está na Seção 26.

Proteções contra vazamento acidental: os valores nunca são logados (a configuração de redação está em 23.1); um toJSON() sobrescrito no objeto de configuração devolve [REDACTED] para chaves sensíveis; o rastreamento de pilha em produção não inclui variáveis de ambiente; e um hook de pré-commit roda gitleaks para barrar segredo colado por engano.

22.4.2 Inventário e rotação #

A cadência de rotação de cada segredo é a da Seção 26.7.3, que é a dona única do registro de configuração e, portanto, do prazo. Esta subseção não repete prazos: ela declara para que serve cada segredo e como rotacioná-lo sem derrubar o produto. As três âncoras de cadência fixadas em 26.7.3 são 90 dias para a chave de assinatura de sessão, 180 dias para as senhas de banco e de Redis, e 365 dias para as chaves de cifra.

Segredo Uso Procedimento de rotação
DATABASE_URL (senha) Acesso ao Postgres Criar segunda senha com ALTER ROLE, atualizar segredo, reiniciar web e worker em sequência, confirmar conexões, revogar a antiga
REDIS_PASSWORD Fila e cache Redis aceita requirepass novo com reinício; janela de manutenção de 2 minutos fora do horário de envio
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY (EdDSA) Assinatura de sessão Publicar novo par com kid novo, manter o anterior na lista de verificação por 30 dias, remover depois. Sessões vigentes continuam válidas.
ENCRYPTION_KEY + ENCRYPTION_KEY_PREVIOUS Criptografia de coluna A chave corrente vira a anterior, uma chave nova entra como corrente, o job de re-criptografia converte o histórico, a anterior é removida ao fim (22.5.5 e Seção 26.7.4)
PHONE_INDEX_KEY + PHONE_INDEX_KEY_PREVIOUS HMAC do índice cego Rotação na mesma janela de ENCRYPTION_KEY, com chave anterior aceita em leitura e backfill de reescrita (22.5.5)
WHATSAPP_SYSTEM_USER_TOKEN Envio pela Cloud API Gerar novo token no painel da Meta, atualizar segredo, validar com envio de teste ao número interno, revogar o antigo
META_APP_SECRET HMAC do webhook da Meta Rotacionar no painel da Meta; aceitar as duas assinaturas por 24 h com META_APP_SECRET_PREVIOUS
WHATSAPP_VERIFY_TOKEN Verificação inicial do webhook Atualizar segredo e reconfigurar a assinatura na Meta
ASAAS_API_KEY Cobrança Gerar nova chave no painel da Asaas, atualizar, validar com GET /v3/finance/balance, revogar a antiga
ASAAS_WEBHOOK_TOKEN Autenticação do webhook Aceitar token novo e anterior por 24 h via ASAAS_WEBHOOK_TOKEN_PREVIOUS, atualizar no painel da Asaas, remover o anterior
ELEVENLABS_API_KEY TTS primário Rotação simples; falha só afeta geração de áudio, com fallback ativo
GOOGLE_TTS_CREDENTIALS_JSON TTS de fallback Nova conta de serviço, chave antiga desativada após validação
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY Storage de mídia Criar par novo, atualizar, validar leitura e escrita, apagar o antigo
RESEND_API_KEY E-mail transacional Rotação simples
METRICS_TOKEN Proteção de /api/internal/metrics, /api/internal/ready e /api/internal/version Atualizar segredo e a configuração do coletor de métricas e do monitor externo na mesma janela
BACKUP_ENCRYPTION_KEY Cifra dos backups Nunca descartar chave antiga enquanto existir backup cifrado com ela; guardar em cofre offline
TURNSTILE_SECRET_KEY Anti-bot Rotação no painel do provedor

Rotação fora de cadência é sempre permitida e sempre obrigatória sob suspeita de vazamento: o prazo de 26.7.3 é o teto, nunca o piso.

Os nomes acima são os nomes canônicos definidos pela Seção 26, que é a dona do registro de variáveis de ambiente. Nenhum apelido é aceito em código, em arquivo de composição ou em script de inicialização; a regra e a lista de apelidos proibidos estão em 26.1.1, e um teste de pipeline reprova qualquer nome fora do registro de 26.3.

Toda rotação é registrada em admin_audit_log com action = 'SECRET_ROTATED', o nome do segredo (nunca o valor), o operador e a data. Um job mensal verifica a data da última rotação de cada segredo em settings, sob a chave ops.secret_rotated_at (Seção 26.8.1), e dispara o alerta secret_rotation_due (Seção 23.8) quando o prazo vence.

22.4.3 Resposta a vazamento de segredo #

Prazos contados a partir da suspeita, não da confirmação.

Momento Ação
0–15 min Revogar o segredo no provedor de origem. Revogação vem antes de investigação: um segredo revogado não faz mais dano.
15–30 min Emitir substituto, atualizar o arquivo de segredo, reiniciar os serviços afetados, confirmar operação normal.
30–60 min Levantar o escopo: quando o segredo foi criado, onde esteve, quem teve acesso, quais requisições usaram ele. Consultar admin_audit_log e os logs de acesso.
1–4 h Avaliar impacto sobre dados pessoais. Se houve ou pode ter havido acesso a dados de titulares, o procedimento de incidente da Seção 22.7.8 é acionado em paralelo.
4–24 h Para SESSION_JWT_PRIVATE_KEY: invalidar todas as sessões (DELETE FROM sessions) e forçar novo login. Para ENCRYPTION_KEY ou PHONE_INDEX_KEY: iniciar re-criptografia e reindexação imediatas (22.5.5). Para WHATSAPP_SYSTEM_USER_TOKEN: auditar mensagens enviadas no período e verificar quality_rating.
24–72 h Relatório pós-incidente conforme Seção 23.10, incluindo a causa raiz da exposição e a correção que impede a repetição.

Casos especiais: se o segredo vazou em um commit, revogar e reescrever o histórico não é suficiente — assume-se comprometido de forma permanente, porque cópias e espelhos podem existir. Se vazou em log, o expurgo do log é feito, mas a revogação continua obrigatória.

22.5 Criptografia #

22.5.1 Em trânsito #

TLS terminado no Caddy (linha de versão na Seção 4), com certificados Let's Encrypt renovados automaticamente. Versão mínima TLS 1.2, com TLS 1.3 preferido e negociado por padrão; TLS 1.0 e 1.1 desabilitados. Conjuntos de cifras limitados aos AEAD com sigilo encaminhado (TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 e os equivalentes ECDHE em 1.2). HSTS com pré-carregamento (22.3). O tráfego entre contêineres corre em rede Docker interna, não exposta ao host; a conexão com o Postgres usa sslmode=require mesmo dentro da rede interna, para que um erro futuro de exposição de porta não resulte em tráfego em texto claro.

Manter TLS 1.2 é uma decisão consciente: parte dos aparelhos Android antigos usados pelo público do produto não negocia 1.3, e recusá-los excluiria assinantes reais sem ganho proporcional de segurança.

22.5.2 Em repouso #

Camada Controle
Volume do banco Disco do VPS cifrado com LUKS (AES-256-XTS). A chave é fornecida na inicialização e não fica no próprio disco.
PostgreSQL Sem cifra nativa por tablespace; a proteção é o disco cifrado mais a criptografia de coluna (22.5.3).
Storage de mídia Criptografia do lado do servidor habilitada no bucket. Bucket privado, sem acesso anônimo, sem listagem.
Backups pg_dump comprimido e cifrado com age usando BACKUP_ENCRYPTION_KEY antes de sair do host. Backup nunca trafega nem repousa em texto claro.
Logs Não recebem dado pessoal por construção (23.1). O volume de log é cifrado pelo mesmo LUKS.

22.5.3 Criptografia adicional em nível de aplicação #

Decisão: telefone e CPF são cifrados na aplicação, antes de chegar ao banco. Justificativa: são os dois identificadores que, combinados à simples existência da linha, revelam convicção religiosa — o dado sensível do produto. Disco cifrado protege contra roubo físico da máquina; não protege contra um dump obtido por credencial de banco vazada, que é o cenário R1 do modelo de ameaças. A criptografia de coluna fecha exatamente essa lacuna.

Esta subseção não declara coluna nenhuma. As colunas cifradas e as colunas de índice cego correspondentes — subscribers.phone_e164 e subscribers.phone_hmac, subscribers.wa_id e subscribers.wa_id_hmac, subscribers.email e subscribers.email_hmac, subscriber_profiles.cpf e subscriber_profiles.cpf_hmac — são declaradas na Seção 6 (6.3 e 6.4), existem desde a primeira migration e constam do schema.prisma e do DDL daquela seção. Criptografia de campo e índice cego são decisões de schema, não de endurecimento posterior: adiá-las obrigaria a reescrever toda consulta por telefone escrita nos marcos intermediários. Quando houver dúvida sobre tipo, nulidade ou índice de qualquer uma dessas colunas, a Seção 6 é a fonte, e esta seção não a contradiz.

O que é desta seção: o algoritmo do envelope, a gestão e a rotação das chaves, a função de índice cego (22.5.4) e o procedimento de rotação com dados já gravados (22.5.5).

Não são cifrados em nível de aplicação: subscribers.display_name (necessário em ordenação e busca por prefixo no painel, e isoladamente de baixo risco), id, timestamps e demais colunas operacionais.

Formato do valor armazenado e onde o formato é validado. Uma coluna cifrada guarda o envelope v<versão>:<iv>:<ciphertext>:<tag>, e nada além disso. Consequência direta e obrigatória: nenhum CHECK de banco pode validar o conteúdo de uma coluna cifrada — o banco não vê o telefone, vê o envelope, e um CHECK de formato E.164 sobre essa coluna faria todo INSERT de assinante falhar. A validação do formato E.164 é exclusivamente da camada de aplicação, pelo schema Zod da borda (22.2.1), antes de cifrar. O único CHECK admissível sobre essas colunas é o de forma do próprio envelope, e ele é declarado na Seção 6.

Algoritmo e formato do envelope:

// packages/core/src/crypto/field-encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'

/**
 * Duas chaves nomeadas, nunca uma família de variáveis versionadas:
 *   ENCRYPTION_KEY           — corrente, usada em toda escrita nova;
 *   ENCRYPTION_KEY_PREVIOUS  — opcional, aceita apenas em leitura durante a rotação.
 * Os dois nomes são os canônicos do registro da Seção 26.3 e do procedimento de 26.7.4.
 * O prefixo `v<n>` do envelope permanece apenas como marcador de FORMATO — ele diz qual
 * é o layout do envelope, não qual chave o produziu.
 */
const ENVELOPE_FORMAT = 1

/** Formato: v<formato>:<iv b64url>:<ciphertext b64url>:<tag b64url> */
export function encryptField(plain: string, aad: string): string {
  const key = currentKey()                            // ENCRYPTION_KEY, 32 bytes
  const iv = randomBytes(12)                          // 96 bits, único por operação
  const cipher = createCipheriv('aes-256-gcm', key, iv)
  cipher.setAAD(Buffer.from(aad, 'utf8'))
  const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
  return `v${ENVELOPE_FORMAT}:${b64u(iv)}:${b64u(ct)}:${b64u(cipher.getAuthTag())}`
}

export function decryptField(envelope: string, aad: string): string {
  const [v, iv, ct, tag] = envelope.split(':')
  if (v !== `v${ENVELOPE_FORMAT}`) throw new AppError('INTERNAL_ERROR', 500)
  // Corrente primeiro; a anterior só existe durante a janela de rotação, e fora
  // dela decryptionKeys() devolve um único elemento.
  for (const key of decryptionKeys()) {
    try {
      const decipher = createDecipheriv('aes-256-gcm', key, b64uDec(iv))
      decipher.setAAD(Buffer.from(aad, 'utf8'))
      decipher.setAuthTag(b64uDec(tag))
      return Buffer.concat([decipher.update(b64uDec(ct)), decipher.final()]).toString('utf8')
    } catch {
      // Tag inválida com esta chave: tenta a próxima. Nunca vaza qual falhou.
    }
  }
  throw new AppError('INTERNAL_ERROR', 500)
}

/** AAD amarra o texto cifrado à linha e à coluna de origem. */
export const aadFor = (table: string, column: string, id: string) => `${table}:${column}:${id}`

Decisões embutidas no código acima:

  • AES-256-GCM, que é cifra autenticada: adulteração do texto cifrado falha na verificação da tag em vez de produzir texto plausível.
  • IV aleatório de 96 bits por operação, nunca reutilizado. Reuso de IV em GCM destrói a segurança do modo; por isso o IV vem de randomBytes a cada escrita, e nunca é derivado do identificador da linha.
  • AAD amarrando tabela, coluna e id. Sem AAD, um atacante com acesso de escrita ao banco poderia copiar o telefone cifrado do assinante A para a linha do assinante B e o sistema decifraria sem reclamar. Com AAD, a operação falha. Isso exige que o id seja conhecido antes da cifra — o que é verdade, porque o ULID é gerado na aplicação (convenção da Seção 6).
  • Prefixo de formato, que permite trocar o layout do envelope no futuro sem ambiguidade na leitura. Ele não identifica a chave: a rotação de chave é resolvida pela leitura com duas chaves nomeadas (22.5.5 e Seção 26.7.4), não por uma família ENCRYPTION_KEY_V<n>, que produziria duas convenções de rotação incompatíveis no mesmo produto.
  • As chaves ficam em ENCRYPTION_KEY e ENCRYPTION_KEY_PREVIOUS (32 bytes em base64), carregadas na inicialização, mantidas em memória e nunca gravadas no banco. Guardar a chave ao lado do dado cifrado anularia o controle inteiro.

22.5.4 Impacto em índices e busca, e o índice cego #

O problema: phone_e164 cifrado com IV aleatório produz um texto cifrado diferente a cada escrita. Consequências diretas — o índice único do telefone deixa de funcionar, e a busca WHERE phone_e164 = $1 deixa de existir. Como a identidade do assinante é o telefone (regra da Seção 8.1, normalizado pela regra da Seção 11.4) e a busca por telefone acontece em todo webhook de entrada, isso precisa ser resolvido por completo, e não contornado.

Solução adotada: índice cego determinístico com HMAC-SHA-256, em coluna separada.

As colunas de índice cego são declaradas na Seção 6, em 6.3 e 6.4, junto das colunas cifradas que acompanham: phone_hmac, wa_id_hmac, email_hmac e cpf_hmac. Esta subseção define como o valor é calculado e como ele é usado; a Seção 6 define tipo, nulidade e índice de cada coluna. A restrição de unicidade do telefone, exigida pela regra de identidade da Seção 8.1, passa a ser materializada em phone_hmac: funcionalmente é equivalente, porque HMAC é determinístico e injetivo na prática, logo dois telefones iguais colidem e dois diferentes não. O mesmo vale para wa_id_hmac e email_hmac, que recebem índice único parcial.

cpf_hmac é a exceção e não é único. É um índice comum, e a decisão é deliberada. Duas razões concretas, ambas de negócio: um titular pseudonimizado mantém cpf_hmac por até 5 anos por obrigação fiscal (22.9), e uma restrição única o impediria de voltar a assinar meses depois, com um erro de banco que o suporte não conseguiria interpretar; e é corriqueiro no Brasil que uma pessoa contrate para um familiar usando o próprio CPF. O papel de cpf_hmac é detectar, não impedir: o mesmo CPF em mais de duas contas ativas soma 25 pontos na pontuação de risco de 22.6.3 e aparece como sinal na tela de assinantes, que é o comportamento desejado. Ele serve ainda para consultar a lista de bloqueio por fraude.

// packages/core/src/crypto/blind-index.ts
import { createHmac } from 'node:crypto'

const NS = { phone: 'phone_e164', waId: 'wa_id', cpf: 'cpf', email: 'email' } as const

/** Determinístico e com namespace, para que o mesmo valor em colunas diferentes não colida. */
export function blindIndex(value: string, ns: keyof typeof NS, key: Buffer): string {
  return createHmac('sha256', key).update(`${NS[ns]}|${value}`).digest('hex')
}

export const phoneIndex = (e164: string) =>
  blindIndex(e164, 'phone', currentIndexKey())

Por que HMAC e não hash simples: o espaço de telefones brasileiros tem cerca de um bilhão de combinações plausíveis. Um SHA-256 sem chave seria quebrado por força bruta em minutos, e o índice não protegeria nada. O HMAC exige a chave secreta PHONE_INDEX_KEY, que não fica no banco — fica no arquivo de segredos do host (22.4.1) e está declarada no registro de variáveis da Seção 26.3.9, junto de PHONE_INDEX_KEY_PREVIOUS. Um dump do banco, isolado, não permite nem decifrar (falta ENCRYPTION_KEY) nem enumerar (falta PHONE_INDEX_KEY).

Por que uma chave separada da chave de cifra: comprometer a chave de índice permite enumerar, mas não decifrar; comprometer a chave de cifra permite decifrar as linhas obtidas, mas não localizar uma pessoa específica sem varredura completa. Chaves separadas mantêm os dois riscos separados.

O que o índice cego vaza, declarado de forma honesta: igualdade. É possível saber que duas linhas têm o mesmo telefone, e é possível, com a chave, confirmar se um telefone específico está na base. Isso é aceito porque é exatamente a capacidade de que o produto precisa; nenhuma funcionalidade requer comparação de ordem ou busca por prefixo sobre telefone.

Busca por telefone com as variantes do nono dígito, integrando a regra de normalização da Seção 11:

// packages/core/src/subscribers/lookup.ts
export async function findSubscriberByInbound(waId: string, from: string) {
  const candidates = [
    { col: 'wa_id_hmac', hash: blindIndex(waId, 'waId', currentIndexKey()) },
    ...phoneVariants(from).map((p) => ({ col: 'phone_hmac', hash: phoneIndex(p) })),
  ]
  // phoneVariants devolve, em ordem: E.164 exato, variante sem o 9º dígito, variante com o 9º.
  for (const c of candidates) {
    const row = await db.subscriber.findFirst({ where: { [c.col]: c.hash, deletedAt: null } })
    if (row) return row
  }
  return null
}

A ordem de tentativa é a mesma da regra de normalização da Seção 11 — wa_id, depois telefone exato, depois as variantes —, e cada tentativa é uma consulta indexada de custo constante. Em vez de uma consulta, são no máximo quatro; com índice único, isso é irrelevante em desempenho.

Busca no painel administrativo: o administrador digita o telefone completo, o servidor normaliza, calcula o HMAC e busca por igualdade. Busca parcial por telefone não é suportada, e essa é uma decisão explícita, não uma limitação a contornar depois. Busca parcial exigiria índice por prefixo sobre valor determinístico curto, o que reintroduziria a enumerabilidade que o HMAC elimina. A busca operacional cotidiana é feita por nome, por identificador do assinante ou pelo número exato — que é o que o administrador tem em mãos quando um assinante escreve pedindo suporte. A tela de busca explica isso ao administrador em texto de ajuda.

Exibição: o telefone é decifrado apenas quando a tela precisa dele e é mascarado por padrão como +55 11 *****-4321. Revelar o número completo exige clique em Mostrar, que registra admin_audit_log com action = 'PII_REVEALED', o alvo e o motivo selecionado. O CPF nunca é exibido inteiro: apenas ***.***.789-01, e só nas telas de cobrança.

22.5.5 Rotação de chaves com dados já gravados #

Chave de cifra (ENCRYPTION_KEY e ENCRYPTION_KEY_PREVIOUS). A rotação usa duas chaves nomeadas e uma janela de leitura dupla, exatamente como descrito no procedimento operacional da Seção 26.7.4: a chave corrente passa a ser ENCRYPTION_KEY_PREVIOUS, uma chave nova entra como ENCRYPTION_KEY, e a partir do reinício toda escrita usa a nova enquanto a leitura tenta a nova e cai para a anterior (código de decryptField em 22.5.3). O job security.reencrypt_fields percorre as tabelas em lotes de 500 linhas, decifra e recifra com a corrente, dentro de uma transação por lote. O job é retomável — guarda o último id processado em settings sob ops.reencrypt_cursor — e roda fora da janela de envio.

A chave anterior só é removida da configuração quando o comando pnpm ops crypto:verify reportar zero linhas que só decifram com ela. Como o prefixo do envelope marca formato e não chave, a verificação não pode ser feita por LIKE sobre o valor: ela tenta decifrar uma amostra completa com a chave corrente e conta as falhas. Remover a chave anterior antes disso torna os dados correspondentes irrecuperáveis, e por isso a checagem de boot descrita em 26.7.4 recusa iniciar nessa combinação.

Chave de índice (PHONE_INDEX_KEY). Aqui não há prefixo possível, porque o valor precisa ser comparável por igualdade direta no índice. O procedimento tem três fases:

  1. Fase de leitura dupla. PHONE_INDEX_KEY recebe a chave nova e PHONE_INDEX_KEY_PREVIOUS a antiga. Toda busca calcula os dois HMACs e consulta com IN (novo, antigo). Toda escrita usa apenas a chave nova.
  2. Backfill. O job security.reindex_blind percorre as linhas em lotes de 500, recalcula phone_hmac, wa_id_hmac, cpf_hmac e email_hmac com a chave nova e grava. Como a decifra é necessária para recalcular, o job depende da chave de cifra vigente. Com 50.000 assinantes e lotes de 500, são 100 lotes — poucos minutos.
  3. Encerramento. Verificado que nenhuma linha usa a chave antiga (o job registra o total convertido e compara com a contagem da tabela), PHONE_INDEX_KEY_PREVIOUS é removida e a busca volta a um único HMAC.

Durante a fase 1, uma colisão de unicidade é impossível, porque o índice único continua sobre a mesma coluna e cada linha tem um único valor por vez. Se o backfill for interrompido, o sistema permanece funcional na fase 1 indefinidamente; a única perda é o custo de uma comparação a mais por busca.

22.5.6 Dados de cartão e escopo PCI-DSS #

Nenhum dado de cartão é persistido. Não existe coluna de número, CVV ou validade em tabela alguma, e a proibição é verificada por teste que inspeciona o schema. O que o sistema guarda é o token devolvido pelo processador de pagamento e os metadados não sensíveis necessários para a interface e para a régua de cobrança: bandeira, últimos quatro dígitos e mês e ano de validade. Esses metadados não permitem transação e não são dados de conta de pagamento no sentido do padrão.

Escopo declarado: SAQ D-Merchant. O checkout não usa iframe nem redirecionamento hospedado pelo processador — os dados do cartão são recebidos pelo nosso servidor e encaminhados para tokenização em chamada server-side (desenho registrado na Seção 12). Isso coloca a aplicação no caminho dos dados de titular de cartão e afasta os questionários simplificados. Registrar o escopo correto é decisão consciente: alegar SAQ A-EP com este desenho seria falso e inutilizaria a atestação em caso de incidente.

Controles obrigatórios decorrentes desse escopo, todos já cobertos nesta seção e aqui consolidados: TLS em toda a cadeia (22.5.1); dados de cartão apenas em memória, nunca em disco, nunca em log, nunca em rastro (23.1.4 e 23.5); campos de cartão na lista de redação do logger; proibição de gravar o payload de tokenização em qualquer tabela; acesso ao código do checkout restrito e revisado; registro de auditoria de toda ação de cobrança (23.11.1); segregação de papéis de banco (22.12.2); e varredura de vulnerabilidade e revisão de dependências (22.12.4).

Se, no futuro, o checkout migrar para captura hospedada pelo processador, o escopo cai para SAQ A quando a captura for por redirecionamento completo para página hospedada — que é a rota decidida, registrada na Seção 12.9.3 —, ou para SAQ A-EP quando for por componente embutido que a nossa página carrega. A diferença não é semântica: no redirecionamento a nossa página sai inteiramente do fluxo dos dados de cartão, que é exatamente o caso do SAQ A. A migração é desejável e fica registrada como opção, não como pendência: o desenho atual é válido e conforme.

Enquanto o escopo for SAQ D-Merchant, as obrigações abaixo — delegadas a esta seção pela Seção 12.9.3 — são exigíveis e têm responsável nomeado:

  • Varredura trimestral por fornecedor autorizado (ASV) do domínio de checkout, com relatório arquivado.
  • Revisão anual do questionário SAQ D, assinada pelo OWNER.
  • Política de segurança da informação escrita, revisada anualmente e comunicada à equipe.
  • Correção de vulnerabilidades de severidade critical e high em até 30 dias a partir da divulgação, medida pela varredura de 22.12.4.
  • Revisão trimestral de acessos, na forma de 22.12.3.

22.6 Proteção contra abuso #

22.6.1 Rate limit #

Implementação com Redis (linha de versão na Seção 4), algoritmo de janela deslizante, chave composta por dimensão. Toda resposta bloqueada é 429 RATE_LIMITED com header Retry-After em segundos, conforme o envelope da Seção 7.

Endpoint Dimensão Limite Janela Bloqueio ao exceder
POST /api/signups (cadastro) IP 5 1 h 1 h
POST /api/signups Sub-rede /24 30 1 h 1 h
POST /api/auth/otp/request Telefone 3 1 h 1 h
POST /api/auth/otp/request Telefone 8 24 h 24 h
POST /api/auth/otp/request IP 10 1 h 1 h
POST /api/auth/otp/verify Telefone 5 tentativas por código TTL do código (10 min) Código invalidado
POST /api/auth/otp/verify IP 30 1 h 1 h
POST /api/auth/admin/login E-mail 5 15 min 15 min
POST /api/auth/admin/login IP 20 15 min 1 h
POST /api/me/subscription (checkout) Assinante 5 1 h 1 h
POST /api/public/unsubscribe IP 10 1 h 1 h
POST /api/devotionals/{devotionalId}/resends Assinante conforme entitlement (Seção 13) dia civil Recusa com RESEND_LIMIT_REACHED
GET /api/admin/metrics/export Administrador 10 1 h 1 h
Rotas /api/* autenticadas, geral Sessão 300 1 min 1 min
Rotas públicas, geral IP 600 1 min 1 min
Cota global de pedidos de código Sistema 500, com reserva de 20% para números já cadastrados 1 h 503 SERVICE_BUSY

Esta tabela é réplica informativa. A dona dos limites de código de acesso é a Seção 8.2.1; aqui eles reaparecem sob a ótica de controle de abuso, com exatamente os mesmos valores — 3 pedidos por hora por número, 8 por 24 h por número, 10 pedidos por IP por hora, 30 verificações por IP por hora, TTL de 10 minutos e 5 tentativas por código. Divergir de 8.2.1 em qualquer número é defeito, não decisão local.

O rate limit de código de acesso é aplicado antes de qualquer verificação de existência do telefone, e os contadores contam pedidos, nunca envios efetivos. Se contassem envios, o quarto pedido responderia de forma diferente para número cadastrado e não cadastrado, e essa diferença sozinha seria um oráculo completo de enumeração — anulando as demais defesas de 22.6.2. A cota global existe para limitar custo, e a reserva de 20% existe porque uma cota global única é, por si, um vetor de negação de serviço: esgotada de propósito, ela impediria o login de toda a base.

22.6.2 Prevenção de enumeração #

  • POST /api/auth/otp/request responde 200 com o mesmo corpo, no mesmo tempo, exista ou não o telefone. O corpo é { "data": { "sent": true, "expiresInSeconds": 600 } } em ambos os casos. Quando o número não existe, nenhuma mensagem é enviada.
  • Número bloqueado não produz erro próprio. A resposta é o mesmo 200, com o mesmo corpo, e nenhuma mensagem é enviada. Devolver 403 para número bloqueado confirmaria a existência do cadastro e tornaria o oráculo ainda mais barato do que o limite de taxa.
  • O cadastro com telefone já existente não responde 409. Ele responde 200 e envia ao número já cadastrado uma mensagem informando que houve uma tentativa de novo cadastro e como acessar o painel. Assim, quem controla o número recebe a informação; quem apenas testa números não aprende nada.
  • Mensagens de erro de login administrativo são idênticas para e-mail inexistente, senha errada e TOTP errado: INVALID_CREDENTIALS.
  • Comparações de segredo usam timingSafeEqual sobre buffers de tamanho igual, sempre.

22.6.3 Detecção de cadastro em massa #

Sinais avaliados no momento do cadastro, combinados em uma pontuação de risco de 0 a 100:

Sinal Peso
Mais de 3 cadastros do mesmo IP em 24 h quando o IP não pertence a faixa de operadora móvel brasileira conhecida 25
Mais de 25 cadastros da mesma sub-rede /24 nas últimas 24 h 20
Mesmo cpf_hmac em mais de duas contas ativas (22.5.4) 25
Telefones sequenciais (diferença ≤ 3 no sufixo) entre cadastros recentes do mesmo IP 30
Verificação anti-bot ausente ou com pontuação baixa 25
Preenchimento do formulário em menos de 3 segundos 15
E-mail de domínio descartável 20
Nome com padrão gerado (só consoantes, repetição, apenas dígitos) 10
DDD inexistente na lista oficial brasileira 20
Faixa Ação
0–39 Segue normalmente
40–69 Segue, mas o OTP passa a exigir novo desafio anti-bot e o cadastro é marcado para revisão
70–100 Recusa com 422 SIGNUP_BLOCKED e mensagem neutra: "Não foi possível concluir o cadastro agora. Fale com o suporte." O evento é registrado em log com nível warn e incrementa signup_blocked_total (Seção 23.4)

A lista de faixas de operadora móvel fica em settings sob security.mobile_carrier_ranges. O ajuste dos dois primeiros sinais existe porque as operadoras brasileiras usam CGNAT em larga escala: milhares de pessoas sem relação entre si compartilham o mesmo endereço público, e tratar isso como sinal de fraude penalizaria justamente o padrão de aquisição desejado — quarenta pessoas da mesma comunidade se cadastrando na mesma tarde, pelo Wi-Fi da igreja ou pela mesma operadora. Sem o ajuste, o melhor dia de aquisição do produto vira uma fila de suporte.

Um pico de bloqueios dispara o alerta signup_abuse_spike (Seção 23.8). Falso positivo é resolvido pelo suporte, que pode liberar o cadastro manualmente — a ação fica em admin_audit_log. A recusa por pontuação é uma decisão automatizada no sentido do Art. 20 e está declarada como tal em 22.7.4, com as garantias correspondentes.

22.6.4 Verificação anti-bot, bloqueio de descartáveis e proteção de webhook #

Decisão de ferramenta anti-bot: Cloudflare Turnstile, no modo invisível. O modo invisível é o padrão no cadastro e no pedido de código de acesso, porque a promessa de acessibilidade da interface (Seção 9.10) não admite quebra-cabeça visual nesses fluxos. O modo gerenciado (managed) fica reservado ao formulário de contato, onde o atrito é aceitável e o volume de abuso é maior.

Justificativa da ferramenta: é gratuita em volume ilimitado, não exige conta paga, e o token é validado server-side em https://challenges.cloudflare.com/turnstile/v0/siteverify — chamada que consta da lista de destinos permitidos de safeFetch (22.2.6). O widget é carregado do domínio do provedor e, por isso, challenges.cloudflare.com consta de script-src, frame-src e connect-src apenas nas páginas que contêm formulário, pela terceira variante de política descrita em 22.3.1. As duas liberações — CSP e lista de egresso — são condição para que exista qualquer cadastro no dia do lançamento, e são itens nominais do checklist de 22.13. Alternativa aceita, caso o produto precise eliminar dependência externa: prova de trabalho auto-hospedada (altcha), com custo de conversão levemente maior. Rejeitado: reCAPTCHA, por transferência internacional de dados comportamentais e por exigir consentimento.

Aplicação: formulário de cadastro, pedido de OTP a partir da web e formulário de contato. Ausência ou invalidade do token gera 422 BOT_CHECK_FAILED. A validação server-side confere também o campo hostname da resposta, para impedir reuso do token em outro site.

Bloqueio de números descartáveis. Três camadas: (a) rejeição de DDI diferente de 55, com mensagem clara, conforme a regra de normalização da Seção 11; (b) validação do DDD contra a lista oficial de códigos brasileiros em uso, e do prefixo de celular (o nono dígito precisa ser 9); (c) lista de faixas conhecidas de números virtuais e de serviços de recebimento de SMS descartável, mantida em settings sob a chave security.blocked_phone_prefixes, atualizável sem deploy. Um telefone bloqueado recebe 422 PHONE_NOT_ALLOWED. E-mails descartáveis são barrados por lista de domínios em settings sob security.blocked_email_domains; como o e-mail é opcional no produto, o bloqueio apenas impede o cadastro daquele e-mail, não do assinante.

Proteção dos endpoints de webhook. Ambos são públicos por necessidade e, por isso, recebem tratamento específico:

Controle POST /api/webhooks/asaas POST /api/webhooks/whatsapp
Autenticação Header asaas-access-token comparado em tempo constante com o segredo configurado HMAC SHA-256 do corpo cru em X-Hub-Signature-256, comparado em tempo constante com o segredo do aplicativo
Corpo cru Lido antes de qualquer parse, para que a assinatura seja calculada sobre os bytes exatos Idem
Tamanho máximo 512 KB; acima disso, 413 sem processar 512 KB
Resposta a segredo inválido 401 sem detalhe, com log em warn e incremento de webhook_auth_failed_total 401, idem
Tempo de resposta Persiste o evento e enfileira; responde em menos de 2 s (exigência registrada na Seção 12) Idem, conforme Seção 17
Idempotência Índice único no identificador do evento; reentrega devolve 200 sem reprocessar Índice único no identificador da mensagem
Rate limit 600 requisições por minuto por IP, com IPs do provedor em lista de tolerância maior Idem
Replay Eventos com carimbo de tempo mais de 24 h antigo são registrados e descartados Idem
Corpo inválido Nunca produz resposta não-2xx. Autenticado o remetente, corpo que não é JSON, schema reprovado ou evento desconhecido respondem 200 com corpo vazio e persistem o ocorrido (Seção 7.15.1) Idem
CSRF Isento e declarado como tal (22.2.5) Isento

A linha de corpo inválido é de segurança, não de conveniência: 4xx e 5xx levam o provedor a retentar e, em seguida, a desativar a assinatura do webhook. Perder o canal de eventos de cobrança é catastroficamente pior do que engolir um corpo malformado, porque é por ele que a revogação imediata de acesso pago acontece. A autenticação do remetente é a única condição que autoriza 401, e o único 4xx restante é 413 para corpo acima de 512 KB, medido antes da leitura. O alerta webhook_silence (Seção 23.8) é o detector desse estado.

GET /api/webhooks/whatsapp responde ao desafio de verificação comparando hub.verify_token com WHATSAPP_VERIFY_TOKEN em tempo constante e devolvendo hub.challenge como texto puro; qualquer divergência devolve 403 sem corpo. O desafio da plataforma é sempre numérico, e o valor só é ecoado depois de casar com ^[0-9]{1,32}$ — validar o formato antes de ecoar impede que o endpoint sirva de refletor caso o token de verificação vaze.

22.7 LGPD #

22.7.1 Bases legais por finalidade #

Finalidade Dados Base legal Dispositivo
Entrega do devocional no WhatsApp (conteúdo religioso) Telefone, wa_id, nome, preferências Consentimento específico e destacado para dado sensível Art. 11, I
Execução do serviço pago Telefone, nome, e-mail, dados da assinatura Execução de contrato Art. 7º, V
Cobrança e prevenção à fraude no pagamento CPF, nome, dados da cobrança Execução de contrato e cumprimento de obrigação legal Art. 7º, V e II
Emissão e guarda de registros fiscais e contábeis CPF, nome, valores, datas Cumprimento de obrigação legal Art. 7º, II
Comunicações de marketing e novidades do produto Telefone, e-mail, nome Consentimento, separado do consentimento do serviço Art. 7º, I
Segurança da informação, registro de acesso e prevenção a abuso IP, user-agent, requestId, registros de tentativa Legítimo interesse, com teste de balanceamento documentado Art. 7º, IX e Art. 10
Guarda de registros de acesso a aplicações de internet IP, data e hora Cumprimento de obrigação legal Marco Civil da Internet, Art. 15
Métricas agregadas de produto Dados agregados, sem identificação Fora do escopo da LGPD por anonimização Art. 12
Analytics de navegação anônimo Página, referenciador, UTM, categoria de dispositivo, identificador diário não reidentificável Legítimo interesse, com opt-out disponível Art. 7º, IX
Atendimento e suporte Telefone, histórico de mensagens Execução de contrato e legítimo interesse Art. 7º, V e IX

O ponto que estrutura toda a tabela: o consentimento para a finalidade principal é específico, destacado e separado dos demais aceites. Na prática, o formulário tem três caixas independentes, nenhuma marcada previamente: aceite dos termos e da política; consentimento para tratamento de dado sensível relacionado a convicção religiosa, com texto que explica exatamente isso; e consentimento opcional de marketing. Recusar o de marketing não impede o cadastro. Recusar o de dado sensível impede — e a tela explica por quê, porque sem ele não há como prestar o serviço.

22.7.2 Inventário de dados pessoais #

Dado Categoria Onde Finalidade Base legal Retenção Destinatários
Telefone (E.164) Pessoal, identificador subscribers.phone_e164 (cifrado), subscribers.phone_hmac Identidade e entrega Consentimento e contrato Vida da conta + 30 dias Meta (Cloud API)
wa_id Pessoal, identificador subscribers.wa_id (cifrado), subscribers.wa_id_hmac Roteamento de mensagens Consentimento e contrato Idem Meta
Nome Pessoal subscribers.display_name Personalização e cobrança Contrato Idem Asaas
E-mail Pessoal subscribers.email (cifrado), subscribers.email_hmac Login alternativo, recibos, suporte Contrato e consentimento Idem Resend
CPF Pessoal, identificador fiscal subscriber_profiles.cpf (cifrado), subscriber_profiles.cpf_hmac Exigência do processador de pagamento Contrato e obrigação legal 5 anos após o último pagamento Asaas
Convicção religiosa (inferida da assinatura) Sensível Implícita na existência do registro Prestação do serviço Consentimento específico (Art. 11, I) Vida da conta + 30 dias Meta, provedor de TTS (apenas conteúdo, não titular)
Registros de consentimento Pessoal consent_events Prova de conformidade Obrigação legal e legítimo interesse 5 anos após o fim da relação Nenhum
Histórico de mensagens enviadas Pessoal message_logs Operação, suporte, métricas Contrato e legítimo interesse 18 meses Meta
Mensagens recebidas Pessoal inbound_messages Atendimento e comandos Contrato 18 meses Nenhum
Dados de cobrança (valores, status, vencimento) Pessoal e financeiro payments, payment_events Cobrança e conciliação Contrato e obrigação legal 5 anos Asaas
Número, CVV e validade do cartão Pessoal e financeiro Não armazenados em lugar algum Cobrança recorrente Contrato Não aplicável Asaas (tokenização)
Token do cartão, bandeira, últimos quatro dígitos, mês e ano de validade Pessoal e financeiro, não sensível no sentido do padrão de cartões subscriptions Cobrança recorrente e exibição no painel Contrato Vida da assinatura + 5 anos Asaas
IP e user-agent Pessoal consent_events, sessions, logs de acesso Segurança e prova de consentimento Legítimo interesse e obrigação legal 6 meses nos logs; junto do consentimento, 5 anos Nenhum
Sessões Pessoal sessions Autenticação Contrato TTL da sessão + 30 dias Nenhum
Códigos OTP Pessoal, transitório otp_codes Autenticação Contrato 24 h após expirar Meta (envio)
Áudio do devocional Não pessoal audio_assets, storage Produto Não aplicável Vida do acervo Provedor de TTS, Meta
Métricas agregadas Anonimizado daily_metrics Gestão Fora do escopo Indefinida Nenhum

Dado de cartão nunca toca o servidor em texto persistido. A tokenização é feita pelo processador de pagamento; o payload de tokenização, quando trafega server-side, não é gravado nem registrado em log (regra reforçada pela configuração de redação da Seção 23.1). Não existe coluna de cartão em nenhuma tabela.

22.7.3 Registro de consentimento #

consent_events é append-only: a aplicação nunca faz UPDATE nem DELETE. Revogar um consentimento grava um evento novo, do mesmo type, com granted = false; o evento original permanece, porque ele é a prova de que o consentimento existiu no passado.

Conteúdo obrigatório de cada evento: identificador do assinante; type (SERVICE_TERMS, SENSITIVE_DATA, MARKETING, COOKIE_CONSENT, OPT_IN_WEB, OPT_IN_WHATSAPP, OPT_OUT, RE_OPT_IN, PAUSE_STARTED, PAUSE_ENDED); channel (WEB, WHATSAPP, ADMIN); policy_version (identificador da versão exata do texto exibido, por exemplo v3); consent_text_hash (SHA-256 do texto integral apresentado, o que permite provar o teor sem depender de arquivo externo); granted (booleano); evidence (objeto JSON com o contexto do evento); ip; user_agent; e created_at. Os nomes de coluna são os da Seção 6.5, que é a dona da definição física: não existem colunas text_version nem text_hash. O enum completo é o ConsentType da Seção 6, que é a dona da definição física; nenhuma seção introduz valor fora dele, e a grafia canônica é a de lá.

consent_text_hash é o SHA-256 do texto após normalização canônica: NFC, quebras de linha convertidas para \n, espaços em sequência colapsados em um, e espaços removidos das pontas. Sem essa normalização, uma reindentação do arquivo de textos mudaria o hash de uma versão já publicada, o teste de imutabilidade falharia, alguém o atualizaria para o valor novo — e a prova de qual texto foi apresentado deixaria de existir para todos os consentimentos anteriores. O teste compara contra uma tabela de hashes fixos, versionada em separado e protegida por verificação de caminho no pull request.

Imutabilidade imposta em três camadas. O gatilho nunca é desabilitado — desabilitar exige ser dono da tabela e abriria uma janela em que qualquer escrita passa. Em vez disso, ele reconhece dois papéis nomeados e confere, coluna a coluna, que a operação é exatamente a permitida:

-- 1) Privilégio: o usuário da aplicação só insere e lê.
REVOKE UPDATE, DELETE ON consent_events FROM app_user;
GRANT INSERT, SELECT ON consent_events TO app_user;

-- 2) Papéis nomeados, cada um com uma única razão de existir.
CREATE ROLE privacy_operator LOGIN;
GRANT SELECT, UPDATE ON consent_events TO privacy_operator;

CREATE ROLE retention_operator LOGIN;
GRANT SELECT, DELETE ON consent_events TO retention_operator;

-- 3) Gatilho: barra alteração mesmo por engano de um papel privilegiado,
--    mas não impede o cumprimento de um direito do titular nem da retenção legal.
CREATE OR REPLACE FUNCTION consent_events_immutable() RETURNS trigger AS $$
BEGIN
  -- Exceção 1: eliminação por LGPD, restrita a zerar identificadores de rede.
  IF TG_OP = 'UPDATE' AND current_user = 'privacy_operator'
     AND NEW.id = OLD.id AND NEW.subscriber_id = OLD.subscriber_id
     AND NEW.type = OLD.type AND NEW.granted = OLD.granted
     AND NEW.policy_version = OLD.policy_version
     AND NEW.consent_text_hash = OLD.consent_text_hash
     AND NEW.created_at = OLD.created_at
     AND NEW.ip IS NULL AND NEW.user_agent IS NULL THEN
    RETURN NEW;
  END IF;
  -- Exceção 2: expurgo por retenção, restrito a linhas fora do prazo legal.
  IF TG_OP = 'DELETE' AND current_user = 'retention_operator'
     AND OLD.created_at < now() - interval '5 years' THEN
    RETURN OLD;
  END IF;
  RAISE EXCEPTION 'consent_events is append-only';
END $$ LANGUAGE plpgsql;

CREATE TRIGGER consent_events_no_update_delete
  BEFORE UPDATE OR DELETE ON consent_events
  FOR EACH ROW EXECUTE FUNCTION consent_events_immutable();

O conteúdo probatório do consentimento — tipo, versão, hash do texto, data e granted — é imutável inclusive para esses dois papéis: o gatilho compara cada uma dessas colunas e recusa a operação se qualquer uma mudar. O mesmo padrão, com os mesmos dois papéis, é aplicado a admin_audit_log (23.11.3).

A razão de o controle ter exceções codificadas, e não uma porta de desabilitação, é direta: um controle de integridade que impeça o cumprimento de um direito do titular deixa de ser controle e vira descumprimento. Sem a exceção 1, o primeiro pedido de eliminação abortaria a transação inteira e a anonimização nunca aconteceria para titular nenhum, com o prazo de 30 dias do Art. 18, VI vencendo sem que ninguém soubesse. Sem a exceção 2, o expurgo de 22.8 seria impossível.

A terceira camada é o versionamento do texto: os textos de consentimento vivem em packages/core/src/consent/texts.ts, versionados junto com o código, e cada versão é imutável — uma correção, ainda que de vírgula, cria v4, nunca altera v3. Um teste garante que o hash de cada versão publicada não mudou (Seção 24). Quando o texto muda, assinantes existentes continuam vinculados à versão que aceitaram; um novo consentimento só é pedido se a mudança alterar finalidade ou destinatário — mudança de redação não gera novo pedido.

Exemplo de evento gravado no cadastro:

{
  "id": "cse_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
  "subscriberId": "sub_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
  "type": "SENSITIVE_DATA",
  "channel": "WEB",
  "granted": true,
  "textVersion": "v3",
  "textHash": "9f2c1a7d4e8b0c3f5a6d9e2b1c4f7a8d0e3b6c9f2a5d8e1b4c7f0a3d6e9b2c5f",
  "ip": "189.45.12.203",
  "userAgent": "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36",
  "createdAt": "2026-08-25T12:04:11.882Z"
}

22.7.4 Direitos do titular #

Canal único: privacidade@palavradiaria.com.br, mais a área Meus dados do painel do assinante, que atende os pedidos mais comuns sem intervenção humana. Todo pedido, venha por qual canal vier, gera protocolo e registro em admin_audit_log.

Direito Dispositivo Como é atendido Prazo
Confirmação de tratamento e acesso Art. 18, I e II Painel: resposta imediata em tela. Por e-mail: relatório em PDF e JSON Imediato no painel; 15 dias por e-mail
Acesso simplificado Art. 19, § 2º Tela Meus dados com todas as categorias tratadas Imediato
Correção Art. 18, III Nome e e-mail editáveis no painel. Telefone e CPF exigem verificação por OTP e conferência do suporte Imediato no painel; 5 dias úteis nos casos verificados
Portabilidade Art. 18, V Exportação JSON estruturada, gerada sob demanda e baixada dentro do painel, sob sessão plena. A geração exige sessão plena (nunca restrita, conforme 8.15.1.1) e verificação por código de acesso no momento do pedido Até 5 dias úteis (geração assíncrona)
Eliminação Art. 18, VI Botão no painel, com dupla confirmação e verificação por OTP. Anonimização executada em até 30 dias, com dados de retenção legal preservados (22.9) Confirmação imediata; execução em até 30 dias
Revogação do consentimento Art. 8º, § 5º Botão no painel e palavras-chave no WhatsApp. Efeito imediato sobre os envios Imediato
Oposição ao tratamento por legítimo interesse Art. 18, § 2º Pedido ao encarregado, com análise motivada 15 dias
Informação sobre compartilhamento Art. 18, VII Lista de destinatários publicada na Política de Privacidade e repetida no relatório de acesso Imediato
Informação sobre a possibilidade de não consentir Art. 18, VIII Texto no formulário de cadastro, antes do aceite Imediato
Revisão de decisão automatizada Art. 20 Aplicável. A pontuação anti-abuso de 22.6.3 é uma decisão automatizada que pode recusar o cadastro e, portanto, afeta o titular. Garantias efetivas: a recusa nunca informa o motivo detalhado, porque informá-lo ensinaria o atacante, mas sempre oferece contato humano identificado; a solicitação de revisão é atendida por pessoa, com análise dos sinais que compuseram a pontuação; a liberação manual é registrada em admin_audit_log; e o titular pode pedir informação sobre os critérios gerais utilizados, descritos em linguagem acessível na Política de Privacidade, sem revelar os pesos. O Relatório de Impacto de 22.7.7 descreve esta decisão automatizada como tal 2 dias úteis

Exportação de portabilidade — estrutura entregue:

{
  "exportedAt": "2026-08-25T12:00:00.000Z",
  "formatVersion": "1.0",
  "subscriber": { "id": "sub_01J9…", "displayName": "Maria Souza", "phone": "+5511987654321",
                  "email": "maria@exemplo.com.br", "tier": "PAID",
                  "createdAt": "2026-02-11T09:12:03.000Z",
                  "optInConfirmedAt": "2026-02-11T09:14:41.000Z" },
  "consents": [ { "type": "SENSITIVE_DATA", "granted": true, "textVersion": "v3",
                  "channel": "WEB", "createdAt": "2026-02-11T09:12:03.000Z" } ],
  "subscriptions": [ { "id": "sbs_01J9…", "plan": "plan_monthly", "status": "ACTIVE",
                       "billingType": "CREDIT_CARD", "startedAt": "2026-02-11T09:20:00.000Z" } ],
  "payments": [ { "id": "pay_01J9…", "amountCents": 1990, "status": "RECEIVED",
                  "dueDate": "2026-08-11", "confirmedAt": "2026-08-11T13:02:00.000Z" } ],
  "messages": [ { "sentAt": "2026-08-24T09:00:12.000Z", "type": "template",
                  "templateName": "devocional_diario_v1", "status": "read" } ],
  "inboundMessages": [ { "receivedAt": "2026-08-24T09:03:44.000Z", "textBody": "Ler e ouvir agora" } ]
}

O arquivo é gerado pelo job privacy.export_subscriber_data, cifrado no storage sob o prefixo exports/, e não é entregue por URL portadora. O titular recebe apenas o aviso de que a exportação está pronta; o download acontece dentro do painel, sob sessão plena autenticada (nunca restrita, conforme 8.15.1.1), por uma URL assinada de 15 minutos gerada no momento do clique e vinculada à sessão. O arquivo é removido do storage após 7 dias, o download é limitado a 3 vezes, cada download é registrado em admin_audit_log com action = 'DATA_EXPORT_DOWNLOADED', e a geração é limitada a 2 pedidos por assinante por mês.

O motivo é concreto: um link válido por 72 horas é a credencial de acesso ao dossiê completo do titular — telefone, CPF, histórico financeiro e 18 meses de mensagens —, e mensagens de WhatsApp são encaminhadas o tempo todo. Uma titular que peça ajuda ao marido para abrir o arquivo entrega, junto, o dossiê inteiro a quem quer que veja a mensagem encaminhada.

Troca de número e chip reciclado. Números brasileiros são reemitidos pelas operadoras, e a posse do número é o único fator de autenticação do assinante. Por isso: qualquer mudança do wa_id associado a um telefone invalida todas as sessões daquele assinante e exige nova verificação por código antes de liberar qualquer dado pessoal. Nenhuma conta, CPF ou histórico é entregue a um número apenas porque ele respondeu no WhatsApp. A regra completa de sessão restrita por dormência está em 8.15.1.1, e a exportação de portabilidade a exige nominalmente.

22.7.5 Encarregado de dados #

Encarregado nomeado formalmente, com identificação e canal publicados de forma acessível, como exige o Art. 41, § 1º. Canal público: privacidade@palavradiaria.com.br, divulgado no rodapé de todas as páginas, na Política de Privacidade e na mensagem de boas-vindas do WhatsApp. Atribuições: receber comunicações de titulares e da ANPD, orientar a equipe, manter o inventário de 22.7.2 e o registro das operações de tratamento, e conduzir o procedimento de incidente. O prazo interno de primeira resposta é de 2 dias úteis, e o de conclusão é o da tabela de 22.7.4. A caixa é monitorada em dias úteis e tem alerta configurado para mensagens sem resposta em 48 h.

22.7.6 Transferência internacional #

Destinatário Dado transferido País Salvaguarda Aviso
Meta Platforms (WhatsApp Cloud API) Telefone, wa_id, conteúdo das mensagens Estados Unidos e Irlanda Cláusulas-padrão contratuais da ANPD (Resolução CD/ANPD nº 19/2024), cuja adoção pelo provedor precisa ser verificada e arquivada antes do lançamento Nominal na Política de Privacidade, com finalidade e categoria
ElevenLabs (TTS primário) Nenhum dado pessoal. Apenas o texto do devocional, que é conteúdo editorial Estados Unidos Contrato de processamento de dados; cláusulas-padrão da ANPD verificadas e arquivadas antes do lançamento Declarado, com a observação de que não há dado de titular
Google Cloud Text-to-Speech (fallback) Nenhum dado pessoal. Idem Estados Unidos Idem Idem
Resend (e-mail transacional) E-mail, nome, conteúdo da mensagem Estados Unidos Cláusulas-padrão contratuais da ANPD, verificadas e arquivadas antes do lançamento; contrato de processamento Nominal
Asaas (pagamentos) Nome, CPF, e-mail, telefone, valores Brasil Não há transferência internacional Declarado como processador nacional
Storage de mídia Áudio e imagens; sem dado pessoal Brasil, região São Paulo Não há transferência internacional Declarado
Analytics Navegação anônima Brasil, mesmo servidor Não há transferência internacional Declarado

Decisão de arquitetura com consequência jurídica direta, registrada aqui: o storage de mídia e o analytics são contratados em região brasileira, e o provedor de pagamento é nacional. Isso reduz a transferência internacional a três destinatários, dois dos quais não recebem dado pessoal algum. A Política de Privacidade nomeia cada destinatário, a finalidade, a categoria de dado e o país — o aviso genérico "podemos compartilhar com parceiros" é insuficiente e não é usado.

A salvaguarda é um documento arquivado, não uma conclusão. Afirmar que as cláusulas-padrão estão "previstas nos termos do provedor" é uma conclusão jurídica sobre um contrato de adesão de terceiro, e numa fiscalização ela não substitui a demonstração. Por isso: a adoção das cláusulas por cada destinatário é verificada e o documento comprobatório é arquivado antes do lançamento, como item nominal do checklist de 22.13. Quando o provedor não as adotar no instrumento, a alternativa é o contrato específico ou a demonstração de outra hipótese do Art. 33; a decisão e o documento ficam sob guarda do encarregado. Enquanto não houver comprovação arquivada, a transferência não é declarada como salvaguardada na Política de Privacidade — o dado transferido é sensível, e uma declaração sem lastro é pior do que a ausência dela.

22.7.7 Relatório de impacto à proteção de dados #

O relatório é obrigatório neste produto e é elaborado antes do lançamento. O gatilho é direto: há tratamento de dado pessoal sensível (convicção religiosa) em larga escala, com uso de comunicação direta e de decisão automatizada de bloqueio no cadastro. Nessas condições, a elaboração não é discricionária.

Conteúdo mínimo: descrição das operações de tratamento; finalidade e base legal de cada uma; categorias de titulares e de dados, com destaque para o dado sensível; fluxo dos dados, incluindo os destinatários de 22.7.6; medidas técnicas de 22.2 a 22.6; avaliação dos riscos de 22.1.4 com probabilidade, impacto e controle correspondente; riscos residuais aceitos, com justificativa; análise de necessidade e proporcionalidade da coleta de CPF; e plano de reavaliação.

Revisão: anual, ou imediatamente quando houver novo destinatário, nova finalidade, novo tipo de dado coletado, mudança material de arquitetura ou incidente de segurança relevante. O documento fica sob guarda do encarregado, à disposição da ANPD.

22.7.8 Notificação de incidente #

Fluxo, com prazos contados a partir do conhecimento do incidente.

Prazo Ação
Imediato Contenção técnica: revogar credencial, isolar serviço, bloquear acesso.
Até 6 h Registro inicial do incidente pelo encarregado: o que aconteceu, quando, quais sistemas, quais dados podem ter sido afetados e quantos titulares.
Até 24 h Avaliação de risco: houve acesso a dado pessoal? Havia cifra? Havia chave junto? O dado é sensível? Dado sensível eleva a gravidade por definição.
Até 3 dias úteis Comunicação à ANPD, quando o incidente puder acarretar risco ou dano relevante ao titular, pelo canal oficial da Autoridade (prazo da Resolução CD/ANPD nº 15/2024).
Mesmo prazo Comunicação aos titulares afetados, em linguagem clara, pelos canais e na ordem definidos na Seção 27.7 — página pública de estado primeiro, e-mail em seguida, canal de mensagens apenas se ele for o canal saudável, e aviso dentro do painel. Comunicar a queda do canal de mensagens pelo próprio canal é impossível e não pode ser o plano.
Até 20 dias Complemento das informações à ANPD, se a apuração inicial foi parcial.
Até 30 dias Relatório pós-incidente completo (Seção 23.10) e implantação das correções.

Conteúdo da comunicação ao titular, conforme o Art. 48, § 1º: descrição da natureza dos dados afetados; informações sobre os titulares envolvidos; medidas técnicas e de segurança que estavam em uso; riscos relacionados; motivo da demora, se a comunicação não foi imediata; e as medidas adotadas para reverter ou mitigar os efeitos. A comunicação nunca minimiza o ocorrido nem usa linguagem evasiva.

Registro: todo incidente, mesmo o que não atinge o limiar de comunicação, é anotado em um registro interno mantido pelo encarregado, com a análise que fundamentou a decisão de não comunicar. Essa análise é o que sustenta a decisão perante a Autoridade.

22.8 Retenção e eliminação #

Tabela Prazo Fundamento
subscribers Anonimização em até 30 dias após pedido de eliminação ou após 24 meses sem qualquer atividade e sem assinatura ativa Art. 15, I e III: fim da finalidade e pedido do titular
subscriber_profiles Igual a subscribers, exceto CPF, retido por 5 anos após o último pagamento Obrigação fiscal e prescrição de pretensões
consent_events 5 anos após o fim da relação Prova de conformidade; Art. 37 e Art. 16, I
sessions Expurgo diário das sessões expiradas há mais de 30 dias Fim da finalidade
otp_codes Expurgo horário dos códigos expirados há mais de 24 h Fim da finalidade; dado transitório
unsubscribe_tokens Expurgo horário dos tokens expirados há mais de 24 h, no mesmo job de otp_codes Fim da finalidade; dado transitório
admin_users Enquanto houver vínculo; anonimização 12 meses após o desligamento Fim da finalidade, preservando a integridade da auditoria
admin_audit_log 5 anos Prova de conformidade e apuração de incidentes
plans Indefinida Não contém dado pessoal
subscriptions, subscription_events 5 anos após o encerramento Prescrição de pretensões contratuais (Código Civil, Art. 206)
payments, payment_events 5 anos após o pagamento Obrigação fiscal e contábil
devotionals, devotional_revisions, audio_assets Indefinida Conteúdo editorial; não contém dado pessoal
whatsapp_templates Indefinida Configuração
message_logs 18 meses, valor dirigido pela chave privacy.message_log_retention_months Necessidade operacional e de suporte; além disso, o dado perde utilidade
inbound_messages 18 meses, pela mesma chave Idem
delivery_attempts 13 meses Idempotência e apuração de falhas
send_batches, job_runs 13 meses Operação e apuração
webhook_deliveries 6 meses Depuração de integração
media_uploads Vida do conteúdo associado Conteúdo editorial
settings, feature_flags Indefinida Configuração
daily_metrics Indefinida Agregado anonimizado; fora do escopo da LGPD (Art. 12)
Registros de acesso a aplicação (logs) 6 meses Marco Civil da Internet, Art. 15
Backups 35 dias Continuidade do negócio

O prazo de 18 meses das duas tabelas de mensagem é digitado em um único lugar: a chave privacy.message_log_retention_months de settings (Seção 26.8.1). O job de retenção lê a chave; nenhuma seção repete o número como constante de código.

Os jobs de privacidade e de retenção citados nesta seção — maintenance.enforce_retention, privacy.anonymize_subscriber, privacy.export_subscriber_data, security.reencrypt_fields e security.reindex_blind — são nomes de job, e todos são enfileirados na fila maintenance.cleanup do catálogo de treze filas da Seção 18.8. Nome de job não é nome de fila, e nenhuma fila nova é criada por esta seção.

O job maintenance.enforce_retention roda diariamente às 03:40 (America/Sao_Paulo), depois do rollup de métricas das 03:10, em lotes de 5.000 linhas com pausa entre lotes para não travar o banco. Cada execução registra em job_runs quantas linhas foram removidas ou anonimizadas por tabela. O expurgo de consent_events e de admin_audit_log é executado pela conexão do papel retention_operator, cuja exceção está codificada no gatilho de 22.7.3; nenhum gatilho é desabilitado em momento algum.

Interação entre retenção e backups, declarada explicitamente porque é a pergunta que aparece em toda auditoria: um pedido de eliminação é executado na base ativa em até 30 dias, mas os backups existentes continuam contendo o dado até expirarem, em no máximo 35 dias. Backup não é reescrito — reescrever backup destrói sua integridade e sua função. Se um backup precisar ser restaurado dentro dessa janela, o job de retenção é executado imediatamente após a restauração, e o procedimento consta do runbook de restauração da Seção 27. A Política de Privacidade informa essa janela ao titular.

22.9 Anonimização #

Quando o titular pede eliminação, o sistema não apaga a linha de subscribers. Apagar quebraria as chaves estrangeiras de payments e subscriptions, que precisam ser preservados por obrigação fiscal, e destruiria a integridade contábil. A operação tem dois estágios, com regimes jurídicos diferentes, e a distinção não é semântica.

Estágio 1 — pseudonimização (imediato, em até 30 dias do pedido). Todos os identificadores diretos saem, conforme a tabela abaixo, mas cpf e cpf_hmac permanecem enquanto durar a retenção fiscal, e asaas_customer_id continua apontando para um cadastro identificado no processador de pagamento. Nesse estágio a linha continua sendo dado pessoal, permanece integralmente no escopo da LGPD com base no Art. 16, I, conta na apuração de qualquer incidente, e está sujeita ao mesmo controle de acesso das linhas ativas.

Estágio 2 — anonimização (5 anos após o último pagamento). cpf, cpf_hmac, asaas_customer_id, asaas_subscription_id e asaas_payment_id são zerados, e é enviada solicitação de exclusão do cadastro correspondente ao processador. Só então o Art. 12 se aplica. O job maintenance.enforce_retention executa o estágio 2 diariamente sobre as linhas cuja retenção fiscal venceu, e registra a transição em admin_audit_log com action = 'SUBSCRIBER_FULLY_ANONYMIZED'.

Chamar o estágio 1 de anonimização levaria a equipe a excluir essas pessoas da contagem de titulares em um incidente — que é exatamente o erro que a Autoridade procura, e o que inviabilizaria a declaração de conformidade do relatório de impacto inteiro.

Regra de alcance. A anonimização percorre todas as tabelas que contenham telefone, wa_id, e-mail, CPF ou conteúdo de mensagem do titular, e não apenas subscribers. A lista é derivada da coluna, não da tabela: qualquer coluna cujo nome esteja em ('phone_e164','wa_id','email','cpf','payload_excerpt','text_body') e cuja linha se relacione ao assinante é zerada, inclusive nos registros de mensagem. O que a lei obriga a reter — registros fiscais e de consentimento — é retido de forma pseudonimizada, ligado apenas ao identificador interno. Um teste de integração cria um assinante, gera tráfego em todas as tabelas, executa a anonimização e falha se SELECT por telefone, por wa_id ou pelo e-mail original devolver qualquer linha em qualquer tabela.

Sem essa regra o resultado é indefensável: o painel confirmaria "conta anonimizada" enquanto message_logs guardaria o número completo, o horário de leitura de cada devocional e o trecho do conteúdo religioso, por até 18 meses.

Transformação, coluna por coluna:

Coluna Antes Depois Observação
subscribers.display_name Maria Souza Assinante removido Constante
subscribers.phone_e164 envelope cifrado NULL Chave de decifra deixa de ter alvo
subscribers.phone_hmac HMAC real HMAC de anon:{id} Preserva unicidade sem permitir busca pelo telefone real
subscribers.wa_id / wa_id_hmac envelope / HMAC NULL / NULL Índice parcial tolera nulo
subscribers.tier PAID inalterado Não identifica
subscribers.opt_out_at NULL now() Impede qualquer envio futuro
subscribers.deleted_at NULL now() Soft delete, conforme a convenção da Seção 6
subscribers.anonymized_at NULL now() Marca a operação como concluída
subscribers.email / email_hmac envelope / HMAC NULL / NULL
subscriber_profiles.cpf / cpf_hmac envelope / HMAC preservados até 5 anos após o último pagamento; depois NULL (estágio 2) Obrigação fiscal prevalece sobre eliminação (Art. 16, I)
subscribers.asaas_customer_id valor preservado até o fim da retenção fiscal; depois NULL (estágio 2) Enquanto existir, recupera o cadastro identificado no processador: é pseudonimização, não anonimização
subscriptions.asaas_card_token, card_last4, card_brand, card_exp_month, card_exp_year valores NULL Token inutilizável após a anonimização; metadados deixam de ter titular
consent_events.ip / user_agent valor NULL O evento permanece como prova; os identificadores de rede saem. Executado pelo papel privacy_operator (22.7.3)
message_logs.subscriber_id ULID inalterado Aponta para linha já pseudonimizada; a coluna em si não identifica
message_logs.phone_e164 +5511987654321 NULL A coluna passa a ser anulável na Seção 6; o roteamento histórico não precisa do número
message_logs.wa_id valor NULL Idem
message_logs.payload_excerpt trecho do conteúdo NULL Pode conter o nome do titular em parâmetro de template e texto por ele digitado
inbound_messages.phone_e164 / wa_id valor NULL / NULL Idem
inbound_messages.text_body texto livre [removido a pedido do titular] Pode conter dado pessoal digitado pelo titular
delivery_attempts (colunas de destino, quando houver) valor NULL Idem
payments, subscriptions valores e status inalterados, exceto os identificadores do processador no estágio 2 Registro fiscal, retido de forma pseudonimizada e ligado apenas ao identificador interno
sessions, otp_codes, unsubscribe_tokens linhas apagadas Hard delete

Script de execução:

// apps/worker/src/tasks/privacy-anonymize-subscriber.ts
// Fica em tasks/, e não em jobs/: jobs/ tem exatamente um arquivo por fila (Seção 4.5).
export async function anonymizeSubscriber(subscriberId: string, reason: AnonymizeReason) {
  await db.$transaction(async (tx) => {
    const s = await tx.subscriber.findUniqueOrThrow({ where: { id: subscriberId } })
    if (s.anonymizedAt) return                                   // idempotente

    const keepCpf = await hasFiscalHold(tx, subscriberId)        // pagamento nos últimos 5 anos

    await tx.subscriber.update({
      where: { id: subscriberId },
      data: {
        displayName: 'Assinante removido',
        phoneE164: null,
        phoneHmac: blindIndex(`anon:${subscriberId}`, 'phone', currentIndexKey()),
        waId: null,
        waIdHmac: null,
        email: null,
        emailHmac: null,
        ...(keepCpf ? {} : { asaasCustomerId: null }),
        optOutAt: s.optOutAt ?? new Date(),
        deletedAt: new Date(),
        anonymizedAt: new Date(),
      },
    })

    await tx.subscriberProfile.updateMany({
      where: { subscriberId },
      data: { ...(keepCpf ? {} : { cpf: null, cpfHmac: null }) },
    })

    await tx.subscription.updateMany({
      where: { subscriberId },
      data: { asaasCardToken: null, cardLast4: null, cardBrand: null,
              cardExpMonth: null, cardExpYear: null },
    })

    // Alcance obrigatório: os registros de mensagem também guardam identificador pessoal.
    await tx.$executeRaw`UPDATE message_logs
                            SET phone_e164 = NULL, wa_id = NULL, payload_excerpt = NULL
                          WHERE subscriber_id = ${subscriberId}`
    await tx.$executeRaw`UPDATE inbound_messages
                            SET phone_e164 = NULL, wa_id = NULL,
                                text_body = '[removido a pedido do titular]'
                          WHERE subscriber_id = ${subscriberId}`

    // Conexão do papel privacy_operator: a exceção está codificada no gatilho de 22.7.3,
    // que continua barrando qualquer alteração do conteúdo probatório.
    await tx.$executeRaw`UPDATE consent_events SET ip = NULL, user_agent = NULL
                          WHERE subscriber_id = ${subscriberId}`

    await tx.session.deleteMany({ where: { subscriberId } })
    await tx.otpCode.deleteMany({ where: { subscriberId } })

    await tx.adminAuditLog.create({
      data: { action: 'SUBSCRIBER_ANONYMIZED', entityType: 'subscriber',
              entityId: subscriberId, afterJson: { reason, cpfRetained: keepCpf } },
    })
  })
}

Notas: a atualização de consent_events é executada pela conexão do papel privacy_operator, cuja exceção está codificada no próprio gatilho de 22.7.3. Nenhum gatilho é desabilitado em momento algum — desabilitar exige ser dono da tabela, o que o papel não é, e abriria uma janela em que qualquer escrita passa. A exceção existe porque o direito à eliminação prevalece sobre a conveniência da imutabilidade quanto aos identificadores de rede; o conteúdo probatório do consentimento (tipo, versão, hash do texto, data e granted) permanece intacto e o gatilho o verifica coluna a coluna. A operação é idempotente: rodar de novo não muda nada, porque anonymizedAt já está preenchido.

Um teste de integração executa a anonimização de ponta a ponta e o expurgo de retenção contra um banco com o gatilho ativo; ele falha se qualquer um lançar exceção, e falha também se um UPDATE que altere type, granted, consent_text_hash ou created_at for aceito com o papel privacy_operator.

O comando de operação equivalente é pnpm ops privacy:anonymize --subscriber sub_01J9… --reason USER_REQUEST, com confirmação interativa e registro em auditoria.

22.10 Termos de Uso e Política de Privacidade #

22.10.1 Estrutura obrigatória dos Termos de Uso #

  1. Identificação do fornecedor: razão social, CNPJ, endereço e canal de atendimento.
  2. Objeto: descrição do serviço, incluindo que o conteúdo é de natureza religiosa cristã.
  3. Cadastro e requisitos: idade mínima de 18 anos; menores apenas com consentimento de responsável, conforme o Art. 14 da LGPD.
  4. Planos, preços, forma de pagamento e ciclo de cobrança, com valor expresso em reais.
  5. Renovação automática, com aviso claro e destacado antes da contratação.
  6. Cancelamento e efeito: cancelamento a qualquer momento pelo painel; o acesso pago permanece até o fim do ciclo já pago; após isso, o assinante volta ao plano gratuito.
  7. Inadimplência: falha de pagamento revoga o acesso pago imediatamente, sem carência. Esta cláusula é escrita em linguagem simples, aparece em destaque no checkout e é a mesma regra descrita na Seção 13.
  8. Direito de arrependimento: 7 dias a partir da contratação, com devolução integral, conforme o Art. 49 do Código de Defesa do Consumidor. Cláusula obrigatória por se tratar de contratação fora do estabelecimento.
  9. Disponibilidade e limitações: o serviço depende da plataforma de mensageria de terceiro; indisponibilidade dessa plataforma não caracteriza descumprimento.
  10. Regras de uso e opt-out: palavras-chave de saída e o efeito de cada uma.
  11. Propriedade intelectual do conteúdo e das gravações, com a licença de uso pessoal e não comercial concedida ao assinante.
  12. Limitação de responsabilidade, redigida dentro dos limites do Código de Defesa do Consumidor.
  13. Alterações dos termos, com aviso prévio de 30 dias e direito de rescisão sem ônus.
  14. Foro e legislação aplicável: legislação brasileira; foro do domicílio do consumidor.
  15. Data de vigência e versão.

22.10.2 Estrutura obrigatória da Política de Privacidade #

  1. Identificação do controlador e do encarregado, com o canal de 22.7.5.
  2. Quais dados são coletados, por categoria, replicando o inventário de 22.7.2 em linguagem acessível.
  3. Declaração destacada sobre dado sensível: a assinatura revela convicção religiosa, esse dado é tratado com base em consentimento específico, e o consentimento pode ser revogado a qualquer momento.
  4. Finalidades e bases legais, na forma da tabela de 22.7.1.
  5. Compartilhamento: cada destinatário nomeado, com finalidade e país.
  6. Transferência internacional, com as salvaguardas de 22.7.6.
  7. Retenção, com os prazos de 22.8 e o aviso sobre a janela de backup.
  8. Direitos do titular e como exercê-los, com os prazos de 22.7.4.
  9. Segurança: descrição não técnica dos controles, incluindo a criptografia de telefone e CPF.
  10. Cookies e tecnologias similares, replicando a classificação de 21.11.
  11. Menores de idade.
  12. Alterações da política, com histórico de versões acessível.
  13. Data de vigência e versão.

Ambos os documentos são versionados no repositório, publicados em /termos e /privacidade, e o identificador da versão vigente é gravado em cada evento de consentimento (22.7.3). Nenhum dos dois é publicado como PDF apenas: o texto fica em HTML acessível e indexável.

22.10.3 Direitos autorais das versões bíblicas #

Risco real e frequentemente ignorado: as traduções bíblicas modernas em português — NVI, NTLH, ARA, NAA, entre outras — são obras protegidas por direito autoral, com titulares ativos que licenciam e fiscalizam o uso. Reproduzir versículos dessas versões em um produto comercial, em volume diário e por tempo indeterminado, sem licença, expõe o negócio a notificação, remoção de conteúdo e indenização. O limite de "citação para fins de estudo" do Art. 46 da Lei nº 9.610/98 não cobre uso comercial recorrente que constitui o núcleo do produto.

Decisão adotada: o produto usa exclusivamente traduções em domínio público ou com licença livre expressa — a Almeida Revista e Corrigida (Almeida 1911) e a Almeida Livre / Bíblia Livre. Nenhuma versão licenciada é usada sem contrato assinado.

Controles que tornam a decisão executável, e não apenas uma intenção:

  1. A coluna bible_version só aceita valores de uma lista fechada, mantida em settings sob a chave content.allowed_bible_versions, com valor padrão ["ALMEIDA_1911","BIBLIA_LIVRE"]. O painel administrativo apresenta apenas essas opções, em campo de seleção, nunca em texto livre. A sigla ARC é proibida como código e como atribuição: no mercado brasileiro ela identifica a Almeida Revista e Corrigida em edição revisada por editora ativa, que é obra protegida, e não a edição de 1911 em domínio público. Ver a sigla ARC no seletor levaria o editor a copiar versículos da edição moderna, e a atribuição estampada em cada mensagem seria a prova pronta contra a empresa.
  2. A validação Zod do editor rejeita qualquer outro valor com 422 BIBLE_VERSION_NOT_ALLOWED.
  3. Toda entrega — texto no WhatsApp, painel web e narração — inclui a atribuição da versão por extenso, no formato João 3:16 (Almeida 1911) ou João 3:16 (Bíblia Livre), nunca uma sigla ambígua.
  4. Incluir uma versão licenciada exige três passos, nesta ordem: contrato de licença assinado com o titular; registro do contrato e de seus limites de uso em settings; e só então a inclusão do código na lista permitida. A ordem é obrigatória e consta do procedimento editorial da Seção 15.
  5. A Política de Privacidade e os Termos declaram a versão em uso e a atribuição.
  6. O texto-fonte de cada versão é importado uma única vez, de origem verificável e registrada em settings sob content.bible_source_provenance (nome da fonte, URL, data da importação e responsável), e fica congelado no banco. O editor nunca cola versículo de fonte externa: ele seleciona a referência e o sistema busca o texto na base importada. Isso remove a via pela qual texto licenciado entraria no produto sem que ninguém decidisse isso.

O mesmo raciocínio vale para a voz sintetizada: a licença do provedor de TTS precisa permitir uso comercial do áudio gerado, e a verificação dessa cláusula é item do checklist de 22.13.

22.11 Conformidade com as políticas da Meta #

Política Exigência Como o produto atende
Política de Mensagens de Negócios Toda mensagem iniciada pelo negócio exige opt-in prévio, comprovável, obtido em canal onde o usuário entendeu o que receberia Opt-in em duas etapas: consentimento explícito na web e confirmação ativa no WhatsApp. Nenhum envio ocorre antes da confirmação (regra da Seção 11). Prova em consent_events (22.7.3)
Política de Mensagens de Negócios Respeitar pedidos de parada Palavras-chave reconhecidas, efeito imediato, confirmação única (Seção 20)
Política de Mensagens de Negócios Templates precisam refletir o conteúdo real O template diário anuncia o devocional e entrega o devocional. Sem isca, sem promoção disfarçada
Política de Comércio Proíbe venda de itens vedados O produto vende assinatura de conteúdo, categoria permitida. Não há venda de produto físico nem catálogo
Política de Comércio e de Anúncios Restrições sobre conteúdo religioso O conteúdo é próprio, informativo e devocional. Não há alegação de cura, não há pedido de doação, não há conteúdo que atribua ou infira crença de terceiro sem consentimento
Política de Uso Aceitável Proíbe envio em massa não solicitado Envio somente a quem confirmou o opt-in; a taxa é limitada abaixo do teto da plataforma (Seção 17)
Requisitos de qualidade Número com quality_rating baixo perde capacidade de envio Monitoramento contínuo com alerta imediato em RED ou FLAGGED (Seção 23.8)
Verificação do negócio Conta verificada é pré-requisito para tiers maiores Verificação concluída antes do lançamento; item do checklist de 22.13

O que causa banimento ou restrição, em ordem de probabilidade real:

  1. Volume alto de bloqueios e denúncias pelos usuários. É a causa dominante. Mitigação: o opt-in em duas etapas, o rodapé com instrução de saída em todo template, o processamento imediato de opt-out e o monitoramento de opt_out_rate como alerta P1 (Seção 21.12).
  2. Envio a quem nunca deu opt-in, inclusive por importação de lista. Mitigação: não existe funcionalidade de importar lista de números. A ausência é deliberada.
  3. Uso de template aprovado para finalidade diferente da declarada. Mitigação: um template por finalidade, revisão editorial antes da submissão, e catálogo controlado (Seção 19).
  4. Conteúdo que viole a política, incluindo promessa de milagre, arrecadação financeira ou conteúdo político. Mitigação: guarda editorial humana e checklist de publicação (Seção 15).
  5. Tentativa de contornar limites, como usar vários números para o mesmo público. Mitigação: não é feito; a escala é resolvida por elevação de tier junto à plataforma.

Consequência assumida e planejada: a perda do número é um risco existencial para o produto. Por isso existe um número secundário registrado e verificado, mantido inativo, com os templates já aprovados, para permitir migração operacional. O procedimento de troca está no runbook de qualidade do número, na Seção 27.

22.12 Segurança operacional #

22.12.1 Acesso ao servidor #

Controle Regra
SSH Somente chave pública, PasswordAuthentication no, PermitRootLogin no, AllowUsers com lista nominal
Porta Padrão 22, protegida por fail2ban (5 falhas em 10 min resultam em 24 h de bloqueio)
Firewall ufw com política padrão de negar entrada; abertos apenas 22, 80 e 443
Portas de serviço Postgres, Redis, /metrics e o painel de monitoramento não são expostos ao host público; ficam na rede interna do Docker e são acessados por túnel SSH
Sudo Concedido nominalmente, com registro em log; sem NOPASSWD
Atualizações unattended-upgrades para correções de segurança do sistema operacional; reinício planejado mensal
Sessão Timeout de 15 minutos de inatividade em sessão SSH interativa

22.12.2 Menor privilégio #

Cinco papéis de banco distintos, nunca o mesmo para tudo. Cada um existe por uma razão única, e dois deles são exigidos pelas exceções codificadas no gatilho de 22.7.3:

CREATE ROLE app_user LOGIN;          -- aplicação: apenas DML
CREATE ROLE migrator LOGIN;          -- migrações: DDL, só na inicialização do contêiner web
CREATE ROLE readonly_user LOGIN;     -- leitura analítica e suporte
CREATE ROLE privacy_operator LOGIN;  -- eliminação por LGPD (22.9)
CREATE ROLE retention_operator LOGIN;-- expurgo por retenção (22.8)

GRANT ALL PRIVILEGES ON SCHEMA public TO migrator;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;

-- Sem isto, toda tabela criada por uma migration futura nasce inacessível à aplicação.
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  GRANT SELECT ON TABLES TO readonly_user;
ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO app_user;

REVOKE UPDATE, DELETE ON consent_events, admin_audit_log FROM app_user;

-- Leitura analítica não alcança tabela com identificador pessoal, nem cifrado.
REVOKE SELECT ON subscribers, subscriber_profiles, message_logs, inbound_messages
  FROM readonly_user;
CREATE VIEW v_subscribers_safe AS
  SELECT id, tier, status, locale, opt_in_confirmed_at, opt_out_at, created_at
    FROM subscribers;
GRANT SELECT ON v_subscribers_safe TO readonly_user;

Duas armadilhas fechadas explicitamente por esse bloco:

  • GRANT ... ON ALL TABLES concede privilégio apenas às tabelas existentes no momento do comando. Sem ALTER DEFAULT PRIVILEGES, a primeira tabela criada por uma migration posterior nasce sem privilégio para app_user, o health check não percebe porque só consulta as tabelas antigas, e o sintoma aparece horas depois do deploy como permission denied em um fluxo específico. Por isso a verificação pós-migration da Seção 25 passa a incluir: nenhuma tabela do schema public pode existir sem SELECT para app_user; a consulta que comprova isso percorre information_schema.tables contra has_table_privilege, e uma falha bloqueia a troca de tráfego.
  • readonly_user nunca alcança tabela que contenha telefone, wa_id, e-mail, CPF ou conteúdo de mensagem, nem mesmo em forma cifrada. O acesso analítico é servido por views que projetam apenas colunas não identificadoras. Afirmar restrição "por view" sem revogar o SELECT das tabelas seria uma restrição inexistente: uma credencial de leitura entregue a um analista externo daria a base inteira.

Nas demais camadas: o contêiner da aplicação roda como usuário não-root, com sistema de arquivos raiz somente leitura e no-new-privileges; as credenciais do storage têm permissão restrita ao bucket do produto, sem direito de apagar o bucket nem de listar outros; a chave da plataforma de pagamento é a de menor escopo que atenda às operações necessárias; e o painel administrativo separa EDITOR (conteúdo) de ADMIN (assinantes e cobrança) e de OWNER (configuração, segredos e criação de administradores), conforme a matriz da Seção 3.

22.12.3 MFA e revisão de acessos #

MFA é obrigatório, sem exceção, para: painel administrativo do produto (TOTP, regra da Seção 8.4); console do provedor de VPS; painel da Meta; painel do processador de pagamento; provedor de storage; provedor de DNS; repositório de código; e provedor de e-mail transacional. Um administrador sem TOTP configurado não consegue concluir o login — a configuração é forçada no primeiro acesso.

Revisão trimestral de acessos, conduzida pelo OWNER, com registro em admin_audit_log: listar todos os admin_users ativos e confirmar necessidade e papel de cada um; listar as contas em cada provedor externo e remover as desnecessárias; conferir chaves SSH autorizadas no servidor; conferir tokens e chaves de API ativos e revogar os não usados nos últimos 90 dias; conferir a data da última rotação de cada segredo (22.4.2); e revisar quem tem permissão de exportação de dados. A revisão é um item de calendário fixo, com data-limite, e sua conclusão é registrada em settings sob ops.last_access_review_at.

22.12.4 Cadeia de suprimentos e código #

Lockfile obrigatório e commitado; instalação em CI e em build com --frozen-lockfile; pnpm audit executado em cada pull request, falhando o build em vulnerabilidade de severidade alta ou crítica sem correção aplicada; atualizações de dependência revisadas por pessoa, nunca mescladas automaticamente; imagens base fixadas por digest, não por tag móvel; varredura de imagem antes da publicação; e verificação de segredo no pré-commit e no CI. Nenhuma dependência nova entra sem justificativa registrada no pull request — cada pacote adicionado é superfície de ataque nova.

22.12.5 Saída de um administrador #

Executado em até 4 horas a partir do desligamento, na ordem abaixo, com registro de cada passo em admin_audit_log:

  1. Desativar o admin_users correspondente (deleted_at preenchido; a linha permanece para preservar a integridade do histórico de auditoria).
  2. Revogar todas as sessões daquele administrador (DELETE FROM sessions WHERE admin_user_id = …).
  3. Remover a chave SSH do servidor e conferir que não há sessão SSH aberta em nome dele.
  4. Remover o acesso em cada provedor externo, conforme a lista de 22.12.3.
  5. Remover do repositório de código e dos canais de alerta.
  6. Rotacionar todo segredo a que a pessoa teve acesso, com prioridade para chave de criptografia, chave de índice, token da plataforma de mensageria e chave de pagamento. Este passo não é opcional nem adiável: conhecimento de segredo não é revogável por remoção de conta.
  7. Revisar as ações dos últimos 90 dias em admin_audit_log, com atenção a exportações e a revelações de dado pessoal.
  8. Registrar a conclusão do procedimento com data, responsável e itens executados.

22.13 Checklist de segurança pré-lançamento #

Cada item é verificável e tem um responsável. O lançamento não ocorre com item pendente.

Aplicação

  • Todo endpoint público tem schema Zod com .strict() e limite de tamanho em cada string.
  • Nenhuma ocorrência de $queryRawUnsafe, $executeRawUnsafe, eval ou new Function no código; verificado por regra de lint que falha o build.
  • dangerouslySetInnerHTML aparece em exatamente um arquivo, o de renderização sanitizada.
  • Sanitização de markdown testada com vetores de XSS conhecidos (Seção 24).
  • Verificação de origem e token CSRF ativos em todas as rotas que mudam estado; teste automatizado confirma que nenhuma rota escapa, exceto as duas de webhook declaradas.
  • safeFetch é o único caminho de saída HTTP; lista de destinos revisada e contendo challenges.cloudflare.com.
  • Upload administrativo rejeita SVG, valida assinatura binária e re-codifica o arquivo, com os limites de 16 MB e 480 s para áudio.
  • Impersonação administrativa é somente leitura: o invólucro de API recusa todo método não seguro em sessão de impersonação, em qualquer prefixo de rota, inclusive cancelamento de assinatura, troca de forma de pagamento, pedido de eliminação e opt-out (Seção 8.13).

Cabeçalhos e transporte

  • CSP em modo de imposição, sem unsafe-inline e sem unsafe-eval, validada em todas as páginas — incluindo nominalmente /cadastro, /contato e a tela de pedido de código — com o console do navegador limpo.
  • Widget anti-bot renderiza e conclui o desafio em cada página com formulário, em navegador real; um cadastro completo é feito de ponta a ponta antes do lançamento.
  • Player de áudio do plano pago toca em navegador real, o que comprova o domínio de mídia liberado em connect-src.
  • Endpoint de relatório de violação de CSP recebendo e registrando.
  • Todos os cabeçalhos de 22.3 presentes na resposta, verificados em produção.
  • HSTS ativo com preload; redirecionamento de HTTP para HTTPS funcionando.
  • TLS 1.0 e 1.1 desabilitados; nota A ou superior em avaliador externo de TLS.

Criptografia e segredos

  • Telefone, wa_id, CPF e e-mail cifrados no banco, com as colunas e os índices cegos declarados na Seção 6 presentes desde a primeira migration; verificado por inspeção direta de uma linha real.
  • Índice cego funcionando: busca por telefone exato e pelas variantes do nono dígito testada com número real.
  • ENCRYPTION_KEY, PHONE_INDEX_KEY e BACKUP_ENCRYPTION_KEY gerados com openssl rand, guardados no cofre offline e fora do banco.
  • Nenhum nome de segredo fora do registro canônico de 26.3; teste de pipeline verde.
  • Nenhum segredo no repositório; gitleaks limpo no histórico completo.
  • Backup cifrado e restauração testada com sucesso em ambiente separado.
  • Datas de rotação registradas e alerta de vencimento configurado.

Abuso e autenticação

  • Rate limits de 22.6.1 ativos e testados, inclusive o de OTP por número.
  • Respostas de OTP indistinguíveis entre número existente e inexistente, em corpo e em tempo.
  • Anti-bot ativo no cadastro, com validação server-side conferindo o hostname.
  • Pontuação anti-abuso ativa; caminho de liberação manual pelo suporte testado.
  • TOTP obrigatório para todos os administradores; nenhum administrador sem segundo fator.
  • Webhooks validando segredo e assinatura em tempo constante; requisição forjada rejeitada com 401 em teste.

LGPD

  • Política de Privacidade e Termos de Uso publicados, versionados e com data de vigência.
  • Três consentimentos separados no formulário, nenhum pré-marcado, com o de dado sensível destacado.
  • consent_events gravando tipo, versão, hash do texto, canal, IP e user-agent.
  • Gatilho de imutabilidade de consent_events ativo; teste confirma que UPDATE da aplicação falha e que a anonimização pelo papel privacy_operator e o expurgo pelo papel retention_operator concluem sem exceção.
  • Encarregado nomeado, canal publicado e caixa monitorada.
  • Relatório de impacto elaborado e arquivado, descrevendo a decisão automatizada de 22.6.3 como tal.
  • Exportação de portabilidade funcionando, baixada dentro do painel sob sessão plena, com URL de 15 minutos e limite de downloads.
  • Anonimização testada de ponta a ponta em ambiente de homologação, com verificação de que as chaves estrangeiras seguem íntegras e de que nenhuma busca por telefone, wa_id ou e-mail original devolve linha em qualquer tabela, inclusive nos registros de mensagem.
  • Job de retenção agendado e testado em lote.
  • Destinatários e países listados nominalmente na Política de Privacidade.
  • Salvaguarda de transferência internacional comprovada e arquivada para cada um dos três destinatários que recebem dado pessoal.
  • Procedimento de incidente documentado, com os prazos de 22.7.8 e responsável nomeado.

Operação

  • SSH sem senha, sem root, com fail2ban e firewall ativos.
  • Postgres, Redis, /metrics e o painel de monitoramento inacessíveis a partir da internet; verificado por varredura externa de portas.
  • Papéis de banco separados, com app_user sem DDL e sem UPDATE nas tabelas append-only; ALTER DEFAULT PRIVILEGES aplicado, verificado por consulta a information_schema.
  • Contêineres rodando como usuário não-root, com raiz somente leitura.
  • MFA ativo em todos os provedores externos.
  • Alertas de segurança de 23.8 entregando no canal correto; teste real disparado.
  • Procedimento de saída de administrador escrito e com responsável designado.
  • Conta da plataforma de mensageria verificada, templates aprovados e número secundário registrado.
  • Licença comercial do provedor de voz conferida para uso do áudio gerado.
  • Versões bíblicas restritas à lista permitida, com atribuição por extenso visível na entrega e a sigla ARC ausente do código e da atribuição.

23. Observabilidade, Logs, Métricas e Alertas #

O produto envia mensagens uma vez por dia, em uma janela de poucos minutos, para milhares de pessoas. Se essa janela falha, ninguém percebe olhando a tela: o site continua no ar, o painel responde, e o único sinal é a ausência de uma mensagem que deveria ter chegado. Observabilidade aqui não é conforto de engenharia — é o único mecanismo capaz de detectar a falha mais grave do sistema. Toda decisão desta seção parte disso.

A pilha adotada roda no mesmo stack de Docker Compose descrito na Seção 25, em contêineres auxiliares acessíveis apenas pela rede interna e por túnel SSH (regra de exposição em 22.12.1): Prometheus para métricas, Grafana para painéis, Loki com o driver de log do Docker para logs, e Alertmanager para roteamento de alertas. Fora do servidor, um monitor externo verifica disponibilidade e um serviço de sinal de vida recebe o batimento do lote diário — os dois existem porque um servidor caído não consegue avisar que caiu.

23.1 Estratégia de logging #

23.1.1 Biblioteca e formato #

Biblioteca: pino, com pino-http no aplicativo web e o logger base compartilhado no worker e na CLI de operação (linhas de versão na Seção 4). Formato: JSON em uma linha por evento, escrito em stdout. Nada de arquivo de log gravado pela aplicação: o processo escreve em stdout, o Docker captura e o driver do Loki encaminha. Isso mantém o contêiner sem estado e elimina a classe inteira de problema de rotação dentro do contêiner.

Em desenvolvimento local, pino-pretty formata a saída para leitura humana. Em staging e production, sempre JSON puro — log colorido em produção quebra o parser.

Campos obrigatórios em todo evento de log, sem exceção:

Campo Tipo Origem Exemplo
time inteiro (epoch ms) pino 1787635200123
level inteiro, com levelLabel textual pino 30 / "info"
msg string curta e estável, em inglês, sem interpolação de valor código "whatsapp.send.failed"
service web | worker | ops configuração "worker"
env local | staging | production configuração "production"
version SHA curto do commit implantado build "a3f91c2"
requestId ULID da requisição ou do job contexto assíncrono "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6"
hostname nome do contêiner pino "worker-1"
pid inteiro pino 18

msg é uma chave estável em inglês, no formato dominio.acao.resultado, e nunca uma frase com valores embutidos. "whatsapp.send.failed" com errorCode: 131047 em campo próprio é consultável; "Falha ao enviar para +5511987654321: erro 131047" não é, e ainda vaza dado pessoal. Essa regra é o que torna o log agregável.

Campos adicionais conforme o contexto: subscriberId, devotionalId, subscriptionId, jobId, queue, attempt, durationMs, statusCode, method, path, provider, errorCode, errorName, traceId, spanId, batchId.

23.1.2 Níveis e quando usar cada um #

Nível Valor Quando usar Vai para alerta?
fatal 60 O processo não pode continuar: falta de segredo obrigatório, falha de migração, incapacidade de conectar ao banco na inicialização. O processo encerra logo em seguida. Sim, imediato
error 50 Operação falhou e não será recuperada automaticamente, ou esgotou as tentativas. Exemplo: envio que falhou nas três tentativas; webhook cujo processamento estourou a fila de retentativas. Sim, por limiar
warn 40 Comportamento anormal absorvido pelo sistema: uso do provedor de TTS de fallback, tentativa de envio recusada por erro esperado, violação de CSP, tentativa de autenticação de webhook malsucedida, invariante de métrica violada. Por limiar
info 30 Marcos de negócio: assinante confirmou opt-in, assinatura ativada, lote iniciado e concluído, devocional publicado, job concluído. Padrão em produção. Não
debug 20 Detalhe de execução útil em investigação: corpo de requisição já redigido, decisão de roteamento, resultado intermediário. Desligado em produção; ligável por assinante ou por rota (23.1.5). Não
trace 10 Somente desenvolvimento local. Nunca habilitado em produção. Não

Regra prática que evita os dois erros mais comuns: uma falha esperada e tratada é warn, não error — o erro 131047 da plataforma de mensageria, que indica janela de 24 h fechada, é comportamento previsto no desenho e registrado como warn. E um error sem ação humana possível é ruído: se ninguém pode fazer nada, o nível correto é warn.

23.1.3 Correlação entre web e worker #

O requestId nasce na borda HTTP: o middleware lê X-Request-Id se presente e válido, ou gera um ULID novo, e o devolve no header de resposta (convenção registrada na Seção 7). Dentro do processo, ele viaja por AsyncLocalStorage, de modo que nenhuma função precisa recebê-lo como parâmetro.

Quando a requisição enfileira um job, o requestId é copiado para os dados do job junto com o contexto de rastreamento. O worker restaura o contexto ao consumir:

// packages/core/src/observability/context.ts
import { AsyncLocalStorage } from 'node:async_hooks'

export type LogContext = {
  requestId: string
  traceparent?: string
  subscriberId?: string
  jobId?: string
  batchId?: string
}
export const logContext = new AsyncLocalStorage<LogContext>()

export const childLogger = () => baseLogger.child(logContext.getStore() ?? {})
// apps/web — ao enfileirar. O nome da fila é o canônico da Seção 18.8; não há apelido.
await queue.add('send.dispatch', {
  ...payload,
  _ctx: { requestId: current().requestId, traceparent: currentTraceparent() },
})

// apps/worker — ao consumir
worker.on('active', (job) => { /* contexto restaurado pelo wrapper abaixo */ })

export const withJobContext = <T>(job: Job, fn: () => Promise<T>) =>
  logContext.run(
    { requestId: job.data._ctx?.requestId ?? `job_${ulid()}`,
      traceparent: job.data._ctx?.traceparent,
      jobId: job.id, subscriberId: job.data.subscriberId },
    fn,
  )

Resultado prático: dado um requestId, uma única consulta no Loki devolve a linha do tempo completa — a requisição HTTP no web, o enfileiramento, a execução no worker, a chamada ao provedor externo e o webhook de status que chegou minutos depois, porque o webhook também recupera o requestId original a partir do identificador da mensagem.

Para o lote diário, que não nasce de requisição HTTP, o job de planejamento gera um batchId que é propagado a todos os jobs de envio derivados. Assim, batchId responde "o que aconteceu no envio de hoje" e requestId responde "o que aconteceu com este assinante".

23.1.4 Redação de dados sensíveis #

Nenhum log, em nenhum nível, em nenhum ambiente, pode conter: telefone, wa_id, CPF, e-mail, nome completo, código OTP, token de sessão, cabeçalho Authorization ou Cookie, chave de API, segredo de webhook, dado de cartão, corpo de mensagem recebida do assinante, o conteúdo do payload de tokenização de pagamento, ou qualquer URL assinada.

URL assinada de storage e URL de mídia da plataforma de mensagens são credenciais, não endereços: quem tem a URL tem o arquivo, e o padrão da URL ainda revela a estrutura do bucket. Nenhuma das duas é registrada em nenhum nível de log. A varredura semanal por telefone, CPF e e-mail nunca detectaria isso, porque URL não casa com nenhum desses padrões — por isso a proibição é explícita e tem padrão próprio. Logs referem-se a mídia por mediaKey — o caminho no bucket, sem host e sem assinatura — ou pelo identificador de mídia da plataforma.

A redação é aplicada em duas camadas, porque uma só sempre falha:

Camada 1 — redação declarativa do pino, por caminho:

// packages/core/src/observability/logger.ts
import pino from 'pino'

export const baseLogger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: SERVICE, env: ENV, version: BUILD_SHA },
  formatters: { level: (label, n) => ({ level: n, levelLabel: label }) },
  redact: {
    paths: [
      'req.headers.authorization', 'req.headers.cookie', 'req.headers["x-csrf-token"]',
      'req.headers["asaas-access-token"]', 'req.headers["x-hub-signature-256"]',
      'res.headers["set-cookie"]',
      'phone', 'phoneE164', 'phone_e164', 'waId', 'wa_id', 'to', 'from',
      'cpf', 'cpfCnpj', 'email', 'name', 'fullName',
      'otp', 'code', 'otpCode', 'token', 'accessToken', 'refreshToken',
      'apiKey', 'secret', 'password', 'creditCard', 'creditCardToken', 'holderName',
      'body.phone', 'body.cpf', 'body.email', 'body.name', 'body.code',
      '*.phone', '*.cpf', '*.email', '*.otp', '*.token',
      'inboundMessage.textBody', 'message.text.body',
      // URL assinada é credencial: quem tem a URL tem o arquivo.
      'url', 'mediaUrl', 'signedUrl', 'downloadUrl', 'audioUrl', 'videoUrl',
      'presignedUrl', 'invoiceUrl', '*.url',
    ],
    censor: '[REDACTED]',
  },
  serializers: {
    err: pino.stdSerializers.err,
    req: (req) => ({ method: req.method, path: req.url?.split('?')[0], id: req.id }),
    res: (res) => ({ statusCode: res.statusCode }),
  },
})

Reparar em dois detalhes deliberados: a query string é descartada do path registrado, porque parâmetros de busca podem conter telefone digitado por um administrador; e o serializador de requisição não registra o corpo, jamais — corpo só aparece em debug, já redigido, e debug está desligado em produção.

Camada 2 — identificadores derivados no lugar do dado. Quando o log precisa identificar uma pessoa, ele usa subscriberId (um ULID, que não revela nada sobre o titular) ou, quando o assinante ainda não existe, phoneFingerprint: os oito primeiros caracteres do índice cego descrito em 22.5.4. O fingerprint permite correlacionar tentativas do mesmo número sem armazenar o número, e não é reversível sem a chave, que não está no sistema de logs.

export const phoneFingerprint = (e164: string) => phoneIndex(e164).slice(0, 8)

Camada 3 — saneamento de texto livre. scrubText roda sobre qualquer string que chegue ao logger fora de um campo nomeado, e aplica também o padrão https?://\S*(X-Amz-Signature|access_token|lookaside\.fbsbx\.com)\S*, que captura a URL assinada mesmo quando ela aparece no meio de uma mensagem de erro de biblioteca de terceiro — o caminho mais comum de vazamento, porque nenhum campo nomeado a contém.

Verificação automatizada. Um teste da suíte (Seção 24) captura a saída do logger durante uma execução de ponta a ponta e falha se qualquer linha casar com os padrões de telefone brasileiro (\+?55\d{10,11}), CPF (\d{3}\.?\d{3}\.?\d{3}-?\d{2}), e-mail, sequência de seis dígitos isolada, ou o padrão de URL assinada acima. O mesmo teste cobre segredo: nenhuma linha pode conter o código de acesso gerado, o valor do token de sessão ou de refresh, nem o valor de qualquer variável de ambiente cujo nome termine em _TOKEN, _KEY, _SECRET ou _PASSWORD. Ele roda também com o assinante incluído na lista de depuração de 23.1.5, para cobrir o nível debug — um logger.debug acrescentado para depurar entrega de código sobrevive ao commit e só vaza quando alguém liga a depuração pontual, que é justamente o momento em que ninguém está olhando para isso. Além disso, uma consulta agendada semanal roda os mesmos padrões sobre os logs retidos e alerta em caso de correspondência — porque redação quebra silenciosamente quando alguém adiciona um campo novo.

Exemplos de log correto:

{"time":1787635212004,"level":30,"levelLabel":"info","service":"worker","env":"production",
 "version":"a3f91c2","requestId":"req_01J9K2M4R7T8V0X1Y2Z3A4B5C6","batchId":"bat_01J9K2…",
 "subscriberId":"sub_01J9K2…","msg":"whatsapp.send.succeeded","templateName":"devocional_diario_v1",
 "pricingCategory":"UTILITY","durationMs":312}
{"time":1787635213880,"level":40,"levelLabel":"warn","service":"worker","env":"production",
 "version":"a3f91c2","requestId":"req_01J9K2…","subscriberId":"sub_01J9K2…",
 "msg":"whatsapp.send.deferred","errorCode":131049,"reason":"ecosystem_health","retryOn":"next_day"}

23.1.5 Depuração pontual em produção #

Ligar debug globalmente em produção multiplicaria o volume por dez e vazaria contexto desnecessário. Em vez disso, existe amostragem dirigida: a chave ops.debug_subscriber_ids de settings (Seção 26.8.1) aceita uma lista de até 20 identificadores; quando o contexto de log tem um subscriberId dessa lista, o nível efetivo daquele fluxo cai para debug. A lista expira sozinha após 24 h — o campo guarda um carimbo de tempo junto —, e a inclusão de um identificador é registrada em admin_audit_log. O mesmo mecanismo existe por rota, sob ops.debug_paths.

Mesmo com a depuração ligada, a redação de 23.1.4 continua valendo integralmente. Nível debug aumenta o detalhe do fluxo, nunca a permissão de registrar dado pessoal ou segredo.

23.2 Retenção, volume e custo dos logs #

Estimativa de volume, feita para a base do primeiro ano (10.000 assinantes) e para o cenário dimensionado (50.000):

Fonte Linhas/dia (10 mil) Linhas/dia (50 mil)
Requisições HTTP (web), incluindo painéis e API 45.000 220.000
Envio diário: planejamento, envio, resultado, status 60.000 300.000
Webhooks recebidos (status e mensagens) 30.000 150.000
Jobs de manutenção, rollup, cobrança, TTS 3.000 6.000
Erros e avisos 1.500 8.000
Total aproximado 140.000 684.000

Com média de 600 bytes por linha JSON, são cerca de 84 MB/dia na escala do primeiro ano e 410 MB/dia na escala dimensionada. Comprimidos pelo Loki, a taxa típica fica em torno de 10:1, resultando em 8 MB/dia e 41 MB/dia respectivamente.

Política Valor Justificativa
Retenção no Loki 30 dias Cobre a investigação operacional normal e o ciclo de fechamento mensal
Arquivo comprimido em storage 180 dias Apuração de incidente e resposta a questionamento de titular
Registros de acesso (IP, data e hora) 6 meses Prazo do Marco Civil da Internet, replicado em 22.8
Descarte Automático, sem exceção Log retido além do necessário é passivo, não ativo

Custo: 30 dias de log comprimido ocupam cerca de 240 MB na escala do primeiro ano e 1,2 GB na escala dimensionada — desprezível no disco do VPS. O arquivo de 180 dias em storage fica abaixo de 8 GB no pior caso, com custo mensal de poucos reais. Não há custo de plataforma de logs, porque a pilha é auto-hospedada; essa foi a razão principal de escolher Loki em vez de um serviço cobrado por volume ingerido, cujo preço cresceria linearmente com a base.

Rotação: o Loki aplica a retenção por compactação automática. O driver de log do Docker é configurado com max-size: 20m e max-file: 3 como rede de segurança, para o caso de o Loki ficar indisponível e o buffer local crescer. Se o Loki cair, a aplicação continua escrevendo em stdout normalmente — logging nunca é caminho crítico, e uma falha do coletor jamais derruba o produto. O alerta loki_down (23.8) avisa que a visibilidade foi perdida.

Antes do descarte, o arquivamento roda diariamente às 04:20 e envia o bloco do dia anterior, comprimido e cifrado, para o mesmo bucket privado do storage de mídia, sob o prefixo logs/{YYYY}/{MM}/{DD}.jsonl.zst.age.

23.3 Instrumentação de métricas #

Cliente prom-client no web e no worker. O worker expõe um servidor HTTP interno com Fastify (linha de versão na Seção 4) na porta 9091, com os mesmos caminhos sob /api/internal/; o web expõe a rota GET /api/internal/metrics no mesmo processo Next.js, na porta interna 3000.

Item web worker
Endpoint GET /api/internal/metrics GET /api/internal/metrics na porta 9091
Exposição Somente rede interna do Docker; não publicado no proxy Idem
Autenticação Authorization: Bearer <METRICS_TOKEN>, comparado em tempo constante Idem
Formato Exposição de texto do Prometheus, versão 0.0.4 Idem
Métricas padrão de processo Habilitadas com prefixo nodejs_ Habilitadas
Intervalo de coleta 15 s 15 s

A dupla proteção — rede interna e token — existe porque o endpoint de métricas revela volume de negócio, nomes de fila e topologia. Se um erro de configuração expuser a porta, o token ainda protege.

Regra de cardinalidade, obrigatória: nenhum rótulo recebe valor não limitado. Proibidos como rótulo: subscriberId, phone, devotionalId, requestId, jobId e caminho de URL cru. O rótulo route usa o padrão da rota (/api/devotionals/[id]), nunca o caminho concreto. Um único rótulo de alta cardinalidade transforma o Prometheus em consumidor de memória sem limite. Uma verificação de inicialização percorre o registro de métricas e falha o processo se algum rótulo declarado estiver na lista negra.

23.4 Catálogo de métricas expostas #

23.4.1 HTTP #

Métrica Tipo Rótulos Significado
http_requests_total Counter method, route, status_code Requisições atendidas
http_request_duration_seconds Histogram method, route Latência; baldes 0.01, 0.05, 0.1, 0.2, 0.4, 0.8, 1.5, 3, 10
http_request_size_bytes Histogram route Tamanho do corpo recebido
http_response_size_bytes Histogram route Tamanho do corpo enviado
http_requests_in_flight Gauge route Requisições em processamento
http_rate_limited_total Counter route, dimension Bloqueios por limite de taxa (22.6.1)
csp_violation_total Counter directive Violações de política de conteúdo (22.3.1)
csrf_rejected_total Counter reason Rejeições por origem ou token inválido

Os baldes do histograma foram escolhidos em torno do alvo de p95 registrado na Seção 9.12, com resolução fina abaixo dele e grossa acima — baldes uniformes desperdiçariam precisão justamente na faixa que importa.

23.4.2 Banco de dados #

Métrica Tipo Rótulos Significado
db_query_duration_seconds Histogram model, operation Duração das operações do ORM
db_queries_total Counter model, operation, result Volume e taxa de erro
db_pool_connections Gauge state (active, idle, waiting) Ocupação do pool
db_pool_acquire_duration_seconds Histogram Espera por conexão; subida indica esgotamento
db_transaction_duration_seconds Histogram name Duração das transações nomeadas
db_deadlocks_total Counter Conflitos de bloqueio detectados
db_migration_pending Gauge 1 quando há migração não aplicada

23.4.3 Filas e jobs #

Métrica Tipo Rótulos Significado
queue_jobs_waiting Gauge queue Jobs aguardando
queue_jobs_active Gauge queue Jobs em execução
queue_jobs_delayed Gauge queue Jobs agendados para o futuro
queue_jobs_failed Gauge queue Jobs na lista de falhas
queue_job_duration_seconds Histogram queue, job_name Duração de execução
queue_job_wait_duration_seconds Histogram queue Tempo entre enfileirar e iniciar
queue_jobs_processed_total Counter queue, job_name, result (completed, failed) Volume processado
queue_job_retries_total Counter queue, job_name Retentativas
queue_oldest_waiting_age_seconds Gauge queue Idade do job mais antigo na espera; melhor sinal de fila travada que a profundidade
worker_concurrency Gauge queue Concorrência configurada

23.4.4 Integrações externas #

Métrica Tipo Rótulos Significado
external_request_duration_seconds Histogram provider, operation Latência das chamadas de saída
external_requests_total Counter provider, operation, status_class Volume e resultado
external_request_errors_total Counter provider, operation, error_kind (timeout, network, http_4xx, http_5xx) Falhas por natureza
circuit_breaker_state Gauge provider 0 fechado, 1 meio-aberto, 2 aberto
whatsapp_messages_sent_total Counter message_type, template_name, pricing_category, result Envios pela plataforma de mensageria
whatsapp_send_errors_total Counter error_code Erros nomeados da plataforma
whatsapp_message_status_total Counter status (sent, delivered, read, failed) Status recebidos por webhook
whatsapp_quality_rating Gauge phone_number_id 3 verde, 2 amarelo, 1 vermelho, 0 sinalizado
whatsapp_messaging_tier_limit Gauge phone_number_id Teto de destinatários únicos em 24 h
whatsapp_media_upload_total Counter result Envios de mídia para a plataforma
asaas_webhook_events_total Counter event_type, result Eventos de cobrança recebidos
asaas_webhook_lag_seconds Gauge Diferença entre o carimbo do evento e a hora de recebimento
asaas_reconcile_discrepancies_total Counter kind Divergências encontradas na conciliação diária
tts_generation_duration_seconds Histogram provider Duração da síntese de voz
tts_generation_total Counter provider, result Volume e resultado
tts_characters_total Counter provider Caracteres sintetizados
storage_operation_duration_seconds Histogram operation Latência do storage de mídia
email_sent_total Counter template, result E-mails transacionais
external_contract_validation_total Counter provider, operation, result Resultado da sonda de contrato (Seção 24.6.4)
external_contract_last_success_timestamp_seconds Gauge provider Última validação de contrato bem-sucedida

23.4.5 Negócio #

Gauges atualizados a cada 60 s por um coletor que lê daily_metrics — no formato longo definido pela Seção 6, com metric_key e dimension — e alguns contadores ao vivo. As definições e fórmulas são as da Seção 21.2; aqui elas apenas ganham exposição para alerta em tempo quase real. Nenhuma fórmula é redefinida.

Os gauges com sufixo _brl expõem valor em reais apenas para leitura humana no painel. A fonte é sempre inteiro: centavos em *_amount_cents e milionésimos em *_cost_micros (Seção 21.2). Nenhum cálculo monetário é feito sobre o valor exposto, e nenhum alerta compara dois valores em unidades diferentes sem conversão explícita.

Métrica Tipo Rótulos Significado
business_active_subscribers Gauge tier Assinantes ativos
business_active_subscriptions Gauge status Assinaturas por estado
business_mrr_brl Gauge Receita recorrente mensal
business_delivery_rate Gauge Taxa de entrega do dia anterior consolidado
business_optout_today_total Counter Opt-outs no dia corrente
business_signups_today_total Counter tier Cadastros confirmados no dia
business_daily_cost_brl Gauge kind Custo do dia por natureza
business_content_pipeline_days Gauge Dias de conteúdo pronto à frente
business_devotional_ready_today Gauge 1 se o devocional de hoje está pronto para envio
business_audio_ready_today Gauge 1 se o áudio de hoje está pronto

23.4.6 Lote diário e operação #

Métrica Tipo Rótulos Significado
send_batch_started_timestamp_seconds Gauge Início do lote mais recente
send_batch_completed_timestamp_seconds Gauge Conclusão do lote mais recente
send_batch_duration_seconds Gauge Duração do último lote
send_batch_planned_total Gauge tier Envios planejados no lote
send_batch_sent_total Gauge tier, strategy Envios executados por estratégia
send_batch_failed_total Gauge tier Falhas no lote
metrics_rollup_last_success_timestamp_seconds Gauge Última consolidação bem-sucedida (Seção 21.5)
metrics_sanity_violation_total Counter invariant Invariantes de métrica violadas (Seção 21.13.5)
signup_blocked_total Counter reason Cadastros barrados pelo controle de abuso (22.6.3)
webhook_auth_failed_total Counter provider Autenticações de webhook rejeitadas (22.6.4)
webhook_payload_rejected_total Counter provider, result Corpos de webhook rejeitados por schema ou parse, respondidos com 200 (Seção 7.15.1)
backup_last_success_timestamp_seconds Gauge Último backup concluído
retention_rows_purged_total Counter table Linhas removidas pela política de retenção (22.8)
admin_subscriber_viewed_total Counter actor_role Fichas de assinante abertas por administradores (23.11.1)
admin_pii_revealed_total Counter field Revelações de dado pessoal em tela (23.11.1)

As duas últimas existem porque um controle detectivo sem gatilho é um controle inexistente. O registro de SUBSCRIBER_VIEWED e PII_REVEALED é o único mecanismo capaz de detectar um administrador vasculhando a base; sem métrica e sem alerta, ele serve para a apuração posterior e não para a prevenção — o que, em um comprometimento de credencial administrativa, é a diferença entre notar em duas horas e notar em semanas.

23.5 Tracing distribuído #

SDK do OpenTelemetry para Node.js, com exportação por OTLP para o mesmo coletor que alimenta o Grafana. Instrumentação automática habilitada para HTTP, Prisma, ioredis e fetch; instrumentação manual nos pontos de negócio.

Spans mantidos manualmente:

Span Atributos Onde
signup.create_subscriber tier, hasEmail, riskScore web
auth.otp.request / auth.otp.verify channel, attempt web
checkout.create_subscription plan, billingType web
webhook.asaas.receive eventType, persisted web
webhook.whatsapp.receive kind (status, message), count web
batch.plan_daily devotionalDate, plannedCount worker
send.deliver_subscriber strategy, tier, messageCount worker
whatsapp.send_message messageType, templateName, pricingCategory worker
tts.generate provider, charCount, fallbackUsed worker
media.upload_to_meta sizeBytes, reused worker
billing.reconcile checked, discrepancies worker
metrics.rollup dates, rowCount worker

Propagação de contexto entre web, fila e worker: o cabeçalho traceparent do padrão W3C é capturado na borda HTTP, copiado para job.data._ctx.traceparent no enfileiramento (código em 23.1.3) e restaurado no worker como contexto pai. O span do worker fica assim ligado ao span da requisição que o originou, mesmo com horas de distância entre eles. Para o lote diário, o span raiz é criado pelo job de planejamento e cada envio vira um span filho, o que produz um único rastro com milhares de filhos — por isso o rastro do lote é amostrado de forma especial, como descrito abaixo.

Amostragem:

Situação Taxa
Requisições HTTP normais 10%, baseada no pai (ParentBased(TraceIdRatioBased(0.1)))
Requisições que terminaram em erro 5xx 100%, por amostragem tardia no coletor
Requisições com latência acima de 1 s 100%, por amostragem tardia
Webhooks 5%, mais 100% dos que falharam
Jobs de envio dentro do lote diário 1% dos envios, mais 100% das falhas; o span raiz do lote é sempre mantido
Jobs de manutenção, cobrança e TTS 100% (volume baixo)
Ambiente staging 100%

Atributos de span seguem a mesma proibição de dado pessoal dos logs (23.1.4): nunca telefone, CPF, e-mail ou conteúdo de mensagem. subscriberId é permitido, porque é um ULID opaco.

Retenção de rastros: 7 dias. Rastro é ferramenta de diagnóstico imediato; para análise histórica existem métricas e logs.

23.6 Health checks #

Dois endpoints, com propósitos deliberadamente diferentes. Confundi-los é o erro que faz um orquestrador reiniciar em laço um serviço saudável cujo banco está lento.

23.6.1 GET /api/internal/health — liveness #

Verifica apenas que o processo está vivo e capaz de responder. Não toca em nenhuma dependência. Sem autenticação, sem log (para não poluir o volume), resposta em menos de 5 ms, com limite de 60 requisições por minuto por endereço.

{ "status": "ok" }

O corpo é exatamente esse: sem version, sem service, sem uptimeSeconds. Os três campos pareciam inofensivos e não são: version e uptimeSeconds, consultados de fora, revelam o instante exato de cada implantação, que é a informação de que um atacante precisa para escolher a janela. Quem precisa da versão implantada consulta GET /api/internal/version, que não é público.

Sempre 200 enquanto o processo responder. Se não responder, o Docker reinicia o contêiner. Se /api/internal/health consultasse o banco, uma indisponibilidade momentânea do banco causaria reinício em massa de contêineres saudáveis — exatamente no pior momento.

23.6.2 GET /api/internal/ready — readiness #

Verifica se o processo pode receber tráfego agora. Consulta as dependências, com tempo limite curto e resultado em cache por 5 s para não transformar a sondagem em carga.

/api/internal/ready não é publicado pelo proxy. O monitor externo o consulta apresentando Authorization: Bearer <METRICS_TOKEN>; sem o token, o proxy nem encaminha a requisição. A matriz de checks, com latências e estado de disjuntor de cada provedor, só é devolvida com o token. O motivo é concreto: o estado de degradação de cada provedor é informação operacional que, exposta, entrega a um atacante o momento exato para uma campanha de phishing convincente — "seu pagamento falhou, atualize seu cartão", disparada justamente quando o pagamento está fora do ar.

Verificação Como Tempo limite Falha derruba a prontidão?
Banco de dados SELECT 1 1.000 ms Sim
Migrações Nenhuma migração pendente 500 ms Sim
Redis PING 500 ms Sim
Filas (apenas worker) Workers registrados e conectados 500 ms Sim
Storage de mídia HeadBucket 1.500 ms Não — degrada
Plataforma de mensageria Estado do disjuntor imediato Não — degrada
Provedor de TTS Estado do disjuntor imediato Não — degrada
Plataforma de pagamento Estado do disjuntor imediato Não — degrada

Resposta 200 quando tudo está ok ou apenas dependências não críticas estão degraded:

{
  "status": "degraded",
  "service": "web",
  "version": "a3f91c2",
  "checks": [
    { "name": "database", "status": "ok", "latencyMs": 3 },
    { "name": "migrations", "status": "ok", "pending": 0 },
    { "name": "redis", "status": "ok", "latencyMs": 1 },
    { "name": "storage", "status": "ok", "latencyMs": 42 },
    { "name": "whatsapp", "status": "degraded", "detail": "circuit_half_open" },
    { "name": "tts", "status": "ok" },
    { "name": "payments", "status": "ok" }
  ],
  "checkedAt": "2026-08-25T09:00:00.000Z"
}

Resposta 503 com o mesmo formato e "status": "unavailable" quando uma dependência crítica falha. O proxy retira a instância da rotação; nenhum reinício é acionado por /api/internal/ready.

Comportamento em dependência degradada, decidido explicitamente por serviço:

  • Plataforma de mensageria fora do ar: o web continua pronto — o site, o checkout e os painéis funcionam. O worker continua pronto e mantém os jobs de envio na fila com retentativa exponencial. O produto atrasa o envio; não o perde.
  • Provedor de TTS primário fora do ar: nenhum impacto na prontidão; o pipeline usa o provedor de fallback (Seção 16) e registra warn.
  • Storage fora do ar: o web fica degradado; o player web falha, mas o cadastro e o checkout seguem. O worker adia a geração de áudio.
  • Plataforma de pagamento fora do ar: o web fica degradado; o checkout exibe mensagem específica e o restante do produto funciona. Webhooks perdidos são recuperados pela conciliação diária (Seção 12).
  • Banco ou Redis fora do ar: 503. Não há operação possível sem eles.

23.7 Painéis de monitoramento #

Cinco painéis no Grafana, cada um respondendo a uma pergunta específica.

Painel Pergunta que responde Gráficos
Visão operacional O sistema está de pé agora? Prontidão de web e worker; taxa de requisições por status; p50/p95/p99 de latência HTTP; profundidade das quatro filas; idade do job mais antigo em espera; uso de CPU, memória e disco; conexões do banco
Lote diário O envio de hoje funcionou? Horário de início e de conclusão do lote; duração comparada aos 14 dias anteriores; planejados versus enviados versus falhos por tier; distribuição por estratégia de envio; taxa de envio por segundo ao longo da janela; falhas por código de erro; retentativas
Mensageria O canal está saudável? Qualidade do número ao longo do tempo; consumo do tier de mensagens contra o teto; volume por categoria de cobrança; status recebidos por webhook; latência da API externa; estado do disjuntor; erros por código
Integrações e jobs As integrações estão respondendo? Latência e taxa de erro por provedor; eventos de cobrança recebidos e atraso do webhook; divergências da conciliação; duração e resultado dos jobs; profundidade da lista de falhas; gerações de TTS por provedor
Negócio em tempo quase real Os números fazem sentido hoje? Assinantes ativos por tier; MRR; cadastros e opt-outs do dia; taxa de entrega consolidada; custo do dia por natureza; dias de conteúdo pronto; horário da última consolidação de métricas

O painel administrativo do produto (Seção 21.8) e o Grafana têm públicos diferentes e não se sobrepõem: o primeiro é para decisão de negócio, com definições estáveis e histórico longo; o segundo é para diagnóstico técnico, com granularidade de segundos e retenção curta. Números de negócio apresentados no Grafana carregam a nota "valor operacional; a fonte oficial é o painel administrativo".

Retenção do Prometheus: 90 dias em disco local, com regras de gravação que pré-agregam as séries usadas nos painéis de longo prazo, para manter a consulta rápida.

23.8 Alertas #

Roteamento pelo Alertmanager. Canais: #alertas-p1, #alertas-p2 e #alertas-negocio no Slack, via webhook de entrada; e-mail para a lista de plantão pelo provedor de e-mail transacional; e, apenas para P1, ligação telefônica automática disparada pelo monitor externo para o número do responsável técnico. O canal de alerta nunca é o WhatsApp do produto — usar o número comercial para operação interna coloca em risco a reputação que sustenta o negócio.

Severidades:

Sev. Significado Resposta esperada
P1 O produto está quebrado ou vai quebrar em horas, com dano irreversível ou perda de entrega para todos Imediata, inclusive fora do horário
P2 Degradação relevante ou risco que se materializa em dias Mesmo dia útil, até 4 h dentro do horário
P3 Anomalia que merece investigação, sem urgência Próximo dia útil

Toda regra tem for: — um período mínimo de persistência antes de disparar —, porque alerta que dispara em pico instantâneo treina a equipe a ignorá-lo. Todo alerta carrega no corpo: valor observado, limiar, janela, link para o painel filtrado e link para o runbook correspondente na Seção 27.

Regra de formato, obrigatória. Toda linha traz a expressão completa na coluna de condição, mesmo quando a definição de negócio vive em outra seção; a referência cruzada aparece como comentário ao lado da expressão, nunca no lugar dela. Um alerta cuja condição não pode ser lida no catálogo não pode ser revisado na revisão trimestral, e um alerta que não é revisado acaba silenciado em definitivo.

# Nome Condição exata Sev. Canal Destinatário Resposta Runbook (Seção 27)
1 daily_batch_not_started time() - send_batch_started_timestamp_seconds > 3600 avaliado às 06:10 America/Sao_Paulo; ou sinal de vida do lote não recebido até 06:10 P1 Slack P1 + e-mail + ligação Plantão técnico 15 min daily-batch-not-started
2 daily_batch_not_completed send_batch_completed_timestamp_seconds < send_batch_started_timestamp_seconds por mais de 45 min após o início P1 Slack P1 + e-mail + ligação Plantão técnico 15 min daily-batch-stuck
3 daily_batch_slow send_batch_duration_seconds > 1200 (alvo de 20 min registrado na Seção 18.14) P2 Slack P2 Plantão técnico 4 h daily-batch-slow
4 daily_batch_partial_warn send_batch_failed_total / send_batch_planned_total > 0.01 ou send_batch_failed_total > 50, avaliado durante o lote a partir de 300 tentativas registradas P2 Slack P2 Produto 4 h send-failures
4b daily_batch_partial send_batch_failed_total / send_batch_planned_total > 0.03 ou send_batch_failed_total > 200, avaliado durante o lote a partir de 300 tentativas registradas P1 Slack P1 + e-mail Plantão técnico 30 min send-failures
5 whatsapp_send_failure_rate_high sum(rate(whatsapp_messages_sent_total{result="failed"}[15m])) / sum(rate(whatsapp_messages_sent_total[15m])) > 0.10 por 10 min, excluídos os códigos esperados 131049 e 131050 P1 Slack P1 + e-mail Plantão técnico 30 min send-failures
6 whatsapp_quality_degraded whatsapp_quality_rating <= 2 por 5 min P2 Slack P2 + e-mail Plantão técnico e Produto 4 h whatsapp-quality
7 whatsapp_quality_critical whatsapp_quality_rating <= 1 por 5 min P1 Slack P1 + e-mail + ligação Plantão técnico e Owner 15 min whatsapp-quality
8 whatsapp_tier_near_limit send_batch_planned_total > whatsapp_messaging_tier_limit * 0.8 na hora do planejamento P2 Slack P2 Plantão técnico 4 h whatsapp-tier-upgrade
9 whatsapp_rate_limited increase(whatsapp_send_errors_total{error_code="130429"}[10m]) > 20 P2 Slack P2 Plantão técnico 1 h whatsapp-rate-limit
10 whatsapp_template_rejected increase(whatsapp_send_errors_total{error_code=~"132.*"}[10m]) > 5 P1 Slack P1 + e-mail Plantão técnico e Produto 30 min template-problem
11 queue_backlog_growing queue_jobs_waiting > 1000 e derivada positiva por 10 min, fora da janela de envio P2 Slack P2 Plantão técnico 1 h queue-backlog
12 queue_job_stuck queue_oldest_waiting_age_seconds > 900 por 5 min P2 Slack P2 Plantão técnico 1 h queue-stuck-job
13 queue_failed_jobs_high queue_jobs_failed > 100 por 15 min P2 Slack P2 Plantão técnico 4 h queue-failed-jobs
14 worker_down up{job="worker"} == 0 por 2 min P1 Slack P1 + e-mail + ligação Plantão técnico 15 min service-down
15 web_down Monitor externo com 3 falhas consecutivas em /api/internal/health (intervalo de 60 s) P1 Slack P1 + e-mail + ligação Plantão técnico 15 min service-down
16 asaas_webhook_stalled Nenhum evento em asaas_webhook_events_total por 6 h em dia útil, tendo havido cobranças com vencimento no dia P1 Slack P1 + e-mail Plantão técnico e Financeiro 30 min asaas-webhook-paused
17 asaas_webhook_auth_failures increase(webhook_auth_failed_total{provider="asaas"}[15m]) > 10 P2 Slack P2 Plantão técnico 4 h webhook-auth-failures
18 asaas_webhook_lag_high asaas_webhook_lag_seconds > 900 por 15 min P2 Slack P2 Plantão técnico 4 h asaas-webhook-lag
19 billing_reconcile_discrepancy increase(asaas_reconcile_discrepancies_total[1h]) > 5 após a conciliação das 04:00 P2 Slack P2 + e-mail Financeiro e Plantão técnico 4 h billing-reconciliation
20 billing_reconcile_not_run Sem execução bem-sucedida de billing.reconcile nas últimas 26 h P2 Slack P2 Plantão técnico 4 h job-not-running
21 devotional_missing_today business_devotional_ready_today == 0 avaliado às 20:00 do dia anterior e às 05:00 do dia P1 Slack P1 + e-mail + ligação Editorial e Owner 15 min content-missing
22 content_pipeline_low business_content_pipeline_days < 3 por 1 h P1 Slack P1 + e-mail Editorial e Owner 4 h content-pipeline
23 audio_missing_today business_audio_ready_today == 0 às 05:00 do dia do envio P1 Slack P1 + e-mail Plantão técnico e Editorial 30 min audio-not-generated
24 tts_failure_rate_high sum(rate(tts_generation_total{result="failed"}[1h])) / sum(rate(tts_generation_total[1h])) > 0.3 P2 Slack P2 Plantão técnico 4 h tts-failures
25 tts_fallback_in_use increase(tts_generation_total{provider="google"}[24h]) > 3 P3 Slack negócio Plantão técnico Próximo dia útil tts-fallback
26 http_5xx_rate_high sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.02 por 5 min P1 Slack P1 + e-mail Plantão técnico 15 min http-5xx
27 http_latency_high histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) > 0.8 por 10 min (alvo de 400 ms na Seção 9.12) P2 Slack P2 Plantão técnico 4 h latency-high
28 webhook_handler_slow p99 de http_request_duration_seconds nas rotas de webhook acima de 2 s por 5 min P1 Slack P1 Plantão técnico 30 min webhook-slow
29 db_connections_exhausted db_pool_connections{state="waiting"} > 5 por 5 min, ou db_pool_acquire_duration_seconds p95 acima de 1 s P1 Slack P1 + e-mail Plantão técnico 15 min db-pool-exhausted
30 db_slow_queries p95 de db_query_duration_seconds acima de 500 ms por 15 min P2 Slack P2 Plantão técnico 4 h db-slow
31 db_deadlocks increase(db_deadlocks_total[15m]) > 3 P2 Slack P2 Plantão técnico 4 h db-deadlocks
32 disk_space_low Espaço livre no volume de dados abaixo de 20% por 10 min P2 Slack P2 + e-mail Plantão técnico 4 h disk-space
33 disk_space_critical Espaço livre abaixo de 10% P1 Slack P1 + e-mail + ligação Plantão técnico 15 min disk-space
34 memory_pressure Uso de memória do contêiner acima de 90% do limite por 10 min P2 Slack P2 Plantão técnico 4 h memory-pressure
35 tls_certificate_expiring Validade do certificado abaixo de 21 dias P2 Slack P2 + e-mail Plantão técnico 1 dia útil tls-renewal
36 tls_certificate_expiring_critical Validade abaixo de 7 dias P1 Slack P1 + e-mail Plantão técnico 4 h tls-renewal
37 backup_missing time() - backup_last_success_timestamp_seconds > 93600 (26 h) P1 Slack P1 + e-mail Plantão técnico 4 h backup-failure
38 metrics_rollup_failed time() - metrics_rollup_last_success_timestamp_seconds > 108000 (30 h) P2 Slack P2 Plantão técnico 4 h metrics-rollup
39 metrics_sanity_violation increase(metrics_sanity_violation_total[24h]) > 0 P2 Slack P2 Plantão técnico 4 h metrics-sanity
40 daily_cost_over_budget sum(business_daily_cost_brl) > ops.cost_alert_threshold_cents / 100 por 1 h — a chave é de settings (Seção 26.8.1) e a divisão por 100 é a conversão explícita de centavos para reais P2 Slack P2 + e-mail Owner e Plantão técnico 4 h cost-overrun
41 signup_abuse_spike increase(signup_blocked_total[1h]) > 50 P2 Slack P2 Plantão técnico 4 h signup-abuse
42 rate_limit_spike increase(http_rate_limited_total[15m]) > 500 P3 Slack negócio Plantão técnico Próximo dia útil rate-limit-spike
43 csp_violations_spike increase(csp_violation_total[1h]) > 100 P3 Slack negócio Plantão técnico Próximo dia útil csp-violations
44 secret_rotation_due Verificação mensal: algum segredo com rotação vencida conforme 22.4.2 P3 Slack negócio + e-mail Owner 5 dias úteis secret-rotation
45 loki_down up{job="loki"} == 0 por 10 min P3 Slack negócio Plantão técnico Próximo dia útil observability-down
46 prometheus_down Monitor externo não recebe o batimento do Prometheus por 15 min P2 Slack P2 Plantão técnico 4 h observability-down
47 biz_optout_spike increase(business_optout_today_total[24h]) / business_active_subscribers > 0.005 — mais de 0,5% da base saindo em um dia (definição de negócio na Seção 21.12) P1 Slack P1 + e-mail Produto e Conteúdo 4 h optout-spike
48 biz_conversion_drop Cadastros confirmados nos últimos 7 dias abaixo de 50% da média dos 28 dias anteriores (definição de negócio na Seção 21.12) P3 Slack negócio Produto Próximo dia útil conversion-drop
49 biz_delivery_rate_low business_delivery_rate < 0.95 no dia consolidado (definição de negócio na Seção 21.12) P1 Slack P1 Plantão técnico 4 h delivery-rate-low
50 biz_churn_spike Cancelamentos nos últimos 7 dias acima do dobro da média das 4 semanas anteriores, com no mínimo 10 cancelamentos (definição de negócio na Seção 21.12) P2 Slack P2 + e-mail Produto e Owner 1 dia útil churn-spike
51 webhook_silence Nenhum evento de cobrança processado com sucesso nos últimos 90 minutos entre 08:00 e 22:00 America/Sao_Paulo P1 Slack P1 + e-mail + ligação Plantão técnico 15 min asaas-webhook-paused
52 webhook_payload_rejected increase(webhook_payload_rejected_total[15m]) > 3 P2 Slack P2 Plantão técnico 4 h webhook-auth-failures
53 analytics_no_events Nenhum evento de produto recebido nos últimos 60 min entre 08:00 e 22:00 P2 Slack negócio Produto 1 dia útil csp-violations
54 admin_bulk_pii_access increase(admin_subscriber_viewed_total[1h]) > 100 ou increase(admin_pii_revealed_total[1h]) > 20 pelo mesmo administrador P1 Slack P1 + e-mail Owner e Plantão técnico 15 min admin-bulk-access
55 admin_access_off_hours Qualquer abertura de ficha de assinante entre 00:00 e 05:00 America/Sao_Paulo P3 Slack negócio Owner Próximo dia útil admin-bulk-access
56 impersonation_long_running Sessão de impersonação ativa há mais de 30 minutos P2 Slack P2 Owner 4 h admin-bulk-access
57 external_contract_drift increase(external_contract_validation_total{result="invalid"}[30m]) > 2 P1 Slack P1 + e-mail Plantão técnico 30 min contract-drift
58 clock_drift Desvio do relógio do servidor acima de 2 segundos em relação à fonte de tempo — bem antes da tolerância de 5 s do verificador de sessão (Seção 8.15.5) P2 Slack P2 Plantão técnico 4 h clock-drift

Regras de agrupamento e silenciamento no Alertmanager, para que o canal continue sendo lido:

  • Agrupamento por alertname e service, com group_wait: 30s, group_interval: 5m e repeat_interval: 4h para P1, 12h para P2 e 24h para P3.
  • Inibição: quando worker_down ou web_down está ativo, todos os alertas derivados daquele serviço (fila, latência, lote) são suprimidos. Um serviço caído gera dezenas de sintomas; alertar sobre os sintomas esconde a causa.
  • Silenciamento planejado: janelas de manutenção são registradas antes da mudança, com duração máxima de 2 h e motivo obrigatório. Silenciamento sem prazo é proibido.
  • Alerta que dispara sem ação possível é removido ou tem o limiar corrigido. A revisão dos alertas é item da análise pós-incidente (23.10) e da revisão trimestral.
  • Sinal de vida do lote diário (dead man's switch): o serviço externo de batimento espera um sinal do job de planejamento todo dia até as 06:10. A ausência do sinal dispara o alerta 1 por um caminho totalmente independente do servidor — é o único alerta que funciona mesmo com o VPS inteiro fora do ar.

Três alertas desta tabela existem por uma razão que merece registro explícito, porque é a mesma em todos: eles detectam falha silenciosa, o estado em que todos os painéis mostram verde e nada funciona.

  • webhook_silence é o único detector de assinatura de webhook desativada pelo provedor. Perdido o canal de eventos, nenhuma inadimplência chega, e assinantes sem pagamento continuam recebendo conteúdo pago por dias — sem nenhum outro sinal, porque a reconciliação diária corrige o estado sem alertar que os eventos pararam.
  • analytics_no_events detecta a CSP bloqueando o analytics. Sem ele, as telas de métricas mostram zero e ninguém sabe se é falta de tráfego ou falha técnica.
  • external_contract_drift detecta a mudança de formato no provedor externo. Uma sonda que valida apenas leituras baratas fica verde enquanto o corpo do webhook de cobrança já mudou; por isso a sonda usa os mesmos schemas do processamento de produção, nunca schemas próprios, que se tornariam uma segunda verdade e mascarariam a divergência (Seção 24.6.4).

23.9 Política de plantão #

Decisão registrada: não há plantão 24 horas por 7 dias no MVP. Uma equipe pequena não sustenta rodízio noturno, e fingir que sustenta produz alertas ignorados às três da manhã, o que é pior do que não alertar. A política abaixo é o que efetivamente será cumprido.

Janela Cobertura Canal ativo Tempo de resposta
05:45–08:00, todos os dias Janela crítica do envio diário. Uma pessoa de plantão explicitamente designada, com o celular ao alcance Slack P1 com notificação sonora, e-mail, ligação P1 em 15 min
09:00–19:00, dias úteis Horário comercial. Equipe disponível Slack P1 e P2, e-mail P1 em 15 min; P2 em 4 h
08:00–12:00, sábado Meia jornada. Uma pessoa acompanha os canais Slack P1 P1 em 1 h
Domingo, 05:45–09:00 Janela crítica ampliada: domingo é o dia de envio do plano gratuito e o de maior volume Slack P1 com notificação sonora, ligação P1 em 15 min
Demais horários Sem cobertura garantida. Alertas P1 continuam sendo enviados e disparam ligação; o atendimento é por melhor esforço Slack P1, ligação Melhor esforço; atendimento garantido no início da janela seguinte

O que acontece fora do horário, de forma explícita:

  1. Alertas P1 continuam disparando, inclusive a ligação telefônica. Quem estiver disponível atende. Não há obrigação contratual de atender, e não há penalização por não atender.
  2. Alertas P2 e P3 são enfileirados e apresentados como resumo no início da janela seguinte. O Alertmanager usa uma rota com active_time_intervals para isso.
  3. O sistema é desenhado para tolerar a ausência. A fila retém os jobs; a retentativa é exponencial e persistente; a conciliação diária recupera webhooks perdidos; o envio atrasado ainda é enviado. A pergunta de projeto para toda falha é "isso se resolve sozinho até as 09:00?" — quando a resposta é não, existe um mecanismo automático de contenção.
  4. A janela de envio tem prioridade sobre tudo. Um incidente às 06:00 é tratado; um incidente às 23:00 espera, salvo perda de dados ou exposição de dados pessoais, que são as duas exceções que acionam contato imediato com o responsável, a qualquer hora.
  5. Deploy é proibido entre 05:00 e 08:00, aos domingos, e após as 17:00 de sexta-feira. Não se muda o sistema quando não há quem conserte.

A disponibilidade alvo de 99,5% mensal registrado na Seção 9.12 é compatível com esta política: 99,5% permite cerca de 3 h 39 min de indisponibilidade por mês, e a janela sem cobertura raramente coincide com uso relevante — o tráfego do produto se concentra entre 06:00 e 09:00 e no início da noite.

Rodízio: a designação de plantão da janela crítica é semanal, publicada com uma semana de antecedência, com um substituto nomeado. Quem está de plantão tem acesso completo aos runbooks da Seção 27, aos segredos necessários e ao servidor.

23.10 Análise pós-incidente #

Obrigatória para todo incidente P1, para todo incidente que afete dados pessoais (com o rito adicional de 22.7.8) e para todo P2 que se repita três vezes em 30 dias. Prazo: rascunho em 48 h, versão final em 5 dias úteis.

A análise é sem atribuição de culpa. O objetivo é encontrar a condição do sistema que permitiu a falha, não a pessoa que a acionou. Uma análise que termina em "erro humano" está incompleta por definição: a pergunta seguinte é sempre por que o sistema permitiu que aquele erro produzisse aquele efeito.

Modelo do relatório:

# Incidente <AAAA-MM-DD>-<sequencial><título curto e factual>

## Resumo
Duas ou três frases. O que quebrou, por quanto tempo, quem foi afetado.

## Severidade e impacto
- Severidade: P1 | P2
- Início: <timestamp> · Detecção: <timestamp> · Mitigação: <timestamp> · Resolução: <timestamp>
- Tempo até detectar: <min> · Tempo até mitigar: <min> · Duração total: <min>
- Assinantes afetados: <n> (<%> da base)
- Mensagens não entregues: <n> · Receita afetada: R$ <valor>
- Houve acesso ou exposição de dado pessoal? Sim | Não. Se sim, referência ao registro de 22.7.8.

## Linha do tempo
| Horário (America/Sao_Paulo) | Evento | Fonte |
|---|---|---|
| 06:02 | Lote diário iniciado | log `batch.plan.started`, batchId bat_01J9… |
| 06:07 | Alerta `daily_batch_partial` disparado | Alertmanager |

## Causa raiz
O que de fato causou. Sem eufemismo. Inclui a cadeia de condições, não só o gatilho.

## Detecção
Como descobrimos. O alerta funcionou? Disparou no tempo certo? Se a descoberta veio de um
assinante ou de inspeção manual, isso é uma falha de observabilidade e vira item de ação.

## Mitigação
O que foi feito para parar o sangramento e por que funcionou.

## Recuperação da entrega
As mensagens não entregues foram reenviadas? Sim | Não | Parcial. Quando. Quantas.
Se não, a justificativa explícita, aprovada por Produto.

## Comunicação
Os assinantes afetados foram avisados? Sim | Não. Canal e texto utilizado (Seção 10),
seguindo a ordem de canais da Seção 27.7. Havendo exposição de dado pessoal, referência
ao rito de 22.7.8 e à decisão sobre comunicação à Autoridade e aos titulares.

## O que funcionou bem
Registrar também o que salvou o dia: retentativa, fallback, conciliação, limite de taxa.

## Itens de ação
| # | Ação | Tipo (prevenir \| detectar \| mitigar) | Responsável | Prazo | Situação |
|---|---|---|---|---|---|
| 1 | … | prevenir | … | AAAA-MM-DD | aberto |

## Lições
O que sabemos agora que não sabíamos antes.

Regras de qualidade da análise: pelo menos um item de ação da categoria detectar — se a detecção foi boa, o item é registrar por que foi, para não a perder; todo item tem responsável nomeado e prazo; itens vencidos aparecem na revisão mensal; e o relatório é arquivado em local acessível a toda a equipe, com índice por causa raiz, de modo que padrões repetidos fiquem visíveis.

Todo incidente que envolva não entrega registra explicitamente a decisão de reenvio, aprovada por Produto, e a decisão de comunicar ou não os afetados. As duas seções acima existem porque a falha clássica é outra: o relatório é escrito no prazo, aprovado, e ninguém decidiu se as pessoas que não receberam o devocional daquele dia receberiam algo — a fila já foi limpa na mitigação, e o silêncio, numa base religiosa diária, é exatamente o que gera cancelamento.

23.11 Auditoria #

Auditoria é diferente de log: log é diagnóstico técnico, com retenção curta e conteúdo redigido; auditoria é registro de responsabilidade sobre ações administrativas, com retenção de 5 anos (22.8) e valor probatório.

23.11.1 O que é auditado #

Toda ação administrativa que altere estado, acesse dado pessoal ou mude configuração:

Grupo Ações
Autenticação administrativa ADMIN_LOGIN_SUCCESS, ADMIN_LOGIN_FAILED, ADMIN_LOGOUT, ADMIN_MFA_ENROLLED, ADMIN_MFA_RESET
Gestão de administradores ADMIN_CREATED, ADMIN_ROLE_CHANGED, ADMIN_DISABLED, ADMIN_PASSWORD_RESET
Assinantes SUBSCRIBER_VIEWED, SUBSCRIBER_UPDATED, SUBSCRIBER_TIER_CHANGED, SUBSCRIBER_OPTOUT_SET, SUBSCRIBER_REACTIVATED, SUBSCRIBER_ANONYMIZED, SUBSCRIBER_FULLY_ANONYMIZED, ADMIN_IMPERSONATION_STARTED, ADMIN_IMPERSONATION_ENDED
Dado pessoal PII_REVEALED (telefone ou CPF exibido em tela), SUBSCRIBER_DATA_EXPORTED, DATA_EXPORT_DOWNLOADED, METRICS_EXPORTED
Cobrança SUBSCRIPTION_CANCELED_BY_ADMIN, REFUND_ISSUED, PAYMENT_MARKED_MANUALLY, PLAN_PRICE_CHANGED
Conteúdo DEVOTIONAL_CREATED, DEVOTIONAL_UPDATED, DEVOTIONAL_STATUS_CHANGED, DEVOTIONAL_DELETED, AUDIO_REGENERATED, MEDIA_UPLOADED
Envio MANUAL_SEND_TRIGGERED, BATCH_RETRIED, SUBSCRIBER_RESEND_FORCED
Configuração SETTING_CHANGED, FEATURE_FLAG_TOGGLED, TEMPLATE_REGISTERED, SECRET_ROTATED
Operação METRICS_ROLLUP_FORCED, RETENTION_JOB_FORCED, MAINTENANCE_WINDOW_OPENED
Privacidade DSR_RECEIVED, DSR_FULFILLED, CONSENT_REVOKED_BY_ADMIN, ACCESS_REVIEW_COMPLETED

SUBSCRIBER_VIEWED merece justificativa: registrar a simples abertura da ficha de um assinante gera volume, mas é o único controle capaz de detectar um administrador vasculhando a base sem motivo. O registro é agregado por sessão e por assinante em janelas de 15 minutos, para não multiplicar linhas em uma navegação normal. Ele alimenta as métricas de 23.4.6 e os alertas 54 a 56 de 23.8 — sem gatilho, o registro serviria só para a apuração posterior.

ADMIN_IMPERSONATION_STARTED e ADMIN_IMPERSONATION_ENDED são ações auditadas de primeira classe, com reason obrigatório. Além do registro administrativo, a sessão de impersonação aparece para o próprio assinante, na lista Acessos do suporte da tela Meus dados, com data, duração e justificativa — nunca o nome do operador, que é dado do administrador. O Art. 18 dá ao titular o direito de saber como seus dados foram tratados, e uma sessão de suporte dentro da conta dele é tratamento: esconder isso tornaria o direito inexequível.

23.11.2 Formato do registro #

Os nomes de coluna abaixo são exatamente os da Seção 6.9, que é a dona da definição física de admin_audit_log. Esta subseção não renomeia nada: não existem colunas actor_admin_id, before_json, after_json, target_type, target_id, actor_email, created_at_utc nem created_at_brt.

Campo Descrição
id ULID
created_at timestamptz, sempre em UTC. As colunas em horário de Brasília que aparecem na exportação CSV (15.10.3) são projeções de apresentação, não colunas do banco
actor_type ADMIN, SUBSCRIBER ou SYSTEM. Distingue "foi o sistema" de "foi o próprio assinante", o que admin_user_id nulo sozinho não faz
admin_user_id Autor da ação; nulo quando actor_type não é ADMIN
actor_role Papel efetivo no momento (EDITOR, ADMIN, OWNER, SYSTEM)
action Valor da lista de 23.11.1
entity_type subscriber, devotional, subscription, setting, admin_user, …
entity_id Identificador do alvo
changed_fields Lista dos campos alterados, para filtrar a auditoria sem abrir o JSON
before Estado anterior dos campos alterados, com dado pessoal já mascarado
after Estado posterior, idem
metadata Dados estruturados específicos da ação, por exemplo days na concessão de cortesia. Nunca contém dado pessoal em texto claro
reason Justificativa, obrigatória em ações destrutivas ou de revelação de dado pessoal
ip Endereço de origem
user_agent Agente de origem
request_id Correlaciona com os logs técnicos (23.1.3)
record_hash Encadeamento de integridade descrito em 23.11.3

O e-mail do autor não é persistido: ele é resolvido a partir de admin_user_id no momento da exportação. Guardar o e-mail duplicaria dado pessoal em uma tabela de 5 anos de retenção sem acrescentar prova nenhuma.

before e after guardam apenas os campos alterados, e valores sensíveis entram mascarados: o registro prova que o telefone foi alterado e mostra +55 11 *****-4321 antes e +55 11 *****-9876 depois, sem reintroduzir dado em texto claro em uma tabela de 5 anos de retenção. Para PII_REVEALED, o registro guarda qual campo foi revelado e o motivo, nunca o valor.

Exemplo:

{
  "id": "aud_01J9K2M4R7T8V0X1Y2Z3A4B5C6",
  "createdAt": "2026-08-25T14:22:07.331Z",
  "actorType": "ADMIN", "adminUserId": "adm_01J8…", "actorRole": "ADMIN",
  "action": "SUBSCRIBER_TIER_CHANGED",
  "entityType": "subscriber", "entityId": "sub_01J9…",
  "changedFields": ["tier"],
  "before": { "tier": "PAID" },
  "after": { "tier": "FREE" },
  "reason": "Chargeback confirmado pelo processador; protocolo 88213.",
  "ip": "189.45.12.203",
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
  "requestId": "req_01J9K2M4R7T8V0X1Y2Z3A4B5C6"
}

23.11.3 Imutabilidade #

Mesmo tratamento de consent_events (22.7.3), com o mesmo gatilho de exceções codificadas: o usuário de banco da aplicação tem INSERT e SELECT, e não tem UPDATE nem DELETE; o gatilho barra alteração mesmo por papel privilegiado; e a remoção só ocorre pelo job de retenção, executado pela conexão do papel retention_operator (22.12.2), restrita a linhas com mais de 5 anos. Nenhum gatilho é desabilitado em momento algum — a exceção vive dentro dele, verificada linha a linha.

Camada adicional de detecção de adulteração: cada linha guarda record_hash, calculado como SHA-256 do conteúdo canônico da linha concatenado ao record_hash da linha anterior — uma cadeia. Um trabalho semanal recalcula a cadeia do período e alerta em caso de divergência.

const canonical = (r: AuditRow) => JSON.stringify([
  r.id, r.createdAt.toISOString(), r.actorType, r.adminUserId, r.actorRole, r.action,
  r.entityType, r.entityId, r.changedFields, r.before, r.after, r.metadata,
  r.reason, r.ip, r.requestId,
])
const recordHash = (r: AuditRow, prevHash: string) =>
  createHash('sha256').update(prevHash + canonical(r)).digest('hex')

A cadeia não impede a adulteração — quem controla o banco pode recalcular tudo. O que ela faz é tornar a adulteração detectável por quem tem a cópia do último hash, que é registrada diariamente no arquivo de logs cifrado e no relatório de backup. Essa é a propriedade que interessa em uma apuração.

23.11.4 Consulta #

Tela /admin/auditoria, restrita a ADMIN e OWNER. EDITOR não tem acesso. Filtros: intervalo de datas, autor, ação, tipo de entidade e identificador de entidade. Paginação por cursor, conforme o padrão da Seção 7 — a resposta não devolve total; contagens agregadas vêm dos endpoints de métricas. Ordenação padrão: mais recente primeiro.

A tela de um assinante traz o histórico de auditoria daquele assinante em aba própria, o que responde diretamente à pergunta mais comum do suporte: "quem mexeu nisto e por quê". Consultar a auditoria não gera um registro de auditoria — isso criaria recursão sem valor; o acesso à tela é registrado no log técnico, que basta.

A leitura da auditoria é servida por GET /api/admin/audit-logs, e a exportação por POST /api/admin/audit-logs/exports, exclusiva de OWNER, formato CSV ou JSON, limite de 90 dias por exportação, com a própria exportação registrada como METRICS_EXPORTED — porque exportar a auditoria é, ela mesma, uma ação auditável.

24. Estratégia de Testes e QA #

Esta seção define o que é testado, com que ferramenta, com que meta de cobertura e com que critério de aprovação. Nenhum código entra em main sem passar por ela. As versões das ferramentas estão na Seção 4 e não são repetidas aqui.

24.1 Princípios #

  1. O domínio é testado exaustivamente; a borda é testada por contrato. A lógica que decide quem recebe o quê, quando e em que tier vive em packages/core e é pura. Testar pureza é barato. É onde a densidade de testes deve ser maior.
  2. Nenhum teste automatizado fala com a internet. Asaas, Meta e o provedor de TTS são sempre simulados. Um teste que abre socket externo é um teste que quebra no CI por motivo errado.
  3. Nenhum teste automatizado envia mensagem real de WhatsApp. A única exceção é a bateria manual de pré-lançamento da Seção 24.11, executada por pessoa, com número de teste declarado.
  4. Banco de verdade em testes de integração. SQLite em memória não reproduz timestamptz, índices únicos parciais, enums nativos nem ON CONFLICT. Todo teste de integração roda contra PostgreSQL real em contêiner.
  5. Teste determinístico. Nenhum Date.now() direto no código de domínio: tudo passa por uma interface Clock injetável (ver Seção 24.7.2). Nenhum Math.random() sem seed. Nenhuma dependência de ordem de execução entre arquivos de teste.
  6. Falha de teste é bloqueio, não aviso. Não existe teste marcado como skip em main. Um teste instável é corrigido ou removido no mesmo dia, com issue aberta.

24.2 Pirâmide de testes e metas de cobertura #

                     ┌───────────────────────────┐
                     │   E2E (Playwright)        │  ~20 cenários
                     │   ~5% dos testes          │  ~10 min de execução
                     ├───────────────────────────┤
                  ┌──┤   Contrato (mock server)  │  ~34 testes
                  │  │   ~4% dos testes          │  ~40 s
                  │  ├───────────────────────────┤
              ┌───┴──┤   Integração (PG + Redis) │  ~130 testes
              │      │   ~16% dos testes         │  ~3 min
              │      ├───────────────────────────┤
    ┌─────────┴──────┤   Unitários (Vitest)      │  ~620 testes
    │                │   ~75% dos testes         │  ~25 s
    └────────────────┴───────────────────────────┘

Metas de cobertura por camada, aplicadas como gate no CI:

Alvo Linhas Ramos Funções Justificativa da meta
packages/core/** 95% 90% 95% Código puro, sem I/O. Contém entitlements, máquina de estados, normalização de telefone e cálculo de lote. Um erro aqui cobra dinheiro errado ou entrega conteúdo errado a milhares de pessoas. Cobertura alta é barata e o retorno é máximo.
packages/integrations/** 85% 75% 85% Mapeamento de payloads externos e classificação de erros. A rede é simulada, então o que se testa é tradução e tratamento de erro — exatamente o que quebra em produção. Os 15% descobertos são wrappers de SDK sem lógica.
packages/db/** 60% 50% 60% Majoritariamente cliente Prisma gerado e seeds. Cobrir código gerado não produz valor. O que importa aqui é validado nos testes de integração.
apps/worker/src/jobs/** 90% 80% 90% Cada job é um efeito colateral com dinheiro ou reputação envolvida. Retry, idempotência e classificação de falha precisam de teste explícito.
apps/worker/** (restante) 80% 70% 80% Bootstrap, registro de filas e servidor de health. Coberto por integração.
apps/web/src/app/api/** 85% 75% 85% Handlers HTTP: validação Zod, autorização, envelope de resposta e catálogo de erros.
apps/web/src/lib/** 85% 75% 85% Utilitários de sessão, formatação e adaptação de payload.
apps/web/src/components/** sem gate numérico Componentes de UI são validados por E2E e por inspeção visual. Perseguir cobertura de linha em JSX gera testes que afirmam que o React renderiza React.
apps/ops/** 70% 60% 70% CLI de operação. O valor está nos comandos de correção, cobertos por integração; o parser de argumentos é trivial.
Gate global do repositório 85% 78% 85% Média ponderada realista das metas acima.

O gate global é verificado por vitest --coverage com provider v8. O CI falha se qualquer meta por alvo for violada, mesmo que a média global passe. Cobertura não pode cair entre commits: o CI compara com o valor gravado em coverage/baseline.json e falha se a queda for maior que 0,5 ponto percentual.

Exclusões de cobertura declaradas (e apenas estas): arquivos *.d.ts, *.config.*, packages/db/generated/**, **/index.ts que só reexporta, apps/web/src/app/**/layout.tsx, apps/web/src/app/**/loading.tsx, **/*.stories.tsx, e arquivos sob **/testing/** (o próprio ferramental de teste).

24.3 Ferramentas e configuração #

Necessidade Ferramenta Onde roda
Testes unitários e de integração Vitest local + CI
Cobertura @vitest/coverage-v8 local + CI
Banco e Redis para integração Testcontainers (@testcontainers/postgresql, @testcontainers/redis) local + CI
Simulação de HTTP externo (unitário) MSW (msw) em modo Node local + CI
Simulação de HTTP externo (integração/E2E) servidor de simulação próprio em packages/integrations/src/testing/mock-server.ts local + CI
E2E de navegador Playwright local + CI
Carga k6 (binário) local + máquina de staging
Lint ESLint (flat config) + eslint-plugin-vitest local + CI
Formatação Prettier local + CI
Tipos tsc --noEmit por workspace local + CI
Varredura de dependências pnpm audit + osv-scanner CI
Varredura de segredos gitleaks CI + hook de pre-commit
Propriedades e fuzzing fast-check local + CI

24.3.1 vitest.config.ts (raiz do monorepo) #

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    globals: false,
    reporters: process.env.CI ? ['default', 'junit'] : ['default'],
    outputFile: { junit: './reports/vitest-junit.xml' },
    pool: 'threads',
    poolOptions: { threads: { singleThread: false, maxThreads: 8 } },
    coverage: {
      provider: 'v8',
      reporter: ['text-summary', 'json-summary', 'lcov'],
      reportsDirectory: './coverage',
      all: true,
      exclude: [
        '**/*.d.ts',
        '**/*.config.*',
        'packages/db/generated/**',
        '**/testing/**',
        '**/*.stories.tsx',
        'apps/web/src/app/**/layout.tsx',
        'apps/web/src/app/**/loading.tsx',
        '**/index.ts',
      ],
      thresholds: {
        lines: 85,
        branches: 78,
        functions: 85,
        'packages/core/src/**': { lines: 95, branches: 90, functions: 95 },
        'packages/integrations/src/**': { lines: 85, branches: 75, functions: 85 },
        'apps/worker/src/jobs/**': { lines: 90, branches: 80, functions: 90 },
        'apps/web/src/app/api/**': { lines: 85, branches: 75, functions: 85 },
      },
    },
    projects: [
      {
        extends: true,
        test: {
          name: 'unit',
          include: ['packages/*/src/**/*.test.ts', 'apps/*/src/**/*.test.ts'],
          environment: 'node',
          setupFiles: ['./tools/testing/setup-unit.ts'],
          testTimeout: 5_000,
        },
      },
      {
        extends: true,
        test: {
          name: 'integration',
          include: ['tests/integration/**/*.int.test.ts'],
          environment: 'node',
          globalSetup: ['./tools/testing/global-setup-containers.ts'],
          setupFiles: ['./tools/testing/setup-integration.ts'],
          testTimeout: 30_000,
          hookTimeout: 120_000,
          fileParallelism: true,
          maxConcurrency: 4,
        },
      },
      {
        extends: true,
        test: {
          name: 'contract',
          include: ['tests/contract/**/*.contract.test.ts'],
          environment: 'node',
          setupFiles: ['./tools/testing/setup-contract.ts'],
          testTimeout: 15_000,
        },
      },
    ],
  },
});

24.3.2 tools/testing/setup-unit.ts #

import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server as mswServer } from './msw-server';

process.env.TZ = 'America/Sao_Paulo';

beforeAll(() => {
  mswServer.listen({ onUnhandledRequest: 'error' });
});

afterEach(() => {
  mswServer.resetHandlers();
  vi.useRealTimers();
  vi.restoreAllMocks();
});

afterAll(() => {
  mswServer.close();
});

onUnhandledRequest: 'error' é obrigatório. Ele garante o princípio 2 da Seção 24.1: qualquer chamada de rede não declarada derruba o teste em vez de vazar para a internet.

24.3.3 tools/testing/global-setup-containers.ts #

import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { RedisContainer, type StartedRedisContainer } from '@testcontainers/redis';
import { execFileSync } from 'node:child_process';
import type { GlobalSetupContext } from 'vitest/node';

let pg: StartedPostgreSqlContainer;
let redis: StartedRedisContainer;

export async function setup({ provide }: GlobalSetupContext) {
  pg = await new PostgreSqlContainer('postgres:17-alpine')
    .withDatabase('palavra_diaria_test')
    .withUsername('test')
    .withPassword('test')
    // Testes não precisam de durabilidade. Isso corta ~40% do tempo da suíte.
    .withCommand(['postgres', '-c', 'fsync=off', '-c', 'synchronous_commit=off',
                  '-c', 'full_page_writes=off', '-c', 'max_connections=200'])
    .start();

  redis = await new RedisContainer('redis:8-alpine').start();

  const databaseUrl = pg.getConnectionUri();
  process.env.DATABASE_URL = databaseUrl;

  // Schema aplicado uma única vez por execução da suíte, a partir das migrations reais.
  execFileSync('pnpm', ['--filter', '@palavra-diaria/db', 'exec', 'prisma', 'migrate', 'deploy'], {
    env: { ...process.env, DATABASE_URL: databaseUrl },
    stdio: 'inherit',
  });

  provide('databaseUrl', databaseUrl);
  provide('redisUrl', redis.getConnectionUrl());
}

export async function teardown() {
  await redis?.stop();
  await pg?.stop();
}

Rodar prisma migrate deploy — e não db push — é decisão deliberada: garante que a sequência real de migrations que vai para produção é executável do zero. Uma migration quebrada é detectada no CI, não no deploy.

24.3.4 playwright.config.ts (em apps/web) #

import { defineConfig, devices } from '@playwright/test';

const PORT = Number(process.env.E2E_PORT ?? 3100);
const BASE_URL = `http://127.0.0.1:${PORT}`;

export default defineConfig({
  testDir: './tests/e2e',
  outputDir: './test-results',
  fullyParallel: false,          // cenários compartilham o mesmo banco efêmero
  workers: 1,
  retries: process.env.CI ? 1 : 0,
  timeout: 60_000,
  expect: { timeout: 10_000 },
  reporter: process.env.CI
    ? [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'reports/e2e-junit.xml' }]]
    : [['list']],
  use: {
    baseURL: BASE_URL,
    locale: 'pt-BR',
    timezoneId: 'America/Sao_Paulo',
    trace: 'retain-on-failure',
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
    actionTimeout: 10_000,
  },
  projects: [
    { name: 'setup', testMatch: /global\.setup\.ts/ },
    {
      name: 'chromium-desktop',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
    {
      name: 'mobile-android',
      use: { ...devices['Pixel 7'] },
      dependencies: ['setup'],
      testMatch: /.*\.mobile\.spec\.ts/,
    },
  ],
  webServer: [
    {
      command: 'pnpm --filter @palavra-diaria/integrations mock:serve',
      url: 'http://127.0.0.1:4010/__mock/health',
      reuseExistingServer: !process.env.CI,
      timeout: 30_000,
    },
    {
      command: `pnpm --filter @palavra-diaria/web start -p ${PORT}`,
      url: `${BASE_URL}/api/internal/health`,
      reuseExistingServer: !process.env.CI,
      timeout: 120_000,
      env: {
        NODE_ENV: 'production',
        E2E_TEST_HOOKS: 'true',
        E2E_HOOK_SECRET: 'e2e-local-secret',
        ASAAS_API_BASE_URL: 'http://127.0.0.1:4010/asaas/v3',
        WHATSAPP_API_BASE_URL: 'http://127.0.0.1:4010/meta/v26.0',
        ELEVENLABS_BASE_URL: 'http://127.0.0.1:4010/elevenlabs/v1',
      },
    },
  ],
});

Decisão: o E2E roda contra o build de produção (next start), não contra next dev. Modo de desenvolvimento tem comportamento de hidratação e de cache diferente; testar o artefato que vai para produção é o único jeito de o teste significar alguma coisa.

24.3.5 Scripts do package.json da raiz #

{
  "scripts": {
    "lint": "eslint . --max-warnings=0",
    "format:check": "prettier --check .",
    "typecheck": "pnpm -r exec tsc --noEmit",
    "test": "vitest run --project unit",
    "test:watch": "vitest --project unit",
    "test:integration": "vitest run --project integration",
    "test:contract": "vitest run --project contract",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "pnpm --filter @palavra-diaria/web exec playwright test",
    "test:e2e:ui": "pnpm --filter @palavra-diaria/web exec playwright test --ui",
    "test:load": "k6 run tools/load/daily-batch.js",
    "test:all": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm test:integration && pnpm test:contract && pnpm test:e2e"
  }
}

24.4 Testes unitários — módulos críticos #

Cada módulo abaixo é obrigatório. Um pull request que altere qualquer um deles sem alterar o arquivo de teste correspondente é rejeitado automaticamente pela regra descrita na Seção 24.12.

24.4.1 packages/core/src/phone.ts — normalização de telefone brasileiro #

Contrato da função:

export type PhoneError =
  | 'PHONE_REQUIRED'
  | 'PHONE_INVALID'
  | 'PHONE_TOO_SHORT'
  | 'PHONE_TOO_LONG'
  | 'PHONE_INVALID_AREA_CODE'
  | 'PHONE_NOT_MOBILE'
  | 'PHONE_COUNTRY_NOT_SUPPORTED';

export type PhoneResult =
  | { ok: true; e164: string; countryCode: '55'; areaCode: string; localNumber: string }
  | { ok: false; code: PhoneError };

export function normalizePhone(input: string | null | undefined): PhoneResult;

/** Variantes aceitáveis para casar com o `wa_id` devolvido pela Meta. */
export function waIdVariants(e164: string): string[];

Algoritmo obrigatório, na ordem:

  1. Rejeitar null, undefined e string que, após trim(), seja vazia → PHONE_REQUIRED.
  2. Normalizar Unicode com String.prototype.normalize('NFKC') (converte dígitos de largura total em ASCII) e remover caracteres de controle e de largura zero.
  3. Remover prefixo de esquema tel: ou whatsapp: se presente.
  4. Guardar se havia + inicial. Remover todo caractere que não seja dígito.
  5. Se a string de dígitos começar com 00, remover os dois primeiros dígitos e tratar como discagem internacional.
  6. Se não havia + e a string tiver 10 ou 11 dígitos, assumir DDI 55. Se tiver 11 ou 12 dígitos começando com 0 (prefixo de operadora), remover o 0 e assumir DDI 55.
  7. Aplicar parsePhoneNumberFromString de libphonenumber-js com região padrão BR.
  8. Se o país resolvido não for BRPHONE_COUNTRY_NOT_SUPPORTED.
  9. Validar o DDD contra a lista fechada de DDDs válidos do Brasil. Fora dela → PHONE_INVALID_AREA_CODE.
  10. Se o número nacional tiver 10 dígitos (DDD + 8) e o primeiro dígito do assinante for 6, 7, 8 ou 9, inserir o nono dígito 9. Se for 2, 3, 4 ou 5, é fixo → PHONE_NOT_MOBILE.
  11. Se o número nacional tiver 11 dígitos e o primeiro do assinante não for 9PHONE_NOT_MOBILE.
  12. Emitir +55 + DDD + 9 dígitos.

Lista fechada de DDDs válidos (constante BR_AREA_CODES, testada por igualdade de conjunto): 11–19, 21, 22, 24, 27, 28, 31–35, 37, 38, 41–49, 51, 53, 54, 55, 61–69, 71, 73, 74, 75, 77, 79, 81–89, 91–99.

Tabela de casos obrigatórios. Cada linha vira um caso de it.each. O celular de referência é (11) 98765-4321; o fixo de referência é (11) 3123-4567.

# Entrada Resultado esperado Motivo
1 "11987654321" +5511987654321 Nacional, 11 dígitos, já com nono dígito
2 "+5511987654321" +5511987654321 E.164 canônico, sem alteração
3 "5511987654321" +5511987654321 DDI sem +
4 "(11) 98765-4321" +5511987654321 Formatação brasileira usual
5 "(11) 9 8765-4321" +5511987654321 Nono dígito destacado
6 "11 98765 4321" +5511987654321 Espaços como separador
7 "11.98765.4321" +5511987654321 Pontos como separador
8 "+55 (11) 98765-4321" +5511987654321 Mistura de +, parênteses e hífen
9 "011987654321" +5511987654321 Prefixo 0 de discagem
10 "0 11 98765-4321" +5511987654321 Prefixo 0 com espaço
11 "0219876543211" PHONE_INVALID Código de operadora (021) não é suportado; regra explícita
12 "005511987654321" +5511987654321 Discagem internacional com 00
13 "1187654321" +5511987654321 10 dígitos, celular legado; nono dígito inserido
14 "551187654321" +5511987654321 12 dígitos com DDI; nono dígito inserido
15 "+551187654321" +5511987654321 E.164 sem nono dígito (formato que a Meta às vezes devolve)
16 "1131234567" PHONE_NOT_MOBILE Fixo de 8 dígitos começando em 3
17 "+551131234567" PHONE_NOT_MOBILE Fixo em E.164
18 "11987654321 " +5511987654321 Espaço à direita
19 " 11987654321" +5511987654321 Espaço à esquerda
20 "+5511987654321\n" +5511987654321 Quebra de linha colada do WhatsApp
21 "<ZWSP>+5511987654321" +5511987654321 Espaço de largura zero (U+200B)
22 "+5511987654321" +5511987654321 Dígitos de largura total (teclado iOS/japonês)
23 "tel:+5511987654321" +5511987654321 Esquema tel:
24 "whatsapp:+5511987654321" +5511987654321 Esquema whatsapp: (formato de outros provedores)
25 "📱 (11) 98765-4321" +5511987654321 Emoji e ruído descartados
26 "Meu zap é 11 98765-4321" +5511987654321 Texto ao redor; só dígitos importam
27 "11 98765-4321 ramal 22" PHONE_TOO_LONG Ramal gera dígitos extras; rejeitar em vez de adivinhar
28 "" PHONE_REQUIRED Vazio
29 " " PHONE_REQUIRED Só espaços
30 null PHONE_REQUIRED Nulo
31 undefined PHONE_REQUIRED Indefinido
32 "abcdefghij" PHONE_INVALID Sem dígito nenhum
33 "999" PHONE_TOO_SHORT Curto demais
34 "+5511987" PHONE_TOO_SHORT E.164 truncado
35 "+55119876543210000" PHONE_TOO_LONG Excede 15 dígitos de E.164
36 "+14155552671" PHONE_COUNTRY_NOT_SUPPORTED DDI 1 (EUA) — aceito pelo schema, rejeitado no cadastro
37 "+351912345678" PHONE_COUNTRY_NOT_SUPPORTED DDI 351 (Portugal)
38 "+5491123456789" PHONE_COUNTRY_NOT_SUPPORTED DDI 54 (Argentina) — confusão comum com 55
39 "+5510987654321" PHONE_INVALID_AREA_CODE DDD 10 não existe
40 "+5520987654321" PHONE_INVALID_AREA_CODE DDD 20 não existe
41 "+5523987654321" PHONE_INVALID_AREA_CODE DDD 23 não existe
42 "+5552987654321" PHONE_INVALID_AREA_CODE DDD 52 não existe
43 "+5599987654321" +5599987654321 DDD 99 (MA) é válido — não confundir com lixo
44 "+5568987654321" +5568987654321 DDD 68 (AC) é válido
45 "+5511587654321" PHONE_NOT_MOBILE 11 dígitos mas começa em 5: não é celular
46 "+5511087654321" PHONE_NOT_MOBILE 11 dígitos começando em 0
47 "+55011987654321" PHONE_INVALID 0 logo após o DDI
48 "11987654321" normalizado duas vezes mesma saída Idempotência

Casos obrigatórios adicionais, fora da tabela:

  • Propriedade de idempotência, verificada com fast-check sobre 1.000 entradas geradas: para todo input cujo resultado seja ok, normalizePhone(result.e164) devolve o mesmo e164.
  • waIdVariants('+5511987654321') devolve exatamente ['5511987654321', '551187654321'], nessa ordem (com nono dígito primeiro).
  • waIdVariants('+551133334444') — para número que não é celular a função devolve apenas ['551133334444'], sem inventar variante.
  • waIdVariants para DDD ≥ 31: o comportamento é idêntico ao de DDD ≤ 30. A regra do nono dígito é nacional; não existe exceção por região no código.
  • Ordem de resolução do assinante (testada em packages/core/src/subscriber-lookup.ts, com repositório falso): dado um webhook com wa_id = '551187654321' e um assinante cujo telefone normalizado é +5511987654321 e que ainda não tem wa_id, a busca precisa encontrá-lo pela variante sem nono dígito e, como efeito colateral, gravar o wa_id. Um segundo webhook com o mesmo wa_id precisa resolver na primeira tentativa, sem tocar nas variantes. Toda busca é por índice cego: a consulta compara wa_id_hmac e phone_hmac, nunca a coluna cifrada (Seção 6.3). Um teste afirma que nenhuma consulta do repositório de assinantes contém WHERE phone_e164 = ou WHERE wa_id =.

24.4.2 packages/core/src/cpf.ts — validação de CPF #

export function isValidCpf(input: string): boolean;
export function normalizeCpf(input: string): string | null; // 11 dígitos, sem máscara
export function formatCpf(digits: string): string;          // 000.000.000-00

Regras: remover máscara; exigir exatamente 11 dígitos; rejeitar as onze sequências de dígitos repetidos; validar os dois dígitos verificadores pelo algoritmo de módulo 11.

Entrada isValidCpf Motivo
"529.982.247-25" true CPF válido com máscara
"52998224725" true Mesmo CPF sem máscara
" 529 982 247 25 " true Espaços descartados
"529.982.247-24" false Segundo dígito verificador errado
"529.982.247-15" false Primeiro dígito verificador errado
"111.111.111-11" false Sequência repetida (passa no módulo 11, mas é inválida)
"000.000.000-00" false Sequência repetida
"999.999.999-99" false Sequência repetida
"12345678909" true CPF de teste clássico, matematicamente válido
"1234567890" false 10 dígitos
"123456789012" false 12 dígitos
"" false Vazio
"abcdefghijk" false Sem dígitos
"529.982.247-2A" false Letra no lugar de dígito
"52998224725" com largura zero no fim true Caractere invisível removido antes da validação
"CPF: 529.982.247-25" false normalizeCpf só aceita a string do campo, não texto livre

Caso obrigatório extra: formatCpf('52998224725') === '529.982.247-25' e normalizeCpf(formatCpf(x)) === x para 200 CPFs válidos gerados.

Caso obrigatório de segurança: o CPF nunca aparece em log. O teste cpf-never-logged.test.ts instala um transporte de log falso, executa o fluxo de criação de cliente na Asaas com CPF 52998224725 e afirma que nenhuma linha emitida contém a substring 52998224725 nem 529.982.247-25. O campo é redigido como ***.***.***-25 (apenas os dois últimos dígitos), conforme a política de redação da Seção 22.

24.4.2.1 secrets-never-logged.test.ts — nenhum segredo vai para o log #

O CPF é apenas um dos dados que não podem vazar por log, e é o único que tinha teste. Este arquivo cobre o resto e é bloqueador de pull request.

Ele instala o mesmo transporte de log falso e executa, em sequência: emissão e verificação de código de acesso; criação, rotação e revogação de sessão; uma chamada a cada provedor externo simulado; e a geração de uma URL assinada de mídia. Depois afirma que nenhuma linha emitida contém:

  1. o código de acesso gerado, em nenhuma das suas formas (123456, 123 456, 123-456);
  2. o valor do token de sessão nem o do token de renovação;
  3. o valor de qualquer variável de ambiente cujo nome termine em _TOKEN, _KEY, _SECRET ou _PASSWORD — a lista é derivada de process.env em tempo de execução, e não escrita à mão, para que uma variável nova nasça coberta;
  4. a substring X-Amz-Signature, o host de mídia da plataforma de mensagens, nem qualquer URL contendo access_token=.

O teste roda duas vezes: uma com o nível de log em info e outra com o assinante incluído na lista de depuração da Seção 23.1.5, que liga o nível debug. Sem essa segunda execução, um logger.debug({ otpPlain, phone }) acrescentado para investigar um problema de entrega passaria despercebido — e é exatamente ao investigar entrega de código que alguém coloca o assinante na lista de depuração. A proibição da regra 12 da Seção 30.3 vale também em desenvolvimento.

24.4.3 packages/core/src/entitlements.tsresolveEntitlements #

Fonte única da verdade dos entitlements, cuja matriz é definida na Seção 13. Nenhum outro módulo pode recalcular. O teste principal é uma tabela de estados de entrada para saída esperada.

export type Entitlements = {
  tier: 'FREE' | 'PAID';
  sendsDaily: boolean;
  sendWeekday: number | null;   // 0..6, null quando diário
  hasAudio: boolean;
  archiveDays: number | null;   // null = ilimitado
  manualResendsPerDay: number;
  supportSla: 'FAQ_EMAIL' | 'EMAIL_1_BUSINESS_DAY';
};

export function resolveEntitlements(input: {
  tier: 'FREE' | 'PAID';
  subscriptionStatus: SubscriptionStatus | null;
  currentPeriodEnd: Date | null;
  optOutAt: Date | null;
  deletedAt: Date | null;
  now: Date;
}): Entitlements;
Cenário tier status current_period_end Saída esperada
Assinante gratuito comum FREE null null FREE, semanal domingo, sem áudio, acervo 7 dias, 1 reenvio
Pago em dia PAID ACTIVE futuro PAID, diário, com áudio, acervo ilimitado, 3 reenvios
Pago com período vencido no relógio PAID ACTIVE passado FREE — o vencimento manda, mesmo com tier desatualizado no registro
Cancelou, ciclo ainda pago PAID CANCELED futuro PAID — ele pagou por este período (Seção 13)
Cancelou, ciclo terminou PAID CANCELED passado FREE
Inadimplente PAID EXPIRED qualquer FREE — revogação imediata, sem carência
Reembolsado PAID REFUNDED futuro FREE — revogação imediata mesmo com período em aberto
Aguardando primeiro pagamento FREE PENDING_PAYMENT null FREE — só paga depois do PAYMENT_CONFIRMED
Opt-out ativo PAID ACTIVE futuro PAID nos entitlements, mas o bloqueio de envio é do motor de envio (Seção 18), não dos entitlements
Excluído (soft delete) PAID ACTIVE futuro Lança EntitlementsError('SUBSCRIBER_DELETED') — estado impossível de ser consultado

Casos de borda obrigatórios:

  • current_period_end exatamente igual a now (milissegundo idêntico): trata-se como ainda válido. A comparação é currentPeriodEnd >= now, não >. Um teste fixa isso para impedir que alguém "otimize" o operador.
  • tier = 'PAID' com subscriptionStatus = null: estado impossível. A função lança EntitlementsError('INCONSISTENT_STATE'). Um assinante PAID sem assinatura é bug de escrita, não caso a tratar silenciosamente.
  • Fuso: now em 2026-08-25T02:30:00Z é 2026-08-24T23:30:00-03:00. Um current_period_end de 2026-08-25T00:00:00-03:00 ainda é futuro. O teste usa esses valores exatos para impedir comparação ingênua em UTC de calendário.
  • A saída é congelada: Object.isFrozen(resolveEntitlements(...)) === true. Ninguém muta entitlements depois de resolvidos.

24.4.4 packages/core/src/subscription-state.ts — máquina de estados #

export function applySubscriptionEvent(
  current: SubscriptionStatus,
  event: SubscriptionEventType,
): { next: SubscriptionStatus; tierAfter: 'FREE' | 'PAID' } | { error: 'INVALID_TRANSITION' };

Matriz completa de transições. Linha = estado atual, coluna = evento. significa transição inválida, que deve devolver INVALID_TRANSITION e não lançar exceção (o processador de webhook registra e descarta, conforme a Seção 12).

De \ Evento PAYMENT_CONFIRMED PAYMENT_RECEIVED PAYMENT_OVERDUE PAYMENT_DELETED PAYMENT_REFUNDED PAYMENT_CHARGEBACK_REQUESTED CANCEL_REQUESTED PERIOD_ENDED
PENDING_PAYMENT ACTIVE / PAID ACTIVE / PAID EXPIRED / FREE EXPIRED / FREE CANCELED / FREE EXPIRED / FREE
ACTIVE ACTIVE / PAID ACTIVE / PAID EXPIRED / FREE EXPIRED / FREE REFUNDED / FREE EXPIRED / FREE CANCELED / PAID EXPIRED / FREE
CANCELED ACTIVE / PAID ACTIVE / PAID EXPIRED / FREE REFUNDED / FREE EXPIRED / FREE EXPIRED / FREE
EXPIRED ACTIVE / PAID ACTIVE / PAID REFUNDED / FREE
REFUNDED

Casos obrigatórios:

  • Renovação normal: ACTIVE + PAYMENT_CONFIRMEDACTIVE/PAID. O teste também afirma que current_period_end avança exatamente 1 mês para plan_monthly e 1 ano para plan_annual, calculado com date-fns em America/Sao_Paulo.
  • Cancelamento não revoga imediatamente: ACTIVE + CANCEL_REQUESTEDCANCELED/PAID. É o único caso em que o estado deixa de ser ACTIVE e o tier permanece PAID. Esse teste é a garantia contra a confusão entre "cancelou" e "inadimplente".
  • Inadimplência revoga imediatamente: ACTIVE + PAYMENT_OVERDUEEXPIRED/FREE. O teste afirma que não existe estado intermediário e que não há nenhum campo do tipo grace_until no resultado.
  • REFUNDED é terminal: todos os oito eventos aplicados a REFUNDED devolvem INVALID_TRANSITION. Um it.each percorre a lista inteira de eventos.
  • Reativação após expiração: EXPIRED + PAYMENT_CONFIRMEDACTIVE/PAID. Um assinante que voltou a pagar recupera o acesso na mesma transação.
  • Idempotência do evento: aplicar PAYMENT_CONFIRMED duas vezes a ACTIVE produz o mesmo estado e o mesmo current_period_end quando o event.id é o mesmo. O teste de integração da Seção 24.5 cobre a persistência; o unitário cobre a função pura.
  • Cobertura total da matriz: um teste gera o produto cartesiano estados × eventos (5 × 8 = 40 combinações) e verifica que cada célula corresponde à tabela acima. Se alguém adicionar um estado ou evento sem atualizar a matriz, o teste falha por contagem.

24.4.5 packages/core/src/teaser.ts — sanitização do teaser do template #

O teaser é o parâmetro {{2}} do template diário. A Meta rejeita parâmetro com quebra de linha, tabulação ou 4 ou mais espaços consecutivos, e o corpo do template tem limite de 1024 caracteres. A função a seguir é a única fonte de teaser válido.

export function sanitizeTeaser(raw: string, maxLength = 300): string;
export function isTemplateParamSafe(value: string): boolean;

Passos obrigatórios, na ordem: normalizar NFC; substituir \r\n, \r, \n, \t e espaço não separável por espaço simples; colapsar 2 ou mais espaços em um; remover marcação Markdown (**, *, _, #, >, crase, links [texto](url) viram texto); remover caracteres de controle e de largura zero; trim(); truncar em maxLength na fronteira da última palavra completa, removendo pontuação pendente e acrescentando reticências quando houve truncamento.

Entrada Saída esperada
"A graça basta." "A graça basta."
"Linha um\nLinha dois" "Linha um Linha dois"
"Linha um\r\n\r\nLinha dois" "Linha um Linha dois"
"Coluna\tvalor" "Coluna valor"
"palavra espaçada" (5 espaços) "palavra espaçada"
"palavra espaçada" (exatamente 4 espaços — o caso que a Meta rejeita) "palavra espaçada"
"palavra espaçada" (2 espaços) "palavra espaçada"
"**Confie** no _Senhor_" "Confie no Senhor"
"# Título\n## Subtítulo" "Título Subtítulo"
"Veja [aqui](https://exemplo.com)" "Veja aqui"
"> Citação bíblica" "Citação bíblica"
"Texto com" + espaço não separável + "NBSP" "Texto com NBSP" com espaço comum
" espaços nas pontas " "espaços nas pontas"
"Fé" + largura zero + "quebrada" "Féquebrada" (largura zero removida sem virar espaço)
String de 340 caracteres terminando em "...misericórdia infinita do Senhor." Truncada em ≤ 300, terminando em palavra completa +
String de 300 caracteres exatos Inalterada, sem reticências
String de 301 caracteres Truncada, com reticências, comprimento final ≤ 300
"Palavra, do dia!" truncado em "Palavra," "Palavra…" — vírgula pendente removida antes das reticências
"" ""
" \n\t " ""
"🙏 Ore hoje 🙏" "🙏 Ore hoje 🙏" — emoji é permitido pela Meta e preservado
Texto com 250 caracteres + emoji no fim Preservado; o truncamento conta pontos de código, não unidades UTF-16, e nunca parte um par substituto

Propriedades obrigatórias, verificadas com fast-check sobre 2.000 strings arbitrárias (incluindo Unicode e emoji):

  1. isTemplateParamSafe(sanitizeTeaser(x)) é sempre true.
  2. [...sanitizeTeaser(x)].length <= 300 sempre.
  3. sanitizeTeaser(sanitizeTeaser(x)) === sanitizeTeaser(x) (idempotência).
  4. sanitizeTeaser(x) nunca contém \n, \r, \t nem quatro espaços consecutivos.
  5. sanitizeTeaser(x) nunca termina em par substituto quebrado.

24.4.6 packages/core/src/daily-batch.ts — cálculo do lote diário #

Função pura que, dada a lista de assinantes elegíveis e a data-alvo, devolve o plano de envio. O motor de envio da Seção 18 apenas executa o que esta função decide.

export type PlannedSend = {
  subscriberId: string;
  devotionalDate: string;                       // YYYY-MM-DD, America/Sao_Paulo
  channel: 'TEMPLATE_TEXT' | 'FREEFORM_BUNDLE' | 'TEMPLATE_VIDEO';
  includeAudio: boolean;
  templateName: string | null;
  idempotencyKey: string;                       // send:{subscriberId}:{devotionalDate}
};

export function planDailyBatch(input: {
  targetDate: Date;                             // instante do disparo (05:40 local)
  freeSendWeekday: number;                      // 0 = domingo
  subscribers: PlanCandidate[];
}): { sends: PlannedSend[]; skipped: Array<{ subscriberId: string; reason: SkipReason }> };

SkipReason é um enum fechado: NOT_OPTED_IN, OPTED_OUT, DELETED, NOT_FREE_SEND_DAY, ALREADY_SENT, NO_AUDIO_AVAILABLE, INVALID_PHONE, SUPPRESSED_BY_HARD_BOUNCE.

Casos obrigatórios:

Cenário Entrada Resultado esperado
PAID, janela fechada tier PAID, service_window_expires_at no passado channel: TEMPLATE_TEXT, templateName: 'devocional_diario_v1', includeAudio: false (o áudio vem depois, na janela)
PAID, janela aberta tier PAID, service_window_expires_at = agora + 3 h channel: FREEFORM_BUNDLE, includeAudio: true, templateName: null — caminho preferencial
PAID, janela expira em 1 minuto service_window_expires_at = agora + 60 s FREEFORM_BUNDLE — a comparação usa o instante do planejamento; a expiração durante o envio é tratada como erro 131047 pelo motor (Seção 27.2)
PAID sem abrir janela há 3 dias consecutive_window_misses = 3 channel: TEMPLATE_VIDEO, templateName: 'devocional_diario_video_v1', includeAudio: true
PAID sem abrir há 2 dias consecutive_window_misses = 2 TEMPLATE_TEXT — o fallback só entra no 4º dia
PAID sem abrir há 7 dias consecutive_window_misses = 7 TEMPLATE_VIDEO — continua no fallback
PAID, fallback de vídeo sem MP4 pronto videoMediaId = null TEMPLATE_TEXT + registro em skipped com NO_AUDIO_AVAILABLE para o áudio; o texto sempre sai
FREE em domingo tier FREE, targetDate é domingo TEMPLATE_TEXT, includeAudio: false
FREE em segunda tier FREE, targetDate é segunda ausente de sends; skipped com NOT_FREE_SEND_DAY
FREE em domingo com janela aberta tier FREE, janela aberta FREEFORM_BUNDLE com includeAudio: false — economiza o template, mas não dá áudio
Sem opt-in confirmado opt_in_confirmed_at = null skipped com NOT_OPTED_IN
Opt-out ativo opt_out_at preenchido skipped com OPTED_OUT
Soft delete deleted_at preenchido skipped com DELETED
Já enviado hoje delivery_attempts já contém a chave skipped com ALREADY_SENT
Telefone inválido gravado phone_e164 não passa em normalizePhone skipped com INVALID_PHONE
Erro 131026 permanente registrado blocked_at e blocked_reason preenchidos skipped com SUPPRESSED_BY_HARD_BOUNCE

Casos de borda obrigatórios:

  • Chave de idempotência estável: para o mesmo assinante e a mesma data, a chave é byte a byte idêntica entre duas execuções. O teste compara duas chamadas com targetDate diferindo em 4 minutos dentro do mesmo dia local.
  • Virada de dia no fuso local: targetDate = 2026-08-26T08:39:00Z é 2026-08-26T05:39:00-03:00devotionalDate = '2026-08-26'. targetDate = 2026-08-26T02:59:00Z é 2026-08-25T23:59:00-03:00devotionalDate = '2026-08-25'. Os dois casos são fixados em teste para travar a conversão de fuso.
  • Domingo é calculado no fuso local: 2026-08-30T02:00:00Z é sábado 23:00 em São Paulo, portanto não é dia de envio FREE, mesmo sendo domingo em UTC.
  • Ordenação determinística: sends sai ordenado por subscriberId ascendente. Duas execuções com a mesma entrada embaralhada produzem arrays idênticos. Isso torna o lote reproduzível e a simulação do plano comparável entre execuções.
  • Volume: com 50.000 candidatos sintéticos a função executa em menos de 250 ms. O teste usa performance.now() e falha acima de 500 ms, com margem para máquinas lentas de CI.
  • Nenhum duplicado: o conjunto de idempotencyKey em sends tem tamanho igual ao tamanho de sends. Verificado com new Set(...).size.
  • Soma fechada: sends.length + skipped.length === subscribers.length. Nenhum assinante desaparece silenciosamente do plano.
  • O dia do envio gratuito é dirigido por parâmetro, não fixado no código. Um caso parametrizado percorre freeSendWeekday de 0 a 6; para cada valor, monta sete datas-alvo — uma por dia da semana, às 05:40 no fuso local — e afirma que o assinante gratuito entra em sends exatamente quando o dia local corresponde ao parâmetro, e aparece em skipped com NOT_FREE_SEND_DAY nos outros seis. O teste falha se o resultado for idêntico para dois valores diferentes do parâmetro, que é a assinatura de uma comparação fixa com domingo dentro da função. Sem esse caso, mudar send.free_tier_weekday para quarta-feira no painel não teria efeito nenhum e a suíte continuaria verde.
  • Reavaliação no disparo não pertence a esta função. planDailyBatch decide o plano; quem envia relê o assinante imediatamente antes de chamar o provedor (Seção 18.5). Um teste de comentário não basta: a cobertura desse comportamento está em S-13 a S-16 da Seção 24.8.3.

24.4.7 packages/core/src/narration.tsbuildNarrationScript #

Monta o texto que vai ao provedor de TTS a partir do devocional. A ordem e o conteúdo são fixados pelo pipeline de áudio da Seção 16.

export function buildNarrationScript(d: {
  title: string;
  bibleReference: string;
  bibleText: string;
  bibleVersion: string;
  reflectionMd: string;
  prayer: string;
}): string;

Casos obrigatórios:

Caso Entrada relevante Esperado
Ordem dos blocos devocional completo título → referência falada → texto bíblico → reflexão → oração, separados por parágrafo em branco
Markdown removido "**Deus** é _fiel_" "Deus é fiel"
Cabeçalho Markdown "## Reflexão\nTexto" "Reflexão. Texto" — cabeçalho vira frase com ponto, para a prosódia não engolir
Lista Markdown "- primeiro\n- segundo" "Primeiro. Segundo." — marcadores viram frases
Link Markdown "veja [o site](https://x)" "veja o site" — URL nunca é narrada
Referência abreviada "Jo 3:16" "João, capítulo 3, versículo 16"
Referência com faixa "Sl 23:1-6" "Salmos, capítulo 23, versículos 1 a 6"
Referência com livro numerado "1Co 13:4" "Primeira aos Coríntios, capítulo 13, versículo 4"
Referência com livro numerado por extenso "2 Timóteo 1:7" "Segunda a Timóteo, capítulo 1, versículo 7"
Referência com versículos avulsos "Pv 3:5,6" "Provérbios, capítulo 3, versículos 5 e 6"
Referência desconhecida "Xyz 1:1" Mantida literal, sem expandir, e emite aviso estruturado (não lança)
Versículo numerado no texto "1 No princípio... 2 E a terra..." Números de versículo removidos do corpo narrado
Aspas tipográficas aspas curvas Convertidas para aspas simples ASCII, para o TTS não pronunciar
Reticências "..." "…" normalizado
Espaço duplo "a b" "a b"
Nome da versão bibleVersion = 'ALMEIDA_1911' Frase final da leitura bíblica: "Almeida 1911." A sigla ARC é proibida como código e como atribuição (Seção 1.13)
Comprimento máximo script > 5.000 caracteres Lança NarrationError('SCRIPT_TOO_LONG') com o comprimento no erro — protege o orçamento de TTS
Campos vazios prayer = '' Bloco de oração omitido, sem parágrafo em branco duplicado
Determinismo mesma entrada, duas chamadas Strings idênticas — o script é a chave de cache do áudio

Propriedade obrigatória: o script resultante nunca contém #, *, _, crase, [, ], (http, nem sequência de três ou mais quebras de linha.

24.4.8 Outros módulos com teste unitário obrigatório #

Módulo Casos mínimos
packages/core/src/idempotency.ts Chave plan:{date} e send:{id}:{date} geradas conforme especificado; colisão impossível entre os dois prefixos; chave é ASCII e ≤ 120 caracteres.
packages/core/src/keywords.ts normalizeKeyword() termina em .toUpperCase() — um teste fixa isso, porque a comparação em maiúsculas é a implementação canônica (Seção 20.3.2). Reconhecimento das sete palavras de saída SAIR, PARAR, PARE, CANCELAR, STOP, DESCADASTRAR e REMOVER, e das palavras de retorno VOLTAR, RETORNAR, QUERO VOLTAR e REATIVAR, insensível a caixa, acento e pontuação (sair, Sair, SAÍR, sair , Sair!); não reconhece dentro de frase ("não quero sair do grupo" não é opt-out); CANCELAR sozinho é opt-out e CANCELAR ASSINATURA é pedido de cancelamento de cobrança, o que é garantido pela exigência de correspondência da mensagem inteira.
packages/core/src/money.ts Duas unidades e dois divisores, nunca float: centavos (/100) para valor monetário e micros (/1000000) para custo unitário. formatBRL(1990) === 'R$ 19,90'; formatBRL(19900) === 'R$ 199,00'; microsToBRL(1990000) === 'R$ 1,99'; a divisão anual por 12 arredonda para baixo e o resto fica no primeiro mês. Um teste afirma que somar um valor em centavos com um valor em micros sem conversão explícita é erro de tipo em tempo de compilação — as duas unidades usam tipos nominais distintos (Cents e Micros).
packages/core/src/window.ts isServiceWindowOpen(expiresAt, now) com limite exato (igual = fechado, porque a Meta expira no instante); janela nula = fechada; janela de 24 h calculada a partir do timestamp da mensagem recebida, não do processamento.
packages/integrations/src/whatsapp/errors.ts Mapeamento de cada código da Meta para a classe de erro e ação: 131047 → transitório/adiar; 131026 → permanente/suprimir; 131049 e 131050 → adiar sem contar falha; 132000132015 → contrato/alerta de template; 130429 → transitório com backoff longo; 133xxx → integração/alerta crítico; código desconhecido → transitório com no máximo 2 tentativas.
packages/integrations/src/asaas/mapper.ts Conversão de value decimal da Asaas (19.9) para centavos (1990) sem erro de ponto flutuante; 19.90, 19.9, 199, 199.00 e 0.01 testados; valor negativo lança.
packages/core/src/redaction.ts CPF, cartão, token e telefone redigidos em log; telefone vira +55119****4321; token vira ***; objeto aninhado é redigido em profundidade; array de objetos também.

24.5 Testes de integração #

Rodam contra PostgreSQL 17 e Redis 8 reais, subidos por Testcontainers (Seção 24.3.3). Cobrem o que só existe quando código e banco se encontram: transações, índices únicos, ON CONFLICT, enums, cascatas e concorrência.

24.5.1 Isolamento entre testes #

Decisão: truncamento seletivo em beforeEach, não transação com rollback.

A alternativa comum — abrir transação no beforeEach e dar rollback no afterEach — foi descartada porque o código sob teste usa prisma.$transaction internamente, e transações aninhadas no Postgres exigem savepoints que mudam o comportamento de bloqueio e de ON CONFLICT. Testar com semântica diferente da produção derrota o propósito do teste.

// tools/testing/setup-integration.ts
import { afterAll, afterEach, beforeEach, inject } from 'vitest';
import { PrismaClient } from '@palavra-diaria/db';
import Redis from 'ioredis';

const databaseUrl = inject('databaseUrl');
const redisUrl = inject('redisUrl');

export const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
export const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });

// Tabelas nunca truncadas: apenas as populadas por migration/seed imutável.
const PRESERVED = new Set(['_prisma_migrations', 'plans']);

let truncateStatement: string | null = null;

beforeEach(async () => {
  if (!truncateStatement) {
    const rows = await prisma.$queryRaw<Array<{ tablename: string }>>`
      SELECT tablename FROM pg_tables WHERE schemaname = 'public'
    `;
    const targets = rows
      .map((r) => r.tablename)
      .filter((t) => !PRESERVED.has(t))
      .map((t) => `"public"."${t}"`)
      .join(', ');
    truncateStatement = `TRUNCATE TABLE ${targets} RESTART IDENTITY CASCADE`;
  }
  await prisma.$executeRawUnsafe(truncateStatement);
  await redis.flushdb();
});

afterEach(async () => {
  // Nenhum timer de BullMQ pode sobreviver ao teste.
  await redis.flushdb();
});

afterAll(async () => {
  await prisma.$disconnect();
  redis.disconnect();
});

Cada arquivo de teste de integração roda em um worker Vitest próprio, e cada worker recebe um schema Postgres dedicado quando maxConcurrency > 1: o DATABASE_URL do worker recebe ?schema=test_w{workerId}. Isso permite paralelismo real sem que um TRUNCATE derrube o teste do vizinho. Com 4 workers a suíte de integração cai de ~11 min para ~3 min.

24.5.2 Factories e fixtures #

Factories vivem em tools/testing/factories/ e seguem três regras:

  1. Toda factory aceita overrides parciais e preenche o resto com valores válidos.
  2. Nenhuma factory grava dependência implícita: se um subscription precisa de subscriber, a factory cria um a menos que receba subscriberId.
  3. Valores variáveis (telefone, e-mail, CPF, ULID) vêm de um contador determinístico semeado por arquivo de teste, nunca de Math.random(). Duas execuções da suíte geram os mesmos dados, o que torna falha reproduzível.
// tools/testing/factories/subscriber.ts
import { prisma } from '../setup-integration';
import { nextSeq } from '../seq';
import { ulid } from '@palavra-diaria/core';

export async function makeSubscriber(overrides: Partial<SubscriberInput> = {}) {
  const n = nextSeq('subscriber');            // 1, 2, 3, ... por arquivo de teste
  const local = String(900000000 + n).padStart(9, '0');
  return prisma.subscriber.create({
    data: {
      id: overrides.id ?? ulid(),
      phoneE164: overrides.phoneE164 ?? `+5511${local}`,
      waId: overrides.waId ?? null,
      tier: overrides.tier ?? 'FREE',
      optInConfirmedAt: overrides.optInConfirmedAt ?? new Date('2026-01-01T12:00:00Z'),
      optOutAt: overrides.optOutAt ?? null,
      serviceWindowExpiresAt: overrides.serviceWindowExpiresAt ?? null,
      consecutiveUnopenedDays: overrides.consecutiveUnopenedDays ?? 0,
      deletedAt: overrides.deletedAt ?? null,
      createdAt: overrides.createdAt ?? new Date('2026-01-01T12:00:00Z'),
      ...overrides,
    },
  });
}

export const makePaidSubscriber = (o: Partial<SubscriberInput> = {}) =>
  makeSubscriber({ tier: 'PAID', ...o });

Factories obrigatórias: makeSubscriber, makePaidSubscriber, makeSubscription, makePayment, makeDevotional, makeAudioAsset, makeAdminUser, makeSession, makeSendBatch, makeMessageLog, makeInboundMessage, makePaymentEvent.

Fixtures estáticos (arquivos JSON versionados em tools/testing/fixtures/): payloads brutos reais de webhook, com dados anonimizados. Cada arquivo carrega um cabeçalho _meta com data de captura e versão da API de origem.

fixtures/
  asaas/payment-created.json
  asaas/payment-confirmed.json
  asaas/payment-overdue.json
  asaas/payment-refunded.json
  asaas/payment-chargeback-requested.json
  asaas/subscription-created.json
  asaas/subscription-deleted.json
  meta/inbound-text.json
  meta/inbound-button-open.json
  meta/inbound-button-snooze.json
  meta/inbound-audio.json
  meta/status-sent.json
  meta/status-delivered.json
  meta/status-read.json
  meta/status-failed-131047.json
  meta/status-failed-131026.json
  meta/status-failed-131049.json
  meta/account-quality-red.json
  meta/template-status-rejected.json

24.5.3 Fluxos cobertos por teste de integração #

# Fluxo O que se afirma
I-01 Cadastro + OTP + opt-in subscribers criado com phone_e164 normalizado; otp_codes com hash, não o código em claro; consent_events com duas entradas (web e WhatsApp); opt_in_confirmed_at só preenchido após a confirmação no WhatsApp
I-02 Unicidade de telefone Dois cadastros concorrentes com 11987654321 e +551187654321 — apenas um registro é criado; o segundo recebe erro de conflito, não uma linha duplicada
I-03 Consumo de OTP Código correto marca consumed_at e a segunda tentativa com o mesmo código falha; 5 tentativas erradas invalidam o código; TTL de 10 minutos expira com relógio controlado
I-04 Limite de pedidos de OTP conta pedidos, não envios 3 pedidos por hora por número; o 4º devolve o erro de limite e não grava linha em otp_codes. O contador é incrementado antes de qualquer consulta ao banco e vale igualmente para número cadastrado e não cadastrado. O teste executa quatro pedidos para um número existente e quatro para um inexistente e afirma que as quatro respostas são idênticas em código HTTP, corpo e error.code nos dois casos — se o contador só subisse para número existente, a diferença no quarto pedido seria, sozinha, um oráculo completo de enumeração da base
I-05 Checkout cartão subscriptions em PENDING_PAYMENT; externalReference gravado; nenhuma coluna do banco contém número de cartão (varredura por regex em todas as colunas text de todas as tabelas)
I-06 Webhook PAYMENT_CONFIRMED payment_events gravado; subscriptions.status = ACTIVE; subscribers.tier = PAID; current_period_end correto — tudo na mesma transação
I-07 Idempotência de webhook O mesmo event.id entregue 3 vezes gera 1 linha em payment_events e 1 transição em subscription_events; o índice único é o guarda, não um SELECT prévio
I-08 Webhook fora de ordem PAYMENT_CONFIRMED chega depois de PAYMENT_OVERDUE do mesmo ciclo: o estado final é ACTIVE, decidido pela ordem de event.dateCreated, não pela ordem de chegada
I-09 Revogação imediata PAYMENT_OVERDUE em assinatura ACTIVE: status = EXPIRED e tier = FREE na mesma transação; um planDailyBatch executado logo em seguida já classifica o assinante como FREE
I-10 Cancelamento pelo painel status = CANCELED e tier permanece PAID; após PERIOD_ENDED, tier = FREE
I-11 Reconciliação Banco com ACTIVE e Asaas simulada devolvendo INACTIVE: a reconciliação corrige o banco, grava subscription_events com origem RECONCILIATION e emite alerta
I-12 Ciclo editorial DRAFTREADY dispara tts.generate; AUDIO_READYPUBLISHED só é permitido com audio_assets presente; transição inválida é rejeitada com erro de contrato
I-13 Revisões de devocional Cada edição cria linha em devotional_revisions com o diff; a exclusão do devocional é soft delete e as revisões permanecem
I-14 Planejamento do lote 200 assinantes sintéticos, mistura de tiers e estados: send_batches criado, delivery_attempts com chave única, contagens conferem com o resultado de planDailyBatch
I-15 Idempotência do lote plan:2026-08-26 executado duas vezes concorrentemente cria um send_batches; a segunda execução termina sem erro e sem linhas extras
I-16 Concorrência de envio Dois workers processam o mesmo delivery_attempt: apenas um envia; o outro recebe violação de índice único e encerra como ALREADY_SENT, sem retry
I-17 Abertura de janela Webhook de mensagem recebida atualiza service_window_expires_at = inbound.timestamp + 24 h; um segundo webhook mais antigo não retrocede o valor
I-18 Resolução por wa_id Webhook com wa_id sem nono dígito encontra o assinante e grava wa_id; segundo webhook resolve pelo índice de wa_id
I-19 Opt-out SAIR grava opt_out_at, envia confirmação uma única vez e o lote seguinte pula o assinante; segundo SAIR não envia nova confirmação
I-20 Opt-out suspende a cobrança sem cancelar a assinatura Após SAIR, subscriptions.status continua ACTIVE — um SAIR acidental não destrói a contratação — mas a cobrança do ciclo seguinte fica suspensa na mesma transação, o painel expõe o estado "envios pausados, cobrança suspensa" com as duas ações possíveis, e o teste avança o relógio 30 dias sem reativação e afirma que a assinatura é então encerrada ao fim do período já pago
I-21 Reativação VOLTAR limpa opt_out_at e grava consent_events; o lote seguinte inclui o assinante
I-22 Status de mensagem sentdeliveredread atualizam message_logs com timestamps próprios; um delivered que chega depois de read não regride o status
I-23 Falha permanente Status failed com 131026 marca subscribers.blocked_at e blocked_reason e suprime o assinante nos lotes seguintes. Não existe coluna hard_bounce_at (Seção 6.3)
I-24 Falha por janela Status failed com 131047 reenfileira como template no mesmo dia, no máximo uma vez
I-25 Pipeline de áudio tts.generate grava audio_assets com provider, duration_ms, chaves OGG e MP3; segunda execução com o mesmo script reaproveita o asset em vez de gerar de novo
I-26 Fallback de TTS 3 falhas do provedor primário acionam o secundário; audio_assets.provider registra o secundário; a 4ª tentativa não chama mais o primário
I-27 Reupload de mídia whatsapp_media_id com mais de 30 dias dispara reupload e atualiza media_uploads
I-28 Sessão Login grava sessions; logout revoga; JWT de sessão revogada é rejeitado; rotação emite novo token e invalida o anterior
I-29 Autorização por papel EDITOR não consegue acessar rotas de ADMIN; SUBSCRIBER não consegue ler devocional de outro assinante; cada negativa devolve o code correto
I-30 Auditoria administrativa Toda ação de admin grava admin_audit_log com ator, alvo, ação e diff
I-31 Métricas diárias daily_metrics calculado para uma data com dados conhecidos bate com as fórmulas da Seção 21. O formato é o longo: uma linha por (metric_date, metric_key, dimension), com value, numerator, denominator, rollup_version e is_final. O teste afirma que a chave única impede duas linhas para a mesma tripla e que dimension usa string vazia, nunca NULL
I-32 Exportação LGPD O export JSON do assinante contém todas as tabelas previstas na Seção 22 e nenhum dado de terceiro
I-33 Eliminação LGPD alcança todas as tabelas Cria um assinante, gera tráfego em todas as tabelas que guardam identificador pessoal, executa a anonimização e falha se uma busca pelo telefone original, pelo wa_id ou pelo e-mail devolver qualquer linha em qualquer tabela. Verifica explicitamente que message_logs.phone_e164, message_logs.wa_id, message_logs.payload_excerpt, inbound_messages.phone_e164 e inbound_messages.wa_id ficaram nulos; que nome, e-mail e CPF saíram de subscribers e subscriber_profiles; e que consent_events e payment_events permanecem íntegros no conteúdo probatório, ligados apenas ao identificador interno
I-34 Retenção O job de retenção apaga message_logs com mais de 18 meses e preserva payment_events de 3 anos
I-35 Guarda de ganchos de teste Subir a aplicação com E2E_TEST_HOOKS=true e NODE_ENV=production faz o processo terminar com código diferente de zero e a mensagem test hooks are forbidden in production
I-36 Registro do agendador queue.getRepeatableJobs() contém plan-daily-batch com padrão 40 5 * * * e tz: 'America/Sao_Paulo'. As duas asserções são obrigatórias: sem o fuso, o job dispara às 05:40 UTC, ou seja, 02:40 em Brasília. O nome do job repetível é plan-daily-batch em todos os pontos do sistema
I-37 Criptografia de campo e índice cego Grava um assinante e o relê. Afirma que a coluna phone_e164 no banco não contém o telefone em claro e casa com o formato de envelope; que phone_hmac tem 64 caracteres hexadecimais; e que a busca por phone_hmac encontra a linha. Percorre information_schema.columns e falha se alguma coluna nomeada phone_e164, wa_id, email ou cpf não for text com CHECK de envelope, ou se a coluna _hmac correspondente estiver ausente
I-38 Gatilho append-only não impede direito do titular Executa a anonimização por LGPD de ponta a ponta e o expurgo de retenção contra um banco com o gatilho de consent_events ativo. Falha se qualquer um dos dois lançar exceção, e falha também se um UPDATE que altere type, granted, consent_text_hash ou created_at for aceito com o papel privilegiado. Nenhum gatilho é desabilitado em momento algum
I-39 Atributos dos cookies de sessão Percorre todo Set-Cookie emitido pelas rotas de autenticação e afirma que todo cookie cujo nome comece com __Host- contém Secure, contém Path=/ e não contém Domain. Afirma que os nomes emitidos são exatamente __Host-session, __Host-refresh, __Host-admin-session, __Host-admin-refresh, __Host-admin-device, __Host-auth-challenge e __Host-csrf, e que o nome csrf-token não é emitido em lugar nenhum
I-40 Precedência absoluta da palavra de saída Envia cada uma das sete palavras de saída em cada estado possível do assinante — inclusive pausado, bloqueado, em atendimento humano e em modo silencioso pela proteção anti-laço — e falha se opt_out_at não for gravado em qualquer combinação. Nenhum caminho de código pode consumir a mensagem antes desse teste
I-41 Privilégio de tabela nova Cria uma tabela por migration com o papel de migração e afirma que app_user tem SELECT, INSERT, UPDATE e DELETE nela sem nenhum GRANT adicional, o que prova que os ALTER DEFAULT PRIVILEGES da Seção 22.12.2 estão aplicados

24.6 Testes de contrato com serviços externos #

Três integrações externas: Asaas, WhatsApp Cloud API (Meta) e provedor de TTS. Nenhuma é chamada de verdade em teste automatizado. O risco disso é óbvio: o simulador pode divergir do serviço real e os testes passam enquanto a produção quebra. A estratégia abaixo existe para controlar exatamente esse risco.

24.6.1 Três níveis de simulação #

Nível Onde Como Para quê
Handler MSW Testes unitários Interceptação em memória por msw Testar o cliente tipado: montagem de requisição, parsing, classificação de erro
Servidor de simulação Integração, E2E e desenvolvimento local Processo HTTP em 127.0.0.1:4010 Testar o sistema inteiro com dependências previsíveis e cenários de erro provocáveis
Reprodução de gravação Testes de contrato Respostas gravadas do serviço real, em disco Provar que o cliente ainda entende a forma real do payload

24.6.2 Gravação e reprodução #

Gravações ficam em tools/testing/cassettes/<serviço>/<operação>.json. Cada arquivo tem requisição sanitizada, resposta bruta, status, cabeçalhos relevantes e um cabeçalho _meta com data da captura e ambiente de origem (sempre sandbox ou número de teste).

{
  "_meta": {
    "service": "asaas",
    "operation": "createSubscription",
    "capturedAt": "2026-08-20T14:11:03.000Z",
    "environment": "sandbox",
    "apiVersion": "v3"
  },
  "request": {
    "method": "POST",
    "path": "/v3/subscriptions",
    "headers": { "access_token": "***" },
    "body": {
      "customer": "cus_000005219999",
      "billingType": "CREDIT_CARD",
      "cycle": "MONTHLY",
      "value": 19.9,
      "nextDueDate": "2026-09-20",
      "externalReference": "01K3Q9X0000000000000000000"
    }
  },
  "response": {
    "status": 200,
    "body": {
      "object": "subscription",
      "id": "sub_9v8x7c6b5a",
      "customer": "cus_000005219999",
      "billingType": "CREDIT_CARD",
      "cycle": "MONTHLY",
      "value": 19.9,
      "nextDueDate": "2026-09-20",
      "status": "ACTIVE",
      "externalReference": "01K3Q9X0000000000000000000",
      "dateCreated": "2026-08-20"
    }
  }
}

Regras de gravação, sem exceção:

  1. Gravação só é feita contra sandbox da Asaas, número de teste da Meta e a conta de desenvolvimento do provedor de TTS. Nunca produção.
  2. O gravador redige access_token, Authorization, xi-api-key, CPF, número de cartão e telefone antes de escrever em disco. O CI roda gitleaks e uma verificação própria (pnpm test:cassettes:lint) que falha se qualquer gravação contiver algo parecido com token (/[A-Za-z0-9_\-]{32,}/) fora dos campos redigidos.
  3. Gravação é ato manual e deliberado: pnpm test:contract --record --service=asaas --operation=createSubscription. Nunca automática, nunca no CI.
  4. Toda alteração de gravação aparece no diff do pull request e exige revisão humana.

O teste de contrato reproduz a gravação e afirma duas direções:

// tests/contract/asaas/create-subscription.contract.test.ts
import { describe, expect, it } from 'vitest';
import { loadCassette, replay } from '../../../tools/testing/cassettes';
import { AsaasClient } from '@palavra-diaria/integrations/asaas';
import { AsaasSubscriptionSchema } from '@palavra-diaria/integrations/asaas/schemas';

describe('contrato: Asaas createSubscription', () => {
  const cassette = loadCassette('asaas', 'createSubscription');

  it('a requisição que produzimos é a que foi gravada', async () => {
    const captured = await replay(cassette, (baseUrl) => {
      const client = new AsaasClient({ baseUrl, apiKey: 'test' });
      return client.createSubscription({
        customerId: 'cus_000005219999',
        billingType: 'CREDIT_CARD',
        cycle: 'MONTHLY',
        valueCents: 1990,
        nextDueDate: '2026-09-20',
        externalReference: '01K3Q9X0000000000000000000',
      });
    });
    expect(captured.request.body).toEqual(cassette.request.body);
  });

  it('a resposta gravada satisfaz nosso schema Zod', () => {
    const parsed = AsaasSubscriptionSchema.safeParse(cassette.response.body);
    expect(parsed.success).toBe(true);
  });

  it('o schema rejeita campo obrigatório ausente', () => {
    const { id, ...semId } = cassette.response.body as Record<string, unknown>;
    expect(AsaasSubscriptionSchema.safeParse(semId).success).toBe(false);
  });
});

A terceira afirmação é o que impede o schema de virar z.any() com o tempo. Um schema permissivo passa no teste 2 mas falha no teste 3.

24.6.3 Servidor de simulação local #

Um único processo Fastify serve as três APIs em caminhos distintos. Ele roda em desenvolvimento (pnpm mock:serve), nos testes de integração e no E2E.

Prefixo Serviço simulado
/asaas/v3/* Asaas API v3
/meta/v26.0/* WhatsApp Cloud API
/elevenlabs/v1/* TTS primário
/gcp-tts/v1/* TTS de fallback
/s3/* Storage S3-compatível
/__mock/* Painel de controle do próprio simulador

O painel de controle é o que torna o simulador útil para testar falha:

Rota Efeito
GET /__mock/health Prontidão do simulador
POST /__mock/reset Zera estado e cenários
POST /__mock/scenario Ativa um cenário nomeado (tabela abaixo)
GET /__mock/requests?service=meta Devolve todas as requisições recebidas, para asserção
POST /__mock/webhook/asaas Faz o simulador disparar um webhook contra nosso sistema
POST /__mock/webhook/meta Idem, para mensagens de entrada e status

Cenários obrigatórios, cada um com teste que o exercita:

Cenário Serviço Comportamento
asaas.ok Asaas Fluxo feliz
asaas.timeout Asaas Não responde por 30 s
asaas.500 Asaas 500 em toda chamada
asaas.429 Asaas 429 com Retry-After: 5
asaas.401 Asaas Token inválido
asaas.card_declined Asaas POST /payments devolve erro de cartão recusado
asaas.duplicate_webhook Asaas Entrega o mesmo evento 3 vezes
asaas.out_of_order_webhook Asaas Entrega PAYMENT_OVERDUE antes de PAYMENT_CONFIRMED
asaas.subscription_drift Asaas GET /subscriptions reporta estado diferente do nosso banco
meta.ok Meta Envio aceito, status sentdelivered
meta.error_131047 Meta Fora da janela de 24 h
meta.error_131026 Meta Não é possível entregar (permanente)
meta.error_131049 Meta Limitação por saúde do ecossistema
meta.error_130429 Meta Rate limit
meta.error_132001 Meta Template não existe ou não aprovado
meta.error_133010 Meta Conta/número com problema
meta.token_expired Meta 190 com subcódigo de token expirado
meta.quality_red Meta Dispara webhook phone_number_quality_update com RED
meta.template_paused Meta Dispara webhook de template pausado
meta.slow Meta Responde em 8 s, para exercitar timeout
meta.media_expired Meta media_id inválido/expirado no envio
tts.ok TTS Devolve MP3 válido de 12 s
tts.500 TTS Falha permanente do primário
tts.quota_exceeded TTS 401/429 de cota esgotada
tts.slow TTS Responde em 60 s
storage.ok Storage Upload e URL assinada funcionam
storage.503 Storage Indisponível

Toda linha desta tabela referencia, na mesma linha do arquivo de registro do simulador, o identificador do teste que a exercita. O teste mock-scenario-coverage.contract.test.ts enumera os cenários registrados e falha se algum não estiver referenciado por um teste existente. Um cenário sem teste é um caminho de produção sem cobertura, e a lista de cenários é justamente o inventário do que se sabe que vai acontecer: token expirado, qualidade em vermelho, template pausado, mídia expirada, cota de voz esgotada, tempo limite e erro do provedor de pagamento não podem ficar registrados e não exercitados.

Uso em teste:

await mock.scenario('meta.error_131047');
await runDailyBatchForDate('2026-08-26');
const attempts = await prisma.deliveryAttempt.findMany({ where: { status: 'RETRY_SCHEDULED' } });
expect(attempts).toHaveLength(1);
expect(attempts[0].lastErrorCode).toBe('131047');

24.6.4 Como se garante que o simulador não fica defasado #

Quatro mecanismos, todos automáticos exceto o último:

  1. O simulador valida com os nossos schemas Zod de resposta. Toda rota do simulador passa o corpo que vai devolver por AsaasSubscriptionSchema, MetaSendResponseSchema e equivalentes antes de responder. Se o schema mudar e o simulador não, o próprio simulador falha ao inicializar.
  2. O simulador é semeado a partir das gravações. As respostas de sucesso não são escritas à mão: são carregadas de tools/testing/cassettes/. Uma gravação nova atualiza o simulador no mesmo commit.
  3. Verificação de cobertura de operações. O teste mock-server-coverage.contract.test.ts enumera todos os métodos públicos dos clientes em packages/integrations por reflexão e afirma que cada um tem rota correspondente no simulador e gravação em disco. Adicionar um método sem simulação quebra o CI.
  4. Recaptura trimestral obrigatória. A cada 90 dias, uma tarefa recorrente exige rodar pnpm test:contract --record --all contra sandbox e número de teste, revisar o diff e commitar. O CI falha com aviso (não bloqueante) quando a gravação mais antiga passa de 100 dias, e com erro (bloqueante) aos 150 dias. A idade é lida do campo capturedAt. Isso transforma "o simulador envelheceu" de surpresa em tarefa agendada.

Além disso, a sonda de integrações — executada na fila maintenance.cleanup (Seção 18.8), que é a fila de tarefas periódicas de manutenção — chama em produção uma operação de leitura barata de cada serviço (GET /v3/finance/balance na Asaas, GET /{PHONE_NUMBER_ID} na Meta) a cada 15 minutos e valida a resposta com o mesmo schema Zod usado no código.

Leitura barata, porém, não é o formato que quebra. Os formatos que quebram são o corpo do webhook de cobrança e a resposta de envio de mensagem. Por isso a sonda faz duas coisas a mais:

  1. Valida um objeto real recente de cada serviço — a última cobrança criada, o último envio aceito — usando exatamente os mesmos schemas que o processamento de webhook usa em produção. Nunca schemas próprios: um schema próprio vira uma segunda verdade e mascara justamente a divergência que a sonda existe para achar.
  2. Uma rotina semanal em ambiente de teste do provedor percorre o ciclo completo — criar cobrança, confirmar, vencer, estornar — e valida os quatro corpos de webhook resultantes contra os schemas de produção.

A sonda alimenta as métricas external_contract_validation_total{provider,operation,result} e external_contract_last_success_timestamp_seconds{provider} e o alerta external_contract_drift, todos catalogados na Seção 23. Uma mudança de contrato em produção vira alerta em até 15 minutos, não em uma falha de envio no dia seguinte.

24.7 Testes E2E com Playwright #

24.7.1 Ambiente de E2E #

  • Banco: PostgreSQL efêmero por execução, com migrations aplicadas e seed determinístico (pnpm ops seed:e2e).
  • Externos: servidor de simulação da Seção 24.6.3.
  • Aplicação: build de produção do web, com E2E_TEST_HOOKS=true.
  • O worker roda no mesmo processo do Playwright em modo "inline": os jobs são executados sincronamente por um runner de teste em vez de por BullMQ, para eliminar espera. Filas reais são exercitadas nos testes de integração e nos testes do motor de envio, não aqui.

Ganchos de teste. Quatro rotas internas existem apenas quando E2E_TEST_HOOKS=true, todas exigindo o cabeçalho x-e2e-secret igual a E2E_HOOK_SECRET:

Rota Função
POST /api/internal/test/clock Define o deslocamento do relógio ({ "offsetMs": 86400000 })
POST /api/internal/test/inbound Injeta uma mensagem de entrada do WhatsApp como se viesse da Meta
GET /api/internal/test/otp?phone= Devolve o último OTP em claro para o número
POST /api/internal/test/run-jobs Executa a fila pendente de forma síncrona

Guarda obrigatória: o web falha ao iniciar se E2E_TEST_HOOKS === 'true' e NODE_ENV === 'production' sem ALLOW_TEST_HOOKS_IN_PROD_BUILD=true. O arquivo de composição de produção nunca define nenhuma das duas. O teste I-35 da Seção 24.5.3 fixa esse comportamento. Como defesa em profundidade, o proxy reverso devolve 404 para todo o prefixo /api/internal/* (Seção 25.7.2).

24.7.2 Controle de relógio #

packages/core exporta:

export interface Clock { now(): Date }
export const systemClock: Clock = { now: () => new Date() };
export class OffsetClock implements Clock {
  constructor(private readonly offsetMs: () => number) {}
  now() { return new Date(Date.now() + this.offsetMs()); }
}

Em produção, injeta-se systemClock. Em teste e E2E, injeta-se OffsetClock lendo a chave Redis test:clock:offset. Nenhum arquivo fora de packages/core/src/clock.ts pode chamar new Date() sem argumento nem Date.now(). Isso é imposto por regra de ESLint (no-restricted-syntax sobre NewExpression[callee.name='Date'][arguments.length=0] e MemberExpression[object.name='Date'][property.name='now']), com exceção declarada apenas para clock.ts e para arquivos de teste.

24.7.3 Cenários E2E #

Cada cenário abaixo é um arquivo em apps/web/tests/e2e/. Todos usam dados sintéticos da Seção 24.10.

E2E-01 — Cadastro completo com opt-in duplo (signup.spec.ts)

  • Passos: abrir /; clicar em "Receber grátis"; preencher telefone (11) 99000-0001; marcar o checkbox de consentimento; enviar; ler o OTP via gancho de teste; digitar os 6 dígitos; injetar a resposta SIM via gancho de mensagem de entrada; recarregar o painel.
  • Dados: telefone sintético não usado por outro cenário.
  • Sucesso: painel do assinante mostra "Plano gratuito"; o banco tem opt_in_confirmed_at preenchido e duas linhas em consent_events; a chamada ao simulador registra exatamente um envio de template codigo_acesso_v1 e um de boas_vindas_v1.

E2E-02 — Cadastro rejeita telefone estrangeiro (signup-foreign.spec.ts)

  • Passos: preencher +1 415 555 2671; enviar.
  • Sucesso: mensagem em português informando que apenas números brasileiros são aceitos no momento; nenhuma linha criada em subscribers; nenhuma chamada ao simulador da Meta.

E2E-03 — Cadastro rejeita telefone fixo (signup-landline.spec.ts)

  • Passos: preencher (11) 3123-4567.
  • Sucesso: erro de campo indicando que é necessário um celular com WhatsApp; sem registro criado.

E2E-04 — Login por OTP com reenvio e limite (login-otp.spec.ts)

  • Passos: pedir código 3 vezes seguidas; pedir a 4ª.
  • Sucesso: as 3 primeiras retornam sucesso; a 4ª mostra a mensagem de limite; o banco tem 3 linhas em otp_codes para o número.

E2E-05 — Checkout com cartão (checkout-card.spec.ts)

  • Passos: logado como FREE, abrir /assinar; escolher mensal; preencher nome, CPF 529.982.247-25 e cartão de teste; confirmar; o simulador dispara PAYMENT_CONFIRMED; executar os jobs pendentes; recarregar o painel.
  • Sucesso: painel mostra "Plano pago — próxima cobrança em 20/09/2026"; subscriptions em ACTIVE; subscribers.tier = PAID; nenhuma requisição registrada no simulador contém o número de cartão fora do endpoint de tokenização.

E2E-06 — Checkout com cartão recusado (checkout-card-declined.spec.ts)

  • Passos: ativar asaas.card_declined; repetir o checkout.
  • Sucesso: mensagem "Não conseguimos aprovar o pagamento. Confira os dados do cartão ou tente outro."; a assinatura permanece PENDING_PAYMENT; o assinante continua FREE; o botão de tentar de novo está habilitado.

E2E-07 — Checkout com PIX (checkout-pix.spec.ts)

  • Passos: escolher anual + PIX; confirmar; a página mostra o QR Code e o código copia e cola; clicar em "Copiar código"; o simulador dispara PAYMENT_RECEIVED; a página faz polling e atualiza sem recarregar.
  • Sucesso: QR Code renderizado (imagem com alt descritivo); o texto copiado bate com o payload devolvido; após o webhook, a página mostra confirmação em até 10 s; subscribers.tier = PAID; current_period_end um ano à frente.

E2E-08 — PIX expirado (checkout-pix-expired.spec.ts)

  • Passos: gerar cobrança PIX; avançar o relógio 25 horas; recarregar a página de pagamento.
  • Sucesso: a página informa que o código expirou e oferece gerar novo; a assinatura continua PENDING_PAYMENT; nenhum acesso pago liberado.

E2E-09 — Cancelamento pelo assinante (cancel.spec.ts)

  • Passos: logado como PAID com current_period_end em 10 dias, abrir /conta; clicar em "Cancelar assinatura"; confirmar no diálogo; ler a tela de confirmação.
  • Sucesso: a tela diz explicitamente até quando o acesso continua ("Você continua com acesso completo até 04/09/2026"); subscriptions.status = CANCELED; tier continua PAID; a Asaas recebeu DELETE /v3/subscriptions/{id}.

E2E-10 — Fim do período após cancelamento (cancel-period-end.spec.ts)

  • Passos: continuar do cenário anterior; avançar o relógio 11 dias; executar os jobs.
  • Sucesso: tier = FREE; o painel mostra "Plano gratuito"; o acervo passa a exibir apenas os últimos 7 dias.

E2E-11 — Inadimplência revoga na hora (overdue.spec.ts)

  • Passos: assinante PAID ACTIVE; o simulador dispara PAYMENT_OVERDUE; executar os jobs; recarregar o painel.
  • Sucesso: tier = FREE imediatamente; subscriptions.status = EXPIRED; o painel mostra o aviso de pagamento não identificado com botão para regularizar; nenhum texto na interface menciona prazo ou tolerância.

E2E-12 — Opt-out por mensagem (opt-out.spec.ts)

  • Passos: injetar mensagem de entrada sair (minúsculo, com espaço à direita) para um assinante PAID; executar os jobs; abrir o painel.
  • Sucesso: opt_out_at preenchido; o simulador registrou exatamente uma mensagem de confirmação de saída; o painel mostra o estado "envios pausados, cobrança suspensa", com a ação de voltar a receber e a ação de encerrar a assinatura, deixando a distinção explícita; um segundo sair não gera nova confirmação.

E2E-13 — Reativação por VOLTAR (opt-in-again.spec.ts)

  • Passos: continuar do anterior; injetar voltar.
  • Sucesso: opt_out_at nulo; nova linha em consent_events; mensagem de boas-vindas de retorno registrada uma vez.

E2E-14 — Painel administrativo publica um devocional (admin-publish.spec.ts)

  • Passos: login como EDITOR com e-mail, senha e TOTP (código gerado no teste a partir do segredo semeado); abrir o calendário editorial; criar devocional para 2026-09-01 com título, referência Sl 23:1-6, texto, reflexão e oração; salvar como DRAFT; marcar como READY; aguardar o áudio (o simulador de TTS responde na hora); conferir o player; publicar.
  • Sucesso: devotionals.status = PUBLISHED; audio_assets com OGG e MP3; o teaser gerado tem ≤ 300 caracteres e passa em isTemplateParamSafe; admin_audit_log com três linhas (criação, transição, publicação); o player web toca o MP3 por URL assinada.

E2E-15 — Editor não pode o que é de admin (admin-rbac.spec.ts)

  • Passos: logado como EDITOR, navegar direto para /admin/assinantes e para /admin/configuracoes.
  • Sucesso: ambas as páginas devolvem a tela de acesso negado; a chamada de API correspondente devolve 403 com o code de permissão insuficiente definido na Seção 7; a tentativa é registrada em admin_audit_log.

E2E-16 — Reenvio manual respeita o limite do tier (resend-limit.spec.ts)

  • Passos: como FREE, clicar em "Reenviar devocional de hoje" duas vezes; como PAID, clicar quatro vezes.
  • Sucesso: FREE tem sucesso na 1ª e erro de limite na 2ª; PAID tem sucesso nas 3 primeiras e erro na 4ª; as mensagens são as da Seção 10.

E2E-17 — Acervo respeita o tier (archive.spec.ts)

  • Passos: com 40 devocionais publicados, abrir /acervo como FREE e como PAID. Em seguida, sem usar a interface, chamar a rota de listagem do acervo com o maior limite aceito e percorrer todos os cursores com a sessão do assinante gratuito, e pedir a leitura de um devocional de 30 dias atrás.
  • Sucesso: FREE vê no máximo 7 itens e um aviso de upgrade na interface; a rota devolve no máximo os 7 dias mais recentes mesmo percorrendo todos os cursores, e a leitura do item de 30 dias atrás devolve 403. PAID vê todos os itens desde a primeira assinatura paga (Seção 13.6.1), com paginação por cursor funcionando (clicar em "Carregar mais" duas vezes). O limite é imposto no servidor, e não apenas na tela.

E2E-18 — Landing page e acessibilidade (landing.mobile.spec.ts)

  • Passos: abrir / no perfil mobile-android; rodar varredura axe-core; navegar apenas por teclado até o botão principal.
  • Sucesso: zero violações de severidade serious ou critical; foco visível em todos os elementos interativos; o botão principal é alcançável em no máximo 6 Tab; LCP medido pelo Playwright abaixo de 2,5 s no perfil móvel emulado (o alvo de produção em 4G real, 2,0 s, é validado no teste de carga da Seção 24.9).

E2E-19 — Política de conteúdo não bloqueia nada (csp-violations.spec.ts)

  • Passos: abrir, uma a uma, todas as rotas com formulário (/cadastro, /contato, a tela de pedido de código), mais /app/hoje com sessão de assinante pago e o player de áudio, com a política de conteúdo em modo de imposição; coletar as mensagens de violação do console do navegador em cada rota.
  • Sucesso: zero violações em todas as rotas. Especificamente: o script do verificador anti-bot carrega e o widget renderiza; o iframe do verificador não é bloqueado; e o fetch do player para o domínio de mídia conclui e o áudio toca. Este cenário existe porque os dois defeitos que ele pega — verificador bloqueado por falta de diretiva de frame-src e domínio de mídia ausente de connect-src — não aparecem em nenhum teste unitário e produzem, respectivamente, taxa de cadastro zero e áudio pago que não toca em navegador nenhum.

E2E-20 — Sessão sobrevive à expiração do token de acesso (session-refresh.spec.ts)

  • Passos: entrar como assinante; avançar o relógio além da validade do token de acesso; navegar para outra tela do painel.
  • Sucesso: a renovação ocorre de forma transparente e o assinante continua autenticado. O teste inspeciona os cabeçalhos Set-Cookie emitidos no login e falha se algum cookie com prefixo __Host- tiver Path diferente de /, tiver Domain ou não tiver Secure — um cookie assim é descartado sem erro pelo navegador, e o sintoma em produção seria "o site desloga sozinho a cada 15 minutos", com causa raiz invisível.

24.8 Testes do motor de envio #

O motor de envio é o único componente que pode causar dano irreversível: mensagem enviada não volta. Ele tem uma bateria própria.

24.8.1 Como testar um lote sem enviar mensagem real #

Três camadas de proteção, todas ativas simultaneamente em ambiente de teste:

  1. Provedor substituível. O motor depende da interface WhatsAppProvider, nunca do cliente HTTP direto. Em teste injeta-se RecordingWhatsAppProvider, que grava a chamada em memória e devolve uma resposta configurável.
  2. Guarda de ambiente. O cliente HTTP real lança ProviderMisconfiguredError('REAL_PROVIDER_IN_TEST') no construtor se NODE_ENV === 'test' e ALLOW_REAL_WHATSAPP !== 'true'. Ninguém injeta o provedor real por engano.
  3. Lista de destinos permitidos. Quando WHATSAPP_ALLOWLIST está definida (ambiente local e staging), o motor recusa qualquer destino fora dela com RECIPIENT_NOT_ALLOWLISTED. Em produção a variável é vazia e a regra não se aplica. Um teste de integração afirma que, com a lista preenchida com um único número, um lote de 50 assinantes envia 1 mensagem e registra 49 recusas.

Modo --dry-run do planejamento: calcula o plano, grava send_batches com mode = 'DRY_RUN', cria as linhas de delivery_attempts com status = 'PLANNED_DRY_RUN' e não enfileira nada. A saída é um resumo por canal e por tier, mais o caminho de um CSV com o plano completo. É o comando usado em produção antes de qualquer mudança no motor.

24.8.2 Como avançar o relógio #

  • Testes unitários: vi.setSystemTime(new Date('2026-08-26T08:39:00Z')) com vi.useFakeTimers(). Usado para lógica pura.
  • Testes de integração: injeção de OffsetClock (Seção 24.7.2). O tempo do Postgres também precisa acompanhar; por isso nenhuma coluna usa DEFAULT now() para timestamps de negócio — todos são escritos pela aplicação a partir do Clock. created_at de auditoria pode usar now() do banco, e os testes que dependem dele usam janelas de tolerância, não igualdade.
  • BullMQ: o agendamento repetível não é simulado com espera real. O teste chama diretamente o processador (processPlanDailyBatch(job)) com um job falso. O teste I-36 afirma que o job repetível plan-daily-batch está registrado com o padrão cron 40 5 * * * e com tz: 'America/Sao_Paulo'. As duas asserções são obrigatórias e nenhuma delas é opcional: sem o fuso, o job dispara às 05:40 UTC, ou seja, 02:40 em Brasília, e os assinantes recebem de madrugada.
  • E2E: rota POST /api/internal/test/clock.

Teste obrigatório de fuso do agendador: com o relógio em 2026-08-26T08:39:59Z (05:39:59 local), o job ainda não rodou; em 2026-08-26T08:40:00Z, rodou. E um teste que afirma que o cron não usa offset fixo: a configuração é lida e comparada com a string 'America/Sao_Paulo'; qualquer -03:00 no código de agendamento é proibido por regra de lint.

Teste obrigatório de fuso do disparo, que é distinto do teste do agendador: com um lote planejado e pronto, o relógio é avançado até 06:00:00 no fuso America/Sao_Paulo e o teste afirma que nenhuma chamada ao provedor de gravação ocorreu antes desse instante e que a primeira ocorreu entre 06:00:00 e 06:00:30 locais. O mesmo teste é repetido com o processo configurado com TZ=UTC e exige resultado idêntico — é essa segunda execução que pega a conversão feita pelo relógio do servidor em vez do fuso nomeado. Sem esses dois casos, remover a espera entre o planejamento das 05:40 e o disparo das 06:00 passaria pela suíte inteira sem nenhum teste vermelho, e dez mil pessoas receberiam às 05:41.

24.8.3 Testes de idempotência do lote #

# Teste Afirmação
S-01 Planejamento duplo sequencial Planejar duas vezes para 2026-08-26 cria um send_batches e o mesmo conjunto de delivery_attempts
S-02 Planejamento duplo concorrente Duas chamadas em paralelo (Promise.all): uma vence, a outra encerra com BATCH_ALREADY_PLANNED; contagem final idêntica a S-01
S-03 Envio duplo do mesmo assinante Dois jobs com a mesma send:{id}:{date}: uma mensagem enviada, registrada no provedor de gravação; o segundo job termina como ALREADY_SENT sem chamar o provedor
S-04 Retry após timeout de rede O provedor lança timeout depois de a Meta ter aceitado. O retry é feito com o mesmo delivery_attempt; a deduplicação impede terceira tentativa. O teste afirma no máximo 2 chamadas ao provedor e exatamente 1 message_logs com wamid
S-05 Reinício do worker no meio do lote Processar 100 jobs, matar o worker no 43º, reiniciar, retomar o lote: total de mensagens enviadas é exatamente 100, sem duplicata
S-06 Chave estável entre reprocessamentos O reenvio de falhas reutiliza a chave existente; nenhum delivery_attempts novo é criado
S-07 Lote de data diferente send:{id}:2026-08-26 e send:{id}:2026-08-27 coexistem; a chave inclui a data e não colide
S-08 Falha parcial 100 jobs, 10 falham com 500 da Meta: 90 SENT, 10 RETRY_SCHEDULED; o lote fica PARTIAL; o reenvio de falhas processa só os 10
S-09 Ordem e taxa Com WHATSAPP_SEND_RATE_PER_SECOND=20, 200 jobs levam ao menos 10 s; o limitador do BullMQ é verificado por contagem de chamadas por segundo, com tolerância de ±10%
S-10 Interruptor geral durante o lote Ativar o interruptor da Seção 27.8 no meio: os jobs restantes terminam sem enviar e ficam HALTED; nenhum é perdido; desligar o interruptor e retomar completa o lote
S-11 Áudio ausente Assinante PAID cujo devocional não tem audio_assets: o texto é enviado, o áudio não, delivery_attempts.audio_status = 'MISSING' e um alerta é emitido
S-12 Janela fecha entre plano e envio Plano decidiu FREEFORM_BUNDLE; no envio a Meta devolve 131047: o motor reclassifica para template no mesmo dia, uma única vez, e registra a reclassificação
S-13 Opt-out durante o lote O item 43 de 100 recebe opt_out_at depois do planejamento e antes de o job dele rodar. O job termina como SKIPPED_OPTED_OUT, sem nenhuma chamada ao provedor, e o provedor de gravação registra exatamente 99 envios
S-14 Revogação durante o lote PAYMENT_OVERDUE é processado depois do planejamento e antes do job de um assinante planejado como PAID. O envio ocorre no formato gratuito, sem áudio, com delivery_attempts.downgraded_at preenchido e tier_at_send_effective = 'FREE'. O teste falha se qualquer chamada de envio de áudio for registrada
S-15 Exclusão durante o lote deleted_at é preenchido depois do planejamento. Nenhuma chamada ao provedor é feita para esse item, que termina como SKIPPED_INELIGIBLE
S-16 Bloqueio durante o lote blocked_at é preenchido depois do planejamento, por bloqueio administrativo ou por erro permanente da plataforma. O item termina como SKIPPED_INELIGIBLE, sem chamada ao provedor, e o assinante volta a receber assim que for desbloqueado

Os quatro casos S-13 a S-16 existem porque tier_at_send é registro histórico e nunca autoridade. O congelamento das 05:40, sem revalidação no disparo, concede na prática um dia de carência a todo inadimplente — exatamente o que a regra dura da Seção 13.3 proíbe. A revalidação é uma leitura por chave primária, abaixo de 1 ms, sobre um lote de no máximo 12.400 itens, e vale também para as varreduras de send.followup que vão até 23:55.

24.9 Testes de carga #

Ferramenta: k6. Executados contra staging, nunca contra produção, com o servidor de simulação no lugar dos externos (para medir o nosso sistema, não a latência da Meta).

Cenário Perfil de carga Meta
C-01 Lote diário de 15.000 envios Enfileirar 15.000 jobs de uma vez Lote concluído em ≤ 20 min; p95 de tempo por job ≤ 900 ms; nenhuma duplicata; memória do worker estável (variação < 15% após aquecimento)
C-02 Rajada de webhooks da Asaas 200 req/s por 60 s em /api/webhooks/asaas p99 < 2 s; 100% de 200; zero evento perdido; a fila de processamento drena em ≤ 5 min
C-03 Rajada de status da Meta 500 req/s por 60 s em /api/webhooks/whatsapp (lotes de 50 status por requisição) p99 < 2 s; 100% de 200; message_logs consistente ao final
C-04 Landing page 100 usuários virtuais por 5 min p95 de TTFB < 300 ms; taxa de erro < 0,1%
C-05 API do painel 200 usuários virtuais autenticados por 5 min em /api/devotionals e GET /api/me/subscription p95 < 400 ms; sem esgotar o pool do banco
C-06 Pico de checkout 20 checkouts/s por 2 min p95 < 1,5 s; nenhuma assinatura duplicada; nenhum deadlock no Postgres
C-07 Enxurrada de OTP 50 pedidos/s de números distintos por 60 s Limite por número respeitado; p95 < 500 ms; sem estouro de conexões Redis

O que é medido em todos: latência (p50/p95/p99), taxa de erro por code, uso de CPU e memória por contêiner, conexões ativas no Postgres, profundidade de fila no Redis, e tempo de drenagem da fila após o fim da carga.

// tools/load/daily-batch.js
import http from 'k6/http';
import exec from 'k6/execution';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

const enqueueLatency = new Trend('enqueue_latency_ms');
const duplicates = new Counter('duplicate_sends');

export const options = {
  scenarios: {
    burst: {
      executor: 'shared-iterations',
      vus: 50,
      iterations: 15000,
      maxDuration: '25m',
    },
  },
  thresholds: {
    // Latência de ENFILEIRAMENTO, medida no HTTP. Não é tempo por job.
    'http_req_duration{expected_response:true}': ['p(95)<500'],
    'http_req_failed': ['rate<0.001'],
    'duplicate_sends': ['count==0'],
  },
};

const BASE = __ENV.TARGET_BASE_URL;
const SECRET = __ENV.E2E_HOOK_SECRET;

export default function () {
  // Índice global ao cenário. `__ITER` é por usuário virtual e, com 50 VUs, colide
  // sistematicamente — o teste acusaria 14.700 "duplicatas" que ele mesmo criou.
  const i = exec.scenario.iterationInTest;
  const res = http.post(
    `${BASE}/api/internal/test/enqueue-send`,
    JSON.stringify({ subscriberIndex: i, devotionalDate: '2026-08-26' }),
    { headers: { 'Content-Type': 'application/json', 'x-e2e-secret': SECRET } },
  );
  enqueueLatency.add(res.timings.duration);
  check(res, { 'aceito': (r) => r.status === 202 });
  if (res.status === 409) duplicates.add(1);
  sleep(0.01);
}

Duas métricas distintas, dois limiares distintos. O http_req_duration acima mede apenas o enfileiramento, que é o que a requisição HTTP faz. O tempo por job é uma métrica própria, job_processing_ms, alimentada ao fim do cenário pela leitura da métrica de duração de job exposta pelo worker, com limiar p(95)<900. Misturar as duas em um único limiar mede a coisa errada e leva alguém a relaxar o limiar quando ele acusa. O critério de aprovação exige, além disso, que a diferença entre o instante do último envio aceito e o do primeiro fique abaixo de 1.200 s.

Critério de aprovação da bateria de carga: todos os thresholds verdes em duas execuções consecutivas, com o relatório anexado ao pull request que altera o motor de envio, o processamento de webhook ou o schema do banco.

24.10 Dados de teste #

24.10.1 Geração de assinantes sintéticos #

# 5.000 assinantes sintéticos: 70% FREE, 30% PAID, distribuição de engajamento realista
pnpm ops seed:synthetic --count=5000 --paid-ratio=0.30 --seed=20260825

O gerador é determinístico: a mesma --seed produz exatamente o mesmo conjunto. Regras:

Campo Regra de geração
phone_e164 Faixa reservada +5511990000000 a +5511999999999, atribuída sequencialmente. Nenhum número fora dessa faixa é gerado
wa_id 60% preenchido com o mesmo número sem +; 15% com a variante sem nono dígito; 25% nulo
name Combinação de listas fixas de nomes e sobrenomes brasileiros comuns, indexadas pela sequência
email assinante+{n}@exemplo.invalid — o TLD .invalid é reservado pela RFC 2606 e nunca resolve
cpf Gerado com dígitos verificadores válidos a partir de uma base sequencial. Válido matematicamente, não emitido a ninguém
tier / status Conforme --paid-ratio, com 5% em CANCELED com período futuro, 3% em EXPIRED, 2% com opt_out_at
service_window_expires_at 50% aberta (distribuída nas próximas 24 h), 35% fechada, 15% nula
consecutive_window_misses Distribuição: 60% zero, 25% entre 1 e 2, 10% entre 3 e 6, 5% acima de 7
created_at Espalhado nos últimos 18 meses, para exercitar o acervo e a retenção

24.10.2 Proibição de dados reais #

Regra absoluta, sem exceção e sem processo de aprovação:

  1. Nenhum dado pessoal de pessoa real entra em ambiente de desenvolvimento, teste, staging, fixture, gravação de contrato, log de CI, captura de tela ou anexo de issue. Isso inclui telefone, nome, e-mail, CPF, endereço e conteúdo de mensagem.
  2. Restauração de backup de produção em staging é proibida. Se for necessário reproduzir um problema com volume realista, usa-se o gerador sintético com a mesma cardinalidade. O procedimento de restauração da Seção 25.8.4 só existe para restaurar produção em produção.
  3. Investigação de caso individual é feita no ambiente de produção, por comando de leitura do CLI de operação, com saída redigida e registro em admin_audit_log. Nunca por cópia de dados para fora.
  4. Números e faixas reservados: telefones sintéticos usam apenas +5511990000000+5511999999999; e-mails usam apenas @exemplo.invalid; a única exceção é o número de teste real da Meta, usado na bateria manual da Seção 24.11 e declarado na variável de ambiente WHATSAPP_ALLOWLIST do ambiente de homologação. Números de teste não são chaves de settings: o catálogo de settings é fechado na Seção 26.8.1 e não recebe entrada de QA.
  5. O CI executa pnpm test:fixtures:lint, que falha se qualquer arquivo sob tools/testing/ contiver telefone brasileiro fora da faixa sintética, e-mail com TLD real, ou CPF que não venha do gerador determinístico.

24.11 Bateria manual obrigatória antes do lançamento #

Executada por pessoa, uma vez, com o resultado registrado em documents/ do repositório e anexado ao pull request de lançamento. Nenhuma linha pode ficar em branco.

Recursos declarados para a bateria:

Recurso Valor Onde fica configurado
Número de teste real (aparelho do operador) Em E.164, na variável WHATSAPP_ALLOWLIST do ambiente de homologação Ambiente de staging
Número de teste fornecido pela Meta O número de teste gratuito criado no aplicativo do WhatsApp Business Platform, associado à conta de desenvolvimento; também em WHATSAPP_ALLOWLIST Ambiente de staging
Conta sandbox da Asaas Conta criada no ambiente sandbox, com chave de API própria em ASAAS_API_KEY do ambiente staging e URL base https://api-sandbox.asaas.com/v3 Segredos de staging
Cartão de teste da Asaas sandbox O cartão de teste documentado pela Asaas para aprovação e o de recusa Roteiro do teste

O número de teste da Meta envia para no máximo 5 destinos cadastrados e não consome saldo, o que o torna adequado para os itens M-01 a M-12. Os itens M-13 a M-26 exigem, conforme o caso, a sandbox da Asaas, o ambiente de homologação ou o número de produção real, porque validam entrega, custo, qualidade e cadeia de alerta de verdade.

# Teste Como Critério de sucesso
M-01 Recebimento do template diário Disparar um envio individual usando o número de teste da Meta O template chega no aparelho com header, corpo, footer e dois botões, todos legíveis, sem {{1}} cru
M-02 Botão "Ler e ouvir agora" Tocar no botão O texto completo chega em até 10 s, com parágrafos preservados
M-03 Áudio no aparelho Continuar de M-02 com assinante PAID O áudio chega como mensagem de voz (com forma de onda e player), não como arquivo anexo; toca do início ao fim; duração entre 3 e 6 min
M-04 Áudio no iOS e no Android Repetir M-03 em um aparelho de cada Reprodução correta nos dois; sem erro de codec
M-05 Botão "Depois" Tocar em Depois Nenhuma mensagem adicional é enviada; o registro de SNOOZE aparece em inbound_messages
M-06 Atalho de janela aberta Responder qualquer coisa e disparar o envio do dia seguinte Nenhum template é consumido; o pacote free-form chega direto
M-07 Fallback de vídeo Forçar consecutive_window_misses = 3 e disparar Chega o template com header de vídeo; o vídeo toca com áudio audível e a capa correta
M-08 Opt-out Responder SAIR Confirmação chega uma única vez; o envio seguinte não ocorre
M-09 Reativação Responder VOLTAR Mensagem de retorno chega; o envio seguinte ocorre
M-10 OTP no WhatsApp Solicitar login por OTP Código de 6 dígitos chega em ≤ 30 s pelo template de autenticação; o botão de copiar código funciona
M-11 Limite de OTP Pedir 4 códigos em uma hora O 4º é bloqueado com a mensagem correta
M-12 Escrita de número não cadastrado Enviar mensagem de um número desconhecido para o número do serviço Resposta automática com instrução de cadastro; nenhum erro no log
M-13 Checkout com cartão na sandbox Assinar mensal com o cartão de teste de aprovação Cobrança criada; webhook PAYMENT_CONFIRMED recebido; acesso liberado em ≤ 60 s
M-14 Cartão recusado na sandbox Repetir com o cartão de recusa Mensagem de erro correta; nenhum acesso liberado
M-15 PIX na sandbox Assinar anual com PIX QR Code renderiza e é legível por leitor de celular; após simular o pagamento na sandbox, o acesso libera em ≤ 60 s
M-16 Inadimplência na sandbox Marcar a cobrança como vencida na sandbox Acesso revogado imediatamente; o painel exibe o aviso; o envio do dia seguinte é de tier FREE
M-17 Cancelamento na sandbox Cancelar pelo painel Assinatura removida na Asaas; acesso mantido até o fim do período
M-18 Envio real de produção com 5 pessoas Com o número de produção aprovado, cadastrar 5 pessoas da equipe e rodar um lote real por 3 dias consecutivos Taxa de entrega 100%; quality_rating permanece GREEN; custo por mensagem confere com a faixa esperada da Seção 17
M-19 Aprovação dos templates Conferir no painel da Meta Os oito templates — devocional_diario_v1, devocional_diario_video_v1, codigo_acesso_v1, boas_vindas_v1, lembrete_pagamento_v1, pagamento_confirmado_v1, acesso_encerrado_v1 e reativacao_v1 — com status registrado no banco igual ao exibido pela Meta, e categoria efetiva conferida. Ausência de qualquer um dos quatro últimos é documentada com o caminho alternativo ativo (Seção 28.9, item 5)
M-20 Interruptor geral Ligar o interruptor durante um lote de teste e depois desligar Envios param em ≤ 5 s; nenhuma mensagem perdida; ao religar, o lote conclui
M-21 Restauração de backup Executar o ensaio da Seção 25.8.5 em máquina separada Banco restaurado íntegro; contagens conferem; tempo dentro do previsto
M-22 Acessibilidade do painel Navegação por teclado e leitor de tela em /, /entrar, /assinar e /conta Todos os fluxos concluíveis; rótulos anunciados corretamente em português
M-23 Cadeia de alerta ponta a ponta Disparar um alerta sintético de severidade máxima em homologação e, em seguida, deixar de enviar o sinal de vida do lote por 15 minutos A mensagem chega ao canal em até 60 s; o e-mail chega à lista de plantão em até 5 min; a ligação toca no número registrado em até 3 min e o responsável confirma o atendimento; e a ausência do sinal de vida gera alerta pelo serviço externo com o servidor desligado. Este último passo é executado com a máquina efetivamente parada, porque é o único cenário em que o monitor interno não pode ajudar. Alerta que vai para um canal criado no espaço de trabalho pessoal de quem configurou, ou para um número desativado, é alerta inexistente
M-24 Supressão de alertas derivados Derrubar o processo de trabalho em homologação Os alertas derivados são suprimidos e apenas o alerta de origem chega ao canal
M-25 Turnstile no formulário real Abrir /cadastro e /contato em navegador real, com a política de conteúdo em modo de imposição, e completar um cadastro O widget anti-bot renderiza; o console do navegador fica sem nenhuma violação de política de conteúdo; o cadastro conclui. A verificação do lado do servidor alcança o destino externo sem bloqueio de egresso
M-26 Bucket privado de verdade Requisitar, sem assinatura, um objeto de áudio conhecido e um arquivo sob exports/ As duas requisições respondem 403. Nenhuma responde 200 nem 404 com corpo útil

24.12 Definição de pronto e critérios de aceite de pull request #

24.12.1 Definição de pronto para uma tarefa #

Uma tarefa só é considerada pronta quando todos os itens abaixo são verdadeiros:

  1. O comportamento especificado está implementado, incluindo os casos de borda descritos na seção do documento que define a funcionalidade.
  2. Existem testes automatizados novos ou alterados cobrindo o comportamento, no nível correto da pirâmide (Seção 24.2).
  3. pnpm test:all passa localmente.
  4. As metas de cobertura da Seção 24.2 são atendidas e a cobertura global não caiu.
  5. Erros novos estão catalogados na Seção 7 com code, status HTTP e mensagem em português.
  6. Textos visíveis ao usuário estão em português do Brasil e conferem com a Seção 10.
  7. Variáveis de ambiente novas estão registradas na Seção 26 e adicionadas ao .env.example, com valor seguro por padrão.
  8. Migrations, quando houver, são reversíveis ou seguem o procedimento de duas fases da Seção 25.11.
  9. Logs relevantes seguem o formato estruturado da Seção 23 e não contêm dado pessoal fora da política de redação.
  10. A documentação afetada no repositório (README.md e comentários de contrato) está atualizada.

24.12.2 Critérios de aceite de um pull request #

Bloqueantes, verificados pelo CI:

Verificação Comando Falha se
Formatação pnpm format:check Qualquer arquivo fora do padrão
Lint pnpm lint Qualquer erro ou aviso (--max-warnings=0)
Tipos pnpm typecheck Qualquer erro de tipo em qualquer workspace
Unitários + cobertura pnpm test:coverage Teste falhando ou meta de cobertura violada
Integração pnpm test:integration Teste falhando
Contrato pnpm test:contract Teste falhando ou gravação com mais de 150 dias
Build pnpm build Build de web, worker ou ops falhando
Segredos gitleaks detect Qualquer segredo detectado
Dependências pnpm audit --audit-level=high e osv-scanner Vulnerabilidade high ou critical sem exceção declarada
E2E pnpm test:e2e Qualquer cenário falhando (roda em main e em PRs que tocam apps/web)
Migration nova verificação própria Migration adicionada sem teste de integração correspondente
Módulo crítico verificação própria Alteração em qualquer arquivo listado na Seção 24.4 sem alteração no .test.ts correspondente

Bloqueantes, verificados por pessoa:

  1. Uma aprovação de revisor humano. Pull request não é auto-aprovado.
  2. A descrição do pull request explica o que muda no comportamento observável, não só o que mudou no código.
  3. Nenhum console.log, .only, .skip, @ts-ignore sem justificativa em comentário, nem any novo sem comentário explicando por que o tipo não pode ser expresso.
  4. Alterações no motor de envio, no processamento de webhook ou no schema do banco vêm com o relatório de carga da Seção 24.9 anexado.
  5. Alterações que afetam mensagens ao assinante vêm com captura de tela do WhatsApp real ou do simulador.

Limite de tamanho: pull request com mais de 800 linhas alteradas (excluindo lockfile, migrations geradas e gravações de contrato) recebe aviso automático pedindo divisão. Não é bloqueante, mas exige justificativa na descrição.

25. Infraestrutura, Deploy e CI/CD #

25.1 Topologia de referência #

Um único servidor virtual com Docker Compose atende o alvo de escala declarado na Seção 4.11. Não há Kubernetes, não há balanceador externo, não há serviço gerenciado de banco. A decisão é deliberada: o produto tem um pico previsível de 20 minutos por dia e o resto do tempo é quase ocioso. Complexidade operacional aqui só custaria dinheiro e tempo de resposta em incidente.

                          Internet
                             │
                             │  443/tcp, 443/udp (HTTP/3), 80/tcp
                             ▼
        ┌──────────────────────────────────────────────────────┐
        │  VPS  (Ubuntu LTS, Docker Engine, ufw)               │
        │                                                      │
        │  ┌────────────────────────────────────────────────┐  │
        │  │ rede: edge  (bridge)                           │  │
        │  │                                                │  │
        │  │   ┌──────────┐            ┌──────────────┐     │  │
        │  │   │  caddy   │──────────▶ │     web      │     │  │
        │  │   │  :80/:443│  :3000     │  Next.js     │     │  │
        │  │   │  TLS auto│            │  (2 réplicas)│     │  │
        │  │   └──────────┘            └──────┬───────┘     │  │
        │  └──────────────────────────────────┼─────────────┘  │
        │                                     │                │
        │  ┌──────────────────────────────────┼─────────────┐  │
        │  │ rede: backend  (bridge, internal)│             │  │
        │  │                                  │             │  │
        │  │   ┌──────────────┐   ┌───────────▼──┐          │  │
        │  │   │   worker     │   │   postgres   │          │  │
        │  │   │ BullMQ +     │──▶│   17         │          │  │
        │  │   │ Fastify :3001│   │   :5432      │          │  │
        │  │   └──────┬───────┘   └──────┬───────┘          │  │
        │  │          │                  │                  │  │
        │  │          ▼                  ▼                  │  │
        │  │   ┌──────────────┐   ┌──────────────┐          │  │
        │  │   │    redis     │   │  pgbackrest  │          │  │
        │  │   │    8  :6379  │   │  (auxiliar)  │          │  │
        │  │   └──────────────┘   └──────┬───────┘          │  │
        │  └─────────────────────────────┼──────────────────┘  │
        │                                │                     │
        │  volumes:                      │  saída 443/tcp      │
        │    pg_data   redis_data        │                     │
        │    caddy_data  caddy_config    │                     │
        └────────────────────────────────┼─────────────────────┘
                                         │
                    ┌────────────────────┴─────────────────────┐
                    ▼                                          ▼
        ┌───────────────────────┐              ┌──────────────────────────┐
        │  Bucket de mídia      │              │  Bucket de backup        │
        │  (S3-compatível,      │              │  (S3-compatível, OUTRO   │
        │   região BR/SA)       │              │   provedor/região)       │
        │  áudio OGG/MP3/MP4    │              │  pgBackRest + dumps      │
        └───────────────────────┘              └──────────────────────────┘

        Saídas HTTPS do worker e do web:
          graph.facebook.com   api.asaas.com   api.elevenlabs.io
          texttospeech.googleapis.com   api.resend.com   <bucket de mídia>

Contêineres em produção: caddy, web (2 réplicas), worker, postgres, redis, pgbackrest. O ops não roda como serviço: é um binário dentro da imagem do worker, invocado com docker compose exec worker ops <comando>.

Redes:

Rede Tipo Quem participa Observação
edge bridge caddy, web Única rede com portas publicadas no host
backend bridge, internal: true web, worker, postgres, redis, pgbackrest Sem rota para a internet de entrada; a saída é feita pelo NAT do host

Volumes:

Volume Conteúdo Crescimento
pg_data Diretório de dados do PostgreSQL Ver Seção 25.2.4
redis_data AOF e RDB do Redis < 1 GB
caddy_data Certificados TLS e estado ACME < 50 MB
caddy_config Configuração autogerada do Caddy < 10 MB
pgbackrest_spool Fila assíncrona de WAL < 2 GB

25.2 Dimensionamento #

25.2.1 O que dimensiona o sistema #

O pico é o lote diário. Fora dele o sistema é quase ocioso. As grandezas relevantes:

  • Envios no pico: para 10.000 assinantes (7.000 FREE, 3.000 PAID), um dia útil envia 3.000 mensagens; um domingo envia 3.000 + 7.000 = 10.000. O domingo é o pico real.
  • Taxa de envio: limitada por configuração a 20 mensagens por segundo. 10.000 mensagens levam 500 s ≈ 8,3 min de chamadas à API, dentro da janela de 20 minutos da Seção 18.14.
  • Concorrência de saída: 20 msg/s com latência média de 400 ms por chamada exige 8 requisições em voo. O worker usa concorrência 24 para ter folga com retries.
  • Webhooks de entrada: cada mensagem enviada gera 2 a 3 webhooks de status. 10.000 envios geram até 30.000 webhooks, concentrados nos 15 minutos seguintes ao lote — aproximadamente 33 requisições por segundo de pico.

25.2.2 CPU #

Componente Consumo no pico Base do cálculo
worker ~1,2 vCPU 20 msg/s, cada envio ~25 ms de CPU (serializar, assinar, log, gravar); mais o consumo de processar 33 webhooks/s a ~8 ms cada
web (2 réplicas) ~0,9 vCPU 33 webhooks/s a ~10 ms de CPU cada + tráfego web normal (pico de 40 req/s a ~15 ms)
postgres ~1,0 vCPU ~250 escritas/s no pico (envios + status + logs), cada uma barata; mais o cálculo de métricas diário
redis ~0,2 vCPU Fila com 10.000 jobs; operações O(1)
caddy ~0,2 vCPU Terminação TLS de tráfego modesto
Sistema, Docker, backup ~0,5 vCPU
Total no pico ~4,0 vCPU

Recomendação: 4 vCPU para 10.000 assinantes, com o entendimento de que o pico dura menos de 15 minutos por dia e que picos curtos de 100% são aceitáveis. Para 50.000 assinantes o cálculo escala quase linearmente na parte de envio (50.000 mensagens no domingo a 20 msg/s levariam 42 min, acima da janela de 20 min), então a taxa sobe para 45 msg/s e o total vai para 8 vCPU.

25.2.3 Memória #

Componente 1.000 10.000 50.000 Base
postgres 1,0 GB 2,0 GB 4,0 GB shared_buffers = 25% da RAM da máquina; work_mem × conexões
redis 256 MB 768 MB 2,0 GB 50.000 jobs × ~2 KB + limitadores e estado de disjuntor, com folga de 3×
web × 2 1,0 GB 1,4 GB 2,0 GB Next.js em produção estabiliza em ~500 MB por réplica com heap limitado
worker 512 MB 1,0 GB 1,5 GB Concorrência 24, buffers de áudio de até 16 MB, --max-old-space-size ajustado
caddy 64 MB 128 MB 256 MB
pgbackrest 128 MB 256 MB 512 MB Compressão e upload
Sistema 512 MB 768 MB 1,0 GB
Folga (25%) 0,9 GB 1,7 GB 2,9 GB Evita OOM killer durante backup + lote simultâneos
Total ~4,5 GB ~8,0 GB ~14,2 GB

Recomendação: 4 GB para 1.000, 8 GB para 10.000, 16 GB para 50.000.

25.2.4 Disco #

Tabela dominante: message_logs. Estimativa por linha, incluindo índices: ~420 bytes.

Perfil Mensagens/dia Linhas em 18 meses message_logs Demais tabelas Docker + logs Total com folga
1.000 ~1.000 ~547.000 ~0,23 GB ~0,3 GB ~20 GB 40 GB
10.000 ~10.000 (domingo) / ~3.000 (dia útil) ~2,4 M ~1,0 GB ~1,5 GB ~25 GB 80 GB
50.000 ~50.000 / ~15.000 ~12 M ~5,0 GB ~7 GB ~35 GB 160 GB

Detalhamento da coluna "Docker + logs": imagens (web ~420 MB, worker ~380 MB, mais Postgres, Redis, Caddy e as duas versões anteriores mantidas para rollback) ≈ 4 GB; logs JSON rotacionados a 20 MB × 5 arquivos × 6 serviços ≈ 0,6 GB; volume de WAL local antes do arquivamento ≈ 2 GB; espaço para dump lógico temporário ≈ 3 GB no perfil de 10.000. O restante é folga do sistema operacional, que nunca deve passar de 80% de uso — o alerta de disco da Seção 23 dispara em 75%.

Mídia não ocupa disco local: vai direto para o bucket. Volume de mídia: por devocional, 1 OGG (~1,4 MB), 1 MP3 (~4,5 MB) e 1 MP4 de fallback (~7 MB) ≈ 13 MB/dia ≈ 4,7 GB/ano, independente do número de assinantes.

25.2.5 Especificação recomendada #

Assinantes vCPU RAM Disco NVMe Banda mensal estimada
1.000 2 4 GB 40 GB ~200 GB
10.000 4 8 GB 80 GB ~600 GB
50.000 8 16 GB 160 GB ~2 TB

Sistema operacional: Ubuntu Server LTS, 64 bits, com kernel padrão. Sistema de arquivos ext4 com noatime. Swap de 2 GB configurado com vm.swappiness=10, apenas como rede de segurança contra OOM durante backup.

25.3 docker-compose.yml de produção #

name: palavra-diaria

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "20m"
    max-file: "5"

x-restart: &default-restart
  restart: unless-stopped

services:
  caddy:
    <<: *default-restart
    image: caddy:2-alpine
    container_name: pd-caddy
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - edge
    depends_on:
      web:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:2019/config/"]
      interval: 30s
      timeout: 5s
      retries: 3
    logging: *default-logging

  web:
    <<: *default-restart
    image: ${IMAGE_REGISTRY}/web:${IMAGE_TAG}
    env_file:
      - ./.env.production
    environment:
      NODE_ENV: production
      PORT: "3000"
      HOSTNAME: "0.0.0.0"
      RUN_MIGRATIONS: "true"
      NODE_OPTIONS: "--max-old-space-size=640"
    networks:
      - edge
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "/app/docker/healthcheck.js", "http://127.0.0.1:3000/api/internal/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 60s
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "1.5"
          memory: 768M
      update_config:
        order: start-first
    stop_grace_period: 30s
    logging: *default-logging

  worker:
    <<: *default-restart
    image: ${IMAGE_REGISTRY}/worker:${IMAGE_TAG}
    container_name: pd-worker
    env_file:
      - ./.env.production
    environment:
      NODE_ENV: production
      WORKER_HTTP_PORT: "3001"
      RUN_MIGRATIONS: "false"
      NODE_OPTIONS: "--max-old-space-size=1024"
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      web:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "/app/docker/healthcheck.js", "http://127.0.0.1:3001/api/internal/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 40s
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1536M
    # 120 s: tempo suficiente para o job em curso terminar sem duplicar envio.
    stop_grace_period: 120s
    stop_signal: SIGTERM
    logging: *default-logging

  postgres:
    <<: *default-restart
    image: postgres:17-alpine
    container_name: pd-postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_INITDB_ARGS: "--data-checksums --encoding=UTF8"
      PGDATA: /var/lib/postgresql/data/pgdata
    command:
      - "postgres"
      - "-c"
      - "config_file=/etc/postgresql/postgresql.conf"
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
      - ./postgres/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
      - pgbackrest_spool:/var/spool/pgbackrest
      - ./pgbackrest/pgbackrest.conf:/etc/pgbackrest/pgbackrest.conf:ro
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    shm_size: 512mb
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2560M
    stop_grace_period: 60s
    logging: *default-logging

  redis:
    <<: *default-restart
    image: redis:8-alpine
    container_name: pd-redis
    command:
      - "redis-server"
      - "/usr/local/etc/redis/redis.conf"
      - "--requirepass"
      - "${REDIS_PASSWORD}"
    volumes:
      - redis_data:/data
      - ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
    networks:
      - backend
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1024M
    logging: *default-logging

  pgbackrest:
    <<: *default-restart
    image: ${IMAGE_REGISTRY}/pgbackrest:${IMAGE_TAG}
    container_name: pd-pgbackrest
    env_file:
      - ./.env.production
    volumes:
      - pg_data:/var/lib/postgresql/data
      - pgbackrest_spool:/var/spool/pgbackrest
      - ./pgbackrest/pgbackrest.conf:/etc/pgbackrest/pgbackrest.conf:ro
    networks:
      - backend
    depends_on:
      postgres:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging: *default-logging

networks:
  edge:
    driver: bridge
  backend:
    driver: bridge
    internal: true

volumes:
  pg_data:
  redis_data:
  caddy_data:
  caddy_config:
  pgbackrest_spool:

Decisões registradas neste arquivo:

  • Nenhuma porta de banco ou Redis publicada no host. O acesso administrativo é feito por túnel SSH combinado com docker compose exec, nunca por exposição direta. A rede backend é internal: true.
  • web com replicas: 2 e update_config.order: start-first permite troca sem queda: a nova réplica sobe e passa no health check antes de a antiga sair.
  • stop_grace_period: 120s no worker é o valor que garante que um job de envio em curso termine. Um SIGKILL no meio de um envio não duplicaria mensagem (a idempotência cobre), mas deixaria delivery_attempts em estado IN_FLIGHT que exigiria varredura.
  • RUN_MIGRATIONS: "true" apenas no web, conforme a decisão de aplicar migrations no start do web com lock. O worker nunca aplica migration.
  • shm_size: 512mb no Postgres evita erro de memória compartilhada em consultas com paralelismo, que a imagem padrão limita a 64 MB.

25.4 Dockerfile do web #

# syntax=docker/dockerfile:1.7

# ---------- base ----------
FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable && apk add --no-cache libc6-compat tini
WORKDIR /app

# ---------- deps ----------
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY apps/web/package.json ./apps/web/
COPY apps/worker/package.json ./apps/worker/
COPY apps/ops/package.json ./apps/ops/
COPY packages/db/package.json ./packages/db/
COPY packages/core/package.json ./packages/core/
COPY packages/integrations/package.json ./packages/integrations/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

# ---------- build ----------
FROM deps AS build
COPY . .
RUN pnpm --filter @palavra-diaria/db exec prisma generate
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @palavra-diaria/web build

# ---------- runtime ----------
FROM node:24-alpine AS runtime
RUN apk add --no-cache tini && \
    addgroup -g 1001 -S nodejs && \
    adduser -u 1001 -S nextjs -G nodejs
WORKDIR /app
ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

# Saída standalone do Next.js: apenas o necessário para executar.
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
# Migrations e cliente Prisma: o web aplica migrations no start.
COPY --from=build --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
COPY --from=build --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --chown=nextjs:nodejs docker/web-entrypoint.sh /app/docker/web-entrypoint.sh
COPY --chown=nextjs:nodejs docker/healthcheck.js /app/docker/healthcheck.js
RUN chmod +x /app/docker/web-entrypoint.sh

USER nextjs
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--", "/app/docker/web-entrypoint.sh"]
CMD ["node", "apps/web/server.js"]

25.5 Dockerfile do worker #

# syntax=docker/dockerfile:1.7

FROM node:24-alpine AS base
ENV PNPM_HOME="/pnpm" PATH="/pnpm:$PATH"
RUN corepack enable && apk add --no-cache libc6-compat tini
WORKDIR /app

FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY apps/worker/package.json ./apps/worker/
COPY apps/ops/package.json ./apps/ops/
COPY packages/db/package.json ./packages/db/
COPY packages/core/package.json ./packages/core/
COPY packages/integrations/package.json ./packages/integrations/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

FROM deps AS build
COPY . .
RUN pnpm --filter @palavra-diaria/db exec prisma generate
RUN pnpm --filter @palavra-diaria/worker build && \
    pnpm --filter @palavra-diaria/ops build
# Descarta dependências de desenvolvimento antes de copiar para a imagem final.
RUN pnpm --filter @palavra-diaria/worker --prod deploy /tmp/worker-prod && \
    pnpm --filter @palavra-diaria/ops --prod deploy /tmp/ops-prod

FROM node:24-alpine AS runtime
# ffmpeg é requisito do pipeline de áudio; é o único binário de SO necessário.
RUN apk add --no-cache tini ffmpeg curl && \
    addgroup -g 1001 -S nodejs && \
    adduser -u 1001 -S worker -G nodejs
WORKDIR /app
ENV NODE_ENV=production \
    WORKER_HTTP_PORT=3001

COPY --from=build --chown=worker:nodejs /tmp/worker-prod ./
COPY --from=build --chown=worker:nodejs /tmp/ops-prod /opt/ops
COPY --from=build --chown=worker:nodejs /app/packages/db/prisma ./prisma
COPY --chown=worker:nodejs docker/worker-entrypoint.sh /app/docker/worker-entrypoint.sh
COPY --chown=worker:nodejs docker/healthcheck.js /app/docker/healthcheck.js
RUN chmod +x /app/docker/worker-entrypoint.sh && \
    printf '#!/bin/sh\nexec node /opt/ops/dist/index.js "$@"\n' > /usr/local/bin/ops && \
    chmod +x /usr/local/bin/ops

USER worker
EXPOSE 3001
ENTRYPOINT ["/sbin/tini", "--", "/app/docker/worker-entrypoint.sh"]
CMD ["node", "dist/index.js"]

O binário ops é instalado em /usr/local/bin/ops dentro da imagem do worker. Todos os runbooks da Seção 27 o invocam como docker compose exec worker ops <comando>.

25.6 Scripts de inicialização #

25.6.1 docker/web-entrypoint.sh #

#!/bin/sh
set -eu

log() { printf '{"level":"info","svc":"web-entrypoint","msg":"%s"}\n' "$1"; }
fail() { printf '{"level":"fatal","svc":"web-entrypoint","msg":"%s"}\n' "$1" >&2; exit 1; }

# Guarda dura: ganchos de teste nunca podem existir em produção.
if [ "${E2E_TEST_HOOKS:-false}" = "true" ] && [ "${NODE_ENV}" = "production" ] \
   && [ "${ALLOW_TEST_HOOKS_IN_PROD_BUILD:-false}" != "true" ]; then
  fail "test hooks are forbidden in production"
fi

# Variáveis obrigatórias. Falhar no start é melhor do que falhar no primeiro pedido.
for v in DATABASE_URL REDIS_URL SESSION_JWT_PRIVATE_KEY ASAAS_API_KEY ASAAS_WEBHOOK_TOKEN \
         META_APP_SECRET WHATSAPP_SYSTEM_USER_TOKEN WHATSAPP_VERIFY_TOKEN \
         WHATSAPP_PHONE_NUMBER_ID ENCRYPTION_KEY PHONE_INDEX_KEY \
         S3_BUCKET S3_ENDPOINT APP_URL PUBLIC_SITE_URL; do
  eval "value=\${$v:-}"
  [ -n "$value" ] || fail "missing required env var: $v"
done

if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then
  log "acquiring migration advisory lock"
  # Lock de aplicação: garante que só uma réplica aplica migrations.
  # A chave 8149021 é arbitrária, fixa e exclusiva deste sistema.
  node -e '
    const { Client } = require("pg");
    const { execFileSync } = require("child_process");
    (async () => {
      const c = new Client({ connectionString: process.env.DATABASE_URL });
      await c.connect();
      const deadline = Date.now() + 120000;
      for (;;) {
        const { rows } = await c.query("SELECT pg_try_advisory_lock(8149021) AS ok");
        if (rows[0].ok) break;
        if (Date.now() > deadline) { console.error("migration lock timeout"); process.exit(1); }
        await new Promise((r) => setTimeout(r, 2000));
      }
      try {
        execFileSync("node", ["node_modules/prisma/build/index.js", "migrate", "deploy",
                              "--schema", "packages/db/prisma/schema.prisma"],
                     { stdio: "inherit" });
      } finally {
        await c.query("SELECT pg_advisory_unlock(8149021)");
        await c.end();
      }
    })().catch((e) => { console.error(e); process.exit(1); });
  ' || fail "migration failed"
  log "migrations applied"
fi

log "starting web"
exec "$@"

Os nomes acima são exatamente os nomes canônicos do registro de variáveis da Seção 26.3, que é a dona. A chave privada de assinatura de sessão tem um nome, SESSION_JWT_PRIVATE_KEY; SESSION_JWK_PRIVATE e JWT_PRIVATE_KEY são apelidos proibidos e não aparecem em lugar nenhum. PHONE_INDEX_KEY é obrigatória desde o primeiro boot porque os índices cegos das colunas cifradas existem desde a primeira migration (Seção 6.3).

25.6.2 docker/worker-entrypoint.sh #

#!/bin/sh
set -eu

fail() { printf '{"level":"fatal","svc":"worker-entrypoint","msg":"%s"}\n' "$1" >&2; exit 1; }

for v in DATABASE_URL REDIS_URL ASAAS_API_KEY WHATSAPP_SYSTEM_USER_TOKEN \
         WHATSAPP_PHONE_NUMBER_ID ELEVENLABS_API_KEY ENCRYPTION_KEY PHONE_INDEX_KEY \
         S3_BUCKET S3_ENDPOINT; do
  eval "value=\${$v:-}"
  [ -n "$value" ] || fail "missing required env var: $v"
done

command -v ffmpeg >/dev/null 2>&1 || fail "ffmpeg not found in image"

# O worker espera o schema estar na versão esperada. Ele não aplica migrations.
node dist/check-schema-version.js || fail "database schema is behind the expected version"

exec "$@"

check-schema-version.js compara o último migration_name em _prisma_migrations com o valor embutido na imagem no momento do build. Se o worker subir antes de o web aplicar a migration, ele falha e reinicia até que a versão bata. Isso evita o pior caso de deploy: worker novo escrevendo em schema antigo.

25.6.3 docker/healthcheck.js #

const url = process.argv[2];
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 4000);
fetch(url, { signal: ac.signal })
  .then((r) => { clearTimeout(timer); process.exit(r.ok ? 0 : 1); })
  .catch(() => { clearTimeout(timer); process.exit(1); });

25.6.4 Comandos do CLI de operação #

O ops é a interface única de operação. Todo comando registra execução em job_runs com ator, argumentos redigidos, resultado e duração.

Comando Efeito
ops health [--strict] Verifica banco, Redis, Meta, Asaas, TTS e storage; devolve JSON e código de saída
ops send:plan --date=<YYYY-MM-DD> [--dry-run] [--only-channel=<canal>] Planeja o lote de uma data
ops send:resume --batch=<id> [--limit=N] Retoma um lote interrompido
ops send:retry-failed --batch=<id> [--code=<erro>] Reenfileira apenas as falhas
ops send:one --subscriber=<id> --date=<YYYY-MM-DD> [--force --reason=<texto>] Envia para um único assinante
ops send:audio-catchup --date=<YYYY-MM-DD> Entrega o áudio pendente a quem está com a janela aberta
ops send:abandon --batch=<id> --reason=<texto> Encerra um lote sem enviar o restante
ops kill-switch:on --reason=<texto> / off [--no-resume] / status Interruptor geral (Seção 27.8)
ops queue:stats [--queue=<nome>] [--all] Contagem por estado de cada fila
ops queue:active --queue=<nome> --older-than=<segundos> Jobs ativos há muito tempo
ops queue:repeatable --queue=<nome> Agendamentos repetíveis registrados
ops queue:unstall --queue=<nome> Libera jobs travados
ops queue:dead:list --queue=<nome> [--limit=N] [--group-by=code] Lista o trabalho morto: jobs no estado failed da fila com linha job_runs.status = 'DEAD'. Não existe fila .dlq separada (Seção 18.8)
ops queue:dead:replay --queue=<nome> (--id=<jobId> | --code=<código> | --all) [--dry-run] [--force] Reprocessa trabalho morto a partir de job_runs
ops queue:dead:purge --queue=<nome> --older-than=<dias> --reason=<texto> Descarta registros de trabalho morto antigos
ops queue:recover [--all] Reenfileira o que ficou pendente no banco
ops billing:reconcile [--since=<data>] [--subscription=<id>] [--fix] [--dry-run] [--verbose] Reconciliação Asaas × banco
ops billing:replay-events (--since=<ISO> | --event-id=<id>) Reprocessa payment_events persistidos
ops billing:pause-all --reason=<texto> Suspende cobranças recorrentes
ops subscription:extend-all --days=N Estende current_period_end de todos os ativos
ops subscription:force-activate --id=<id> --until=<data> --reason=<texto> Libera acesso manualmente, com auditoria
ops subscriber:inspect --phone=<E.164> Estado completo de um assinante, com dados redigidos
ops subscriber:entitlements --id=<id> Resultado de resolveEntitlements
ops subscriber:suppress --code=<erro> --batch=<id> Marca falhas permanentes como suprimidas
ops devotional:transition --id=<id> --to=<estado> / devotional:publish --id=<id> / devotional:reuse --source=<id> --for-date=<data> Operações editoriais de emergência
ops tts:generate --devotional=<id> [--force] [--provider=primary|fallback] / ops tts:script --devotional=<id> --stats Áudio
ops media:reupload --devotional=<id> Reenvia mídia à Meta e atualiza o identificador
ops template:sync / ops template:submit --name=<novo> --from=<existente> Templates da Meta
ops whatsapp:status / ops whatsapp:token-info Qualidade, limites e validade do token
ops storage:check Testa list, put e get no bucket
ops circuit:status / ops circuit:reset --name=<integração> Estado e reset de disjuntor
ops backup:run --type=full|diff / ops backup:verify [--post-restore|--drill] / ops backup:restore --to=<ISO> Backup e restauração
ops db:create-index-concurrently --name=<idx> --sql=<comando> Criação de índice fora da migration
ops retention:run [--dry-run] Aplica a política de retenção
ops metrics:recompute --date=<YYYY-MM-DD> Recalcula daily_metrics
ops consent:export --sample=N --format=csv Evidência de opt-in para apelação
ops config:set --key=<chave> --value=<valor> Ajusta configuração dinâmica em settings
ops secrets:audit Confere permissões, presença e validade dos segredos
ops deploy:mark --tag=<tag> --phase=start|end [--result=<status>] Marcação de deploy
ops dr:preflight Confere os pré-requisitos de recuperação de desastre
ops seed:synthetic --count=N --paid-ratio=R --seed=S / ops seed:e2e Dados sintéticos (apenas fora de produção)

ops seed:synthetic e ops seed:e2e recusam executar se NODE_ENV=production, sem opção de forçar.

25.7 Rede, domínios e firewall #

25.7.1 Domínios #

Domínio Destino Uso
palavradiaria.com.br web Landing page, páginas públicas, política de privacidade e termos
www.palavradiaria.com.br redirecionamento 301 para o apex Evita conteúdo duplicado
app.palavradiaria.com.br web Painel do assinante e painel administrativo
api.palavradiaria.com.br web Webhooks da Asaas e da Meta e integrações máquina a máquina

Decisão: os três subdomínios apontam para o mesmo contêiner web. A separação existe para isolar política de cookie, cabeçalhos de segurança e regras de limite de taxa, e para permitir mover a API para outra máquina no futuro sem trocar URL de webhook.

O cookie de sessão usa o prefixo __Host-, que obriga Secure, Path=/ e proíbe o atributo Domain. Consequência prática: a sessão emitida em app.palavradiaria.com.br não é enviada para api.palavradiaria.com.br. Por isso as rotas de API consumidas pelo painel são servidas sob app.palavradiaria.com.br/api/*, e api.palavradiaria.com.br é reservado exclusivamente para webhooks de entrada e integrações que usam token próprio e não dependem de cookie. Esta é uma decisão de arquitetura de domínio; o contrato das rotas é da Seção 7.

TLS: certificados emitidos e renovados automaticamente pelo Caddy via ACME. Renovação começa 30 dias antes do vencimento. HSTS com max-age=31536000, includeSubDomains e preload.

25.7.2 Caddyfile #

{
	email ops@palavradiaria.com.br
	admin 127.0.0.1:2019
	servers {
		protocols h1 h2 h3
		trusted_proxies static private_ranges
	}
	log {
		output stdout
		format json
		level INFO
	}
}

(security_headers) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()"
		Cross-Origin-Opener-Policy "same-origin"
		-Server
		-X-Powered-By
	}
}

(common) {
	import security_headers
	encode zstd gzip
	request_body {
		max_size 5MB
	}
	log {
		output stdout
		format json
	}
}

# ---------- Landing pública ----------
palavradiaria.com.br {
	import common
	reverse_proxy web:3000 {
		lb_policy round_robin
		health_uri /api/internal/health
		health_interval 10s
		health_timeout 3s
		fail_duration 30s
		transport http {
			dial_timeout 3s
			response_header_timeout 20s
		}
	}
}

www.palavradiaria.com.br {
	import security_headers
	redir https://palavradiaria.com.br{uri} permanent
}

# ---------- Painéis ----------
app.palavradiaria.com.br {
	import common
	# A politica de conteudo NAO e definida aqui. Ela carrega um nonce por
	# requisicao e varia por rota, o que um cabecalho estatico nao consegue
	# expressar. Quem a emite e a aplicacao, com os valores exatos da Secao 22.3.1.
	# Duplicar a politica no proxy criaria uma segunda verdade que diverge na
	# primeira mudanca e bloqueia silenciosamente o verificador anti-bot ou o
	# player de audio.

	# Prontidão só com token do monitor externo; a matriz de dependências,
	# com latências e estado de disjuntor, nunca é pública.
	@ready {
		path /api/internal/ready
		header Authorization Bearer*
	}
	handle @ready {
		reverse_proxy web:3000
	}

	# Vivacidade é pública e devolve apenas {"status":"ok"}.
	@health path /api/internal/health
	handle @health {
		rate_limit {
			zone health {
				key {remote_host}
				events 60
				window 1m
			}
		}
		reverse_proxy web:3000
	}

	# Todo o restante de /api/internal/* nao existe para a internet.
	@internal path /api/internal/*
	handle @internal {
		respond 404
	}

	handle {
		reverse_proxy web:3000 {
			lb_policy round_robin
			health_uri /api/internal/health
			health_interval 10s
		}
	}
}

# ---------- Webhooks e integração máquina a máquina ----------
api.palavradiaria.com.br {
	import common

	# Webhooks precisam do corpo cru para validação de assinatura.
	# Nenhuma reescrita, nenhuma transformação de corpo.
	@webhooks path /api/webhooks/*
	handle @webhooks {
		reverse_proxy web:3000 {
			transport http {
				dial_timeout 3s
				response_header_timeout 5s
			}
		}
	}

	# Prontidão só com token do monitor externo; a matriz de dependências,
	# com latências e estado de disjuntor, nunca é pública.
	@ready {
		path /api/internal/ready
		header Authorization Bearer*
	}
	handle @ready {
		reverse_proxy web:3000
	}

	# Vivacidade é pública e devolve apenas {"status":"ok"}.
	@health path /api/internal/health
	handle @health {
		rate_limit {
			zone health {
				key {remote_host}
				events 60
				window 1m
			}
		}
		reverse_proxy web:3000
	}

	# Todo o restante de /api/internal/* nao existe para a internet.
	@internal path /api/internal/*
	handle @internal {
		respond 404
	}

	handle {
		reverse_proxy web:3000 {
			lb_policy round_robin
			health_uri /api/internal/health
			health_interval 10s
		}
	}
}

Quatro decisões registradas no arquivo:

  1. /api/internal/* devolve 404 na borda, sem chegar à aplicação, com duas exceções nomeadas. É defesa em profundidade: mesmo que os ganchos de teste da Seção 24.7.1 fossem habilitados por engano, não haveria caminho de rede até eles — /api/internal/test/* nunca é publicado, em nenhuma circunstância. A primeira exceção é /api/internal/health, público e limitado a 60 requisições por minuto por endereço, que devolve exclusivamente {"status":"ok"} — sem versão, sem nome de serviço e sem tempo de atividade, porque esses três campos revelam exatamente quando cada implantação ocorre. A segunda é /api/internal/ready, que só é roteado quando o monitor externo apresenta Authorization: Bearer <METRICS_TOKEN>; sem o token, o caminho responde 404 como o restante. Motivo: a matriz de prontidão expõe o estado de degradação de cada provedor, e um atacante que a consulte de minuto em minuto sabe o instante exato em que uma campanha de mensagem falsa sobre falha de pagamento seria mais convincente.
  2. Webhooks têm response_header_timeout de 5 s, contra os 20 s do resto. Asaas e Meta desistem antes disso; segurar a conexão só piora.
  3. request_body max_size 5MB, exatamente o valor de WEBHOOK_MAX_BODY_BYTES (5242880) declarado no registro da Seção 26.3 e no teto de corpo de webhook da Seção 7.14. O proxy é o teto externo e a aplicação é o teto interno, e o externo nunca é menor que o interno: se fosse, um corpo aceito pela aplicação seria recusado na borda por um componente que a Seção 12 não sabe que existe, e o provedor receberia 413 sem explicação. Nenhuma rota legítima recebe corpo maior; o upload de mídia editorial é feito por URL assinada direto para o bucket, sem passar pelo servidor. O webhook que exceder esse tamanho recebe 413, medido antes da leitura do corpo — é a única resposta não-2xx permitida em webhook autenticado (Seção 7.15.1). As rotas gerais continuam limitadas a MAX_BODY_BYTES (256 KB) e as editoriais a MAX_BODY_BYTES_ADMIN (1 MB), ambas aplicadas dentro da aplicação.
  4. A política de segurança de conteúdo não é emitida pelo proxy. Ela carrega um nonce por requisição e varia por rota, e um cabeçalho estático não expressa nenhuma das duas coisas. Quem a emite é a aplicação, com os valores exatos da Seção 22.3.1, que é a dona. Os demais cabeçalhos de segurança, que são constantes, continuam no proxy.

25.7.3 Firewall #

# Regras aplicadas no host, uma única vez, na provisão.
ufw default deny incoming
ufw default allow outgoing
ufw allow from <IP_DE_OPERACAO_1> to any port 22 proto tcp comment 'SSH operacao 1'
ufw allow from <IP_DE_OPERACAO_2> to any port 22 proto tcp comment 'SSH operacao 2'
ufw allow 80/tcp  comment 'HTTP (redirect + ACME)'
ufw allow 443/tcp comment 'HTTPS'
ufw allow 443/udp comment 'HTTP/3'
ufw --force enable
Porta Protocolo Origem Motivo
22 tcp Apenas IPs de operação declarados Administração
80 tcp Qualquer Desafio ACME e redirecionamento para HTTPS
443 tcp Qualquer HTTPS
443 udp Qualquer HTTP/3 (QUIC)
5432, 6379, 3000, 3001, 2019 Nenhuma Não publicadas no host

Armadilha conhecida e sua correção obrigatória: o Docker escreve regras diretamente na cadeia DOCKER-USER do iptables e contorna o ufw. Uma porta publicada com ports: "5432:5432" fica acessível na internet mesmo com ufw deny. Duas proteções são aplicadas em conjunto:

  1. Nenhum serviço além do caddy publica portas — é o que o arquivo da Seção 25.3 faz.
  2. Regra explícita na cadeia DOCKER-USER, aplicada na provisão e verificada semanalmente pelo job de conformidade:
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 5432 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 6379 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 3000 -j DROP
iptables -I DOCKER-USER -i eth0 ! -s 127.0.0.1 -p tcp --dport 3001 -j DROP
netfilter-persistent save

Acesso SSH: apenas chave, PasswordAuthentication no, PermitRootLogin no, usuário deploy com comando restrito para o CI e usuário nominal para operação humana. fail2ban ativo com a jail padrão de SSH.

Saída: irrestrita, porque o worker precisa alcançar Meta, Asaas, TTS, storage e e-mail, e todos esses serviços usam faixas de IP variáveis. Isso é registrado como decisão consciente; a alternativa (lista branca de destinos) geraria incidentes de entrega sem ganho real de segurança para este produto.

25.8 PostgreSQL em produção #

25.8.1 postgres/postgresql.conf #

Valores calculados para a máquina de 4 vCPU / 8 GB do perfil de 10.000 assinantes. Para 16 GB, dobrar shared_buffers, effective_cache_size e maintenance_work_mem.

listen_addresses = '*'
port = 5432
max_connections = 120

# --- Memoria ---
shared_buffers = 2GB                  # 25% da RAM da maquina
effective_cache_size = 5GB            # estimativa do cache do SO + shared_buffers
work_mem = 12MB                       # por operacao de ordenacao
maintenance_work_mem = 512MB
huge_pages = try

# --- Escrita ---
wal_level = replica                   # exigido pelo arquivamento de WAL
max_wal_size = 4GB
min_wal_size = 1GB
checkpoint_completion_target = 0.9
wal_compression = zstd
synchronous_commit = on               # dinheiro envolvido; nao relaxar

# --- Arquivamento (backup continuo) ---
archive_mode = on
archive_command = 'pgbackrest --stanza=palavra-diaria archive-push %p'
archive_timeout = 60s                 # limita a perda maxima a 60 s de WAL

# --- Planejador (disco NVMe) ---
random_page_cost = 1.1
effective_io_concurrency = 200
default_statistics_target = 200

# --- Paralelismo ---
max_worker_processes = 4
max_parallel_workers = 4
max_parallel_workers_per_gather = 2
max_parallel_maintenance_workers = 2

# --- Autovacuum: tabelas de log crescem rapido ---
autovacuum = on
autovacuum_max_workers = 3
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 2000

# --- Observabilidade ---
log_destination = 'stderr'
logging_collector = off               # o Docker coleta o stderr
log_min_duration_statement = 500ms
log_checkpoints = on
log_connections = off
log_disconnections = off
log_lock_waits = on
log_temp_files = 10MB
log_autovacuum_min_duration = 1s
log_line_prefix = '%m [%p] %q%u@%d %a '
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 5000
pg_stat_statements.track = top

# --- Seguranca de sessao ---
statement_timeout = 15s               # nenhuma consulta de aplicacao passa disso
idle_in_transaction_session_timeout = 30s
lock_timeout = 5s
timezone = 'UTC'

statement_timeout = 15s é limite global. Os jobs de manutenção que legitimamente demoram mais (recálculo de métricas, retenção, VACUUM manual) elevam o valor na própria sessão com SET LOCAL statement_timeout = '10min'. Isso força cada consulta lenta a ser uma decisão explícita.

postgres/pg_hba.conf aceita apenas scram-sha-256 a partir da rede backend:

local   all   all                     scram-sha-256
host    all   all   172.16.0.0/12     scram-sha-256
host    all   all   127.0.0.1/32      scram-sha-256

25.8.2 Pool de conexões #

Sem pgbouncer. O número de processos é conhecido e pequeno; um pool intermediário adicionaria um ponto de falha e complicaria transações. A decisão é revista se o número de réplicas de web passar de 4.

Distribuição de max_connections = 120:

Consumidor Conexões Cálculo
web réplica 1 15 connection_limit=15 na URL do Prisma
web réplica 2 15
worker (pool principal) 30 Concorrência 24 + folga
worker (pool de manutenção) 5 Jobs longos, isolados do pool principal
ops (execuções manuais) 10
pgbackrest 3
Reservado ao superusuário 3 superuser_reserved_connections
Folga 39 Absorve reinício com sobreposição de réplicas

URL do Prisma em produção:

postgresql://app:<senha>@postgres:5432/palavra_diaria?schema=public&connection_limit=15&pool_timeout=10&connect_timeout=5&application_name=web-1

application_name distinto por processo é obrigatório: é o que permite identificar em pg_stat_activity qual componente está segurando conexões durante o runbook R-14.

O usuário da aplicação (app) não é superusuário e não é dono do schema. Ele tem SELECT, INSERT, UPDATE, DELETE nas tabelas e USAGE nas sequências. O dono do schema é o usuário migrator, usado exclusivamente pelo passo de migration. Um DROP TABLE acidental por parte da aplicação é impossível por permissão, não por disciplina.

25.8.3 Backup #

Duas camadas independentes, com destinos diferentes.

Camada 1 — pgBackRest (recuperação a ponto no tempo).

# pgbackrest/pgbackrest.conf
[global]
repo1-type=s3
repo1-path=/palavra-diaria
repo1-s3-bucket=pd-backups
repo1-s3-endpoint=<endpoint-do-provedor-de-backup>
repo1-s3-region=<regiao>
repo1-s3-key=<lido de /run/secrets/backup_s3_key>
repo1-s3-key-secret=<lido de /run/secrets/backup_s3_secret>
repo1-s3-uri-style=path
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<lido de /run/secrets/backup_cipher_pass>
repo1-retention-full=5
repo1-retention-full-type=count
repo1-retention-diff=6
repo1-bundle=y
repo1-block=y
compress-type=zst
compress-level=6
process-max=2
start-fast=y
archive-async=y
archive-push-queue-max=2GB
spool-path=/var/spool/pgbackrest
log-level-console=info
log-level-file=detail

[palavra-diaria]
pg1-path=/var/lib/postgresql/data/pgdata
pg1-host=postgres
pg1-port=5432
pg1-user=backup

Nenhum valor de credencial existe em arquivo versionado. O arquivo acima é um gabarito e contém apenas os marcadores mostrados. O script de inicialização gera a configuração efetiva em /etc/pgbackrest/pgbackrest.conf, modo 0400, dono root, substituindo cada marcador pelo conteúdo do arquivo de segredo correspondente montado em /run/secrets/, conforme a regra "repositório: nenhum segredo, nunca" da Seção 22.4.1. Duas exigências adicionais: a credencial do bucket de backup tem permissão apenas de escrita e leitura, sem exclusão e sem alteração de política, para que o comprometimento da aplicação não permita apagar os backups; e a frase de cifra tem cópia em cofre offline, porque perdê-la torna todo backup irrecuperável. O detector de segredos do pipeline recebe uma regra própria para os padrões repo1-s3-key, repo1-s3-key-secret e repo1-cipher-pass, porque nenhum deles casa com as regras genéricas do detector: sem essa regra, um valor colado por engano entra no repositório sem que nada reprove o commit, e quem tiver leitura no repositório passa a ter a credencial do bucket de backup e a frase que decifra os backups — ou seja, a base inteira, incluindo tudo o que a criptografia de campo deveria proteger.

Cronograma (cron do host, chamando docker compose exec):

Quando Comando O que faz
Domingo 02:00 ops backup:run --type=full Backup completo
Segunda a sábado 02:00 ops backup:run --type=diff Backup diferencial
Contínuo archive_command do Postgres Envia cada segmento de WAL; archive_timeout=60s força um segmento por minuto mesmo sem escrita
Diariamente 02:40 ops backup:verify Verificação do repositório e integridade do último backup

Retenção de 35 dias, obtida por construção: repo1-retention-full=5 mantém 5 backups completos semanais. O mais antigo tem 4 semanas (28 dias) e os diferenciais e WAL associados a ele são preservados até o completo seguinte expirar, o que cobre a 5ª semana. O intervalo garantido de recuperação a ponto no tempo é, portanto, de 35 dias.

Camada 2 — dump lógico diário.

# 03:10, cron do host
docker compose exec -T postgres \
  pg_dump -U app -d palavra_diaria -Fc -Z0 --no-owner --no-privileges \
| zstd -T2 -9 \
| age -r "$BACKUP_AGE_RECIPIENT" \
> "/tmp/pd-$(date -u +%Y%m%dT%H%M%SZ).dump.zst.age"

rclone move /tmp/pd-*.dump.zst.age backup-remote:pd-backups/logical/ \
  --immutable --s3-no-check-bucket

O dump lógico é criptografado com age usando uma chave pública cuja chave privada não está no servidor: ela é guardada no cofre de segredos da organização e em cópia física offline. Um invasor com acesso total ao servidor pode apagar dados, mas não consegue ler os backups lógicos que já saíram. A retenção do bucket lógico também é 35 dias, aplicada por política de ciclo de vida do bucket, com bloqueio de objeto ativado para impedir exclusão antecipada.

O bucket de backup fica em provedor diferente do bucket de mídia e do provedor do VPS. Isso é decisão explícita: um comprometimento de conta ou um incidente de provedor não pode levar dados e backups juntos.

25.8.4 Restauração #

Restauração completa para o ponto mais recente (usada em desastre):

# 1. Parar tudo que escreve.
docker compose stop web worker

# 2. Restaurar. O diretorio de dados e substituido.
docker compose run --rm pgbackrest \
  pgbackrest --stanza=palavra-diaria --delta restore

# 3. Subir o Postgres. Ele reproduz o WAL ate o fim do arquivo.
docker compose up -d postgres
docker compose logs -f postgres | grep -m1 "database system is ready to accept connections"

# 4. Conferir integridade antes de liberar trafego.
docker compose exec -T worker ops backup:verify --post-restore

# 5. Religar a aplicacao.
docker compose up -d web worker

Restauração a ponto no tempo (usada após erro humano, por exemplo um UPDATE sem WHERE):

docker compose stop web worker
docker compose run --rm pgbackrest \
  pgbackrest --stanza=palavra-diaria --delta \
    --type=time --target="2026-08-25 04:55:00-03" \
    --target-action=promote restore
docker compose up -d postgres

Tempos esperados, medidos no ensaio e revalidados a cada trimestre:

Perfil Tamanho do banco Restauração do backup Reprodução de WAL Verificação Total
1.000 assinantes ~0,8 GB ~2 min ~1 min ~1 min ~4 min
10.000 assinantes ~3 GB ~7 min ~3 min ~2 min ~12 min
50.000 assinantes ~14 GB ~25 min ~8 min ~4 min ~37 min

Esses tempos são para restauração na mesma máquina, com o repositório de backup acessível. A recuperação em máquina nova soma o tempo de provisão, coberto no RTO da Seção 25.16.

25.8.5 Ensaio de restauração trimestral obrigatório #

A cada 90 dias, sem exceção, o procedimento completo é executado em uma máquina descartável. Nunca em produção, nunca com o repositório de produção montado como gravável.

# 1. Provisionar maquina temporaria e clonar o repositorio de deploy.
# 2. Restaurar o ultimo backup completo com credencial somente leitura.
pgbackrest --stanza=palavra-diaria --repo1-s3-key="$READONLY_KEY" \
  --pg1-path=/tmp/restore-drill --delta restore

# 3. Subir um Postgres apontando para o diretorio restaurado e validar.
docker compose -f docker-compose.drill.yml up -d postgres-drill
docker compose -f docker-compose.drill.yml exec worker ops backup:verify --drill

ops backup:verify --drill executa e registra:

  1. Contagem de linhas das 10 tabelas principais, comparada com o valor esperado gravado no último backup.
  2. SELECT max(created_at) FROM message_logs — precisa estar dentro da janela de RPO.
  3. Verificação de integridade referencial em 20 amostras aleatórias de subscriptions e payments.
  4. prisma migrate status — o schema restaurado precisa estar na mesma versão da aplicação corrente.
  5. Medição do tempo total, comparada com a tabela da Seção 25.8.4. Desvio acima de 50% abre incidente de capacidade.

O resultado é gravado em job_runs com job_name = 'backup.restore_drill'. Um alerta de severidade alta é emitido quando now() - último sucesso > 100 dias. A máquina de ensaio é destruída ao final; o comando de destruição faz parte do procedimento e é verificado no mesmo registro.

25.9 Redis #

25.9.1 redis/redis.conf #

bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300

# --- Persistencia ---
# AOF e a fonte primaria; RDB serve de snapshot rapido para reinicio.
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-use-rdb-preamble yes

save 900 1
save 300 10
save 60 10000
rdbcompression yes
rdbchecksum yes
stop-writes-on-bgsave-error yes

# --- Memoria ---
maxmemory 768mb
maxmemory-policy noeviction
maxmemory-samples 5

# --- Seguranca ---
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG "CONFIG_b4f7a2"

# --- Latencia ---
latency-monitor-threshold 100
slowlog-log-slower-than 20000
slowlog-max-len 256

maxmemory-policy noeviction é obrigatório e não negociável. BullMQ guarda o estado dos jobs em Redis. Qualquer política de despejo descartaria jobs silenciosamente sob pressão de memória, e o sintoma seria "algumas pessoas não receberam o devocional", sem erro em lugar nenhum. Com noeviction, a escrita falha em voz alta e o alerta dispara. FLUSHALL e FLUSHDB são desabilitados por renome para tornar impossível o comando destrutivo digitado por engano em produção.

25.9.2 O que o Redis guarda #

Chave / estrutura Conteúdo Criticidade se perdido
Filas BullMQ Jobs pendentes, ativos, atrasados, falhos Alta, mas recuperável (ver abaixo)
ratelimit:otp:{phone} Contadores de envio de OTP Baixa: o limite reinicia
ratelimit:api:{ip} Limite de taxa das rotas públicas Baixa
circuit:{integração} Estado do disjuntor (Seção 27.3) Baixa: reabre fechado
killswitch:global Interruptor geral (Seção 27.8) Média: ver nota
test:clock:offset Deslocamento de relógio (fora de produção) Irrelevante
cache:devotional:{date} Cache de leitura do devocional do dia, TTL 1 h Baixa
lock:{recurso} Locks de curta duração Baixa

25.9.3 O que acontece se o Redis perder dados #

Perda total do Redis é um evento degradado, não catastrófico, porque nenhuma verdade de negócio vive nele. A recuperação:

  1. Jobs de envio. delivery_attempts no Postgres é a fonte da verdade sobre o que foi enviado. Após perder o Redis, a retomada do lote recria os jobs apenas para as tentativas que não estão em estado terminal. A chave única impede envio duplicado, então a operação é segura mesmo que o Redis tivesse jobs em voo.
  2. Jobs de TTS. Recriados para os devocionais em AUDIO_PENDING.
  3. Eventos de pagamento. payment_events está no Postgres; o reprocessamento cobre os não processados.
  4. Jobs repetíveis. Recriados automaticamente no start do worker, que registra os agendamentos de forma idempotente.
  5. Limites de taxa. Reiniciam do zero. Um assinante conseguiria pedir alguns OTPs a mais naquela hora. Aceitável.
  6. Interruptor geral. Aqui há um risco real: se o interruptor estivesse ligado e o Redis fosse perdido, o sistema voltaria enviando. Por isso o estado do interruptor é gravado também em settings sob a chave ops.kill_switch, e o Redis é apenas o cache de leitura rápida. No start, o worker lê settings e repopula o Redis. A decisão é registrada na Seção 27.8.

Após qualquer perda de Redis, o procedimento obrigatório é o runbook R-18 da Seção 27.6.

25.10 Storage de mídia #

Item Decisão
Serviço Bucket S3-compatível, com região na América do Sul quando disponível
Nome pd-media (produção), pd-media-staging (staging)
Acesso público Bloqueado. Nenhum objeto é público, não há listagem anônima e não existe URL previsível. Toda leitura é por URL assinada com TTL de 15 minutos. A variável S3_PUBLIC_BASE_URL (Seção 26.3.7) aponta exclusivamente para o domínio de mídia que fica na frente do bucket e que exige assinatura; ela nunca é a URL direta do bucket
Criptografia Em repouso, gerenciada pelo provedor; em trânsito, TLS obrigatório
Credenciais Uma chave por ambiente, com política mínima: s3:PutObject, s3:GetObject, s3:DeleteObject, s3:ListBucket restritos ao próprio bucket
Versionamento Ativado, com expiração de versões não correntes em 30 dias
CORS Apenas https://app.palavradiaria.com.br, métodos GET e HEAD

Estrutura de chaves (definida no pipeline de áudio da Seção 16):

devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.ogg
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}.mp3
devotionals/{YYYY}/{MM}/{devotionalId}/{voice}-fallback.mp4
devotionals/{YYYY}/{MM}/{devotionalId}/cover.jpg
tmp/{uploadId}/{arquivo}
exports/{subscriberId}/{ulid}.json

Política de ciclo de vida:

Prefixo Regra Motivo
tmp/ Excluir 1 dia após a criação Arquivos intermediários de transcodificação
exports/ Excluir 7 dias após a criação Exportações LGPD são baixadas de dentro do painel, sob sessão plena, por URL assinada de 15 minutos gerada no clique (Seção 22.7.4). O prefixo é isolado e nunca alcançável pelo domínio de mídia público
devotionals/**/*.mp4 Mover para classe de acesso infrequente após 60 dias O MP4 de fallback quase nunca é lido depois da semana do envio
devotionals/**/*.ogg e *.mp3 Mover para classe de acesso infrequente após 365 dias; nunca excluir O acervo completo é entitlement do plano pago
Uploads multiparte incompletos Abortar após 7 dias Evita cobrança por lixo invisível

Backup do storage: sincronização diária às 03:40 para um segundo bucket, em provedor diferente, com o mesmo esquema de chaves.

rclone sync media-remote:pd-media backup-remote:pd-media-mirror \
  --transfers 8 --checkers 16 \
  --immutable \
  --exclude "tmp/**" --exclude "exports/**" \
  --log-level INFO --stats 1m

--immutable faz o comando falhar em vez de sobrescrever um objeto que mudou, o que transforma corrupção ou sobrescrita maliciosa em alerta em vez de propagação silenciosa. O espelho tem retenção indefinida e bloqueio de exclusão.

Teste de fumaça pós-implantação, obrigatório e bloqueante. Depois de cada deploy, o pipeline requisita um objeto conhecido do bucket sem assinatura e falha se a resposta não for 403; repete a requisição pelo domínio de mídia, também sem assinatura, e exige 403; e requisita um objeto sob exports/ pelo domínio de mídia, exigindo 403. Um bucket que responde 200 a qualquer uma dessas três chamadas torna o acervo pago gratuito e permanente para quem descobrir o padrão de chave, e expõe as exportações de portabilidade.

Perda do storage de mídia não perde conteúdo editorial: devotionals no Postgres tem todo o texto, e o áudio é regenerável. O custo de regenerar 365 devocionais é da ordem de uma diária de TTS, não um desastre.

25.11 Migrations em produção #

25.11.1 Quando rodam e com que lock #

Migrations são aplicadas por prisma migrate deploy no start do contêiner web, protegido por um lock consultivo do PostgreSQL (pg_try_advisory_lock(8149021)), como mostra o script da Seção 25.6.1. Com duas réplicas de web, apenas uma aplica; a outra espera até 120 s e então segue, porque a migration já terá sido aplicada pela primeira. O worker verifica a versão do schema e recusa subir se estiver atrás.

Ordem de subida no deploy, garantida pelas dependências do arquivo da Seção 25.3: postgres e redis saudáveis → web (aplica migration, passa no health check) → worker (valida a versão do schema).

25.11.2 Regras de compatibilidade #

Toda migration precisa ser compatível com a versão de aplicação anterior, porque durante a troca com start-first as duas versões coexistem por alguns segundos. Consequências obrigatórias:

  • Coluna nova é sempre NULL ou tem DEFAULT. Nunca NOT NULL sem default no mesmo passo.
  • Coluna renomeada é feita como adição + cópia + remoção, em releases diferentes.
  • Índice em tabela grande é criado com CREATE INDEX CONCURRENTLY, fora do fluxo automático (ver abaixo), porque CONCURRENTLY não roda dentro de transação.
  • ALTER TABLE ... ADD CONSTRAINT de chave estrangeira usa NOT VALID primeiro e VALIDATE CONSTRAINT depois, para não travar a tabela.
  • Nenhuma migration executa UPDATE em tabela grande. Backfill é comando de operação, com lotes e pausas.

25.11.3 Migrations destrutivas em duas fases #

Remover uma coluna ou tabela é sempre feito em duas releases separadas por, no mínimo, uma semana de operação estável.

Exemplo concreto: remover subscribers.consecutive_window_misses, depois que a métrica de dias sem interação migrar para daily_metrics (Seção 21). A coluna existe de verdade no schema da Seção 6.3, o que torna o exemplo executável como está.

Fase 1 — release N (a coluna para de ser usada, mas continua existindo):

-- migration: 20260901T120000_deprecate_consecutive_window_misses
COMMENT ON COLUMN subscribers.consecutive_window_misses IS
  'DEPRECATED em 2026-09-01. Remocao prevista para 2026-09-15. Nao escrever.';

Nenhuma alteração estrutural. O código da release N deixa de ler e de escrever a coluna. Um gatilho temporário registra qualquer escrita remanescente, o que prova que nada mais a usa:

CREATE OR REPLACE FUNCTION warn_window_misses_write() RETURNS trigger AS $$
BEGIN
  IF NEW.consecutive_window_misses IS DISTINCT FROM OLD.consecutive_window_misses THEN
    RAISE WARNING 'consecutive_window_misses still being written for subscriber %', NEW.id;
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_warn_window_misses
  BEFORE UPDATE ON subscribers
  FOR EACH ROW EXECUTE FUNCTION warn_window_misses_write();

Fase 2 — release N+1, no mínimo 7 dias depois, com zero avisos no log:

-- migration: 20260915T120000_drop_consecutive_window_misses
DROP TRIGGER IF EXISTS trg_warn_window_misses ON subscribers;
DROP FUNCTION IF EXISTS warn_window_misses_write();
ALTER TABLE subscribers DROP COLUMN consecutive_window_misses;

Antes da fase 2, um dump lógico da tabela afetada é feito e guardado por 90 dias:

docker compose exec -T postgres \
  pg_dump -U app -d palavra_diaria -Fc -t subscribers \
| age -r "$BACKUP_AGE_RECIPIENT" > pre-drop-subscribers-20260915.dump.age

Índices concorrentes seguem o mesmo padrão de duas fases, mas por motivo diferente: a migration registra o índice como aplicado sem criá-lo, e a criação real é um comando de operação executado em janela de baixa carga.

docker compose exec worker ops db:create-index-concurrently \
  --name=idx_message_logs_created_at \
  --sql="CREATE INDEX CONCURRENTLY idx_message_logs_created_at ON message_logs (created_at DESC)"

O comando monitora pg_stat_progress_create_index e aborta se passar de 30 minutos.

25.11.4 Verificação de privilégio pós-migration #

GRANT ... ON ALL TABLES concede privilégio apenas às tabelas existentes no momento do comando. Toda tabela criada por uma migration posterior nasce sem privilégio para app_user, e o sintoma — permission denied for table ... seguido de 500 — aparece horas depois do deploy, longe da causa. Os ALTER DEFAULT PRIVILEGES da Seção 22.12.2 resolvem o caso normal; esta verificação é a rede de proteção e roda antes de o tráfego ser trocado:

SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
  AND c.relkind = 'r'
  AND NOT has_table_privilege('app_user', c.oid, 'SELECT');

Qualquer linha devolvida aborta o deploy e dispara rollback da imagem. A mesma consulta é repetida para INSERT, UPDATE e DELETE. O comando ops db:check executa essa verificação e é chamado pelo passo de conferência da Seção 25.12.3.

25.11.5 Rollback de migration #

Prisma não gera rollback automático, e isso é tratado como característica, não como falta. A regra é: rollback de migration é rollback de dados, e rollback de dados é restauração.

Situação Ação
Migration aditiva (coluna nova, tabela nova, índice novo) causou problema Rollback da imagem para a versão anterior. A estrutura extra é inerte. Nenhuma ação no banco
Migration alterou tipo ou restrição e a aplicação antiga não funciona Rollback da imagem + migration de compensação escrita à mão e aplicada como nova migration. Nunca editar migration já aplicada
Migration destrutiva na fase 2 causou perda Restauração a ponto no tempo para o instante imediatamente anterior (Seção 25.8.4), com perda declarada das escritas do intervalo. É por isso que a fase 2 é feita em janela de baixa escrita, às 03:00
Migration falhou no meio prisma migrate deploy é transacional por migration: ou aplica inteira ou nenhuma. O web não passa no health check e o deploy é abortado automaticamente pelo passo de verificação da Seção 25.12

Migration marcada como aplicada mas com falha parcial (situação possível quando a migration contém comando não transacional) é resolvida com prisma migrate resolve --rolled-back <nome> seguido de correção manual, sempre com o banco em janela de manutenção.

25.12 Estratégia de deploy #

25.12.1 Princípios #

  1. A imagem é imutável e identificada pelo commit. Nada é construído no servidor.
  2. Uma versão por tag Git. Tag v1.4.0 produz imagens web:v1.4.0 e worker:v1.4.0, além de web:sha-<commit curto>. Tags móveis nunca são usadas no arquivo de composição.
  3. Nada é trocado antes de passar no health check. O tráfego só muda depois que a nova réplica responde 200 em /api/internal/health e depois que a verificação de privilégio de tabela da Seção 25.11.4 devolve zero linhas.
  4. Rollback é troca de tag. Sempre há pelo menos as duas versões anteriores presentes no registro e no disco do servidor.
  5. Deploy nunca acontece entre 05:00 e 07:00 de São Paulo. É a janela do lote diário. O pipeline recusa deploy nesse intervalo, salvo com force_deploy: true explícito.

25.12.2 Etiquetagem de imagem #

Tag Quando Uso
sha-a1b2c3d Todo commit em main Rastreabilidade e deploy em staging
v1.4.0 Tag Git semântica Deploy em produção
PREVIOUS_TAG no arquivo .env.previous Gravada antes de cada deploy Rollback imediato

O IMAGE_TAG efetivo fica em /opt/palavra-diaria/.env.deploy no servidor, e o arquivo de composição o lê. Trocar de versão é trocar uma linha desse arquivo e subir.

25.12.3 Procedimento de deploy #

#!/usr/bin/env bash
# deploy/deploy.sh — executado no servidor pelo CI, via SSH.
set -euo pipefail

NEW_TAG="$1"
cd /opt/palavra-diaria

# 1. Recusar a janela do lote diario, exceto com FORCE_DEPLOY=true.
HOUR="$(TZ=America/Sao_Paulo date +%H)"
if [ "${FORCE_DEPLOY:-false}" != "true" ] && [ "$HOUR" -ge 5 ] && [ "$HOUR" -lt 7 ]; then
  echo "recusado: janela do lote diario (05:00-07:00 America/Sao_Paulo)" >&2
  exit 2
fi

# 2. Guardar a versao atual para rollback.
CURRENT_TAG="$(grep -E '^IMAGE_TAG=' .env.deploy | cut -d= -f2)"
echo "PREVIOUS_TAG=${CURRENT_TAG}" > .env.previous

# 3. Baixar as imagens novas antes de qualquer troca.
IMAGE_TAG="$NEW_TAG" docker compose pull web worker pgbackrest

# 4. Verificacao de sanidade da imagem, fora do trafego.
docker run --rm --env-file ./.env.production \
  --network palavra-diaria_backend \
  "${IMAGE_REGISTRY}/worker:${NEW_TAG}" node dist/check-schema-version.js --dry-run

# 5. Trocar a tag e subir. start-first sobe a replica nova antes de derrubar a antiga.
sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=${NEW_TAG}/" .env.deploy
docker compose up -d --no-deps --wait --wait-timeout 180 web

# 6. Verificacao pos-troca do web.
for i in $(seq 1 30); do
  if curl -fsS -m 5 https://app.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'; then
    break
  fi
  [ "$i" -eq 30 ] && { echo "health check falhou apos deploy" >&2; ./deploy/rollback.sh; exit 1; }
  sleep 5
done

# 7. Trocar o worker apenas depois de o web estar saudavel (o schema ja esta migrado).
docker compose up -d --no-deps --wait --wait-timeout 180 worker

# 8. Verificacao de privilegio de tabela (Secao 25.11.4). Tabela nova sem GRANT
#    derruba a aplicacao horas depois, longe da causa.
docker compose exec -T worker ops db:check --privileges || { ./deploy/rollback.sh; exit 1; }

# 9. Verificacao funcional profunda.
docker compose exec -T worker ops health --strict || { ./deploy/rollback.sh; exit 1; }

# 10. Limpeza: manter as tres ultimas versoes de imagem.
docker image prune -f --filter "until=168h" >/dev/null

echo "deploy concluido: ${CURRENT_TAG} -> ${NEW_TAG}"

ops health --strict verifica, com código de saída diferente de zero em qualquer falha: conexão com Postgres e latência de consulta simples abaixo de 50 ms; conexão com Redis; GET /{PHONE_NUMBER_ID} na Meta devolvendo o número esperado; GET /v3/finance/balance na Asaas devolvendo 200; HEAD em um objeto conhecido do bucket, e 403 no mesmo objeto sem assinatura; presença dos oito templates com o status registrado; registro do job repetível plan-daily-batch com o padrão cron e o fuso America/Sao_Paulo; versão do schema igual à esperada; e ausência de job em estado active há mais de 10 minutos.

25.12.4 Rollback #

#!/usr/bin/env bash
# deploy/rollback.sh
set -euo pipefail
cd /opt/palavra-diaria
source .env.previous

echo "revertendo para ${PREVIOUS_TAG}"
sed -i "s/^IMAGE_TAG=.*/IMAGE_TAG=${PREVIOUS_TAG}/" .env.deploy
docker compose up -d --no-deps --wait --wait-timeout 180 web worker
docker compose exec -T worker ops health --strict
echo "rollback concluido para ${PREVIOUS_TAG}"

Comando exato para rollback manual, digitado por uma pessoa em incidente:

ssh deploy@<servidor> '/opt/palavra-diaria/deploy/rollback.sh'

Se a versão anterior não puder ser usada porque a migration nova é incompatível (situação rara, coberta pela regra de compatibilidade da Seção 25.11.2), o rollback de código é acompanhado de restauração a ponto no tempo. Essa combinação exige janela de manutenção declarada e segue o runbook R-16.

25.12.5 Janela de manutenção #

Deploy normal não tem janela: a troca com start-first é transparente. Janela declarada é necessária apenas para: migration destrutiva de fase 2, restauração de banco, mudança de versão maior do PostgreSQL e migração de servidor.

Janela padrão: terça-feira, 02:00 às 03:30 (America/Sao_Paulo). Escolhida por ser o horário de menor tráfego e por deixar mais de 2 horas de folga até o lote das 05:40. Aviso aos assinantes só é dado se a janela puder afetar o envio (ver Seção 27.7).

Durante a janela, o Caddy serve uma página estática de manutenção:

docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile.maintenance

O Caddyfile.maintenance mantém /api/webhooks/* funcionando (Asaas e Meta não podem receber erro, sob pena de pausa de fila) e devolve 503 com corpo HTML nas demais rotas. Os webhooks continuam persistindo eventos; o processamento espera a fila religar.

25.13 Pipelines de CI/CD #

25.13.1 .github/workflows/ci.yml #

name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '24'
  PNPM_VERSION: '10'

jobs:
  setup:
    name: Instalar dependências
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter @palavra-diaria/db exec prisma generate
      - name: Guardar dependências instaladas
        uses: actions/cache/save@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
            packages/db/generated
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}

  static:
    name: Lint, formatação e tipos
    needs: setup
    runs-on: ubuntu-latest
    timeout-minutes: 12
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: '10' }
      - uses: actions/setup-node@v4
        with: { node-version: '24', cache: pnpm }
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
            packages/db/generated
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          fail-on-cache-miss: true
      - run: pnpm format:check
      - run: pnpm lint
      - run: pnpm typecheck

  unit:
    name: Testes unitários e cobertura
    needs: setup
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: '10' }
      - uses: actions/setup-node@v4
        with: { node-version: '24', cache: pnpm }
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
            packages/db/generated
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          fail-on-cache-miss: true
      - run: pnpm test:coverage
      - name: Comparar cobertura com a linha de base
        run: node tools/ci/check-coverage-delta.js
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage
          path: coverage/
          retention-days: 14

  integration:
    name: Testes de integração e contrato
    needs: setup
    runs-on: ubuntu-latest
    timeout-minutes: 25
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: palavra_diaria_test
        options: >-
          --health-cmd "pg_isready -U test"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 10
        ports: ['5432:5432']
      redis:
        image: redis:8-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports: ['6379:6379']
    env:
      DATABASE_URL: postgresql://test:test@127.0.0.1:5432/palavra_diaria_test?schema=public
      REDIS_URL: redis://127.0.0.1:6379
      USE_EXTERNAL_SERVICES: 'false'
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: '10' }
      - uses: actions/setup-node@v4
        with: { node-version: '24', cache: pnpm }
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
            packages/db/generated
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          fail-on-cache-miss: true
      - run: pnpm --filter @palavra-diaria/db exec prisma migrate deploy
      - run: pnpm test:integration
      - run: pnpm test:contract
      - name: Verificar idade das gravações de contrato
        run: node tools/ci/check-cassette-age.js --warn-days=100 --fail-days=150

  build:
    name: Build das imagens
    needs: [static, unit]
    runs-on: ubuntu-latest
    timeout-minutes: 25
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        run: echo "tag=sha-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
      - name: Build e push do web
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./apps/web/Dockerfile
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}/web:${{ steps.meta.outputs.tag }}
          cache-from: type=gha,scope=web
          cache-to: type=gha,mode=max,scope=web
          provenance: true
          sbom: true
      - name: Build e push do worker
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./apps/worker/Dockerfile
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}/worker:${{ steps.meta.outputs.tag }}
          cache-from: type=gha,scope=worker
          cache-to: type=gha,mode=max,scope=worker
          provenance: true
          sbom: true

  e2e:
    name: Testes E2E
    needs: setup
    runs-on: ubuntu-latest
    timeout-minutes: 30
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: palavra_diaria_e2e
        options: >-
          --health-cmd "pg_isready -U test" --health-interval 10s --health-retries 10
        ports: ['5432:5432']
      redis:
        image: redis:8-alpine
        options: --health-cmd "redis-cli ping" --health-interval 10s --health-retries 5
        ports: ['6379:6379']
    env:
      DATABASE_URL: postgresql://test:test@127.0.0.1:5432/palavra_diaria_e2e?schema=public
      REDIS_URL: redis://127.0.0.1:6379
      E2E_TEST_HOOKS: 'true'
      E2E_HOOK_SECRET: ci-e2e-secret
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: '10' }
      - uses: actions/setup-node@v4
        with: { node-version: '24', cache: pnpm }
      - uses: actions/cache/restore@v4
        with:
          path: |
            node_modules
            apps/*/node_modules
            packages/*/node_modules
            packages/db/generated
          key: deps-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          fail-on-cache-miss: true
      - run: pnpm --filter @palavra-diaria/web exec playwright install --with-deps chromium
      - run: pnpm --filter @palavra-diaria/db exec prisma migrate deploy
      - run: pnpm --filter @palavra-diaria/web build
      - run: pnpm test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: apps/web/playwright-report/
          retention-days: 14

  security:
    name: Varredura de segurança
    needs: setup
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
        with: { version: '10' }
      - uses: actions/setup-node@v4
        with: { node-version: '24', cache: pnpm }
      - name: Segredos no histórico
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Vulnerabilidades de dependência
        run: pnpm audit --audit-level=high --prod
      - name: Varredura OSV
        uses: google/osv-scanner-action@v1
        with:
          scan-args: |-
            --lockfile=./pnpm-lock.yaml
            --format=sarif
            --output=osv.sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with: { sarif_file: osv.sarif }
      - name: Higiene de fixtures e gravações de contrato
        run: |
          node tools/ci/check-fixtures.js
          node tools/ci/check-cassettes-redaction.js

  gate:
    name: Portão do pull request
    if: always()
    needs: [static, unit, integration, build, e2e, security]
    runs-on: ubuntu-latest
    steps:
      - name: Falhar se qualquer job anterior falhou
        run: |
          echo '${{ toJSON(needs) }}' | node -e '
            const needs = JSON.parse(require("fs").readFileSync(0, "utf8"));
            const bad = Object.entries(needs).filter(([, v]) => v.result !== "success");
            if (bad.length) {
              console.error("jobs com falha:", bad.map(([k]) => k).join(", "));
              process.exit(1);
            }
            console.log("todos os jobs passaram");
          '

O job gate é o único marcado como verificação obrigatória na proteção de branch. Isso evita ter que atualizar a configuração do repositório toda vez que um job novo é adicionado.

25.13.2 .github/workflows/deploy.yml #

name: Deploy

on:
  push:
    tags: ['v*.*.*']
  workflow_dispatch:
    inputs:
      image_tag:
        description: 'Tag da imagem a implantar (ex.: v1.4.0 ou sha-a1b2c3d)'
        required: true
      environment:
        description: 'Ambiente'
        required: true
        type: choice
        options: [staging, production]
      force_deploy:
        description: 'Ignorar a janela protegida do lote diário'
        required: false
        type: boolean
        default: false

concurrency:
  group: deploy-${{ github.event.inputs.environment || 'production' }}
  cancel-in-progress: false

jobs:
  build-release:
    name: Construir e publicar imagens da release
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: ./apps/web/Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/web:${{ github.ref_name }}
            ghcr.io/${{ github.repository }}/web:sha-${{ github.sha }}
          cache-from: type=gha,scope=web
          provenance: true
          sbom: true
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: ./apps/worker/Dockerfile
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/worker:${{ github.ref_name }}
            ghcr.io/${{ github.repository }}/worker:sha-${{ github.sha }}
          cache-from: type=gha,scope=worker
          provenance: true
          sbom: true

  deploy-staging:
    name: Implantar em staging
    needs: [build-release]
    if: always() && (needs.build-release.result == 'success' || github.event_name == 'workflow_dispatch')
    runs-on: ubuntu-latest
    timeout-minutes: 20
    environment:
      name: staging
      url: https://app.staging.palavradiaria.com.br
    steps:
      - uses: actions/checkout@v4
      - name: Configurar chave SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
      - name: Implantar
        env:
          TAG: ${{ github.event.inputs.image_tag || github.ref_name }}
        run: |
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "FORCE_DEPLOY=true /opt/palavra-diaria/deploy/deploy.sh ${TAG}"
      - name: Verificação de fumaça
        run: |
          set -e
          curl -fsS -m 10 https://app.staging.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
          curl -fsS -m 10 https://staging.palavradiaria.com.br/ | grep -q 'Palavra Diária'
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "cd /opt/palavra-diaria && docker compose exec -T worker ops health --strict"

  deploy-production:
    name: Implantar em produção
    needs: [deploy-staging]
    runs-on: ubuntu-latest
    timeout-minutes: 25
    # A aprovação manual vem da configuração do ambiente 'production':
    # revisores obrigatórios + espera mínima de 5 minutos + branches permitidas.
    environment:
      name: production
      url: https://palavradiaria.com.br
    steps:
      - uses: actions/checkout@v4
      - name: Configurar chave SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
      - name: Registrar início do deploy
        run: |
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "cd /opt/palavra-diaria && docker compose exec -T worker \
             ops deploy:mark --tag=${{ github.event.inputs.image_tag || github.ref_name }} --phase=start"
      - name: Implantar
        env:
          TAG: ${{ github.event.inputs.image_tag || github.ref_name }}
          FORCE: ${{ github.event.inputs.force_deploy || 'false' }}
        run: |
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "FORCE_DEPLOY=${FORCE} /opt/palavra-diaria/deploy/deploy.sh ${TAG}"
      - name: Verificação de fumaça em produção
        run: |
          set -e
          curl -fsS -m 10 https://app.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
          curl -fsS -m 10 https://api.palavradiaria.com.br/api/internal/health | grep -q '"status":"ok"'
          curl -fsS -m 10 https://palavradiaria.com.br/ | grep -q 'Palavra Diária'
      - name: Rollback automático em caso de falha
        if: failure()
        run: |
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "/opt/palavra-diaria/deploy/rollback.sh"
      - name: Registrar fim do deploy
        if: always()
        run: |
          ssh -i ~/.ssh/id_ed25519 "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
            "cd /opt/palavra-diaria && docker compose exec -T worker \
             ops deploy:mark --tag=${{ github.event.inputs.image_tag || github.ref_name }} --phase=end --result=${{ job.status }}"

A aprovação manual não é um passo do YAML: é a configuração do ambiente production no repositório, com revisores obrigatórios (no mínimo uma pessoa que não seja quem abriu o deploy), espera mínima de 5 minutos e restrição de branch à main e a tags v*. Isso é mais confiável do que uma etapa de aprovação escrita no arquivo, porque não pode ser contornada editando o YAML no mesmo pull request.

25.13.3 Pipelines auxiliares #

Arquivo Gatilho O que faz
.github/workflows/nightly.yml Diário às 03:00 UTC Roda a suíte completa contra main, incluindo E2E no perfil móvel; abre issue automática em caso de falha
.github/workflows/deps.yml Semanal, segunda 06:00 UTC Abre pull request com atualizações de dependência agrupadas por linha major; não atualiza major automaticamente
.github/workflows/contract-age.yml Semanal Verifica a idade das gravações de contrato e abre issue quando passa de 100 dias

25.14 Gestão de segredos #

25.14.1 No CI #

Onde O quê Regras
Segredos do repositório GITHUB_TOKEN (automático) Escopo mínimo declarado por job em permissions:
Segredos do ambiente staging DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS Só acessíveis a jobs com environment: staging
Segredos do ambiente production Os mesmos, com valores de produção Só acessíveis após aprovação do ambiente

Regras absolutas:

  1. Nenhum segredo de terceiros (Asaas, Meta, TTS, storage) existe no CI. O CI nunca fala com serviço externo real. As únicas credenciais do CI são as de deploy e as de registro de imagem.
  2. Segredos nunca são passados como argumento de linha de comando (visíveis em ps). São passados por variável de ambiente ou por entrada padrão.
  3. Nenhum echo de segredo. O mascaramento automático do CI é tratado como rede de segurança, não como permissão para imprimir.
  4. Toda mudança em segredo de produção é registrada manualmente no registro de operação, com data, quem trocou e motivo.
  5. A chave de deploy é ed25519, restrita no servidor por command="/opt/palavra-diaria/deploy/ssh-wrapper.sh",no-port-forwarding,no-agent-forwarding,no-pty no authorized_keys. O wrapper aceita apenas deploy.sh, rollback.sh e um subconjunto declarado de comandos ops, e nada mais.

25.14.2 No servidor #

Arquivo /opt/palavra-diaria/.env.production, dono root:docker, modo 0640. Não fica em Git. É gerado a partir do cofre de segredos da organização e aplicado por procedimento manual. O catálogo completo das variáveis está na Seção 26.

A cadência de rotação de cada segredo é a da Seção 26.7.3. Esta subseção declara apenas o procedimento no servidor; nenhum prazo é repetido aqui, para que exista um único número por segredo em todo o documento.

Segredo Procedimento no servidor
POSTGRES_PASSWORD ALTER ROLE app WITH PASSWORD, atualizar arquivo, subir web e worker
REDIS_PASSWORD Atualizar arquivo, reiniciar redis, web e worker na ordem
SESSION_JWT_PRIVATE_KEY Rotação com sobreposição: a chave nova entra como assinante e a antiga fica como verificadora por 30 dias (SESSION_JWT_PREVIOUS_PUBLIC_KEY), depois é removida. Sem deslogar ninguém
ENCRYPTION_KEY e PHONE_INDEX_KEY Rotação em duas fases da Seção 22.5.5: a chave nova entra como escritora, ENCRYPTION_KEY_PREVIOUS e PHONE_INDEX_KEY_PREVIOUS continuam aceitas em leitura até o recálculo terminar, e só então são removidas. São chaves distintas: comprometer uma não compromete a outra
ASAAS_API_KEY Gerar nova no painel da Asaas, atualizar, reiniciar, revogar a antiga
ASAAS_WEBHOOK_TOKEN Atualizar no painel da Asaas e no arquivo na mesma janela; eventos entre as duas trocas são recuperados pela reconciliação
WHATSAPP_SYSTEM_USER_TOKEN Ver runbook R-07
META_APP_SECRET Rotação com janela dupla: META_APP_SECRET e META_APP_SECRET_PREVIOUS são aceitos simultaneamente na verificação do HMAC de webhook, com registro de qual validou; a anterior só é removida depois que 100% das validações usarem a nova. Sem essa janela, toda rotação rejeita webhooks legítimos entre a troca no painel do provedor e o reinício dos processos (Seção 17.7)
ELEVENLABS_API_KEY Atualizar e reiniciar worker
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY Criar chave nova, atualizar, validar, revogar a antiga
BACKUP_ENCRYPTION_KEY e senha do repositório de backup Nunca rotacionar sem antes validar que backups antigos ainda são legíveis com a chave antiga guardada
Chave privada age do backup lógico Nunca fica no servidor: guardada apenas no cofre e em cópia física offline

Rotação fora de cadência é sempre permitida e obrigatória sob suspeita de vazamento; o procedimento é o mesmo da linha correspondente, e o prazo de 26.7.3 é o teto, nunca o piso.

Verificação automática semanal (ops secrets:audit): confere que o arquivo tem modo 0640, que todas as variáveis obrigatórias existem, que nenhuma tem valor de exemplo, que o token da Meta tem mais de 14 dias de validade restante, e que nenhuma chave aparece em log ou em variável exportada por outro processo. Falha vira alerta.

25.15 Ambientes #

Aspecto local staging production
Onde roda Máquina do desenvolvedor, Docker Compose VPS menor dedicado, ou projeto Compose separado no mesmo host VPS dedicado
Domínio localhost:3000 staging.palavradiaria.com.br, app.staging..., api.staging... palavradiaria.com.br e subdomínios
TLS Não (HTTP puro) Sim, Caddy/ACME Sim, Caddy/ACME
Banco Postgres em contêiner, dados descartáveis Postgres em contêiner, dados sintéticos Postgres em contêiner, dados reais
Backup Nenhum Nenhum pgBackRest + dump lógico (Seção 25.8.3)
Asaas Servidor de simulação local Sandbox (api-sandbox.asaas.com/v3) Produção (api.asaas.com/v3)
WhatsApp Servidor de simulação local Número de teste da Meta, com lista de destinos permitidos Número de produção aprovado
TTS Servidor de simulação local (áudio sintético fixo) Provedor real, conta de desenvolvimento, cota limitada Provedor real, conta de produção
E-mail Console (impresso no log) Provedor real, domínio de staging, destinos restritos à equipe Provedor real, domínio de produção
Storage MinIO local ou simulação em disco Bucket pd-media-staging Bucket pd-media
WHATSAPP_ALLOWLIST Definida (números da equipe) Definida (números da equipe) Vazia
E2E_TEST_HOOKS true false false, com guarda que impede o start
Lote diário Manual Automático às 05:40, sobre dados sintéticos Automático às 05:40
Réplicas de web 1 1 2
Nível de log debug info info
Origem dos dados Seed de desenvolvimento ops seed:synthetic Cadastro real
Restauração de backup de produção Proibida Proibida Permitida (é o próprio ambiente)
Quem faz deploy O próprio desenvolvedor Automático, ao publicar tag Automático, após aprovação manual

Não existe ambiente de QA separado. A bateria manual da Seção 24.11 é executada em staging para os itens de sandbox e em produção para os itens que exigem número e cobrança reais.

25.16 Plano de continuidade #

25.16.1 Objetivos declarados #

Objetivo Valor Como é sustentado
RPO (perda máxima de dados aceitável) 5 minutos archive_timeout = 60s força o envio de um segmento de WAL por minuto; o envio assíncrono e o upload somam, no pior caso, mais 2 a 3 minutos. O valor declarado tem folga sobre o comportamento medido
RTO (tempo máximo até voltar a operar) 90 minutos Provisão de VPS novo (~10 min) + instalação e clone (~15 min) + restauração e reprodução de WAL (~12 a 37 min conforme o porte) + verificação (~5 min) + propagação de DNS (~10 min com TTL de 300 s) + folga
Disponibilidade alvo 99,5% mensal Conforme a meta declarada na Seção 2.6. Equivale a cerca de 3,6 h de indisponibilidade por mês

O RTO de 90 minutos é aceitável para este produto por uma razão específica: o envio é diário e concentrado em 20 minutos. Uma queda às 14:00 não afeta nenhum assinante além do painel web. Uma queda às 05:30 é o único cenário verdadeiramente crítico, e para ele existe uma mitigação própria: o lote pode ser disparado com atraso de até 4 horas sem que a experiência mude de forma relevante, porque o planejamento é idempotente por data e o motor respeita a chave send:{subscriberId}:{devotionalDate}.

25.16.2 Pré-requisitos permanentes #

Estes itens precisam existir antes do desastre, e sua existência é verificada mensalmente por ops dr:preflight:

  1. Repositório de deploy em Git, com o arquivo de composição, Caddyfile, configurações e scripts versionados.
  2. Imagens das três últimas versões publicadas no registro, acessíveis com credencial que não depende do servidor caído.
  3. .env.production guardado no cofre de segredos da organização, fora do servidor.
  4. Chave privada age do backup lógico no cofre e em cópia física offline.
  5. Credencial de leitura do repositório de backup guardada separadamente da credencial de escrita usada pelo servidor.
  6. DNS com TTL de 300 segundos nos registros A/AAAA dos quatro domínios, permanentemente.
  7. Contato e procedimento de suporte do provedor de VPS documentados.
  8. Lista de contatos da Meta e da Asaas, com o identificador da conta.

25.16.3 Procedimento de recuperação total #

Cenário: o servidor foi perdido por completo, sem possibilidade de recuperação do disco.

# --- Passo 1: provisionar (alvo: 10 min) ---
# Criar VPS com a mesma especificacao (Secao 25.2.5), Ubuntu LTS, na mesma regiao.
# Anotar o IP novo.

# --- Passo 2: preparar o host (alvo: 10 min) ---
ssh root@<novo-ip>
apt-get update && apt-get install -y ca-certificates curl git ufw fail2ban
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  > /etc/apt/sources.list.d/docker.list
apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Firewall conforme a Secao 25.7.3.

# --- Passo 3: restaurar a configuracao (alvo: 5 min) ---
git clone <repositorio-de-deploy> /opt/palavra-diaria
cd /opt/palavra-diaria
# Trazer .env.production do cofre. Nunca por canal nao criptografado.
install -m 0640 -o root -g docker /caminho/do/cofre/.env.production ./.env.production
echo "IMAGE_TAG=<ultima-tag-boa-conhecida>" > .env.deploy

# --- Passo 4: subir a infraestrutura sem trafego (alvo: 5 min) ---
docker compose up -d postgres redis
docker compose exec -T postgres pg_isready -U app

# --- Passo 5: restaurar o banco (alvo: 12 a 37 min) ---
docker compose stop postgres
docker compose run --rm pgbackrest \
  pgbackrest --stanza=palavra-diaria --delta restore
docker compose up -d postgres
docker compose logs -f postgres | grep -m1 "ready to accept connections"

# --- Passo 6: garantir que nada dispare sozinho ---
docker compose up -d worker
docker compose exec -T worker ops kill-switch:on --reason="recuperacao de desastre em andamento"

# --- Passo 7: validar antes de abrir (alvo: 5 min) ---
docker compose exec -T worker ops backup:verify --post-restore
docker compose exec -T worker ops health --strict

# --- Passo 8: abrir o trafego (alvo: 5 min) ---
docker compose up -d web caddy
curl -fsS -m 10 http://127.0.0.1/api/internal/health --resolve palavradiaria.com.br:80:127.0.0.1

# --- Passo 9: apontar o DNS (alvo: 10 min de propagacao com TTL 300) ---
# Atualizar os registros A/AAAA dos quatro dominios para o IP novo.

# --- Passo 10: reconciliar e religar ---
docker compose exec -T worker ops billing:reconcile --since=<data-do-backup> --fix
docker compose exec -T worker ops billing:replay-events --since=<timestamp-do-backup>
docker compose exec -T worker ops template:sync
docker compose exec -T worker ops kill-switch:off
docker compose exec -T worker ops send:plan --date=<hoje> --dry-run   # conferir antes
docker compose exec -T worker ops send:plan --date=<hoje>             # se o lote do dia nao saiu

O passo 10 é o que fecha a lacuna do RPO: eventos de pagamento que a Asaas entregou nos minutos perdidos são recuperados pela reconciliação, porque a Asaas é a fonte da verdade para cobrança. Mensagens de WhatsApp recebidas no intervalo perdido não são recuperáveis — a Meta não reentrega webhooks antigos. Consequência prática: alguns assinantes podem ter tocado no botão do devocional e não receber o pacote completo. O tratamento está no runbook R-17.

25.16.4 Cenários parciais #

Cenário RTO Ação
Volume de dados corrompido, servidor íntegro ~20 min Restauração local (Seção 25.8.4), sem reprovisionar
Erro humano em dados (UPDATE sem WHERE) ~25 min Restauração a ponto no tempo para o instante anterior
Redis perdido ~10 min Runbook R-18
Bucket de mídia perdido ~4 h Restaurar do espelho; áudio faltante é regerado
Provedor de VPS com incidente regional Depende do provedor Se passar de 60 min, executar a recuperação total em outro provedor
Conta do WhatsApp banida Indeterminado Runbook R-05; o produto continua funcionando no painel web enquanto isso

25.17 Custo mensal estimado #

Premissas de cálculo, declaradas aqui para que os números sejam auditáveis:

  • Distribuição de tier conforme a Seção 2.6: 70% FREE, 30% PAID.
  • FREE recebe 1 template por semana: 4,33 templates por mês.
  • PAID recebe template apenas quando a janela de atendimento está fechada. Adota-se 50% dos dias com janela aberta (comportamento esperado do desenho de entrega), logo 15 templates por mês por assinante PAID. Mensagens free-form dentro da janela não têm custo por mensagem.
  • Preço por template na faixa UTILITY, referência de planejamento: R$ 0,04. A faixa MARKETING custa cerca de 8 vezes mais; a linha "cenário MARKETING" mostra o impacto. As faixas canônicas de custo por categoria estão na Seção 17.
  • Asaas, referência de planejamento: cartão 2,99% + R$ 0,49 por transação; PIX R$ 1,99 por cobrança recebida. Divisão de meio de pagamento: 70% cartão, 30% PIX.
  • TTS: o áudio é gerado uma vez por devocional, nunca por assinante. 30 devocionais por mês, cerca de 2.500 caracteres cada, cerca de 75.000 caracteres por mês. O custo é constante em qualquer escala.
  • Câmbio de referência: US$ 1,00 = R$ 5,40. Valores arredondados.

Cenário A — 1.000 assinantes (700 FREE, 300 PAID)

Item Cálculo Custo mensal
Templates FREE 700 × 4,33 = 3.031 msg × R$ 0,04 R$ 121
Templates PAID 300 × 15 = 4.500 msg × R$ 0,04 R$ 180
Mensagens free-form (texto + áudio) Sem custo dentro da janela R$ 0
Asaas — cartão 210 × (R$ 19,90 × 2,99% + R$ 0,49) R$ 228
Asaas — PIX 90 × R$ 1,99 R$ 180
TTS 75.000 caracteres, plano de entrada R$ 120
VPS (2 vCPU / 4 GB / 40 GB) R$ 130
Storage de mídia + espelho ~10 GB acumulados + tráfego R$ 20
Storage de backup ~15 GB com 35 dias de retenção R$ 15
E-mail transacional ~2.000 mensagens, faixa gratuita R$ 0
Domínio (rateio mensal) R$ 5
Monitoramento externo de disponibilidade R$ 30
Total R$ 1.029
Receita bruta 300 × R$ 19,90 R$ 5.970
Custo por assinante ativo R$ 1.029 / 1.000 R$ 1,03
Cenário MARKETING templates a R$ 0,34 R$ 3.310 (+R$ 2.281)

Cenário B — 10.000 assinantes (7.000 FREE, 3.000 PAID)

Item Cálculo Custo mensal
Templates FREE 7.000 × 4,33 = 30.310 msg × R$ 0,04 R$ 1.213
Templates PAID 3.000 × 15 = 45.000 msg × R$ 0,04 R$ 1.800
Asaas — cartão 2.100 × (R$ 19,90 × 2,99% + R$ 0,49) R$ 2.279
Asaas — PIX 900 × R$ 1,99 R$ 1.791
TTS 75.000 caracteres R$ 120
VPS (4 vCPU / 8 GB / 80 GB) R$ 260
Storage de mídia + espelho ~60 GB acumulados + tráfego R$ 60
Storage de backup ~60 GB com 35 dias R$ 45
E-mail transacional ~20.000 mensagens R$ 110
Domínio (rateio mensal) R$ 5
Monitoramento externo R$ 60
Total R$ 7.743
Receita bruta 3.000 × R$ 19,90 R$ 59.700
Custo por assinante ativo R$ 7.743 / 10.000 R$ 0,77
Cenário MARKETING templates a R$ 0,34 R$ 30.335 (+R$ 22.592)

Cenário C — 50.000 assinantes (35.000 FREE, 15.000 PAID)

Item Cálculo Custo mensal
Templates FREE 35.000 × 4,33 = 151.550 msg × R$ 0,04 R$ 6.062
Templates PAID 15.000 × 15 = 225.000 msg × R$ 0,04 R$ 9.000
Asaas — cartão 10.500 × (R$ 19,90 × 2,99% + R$ 0,49) R$ 11.393
Asaas — PIX 4.500 × R$ 1,99 R$ 8.955
TTS 75.000 caracteres R$ 120
VPS (8 vCPU / 16 GB / 160 GB) R$ 700
Storage de mídia + espelho ~200 GB acumulados + tráfego R$ 180
Storage de backup ~250 GB com 35 dias R$ 160
E-mail transacional ~100.000 mensagens R$ 430
Domínio (rateio mensal) R$ 5
Monitoramento externo R$ 60
Total R$ 37.065
Receita bruta 15.000 × R$ 19,90 R$ 298.500
Custo por assinante ativo R$ 37.065 / 50.000 R$ 0,74
Cenário MARKETING templates a R$ 0,34 R$ 165.010 (+R$ 127.945)

Três conclusões operacionais que decorrem da tabela e que orientam decisões de produto:

  1. A categoria do template é a variável de custo mais importante do produto. A diferença entre UTILITY e MARKETING no cenário C é de mais de R$ 127 mil por mês. Manter o template classificado como UTILITY e monitorar a categoria efetiva devolvida pela API não é detalhe de implementação: é a diferença entre margem alta e prejuízo.
  2. A taxa de abertura da janela é a segunda variável. Cada ponto percentual a mais de janelas abertas elimina templates. Se a abertura subisse de 50% para 70% no cenário C, o custo de templates PAID cairia de R$ 9.000 para R$ 5.400.
  3. Infraestrutura é irrelevante perto de mensagem e cobrança. No cenário C, VPS, storage e monitoramento somam menos de 3% do custo total. Otimizar servidor para economizar dinheiro é esforço mal aplicado; otimizar categoria de template e abertura de janela é onde o dinheiro está.

26. Registro de Configuração e Variáveis de Ambiente #

Esta seção é a dona do registro de configuração. Nenhuma outra seção lista variáveis de ambiente de forma completa: todas referenciam aqui.

26.1 Princípios #

  1. Toda variável é declarada e validada. Não existe process.env.X espalhado pelo código. O acesso é sempre por um objeto tipado, produzido por um único módulo.
  2. O boot falha rápido e com mensagem clara. Variável obrigatória ausente ou inválida derruba o processo antes de aceitar a primeira requisição, com a lista completa do que está errado — nunca uma variável por vez.
  3. Segredo nunca aparece em log, erro, resposta ou artefato de build. A lista de redação é explícita e testada.
  4. Sem valor padrão para segredo. Um padrão de desenvolvimento que vaza para produção é a origem clássica do incidente. Segredo obrigatório não tem default.
  5. Padrão seguro. Onde há dúvida, o padrão é o comportamento mais restritivo.

26.1.1 Nome canônico de segredo #

Cada segredo tem exatamente um nome, e ele é definido nesta seção. Um mesmo segredo com dois ou três nomes ao longo do documento não é uma inconsistência cosmética: é um contêiner que não sobe. O guarda de inicialização procura um nome, o registro de configuração declara outro, e o operador acrescenta a variável faltante para destravar — passando a existir duas chaves no ambiente sem que ninguém saiba qual assina.

Nomes canônicos, com os apelidos proibidos entre parênteses:

Nome canônico Apelidos proibidos
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY SESSION_JWK_PRIVATE, JWT_PRIVATE_KEY, JWT_PUBLIC_KEY
WHATSAPP_SYSTEM_USER_TOKEN WHATSAPP_TOKEN, WHATSAPP_ACCESS_TOKEN, META_WHATSAPP_TOKEN
WHATSAPP_VERIFY_TOKEN HUB_VERIFY_TOKEN, META_VERIFY_TOKEN
ENCRYPTION_KEY e ENCRYPTION_KEY_PREVIOUS ENCRYPTION_KEY_V<n> e qualquer família versionada
PHONE_INDEX_KEY e PHONE_INDEX_KEY_PREVIOUS TAX_ID_ENCRYPTION_KEY e qualquer chave por coluna
META_APP_SECRET e META_APP_SECRET_PREVIOUS
ELEVENLABS_API_KEY TTS_API_KEY
BUILD_SHA APP_VERSION
APP_URL APP_BASE_URL
PUBLIC_SITE_URL NEXT_PUBLIC_SITE_URL
OPS_WEBHOOK_URL ALERT_WEBHOOK_URL
ASAAS_API_BASE_URL ASAAS_BASE_URL
S3_BUCKET e S3_ENDPOINT MEDIA_BUCKET, MEDIA_ENDPOINT, S3_ENDPOINT_HOST
WEBHOOK_MAX_BODY_BYTES ASAAS_WEBHOOK_MAX_BODY_BYTES

O par de chaves de sessão usa o prefixo SESSION_JWT_ em toda a família, incluindo SESSION_JWT_KEY_ID, SESSION_JWT_PREVIOUS_PUBLIC_KEY e SESSION_JWT_PREVIOUS_KEY_ID. O guarda de boot de cada contêiner valida exatamente esses nomes, e nenhum outro.

Um teste do pipeline extrai toda ocorrência de env.<NOME>, process.env.<NOME> e ${<NOME>} do código, dos arquivos de composição e dos scripts de inicialização, e falha se algum nome não constar do registro de 26.3. É o único mecanismo capaz de pegar essa classe de erro antes do primeiro deploy de produção, porque nenhum teste unitário a alcança.

26.2 Variável de ambiente versus setting de runtime #

A fronteira é decidida por três perguntas:

Pergunta Sim → Não →
É segredo ou credencial? Variável de ambiente continue
Mudar exige reiniciar o processo ou redeploy? Variável de ambiente continue
Um administrador não técnico precisa mudar sem chamar alguém? Setting de runtime Variável de ambiente
Critério Variável de ambiente Setting de runtime (settings)
Onde vive Arquivo .env / gestor de segredos Tabela settings
Quem altera Operador com acesso ao servidor Administrador pelo painel
Quando vale No próximo start do processo Em até 60 segundos, sem reinício
Validação Schema Zod no boot (26.5) CHECK no banco + Zod na borda da API
Auditoria Histórico do gestor de segredos admin_audit_log
Pode ser segredo Sim Sim, com is_secret = true, mas evitado
Exemplos DATABASE_URL, WHATSAPP_SYSTEM_USER_TOKEN, SESSION_JWT_PRIVATE_KEY send.rate_per_second, resend.paid_daily_limit, privacy.dpo_email

Casos limítrofes decididos aqui, para não sobrar dúvida:

Configuração Decisão Por quê
Taxa de envio ao WhatsApp Ambos. WHATSAPP_SEND_RATE_PER_SECOND é o teto absoluto; send.rate_per_second é o valor operacional, limitado pelo teto O operador precisa reduzir a taxa durante um incidente sem redeploy, mas não pode elevá-la acima do que a plataforma aceita
Hora do envio diário Setting (send.daily_hour_local) Decisão de produto, sem impacto de segurança
Dia do envio gratuito Setting (send.free_tier_weekday) Idem
Preço dos planos Nem um nem outro: linhas em plans Preço tem histórico e vínculo com assinaturas existentes
Modo manutenção Setting (ops.maintenance_mode) Precisa ser ligado em segundos, sem deploy
E-mail do encarregado de dados Setting (privacy.dpo_email) Muda com a pessoa, não com a infraestrutura
Limite de reenvio por tier Setting (resend.free_daily_limit, resend.paid_daily_limit) Ajuste de produto

Quando um valor existe nos dois lugares, o setting define o valor operacional e a variável de ambiente define o limite máximo permitido. O código aplica Math.min(setting, envCeiling).

26.3 Registro completo de variáveis #

Legenda de App: W = apps/web, K = apps/worker, O = apps/ops. Legenda de Obrig.: Sim = o boot falha sem ela; Não = tem padrão ou é opcional.

26.3.1 Núcleo da aplicação #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
NODE_ENV enum Sim production W K O Não development, test ou production. Ausente: boot falha; muitas bibliotecas mudam de comportamento e adivinhar é perigoso.
APP_ENV enum Sim production W K O Não local, staging ou production. Separado de NODE_ENV porque staging roda com NODE_ENV=production. Ausente: boot falha.
APP_NAME string Não palavra-diaria palavra-diaria W K O Não Identificador em logs e métricas.
APP_URL url Sim https://app.palavradiaria.com.br W K O Não URL base dos painéis. Usada em magic link, e-mails e no claim iss. Ausente: boot falha; link quebrado é pior que não subir.
PUBLIC_SITE_URL url Sim https://palavradiaria.com.br W Não URL da landing. Usada em redirecionamentos e e-mails.
PORT int Não 3000 3000 W Não Porta HTTP do app web.
WORKER_PORT int Não 3001 3001 K Não Porta do servidor interno de saúde e métricas do worker.
TZ string Não UTC UTC W K O Não Fuso do processo. Sempre UTC. A conversão para horário de Brasília é feita em código, nunca pelo fuso do processo.
BUILD_SHA string Não unknown a1b2c3d W K O Não SHA curto do commit. Ecoado em X-Api-Build e em /api/internal/version. Nome canônico único: APP_VERSION é apelido proibido (26.1.1).
NEXT_PUBLIC_GA_ID string Não — (vazia) G-XXXXXXXXXX W Não Identificador de medição de terceiro. Vazia por padrão e vazia em produção no lançamento: a analítica do produto é auto-hospedada e sem cookie (Seção 21). Preenchê-la liga um script de terceiro e passa a exigir aviso de cookies.
NEXT_PUBLIC_META_PIXEL_ID string Não — (vazia) 123456789012345 W Não Pixel de anúncios. Mesma regra da anterior: vazia por padrão, e só é preenchida junto com a base legal e o aviso correspondentes (Seção 22.7.1).

26.3.2 Banco de dados #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
DATABASE_URL url Sim postgresql://app:senha@postgres:5432/palavra_diaria?schema=public W K O Sim Conexão principal. Ausente: boot falha imediatamente.
SHADOW_DATABASE_URL url Não postgresql://app:senha@postgres:5432/palavra_diaria_shadow O Sim Banco sombra do fluxo de migrations. Só em local. Ausente em desenvolvimento: prisma migrate dev falha com mensagem própria.
DATABASE_POOL_MAX int Não 10 20 W K Não Conexões máximas por processo. Web e worker somados precisam caber em max_connections.
DATABASE_POOL_MIN int Não 2 2 W K Não Conexões mantidas abertas.
DATABASE_CONNECT_TIMEOUT_MS int Não 5000 5000 W K O Não Tempo máximo para obter conexão. Estouro: DATABASE_UNAVAILABLE.
DATABASE_STATEMENT_TIMEOUT_MS int Não 8000 8000 W K Não statement_timeout da sessão. Impede que uma consulta ruim trave o pool.
DATABASE_SSL bool Não false true W K O Não Exige TLS na conexão. true obrigatório quando o banco não está na mesma rede privada.
DATABASE_LOG_QUERIES bool Não false false W K Não Registra consultas com parâmetros. Nunca true em produção: parâmetros contêm dado pessoal.

26.3.3 Redis e filas #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
REDIS_URL url Sim redis://:senha@redis:6379/0 W K O Sim Conexão do Redis. Ausente: boot falha.
REDIS_TLS bool Não false false W K O Não Conexão por TLS.
REDIS_COMMAND_TIMEOUT_MS int Não 1000 1000 W K Não Timeout por comando. Estouro no web degrada conforme a Seção 7.12.4.
REDIS_MAX_RETRIES int Não 3 3 W K Não Tentativas por comando antes de desistir.
BULLMQ_PREFIX string Não pd pd W K O Não Prefixo das chaves das filas. Separa ambientes que dividem um mesmo Redis.
BULLMQ_CONCURRENCY int Não 10 25 K Não Jobs simultâneos por worker. Valor alto sem aumentar DATABASE_POOL_MAX esgota o pool.
BULLMQ_MAX_STALLED_COUNT int Não 2 2 K Não Vezes que um job pode ficar travado antes de ir para a fila de falhas.
BULLMQ_JOB_RETENTION_COMPLETED int Não 1000 1000 K Não Jobs concluídos mantidos no Redis.
BULLMQ_JOB_RETENTION_FAILED int Não 5000 5000 K Não Jobs falhos mantidos, para diagnóstico.

26.3.4 WhatsApp Business Cloud API #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
WHATSAPP_API_BASE_URL url Não derivado de WHATSAPP_GRAPH_VERSION https://graph.facebook.com/v26.0 W K O Não Base da API. A linha de versão está na Seção 4.
WHATSAPP_GRAPH_VERSION string Não v26.0 v26.0 W K O Não Versão da Graph API, injetada na URL base. Nenhum caminho de código embute a versão (Seção 17.1): subir de versão é trocar esta variável, não editar arquivo.
WHATSAPP_PHONE_NUMBER_ID string Sim 109876543210987 W K O Não Número remetente. Também compõe a chave de unicidade de mídia (Seção 6.27). Ausente: boot falha.
WHATSAPP_BUSINESS_ACCOUNT_ID string Sim 210987654321098 W K O Não Conta de negócio. Usada para gerenciar templates.
WHATSAPP_SYSTEM_USER_TOKEN string Sim EAAG... W K O Sim Token de acesso. Ausente: boot falha. Rotação em 26.7.3.
META_APP_ID string Sim 1234567890123456 W Não Aplicativo da plataforma.
META_APP_SECRET string Sim a1b2c3d4... W Sim Segredo usado no HMAC do webhook de entrada. Ausente: boot falha; sem ele nenhum webhook pode ser verificado.
META_APP_SECRET_PREVIOUS string Não W Sim Segredo anterior, aceito simultaneamente durante a rotação. A verificação aceita qualquer um dos dois e registra qual validou; sem essa janela dupla, toda rotação rejeita webhooks legítimos entre a troca no painel do provedor e o reinício dos processos.
WHATSAPP_VERIFY_TOKEN string Sim pd_verify_9f2c... W Sim Token do handshake hub.verify_token. Ausente: boot falha.
WHATSAPP_SEND_RATE_PER_SECOND int Não 20 20 K Não Teto de mensagens por segundo. O valor operacional é o setting send.rate_per_second, limitado por este.
WHATSAPP_TIMEOUT_MS int Não 15000 15000 K Não Timeout por chamada de envio.
WHATSAPP_MAX_RETRIES int Não 3 3 K Não Tentativas por mensagem antes de marcar a tentativa como falha.
WHATSAPP_TEMPLATE_DAILY string Não devocional_diario_v1 devocional_diario_v1 K Não Nome do template de convite diário.
WHATSAPP_TEMPLATE_VIDEO string Não devocional_diario_video_v1 devocional_diario_video_v1 K Não Template de fallback com cabeçalho de vídeo.
WHATSAPP_TEMPLATE_OTP string Não codigo_acesso_v1 codigo_acesso_v1 W Não Template de código de acesso.
WHATSAPP_TEMPLATE_WELCOME string Não boas_vindas_v1 boas_vindas_v1 W K Não Template de confirmação de inscrição.
WHATSAPP_TEMPLATE_LANGUAGE string Não pt_BR pt_BR W K Não Idioma dos templates.
WHATSAPP_MEDIA_TTL_DAYS int Não 30 30 K Não Validade do identificador de mídia. Reupload é disparado antes de vencer.

26.3.5 Provedor de pagamento #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
ASAAS_ENVIRONMENT enum Sim production W K O Não sandbox ou production. Ausente: boot falha; adivinhar significaria cobrar de verdade em staging.
ASAAS_API_BASE_URL url Não derivado de ASAAS_ENVIRONMENT https://api.asaas.com/v3 W K O Não Base da API. sandbox resolve para https://api-sandbox.asaas.com/v3.
ASAAS_API_KEY string Sim $aact_... W K O Sim Chave enviada no header access_token. Ausente: boot falha.
ASAAS_WEBHOOK_TOKEN string Sim pd_asaas_7d1e... W Sim Token comparado em tempo constante no webhook. Ausente: boot falha.
ASAAS_WEBHOOK_TOKEN_PREVIOUS string Não W Sim Token anterior, aceito simultaneamente por até 24 h durante a rotação (26.7.3). Sem essa janela, todo evento que chega entre a troca no painel do provedor e o reinício dos processos é rejeitado.
ASAAS_RATE_LIMIT_RPS int Não 8 8 W K O Não Teto local de requisições por segundo à API do provedor, aplicado por token bucket antes de a chamada sair (Seção 12.2). Existe para o produto nunca ser quem estoura o limite do provedor.
ASAAS_TIMEOUT_MS int Não 10000 10000 W K Não Timeout por chamada. Estouro: PAYMENT_PROVIDER_UNAVAILABLE.
ASAAS_MAX_RETRIES int Não 2 2 W K Não Tentativas em erro transitório. Nunca retenta POST de criação sem chave de idempotência.
ASAAS_WEBHOOK_URL url Não ${APP_URL}/api/webhooks/asaas O Não URL registrada no provedor. Usada pelo comando de configuração.

26.3.6 Síntese de voz e mídia #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
TTS_PRIMARY_PROVIDER enum Não ELEVENLABS ELEVENLABS K Não ELEVENLABS ou GOOGLE_TTS.
ELEVENLABS_API_KEY string Condicional sk_a1b2... K Sim Obrigatória quando o primário é ELEVENLABS. Ausente nesse caso: boot falha.
ELEVENLABS_BASE_URL url Não https://api.elevenlabs.io/v1 K Não Base da API de voz.
TTS_VOICE_ID string Sim 21m00Tcm4TlvDq8ikWAM K Não Voz em português do Brasil. Ausente: boot falha; áudio com voz errada é pior que áudio nenhum.
TTS_MODEL_ID string Não eleven_multilingual_v2 K Não Modelo de síntese multilíngue.
TTS_STABILITY float Não 0.45 0.45 K Não 0 a 1. Menor gera mais variação expressiva.
TTS_SIMILARITY_BOOST float Não 0.75 0.75 K Não 0 a 1. Aderência ao timbre da voz de referência.
TTS_OUTPUT_FORMAT string Não mp3_44100_128 K Não Formato bruto pedido ao provedor, antes da transcodificação.
TTS_TIMEOUT_MS int Não 120000 120000 K Não Timeout da síntese. Texto de 5 minutos leva dezenas de segundos.
TTS_MAX_ATTEMPTS_BEFORE_FALLBACK int Não 3 3 K Não Falhas no primário antes de cair para o secundário.
GOOGLE_TTS_CREDENTIALS_JSON json Condicional {"type":"service_account",...} K Sim Credencial da conta de serviço, como JSON em uma linha. Obrigatória quando o fallback está habilitado.
GOOGLE_TTS_VOICE_NAME string Não pt-BR-Neural2-C K Não Voz do provedor secundário.
TTS_FALLBACK_VOICE_ID string Não pt-BR-Neural2-B K Não Voz alternativa usada quando a voz configurada é recusada pelo provedor (Seção 16.9). Vazia: o fallback usa GOOGLE_TTS_VOICE_NAME.
GOOGLE_TTS_LANGUAGE_CODE string Não pt-BR pt-BR K Não Idioma do provedor secundário.
FFMPEG_PATH string Não ffmpeg /usr/bin/ffmpeg K Não Binário de transcodificação. Ausente do sistema: o boot do worker falha na verificação de dependências.
AUDIO_OGG_BITRATE_KBPS int Não 32 32 K Não Bitrate do OGG/Opus enviado ao WhatsApp.
AUDIO_OGG_FALLBACK_BITRATE_KBPS int Não 24 24 K Não Bitrate de reencode quando o arquivo passa do limite de aviso.
AUDIO_MP3_BITRATE_KBPS int Não 128 128 K Não Bitrate do MP3 do player web.
AUDIO_MAX_BYTES int Não 16777216 16777216 K Não Limite duro de mídia. Acima disso a geração falha localmente.
AUDIO_WARN_BYTES int Não 12582912 12582912 K Não Limiar de aviso que dispara o reencode em bitrate menor.

26.3.7 Storage de objetos #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
S3_ENDPOINT url Sim https://abc123.r2.cloudflarestorage.com W K O Não Endpoint compatível com S3. Ausente: boot falha.
S3_REGION string Não auto auto W K O Não Região. Provedores compatíveis costumam aceitar auto.
S3_BUCKET string Sim palavra-diaria-media W K O Não Bucket privado de mídia. Ausente: boot falha.
S3_ACCESS_KEY_ID string Sim AKIA... W K O Sim Chave de acesso.
S3_SECRET_ACCESS_KEY string Sim W K O Sim Segredo de acesso. Ausente: boot falha.
S3_FORCE_PATH_STYLE bool Não true true W K O Não Necessário na maioria dos provedores compatíveis.
S3_SIGNED_URL_TTL_SECONDS int Não 900 900 W Não Validade da URL assinada do player web. 15 minutos.
S3_PUBLIC_BASE_URL url Não https://media.palavradiaria.com.br W Não Uso restrito. Aponta exclusivamente para o domínio de mídia que fica na frente do bucket e que exige URL assinada; nunca é a URL direta do bucket. O bucket permanece privado, sem acesso anônimo e sem listagem (Seção 25.10 e 22.2.7). Um teste de fumaça pós-implantação requisita um objeto conhecido sem assinatura e falha se a resposta não for 403. As exportações de portabilidade (22.7.4) ficam no prefixo exports/, jamais alcançável por esta variável. Ausente: usa URL assinada direta.
S3_ARCHIVE_BUCKET string Não igual a S3_BUCKET palavra-diaria-archive O Não Destino do arquivamento de partições (Seção 6.33.3).

26.3.8 E-mail transacional #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
EMAIL_ENABLED bool Não true true W K Não false desliga todo envio e apenas registra em log. Útil em desenvolvimento.
RESEND_API_KEY string Condicional re_a1b2... W K Sim Obrigatória quando EMAIL_ENABLED=true. Ausente nesse caso: boot falha.
EMAIL_FROM email Condicional Palavra Diária <ola@palavradiaria.com.br> W K Não Remetente. Domínio precisa estar verificado no provedor.
EMAIL_REPLY_TO email Não igual a EMAIL_FROM suporte@palavradiaria.com.br W K Não Endereço de resposta.
EMAIL_ALERTS_TO email Sim alertas@palavradiaria.com.br W K O Não Destino dos alertas operacionais. Ausente: boot falha; alerta que não chega a ninguém é pior que nenhum alerta.
EMAIL_MAX_RETRIES int Não 3 3 K Não Tentativas por e-mail.

26.3.9 Sessão, criptografia e segredos de aplicação #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
SESSION_JWT_PRIVATE_KEY pem Sim -----BEGIN PRIVATE KEY-----\n... W Sim Chave privada Ed25519, formato PKCS#8. Só o app web assina. Ausente: boot falha.
SESSION_JWT_PUBLIC_KEY pem Sim -----BEGIN PUBLIC KEY-----\n... W K Não Chave pública SPKI correspondente. Usada para verificar.
SESSION_JWT_KEY_ID string Não k1 k1 W K Não Valor do cabeçalho kid. Muda a cada rotação.
SESSION_JWT_PREVIOUS_PUBLIC_KEY pem Não W K Não Chave pública anterior, aceita durante a janela de rotação (26.7.3).
SESSION_JWT_PREVIOUS_KEY_ID string Não k0 W K Não kid da chave anterior.
ACCESS_TOKEN_TTL_SECONDS int Não 900 900 W Não Validade do token de acesso. 15 minutos.
SESSION_TTL_SUBSCRIBER_DAYS int Não 30 30 W Não Validade da sessão do assinante.
SESSION_TTL_ADMIN_HOURS int Não 12 12 W Não Validade da sessão do administrador.
IMPERSONATION_TTL_MINUTES int Não 30 30 W Não Validade da sessão de suporte.
OTP_PEPPER string Sim 32 bytes em hex W Sim Pepper do hash de OTP (Seção 8.2.2). Mínimo 32 caracteres. Ausente: boot falha.
ENCRYPTION_KEY string Sim 32 bytes em base64 W K O Sim Chave AES-256-GCM para telefone, wa_id, e-mail, CPF e segredo TOTP (Seção 22.5.3). Exatamente 32 bytes decodificados. Ausente: boot falha.
ENCRYPTION_KEY_PREVIOUS string Não W K O Sim Chave anterior, aceita apenas para decifrar durante a rotação (26.7.4).
PHONE_INDEX_KEY string Sim 32 bytes em base64 W K O Sim Chave HMAC do índice cego de telefone, wa_id, CPF e e-mail (Seção 22.5.4). Distinta de ENCRYPTION_KEY: comprometer uma não compromete a outra — a de índice permite enumerar, a de cifra permite decifrar. Ausente: boot falha.
PHONE_INDEX_KEY_PREVIOUS string Não W K O Sim Chave anterior, aceita apenas em leitura durante a fase 1 da rotação de 22.5.5.
CSRF_SECRET string Sim 32 bytes em hex W Sim Chave do token CSRF double-submit, emitido no cookie __Host-csrf. Ausente: boot falha.
COOKIE_DOMAIN string Não W Não Deixado vazio de propósito: o prefixo __Host- proíbe Domain. Existe só para diagnóstico.
TRUST_PROXY bool Não true true W Não Confia em X-Forwarded-For do proxy reverso. false em execução sem proxy. Errado: o IP registrado vira o do proxy e o rate limit por IP deixa de funcionar.
ALLOWED_ORIGINS csv Sim https://app.palavradiaria.com.br,https://palavradiaria.com.br W Não Origens aceitas em CORS e na verificação anti-CSRF. Ausente: boot falha. Não confundir com a constante de código EGRESS_ALLOWED_HOSTS (Seção 22.2.6), que é a lista de destinos de saída.
TURNSTILE_SECRET_KEY string Condicional 0x4AAA... W Sim Chave de verificação do desafio anti-bot no servidor (Seção 22.6.4). Obrigatória quando APP_ENV=production; ausente nesse caso, o boot falha, porque um formulário público sem verificação vira porta de cadastro em massa.
BACKUP_ENCRYPTION_KEY string Condicional chave pública age O Sim Chave de cifra dos backups lógicos (Seção 25.8.3). O backup nunca sai do host em texto claro. Obrigatória quando APP_ENV=production. Nunca descartar a chave antiga enquanto existir backup cifrado com ela.

26.3.10 Observabilidade #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
LOG_LEVEL enum Não info info W K O Não trace, debug, info, warn, error ou fatal.
LOG_FORMAT enum Não json json W K O Não json em staging e produção; pretty em desenvolvimento.
LOG_REDACT_EXTRA csv Não req.body.cpfCnpj W K O Não Caminhos adicionais de redação, somados à lista fixa de 26.7.5.
SENTRY_DSN url Não https://abc@o1.ingest.sentry.io/1 W K Sim Coletor de exceções. Ausente: o rastreamento fica só no log local.
SENTRY_TRACES_SAMPLE_RATE float Não 0.1 0.1 W K Não Amostragem de rastros. 0 a 1.
METRICS_ENABLED bool Não true true W K Não Expõe /api/internal/metrics e o endpoint equivalente no worker.
METRICS_TOKEN string Condicional 32 bytes em hex W K Sim Token do endpoint de métricas. Obrigatório quando METRICS_ENABLED=true e APP_ENV não é local.
OPS_WEBHOOK_URL url Não https://hooks.exemplo.com/pd W K Sim Destino dos alertas operacionais por webhook (Seção 7.15.4).
OPS_WEBHOOK_SECRET string Condicional 32 bytes em hex W K Sim Chave do HMAC do webhook de saída. Obrigatória quando OPS_WEBHOOK_URL está definida.
HEALTHCHECK_TIMEOUT_MS int Não 3000 3000 W K Não Timeout de cada verificação de dependência em /api/internal/ready.

26.3.11 Agendamento #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
SCHEDULER_ENABLED bool Não true true K Não false desliga todos os jobs repetíveis. Usado em réplicas que só processam fila.
SCHEDULER_TIMEZONE string Não America/Sao_Paulo K Não Fuso dos jobs repetíveis. Registrado no agendador da fila.
DAILY_SEND_HOUR int Não 6 6 K Não Teto/piso da hora de envio. O valor operacional é o setting send.daily_hour_local.
DAILY_PLAN_OFFSET_MINUTES int Não 20 20 K Não Antecedência do planejamento. Com padrão 6 e 20, o planejamento roda às 05:40.
FREE_TIER_SEND_WEEKDAY int Não 0 0 K Não Dia do envio gratuito. 0 = domingo. Teto do setting send.free_tier_weekday.
RECONCILE_HOUR int Não 4 4 K Não Hora da reconciliação de cobranças.
RETENTION_HOUR int Não 3 3 K Não Hora do expurgo por retenção.
PARTITION_HOUR int Não 3 3 K Não Hora da criação de partições.
METRICS_ROLLUP_HOUR int Não 3 3 K Não Hora da consolidação de daily_metrics. O job roda às 03:10 sobre o dia D−1 já fechado (Seção 21.5); o deslocamento de 10 minutos é fixo no agendador. Não existe consolidação às 23:00: ela mediria o dia antes de o dia acabar.
JOB_STALE_MINUTES int Não 30 30 K Não Minutos em RUNNING antes de um job ser considerado travado e alertado.

26.3.12 Rate limiting #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
RATE_LIMIT_ENABLED bool Não true true W Não false apenas em desenvolvimento e em testes de carga internos.
RATE_LIMIT_GLOBAL_IP_CAPACITY int Não 600 600 W Não Rajada global por IP.
RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE int Não 300 300 W Não Recarga global por IP.
OTP_MAX_PER_HOUR_PER_PHONE int Não 3 3 W Não Envios de código por número por hora.
OTP_MAX_PER_HOUR_PER_IP int Não 10 10 W Não Envios por IP por hora. Barreira contra varredura de números.
OTP_RESEND_COOLDOWN_SECONDS int Não 60 60 W Não Intervalo mínimo entre envios para o mesmo número.
OTP_TTL_SECONDS int Não 600 600 W Não Validade do código do WhatsApp.
OTP_MAX_ATTEMPTS int Não 5 5 W Não Tentativas por código.
MAGIC_LINK_TTL_SECONDS int Não 1200 1200 W Não Validade do link por e-mail.
ADMIN_LOGIN_MAX_PER_HOUR_PER_IP int Não 10 10 W Não Tentativas de login administrativo por IP.
IDEMPOTENCY_TTL_SECONDS int Não 86400 86400 W Não Validade da chave de idempotência (Seção 7.7.2).

26.3.13 Limites de negócio e de protocolo #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
FREE_ARCHIVE_DAYS int Não 7 7 W Não Teto do acervo visível no plano gratuito. Limita o setting archive.free_days.
RESEND_LIMIT_FREE int Não 1 1 W Não Teto de reenvios diários no plano gratuito.
RESEND_LIMIT_PAID int Não 3 3 W Não Teto de reenvios diários no plano pago.
WINDOW_MISS_THRESHOLD int Não 3 3 K Não Dias sem abrir a janela antes de usar o template de vídeo.
SERVICE_WINDOW_HOURS int Não 24 24 W K Não Duração da janela de atendimento. Fixado pela plataforma; existe para testes.
TEASER_MAX_LENGTH int Não 300 300 W Não Teto do resumo enviado no template.
FREEFORM_TEXT_MAX_LENGTH int Não 4096 4096 K Não Limite da mensagem de texto livre.
MAX_BODY_BYTES int Não 262144 262144 W Não Corpo máximo em rotas gerais. 256 KB.
MAX_BODY_BYTES_ADMIN int Não 1048576 1048576 W Não Corpo máximo em rotas editoriais. 1 MB.
WEBHOOK_MAX_BODY_BYTES int Não 5242880 5242880 W Não Corpo máximo de webhook. 5 MB.
HTTP_HANDLER_TIMEOUT_MS int Não 25000 25000 W Não Timeout total do handler HTTP.
SEND_BATCH_TIMEOUT_MINUTES int Não 40 40 K Não Tempo máximo de um lote de envio antes de ser marcado como falho. Dobro da meta de 20 minutos.

26.3.14 Bootstrap e operação #

Nome Tipo Obrig. Padrão Exemplo App Segredo Descrição / se faltar
BOOTSTRAP_ADMIN_EMAIL email Condicional owner@palavradiaria.com.br O Não Conta inicial. Obrigatória só na primeira execução do seed.
BOOTSTRAP_ADMIN_PASSWORD string Condicional O Sim Senha inicial, com no mínimo 24 caracteres. Lida dentro do caminho que cria o primeiro proprietário, nunca no topo do módulo, e só depois da verificação de que nenhum OWNER existe — ver a regra completa logo abaixo da tabela.
SEED_DEV_DATA bool Não false false O Não Habilita os seeds de desenvolvimento. Ignorada quando NODE_ENV=production.
MAINTENANCE_MODE bool Não false false W Não Interruptor de emergência por ambiente. O modo normal é o setting ops.maintenance_mode; esta variável existe para o caso de o banco estar inacessível.
MAINTENANCE_BYPASS_TOKEN string Condicional 32 bytes em hex W Sim Valor exigido no cabeçalho X-Maintenance-Bypass para atravessar a página de manutenção e validar o sistema antes de reabrir o tráfego (Seção 9.15.3). Obrigatória quando MAINTENANCE_MODE=true.
E2E_TEST_HOOKS bool Não false false W Não Habilita as rotas /api/internal/test/* da Seção 24.7.1. O boot do web falha quando esta variável é true com NODE_ENV=production, a menos que ALLOW_TEST_HOOKS_IN_PROD_BUILD=true. Está no registro justamente por governar uma guarda de produção.
E2E_HOOK_SECRET string Condicional 32 bytes em hex W Sim Valor exigido no cabeçalho x-e2e-secret das rotas de teste. Obrigatória quando E2E_TEST_HOOKS=true.
ALLOW_TEST_HOOKS_IN_PROD_BUILD bool Não false false W Não Única forma de rodar os ganchos de teste com NODE_ENV=production, usada apenas na imagem de CI que executa os testes E2E contra um build de produção. Nunca definida no servidor de produção, e o proxy devolve 404 para todo /api/internal/test/* em qualquer ambiente (Seção 25.7.2).
OPS_CLI_TOKEN string Condicional 32 bytes em hex O Sim Autoriza comandos destrutivos da linha de comando de operação. Obrigatório quando APP_ENV=production.
BACKUP_S3_BUCKET string Não palavra-diaria-backups O Não Destino dos backups lógicos.
BACKUP_RETENTION_DAYS int Não 35 35 O Não Retenção dos backups.

Regra da senha inicial, imposta pelo boot e não por checklist. BOOTSTRAP_ADMIN_PASSWORD só é lida dentro do caminho que cria o primeiro proprietário, depois de confirmar que nenhum OWNER existe. Sua ausência com proprietário já criado é o estado normal e não produz erro — avaliar requireEnv no topo do módulo faria todo deploy posterior falhar assim que a variável fosse removida, e o operador a reporia com a senha original para destravar, deixando-a lá para sempre. O valor precisa ter no mínimo 24 caracteres e é rejeitado pelo schema se constar da lista de senhas comuns ou for igual ao exemplo publicado. A conta criada nasce com must_change_password = true e totp_enrolled_at nulo, e a sessão emitida no primeiro acesso é restrita às rotas de troca de senha e de enrolamento de segundo fator. O processo recusa iniciar se APP_ENV = 'production', existir ao menos um OWNER com TOTP enrolado, e BOOTSTRAP_ADMIN_PASSWORD continuar definida: a remoção deixa de ser um item de checklist e passa a ser imposta pelo boot.

Total: 165 variáveis, sendo 29 obrigatórias sem padrão, 13 condicionalmente obrigatórias (dependem do valor de outra variável, conforme as regras cruzadas de 26.5) e 32 marcadas como segredo.

Não existe variável ASAAS_WEBHOOK_MAX_BODY_BYTES: o teto de corpo de webhook tem um nome, WEBHOOK_MAX_BODY_BYTES (26.3.13), e o proxy da Seção 25.7.2 usa exatamente esse valor. Também não existe TAX_ID_ENCRYPTION_KEY: o CPF é cifrado com ENCRYPTION_KEY e indexado com PHONE_INDEX_KEY, como toda outra coluna de dado pessoal (Seção 22.5.3).

26.4 .env.example #

Arquivo .env.example na raiz do repositório. Segredos aparecem apenas como placeholder descritivo — nunca com valor real, nem de desenvolvimento.

# ============================================================================
# Palavra Diária — exemplo de configuração
# Copie para .env e preencha. Nunca faça commit de .env.
# Gere segredos com:  openssl rand -hex 32   |   openssl rand -base64 32
# ============================================================================

# ---------------------------------------------------------------- núcleo
NODE_ENV=development
APP_ENV=local
APP_NAME=palavra-diaria
APP_URL=http://localhost:3000
PUBLIC_SITE_URL=http://localhost:3000
PORT=3000
WORKER_PORT=3001
TZ=UTC
BUILD_SHA=local
NEXT_PUBLIC_GA_ID=
NEXT_PUBLIC_META_PIXEL_ID=

# ---------------------------------------------------------------- banco
DATABASE_URL=postgresql://palavra:palavra@localhost:5432/palavra_diaria?schema=public
SHADOW_DATABASE_URL=postgresql://palavra:palavra@localhost:5432/palavra_diaria_shadow
DATABASE_POOL_MAX=10
DATABASE_POOL_MIN=2
DATABASE_CONNECT_TIMEOUT_MS=5000
DATABASE_STATEMENT_TIMEOUT_MS=8000
DATABASE_SSL=false
DATABASE_LOG_QUERIES=false

# ---------------------------------------------------------------- redis e filas
REDIS_URL=redis://localhost:6379/0
REDIS_TLS=false
REDIS_COMMAND_TIMEOUT_MS=1000
REDIS_MAX_RETRIES=3
BULLMQ_PREFIX=pd
BULLMQ_CONCURRENCY=10
BULLMQ_MAX_STALLED_COUNT=2
BULLMQ_JOB_RETENTION_COMPLETED=1000
BULLMQ_JOB_RETENTION_FAILED=5000

# ---------------------------------------------------------------- whatsapp
WHATSAPP_GRAPH_VERSION=v26.0
WHATSAPP_API_BASE_URL=https://graph.facebook.com/v26.0
WHATSAPP_PHONE_NUMBER_ID=SUBSTITUA_PELO_ID_DO_NUMERO
WHATSAPP_BUSINESS_ACCOUNT_ID=SUBSTITUA_PELO_ID_DA_CONTA
WHATSAPP_SYSTEM_USER_TOKEN=SEGREDO_TOKEN_DE_ACESSO_DA_PLATAFORMA
META_APP_ID=SUBSTITUA_PELO_ID_DO_APP
META_APP_SECRET=SEGREDO_DO_APP
META_APP_SECRET_PREVIOUS=
WHATSAPP_VERIFY_TOKEN=SEGREDO_GERE_COM_openssl_rand_hex_32
WHATSAPP_SEND_RATE_PER_SECOND=20
WHATSAPP_TIMEOUT_MS=15000
WHATSAPP_MAX_RETRIES=3
WHATSAPP_TEMPLATE_DAILY=devocional_diario_v1
WHATSAPP_TEMPLATE_VIDEO=devocional_diario_video_v1
WHATSAPP_TEMPLATE_OTP=codigo_acesso_v1
WHATSAPP_TEMPLATE_WELCOME=boas_vindas_v1
WHATSAPP_TEMPLATE_LANGUAGE=pt_BR
WHATSAPP_MEDIA_TTL_DAYS=30

# ---------------------------------------------------------------- pagamentos
ASAAS_ENVIRONMENT=sandbox
ASAAS_API_BASE_URL=https://api-sandbox.asaas.com/v3
ASAAS_API_KEY=SEGREDO_CHAVE_DA_API_DE_PAGAMENTOS
ASAAS_WEBHOOK_TOKEN=SEGREDO_GERE_COM_openssl_rand_hex_32
ASAAS_WEBHOOK_TOKEN_PREVIOUS=
ASAAS_TIMEOUT_MS=10000
ASAAS_MAX_RETRIES=2
ASAAS_RATE_LIMIT_RPS=8

# ---------------------------------------------------------------- voz e mídia
TTS_PRIMARY_PROVIDER=ELEVENLABS
ELEVENLABS_API_KEY=SEGREDO_CHAVE_DO_PROVEDOR_DE_VOZ
ELEVENLABS_BASE_URL=https://api.elevenlabs.io/v1
TTS_VOICE_ID=SUBSTITUA_PELO_ID_DA_VOZ_PT_BR
TTS_MODEL_ID=eleven_multilingual_v2
TTS_STABILITY=0.45
TTS_SIMILARITY_BOOST=0.75
TTS_OUTPUT_FORMAT=mp3_44100_128
TTS_TIMEOUT_MS=120000
TTS_MAX_ATTEMPTS_BEFORE_FALLBACK=3
GOOGLE_TTS_CREDENTIALS_JSON=
GOOGLE_TTS_VOICE_NAME=pt-BR-Neural2-C
TTS_FALLBACK_VOICE_ID=
GOOGLE_TTS_LANGUAGE_CODE=pt-BR
FFMPEG_PATH=ffmpeg
AUDIO_OGG_BITRATE_KBPS=32
AUDIO_OGG_FALLBACK_BITRATE_KBPS=24
AUDIO_MP3_BITRATE_KBPS=128
AUDIO_MAX_BYTES=16777216
AUDIO_WARN_BYTES=12582912

# ---------------------------------------------------------------- storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=auto
S3_BUCKET=palavra-diaria-media
S3_ACCESS_KEY_ID=SEGREDO_CHAVE_DE_ACESSO
S3_SECRET_ACCESS_KEY=SEGREDO_CHAVE_SECRETA
S3_FORCE_PATH_STYLE=true
S3_SIGNED_URL_TTL_SECONDS=900
S3_PUBLIC_BASE_URL=
S3_ARCHIVE_BUCKET=palavra-diaria-archive

# ---------------------------------------------------------------- e-mail
EMAIL_ENABLED=false
RESEND_API_KEY=SEGREDO_CHAVE_DO_PROVEDOR_DE_EMAIL
EMAIL_FROM=Palavra Diária <ola@palavradiaria.com.br>
EMAIL_REPLY_TO=suporte@palavradiaria.com.br
EMAIL_ALERTS_TO=alertas@palavradiaria.com.br
EMAIL_MAX_RETRIES=3

# ---------------------------------------------------------------- sessão e cripto
# Gere o par Ed25519 com:
#   openssl genpkey -algorithm ed25519 -out jwt.key
#   openssl pkey -in jwt.key -pubout -out jwt.pub
# Use \n literal para representar quebras de linha em uma única linha do .env
SESSION_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nSEGREDO\n-----END PRIVATE KEY-----\n"
SESSION_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nCONTEUDO\n-----END PUBLIC KEY-----\n"
SESSION_JWT_KEY_ID=k1
SESSION_JWT_PREVIOUS_PUBLIC_KEY=
SESSION_JWT_PREVIOUS_KEY_ID=
ACCESS_TOKEN_TTL_SECONDS=900
SESSION_TTL_SUBSCRIBER_DAYS=30
SESSION_TTL_ADMIN_HOURS=12
IMPERSONATION_TTL_MINUTES=30
OTP_PEPPER=SEGREDO_GERE_COM_openssl_rand_hex_32
ENCRYPTION_KEY=SEGREDO_GERE_COM_openssl_rand_base64_32
ENCRYPTION_KEY_PREVIOUS=
PHONE_INDEX_KEY=SEGREDO_GERE_COM_openssl_rand_base64_32
PHONE_INDEX_KEY_PREVIOUS=
CSRF_SECRET=SEGREDO_GERE_COM_openssl_rand_hex_32
COOKIE_DOMAIN=
TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:3000
TURNSTILE_SECRET_KEY=SEGREDO_CHAVE_DE_VERIFICACAO_ANTI_BOT

# ---------------------------------------------------------------- observabilidade
LOG_LEVEL=debug
LOG_FORMAT=pretty
LOG_REDACT_EXTRA=
SENTRY_DSN=
SENTRY_TRACES_SAMPLE_RATE=0.1
METRICS_ENABLED=true
METRICS_TOKEN=
OPS_WEBHOOK_URL=
OPS_WEBHOOK_SECRET=
HEALTHCHECK_TIMEOUT_MS=3000

# ---------------------------------------------------------------- agendamento
SCHEDULER_ENABLED=true
SCHEDULER_TIMEZONE=America/Sao_Paulo
DAILY_SEND_HOUR=6
DAILY_PLAN_OFFSET_MINUTES=20
FREE_TIER_SEND_WEEKDAY=0
RECONCILE_HOUR=4
RETENTION_HOUR=3
PARTITION_HOUR=3
METRICS_ROLLUP_HOUR=3
JOB_STALE_MINUTES=30

# ---------------------------------------------------------------- rate limiting
RATE_LIMIT_ENABLED=true
RATE_LIMIT_GLOBAL_IP_CAPACITY=600
RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE=300
OTP_MAX_PER_HOUR_PER_PHONE=3
OTP_MAX_PER_HOUR_PER_IP=10
OTP_RESEND_COOLDOWN_SECONDS=60
OTP_TTL_SECONDS=600
OTP_MAX_ATTEMPTS=5
MAGIC_LINK_TTL_SECONDS=1200
ADMIN_LOGIN_MAX_PER_HOUR_PER_IP=10
IDEMPOTENCY_TTL_SECONDS=86400

# ---------------------------------------------------------------- limites de negócio
FREE_ARCHIVE_DAYS=7
RESEND_LIMIT_FREE=1
RESEND_LIMIT_PAID=3
WINDOW_MISS_THRESHOLD=3
SERVICE_WINDOW_HOURS=24
TEASER_MAX_LENGTH=300
FREEFORM_TEXT_MAX_LENGTH=4096
MAX_BODY_BYTES=262144
MAX_BODY_BYTES_ADMIN=1048576
WEBHOOK_MAX_BODY_BYTES=5242880
HTTP_HANDLER_TIMEOUT_MS=25000
SEND_BATCH_TIMEOUT_MINUTES=40

# ---------------------------------------------------------------- bootstrap
BOOTSTRAP_ADMIN_EMAIL=owner@palavradiaria.com.br
BOOTSTRAP_ADMIN_PASSWORD=SEGREDO_TROQUE_NO_PRIMEIRO_LOGIN
SEED_DEV_DATA=true
MAINTENANCE_MODE=false
MAINTENANCE_BYPASS_TOKEN=
OPS_CLI_TOKEN=
BACKUP_S3_BUCKET=
BACKUP_RETENTION_DAYS=35
BACKUP_ENCRYPTION_KEY=SEGREDO_CHAVE_PUBLICA_age_DO_BACKUP

# ------------------------------------------------------- ganchos de teste
# Ligados apenas em execucao de teste. Nunca no servidor de producao.
E2E_TEST_HOOKS=false
E2E_HOOK_SECRET=
ALLOW_TEST_HOOKS_IN_PROD_BUILD=false

26.5 Schema Zod de validação de ambiente #

Arquivo packages/core/src/env.ts. É a única porta de entrada para configuração. process.env é proibido em qualquer outro arquivo, e uma regra de lint reforça isso.

// packages/core/src/env.ts
import { z } from 'zod';

// ------------------------------------------------------------ auxiliares
const bool = z
  .enum(['true', 'false', '1', '0'])
  .transform((v) => v === 'true' || v === '1');

const int = (min: number, max: number) =>
  z.coerce.number().int().min(min).max(max);

const float01 = z.coerce.number().min(0).max(1);

const csv = z
  .string()
  .min(1)
  .transform((v) => v.split(',').map((s) => s.trim()).filter(Boolean));

const pem = (kind: 'PRIVATE' | 'PUBLIC') =>
  z.string().transform((v) => v.replace(/\\n/g, '\n')).refine(
    (v) => v.includes(`-----BEGIN ${kind} KEY-----`),
    { message: `deve ser uma chave ${kind} no formato PEM` },
  );

const secret = (minLength: number) =>
  z.string().min(minLength, `precisa de ao menos ${minLength} caracteres`);

const base64Key32 = z.string().refine(
  (v) => { try { return Buffer.from(v, 'base64').length === 32; } catch { return false; } },
  { message: 'deve ser 32 bytes em base64 (openssl rand -base64 32)' },
);

// ------------------------------------------------------------ schema
export const envSchema = z
  .object({
    // núcleo
    NODE_ENV: z.enum(['development', 'test', 'production']),
    APP_ENV: z.enum(['local', 'staging', 'production']),
    APP_NAME: z.string().default('palavra-diaria'),
    APP_URL: z.string().url(),
    PUBLIC_SITE_URL: z.string().url(),
    PORT: int(1, 65535).default(3000),
    WORKER_PORT: int(1, 65535).default(3001),
    TZ: z.string().default('UTC'),
    BUILD_SHA: z.string().default('unknown'),
    NEXT_PUBLIC_GA_ID: z.string().optional().or(z.literal('')),
    NEXT_PUBLIC_META_PIXEL_ID: z.string().optional().or(z.literal('')),

    // banco
    DATABASE_URL: z.string().url().startsWith('postgresql://'),
    SHADOW_DATABASE_URL: z.string().url().optional(),
    DATABASE_POOL_MAX: int(1, 200).default(10),
    DATABASE_POOL_MIN: int(0, 50).default(2),
    DATABASE_CONNECT_TIMEOUT_MS: int(100, 60_000).default(5_000),
    DATABASE_STATEMENT_TIMEOUT_MS: int(100, 120_000).default(8_000),
    DATABASE_SSL: bool.default('false'),
    DATABASE_LOG_QUERIES: bool.default('false'),

    // redis e filas
    REDIS_URL: z.string().url(),
    REDIS_TLS: bool.default('false'),
    REDIS_COMMAND_TIMEOUT_MS: int(50, 30_000).default(1_000),
    REDIS_MAX_RETRIES: int(0, 20).default(3),
    BULLMQ_PREFIX: z.string().min(1).max(20).default('pd'),
    BULLMQ_CONCURRENCY: int(1, 200).default(10),
    BULLMQ_MAX_STALLED_COUNT: int(0, 10).default(2),
    BULLMQ_JOB_RETENTION_COMPLETED: int(0, 100_000).default(1_000),
    BULLMQ_JOB_RETENTION_FAILED: int(0, 100_000).default(5_000),

    // whatsapp
    WHATSAPP_GRAPH_VERSION: z.string().regex(/^v[0-9]+\.[0-9]+$/).default('v26.0'),
    WHATSAPP_API_BASE_URL: z.string().url().default('https://graph.facebook.com/v26.0'),
    WHATSAPP_PHONE_NUMBER_ID: z.string().regex(/^[0-9]{10,20}$/),
    WHATSAPP_BUSINESS_ACCOUNT_ID: z.string().regex(/^[0-9]{10,20}$/),
    WHATSAPP_SYSTEM_USER_TOKEN: secret(40),
    META_APP_ID: z.string().regex(/^[0-9]{10,20}$/),
    META_APP_SECRET: secret(20),
    META_APP_SECRET_PREVIOUS: secret(20).optional().or(z.literal('')),
    WHATSAPP_VERIFY_TOKEN: secret(20),
    WHATSAPP_SEND_RATE_PER_SECOND: int(1, 80).default(20),
    WHATSAPP_TIMEOUT_MS: int(1_000, 120_000).default(15_000),
    WHATSAPP_MAX_RETRIES: int(0, 10).default(3),
    WHATSAPP_TEMPLATE_DAILY: z.string().default('devocional_diario_v1'),
    WHATSAPP_TEMPLATE_VIDEO: z.string().default('devocional_diario_video_v1'),
    WHATSAPP_TEMPLATE_OTP: z.string().default('codigo_acesso_v1'),
    WHATSAPP_TEMPLATE_WELCOME: z.string().default('boas_vindas_v1'),
    WHATSAPP_TEMPLATE_LANGUAGE: z.string().default('pt_BR'),
    WHATSAPP_MEDIA_TTL_DAYS: int(1, 30).default(30),

    // pagamentos
    ASAAS_ENVIRONMENT: z.enum(['sandbox', 'production']),
    ASAAS_API_BASE_URL: z.string().url().optional(),
    ASAAS_API_KEY: secret(20),
    ASAAS_WEBHOOK_TOKEN: secret(20),
    ASAAS_WEBHOOK_TOKEN_PREVIOUS: secret(20).optional().or(z.literal('')),
    ASAAS_TIMEOUT_MS: int(1_000, 60_000).default(10_000),
    ASAAS_MAX_RETRIES: int(0, 5).default(2),
    ASAAS_RATE_LIMIT_RPS: int(1, 100).default(8),

    // voz e mídia
    TTS_PRIMARY_PROVIDER: z.enum(['ELEVENLABS', 'GOOGLE_TTS']).default('ELEVENLABS'),
    ELEVENLABS_API_KEY: secret(20).optional(),
    ELEVENLABS_BASE_URL: z.string().url().default('https://api.elevenlabs.io/v1'),
    TTS_VOICE_ID: z.string().min(3),
    TTS_MODEL_ID: z.string().default('eleven_multilingual_v2'),
    TTS_STABILITY: float01.default(0.45),
    TTS_SIMILARITY_BOOST: float01.default(0.75),
    TTS_OUTPUT_FORMAT: z.string().default('mp3_44100_128'),
    TTS_TIMEOUT_MS: int(5_000, 600_000).default(120_000),
    TTS_MAX_ATTEMPTS_BEFORE_FALLBACK: int(1, 10).default(3),
    GOOGLE_TTS_CREDENTIALS_JSON: z
      .string()
      .refine((v) => { try { JSON.parse(v); return true; } catch { return false; } },
              { message: 'deve ser um JSON válido em uma única linha' })
      .optional(),
    GOOGLE_TTS_VOICE_NAME: z.string().default('pt-BR-Neural2-C'),
    TTS_FALLBACK_VOICE_ID: z.string().optional().or(z.literal('')),
    GOOGLE_TTS_LANGUAGE_CODE: z.string().default('pt-BR'),
    FFMPEG_PATH: z.string().default('ffmpeg'),
    AUDIO_OGG_BITRATE_KBPS: int(8, 128).default(32),
    AUDIO_OGG_FALLBACK_BITRATE_KBPS: int(8, 128).default(24),
    AUDIO_MP3_BITRATE_KBPS: int(32, 320).default(128),
    AUDIO_MAX_BYTES: int(1, 16_777_216).default(16_777_216),
    AUDIO_WARN_BYTES: int(1, 16_777_216).default(12_582_912),

    // storage
    S3_ENDPOINT: z.string().url(),
    S3_REGION: z.string().default('auto'),
    S3_BUCKET: z.string().min(3),
    S3_ACCESS_KEY_ID: secret(8),
    S3_SECRET_ACCESS_KEY: secret(16),
    S3_FORCE_PATH_STYLE: bool.default('true'),
    S3_SIGNED_URL_TTL_SECONDS: int(60, 604_800).default(900),
    S3_PUBLIC_BASE_URL: z.string().url().optional().or(z.literal('')),
    S3_ARCHIVE_BUCKET: z.string().optional(),

    // e-mail
    EMAIL_ENABLED: bool.default('true'),
    RESEND_API_KEY: secret(20).optional(),
    EMAIL_FROM: z.string().min(5).optional(),
    EMAIL_REPLY_TO: z.string().email().optional(),
    EMAIL_ALERTS_TO: z.string().email(),
    EMAIL_MAX_RETRIES: int(0, 10).default(3),

    // sessão e cripto
    SESSION_JWT_PRIVATE_KEY: pem('PRIVATE'),
    SESSION_JWT_PUBLIC_KEY: pem('PUBLIC'),
    SESSION_JWT_KEY_ID: z.string().min(1).max(16).default('k1'),
    SESSION_JWT_PREVIOUS_PUBLIC_KEY: pem('PUBLIC').optional().or(z.literal('')),
    SESSION_JWT_PREVIOUS_KEY_ID: z.string().max(16).optional(),
    ACCESS_TOKEN_TTL_SECONDS: int(60, 3_600).default(900),
    SESSION_TTL_SUBSCRIBER_DAYS: int(1, 365).default(30),
    SESSION_TTL_ADMIN_HOURS: int(1, 168).default(12),
    IMPERSONATION_TTL_MINUTES: int(5, 240).default(30),
    OTP_PEPPER: secret(32),
    ENCRYPTION_KEY: base64Key32,
    ENCRYPTION_KEY_PREVIOUS: base64Key32.optional().or(z.literal('')),
    PHONE_INDEX_KEY: base64Key32,
    PHONE_INDEX_KEY_PREVIOUS: base64Key32.optional().or(z.literal('')),
    CSRF_SECRET: secret(32),
    COOKIE_DOMAIN: z.string().optional(),
    TRUST_PROXY: bool.default('true'),
    ALLOWED_ORIGINS: csv,
    TURNSTILE_SECRET_KEY: secret(20).optional(),

    // observabilidade
    LOG_LEVEL: z.enum(['trace','debug','info','warn','error','fatal']).default('info'),
    LOG_FORMAT: z.enum(['json', 'pretty']).default('json'),
    LOG_REDACT_EXTRA: csv.optional(),
    SENTRY_DSN: z.string().url().optional().or(z.literal('')),
    SENTRY_TRACES_SAMPLE_RATE: float01.default(0.1),
    METRICS_ENABLED: bool.default('true'),
    METRICS_TOKEN: secret(32).optional(),
    OPS_WEBHOOK_URL: z.string().url().optional().or(z.literal('')),
    OPS_WEBHOOK_SECRET: secret(32).optional(),
    HEALTHCHECK_TIMEOUT_MS: int(100, 30_000).default(3_000),

    // agendamento
    SCHEDULER_ENABLED: bool.default('true'),
    SCHEDULER_TIMEZONE: z.string().default('America/Sao_Paulo'),
    DAILY_SEND_HOUR: int(0, 23).default(6),
    DAILY_PLAN_OFFSET_MINUTES: int(5, 120).default(20),
    FREE_TIER_SEND_WEEKDAY: int(0, 6).default(0),
    RECONCILE_HOUR: int(0, 23).default(4),
    RETENTION_HOUR: int(0, 23).default(3),
    PARTITION_HOUR: int(0, 23).default(3),
    METRICS_ROLLUP_HOUR: int(0, 23).default(3),
    JOB_STALE_MINUTES: int(1, 1_440).default(30),

    // rate limiting
    RATE_LIMIT_ENABLED: bool.default('true'),
    RATE_LIMIT_GLOBAL_IP_CAPACITY: int(1, 100_000).default(600),
    RATE_LIMIT_GLOBAL_IP_REFILL_PER_MINUTE: int(1, 100_000).default(300),
    OTP_MAX_PER_HOUR_PER_PHONE: int(1, 20).default(3),
    OTP_MAX_PER_HOUR_PER_IP: int(1, 200).default(10),
    OTP_RESEND_COOLDOWN_SECONDS: int(10, 600).default(60),
    OTP_TTL_SECONDS: int(60, 1_800).default(600),
    OTP_MAX_ATTEMPTS: int(1, 10).default(5),
    MAGIC_LINK_TTL_SECONDS: int(300, 3_600).default(1_200),
    ADMIN_LOGIN_MAX_PER_HOUR_PER_IP: int(1, 100).default(10),
    IDEMPOTENCY_TTL_SECONDS: int(300, 604_800).default(86_400),

    // limites de negócio
    FREE_ARCHIVE_DAYS: int(1, 365).default(7),
    RESEND_LIMIT_FREE: int(0, 10).default(1),
    RESEND_LIMIT_PAID: int(0, 20).default(3),
    WINDOW_MISS_THRESHOLD: int(1, 30).default(3),
    SERVICE_WINDOW_HOURS: int(1, 24).default(24),
    TEASER_MAX_LENGTH: int(50, 300).default(300),
    FREEFORM_TEXT_MAX_LENGTH: int(500, 4_096).default(4_096),
    MAX_BODY_BYTES: int(1_024, 10_485_760).default(262_144),
    MAX_BODY_BYTES_ADMIN: int(1_024, 10_485_760).default(1_048_576),
    WEBHOOK_MAX_BODY_BYTES: int(1_024, 10_485_760).default(5_242_880),
    HTTP_HANDLER_TIMEOUT_MS: int(1_000, 120_000).default(25_000),
    SEND_BATCH_TIMEOUT_MINUTES: int(5, 240).default(40),

    // bootstrap
    BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
    BOOTSTRAP_ADMIN_PASSWORD: z
      .string()
      .min(24, 'precisa de ao menos 24 caracteres')
      .refine((v) => !COMMON_PASSWORDS.has(v.toLowerCase()) && v !== PUBLISHED_EXAMPLE_PASSWORD,
              { message: 'senha comum ou igual ao exemplo publicado' })
      .optional(),
    SEED_DEV_DATA: bool.default('false'),
    MAINTENANCE_MODE: bool.default('false'),
    MAINTENANCE_BYPASS_TOKEN: secret(32).optional(),
    OPS_CLI_TOKEN: secret(32).optional(),
    BACKUP_S3_BUCKET: z.string().optional(),
    BACKUP_RETENTION_DAYS: int(1, 365).default(35),
    BACKUP_ENCRYPTION_KEY: secret(20).optional(),

    // ganchos de teste
    E2E_TEST_HOOKS: bool.default('false'),
    E2E_HOOK_SECRET: secret(20).optional(),
    ALLOW_TEST_HOOKS_IN_PROD_BUILD: bool.default('false'),
  })

  // ---------------------------------------------------- regras entre campos
  .superRefine((e, ctx) => {
    const issue = (path: string, message: string) =>
      ctx.addIssue({ code: 'custom', path: [path], message });

    if (e.TTS_PRIMARY_PROVIDER === 'ELEVENLABS' && !e.ELEVENLABS_API_KEY) {
      issue('ELEVENLABS_API_KEY', 'obrigatória quando TTS_PRIMARY_PROVIDER=ELEVENLABS');
    }
    if (e.EMAIL_ENABLED && !e.RESEND_API_KEY) {
      issue('RESEND_API_KEY', 'obrigatória quando EMAIL_ENABLED=true');
    }
    if (e.EMAIL_ENABLED && !e.EMAIL_FROM) {
      issue('EMAIL_FROM', 'obrigatória quando EMAIL_ENABLED=true');
    }
    if (e.OPS_WEBHOOK_URL && !e.OPS_WEBHOOK_SECRET) {
      issue('OPS_WEBHOOK_SECRET', 'obrigatória quando OPS_WEBHOOK_URL está definida');
    }
    if (e.MAINTENANCE_MODE && !e.MAINTENANCE_BYPASS_TOKEN) {
      issue('MAINTENANCE_BYPASS_TOKEN', 'obrigatório quando MAINTENANCE_MODE=true');
    }
    if (e.E2E_TEST_HOOKS && !e.E2E_HOOK_SECRET) {
      issue('E2E_HOOK_SECRET', 'obrigatório quando E2E_TEST_HOOKS=true');
    }
    if (e.E2E_TEST_HOOKS && e.NODE_ENV === 'production' && !e.ALLOW_TEST_HOOKS_IN_PROD_BUILD) {
      issue('E2E_TEST_HOOKS', 'proibidos com NODE_ENV=production');
    }
    if (e.DATABASE_POOL_MIN > e.DATABASE_POOL_MAX) {
      issue('DATABASE_POOL_MIN', 'não pode ser maior que DATABASE_POOL_MAX');
    }
    if (e.AUDIO_WARN_BYTES > e.AUDIO_MAX_BYTES) {
      issue('AUDIO_WARN_BYTES', 'não pode ser maior que AUDIO_MAX_BYTES');
    }
    if (e.AUDIO_OGG_FALLBACK_BITRATE_KBPS > e.AUDIO_OGG_BITRATE_KBPS) {
      issue('AUDIO_OGG_FALLBACK_BITRATE_KBPS', 'deve ser menor ou igual ao bitrate normal');
    }

    // regras específicas de produção
    if (e.APP_ENV === 'production') {
      if (e.ASAAS_ENVIRONMENT !== 'production') {
        issue('ASAAS_ENVIRONMENT', 'precisa ser "production" quando APP_ENV=production');
      }
      if (!e.APP_URL.startsWith('https://')) {
        issue('APP_URL', 'precisa usar https em produção');
      }
      if (e.ALLOWED_ORIGINS.some((o) => !o.startsWith('https://'))) {
        issue('ALLOWED_ORIGINS', 'todas as origens precisam usar https em produção');
      }
      if (e.DATABASE_LOG_QUERIES) {
        issue('DATABASE_LOG_QUERIES', 'proibido em produção: consultas contêm dado pessoal');
      }
      if (e.SEED_DEV_DATA) {
        issue('SEED_DEV_DATA', 'proibido em produção');
      }
      if (!e.RATE_LIMIT_ENABLED) {
        issue('RATE_LIMIT_ENABLED', 'não pode ser desligado em produção');
      }
      if (e.METRICS_ENABLED && !e.METRICS_TOKEN) {
        issue('METRICS_TOKEN', 'obrigatório em produção quando METRICS_ENABLED=true');
      }
      if (!e.OPS_CLI_TOKEN) {
        issue('OPS_CLI_TOKEN', 'obrigatório em produção');
      }
      if (!e.TURNSTILE_SECRET_KEY) {
        issue('TURNSTILE_SECRET_KEY', 'obrigatória em produção: formulário público sem'
              + ' verificação anti-bot vira porta de cadastro em massa');
      }
      if (!e.BACKUP_ENCRYPTION_KEY) {
        issue('BACKUP_ENCRYPTION_KEY', 'obrigatória em produção: backup nunca sai do host'
              + ' em texto claro');
      }
      if (e.E2E_TEST_HOOKS || e.ALLOW_TEST_HOOKS_IN_PROD_BUILD) {
        issue('E2E_TEST_HOOKS', 'ganchos de teste são proibidos em produção');
      }
      if (!e.DATABASE_SSL && !e.DATABASE_URL.includes('@postgres:')) {
        issue('DATABASE_SSL', 'exigido quando o banco não está na rede privada do compose');
      }
      if (e.LOG_FORMAT !== 'json') {
        issue('LOG_FORMAT', 'precisa ser "json" em produção para a coleta de logs');
      }
      if (e.ENCRYPTION_KEY_PREVIOUS && e.ENCRYPTION_KEY_PREVIOUS === e.ENCRYPTION_KEY) {
        issue('ENCRYPTION_KEY_PREVIOUS', 'não pode ser igual à chave corrente');
      }
      if (e.PHONE_INDEX_KEY === e.ENCRYPTION_KEY) {
        issue('PHONE_INDEX_KEY',
              'precisa ser distinta de ENCRYPTION_KEY: chaves iguais unem os dois riscos');
      }
    }

    if (e.APP_ENV !== 'production' && e.ASAAS_ENVIRONMENT === 'production') {
      issue('ASAAS_ENVIRONMENT',
            'usar o ambiente de produção de pagamentos fora de produção cobraria de verdade');
    }
  });

export type Env = z.infer<typeof envSchema>;

// ------------------------------------------------------------ carga
let cached: Env | null = null;

export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
  if (cached) return cached;

  const parsed = envSchema.safeParse(source);
  if (!parsed.success) {
    const lines = parsed.error.issues.map((i) => {
      const name = i.path.join('.') || '(raiz)';
      return `  • ${name}: ${i.message}`;
    });
    // eslint-disable-next-line no-console -- o logger ainda não existe neste ponto
    console.error(
      [
        '',
        '════════════════════════════════════════════════════════════════',
        ' CONFIGURAÇÃO INVÁLIDA — o processo não pode iniciar',
        '════════════════════════════════════════════════════════════════',
        '',
        `Foram encontrados ${parsed.error.issues.length} problema(s):`,
        '',
        ...lines,
        '',
        'Consulte .env.example para o formato esperado de cada variável.',
        'Segredos nunca são exibidos nesta mensagem.',
        '════════════════════════════════════════════════════════════════',
        '',
      ].join('\n'),
    );
    process.exit(1);
  }

  cached = parsed.data;
  return cached;
}

export const env = loadEnv();

Exemplo de saída real quando faltam variáveis:

════════════════════════════════════════════════════════════════
 CONFIGURAÇÃO INVÁLIDA — o processo não pode iniciar
════════════════════════════════════════════════════════════════

Foram encontrados 4 problema(s):

  • DATABASE_URL: Required
  • OTP_PEPPER: precisa de ao menos 32 caracteres
  • ENCRYPTION_KEY: deve ser 32 bytes em base64 (openssl rand -base64 32)
  • ASAAS_ENVIRONMENT: precisa ser "production" quando APP_ENV=production

Consulte .env.example para o formato esperado de cada variável.
Segredos nunca são exibidos nesta mensagem.
════════════════════════════════════════════════════════════════

Três propriedades garantidas por esse desenho:

  1. Todos os problemas de uma vez. safeParse acumula; ninguém corrige uma variável, reinicia, e descobre a seguinte.
  2. Nenhum valor é impresso. A mensagem cita o nome e a regra violada, nunca o valor recebido. Imprimir "recebido: sk_live_abc123" vazaria o segredo no log de boot.
  3. Falha antes de servir. loadEnv() é chamado no topo do módulo de instrumentação, antes do servidor abrir a porta. Um processo mal configurado nunca chega a atender.

26.6 Diferenças entre ambientes #

Variável local staging production
NODE_ENV development production production
APP_ENV local staging production
APP_URL http://localhost:3000 https://staging.palavradiaria.com.br https://app.palavradiaria.com.br
DATABASE_SSL false false (rede privada) false (rede privada do compose)
DATABASE_LOG_QUERIES true false false (proibido pelo schema)
DATABASE_POOL_MAX 5 10 20
BULLMQ_CONCURRENCY 5 10 25
ASAAS_ENVIRONMENT sandbox sandbox production
WHATSAPP_PHONE_NUMBER_ID número de teste número de teste número de produção
WHATSAPP_SEND_RATE_PER_SECOND 2 5 20
EMAIL_ENABLED false true (só para a equipe) true
TTS_PRIMARY_PROVIDER ELEVENLABS ELEVENLABS ELEVENLABS
S3_ENDPOINT MinIO local bucket de staging bucket de produção
S3_BUCKET palavra-diaria-media palavra-diaria-media-staging palavra-diaria-media
LOG_LEVEL debug debug info
LOG_FORMAT pretty json json
SENTRY_DSN vazio preenchido preenchido
SENTRY_TRACES_SAMPLE_RATE 0 1.0 0.1
RATE_LIMIT_ENABLED false true true (obrigatório)
TRUST_PROXY false true true
SCHEDULER_ENABLED false true true
DAILY_SEND_HOUR qualquer, para teste 6 6
SEED_DEV_DATA true true false (proibido)
MAINTENANCE_MODE false false false
METRICS_TOKEN vazio preenchido preenchido (obrigatório)
OPS_CLI_TOKEN vazio preenchido preenchido (obrigatório)
BACKUP_S3_BUCKET vazio preenchido preenchido
BACKUP_ENCRYPTION_KEY vazio preenchido preenchido (obrigatório)
TURNSTILE_SECRET_KEY chave de teste do provedor preenchido preenchido (obrigatório)
E2E_TEST_HOOKS true true false (proibido)
ALLOW_TEST_HOOKS_IN_PROD_BUILD false false false (proibido)
NEXT_PUBLIC_GA_ID / NEXT_PUBLIC_META_PIXEL_ID vazios vazios vazios

Duas decisões importantes na tabela:

  • SCHEDULER_ENABLED=false em desenvolvimento. Sem isso, cada desenvolvedor teria um agendador tentando enviar mensagens. O disparo em desenvolvimento é manual, pela linha de comando de operação.
  • ASAAS_ENVIRONMENT=sandbox em staging. Staging usa dados sintéticos. Cobrar de verdade em staging é o tipo de erro que só se comete uma vez, e o schema de 26.5 impede a combinação APP_ENV≠production com ASAAS_ENVIRONMENT=production.

Staging usa SENTRY_TRACES_SAMPLE_RATE=1.0 de propósito: o volume é baixo e o objetivo é enxergar tudo. Produção usa 0,1 porque 100% de rastros em 10.000 assinantes é caro sem ganho proporcional.

26.7 Política de segredos #

26.7.1 Onde ficam #

Ambiente Onde Acesso
local Arquivo .env na máquina do desenvolvedor, fora do controle de versão O próprio desenvolvedor
staging Arquivo /etc/palavra-diaria/staging.env no servidor, modo 0600, dono root Operadores com acesso ao servidor
production Arquivo /etc/palavra-diaria/production.env no servidor, modo 0600, dono root Somente quem tem OWNER e acesso ao servidor
CI Segredos do repositório, escopo por ambiente Quem administra o repositório

Regras não negociáveis:

  • .env e *.env estão no .gitignore. Um gancho de pré-commit recusa qualquer arquivo que case com .env* e não seja .env.example.
  • Segredo nunca entra em imagem de contêiner. É injetado em tempo de execução por env_file no docker-compose.yml.
  • Segredo nunca entra em argumento de build (ARG/--build-arg): argumentos ficam no histórico de camadas da imagem.
  • Segredo nunca aparece em pull request, issue, mensagem de chat ou captura de tela.
  • Segredo nunca é enviado por e-mail ou mensagem. A transferência é por canal cifrado de uso único.

26.7.2 Quem tem acesso #

Papel Acesso
Desenvolvedor Apenas os segredos de local, que apontam para serviços de teste
Operador staging completo; production somente leitura, para diagnóstico
OWNER do produto production completo, incluindo rotação
Agente executor de build Nenhum. Recebe a configuração já montada no ambiente de execução

O acesso a production é individual e nominal. Não existe credencial compartilhada de servidor: cada pessoa tem chave SSH própria, e a revogação é a remoção da chave.

26.7.3 Como rotacionar cada segredo #

Esta subseção é a dona única da cadência de rotação. Nenhuma outra seção declara prazo de rotação de segredo: as Seções 22.4.2 e 25.14.2 trazem o inventário e o procedimento e apontam para cá. As três âncoras são 90 dias para a chave de assinatura de sessão, 180 dias para as senhas de banco e de Redis e 365 dias para as chaves de cifra. O prazo é o teto, nunca o piso: rotação antecipada é sempre permitida e é obrigatória sob suspeita de vazamento. O alerta secret_rotation_due (Seção 23.8) usa exatamente os prazos desta tabela, lidos da chave ops.secret_rotated_at de settings.

Segredo Frequência Procedimento Interrupção
DATABASE_URL (senha) 180 dias, ou em incidente Cria o novo usuário no Postgres com as mesmas permissões, troca a variável, reinicia web e worker, remove o usuário antigo depois de 24 h Alguns segundos no reinício
REDIS_URL (senha) 180 dias CONFIG SET requirepass, troca a variável, reinicia Alguns segundos; sessões sobrevivem, chaves de rate limit são recriadas
WHATSAPP_SYSTEM_USER_TOKEN 60 dias Gera token novo no painel da plataforma, troca a variável, reinicia o worker, revoga o antigo Nenhuma se o token novo entrar antes de o antigo ser revogado
META_APP_SECRET Só em incidente Janela dupla obrigatória: copia o valor atual para META_APP_SECRET_PREVIOUS, gera o novo segredo no painel do provedor, coloca-o em META_APP_SECRET, reinicia o web. A verificação aceita as duas assinaturas e registra qual validou; só depois de 100% das validações usarem a nova é que a anterior é removida Nenhuma. Sem a janela dupla, toda rotação rejeita webhooks legítimos entre a troca no painel e o reinício — e webhook rejeitado leva o provedor a desativar a assinatura
WHATSAPP_VERIFY_TOKEN 365 dias Troca a variável, reinicia, refaz o handshake no painel Nenhuma; o token só é usado no handshake
ASAAS_API_KEY 365 dias Gera nova chave no provedor, troca, reinicia, revoga a antiga Nenhuma com sobreposição
ASAAS_WEBHOOK_TOKEN 365 dias Troca no provedor e na variável na mesma janela, reinicia Webhooks falham entre as duas trocas. Fazer em menos de 2 minutos e reprocessar o que falhar
ELEVENLABS_API_KEY 365 dias Gera nova, troca, reinicia o worker, revoga a antiga Nenhuma
GOOGLE_TTS_CREDENTIALS_JSON 365 dias Nova chave da conta de serviço, troca, reinicia, apaga a antiga Nenhuma
S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY 365 dias Novo par de chaves, troca, reinicia, revoga o antigo Nenhuma
RESEND_API_KEY 365 dias Nova chave, troca, reinicia, revoga Nenhuma
SESSION_JWT_PRIVATE_KEY / SESSION_JWT_PUBLIC_KEY 90 dias Ver 26.7.4 Nenhuma, com a janela de sobreposição
OTP_PEPPER Só em incidente Troca e reinicia. Todos os OTPs pendentes são invalidados Quem estava com código em mãos precisa pedir outro
ENCRYPTION_KEY 365 dias Ver 26.7.4 Nenhuma, com a chave anterior configurada
PHONE_INDEX_KEY 365 dias, na mesma janela de ENCRYPTION_KEY Ver 26.7.4 e o procedimento de três fases de 22.5.5 Nenhuma; durante a fase de leitura dupla cada busca compara dois HMACs
CSRF_SECRET 365 dias Troca e reinicia Tokens CSRF em páginas abertas ficam inválidos; a próxima ação falha uma vez e o front recarrega
METRICS_TOKEN 365 dias Troca, reinicia, atualiza o coletor Coleta de métricas interrompida até atualizar o coletor
OPS_WEBHOOK_SECRET 365 dias Troca dos dois lados na mesma janela Alertas falham entre as trocas; o log continua
OPS_CLI_TOKEN 90 dias, ou na saída de alguém da equipe Troca e reinicia Nenhuma
TURNSTILE_SECRET_KEY 365 dias Gera par novo no painel do provedor, troca a variável, reinicia o web Nenhuma; o desafio anterior continua válido pelo tempo de vida do token já emitido
BACKUP_ENCRYPTION_KEY 365 dias Validar antes que os backups antigos ainda são legíveis com a chave anterior guardada; só então gerar a nova, trocar a variável e passar a cifrar com ela. A chave antiga nunca é descartada enquanto existir backup cifrado com ela Nenhuma na operação; a restauração de um backup antigo exige a chave da época
BOOTSTRAP_ADMIN_PASSWORD Uso único Removida do ambiente após o primeiro login. O boot recusa iniciar em produção se ela persistir com um OWNER já enrolado em TOTP (26.3.14)

26.7.4 Rotação com sobreposição #

Três segredos exigem janela de sobreposição, porque há dados antigos que precisam continuar sendo lidos.

Chave de assinatura de sessão:

# 1. gera o novo par
openssl genpkey -algorithm ed25519 -out jwt-k2.key
openssl pkey -in jwt-k2.key -pubout -out jwt-k2.pub

# 2. move a atual para "anterior" e coloca a nova como corrente
SESSION_JWT_PREVIOUS_PUBLIC_KEY="<conteúdo de jwt-k1.pub>"
SESSION_JWT_PREVIOUS_KEY_ID=k1
SESSION_JWT_PRIVATE_KEY="<conteúdo de jwt-k2.key>"
SESSION_JWT_PUBLIC_KEY="<conteúdo de jwt-k2.pub>"
SESSION_JWT_KEY_ID=k2

# 3. reinicia web e worker
#    tokens novos são assinados com k2; tokens antigos ainda verificam com k1

# 4. depois de 20 minutos (TTL do access token de 15 min + folga),
#    remove SESSION_JWT_PREVIOUS_PUBLIC_KEY e SESSION_JWT_PREVIOUS_KEY_ID e reinicia

Nenhum assinante é desconectado. O refresh token não depende da chave, então mesmo um token de acesso rejeitado é renovado sem novo login.

Chave de criptografia de dados:

# 1. mantém a atual como anterior e define a nova
ENCRYPTION_KEY_PREVIOUS="<chave atual em base64>"
ENCRYPTION_KEY="$(openssl rand -base64 32)"

# 2. reinicia: escrita usa a nova, leitura tenta a nova e cai para a anterior

# 3. recifra os dados existentes em lote
pnpm ops crypto:rewrap --batch-size 500

# 4. confirma que nada resta com a chave antiga
pnpm ops crypto:verify   # precisa reportar 0 registros com a chave anterior

# 5. remove ENCRYPTION_KEY_PREVIOUS e reinicia

Pular o passo 4 e remover a chave anterior torna telefone, wa_id, e-mail, CPF e segredo TOTP irrecuperáveis. O comando crypto:verify existe exatamente para impedir esse erro, e o passo 5 é bloqueado por checagem de boot: se ainda houver linha que só decifra com a chave anterior e ENCRYPTION_KEY_PREVIOUS estiver ausente, o processo falha ao iniciar com mensagem explícita. Note que a verificação não pode ser feita por comparação de prefixo no envelope: o prefixo v<n> marca o formato do envelope, não a chave que o produziu (Seção 22.5.3).

Chave do índice cego:

# 1. mantém a atual como anterior e define a nova
PHONE_INDEX_KEY_PREVIOUS="<chave atual em base64>"
PHONE_INDEX_KEY="$(openssl rand -base64 32)"

# 2. reinicia: toda busca calcula os dois HMACs e consulta com IN (novo, antigo);
#    toda escrita usa apenas a chave nova

# 3. recalcula os índices cegos existentes em lote
pnpm ops crypto:reindex --batch-size 500

# 4. confirma que nenhuma linha depende da chave anterior
pnpm ops crypto:reindex --verify   # precisa reportar 0 linhas pendentes

# 5. remove PHONE_INDEX_KEY_PREVIOUS e reinicia

A rotação do índice não tem prefixo possível, porque o valor precisa ser comparável por igualdade direta no índice — é por isso que ela usa leitura dupla em vez de marcador. Se o backfill for interrompido, o sistema permanece funcional na fase 1 indefinidamente; a única perda é o custo de uma comparação a mais por busca. O procedimento completo, com as três fases e a justificativa, está em 22.5.5.

26.7.5 Proibição de logar segredos #

Redação obrigatória no logger, aplicada em todos os três apps:

// packages/core/src/logging/redact.ts
export const REDACT_PATHS = [
  // cabeçalhos
  'req.headers.authorization',
  'req.headers.cookie',
  'req.headers["x-hub-signature-256"]',
  'req.headers["asaas-access-token"]',
  'req.headers["idempotency-key"]',
  'res.headers["set-cookie"]',
  // corpos
  'req.body.password',
  'req.body.currentPassword',
  'req.body.newPassword',
  'req.body.code',
  'req.body.token',
  'req.body.creditCardToken',
  'req.body.creditCard',
  'req.body.cpfCnpj',
  '*.creditCard',
  '*.creditCardToken',
  '*.cvv',
  '*.password',
  '*.passwordHash',
  '*.totpSecret',
  '*.totpSecretEncrypted',
  '*.refreshToken',
  '*.refreshTokenHash',
  '*.codeHash',
  '*.apiKey',
  '*.accessToken',
  '*.secret',
  '*.pepper',
  // URL assinada é credencial, não endereço: quem tem a URL tem o arquivo (Seção 23.1.4).
  'url',
  'mediaUrl',
  'signedUrl',
  'downloadUrl',
  'audioUrl',
  'videoUrl',
  'presignedUrl',
  'invoiceUrl',
  '*.url',
] as const;

export const loggerOptions = {
  redact: { paths: [...REDACT_PATHS, ...(env.LOG_REDACT_EXTRA ?? [])], censor: '[REDACTED]' },
  level: env.LOG_LEVEL,
};

Quatro reforços além da lista:

  1. Nunca JSON.stringify(env). O objeto de configuração não tem serialização segura. Ele expõe toJSON() que devolve apenas os nomes das chaves definidas, nunca os valores.
  2. /api/internal/version devolve APP_ENV, BUILD_SHA e a lista de nomes de variáveis presentes — jamais valores.
  3. Teste automatizado de vazamento. A suíte executa um cenário completo de login, checkout, webhook e geração de URL assinada de mídia, capturando toda a saída de log, e falha se encontrar qualquer valor das variáveis marcadas como segredo, o código de acesso gerado, o valor de token de sessão ou de refresh, ou qualquer string contendo X-Amz-Signature ou o host de mídia da plataforma. Roda também com o assinante incluído na lista de depuração de 23.1.5, para cobrir o nível debug. É o único teste que garante a lista de redação na prática, porque um caminho novo de log pode escapar da lista sem ninguém notar — e um logger.debug acrescentado para depurar entrega de código sobrevive ao commit. É bloqueador de pull request.
  4. Erro de provedor externo é sanitizado. Bibliotecas de cliente HTTP costumam anexar os cabeçalhos da requisição ao objeto de erro. O invólucro de cada integração remove config.headers e request antes de propagar.

26.7.6 O que fazer em caso de vazamento #

Passo Prazo Ação
1 Imediato Rotacione o segredo pelo procedimento de 26.7.3. Rotacionar primeiro, investigar depois
2 Imediato Revogue a credencial antiga no provedor. Trocar sem revogar não resolve nada
3 1 hora Registre o incidente: qual segredo, onde vazou, desde quando esteve exposto, quem teve acesso
4 4 horas Avalie o alcance. ENCRYPTION_KEY exposta com cópia do banco significa CPF e segredo TOTP comprometidos
5 24 horas Se houver suspeita de acesso a dado pessoal, avalie a notificação à autoridade e aos titulares conforme a Seção 22
6 48 horas Corrija a causa: remova do histórico do repositório, aperte permissões, feche o canal que vazou
7 1 semana Revise se o mesmo caminho pode vazar outro segredo

Ações adicionais por segredo específico:

Segredo vazado Ação obrigatória além da rotação
SESSION_JWT_PRIVATE_KEY Revogue todas as sessões: UPDATE sessions SET revoked_at = now(), revoked_reason = 'admin_revoke' WHERE revoked_at IS NULL. Quem tinha a chave podia forjar qualquer sessão
ENCRYPTION_KEY Recifre tudo com chave nova. Force o reenrolamento de TOTP de todos os administradores
PHONE_INDEX_KEY Reindexe todos os índices cegos com chave nova (26.7.4). Quem tinha a chave podia confirmar se um telefone específico está na base — o que, neste produto, revela convicção religiosa. Avalie a comunicação de incidente da Seção 22.7.8
OTP_PEPPER Invalide todos os OTPs pendentes
ASAAS_API_KEY Concilie todas as cobranças dos últimos 30 dias contra o provedor, procurando movimentação não originada pelo sistema
WHATSAPP_SYSTEM_USER_TOKEN Verifique o log de mensagens enviadas procurando disparo não originado pelo sistema. Notifique a plataforma
META_APP_SECRET Reprocesse os webhooks recebidos desde o vazamento, procurando evento forjado. webhook_deliveries tem o corpo cru para a perícia
S3_SECRET_ACCESS_KEY Audite os objetos do bucket procurando acesso ou remoção não esperada

26.8 Settings de runtime #

A tabela settings está definida na Seção 6.23 (colunas, índices e constraints) e os seeds estão em 6.32.4. Aqui fica o registro do que cada chave significa.

26.8.1 Chaves canônicas #

Formato obrigatório: grupo.chave, tudo em minúsculas, ponto como único separador. Nenhuma chave em maiúsculas — nome em maiúsculas é nome de variável de ambiente, e confundir os dois faz o administrador procurar no painel um valor que só existe no .env. Nenhuma chave sem grupo.

Esta tabela é a lista completa e fechada. Toda chave citada em qualquer seção do documento existe aqui e no seed obrigatório da Seção 6.32.4 — uma chave lida em código e ausente do seed devolve nulo em produção, e o comportamento que ela deveria controlar simplesmente não acontece, sem erro.

Chave Tipo Padrão Faixa Papel para editar Efeito
send.daily_hour_local INT 6 0–23 ADMIN Hora local do envio diário. Muda o registro do job repetível no próximo ciclo.
send.plan_offset_minutes INT 20 5–120 ADMIN Antecedência do planejamento em relação ao envio.
send.free_tier_weekday INT 0 0–6 ADMIN Dia do envio gratuito. 0 = domingo.
send.rate_per_second INT 20 1–80 ADMIN Taxa operacional de envio. Limitada por WHATSAPP_SEND_RATE_PER_SECOND.
send.max_attempts INT 5 1–10 ADMIN Tentativas por etapa de entrega.
send.window_miss_threshold INT 3 1–14 ADMIN Dias sem abrir a janela antes do template de vídeo.
send.optout_keywords JSON ["SAIR","PARAR","PARE","CANCELAR","STOP","DESCADASTRAR","REMOVER"] ADMIN Palavras de saída reconhecidas. Sete valores, em maiúsculas, conforme a normalização da Seção 20.3.2.
send.optin_keywords JSON ["VOLTAR","RETORNAR","QUERO VOLTAR","REATIVAR"] ADMIN Palavras de retorno reconhecidas.
content.teaser_max_length INT 300 50–300 EDITOR Teto do resumo enviado no template.
content.bible_version_default STRING "ALMEIDA_1911" EDITOR Versão bíblica padrão de novos devocionais. A sigla ARC é proibida (22.10.3).
content.allowed_bible_versions JSON ["ALMEIDA_1911","BIBLIA_LIVRE"] OWNER Lista fechada de versões aceitas pelo editor.
content.bible_source_provenance JSON [] OWNER Origem verificável de cada texto bíblico importado: nome da fonte, URL, data e responsável (22.10.3).
content.editorial_lock_minutes INT 10 1–60 ADMIN Duração do bloqueio de edição concorrente.
resend.free_daily_limit INT 1 0–10 ADMIN Reenvios manuais por dia no plano gratuito. Limitado por RESEND_LIMIT_FREE.
resend.paid_daily_limit INT 3 0–20 ADMIN Reenvios manuais por dia no plano pago.
archive.free_days INT 7 1–90 ADMIN Dias de acervo visíveis no plano gratuito.
landing.announcement_enabled BOOL false ADMIN Liga a faixa de aviso no topo da página pública (Seção 9.3.1). Desligada por padrão: uma faixa esquecida no ar é pior do que faixa nenhuma.
billing.reminder_days JSON [3,1,0] ADMIN Dias antes do vencimento em que o lembrete é enviado.
billing.reconcile_batch_size INT 200 10–2000 ADMIN Assinaturas conferidas por execução da reconciliação.
billing.usd_brl_reference_rate INT 5400 1000–20000 OWNER Câmbio de referência em milésimos de real por dólar (5400 = R$ 5,40/US$). Converte todo custo de mídia e de mensagem cotado em dólar para centavos de real (Seções 16.14, 17.12 e 21.2). Inteiro, nunca decimal: câmbio em float reintroduz erro de arredondamento em cálculo monetário.
whatsapp.price_per_message_micros JSON {"marketing":0,"utility":0,"authentication":0,"service":0} OWNER Preço por categoria de conversa, em micros de dólar por mensagem, conforme a tabela vigente do provedor (Seção 17.12). Alimenta message_logs.cost_micros e as fórmulas de custo da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero.
whatsapp.no_reply_numbers JSON [] ADMIN Números que nunca recebem resposta automática, usados pela proteção anti-laço da Seção 19.13.
tts.cost_per_1k_chars_micros INT 0 0–100000000 OWNER Custo de síntese de voz por mil caracteres, em micros de BRL (Seção 16.4). Alimenta audio_assets.cost_micros e as fórmulas de custo da Seção 21. Zero significa "ainda não informado" e faz o painel exibir o custo como indisponível, nunca como zero. O sufixo é _micros, nunca _micro_usd: a convenção de unidades da Seção 7.10 vale também para chaves de configuração.
support.email STRING "suporte@palavradiaria.com.br" ADMIN E-mail de suporte exibido ao assinante.
support.whatsapp_reply_text STRING texto padrão EDITOR Resposta automática a mensagens sem intenção reconhecida.
privacy.dpo_email STRING "privacidade@palavradiaria.com.br" OWNER E-mail do encarregado de dados. Publicado na política de privacidade. Endereço único em todo o documento (Seção 1.20).
privacy.policy_version STRING "2026-08-01" OWNER Versão vigente do texto de consentimento. Gravada em cada evento de consentimento.
privacy.deletion_grace_days INT 30 1–90 OWNER Dias entre o pedido de exclusão e a anonimização.
privacy.message_log_retention_months INT 18 6–60 OWNER Retenção de message_logs e inbound_messages. Fonte única do prazo de 22.8; nenhuma seção repete o número em código.
privacy.legal_entity JSON {"razaoSocial":null,"cnpj":null,"endereco":null} OWNER Identificação do controlador nos Termos e na Política.
security.blocked_phone_prefixes JSON [] ADMIN Faixas de números virtuais e de SMS descartável (22.6.4).
security.blocked_email_domains JSON [] ADMIN Domínios de e-mail descartável (22.6.4).
security.mobile_carrier_ranges JSON [] ADMIN Faixas de IP de operadora móvel brasileira, usadas para não punir CGNAT na pontuação de risco (22.6.3).
ops.kill_switch JSON {"enabled": false, "reason": null, "activatedBy": null, "activatedAt": null} OWNER Interruptor geral de envios. Enquanto enabled for verdadeiro, nenhuma mensagem sai. É a chave citada pelo procedimento de emergência da Seção 27.8, e não existe nenhum outro nome para ela.
ops.maintenance_mode BOOL false OWNER Bloqueia rotas de escrita e exibe aviso. Leitura continua funcionando.
ops.alert_email STRING "alertas@palavradiaria.com.br" ADMIN Destino dos alertas operacionais.
ops.cost_alert_threshold_cents INT 50000 0–10000000 ADMIN Custo diário acima do qual um alerta é disparado. R$ 500,00 por padrão. Comparado em reais por divisão explícita por 100 (Seção 23.8, alerta 40).
ops.infra_cost_monthly_cents INT 0 0–100000000 OWNER Custo fixo mensal de infraestrutura, em centavos, usado nas fórmulas de custo da Seção 21.2.
ops.cost_per_subscriber_target_cents INT 0 0–1000000 OWNER Meta de custo por assinante, em centavos (Seção 21.8).
ops.marketing_spend_monthly_cents INT 0 0–100000000 OWNER Gasto mensal de marketing, em centavos. É a entrada do custo de aquisição da Seção 21.2.4; sem ela o indicador não pode ser calculado.
ops.debug_subscriber_ids JSON [] máximo 20 itens ADMIN Assinantes com nível de log elevado por 24 h (23.1.5).
ops.debug_paths JSON [] ADMIN Rotas com nível de log elevado por 24 h (23.1.5).
ops.secret_rotated_at JSON {} OWNER Data da última rotação de cada segredo, por nome canônico. Alimenta o alerta secret_rotation_due (22.4.2). Mantida pelo sistema; edição manual é excepcional.
ops.last_access_review_at STRING null OWNER Conclusão da revisão trimestral de acessos (22.12.3). Mantida pelo sistema.
ops.reencrypt_cursor STRING null OWNER Último id processado pelo job de re-criptografia (22.5.5). Mantida pelo sistema; existe para tornar o job retomável.

São 45 chaves. Quatro decisões registradas sobre esta tabela:

  • O interruptor geral chama-se ops.kill_switch e é JSON, não BOOL. O tipo JSON existe porque desligar os envios sem registrar quem desligou, quando e por quê transforma o controle de emergência em mistério na manhã seguinte.
  • As três últimas chaves de ops são estado do sistema, não configuração de produto. Elas vivem em settings porque precisam sobreviver a reinício e ser legíveis pelo painel de operação, e ficam com editable_by = 'OWNER' justamente para que ninguém as ajuste por engano.
  • Custos ficam em centavos, com sufixo _cents. Guardar custo em reais como decimal reintroduziria float em cálculo monetário, que é proibido em todo o documento.
  • Preço unitário fica em micros, com sufixo _micros, e câmbio fica em milésimos. Preço por mensagem e por caractere é pequeno demais para caber em centavos sem perder precisão na soma de centenas de milhares de linhas; o câmbio precisa de três casas. Nenhuma chave usa sufixo _brl e nenhuma guarda valor monetário como decimal.

26.8.2 Regras de edição #

  • PUT /api/admin/settings/{key} valida contra value_type, min_value, max_value e allowed_values antes de gravar. Falha devolve VALIDATION_ERROR com details.
  • O papel exigido é o da coluna editable_by, não o da rota. Papel insuficiente devolve INSUFFICIENT_ROLE.
  • Toda alteração grava settings.update em admin_audit_log, com before e after, e exige reason de no mínimo 10 caracteres (CHECK em 6.9.3).
  • Setting com teto em variável de ambiente é validado contra o teto no momento da gravação. Tentar send.rate_per_second = 50 com WHATSAPP_SEND_RATE_PER_SECOND=20 devolve VALIDATION_ERROR com {"field":"value","issue":"out_of_range"}.
  • settings usa versionamento otimista (6.1.4). Edição concorrente devolve OPTIMISTIC_LOCK_FAILED.
  • "Restaurar padrão" grava default_value em value e é auditado como qualquer edição.

26.8.3 Cache #

Leitura em memória, com TTL de 60 segundos e invalidação ativa: o UPDATE publica no canal Redis settings:invalidate com a chave alterada, e todos os processos limpam a entrada. A janela de inconsistência é de milissegundos no caminho normal e de 60 segundos se o Redis estiver fora.

Nenhum setting é lido dentro de laço quente. O motor de envio lê todos os settings relevantes uma vez no início do lote e usa os mesmos valores até o fim. Mudar send.rate_per_second no meio de um lote afeta o lote seguinte, não o corrente — e isso é deliberado: mudar a taxa no meio da execução tornaria o comportamento imprevisível.

26.9 Feature flags #

Estrutura da tabela em 6.24, seeds em 6.32.5, avaliação determinística em 6.24.3. As chaves de feature_flags não usam prefixo de grupo — essa é a convenção da tabela e a diferença em relação a settings é deliberada: uma flag é um interruptor temporário com data de morte, não uma configuração organizada por domínio. Toda chave desta tabela existe no seed de 6.32.5.

Chave Padrão Tier alvo Efeito quando ligada Efeito quando desligada Quando desligar
video_fallback_enabled ligada, 100% PAID Assinante pago que não abre a janela há 3 dias recebe o template com cabeçalho de vídeo no 4º dia O assinante continua recebendo só o template de convite; o áudio não chega até ele abrir a janela Se o custo do template de vídeo subir acima do aceitável, ou se a taxa de aprovação do template cair
window_shortcut_enabled ligada, 100% todos Quando a janela de 24 h já está aberta, o sistema pula o template e envia o pacote free-form direto Todo envio começa pelo template, mesmo com janela aberta. Custo maior, mais atrito Se aparecer inconsistência na leitura de service_window_expires_at
tts_google_fallback ligada, 100% todos Após 3 falhas no provedor primário de voz, o secundário assume Falha no primário significa devocional sem áudio no dia Se a qualidade do secundário for reprovada editorialmente
web_player_enabled ligada, 100% PAID O painel exibe o player de áudio com URL assinada O painel mostra só o texto; o áudio chega apenas pelo WhatsApp Se o custo de saída do storage ficar alto demais
pix_checkout_enabled ligada, 100% todos PIX aparece como forma de pagamento Só cartão de crédito Durante instabilidade do PIX no provedor
card_checkout_enabled ligada, 100% todos Cartão aparece como forma de pagamento Só PIX Durante instabilidade de cartão no provedor. Nunca desligue as duas juntas: o painel bloqueia a segunda desativação
email_fallback_login ligada, 100% todos Assinante com e-mail verificado pode entrar por magic link Só OTP no WhatsApp Durante instabilidade do provedor de e-mail
admin_impersonation ligada, 100% todos Suporte pode acessar o painel como o assinante, em modo leitura A ação some do painel e a rota devolve FEATURE_DISABLED Durante auditoria de privacidade, ou se houver suspeita de uso indevido
metrics_dashboard ligada, 100% todos Painel de métricas visível para papéis administrativos O menu some e a rota devolve FEATURE_DISABLED Se a agregação estiver com dados incorretos
snooze_button_enabled ligada, 100% todos O template de convite inclui o botão "Depois" O template sai só com "Ler e ouvir agora" Se o botão reduzir a taxa de abertura da janela. Exige resubmissão do template à plataforma
reengagement_campaign desligada, 0% todos Quem não abre a janela há 14 dias recebe uma mensagem de reengajamento Nada é enviado Padrão desligada: exige template aprovado e avaliação de custo antes de ligar
audio_speed_control desligada, 0% PAID O player web ganha controle de velocidade Player com velocidade fixa Padrão desligada: funcionalidade pronta, aguardando validação com assinantes
audio_human_review desligada, 0% todos O devocional fica em AUDIO_PENDING com aviso no painel até um EDITOR aprovar o áudio AUDIO_PENDING → AUDIO_READY é automático assim que o pipeline conclui Padrão desligada: só ligar quando houver troca de voz ou de provedor em avaliação

26.9.1 Regras de uso #

  1. Toda flag tem data de morte. Uma flag existe para reduzir risco de uma mudança, não para ser configuração permanente. Flag em 100% e estável por 60 dias vira comportamento fixo e a flag é removida por migration.
  2. Nenhuma flag altera regra fiscal ou de conformidade. Não existe flag que desligue o registro de consentimento, a auditoria ou a validação de assinatura de webhook.
  3. Flag desligada devolve FEATURE_DISABLED (403) na rota correspondente, e a interface esconde o elemento. As duas coisas: esconder sem bloquear a rota é falso controle.
  4. Rollout percentual usa hash estável de key + subscriberId (6.24.3). Aumentar o percentual só adiciona assinantes; nunca remove quem já estava dentro.
  5. Alteração de flag é auditada com feature_flag.update em admin_audit_log, com o valor anterior e o novo.
  6. Flags que dependem de template aprovadosnooze_button_enabled e reengagement_campaign — verificam whatsapp_templates.status = 'APPROVED' antes de permitir a ativação. Sem isso, ligar a flag produziria falhas 132xxx em massa no envio seguinte.

26.10 Checklist de configuração inicial #

Executado uma vez por ambiente, na ordem. Cada passo é verificável.

# 1. copie o exemplo e preencha
cp .env.example .env

# 2. gere os segredos de aplicação
echo "OTP_PEPPER=$(openssl rand -hex 32)"           >> .env
echo "CSRF_SECRET=$(openssl rand -hex 32)"          >> .env
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)"    >> .env
echo "PHONE_INDEX_KEY=$(openssl rand -base64 32)"   >> .env
echo "METRICS_TOKEN=$(openssl rand -hex 32)"        >> .env
echo "OPS_CLI_TOKEN=$(openssl rand -hex 32)"        >> .env
echo "MAINTENANCE_BYPASS_TOKEN=$(openssl rand -hex 32)" >> .env
#    TURNSTILE_SECRET_KEY vem do painel do provedor anti-bot; BACKUP_ENCRYPTION_KEY
#    é a chave publica `age` gerada com `age-keygen`, cuja parte privada fica no cofre.
#    ENCRYPTION_KEY e PHONE_INDEX_KEY são geradas separadamente e precisam ser
#    diferentes entre si: o schema de 26.5 recusa o boot se forem iguais.

# 3. gere o par de chaves de sessão
openssl genpkey -algorithm ed25519 -out /tmp/jwt.key
openssl pkey -in /tmp/jwt.key -pubout -out /tmp/jwt.pub
# copie o conteúdo para SESSION_JWT_PRIVATE_KEY e SESSION_JWT_PUBLIC_KEY, com \n literal
shred -u /tmp/jwt.key /tmp/jwt.pub

# 4. valide a configuração ANTES de subir qualquer serviço
pnpm --filter @palavra-diaria/core env:check
#    saída esperada: "configuração válida — 165 variáveis, 29 obrigatórias, 0 problemas"

# 5. suba a infraestrutura
docker compose up -d postgres redis

# 6. aplique as migrations
pnpm --filter @palavra-diaria/db migrate:deploy

# 7. confirme os objetos que não estão no schema declarativo (Seção 6.31.2)
pnpm --filter @palavra-diaria/db verify
#    saída esperada: "todos os índices parciais, CHECKs e partições presentes"

# 8. carregue os dados de referência
pnpm --filter @palavra-diaria/db seed

# 9. verifique as credenciais externas sem enviar nada
pnpm ops check:integrations
#    verifica: banco, redis, storage, plataforma de mensagens, pagamentos, voz, e-mail

# 10. suba a aplicação
docker compose up -d web worker caddy

# 11. confirme a prontidão
curl -fsS https://app.palavradiaria.com.br/api/internal/ready | jq

# 12. faça o primeiro login administrativo, troque a senha e enrole o TOTP
#     a sessão do primeiro acesso é restrita a essas duas ações

# 13. REMOVA BOOTSTRAP_ADMIN_PASSWORD do .env e reinicie
#     em produção isto não é opcional: com um OWNER já enrolado em TOTP,
#     o boot RECUSA iniciar enquanto a variável existir (26.3.14)

O passo 13 é imposto pelo boot, e não confiado ao operador, porque o modo de falha do desenho anterior era silencioso e permanente: removida a variável, o próximo deploy que rodasse o seed falharia; o operador a reporia com a senha original para destravar; e ela ficaria lá para sempre, agora com uma conta OWNER ativa correspondente.

O passo 4 antes do passo 5 é deliberado: descobrir configuração inválida depois de subir banco e filas custa tempo e produz estado parcial. env:check roda o mesmo schema de 26.5 sem abrir conexão nenhuma.

O passo 9 é o que separa "configurado" de "funcionando": ele autentica em cada provedor externo com uma chamada de leitura, sem enviar mensagem, sem criar cobrança e sem gerar áudio. Falha ali aponta exatamente qual credencial está errada, com o nome da variável.

27. Tratamento de Erros, Resiliência e Runbooks Operacionais #

27.1 Taxonomia de erros #

Todo erro do sistema é classificado em exatamente uma das seis classes abaixo, no momento em que é criado ou capturado. A classe determina retry, log, alerta e resposta ao usuário. Uma exceção sem classe é tratada como INTEGRATION e gera alerta, porque erro não classificado é bug de classificação.

// packages/core/src/errors.ts
export type ErrorClass =
  | 'TRANSIENT'    // falha temporária, a mesma operação tende a funcionar depois
  | 'PERMANENT'    // a mesma operação nunca vai funcionar; repetir é desperdício
  | 'CONTRACT'     // a forma da requisição ou da resposta não bate com o esperado
  | 'DATA'         // os dados persistidos estão inválidos ou inconsistentes
  | 'INTEGRATION'  // o serviço externo respondeu de forma inesperada ou não respondeu
  | 'BUSINESS';    // o sistema funcionou e a regra de negócio recusou a operação

export class AppError extends Error {
  constructor(
    readonly code: string,          // SCREAMING_SNAKE_CASE, catalogado na Seção 7
    readonly errorClass: ErrorClass,
    message: string,
    readonly context: Record<string, unknown> = {},
    readonly cause?: unknown,
  ) { super(message); }
}
Classe Definição Retry Nível de log Alerta Exposta ao usuário
TRANSIENT Timeout de rede, 429, 502, 503, 504, deadlock do Postgres, conexão recusada Sim, conforme a Seção 27.2 warn na tentativa, error no esgotamento Só ao esgotar as tentativas, ou se a taxa passar do limiar Mensagem genérica de "tente novamente em instantes"
PERMANENT 400, 404, 422 de serviço externo; número que não existe no WhatsApp (131026); áudio maior que o limite Não error Sim, se afetar entrega ao assinante Mensagem específica quando cabível
CONTRACT Resposta externa que não passa no schema Zod; campo obrigatório ausente; enum desconhecido; corpo que não é JSON Não error com o corpo bruto truncado em 2 KB Sim, sempre. Contrato quebrado é sinal de mudança no serviço externo Mensagem genérica
DATA Estado impossível no banco: tier=PAID sem assinatura; phone_e164 que não normaliza; devocional publicado sem áudio Não error com os identificadores envolvidos Sim, sempre. Indica bug de escrita Mensagem genérica
INTEGRATION Serviço externo indisponível, disjuntor aberto, credencial inválida, mudança de comportamento não classificável Depende: token inválido não; indisponibilidade sim error Sim Mensagem genérica ou degradação silenciosa
BUSINESS Assinante já cadastrado; limite de reenvio atingido; tentativa de acessar recurso de outro tier Nunca info Não. Não é falha do sistema Sim, mensagem específica e acionável

Exemplos concretos de classificação, um por classe:

Situação real Classe code O que acontece
A Meta devolve 503 ao enviar o template das 06:00 TRANSIENT WHATSAPP_UNAVAILABLE Retry com backoff; esgotadas as tentativas da fila, o job fica em failed e uma linha DEAD é gravada em job_runs
A Meta devolve 131026 (não é possível entregar) PERMANENT WHATSAPP_UNDELIVERABLE Ao terceiro dia consecutivo, subscribers.blocked_at e blocked_reason são gravados e o status passa a BLOCKED; assinante sai dos lotes; alerta agregado diário
A Asaas passa a devolver value como string em vez de número CONTRACT ASAAS_RESPONSE_SCHEMA_MISMATCH Nenhum retry; alerta imediato; o evento fica pendente em payment_events para reprocessamento após a correção
Um assinante tem tier=PAID e assinatura nula DATA INCONSISTENT_SUBSCRIBER_STATE Alerta imediato; a reconciliação tenta corrigir; o assinante é tratado como FREE até lá
O token de acesso da Meta expirou INTEGRATION WHATSAPP_TOKEN_INVALID Sem retry (repetir não resolve); alerta crítico; disjuntor abre; runbook R-07
Assinante FREE clica em reenviar pela segunda vez no mesmo dia BUSINESS RESEND_LIMIT_REACHED 409 com mensagem clara; nenhum alerta; contabilizado como métrica de produto

Todo AppError é logado no formato estruturado da Seção 23, sempre com requestId ou jobId, code, errorClass, attempt, e context já redigido pela política da Seção 22. A mensagem devolvida ao usuário segue o envelope de erro da Seção 7. Nenhum rastro de pilha chega ao cliente.

27.2 Política canônica de retry #

27.2.1 Fórmula #

Backoff exponencial com jitter total. Sem jitter, milhares de jobs que falharam juntos voltam juntos e derrubam o serviço que estava se recuperando.

// packages/core/src/retry.ts
export function computeBackoffMs(attempt: number, opts: {
  baseMs: number;       // atraso da primeira repetição
  factor: number;       // multiplicador
  maxMs: number;        // teto
  random: () => number; // injetável para teste determinístico
}): number {
  const uncapped = opts.baseMs * Math.pow(opts.factor, attempt - 1);
  const capped = Math.min(uncapped, opts.maxMs);
  // Jitter total: sorteio uniforme em [0, capped].
  return Math.floor(opts.random() * capped);
}

Quando o serviço externo devolve Retry-After, esse valor vence o cálculo, respeitado o teto. Isso é obrigatório para a Meta (130429) e para a Asaas (429).

27.2.2 Tabela de tentativas por tipo de operação #

O número de tentativas de cada fila é o da tabela da Seção 18.8, que é a dona do catálogo de filas. Esta tabela fixa a curva do atraso e o destino ao esgotar; onde as duas falarem da mesma fila, os valores são os mesmos de propósito.

Operação Tentativas baseMs factor maxMs Janela total aproximada Destino ao esgotar
Envio de mensagem WhatsApp, fila send.dispatch (template ou free-form) 4 2.000 3 300.000 ~1,5 min Estado failed de send.dispatch + linha DEAD em job_runs
Entrega do pacote por interação, fila send.followup 3 2.000 3 300.000 ~30 s Estado failed de send.followup + linha DEAD em job_runs
Envio com erro 130429 (rate limit) Não consome tentativa: o job é adiado com moveToDelayed pelo Retry-After, com teto de 600.000 ms 600.000 até ~10 min por adiamento Reentra na fila; o teto de 6 h da Seção 27.3.1 vale aqui também
Envio com erro 131049/131050 1 nova tentativa no mesmo dia; depois adia para o dia seguinte 3.600.000 1 3.600.000 1 h Não é falha: entra na lista do dia seguinte
Upload de mídia para a Meta, fila media.upload 5 3.000 3 120.000 ~4 min Estado failed de media.upload + linha DEAD em job_runs
Geração de TTS (provedor primário), dentro de tts.generate 3 5.000 3 60.000 ~1,5 min Aciona o provedor de fallback
Geração de TTS (provedor de fallback), dentro de tts.generate 3 5.000 3 60.000 ~1,5 min Estado failed de tts.generate + linha DEAD em job_runs + alerta crítico
Transcodificação com ffmpeg 2 1.000 2 5.000 ~5 s Falha o job tts.generate, que segue a política da fila
Upload para o bucket de mídia 5 1.000 3 60.000 ~2 min Falha o job media.upload, que segue a política da fila
Chamada de leitura à Asaas (GET) 4 1.000 3 30.000 ~50 s Falha do job; a reconciliação corrige depois
Chamada de escrita à Asaas (POST/DELETE) Ver Seção 27.2.4
Processamento de evento de pagamento, fila billing.webhook 8 5.000 2 300.000 ~20 min Estado failed de billing.webhook + linha DEAD em job_runs + alerta crítico
Escrita no Postgres com deadlock (40P01) ou falha de serialização (40001) 3 50 4 2.000 ~2,5 s Falha do job, que segue a política do job
Conexão com o Postgres recusada 10 500 2 15.000 ~1,5 min Processo encerra e o Docker reinicia
Conexão com o Redis recusada Contínuo, com teto 500 2 30.000 contínuo Nunca desiste; o cliente reconecta
Envio de e-mail transacional, fila email.send 3 5.000 3 120.000 ~1 min Estado failed de email.send + linha DEAD em job_runs; e-mail nunca é crítico

Exemplo numérico do envio de WhatsApp com baseMs=2000, factor=3, maxMs=300000 e jitter total: os atrasos máximos por tentativa são 2 s, 6 s, 18 s e 54 s; com jitter, cada um é sorteado no intervalo [0, máximo]. O tempo esperado até a 4ª tentativa é de aproximadamente 40 segundos e o pior caso é de aproximadamente 80 segundos, o que cabe folgadamente na janela de 20 minutos do lote.

27.2.3 Configuração no BullMQ #

// apps/worker/src/jobs/send-dispatch.ts
export const sendDispatchQueue = new Queue('send.dispatch', {
  connection,
  defaultJobOptions: {
    attempts: 4,                                // Seção 18.8 é a dona deste número
    backoff: { type: 'jitteredExponential' },
    removeOnComplete: { age: 86_400, count: 10_000 },
    removeOnFail: { age: 2_592_000 },           // 30 dias: falhas ficam para inspeção manual
  },
});

export const sendDispatchWorker = new Worker(
  'send.dispatch',
  processSendDispatch,
  {
    connection,
    concurrency: 12,
    limiter: { max: await settings.getInt('send.rate_per_second'), duration: 1_000 },
    settings: {
      backoffStrategy: (attemptsMade, _type, err) => {
        const retryAfter = (err as AppError)?.context?.retryAfterMs;
        if (typeof retryAfter === 'number') return Math.min(retryAfter, 600_000);
        return computeBackoffMs(attemptsMade, {
          baseMs: 2_000, factor: 3, maxMs: 300_000, random: Math.random,
        });
      },
    },
    // Job considerado travado após 60 s sem renovar o bloqueio.
    lockDuration: 60_000,
    stalledInterval: 30_000,
    maxStalledCount: 2,
  },
);

Erros PERMANENT, CONTRACT, DATA e BUSINESS abortam o retry imediatamente, mesmo que ainda haja tentativas disponíveis:

async function processSendDispatch(job: Job<SendPayload>) {
  try {
    return await sendMessage(job.data);
  } catch (err) {
    if (err instanceof AppError && err.errorClass !== 'TRANSIENT' && err.errorClass !== 'INTEGRATION') {
      throw new UnrecoverableError(`${err.code}: ${err.message}`);
    }
    throw err;
  }
}

27.2.4 Onde o retry é PROIBIDO #

Estas operações não têm retry automático porque não são idempotentes e repetir pode cobrar duas vezes, criar duas assinaturas ou enviar duas mensagens. A regra é absoluta.

Operação Por que não é idempotente O que se faz no lugar
POST /v3/customers na Asaas Cria um cliente novo a cada chamada; a Asaas não deduplica por CPF Antes de criar, buscar por GET /v3/customers?cpfCnpj=<cpf>. Se existir, reusar. O par busca-e-cria é envolvido em um lock por CPF com TTL de 30 s. Em timeout, a operação falha para o usuário com PAYMENT_PROVIDER_TIMEOUT e a próxima tentativa dele encontra o cliente já criado
POST /v3/subscriptions na Asaas Criaria duas assinaturas para o mesmo assinante externalReference recebe o ULID da nossa subscriptions.id, gerado antes da chamada e persistido em PENDING_PAYMENT. Em timeout, um job de conciliação busca GET /v3/subscriptions?externalReference=<ulid> depois de 60 s e adota o que encontrar. Nunca se recria às cegas
POST /v3/payments (cobrança avulsa de PIX) Geraria duas cobranças e o assinante poderia pagar as duas Mesmo mecanismo de externalReference + conciliação
DELETE /v3/subscriptions/{id} Idempotente na prática (a segunda chamada devolve 404), mas o 404 seria interpretado como falha Retry permitido, com 404 tratado como sucesso
Cobrança no cartão tokenizado Cobraria duas vezes Nunca é feita por nós diretamente. A recorrência é da Asaas
POST /{PHONE_NUMBER_ID}/messages na Meta após resposta bem-sucedida Enviaria a mesma mensagem duas vezes ao assinante A idempotência é garantida por delivery_attempts com índice único em send:{subscriberId}:{devotionalDate} e pela gravação do wamid em message_logs. O retry só ocorre quando não houve resposta ou quando a resposta foi um erro classificado como TRANSIENT. Se a resposta chegou com o identificador da mensagem, o job é marcado como concluído mesmo que a gravação subsequente falhe — nesse caso, um job de reparo lê o log e completa o registro
Confirmação de opt-out ao assinante Enviaria duas mensagens de "você foi removido" Guardada por opt_out_confirmation_sent_at, verificada dentro da mesma transação que grava opt_out_at
Envio de OTP Enviaria dois códigos e confundiria o assinante Limite de 3 por hora, verificado em transação; falha de envio devolve erro ao usuário, que decide pedir de novo
POST /{PHONE_NUMBER_ID}/media (upload) Criaria um identificador de mídia novo, desperdiçando cota O identificador é gravado em media_uploads com o hash do arquivo. Antes de subir, consulta-se o hash. Retry só acontece se o upload não completou
E-mail de recibo de pagamento Enviaria dois recibos Guardado pelo jobId determinístico receipt:{paymentId} na fila email.send, que o BullMQ deduplica; o provedor de e-mail também recebe uma chave de idempotência quando suportada. settings é tabela de configuração e nunca é usada como armazenamento de chave de idempotência
Anonimização por pedido de eliminação (LGPD) Segunda execução falharia por não encontrar os dados A operação é idempotente por construção (usa UPDATE ... WHERE deleted_at IS NULL), então o retry é permitido; está listada aqui apenas para registrar que foi analisada

Regra de escrita para quem implementa: qualquer chamada de rede que crie ou cobre algo precisa de uma chave de idempotência decidida por nós, gerada antes da chamada e persistida. Se não for possível gerar essa chave, a operação não pode ter retry automático e precisa de conciliação posterior.

27.3 Disjuntores #

Um disjuntor por integração, implementado em packages/integrations/src/circuit.ts, com estado em Redis sob circuit:{nome} para ser compartilhado entre web e worker.

Estados: CLOSED (normal), OPEN (recusa imediatamente, sem tentar) e HALF_OPEN (deixa passar um número limitado de sondagens).

Integração Limiar de abertura Janela de contagem Tempo em OPEN Sondagens em HALF_OPEN Para fechar
whatsapp 20 falhas TRANSIENT/INTEGRATION ou taxa de falha > 50% com no mínimo 30 chamadas 60 s deslizantes 60 s 3 3 sucessos consecutivos
asaas 10 falhas ou taxa > 50% com no mínimo 20 chamadas 60 s 120 s 2 2 sucessos consecutivos
tts-primary 5 falhas consecutivas 300 s 900 s 1 1 sucesso
tts-fallback 5 falhas consecutivas 300 s 900 s 1 1 sucesso
storage 10 falhas ou taxa > 40% com no mínimo 20 chamadas 60 s 60 s 3 2 sucessos consecutivos
email 10 falhas 300 s 300 s 1 1 sucesso

Qualquer falha durante HALF_OPEN reabre o disjuntor imediatamente e dobra o tempo em OPEN, até o teto de 15 minutos. Isso evita o padrão de "abre, fecha, abre" que castiga um serviço que ainda não se recuperou.

Erros de classe BUSINESS e PERMANENT não contam para o disjuntor: um número que não existe no WhatsApp não é sinal de que a Meta está fora do ar. Apenas TRANSIENT, INTEGRATION e timeouts contam.

27.3.1 O que acontece com a fila enquanto o disjuntor está aberto #

Esta é a decisão mais importante da seção, porque o comportamento ingênuo (falhar os jobs) consumiria todas as tentativas em segundos e mandaria o lote inteiro para o estado failed.

Regra: quando o disjuntor está OPEN, o job não falha — ele é adiado.

async function guardedCall<T>(name: CircuitName, fn: () => Promise<T>, job?: Job): Promise<T> {
  const state = await circuit.state(name);
  if (state.status === 'OPEN') {
    const waitMs = state.reopensInMs + jitter(0, 5_000);
    if (job) {
      // Adia sem consumir tentativa. O job volta para a fila 'delayed'.
      await job.moveToDelayed(Date.now() + waitMs, job.token);
      throw new DelayedError();
    }
    throw new AppError('CIRCUIT_OPEN', 'INTEGRATION', `Integração ${name} indisponível.`, { name });
  }
  return fn();
}

job.moveToDelayed combinado com DelayedError é o mecanismo do BullMQ que devolve o job para o estado delayed sem incrementar o contador de tentativas. Consequências, por fila:

Fila Comportamento com disjuntor aberto
send.dispatch e send.followup Jobs vão para delayed com o tempo restante de abertura + jitter de até 5 s. O lote pausa e retoma sozinho. Se a abertura durar mais de 30 minutos, o lote é marcado como DELAYED_BY_CIRCUIT e um alerta de severidade alta é emitido
billing.webhook Jobs vão para delayed. Os eventos já estão persistidos em payment_events, então nada se perde. O impacto é atraso na liberação de acesso
tts.generate Com tts-primary aberto, o job não é adiado: ele vai direto ao provedor de fallback. Só é adiado se os dois disjuntores estiverem abertos
media.upload Adiado. O envio que depende da mídia é adiado junto, por dependência de job
email.send Adiado por até 30 minutos; depois é descartado com log. E-mail não vale a pena segurar

Um job adiado por disjuntor tem um teto absoluto: se o adiamento acumulado passar de 6 horas, o job falha com CIRCUIT_OPEN_TIMEOUT, fica em failed e grava a linha DEAD em job_runs. Um devocional entregue 6 horas atrasado já perdeu o propósito.

27.3.2 Volta ao normal e visibilidade #

A transição OPENHALF_OPEN é automática, por expiração da chave Redis. Não existe temporizador em memória: se o worker reiniciar, o estado sobrevive.

# Estado de todos os disjuntores.
docker compose exec worker ops circuit:status
{
  "whatsapp": { "status": "CLOSED", "failures60s": 0, "successRate": 1.0 },
  "asaas":    { "status": "OPEN", "openedAt": "2026-08-25T09:12:03.000Z",
                "reopensInMs": 43000, "consecutiveOpens": 2, "lastError": "ASAAS_TIMEOUT" },
  "tts-primary":  { "status": "HALF_OPEN", "probesRemaining": 1 },
  "tts-fallback": { "status": "CLOSED" },
  "storage":  { "status": "CLOSED" },
  "email":    { "status": "CLOSED" }
}

Reset manual, usado apenas quando se sabe que a causa foi corrigida (por exemplo, após trocar um token):

docker compose exec worker ops circuit:reset --name=asaas

O reset é registrado em admin_audit_log com o operador. Toda transição de estado emite métrica e log estruturado, e a abertura de qualquer disjuntor gera alerta conforme a Seção 23.

27.4 Degradação graciosa #

Uma subseção por dependência, com a decisão explícita do comportamento. A pergunta que cada uma responde é: o que o produto ainda consegue fazer?

27.4.1 A Meta (WhatsApp) está fora #

É a dependência mais crítica: sem ela não há entrega do produto.

Capacidade Estado durante a indisponibilidade
Envio do devocional diário Parado. Jobs adiados pelo disjuntor, retomam sozinhos
Recebimento de mensagens Parado. A Meta reentrega webhooks por até 7 dias, então nada se perde de forma permanente
Login por OTP no WhatsApp Parado. Fallback automático: se o assinante tem e-mail verificado, o sistema envia um link mágico por e-mail sem que ele precise pedir, e a tela informa a troca de canal
Cadastro novo Permitido até a etapa de confirmação. O assinante é criado com opt_in_confirmed_at = NULL e recebe a confirmação quando o canal voltar. A tela diz: "Recebemos seu cadastro. Vamos mandar a confirmação no WhatsApp assim que possível."
Checkout e cobrança Funcionam normalmente. Pagamento não depende da Meta
Painel web (leitura do devocional e do áudio) Funciona normalmente. É o plano B de entrega
Painel administrativo Funciona, exceto o botão de envio de teste, que fica desabilitado com o motivo visível

Decisão: o produto não troca de canal automaticamente. Não existe entrega por e-mail nem por SMS, conforme o escopo declarado na Seção 2. Se a indisponibilidade passar de 3 horas dentro da janela de envio, o sistema envia um e-mail (apenas para quem tem e-mail verificado) com o link do devocional no painel web. Esse e-mail é de aviso operacional, não é o canal do produto, e o texto deixa isso claro. O envio é feito uma única vez por incidente, garantido pelo jobId determinístico outage-notice:{data} na fila email.send — o BullMQ descarta o segundo job com o mesmo jobId.

Se a indisponibilidade cruzar a meia-noite, o lote do dia é abandonado. Não se acumula devocional. Um assinante que perdeu o dia 26 recebe o dia 27 normalmente, e o dia 26 fica disponível no acervo do painel. Enviar dois devocionais no dia seguinte seria pior para a experiência e para a reputação do número.

27.4.2 A Asaas está fora #

Capacidade Estado durante a indisponibilidade
Entrega do devocional Funciona normalmente. Nenhuma dependência da Asaas no caminho de envio
Entitlements de quem já é pagante Funcionam normalmente. São lidos do nosso banco
Checkout novo Indisponível. A página exibe: "Não é possível concluir a assinatura agora. Tente de novo em alguns minutos." Nenhuma assinatura é criada em estado ambíguo
Recebimento de webhooks Não chegam. Ficam na fila de reentrega da Asaas
Liberação de acesso após pagamento Atrasada. Corrigida pela reconciliação das 04:00 ou por reconciliação manual
Revogação por inadimplência Atrasada. Um assinante inadimplente pode receber alguns devocionais a mais. Decisão: aceitável. O custo de um dia de entrega indevida é da ordem de centavos; recusar acesso por falta de informação seria pior
Cancelamento pelo assinante Aceito no nosso lado (status = CANCELED, tier mantido até o fim do período) e enfileirado para propagar à Asaas quando voltar. O assinante recebe confirmação normal. Se a propagação falhar por mais de 24 h, alerta crítico — o risco de cobrar quem cancelou é inaceitável

Decisão: o cancelamento é sempre aceito localmente primeiro. É a única operação de cobrança em que assumimos o compromisso antes de a Asaas confirmar, porque o dano de recusar um cancelamento é maior do que o de um cancelamento propagado com atraso.

27.4.3 O provedor de TTS está fora #

Situação Comportamento
Primário fora, fallback disponível Troca automática após 3 falhas. audio_assets.provider registra qual foi usado. Nenhum impacto visível ao assinante. Alerta de severidade baixa
Os dois fora, com antecedência (o áudio é gerado quando o devocional atinge READY, normalmente com dias de folga) O devocional permanece em AUDIO_PENDING e o audio_assets da tentativa recebe status = 'FAILED'; não existe estado AUDIO_FAILED (Seção 16.8). Um job tenta a cada 30 minutos. Alerta de severidade média. Se às 05:00 do dia do envio o áudio ainda não existir, escala para o runbook R-11 — 05:00 é o primeiro dos três marcos de prontidão da Seção 18.4
Os dois fora, no dia do envio, sem áudio O texto é enviado normalmente, para todos os tiers. O assinante PAID recebe o texto completo e, no lugar do áudio, uma mensagem curta: "O áudio de hoje está sendo preparado e chega em breve." Um job de recuperação envia o áudio assim que ele existir, desde que ainda esteja dentro da janela de atendimento de 24 h. Se a janela fechar antes, o áudio fica disponível no painel web e não é enviado

Decisão: o áudio nunca bloqueia o texto. Devocional sem áudio é um produto degradado; devocional não entregue é um produto quebrado. delivery_attempts.audio_status registra MISSING e a métrica de entrega de áudio é acompanhada separadamente da de texto.

27.4.4 O storage de mídia está fora #

Capacidade Estado
Envio de texto Funciona normalmente
Envio de áudio pela Meta Funciona, desde que o identificador de mídia na Meta ainda seja válido. O áudio já está na Meta; o nosso bucket não participa do envio. Esta é uma propriedade importante do desenho: fazer o upload uma vez por devocional desacopla o envio do nosso storage
Geração de áudio novo Bloqueada. O arquivo não pode ser persistido. Job adiado pelo disjuntor
Player de áudio no painel web Indisponível. O painel mostra: "O áudio está temporariamente indisponível. O texto está aqui embaixo." O texto continua visível
Upload de imagem pelo painel administrativo Bloqueado, com mensagem clara
Exportação LGPD Bloqueada. O pedido fica pendente e é processado quando o storage voltar; o prazo legal é de 15 dias, então algumas horas não comprometem

Decisão: o painel web nunca falha por causa do storage. Toda página que exibe áudio trata a URL assinada como opcional e degrada para "somente texto" sem erro visível.

27.4.5 O Redis está fora #

O caso mais delicado, porque as filas param.

Capacidade Estado
Landing page e páginas públicas Funcionam. Não dependem do Redis
Login Funciona. Sessões estão no Postgres; o Redis é só cache. O limite de taxa de OTP passa a ser verificado direto no Postgres, com uma consulta de contagem em otp_codes — mais lenta, mas correta
Painel do assinante Funciona, sem o cache do devocional do dia. Latência maior
Checkout Funciona. O lock de criação de cliente na Asaas degrada para um lock consultivo do Postgres sobre o hash do CPF
Recebimento de webhooks Funciona. O handler persiste o evento em payment_events ou inbound_messages e responde 200. O enfileiramento falha, e a falha é registrada com queued_at IS NULL
Processamento assíncrono Parado. Nada é processado
Envio do devocional Parado

Decisão: o web não derruba o processo se o Redis cair. A rota pública GET /api/internal/health continua devolvendo 200 com o corpo mínimo {"status":"ok"}, e não 503; o estado degradado do Redis aparece apenas em GET /api/internal/ready, que não é publicado pelo proxy e exige o METRICS_TOKEN do monitor externo (Seção 23.6). Devolver 503 faria o proxy tirar a réplica do balanceamento e derrubaria o site inteiro por causa de um cache — e, pior, faria a Asaas e a Meta receberem erro nos webhooks, o que levaria à pausa da fila de webhooks da Asaas. Manter o web de pé é mais importante do que sinalizar o problema pelo código HTTP; o problema é sinalizado pelo alerta.

O worker, ao contrário, não tem função útil sem Redis. Ele reporta 503 na sonda interna do contêiner (/api/internal/health na porta local, que não é publicada pelo proxy), para de aceitar jobs e fica em reconexão. O cliente Redis é configurado com maxRetriesPerRequest: null e enableOfflineQueue: false no worker, para que as operações falhem rápido em vez de acumular na memória.

Quando o Redis volta, o job queue.recover (registrado no start do worker) varre payment_events e inbound_messages com queued_at IS NULL e enfileira o que ficou para trás. É o que fecha a lacuna do período sem Redis. O procedimento completo é o runbook R-18.

27.4.6 O PostgreSQL está fora #

Registrado por completude: sem Postgres não há produto.

Capacidade Estado
Tudo, exceto páginas estáticas Parado
Landing page Serve a versão estática gerada no build; o formulário de cadastro fica desabilitado com aviso
Webhooks Respondem 503. Asaas e Meta reentregam. A Asaas pausa a fila após falhas consecutivas — por isso a indisponibilidade do Postgres tem prioridade máxima de resposta, e o runbook R-14 existe

O web e o worker fazem no máximo 10 tentativas de reconexão e então encerram, deixando o Docker reiniciá-los. Isso evita processos vivos e inúteis segurando memória.

27.5 Trabalho morto: estado failed e job_runs #

27.5.1 Onde fica #

Não existe fila morta separada no BullMQ. Um job que esgota as tentativas fica no estado failed da própria fila e grava uma linha em job_runs com status = 'DEAD', job_name, queue, bull_job_id, attempt, error_code, error, payload redigido e finished_at. job_runs é a dead-letter do sistema (decisão da Seção 18.8). Nenhum nome com sufixo .dlq existe neste produto, nem em comando, nem em alerta, nem em código.

Manter o job no estado failed preserva o payload, o histórico de tentativas e a mensagem de erro final, e permite reprocessar sem reconstruir nada; a linha em job_runs garante que a evidência sobreviva a uma perda de Redis e permite consulta por SQL, que é muito mais prático em incidente do que navegar por chaves.

Retenção do estado failed, por fila (removeOnFail), com os nomes canônicos da Seção 18.8:

Fila Retenção do estado failed
send.dispatch 30 dias
send.followup 30 dias
whatsapp.inbound 30 dias
billing.webhook 30 dias
tts.generate 14 dias
media.upload 14 dias
email.send 7 dias
Demais filas (send.plan, billing.reconcile, billing.lifecycle, metrics.rollup, maintenance.cleanup, messaging.pause) 7 dias

A retenção da linha em job_runs é independente e maior: ela segue a política de retenção de dados operacionais da Seção 22.8. É por isso que a consulta por SQL abaixo continua respondendo depois de o Redis já ter descartado o job.

27.5.2 Como é inspecionada #

# Resumo de todas as filas.
docker compose exec worker ops queue:stats
{
  "send.dispatch":   { "waiting": 0, "active": 0, "delayed": 12, "completed": 2984, "failed": 7 },
  "billing.webhook": { "waiting": 0, "active": 0, "delayed": 0, "completed": 141, "failed": 0 },
  "tts.generate":    { "waiting": 0, "active": 1, "delayed": 0, "completed": 30, "failed": 0 }
}
# Detalhe dos jobs mortos, agrupados por causa.
docker compose exec worker ops queue:dead:list --queue=send.dispatch --limit=50 --group-by=code
{
  "deadCount": 7,
  "groups": [
    { "code": "WHATSAPP_UNDELIVERABLE", "count": 5, "sampleJobIds": ["s_01K3Q...", "s_01K3R..."] },
    { "code": "CIRCUIT_OPEN_TIMEOUT", "count": 2, "sampleJobIds": ["s_01K3S..."] }
  ],
  "oldest": "2026-08-25T09:04:11.000Z"
}

Consulta equivalente por SQL, útil quando o Redis está indisponível — e é ela que responde depois que o estado failed já expirou:

SELECT queue,
       error_code,
       count(*) AS total,
       min(finished_at) AS mais_antigo,
       max(finished_at) AS mais_recente
FROM job_runs
WHERE status = 'DEAD'
  AND finished_at > now() - interval '7 days'
GROUP BY queue, error_code
ORDER BY total DESC;

27.5.3 Como é reprocessado #

# Um job específico.
docker compose exec worker ops queue:dead:replay --queue=send.dispatch --id=s_01K3Q9X

# Todos os jobs de uma causa, depois de a causa ter sido corrigida.
docker compose exec worker ops queue:dead:replay --queue=send.dispatch --code=CIRCUIT_OPEN_TIMEOUT

# Simulação: mostra o que seria reprocessado, sem reprocessar.
docker compose exec worker ops queue:dead:replay --queue=billing.webhook --all --dry-run

Regras de reprocessamento, aplicadas pelo próprio comando:

  1. O contador de tentativas é zerado, mas o histórico anterior é preservado nas linhas de job_runs. Um job reprocessado três vezes é visível como tal.
  2. A idempotência continua valendo. Reprocessar um envio cuja chave send:{subscriberId}:{devotionalDate} já está marcada como enviada termina como ALREADY_SENT sem chamar a Meta. Reprocessar em massa é seguro.
  3. A revalidação do disparo vale no reprocessamento. Um item represado por horas é reavaliado contra o estado atual do assinante (Seção 18.5.1): quem saiu, foi bloqueado, foi excluído ou perdeu o acesso pago nesse intervalo não recebe, ou recebe rebaixado. Reprocessar nunca ressuscita uma permissão que já foi revogada.
  4. Envios com mais de 24 horas não são reprocessados por padrão. O comando recusa e exige --force, porque entregar o devocional de anteontem é pior do que não entregar. Os jobs são descartados com registro.
  5. Jobs PERMANENT não são reprocessáveis sem --force. Reenviar para um número que não existe apenas consome cota e degrada a qualidade do número.
  6. Todo reprocessamento é registrado em admin_audit_log com o operador, o filtro usado e a contagem.
# Descarte controlado, com registro.
docker compose exec worker ops queue:dead:purge --queue=send.dispatch --older-than=30 --reason="expurgo mensal"

27.5.4 Alertas de trabalho morto #

Todas as condições são medidas sobre job_runs, contando linhas com status = 'DEAD' na fila indicada. Não há alerta sobre "fila morta" porque não há fila morta.

Condição Severidade Ação esperada
Qualquer linha DEAD com queue = 'billing.webhook' Crítica, imediata Dinheiro envolvido. Runbook R-10
Mais de 10 linhas DEAD com queue = 'send.dispatch' em 1 hora Alta Runbook R-03
Mais de 50 linhas DEAD com queue = 'send.dispatch' em 1 hora Crítica Runbook R-03 e avaliar o interruptor geral
Qualquer linha DEAD com queue = 'tts.generate' Média Runbook R-11
Linha DEAD com mais de 24 h sem tratamento, em qualquer fila Média Triagem obrigatória
Contagem de linhas DEAD crescendo por 3 janelas de 15 minutos consecutivas Alta Indica causa sistêmica, não caso isolado

O alerta traz sempre a agregação por error_code, não a lista de jobs. O que a pessoa de plantão precisa saber às 06:10 é "50 falhas, todas 131049", não cinquenta identificadores.

27.6 Runbooks operacionais #

Formato fixo: sintoma, impacto, diagnóstico, correção, verificação, prevenção. Todos os comandos partem do diretório de deploy no servidor (cd /opt/palavra-diaria).

R-01 — O lote diário não disparou #

Sintoma. Às 06:05 nenhum envio foi registrado. O painel de métricas mostra zero mensagens no dia. Nenhum alerta de falha, apenas silêncio. O alerta send.batch_not_started dispara (06:05 com send_batches.started_at nulo, Seção 18.12).

Impacto. Todos os assinantes do dia ficam sem devocional. Em domingo, atinge a base inteira. É o incidente de maior impacto do produto.

Diagnóstico.

cd /opt/palavra-diaria

# 1. O worker está de pé e saudável?
docker compose ps worker
docker compose exec -T worker node /app/docker/healthcheck.js http://127.0.0.1:3001/api/internal/health; echo "saida=$?"

# 2. O job repetível está registrado com o fuso correto?
docker compose exec -T worker ops queue:repeatable --queue=send.plan
# Esperado: { "name": "plan-daily-batch", "pattern": "40 5 * * *", "tz": "America/Sao_Paulo",
#             "next": "2026-08-26T08:40:00.000Z" }
# O fuso faz parte da asserção: sem tz, o padrão dispara 02:40 em Brasília (Seção 18.8).

# 3. O relógio do host está correto?
timedatectl status
docker compose exec -T worker date -u

# 4. Houve tentativa de planejamento?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT id, job_name, queue, status, started_at, finished_at, error_code
     FROM job_runs
    WHERE queue = 'send.plan'
    ORDER BY started_at DESC LIMIT 5;"

# 5. O interruptor geral está ligado?
docker compose exec -T worker ops kill-switch:status

# 6. Existe conteúdo publicado para hoje?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT id, title, status, scheduled_for FROM devotionals
    WHERE scheduled_for = current_date AND deleted_at IS NULL;"

# 7. O Redis responde?
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" ping

Causas em ordem de frequência: interruptor geral esquecido ligado; worker parado ou em reinício contínuo; Redis indisponível quando o agendador deveria disparar; devocional do dia não publicado (então o lote roda e não planeja nada); job repetível perdido após uma limpeza manual do Redis.

Correção.

# Se o interruptor estava ligado:
docker compose exec -T worker ops kill-switch:off

# Se o worker estava parado:
docker compose up -d worker
docker compose logs --tail=200 worker

# Se o job repetível sumiu (recriado de forma idempotente no start):
docker compose restart worker
docker compose exec -T worker ops queue:repeatable --queue=send.plan

# Se não havia conteúdo: ver R-12 antes de continuar.

# Disparo manual, sempre com simulação antes:
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --dry-run
# Conferir as contagens por canal e por tier. Se estiverem corretas:
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F)

Regra de horário. Se já passou das 09:00 local, disparar mesmo assim: o devocional do dia entregue às 09:30 ainda é útil. Se já passou das 12:00, não disparar. Registrar o dia como perdido, publicar o devocional no painel web e seguir o procedimento de comunicação da Seção 27.7. Devocional de manhã entregue à noite gera opt-out.

Verificação.

docker compose exec -T worker ops queue:stats
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT status, count(*) FROM delivery_attempts
    WHERE devotional_date = current_date GROUP BY status ORDER BY 2 DESC;"

Esperado: SENT crescendo, PENDING diminuindo, FAILED próximo de zero. Confirmar em um aparelho real que a mensagem chegou.

Prevenção. O alerta send.batch_not_started das 06:05 já existe e foi o que disparou; ele é a prevenção contra a descoberta tardia. Somam-se: alerta separado "interruptor geral ligado há mais de 30 minutos"; verificação ops health --strict no deploy, que confere o registro do job repetível com o fuso; e o alerta de conteúdo ausente às 05:00 do runbook R-12.

R-02 — O lote parou no meio #

Sintoma. O painel mostra 1.240 de 3.000 enviados e o número não sobe há 10 minutos. Nenhuma falha em massa registrada.

Impacto. Parte da base não recebe. Proporcional ao ponto de parada.

Diagnóstico.

# 1. Estado das filas: há jobs presos em 'active' ou parados em 'delayed'?
docker compose exec -T worker ops queue:stats --queue=send.dispatch

# 2. Algum disjuntor aberto?
docker compose exec -T worker ops circuit:status

# 3. O worker está vivo e consumindo CPU?
docker compose ps worker
docker stats --no-stream pd-worker

# 4. Reinícios recentes? (falta de memória aparece como exit code 137)
docker inspect pd-worker --format '{{.RestartCount}} {{.State.ExitCode}} {{.State.OOMKilled}}'

# 5. Erros nos últimos minutos, agrupados por código.
docker compose logs --since=15m worker \
  | grep '"level":"error"' \
  | node -e 'let m={};require("readline").createInterface({input:process.stdin})
      .on("line",l=>{try{const o=JSON.parse(l);m[o.code||"?"]=(m[o.code||"?"]||0)+1}catch{}})
      .on("close",()=>console.log(JSON.stringify(m,null,2)))'

# 6. Estado do lote.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT id, status, planned_count, sent_count, failed_count, started_at, updated_at
     FROM send_batches WHERE devotional_date = current_date;"

Causas: disjuntor aberto adiando tudo; worker reiniciado por falta de memória; jobs travados por processamento longo; Postgres sem conexões; interruptor acionado no meio.

Correção.

# Disjuntor aberto por causa já resolvida:
docker compose exec -T worker ops circuit:reset --name=whatsapp

# Jobs travados:
docker compose exec -T worker ops queue:unstall --queue=send.dispatch

# Worker morto por falta de memória: subir o limite e reiniciar.
# Editar .env.production: NODE_OPTIONS=--max-old-space-size=1280
docker compose up -d worker

# Retomar o lote. É idempotente: reenfileira só o que não está em estado terminal.
docker compose exec -T worker ops send:resume --batch=<id-do-lote>

Verificação. ops queue:stats com waiting e delayed caindo até zero; sent_count + failed_count = planned_count em send_batches; ops queue:dead:list sem crescimento; confirmação em aparelho real.

Prevenção. Alerta "lote sem progresso por 5 minutos com waiting > 0". Limite de memória do worker com folga de 50% sobre o pico medido. maxStalledCount: 2 para que um job travado seja detectado e reprocessado sozinho. Métrica de taxa de envio por minuto exposta no painel, com faixa esperada visível.

R-03 — Taxa de falha de envio alta, ou lote com falha em massa #

Sintoma. Um destes alertas (Seção 18.12): send.failure_rate_high (acima de 5%), send.batch_failure_rate (acima de 10% durante o disparo, a partir de 300 tentativas), send.batch_error_dominant (um único error_code acima de 60% das falhas) ou send.batch_halted_by_guard (o motor pausou o lote sozinho ao passar de 20% nas primeiras 200 tentativas). Muitas linhas DEAD novas em job_runs.

Impacto. De alguns assinantes sem devocional a risco de rebaixamento da qualidade do número, que é dano de longo prazo. Alvo de decisão: 5 minutos.

Diagnóstico.

# 0. Trinta segundos: a foto do lote, com os cinco codigos mais frequentes.
docker compose exec -T worker ops send:status --date=$(TZ=America/Sao_Paulo date +%F)
# Devolve planejados, enviados, falhos, adiados, taxa e os 5 error_code dominantes.

# 1. Distribuição das falhas por código. É a informação que decide tudo.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT error_code, count(*) AS total,
          round(100.0*count(*)/sum(count(*)) OVER (), 1) AS pct
     FROM delivery_attempts
    WHERE devotional_date = current_date AND status = 'FAILED'
    GROUP BY error_code ORDER BY total DESC;"

# 2. É concentrado em uma rota ou tier?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT route, tier_at_send, status, count(*) FROM delivery_attempts
    WHERE devotional_date = current_date GROUP BY 1,2,3 ORDER BY 1,2,3;"

# 3. Qualidade do número e limite de mensagens.
docker compose exec -T worker ops whatsapp:status

Tabela de decisão por código dominante:

Código dominante Significado Ação
131047 Fora da janela de 24 h O plano classificou errado. O motor reclassifica para template automaticamente. Se persistir, investigar service_window_expires_at desatualizado por webhooks de entrada não processados
131026 Não é possível entregar Números inválidos ou sem WhatsApp. Ao terceiro dia consecutivo, blocked_at e blocked_reason são gravados. Se o volume for alto e súbito, suspeitar de importação de base ruim
131049 / 131050 Limitação por saúde do ecossistema Não é falha de sistema. Adiar para o dia seguinte. Se recorrente, revisar categoria e conteúdo do template
130429, 80007, 613, 4 Estrangulamento Reduzir send.rate_per_second e retomar. Não parar o lote
190, 133010, 33, 131031 Erro de configuração ou de conta Parar o lote. Não retentar: cada tentativa queima cota e reputação sem chance de sucesso. Ir para R-07 (190) ou R-05 (133010, 131031)
131047 / 131049 em conjunto Janela e categoria O lote está correto; o problema é de rota. Investigar service_window_expires_at desatualizado por webhooks de entrada não processados (Seção 17.8)
132000132016 Problema de template Ir para R-06
133xxx (demais) Problema de conta ou número Ir para R-04 ou R-05
Misto, sem dominante Instabilidade da Meta Deixar o disjuntor trabalhar; monitorar

Correção.

# Estrangulamento: reduzir a taxa pela metade e retomar.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=10
docker compose restart worker                      # o limitador lê o valor na subida
docker compose exec -T worker ops send:resume --batch=<id>

# Erro sistêmico: PARAR o lote. Interrompe o consumo da fila e PRESERVA os
# itens pendentes; não os cancela.
docker compose exec -T worker ops send:halt --date=$(TZ=America/Sao_Paulo date +%F) --reason="190"

# Corrigida a causa, retomar apenas PENDING e RETRYING.
docker compose exec -T worker ops send:resume --date=$(TZ=America/Sao_Paulo date +%F)

# Falhas transitórias já resolvidas: reprocessar só elas.
docker compose exec -T worker ops queue:dead:replay --queue=send.dispatch --code=WHATSAPP_UNAVAILABLE

# Falhas permanentes: bloquear o destino, não reenviar.
docker compose exec -T worker ops subscriber:block --code=131026 --batch=<id>

Se o lote foi pausado sozinho por send.batch_halted_by_guard, ele já está parado: o passo seguinte é diagnosticar e decidir, não religar às cegas. A retomada é sempre humana.

Se a taxa de falha passar de 25% e o problema não estiver contido no lote — por exemplo, envio para quem não deu opt-in —, acionar o interruptor geral (Seção 27.8) antes de continuar diagnosticando. Um lote falhando em massa castiga a qualidade do número, e qualidade perdida leva semanas para recuperar.

Regra de parada. Se o devocional não puder sair até 10:00 local, ele não sai naquele dia: enviar devocional matinal à tarde tem custo de reputação maior do que não enviar. Registrar a decisão e seguir a regra de comunicação da Seção 27.7.

Verificação. Taxa de falha do lote abaixo de 2%; quality_rating ainda em GREEN; contagem de linhas DEAD em job_runs sem crescimento por 15 minutos.

Prevenção. Alertas escalonados de 5% (alta), 10% (crítica, durante o disparo) e código dominante acima de 60%, mais a contenção automática em 20% (Seção 18.10) — é ela que impede que 8.000 itens queimem 32.000 chamadas enquanto ninguém está acordado. Verificação de service_window_expires_at na simulação do plano. Higiene de base: nenhum número entra sem passar por normalizePhoneBR e por confirmação de opt-in no WhatsApp, o que praticamente elimina 131026. Todo lote com mais de 5% de falha gera relatório pós-incidente (Seção 27.7.4), com a decisão explícita sobre reenvio.

R-04 — Número do WhatsApp com qualidade vermelha #

Sintoma. Webhook phone_number_quality_update com RED, ou o painel da Meta exibindo qualidade baixa. Alerta imediato.

Impacto. Limite de mensagens reduzido, entrega estrangulada e, se não corrigido, o número pode ser restringido ou banido. É o caminho para o R-05.

Diagnóstico.

docker compose exec -T worker ops whatsapp:status
{
  "phoneNumberId": "1099...",
  "displayPhoneNumber": "+55 11 ...",
  "qualityRating": "RED",
  "messagingLimit": "TIER_1K",
  "throughputLevel": "STANDARD",
  "nameStatus": "APPROVED"
}
# Bloqueios e opt-outs recentes: o sinal mais direto de insatisfação.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT date_trunc('day', opt_out_at) AS dia, count(*) FROM subscribers
    WHERE opt_out_at > now() - interval '14 days'
    GROUP BY 1 ORDER BY 1;"

# Taxa de leitura por dia: queda indica conteúdo indesejado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT date_trunc('day', created_at) AS dia,
          count(*) FILTER (WHERE status = 'read') * 100.0 / nullif(count(*),0) AS pct_lida
     FROM message_logs WHERE created_at > now() - interval '14 days'
    GROUP BY 1 ORDER BY 1;"

# Houve envio para quem não tinha opt-in confirmado?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT count(*) FROM delivery_attempts da
     JOIN subscribers s ON s.id = da.subscriber_id
    WHERE da.created_at > now() - interval '7 days'
      AND (s.opt_in_confirmed_at IS NULL OR s.opt_out_at IS NOT NULL);"

O último comando precisa devolver zero. Qualquer valor diferente é bug grave e é a causa mais provável da queda de qualidade.

Correção.

  1. Reduzir o volume imediatamente:
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=5
docker compose exec -T worker ops config:set --key=send.enabled_tiers --value=PAID
docker compose restart worker

Enviar apenas para PAID por alguns dias. São os assinantes com maior engajamento e menor probabilidade de bloquear.

  1. Se houve envio sem opt-in, parar tudo com o interruptor geral, corrigir o bug e só então religar.
  2. Revisar o texto do template: garantir que o rodapé "Responda SAIR para cancelar" está presente e legível, e que o teaser não parece propaganda.
  3. Facilitar a saída: a resposta ao opt-out precisa ser imediata e sem atrito. Dificultar a saída aumenta o bloqueio, que é o que destrói a qualidade.
  4. Não trocar de número. Trocar leva o problema junto e queima um ativo. A qualidade se recupera em 7 a 14 dias de comportamento saudável.

Verificação. qualityRating volta a YELLOW e depois a GREEN em até 14 dias; opt-outs diários voltam à média histórica; taxa de leitura acima de 40%.

Prevenção. Alerta em qualquer transição para YELLOW, não só RED. Painel com opt-outs diários e taxa de leitura visíveis. Opt-in duplo obrigatório, que já é regra do produto. Métrica de bloqueios acompanhada semanalmente.

R-05 — Número banido ou conta restrita #

Sintoma. Erros 133xxx em todos os envios; o painel da Meta mostra o número como restrito ou desabilitado; nenhuma mensagem sai.

Impacto. Máximo. O canal do produto está fora. Duração indeterminada, de horas a permanente.

Diagnóstico.

docker compose exec -T worker ops whatsapp:status
docker compose logs --since=1h worker | grep -oE '"metaCode":[0-9]+' | sort | uniq -c | sort -rn

Consultar o painel de negócios da Meta para o motivo exato e se há apelação disponível.

Correção.

# 1. Parar tudo. Continuar tentando piora o registro da conta.
docker compose exec -T worker ops kill-switch:on --reason="numero restrito pela Meta"
  1. Abrir apelação, com a evidência de opt-in: exportar consent_events de uma amostra de assinantes, que contém texto de consentimento versionado, IP, data e canal. É exatamente a evidência que a Meta pede, e a existência dessa tabela é o que torna a apelação viável.
docker compose exec -T worker ops consent:export --sample=200 --format=csv > /tmp/consent-evidence.csv
  1. Comunicar aos assinantes pelo painel web e por e-mail (ver Seção 27.7). Este é um dos poucos casos em que se comunica proativamente.
  2. Publicar o devocional diário no painel web normalmente. O produto degrada para "somente web" e continua existindo.
  3. Não cobrar durante a interrupção. Se passar de 3 dias, suspender as cobranças recorrentes na Asaas e estender current_period_end de todos os assinantes ativos pelo número de dias parados:
docker compose exec -T worker ops billing:pause-all --reason="canal indisponivel"
docker compose exec -T worker ops subscription:extend-all --days=<dias-parados>
  1. Se um número secundário verificado existir, migrar WHATSAPP_PHONE_NUMBER_ID e ressubmeter os templates. Isso exige que os oito templates já tenham sido aprovados no segundo número, o que é parte da prevenção.

Verificação. Envio de teste para o número do operador; qualityRating legível; ops health --strict passando na verificação de templates.

Prevenção. Um segundo número verificado na mesma conta de negócios, com os oito templates da Seção 17.5 aprovados, mantido em espera. Opt-in duplo com registro imutável. Monitoramento de qualidade com alerta em YELLOW. Nunca importar base comprada ou de terceiros — não existe caminho de cadastro que não passe pela confirmação no WhatsApp.

R-06 — Template rejeitado ou pausado pela Meta #

Sintoma. Erros 132000 a 132015 no envio; webhook message_template_status_update com REJECTED, PAUSED ou DISABLED.

Impacto. Se for devocional_diario_v1, nenhum assinante com janela fechada recebe. Se for codigo_acesso_v1, ninguém consegue entrar no painel.

Diagnóstico.

docker compose exec -T worker ops template:sync
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT name, language, category, status, quality_score, rejected_reason, updated_at
     FROM whatsapp_templates ORDER BY name;"
# O parâmetro enviado violava as regras de formatação?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT id, length(teaser) AS tam,
          teaser ~ E'[\\n\\r\\t]' AS tem_quebra,
          teaser LIKE '%    %' AS tem_4_espacos
     FROM devotionals WHERE scheduled_for >= current_date - 7;"

As colunas tem_quebra e tem_4_espacos precisam ser todas false. Verdadeiro significa que a sanitização do teaser falhou e é a causa direta do erro de template.

Causas: parâmetro com quebra de linha, tabulação ou 4 espaços; teaser acima do limite; template pausado por qualidade baixa (muitos bloqueios após recebê-lo); reclassificação de categoria; template editado no painel da Meta sem ressincronizar.

Correção.

# Pausa por qualidade: a Meta reabilita sozinha após um período.
# Enquanto isso, usar o template alternativo já aprovado.
docker compose exec -T worker ops config:set --key=send.daily_template_name --value=devocional_diario_v2

# Rejeição por conteúdo: corrigir e ressubmeter.
docker compose exec -T worker ops template:submit --name=devocional_diario_v3 --from=devocional_diario_v1

Se o problema foi o parâmetro, a correção é no teaser, e o teste da Seção 24.4.5 precisa ganhar um caso novo com a string exata que falhou.

Se nenhum template diário estiver utilizável, os assinantes com janela aberta continuam recebendo normalmente pelo caminho free-form. Rodar o lote apenas para eles:

docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --only-route=WINDOW_DIRECT

Verificação. whatsapp_templates.status = 'APPROVED'; envio de teste chegando; sem erros de template por 30 minutos.

Prevenção. Manter sempre dois templates diários aprovados (v1 e v2) com conteúdo equivalente, para troca imediata. ops template:sync roda a cada hora e alerta em qualquer mudança de status. O teaser é validado por isTemplateParamSafe no momento do salvamento no painel administrativo, e não apenas no envio — falhar cedo, no editor, e não às 06:00.

R-07 — Token de acesso da Meta expirado #

Sintoma. Todos os envios falham com 190. ops health --strict falha na verificação da Meta.

Impacto. Nenhuma mensagem sai e nenhum webhook é validado. Equivalente à Meta estar fora.

Diagnóstico.

docker compose logs --since=30m worker | grep -c '"metaCode":190'

# Validade do token corrente. Um token de usuário do sistema sem expiração
# devolve expiresAt nulo.
docker compose exec -T worker ops whatsapp:token-info
{ "isValid": false, "expiresAt": "2026-08-25T03:00:00.000Z",
  "scopes": ["whatsapp_business_messaging"], "type": "SYSTEM_USER" }

Correção.

  1. Gerar um token novo no painel de negócios da Meta para o usuário do sistema, com as permissões whatsapp_business_messaging e whatsapp_business_management, sem expiração.
  2. Atualizar o segredo no servidor:
sudo sed -i 's|^WHATSAPP_SYSTEM_USER_TOKEN=.*|WHATSAPP_SYSTEM_USER_TOKEN=<novo-token>|' \
  /opt/palavra-diaria/.env.production
docker compose up -d web worker
docker compose exec -T worker ops circuit:reset --name=whatsapp
docker compose exec -T worker ops whatsapp:token-info
  1. Reprocessar o que falhou por essa causa:
docker compose exec -T worker ops queue:dead:replay --queue=send.dispatch --code=WHATSAPP_TOKEN_INVALID
  1. Revogar o token antigo no painel da Meta.
  2. Atualizar o cofre de segredos da organização.

Verificação. ops health --strict passa; envio de teste chega; nenhum 190 por 15 minutos.

Prevenção. Usar token de usuário do sistema sem expiração, nunca token de usuário comum. ops secrets:audit semanal alerta se o token tiver validade definida e faltarem menos de 14 dias. ops health a cada 15 minutos detecta a invalidação em minutos, não no lote da manhã seguinte.

R-08 — Webhook da Asaas pausado por falhas #

Sintoma. Nenhum evento novo em payment_events há horas; o painel da Asaas mostra a fila de webhooks interrompida; assinantes que pagaram não têm acesso liberado.

Impacto. Alto e silencioso. Pagamentos confirmados não viram acesso; inadimplências não viram revogação. Piora sozinho com o tempo.

Diagnóstico.

# 1. Último evento recebido.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT max(received_at) AS ultimo, now() - max(received_at) AS ha_quanto_tempo
     FROM payment_events;"

# 2. O endpoint responde corretamente e rápido?
curl -i -m 5 -X POST https://api.palavradiaria.com.br/api/webhooks/asaas \
  -H 'Content-Type: application/json' \
  -H "asaas-access-token: $ASAAS_WEBHOOK_TOKEN" \
  -d '{"event":"PING","payment":null}'
# Esperado: HTTP/2 200, em menos de 2 s.

# 3. Erros no handler.
docker compose logs --since=6h web | grep '"route":"/api/webhooks/asaas"' | grep '"level":"error"' | tail -50

# 4. Latência do handler nas últimas horas.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT date_trunc('hour', received_at) AS hora, count(*),
          round(avg(handler_duration_ms)) AS media_ms,
          max(handler_duration_ms) AS max_ms
     FROM payment_events WHERE received_at > now() - interval '12 hours'
    GROUP BY 1 ORDER BY 1;"

max_ms acima de 2.000 é a causa provável: a Asaas interpreta lentidão como falha e pausa a fila após uma sequência de erros. O handler precisa apenas persistir e responder; qualquer processamento síncrono nele é bug.

Correção.

  1. Corrigir a causa (endpoint fora do ar, TLS vencido, handler lento, token divergente).
  2. Reativar a fila no painel da Asaas. A Asaas reentrega os eventos pendentes.
  3. Fechar a lacuna com reconciliação, que não depende de webhook:
docker compose exec -T worker ops billing:reconcile --since=$(date -u -d '3 days ago' +%F) --fix
  1. Reprocessar eventos persistidos mas não processados:
docker compose exec -T worker ops billing:replay-events --since=$(date -u -d '3 days ago' +%FT%TZ)

Verificação. Eventos novos chegando; ops billing:reconcile --dry-run sem divergências; amostra de 5 assinantes que pagaram no período com tier = PAID.

Prevenção. O handler já é mínimo por desenho: valida o token em tempo constante, persiste em payment_events, enfileira e responde 200. Alerta "nenhum evento da Asaas em 6 horas" (a base gera eventos com regularidade suficiente para que o silêncio seja sinal). Alerta de p99 do handler acima de 1,5 s. Reconciliação diária às 04:00 é a rede de segurança permanente.

R-09 — Divergência de assinatura entre a Asaas e o banco #

Sintoma. A reconciliação diária reporta divergências, ou um assinante reclama de estar pagando sem ter acesso, ou de ter acesso sem pagar.

Impacto. Financeiro e de confiança. Cobrar quem não tem acesso é o pior dos dois.

Diagnóstico.

docker compose exec -T worker ops billing:reconcile --dry-run --verbose
{
  "checked": 3011,
  "divergences": [
    { "subscriptionId": "01K3Q...", "asaasId": "sub_9v8x", "field": "status",
      "ours": "ACTIVE", "theirs": "INACTIVE", "since": "2026-08-22" },
    { "subscriptionId": "01K3R...", "asaasId": "sub_7t6r", "field": "nextDueDate",
      "ours": "2026-09-20", "theirs": "2026-09-22" }
  ]
}
# Caso individual, com o histórico completo.
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123

Causas: webhook perdido durante indisponibilidade; evento processado fora de ordem; alteração manual feita no painel da Asaas; falha parcial de transação.

Correção. A regra canônica é: a Asaas é a fonte da verdade para cobrança.

# Aplicar as correções propostas.
docker compose exec -T worker ops billing:reconcile --fix

# Caso individual.
docker compose exec -T worker ops billing:reconcile --subscription=01K3Q --fix

Cada correção grava subscription_events com origem RECONCILIATION, preservando o rastro de que a mudança não veio de webhook.

Duas exceções em que não se aplica a correção automática:

  1. Nós dizemos ACTIVE, a Asaas diz INACTIVE, e o assinante pagou. Antes de revogar, conferir GET /v3/payments?subscription=<id> por um pagamento confirmado. Se existir, o erro é da Asaas ou de um cancelamento acidental; abrir chamado e manter o acesso. Revogar acesso de quem pagou é o pior desfecho possível.
  2. Divergência de valor. Nunca corrigida automaticamente. Sempre revisão humana, porque pode indicar alteração de preço aplicada parcialmente.

Verificação. ops billing:reconcile --dry-run com zero divergências; amostra de 5 casos corrigidos conferidos manualmente; subscription_events com o registro correspondente.

Prevenção. Reconciliação diária às 04:00, sempre. Alerta quando as divergências passam de 0,5% da base. Proibição operacional de alterar assinaturas direto no painel da Asaas (toda alteração passa pelo nosso painel, que propaga). Processamento de eventos ordenado por dateCreated, não por ordem de chegada.

R-10 — Pagamento confirmado sem liberação de acesso #

Sintoma. Um assinante mostra o comprovante e continua vendo "Plano gratuito".

Impacto. Individual, mas de alto atrito. Se for sistêmico, é o pior tipo de incidente.

Diagnóstico.

# 1. Estado do assinante.
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123
{
  "subscriberId": "01K3Q", "tier": "FREE",
  "subscription": { "id": "01K3R", "status": "PENDING_PAYMENT",
                    "asaasId": "sub_9v8x", "currentPeriodEnd": null },
  "lastPaymentEvent": { "event": "PAYMENT_CONFIRMED", "receivedAt": "2026-08-25T11:02:00Z",
                        "processedAt": null, "error": "ASAAS_RESPONSE_SCHEMA_MISMATCH" }
}

processedAt nulo com erro registrado é o diagnóstico completo: o evento chegou e o processamento falhou.

# 2. Eventos pendentes no sistema todo — isto responde se é caso isolado ou sistêmico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT event_type, error_code, count(*) FROM payment_events
    WHERE processed_at IS NULL AND received_at > now() - interval '24 hours'
    GROUP BY 1,2 ORDER BY 3 DESC;"

# 3. Fila morta de cobrança.
docker compose exec -T worker ops queue:dead:list --queue=billing.webhook

# 4. O evento chegou a existir?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT id, event_type, received_at, processed_at, error_code
     FROM payment_events WHERE asaas_subscription_id = 'sub_9v8x'
    ORDER BY received_at;"

Ramos: evento nunca chegou (ir para R-08); evento chegou e falhou (reprocessar); evento processado e a transição foi recusada (bug na máquina de estados, ver Seção 24.4.4); externalReference divergente, impedindo o casamento.

Correção.

# Reprocessar o evento específico.
docker compose exec -T worker ops billing:replay-events --event-id=<id>

# Se o evento não existe mas o pagamento é real, reconciliar direto da Asaas.
docker compose exec -T worker ops billing:reconcile --subscription=01K3R --fix

# Último recurso, com registro em auditoria e justificativa obrigatória:
docker compose exec -T worker ops subscription:force-activate \
  --id=01K3R --until=2026-09-25 --reason="pagamento confirmado, evento perdido, chamado 123"

subscription:force-activate exige --reason, grava em admin_audit_log e emite alerta informativo. Existe para não deixar um assinante pagante esperando enquanto se investiga a causa raiz.

Comunicação com o assinante, sempre no mesmo dia: "Identificamos o seu pagamento e liberamos o acesso. Desculpe pelo transtorno." Sem explicação técnica.

Verificação. ops subscriber:entitlements --id=<id> devolve tier: PAID; o assinante confirma o acesso; o envio seguinte inclui áudio; payment_events.processed_at preenchido.

Prevenção. Alerta crítico para qualquer linha job_runs.status = 'DEAD' com queue = 'billing.webhook'. Alerta para payment_events com processed_at IS NULL há mais de 15 minutos. Reconciliação diária. Botão "Verificar meu pagamento" no painel, que dispara a reconciliação daquela assinatura sob demanda e resolve o caso sem contato com suporte.

R-11 — O áudio do dia não foi gerado #

Sintoma. Alerta "devocional de hoje sem áudio" às 05:00, ou o devocional preso em AUDIO_PENDING.

Impacto. Assinantes pagos recebem texto sem áudio. É a diferença do plano que eles compraram.

Diagnóstico.

docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT d.id, d.title, d.status, d.scheduled_for,
          a.provider, a.status AS audio_status, a.duration_seconds, a.ogg_key,
          m.whatsapp_media_id, m.expires_at, a.created_at
     FROM devotionals d
     LEFT JOIN audio_assets a ON a.devotional_id = d.id AND a.invalidated_at IS NULL
     LEFT JOIN media_uploads m ON m.audio_asset_id = a.id AND m.superseded_at IS NULL
    WHERE d.scheduled_for BETWEEN current_date AND current_date + 3
    ORDER BY d.scheduled_for;"

docker compose exec -T worker ops queue:stats --queue=tts.generate
docker compose exec -T worker ops queue:dead:list --queue=tts.generate
docker compose exec -T worker ops circuit:status
docker compose logs --since=12h worker | grep '"job":"tts.generate"' | grep '"level":"error"' | tail -30

Causas: os dois provedores indisponíveis; cota do provedor esgotada; roteiro de narração acima do máximo absoluto de 20.000 caracteres (NARRATION_SCRIPT_TOO_LARGE — o aviso SCRIPT_TOO_LONG dos 9.000 caracteres não falha o job, apenas divide em partes); ffmpeg falhando; storage fora; devocional que nunca chegou a READY, então o job nunca foi enfileirado.

Correção.

# Regerar, escolhendo o provedor explicitamente se o primário está com problema.
docker compose exec -T worker ops tts:generate --devotional=<id> --force --provider=fallback

# Roteiro longo demais: encurtar a reflexão no painel administrativo e regerar.
# Verificar o tamanho antes. Os três limites da Seção 16.3.7 são distintos:
#   warnAbove  9000  -> aviso SCRIPT_TOO_LONG, o job segue com divisão em partes
#   hardLimit 20000  -> NARRATION_SCRIPT_TOO_LARGE, o job falha
#   chunkSize  5000  -> tamanho de cada requisição ao provedor, não limite do roteiro
docker compose exec -T worker ops tts:script --devotional=<id> --stats
# { "characters": 21430, "estimatedDurationSec": 1509,
#   "warnAbove": 9000, "hardLimit": 20000, "chunkSize": 5000 }

# Áudio existe no bucket mas o identificador de mídia na Meta expirou:
docker compose exec -T worker ops media:reupload --devotional=<id>

Se o áudio não puder ser gerado a tempo, o texto sai mesmo assim, conforme a Seção 27.4.3. Confirmar que o motor está configurado para isso e disparar o job de recuperação para entregar o áudio depois:

docker compose exec -T worker ops send:audio-catchup --date=$(TZ=America/Sao_Paulo date +%F)

O comando envia o áudio apenas para quem ainda está com a janela de atendimento aberta. Quem já fechou a janela recebe o áudio no painel web, e o motivo é registrado.

Verificação. audio_assets com ogg_key, mp3_key, status = 'READY' e duration_seconds entre 180 e 360, e media_uploads.whatsapp_media_id preenchido com expires_at a mais de 48 h; ouvir o áudio pelo painel administrativo antes de liberar; devotionals.status = 'PUBLISHED'.

Prevenção. O áudio é gerado quando o devocional atinge READY, normalmente com dias de antecedência — a folga é a prevenção principal. Alerta às 05:00 para qualquer devocional dos próximos 3 dias sem áudio, que é o primeiro dos três marcos de prontidão da Seção 18.4. Validação de tamanho do roteiro no momento de salvar no painel, não na geração. Monitoramento de cota do provedor com alerta em 80% de uso.

R-12 — Conteúdo do dia ausente às 05:00 #

Sintoma. Alerta "nenhum devocional publicado para hoje", disparado às 05:00.

Impacto. Sem conteúdo não há envio. Se não for resolvido em 40 minutos, o lote das 05:40 não terá o que enviar.

Diagnóstico.

docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT scheduled_for, id, title, status FROM devotionals
    WHERE scheduled_for BETWEEN current_date AND current_date + 14
      AND deleted_at IS NULL
    ORDER BY scheduled_for;"

Verificar se falta o dia (linha ausente) ou se existe em estado não publicável (DRAFT, READY, AUDIO_PENDING).

Correção, em ordem de preferência.

# 1. Existe e está pronto, faltou publicar. Melhor caso.
docker compose exec -T worker ops devotional:publish --id=<id>

# 2. Existe em DRAFT com conteúdo completo: revisar rápido, marcar READY, gerar áudio,
#    publicar. Cabe em 20 minutos.
docker compose exec -T worker ops devotional:transition --id=<id> --to=READY
docker compose exec -T worker ops tts:generate --devotional=<id>
docker compose exec -T worker ops devotional:publish --id=<id>

# 3. Não existe: reaproveitar um devocional do acervo com mais de 180 dias.
docker compose exec -T worker ops devotional:reuse --source=<id-antigo> --for-date=$(TZ=America/Sao_Paulo date +%F)

devotional:reuse clona o conteúdo, gera um id novo, reaproveita o áudio existente se ainda estiver no bucket (evitando o custo e o tempo de TTS) e marca devotional_revisions.origin = 'REUSE'. A escolha de 180 dias é deliberada: mais de meio ano é tempo suficiente para que a repetição não seja percebida, e assinantes com menos de 180 dias de casa nunca viram o conteúdo.

Se nem isso for possível, não enviar. Um devocional vazio ou improvisado é pior do que a ausência.

Verificação. SELECT status FROM devotionals WHERE scheduled_for = current_date devolve PUBLISHED; ops send:plan --dry-run mostra as contagens esperadas.

Prevenção. Alerta escalonado: informativo com 7 dias de antecedência se houver menos de 7 dias de conteúdo publicado na fila; alta com 3 dias; crítica às 05:00 do próprio dia. O painel administrativo mostra permanentemente "conteúdo publicado até DD/MM (N dias de folga)" no topo do calendário editorial. Meta operacional declarada: manter no mínimo 14 dias de conteúdo publicado à frente.

R-13 — Fila travada com job preso #

Sintoma. ops queue:stats mostra jobs em active que não avançam. waiting acumula. O worker parece vivo mas não produz.

Impacto. Depende da fila. Em send.dispatch, o lote para.

Diagnóstico.

docker compose exec -T worker ops queue:stats --all

# Jobs ativos há mais tempo do que o razoável.
docker compose exec -T worker ops queue:active --queue=send.dispatch --older-than=120
[
  { "id": "s_01K3Q...", "name": "send", "processedOn": "2026-08-25T09:02:11.000Z",
    "ageSeconds": 843, "attemptsMade": 1, "lockExpiresIn": -120 }
]

lockExpiresIn negativo significa que o bloqueio expirou e o job está tecnicamente órfão.

# O processo do worker está travado ou trabalhando?
docker stats --no-stream pd-worker
docker compose exec -T worker node -e "console.log(process.memoryUsage())"

# Alguma consulta segurando bloqueio no banco?
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT pid, state, wait_event_type, wait_event, now()-query_start AS duracao,
          left(query, 120) AS consulta
     FROM pg_stat_activity
    WHERE state <> 'idle' AND now()-query_start > interval '30 seconds'
    ORDER BY duracao DESC;"

# Bloqueios em cadeia.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT blocked.pid AS bloqueado, blocking.pid AS bloqueador,
          left(blocked.query,80) AS q_bloqueada, left(blocking.query,80) AS q_bloqueadora
     FROM pg_stat_activity blocked
     JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
    WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;"

Causas: chamada HTTP sem timeout (a mais comum); bloqueio no banco; laço infinito em processamento de áudio; worker com CPU saturada.

Correção.

# 1. Liberar os jobs órfãos. O BullMQ os devolve para 'waiting'.
docker compose exec -T worker ops queue:unstall --queue=send.dispatch

# 2. Se houver consulta travando o banco, encerrar apenas ela.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT pg_cancel_backend(<pid>);"
# Se pg_cancel_backend não resolver em 30 s:
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT pg_terminate_backend(<pid>);"

# 3. Reiniciar o worker. O stop_grace_period de 120 s deixa o job em curso terminar.
docker compose restart worker

# 4. Retomar o lote.
docker compose exec -T worker ops send:resume --batch=<id>

Nunca usar docker kill no worker. O encerramento forçado deixa delivery_attempts em SENDING com locked_at velho e exige a varredura de reparo da Seção 18.15.

Verificação. active volta a valores normais e rotativos; waiting cai; sem jobs com lockExpiresIn negativo; taxa de envio por minuto de volta à faixa esperada.

Prevenção. Timeout obrigatório em toda chamada HTTP de saída (AbortSignal.timeout), imposto por regra de lint que proíbe fetch sem signal. lockDuration: 60_000 e maxStalledCount: 2 fazem o BullMQ recuperar sozinho a maioria dos casos. Alerta "job ativo há mais de 5 minutos". statement_timeout = 15s no Postgres impede consulta eterna.

R-14 — Banco sem conexões disponíveis #

Sintoma. Erros too many clients already ou Timed out fetching a new connection from the connection pool. O site fica lento e depois devolve erro.

Impacto. Total enquanto durar. Webhooks começam a falhar, o que aciona o risco do R-08.

Diagnóstico.

# Quem está consumindo conexões.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT application_name, state, count(*)
     FROM pg_stat_activity WHERE datname = 'palavra_diaria'
    GROUP BY 1,2 ORDER BY 3 DESC;"

# Limite configurado e uso atual.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT setting::int AS max_connections FROM pg_settings WHERE name='max_connections';"

# Conexões ociosas dentro de transação: o vilão clássico.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT pid, application_name, now()-state_change AS ocioso_ha, left(query,100)
     FROM pg_stat_activity
    WHERE state = 'idle in transaction' ORDER BY 2 DESC;"

idle in transaction com muitos minutos indica transação aberta e esquecida — quase sempre uma chamada de rede dentro de prisma.$transaction, o que é proibido por convenção de código.

Correção.

# 1. Encerrar conexões ociosas em transação com mais de 5 minutos.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
    WHERE state = 'idle in transaction' AND now()-state_change > interval '5 minutes';"

# 2. Alívio imediato: reduzir a concorrência do worker. WORKER_CONCURRENCY é variável
#    de ambiente (Seção 26), não chave de settings: edita-se o arquivo e reinicia.
sudo sed -i 's|^WORKER_CONCURRENCY=.*|WORKER_CONCURRENCY=8|' /opt/palavra-diaria/.env.production
docker compose up -d worker

# 3. Se for pico legítimo, elevar o teto (exige reinício do Postgres).
# Editar postgres/postgresql.conf: max_connections = 160
docker compose restart postgres
docker compose restart web worker

Elevar max_connections só é seguro com memória disponível: cada conexão custa aproximadamente 10 MB. De 120 para 160 são ~400 MB adicionais, que precisam caber no limite do contêiner.

Verificação. Contagem de conexões abaixo de 70% do teto; nenhuma idle in transaction com mais de 1 minuto; p95 da API de volta abaixo de 400 ms.

Prevenção. idle_in_transaction_session_timeout = 30s no Postgres, que mata o problema na origem. connection_limit explícito por processo, com a soma sempre abaixo de max_connections. Proibição, imposta em revisão de código, de qualquer chamada de rede dentro de transação. Alerta em 70% de uso do teto. application_name distinto por processo, que é o que torna o diagnóstico imediato.

R-15 — Disco cheio #

Sintoma. Alerta de disco acima de 75%; ou o Postgres recusando escrita com No space left on device; ou o Docker falhando ao baixar imagem.

Impacto. Se o disco encher de verdade, o Postgres para e o sistema inteiro cai. É um incidente que dá muitos avisos antes de acontecer.

Diagnóstico.

df -h /
docker system df -v

# Tamanho dos volumes.
du -sh /var/lib/docker/volumes/* 2>/dev/null | sort -rh | head -10

# Maiores tabelas e índices.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT relname, pg_size_pretty(pg_total_relation_size(c.oid)) AS total,
          pg_size_pretty(pg_relation_size(c.oid)) AS tabela
     FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
    WHERE n.nspname = 'public' AND c.relkind = 'r'
    ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 15;"

# O WAL está acumulando? Indica arquivamento quebrado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT count(*) AS segmentos, pg_size_pretty(sum(size)) AS total
     FROM pg_ls_waldir();"
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT * FROM pg_stat_archiver;"

# Logs de contêiner fora de controle.
du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head

pg_stat_archiver com failed_count alto e last_failed_time recente é a causa mais perigosa: se o arquivamento de WAL falha, o Postgres acumula segmentos até encher o disco.

Correção.

# 1. Ganho rápido e seguro: lixo do Docker.
docker image prune -af --filter "until=168h"
docker builder prune -af
docker container prune -f

# 2. Se o arquivamento de WAL falhou, é a prioridade absoluta.
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria check
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria archive-push-async
# Corrigir credencial ou conectividade do repositório antes de qualquer outra coisa.

# 3. Retenção de dados: apagar o que já passou do prazo.
docker compose exec -T worker ops retention:run --dry-run
docker compose exec -T worker ops retention:run

# 4. Recuperar espaço de tabelas inchadas (bloqueia a tabela; usar em janela).
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "VACUUM (FULL, ANALYZE) message_logs;"

# 5. Truncar logs de contêiner (último recurso, perde histórico).
truncate -s 0 /var/lib/docker/containers/*/*-json.log

Nunca apagar arquivos de pg_wal manualmente. Isso corrompe o banco de forma irreversível. Se o WAL for o problema, a solução é fazer o arquivamento voltar a funcionar.

Verificação. df -h / abaixo de 70%; pg_stat_archiver.failed_count estável; ops backup:verify passando; escrita no banco normal.

Prevenção. Alerta em 75% e crítico em 85%, com margem suficiente para agir. Rotação de log já configurada (20 MB × 5 arquivos por serviço). docker image prune semanal automatizado. Job de retenção diário. Alerta específico para pg_stat_archiver.failed_count crescente, que é o sinal precoce do pior caso. Painel com a projeção de crescimento de disco a 90 dias.

R-16 — Restauração de backup #

Sintoma. Perda ou corrupção de dados: erro humano, falha de disco, ou reconstrução após desastre.

Impacto. Máximo durante a execução. O sistema fica fora enquanto dura.

Diagnóstico. Antes de restaurar, três perguntas precisam de resposta escrita:

  1. Qual é o instante-alvo? Determinar o momento exato imediatamente anterior ao dano.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT * FROM admin_audit_log ORDER BY created_at DESC LIMIT 20;"
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT job_name, status, started_at, finished_at FROM job_runs
    ORDER BY started_at DESC LIMIT 20;"
  1. Restauração completa ou parcial? Se apenas uma tabela foi afetada, restaurar em um banco temporário e copiar a tabela é muito menos destrutivo.
  2. O backup está íntegro?
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria info
docker compose exec -T pgbackrest pgbackrest --stanza=palavra-diaria check

Correção — restauração parcial (preferida sempre que possível).

# 1. Restaurar em diretório separado, sem tocar em produção.
docker compose run --rm pgbackrest \
  pgbackrest --stanza=palavra-diaria --pg1-path=/tmp/restore-parcial \
    --type=time --target="2026-08-25 04:55:00-03" restore

# 2. Subir uma instância temporária apontando para esse diretório.
docker compose -f docker-compose.restore.yml up -d postgres-restore

# 3. Extrair apenas o necessário.
docker compose -f docker-compose.restore.yml exec -T postgres-restore \
  pg_dump -U app -d palavra_diaria -t subscribers --data-only -Fc > /tmp/subscribers.dump

# 4. Conferir antes de aplicar.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "CREATE SCHEMA IF NOT EXISTS restore_tmp;"
pg_restore -d palavra_diaria --schema=restore_tmp /tmp/subscribers.dump
# Comparar restore_tmp.subscribers com public.subscribers e aplicar só as linhas corretas.

Correção — restauração completa. Seguir a Seção 25.8.4, com o interruptor geral ligado antes de subir a aplicação e desligado só depois da verificação:

docker compose stop web worker
docker compose run --rm pgbackrest \
  pgbackrest --stanza=palavra-diaria --delta \
    --type=time --target="2026-08-25 04:55:00-03" --target-action=promote restore
docker compose up -d postgres
docker compose up -d worker
docker compose exec -T worker ops kill-switch:on --reason="pos-restauracao, verificacao pendente"
docker compose exec -T worker ops backup:verify --post-restore
docker compose exec -T worker ops billing:reconcile --since=<data-do-alvo> --fix
docker compose exec -T worker ops health --strict
docker compose up -d web
docker compose exec -T worker ops kill-switch:off

O interruptor ligado durante a verificação é obrigatório: sem ele, um lote poderia disparar com dados parciais e enviar mensagens erradas.

Verificação. Contagens das tabelas principais compatíveis com o esperado; prisma migrate status sem migrations pendentes; ops health --strict passando; ops billing:reconcile --dry-run sem divergências; amostra de 10 assinantes conferida.

Prevenção. Ensaio trimestral obrigatório (Seção 25.8.5), que é o que transforma este runbook de teoria em procedimento praticado. Retenção de 35 dias. Backup em provedor separado. Duas camadas independentes de backup. Permissões que impedem a aplicação de executar DROP TABLE.

R-17 — Assinante relata que não recebeu #

Sintoma. Contato pelo suporte: "não recebi o devocional de hoje".

Impacto. Individual, salvo se for a ponta de um problema sistêmico. A primeira pergunta a responder é justamente qual dos dois é.

Diagnóstico.

# 1. É sistêmico? Sempre verificar antes de investigar o caso individual.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT status, count(*) FROM delivery_attempts
    WHERE devotional_date = current_date GROUP BY 1;"

# 2. Estado do assinante. A busca é pelo índice cego: o comando normaliza o número,
#    calcula phone_hmac e consulta por ele — a coluna phone_e164 é cifrada (Seção 6).
docker compose exec -T worker ops subscriber:inspect --phone=+5511990000123
{
  "subscriberId": "01K3Q...",
  "phoneE164": "+55119****0123",
  "waId": "5511990000123",
  "tier": "PAID",
  "status": "ACTIVE_PAID",
  "optInConfirmedAt": "2026-03-02T14:00:00Z",
  "optOutAt": null,
  "blockedAt": null,
  "serviceWindowExpiresAt": "2026-08-24T22:10:00Z",
  "consecutiveWindowMisses": 4,
  "today": {
    "planned": true, "route": "TEMPLATE_VIDEO",
    "attemptStatus": "SENT", "wamid": "wamid.HBg...",
    "tierAtSend": "PAID", "tierAtSendEffective": "PAID", "downgradedAt": null,
    "messageStatus": "delivered", "deliveredAt": "2026-08-25T09:00:14Z"
  }
}

Árvore de decisão:

O que se vê Conclusão Ação
optInConfirmedAt: null Nunca confirmou o opt-in Reenviar a mensagem de boas-vindas e orientar a responder SIM
optOutAt preenchido Saiu, talvez sem perceber Explicar e oferecer VOLTAR
blockedAt preenchido Número marcado como não entregável, ou bloqueio administrativo Confirmar o número, corrigir se necessário, desbloquear pelo painel (a transição de saída de BLOCKED está na Seção 11.8)
planned: false Não entrou no lote Verificar o motivo em skipped: tier FREE em dia que não é domingo é o caso mais comum e não é erro
attemptStatus: SKIPPED_OPTED_OUT, SKIPPED_INELIGIBLE ou SKIPPED_PAUSED O estado mudou entre o planejamento e o disparo Não é falha: é a revalidação do disparo funcionando (Seção 18.5.1). Explicar o estado atual
downgradedAt preenchido Perdeu o acesso pago entre 05:40 e o disparo Recebeu o texto sem áudio, corretamente. Se o pagamento existir, ir para R-10
attemptStatus: FAILED Envio falhou Verificar error_code e aplicar R-03
messageStatus: sent, sem delivered Aparelho desligado ou sem rede Aguardar; a Meta reentrega por até 30 dias
messageStatus: delivered Foi entregue. Está no aparelho Orientar: verificar arquivadas, silenciadas, ou se o número foi bloqueado
messageStatus: failed com 131049 Adiado por saúde do ecossistema Explicar que chega no dia seguinte; não é falha
Tudo correto e mesmo assim não chegou O assinante bloqueou o número Orientar a desbloquear
# Histórico completo das mensagens do assinante nos últimos 7 dias.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT created_at, direction, message_key, status, wamid, error_code
     FROM message_logs WHERE subscriber_id = '01K3Q...'
      AND created_at > now() - interval '7 days' ORDER BY created_at DESC;"

Correção.

# Reenvio manual, respeitando o limite do tier (Seção 13).
docker compose exec -T worker ops send:one --subscriber=01K3Q... --date=$(TZ=America/Sao_Paulo date +%F)

# Reenvio ignorando o limite, com justificativa registrada.
docker compose exec -T worker ops send:one --subscriber=01K3Q... \
  --date=$(TZ=America/Sao_Paulo date +%F) --force --reason="chamado #456, falha comprovada"

Se delivered estiver confirmado, não reenviar. Reenviar o que já foi entregue produz mensagem duplicada, irrita e degrada a qualidade do número. Orientar o assinante a procurar a conversa.

Verificação. message_logs com delivered para o novo envio; confirmação do assinante.

Prevenção. O painel web mostra o devocional do dia sempre, com botão de reenviar — resolve a maioria dos casos sem contato com suporte. A resposta padrão do suporte já inclui as três orientações mais comuns (arquivadas, silenciadas, bloqueio). Métrica de reclamações por dia: mais de 5 em um dia é sinal sistêmico e escala para R-03.

R-18 — Redis indisponível ou com perda de dados #

Sintoma. worker reportando 503; erros de conexão nos logs; ops queue:stats falhando; ou o Redis de pé mas com todas as filas vazias.

Impacto. Processamento assíncrono parado. O site continua de pé (Seção 27.4.5).

Diagnóstico.

docker compose ps redis
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" ping
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" info memory | grep -E 'used_memory_human|maxmemory_human'
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" info persistence | grep -E 'aof_enabled|aof_last_write_status|rdb_last_bgsave_status|loading'
docker compose logs --tail=100 redis
docker inspect pd-redis --format '{{.State.OOMKilled}} {{.RestartCount}}'

Causas: contêiner morto por falta de memória; maxmemory atingido com noeviction (escritas recusadas, que é o comportamento desejado); AOF corrompido impedindo o start; volume perdido.

Correção.

# 1. Memória cheia: elevar o limite e reiniciar.
# Editar redis/redis.conf: maxmemory 1024mb; e o limite do contêiner no compose.
docker compose up -d redis

# 2. AOF corrompido impedindo o start:
docker compose run --rm --entrypoint redis-check-aof redis \
  --fix /data/appendonlydir/appendonly.aof.1.incr.aof
docker compose up -d redis

# 3. Perda total de dados. Repopular a partir do Postgres, que é a fonte da verdade.
docker compose restart worker           # recria os jobs repetíveis, de forma idempotente
docker compose exec -T worker ops queue:recover --all

ops queue:recover --all executa, nesta ordem:

  1. Reenfileira payment_events com processed_at IS NULL.
  2. Reenfileira inbound_messages com processed_at IS NULL.
  3. Reenfileira devotionals em AUDIO_PENDING.
  4. Para o lote do dia corrente, reenfileira delivery_attempts que não estão em estado terminal — a chave única impede duplicata.
  5. Repopula killswitch:global a partir de settings, que é a proteção descrita na Seção 25.9.3.
# 4. Conferir e retomar.
docker compose exec -T worker ops queue:stats --all
docker compose exec -T worker ops send:resume --batch=<id-do-lote-do-dia>

Verificação. PING respondendo PONG; worker com health 200; filas com movimento; ops kill-switch:status refletindo o valor de settings; nenhum envio duplicado (SELECT count(*), count(DISTINCT idempotency_key) FROM delivery_attempts WHERE devotional_date = current_date com os dois valores iguais).

Prevenção. maxmemory-policy noeviction, que transforma pressão de memória em erro visível. Alerta em 80% de maxmemory. AOF com appendfsync everysec. Limite de memória do contêiner com folga de 30%. Nenhuma verdade de negócio no Redis — é o desenho que torna a perda recuperável.

R-19 — Storage de mídia indisponível #

Sintoma. Erros ao gerar áudio; player do painel devolvendo erro; URLs assinadas falhando.

Impacto. Contido. O envio de áudio pela Meta continua funcionando com os identificadores de mídia já existentes (Seção 27.4.4).

Diagnóstico.

docker compose exec -T worker ops storage:check
{ "endpoint": "...", "bucket": "pd-media", "canList": false, "canPut": false,
  "canGet": false, "lastError": "503 Service Unavailable", "latencyMs": 5001 }
docker compose exec -T worker ops circuit:status | grep -A3 storage
docker compose logs --since=1h worker | grep '"integration":"storage"' | grep error | tail -20

Distinguir três causas: indisponibilidade do provedor (verificar a página de status); credencial expirada ou revogada (erro 403 em vez de 503); e bucket cheio ou com política alterada.

Correção.

# Credencial: atualizar e reiniciar.
sudo sed -i 's|^MEDIA_ACCESS_KEY=.*|MEDIA_ACCESS_KEY=<nova>|' /opt/palavra-diaria/.env.production
docker compose up -d worker web
docker compose exec -T worker ops circuit:reset --name=storage

# Indisponibilidade do provedor: esperar. Os jobs estão adiados, não perdidos.
docker compose exec -T worker ops queue:stats --queue=tts.generate

# Se passar de 6 horas e houver devocional próximo sem áudio, usar o espelho
# como origem temporária de leitura (somente leitura, sem escrita).
docker compose exec -T worker ops config:set --key=MEDIA_READ_ENDPOINT --value=<endpoint-do-espelho>

Se o storage voltar depois de o lote ter saído sem áudio, executar o job de recuperação do R-11.

Verificação. ops storage:check com os três testes verdadeiros; player do painel funcionando; tts.generate drenando.

Prevenção. Espelho diário em provedor diferente (Seção 25.10), que é o que torna a leitura alternativa possível. Alerta de latência do storage acima de 2 s. Rotação anual de credencial com validação antes de revogar a antiga. Desacoplamento do envio: o áudio já está na Meta antes do disparo, então uma queda do storage às 06:00 não afeta a entrega do dia.

R-20 — Certificado TLS não renovado #

Sintoma. Navegador exibindo aviso de certificado; webhooks da Asaas e da Meta falhando em massa com erro de TLS; monitoramento externo acusando expiração.

Impacto. Alto e abrupto. Os dois provedores de webhook recusam conexão com certificado inválido, o que leva à pausa da fila da Asaas (R-08) e à perda de status da Meta.

Diagnóstico.

# Validade dos certificados dos quatro domínios.
for d in palavradiaria.com.br www.palavradiaria.com.br app.palavradiaria.com.br api.palavradiaria.com.br; do
  echo -n "$d: "
  echo | openssl s_client -servername "$d" -connect "$d":443 2>/dev/null \
    | openssl x509 -noout -enddate
done

docker compose logs --since=48h caddy | grep -iE 'certificate|acme|error' | tail -40

# A porta 80 está acessível? O desafio HTTP-01 depende dela.
curl -sI -m 5 http://palavradiaria.com.br/.well-known/acme-challenge/test | head -1

# O DNS aponta para este servidor?
dig +short A palavradiaria.com.br
curl -s -m 5 https://api.ipify.org

Causas: porta 80 bloqueada por mudança de firewall; DNS apontando para outro lugar; limite de emissão do emissor atingido; volume caddy_data perdido, apagando as chaves de conta.

Correção.

# 1. Porta 80 fechada:
sudo ufw allow 80/tcp
sudo ufw status numbered

# 2. Forçar nova tentativa de emissão.
docker compose restart caddy
docker compose logs -f caddy | grep -i acme

# 3. Limite do emissor atingido: alternar o emissor no Caddyfile
#    (bloco `issuer acme` para ZeroSSL) e recarregar.
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

# 4. Volume perdido: o Caddy recria a conta e emite tudo de novo.
#    Aguardar. Emissão dos quatro domínios leva menos de 2 minutos.

Enquanto o certificado está inválido, avisar Asaas e Meta não é possível — o que se faz é resolver rápido e, depois, recuperar os eventos perdidos:

docker compose exec -T worker ops billing:reconcile --since=<data> --fix

Status de mensagem perdidos da Meta não são recuperáveis. message_logs fica com o último status conhecido, o que é aceitável: o registro de sent já prova o envio.

Verificação. openssl s_client mostrando validade futura para os quatro domínios; webhook de teste da Asaas respondendo 200; monitoramento externo verde.

Prevenção. Monitoramento externo com alerta a 21 dias da expiração — o Caddy renova a 30 dias, então 21 dias significa que a renovação automática falhou duas vezes. Verificação semanal de que a porta 80 está aberta, no mesmo job que confere as regras de firewall. Volume caddy_data incluído no inventário de itens críticos. Registros DNS com TTL de 300 s e alerta se o A deixar de apontar para o servidor.

27.7 Comunicação de incidente #

27.7.1 Quem avisa #

Papel Responsabilidade
Quem está de plantão Declara o incidente, executa o runbook, atualiza a página de status
OWNER Único que autoriza comunicação em massa aos assinantes e decisões de crédito ou extensão de assinatura
Suporte Responde contatos individuais com o texto padrão; não improvisa explicação técnica

Ninguém mais fala em nome do produto durante um incidente. Explicação técnica não vai para o assinante em nenhuma hipótese.

27.7.1.1 Canais de comunicação, em ordem #

A dependência circular precisa estar explicitada, porque o canal que costuma cair é o canal de mensagens. A ordem é:

  1. Página pública de estado em /status, servida por infraestrutura independente da aplicação e do canal de mensagens, com o texto atualizado pelo responsável do incidente. O endereço consta do rodapé de todas as páginas e da mensagem de boas-vindas, para que o assinante saiba onde olhar antes de precisar.
  2. E-mail, para quem tiver endereço verificado — pela fila email.send, que não depende da plataforma de mensagens.
  3. Canal de mensagens, somente se ele for o canal saudável. Comunicar a queda do canal pelo próprio canal é impossível e não pode ser o plano.
  4. Aviso dentro do painel do assinante, que continua acessível mesmo com o canal de mensagens indisponível.

Incidente que afete a entrega por mais de 24 h aciona também a suspensão de cobrança descrita na Seção 13. O e-mail é opcional no cadastro: por isso a página de estado é o primeiro item da lista, e não o segundo.

27.7.2 Quando se comunica e quando não #

Situação Comunicar? Canal
Lote atrasado, entregue até 09:00 Não
Lote atrasado, entregue entre 09:00 e 12:00 Não proativamente; aviso no painel web Painel
Dia perdido por completo Sim Painel + e-mail para quem tem e-mail verificado
Falha parcial afetando menos de 5% da base Não Correção silenciosa; resposta individual se houver contato
Falha parcial afetando mais de 20% da base Sim Painel + e-mail aos afetados
Áudio ausente em um dia Não O texto foi entregue; o áudio está no painel
Áudio ausente por 2 dias seguidos Sim, só para PAID E-mail
Canal do WhatsApp fora por mais de 6 horas Sim Painel + e-mail
Número banido Sim, imediatamente Painel + e-mail + aviso permanente no topo do painel
Checkout indisponível Aviso na própria página Página de assinatura
Vazamento de dados pessoais Sim, obrigatório E-mail a todos os afetados + comunicação à ANPD, conforme a Seção 22
Manutenção programada sem impacto no envio Não
Manutenção programada com impacto no envio Sim, com 48 h de antecedência Painel + e-mail
Cobrança indevida Sim, individualmente, com estorno já feito E-mail + WhatsApp

Princípio: comunicar quando o assinante percebeu ou vai perceber. Avisar sobre um problema que ninguém notou cria a impressão de instabilidade sem benefício.

27.7.3 Textos padrão #

Dia perdido — e-mail:

Assunto: Sobre o devocional de hoje

Olá, {primeiro_nome}.

Hoje o devocional não foi enviado no horário de sempre por uma falha técnica do nosso lado. Já resolvemos.

O devocional de hoje está disponível no seu painel: {link}

Amanhã, às 6h, tudo volta ao normal.

Desculpe pelo transtorno.

Equipe Palavra Diária

Canal do WhatsApp indisponível — aviso no painel:

As mensagens no WhatsApp estão temporariamente indisponíveis. Estamos trabalhando para normalizar. Enquanto isso, o devocional de hoje está aqui no painel, com texto e áudio. Assim que o WhatsApp voltar, você recebe normalmente.

Número banido — e-mail:

Assunto: O envio pelo WhatsApp está suspenso temporariamente

Olá, {primeiro_nome}.

O nosso número de WhatsApp está com o envio suspenso e estamos resolvendo isso junto ao WhatsApp. Não sabemos ainda quando será liberado.

Enquanto isso:

  • O devocional continua sendo publicado todos os dias no seu painel, com texto e áudio: {link}
  • Sua assinatura está pausada. Você não será cobrado enquanto o envio estiver suspenso, e os dias parados serão somados ao seu plano.

Avisamos assim que voltar.

Equipe Palavra Diária

Falha parcial — resposta individual do suporte:

Olá, {primeiro_nome}. Verificamos aqui: o devocional de hoje não chegou até você por uma falha no envio. Já reenviamos — deve chegar em instantes. Se não chegar, é só responder esta mensagem.

Cobrança indevida — e-mail:

Assunto: Estorno da cobrança de {data}

Olá, {primeiro_nome}.

Identificamos uma cobrança indevida de {valor} feita em {data}. O estorno já foi solicitado e o valor volta para você em até {prazo} pelo mesmo meio de pagamento.

Sua assinatura continua ativa e nada muda para você.

Desculpe pelo erro.

Equipe Palavra Diária

Manutenção programada com impacto — painel e e-mail:

Manutenção programada: {data}, das {hora_inicio} às {hora_fim}. Nesse período o painel ficará fora do ar. O devocional do dia será enviado normalmente às 6h.

Regras de redação, obrigatórias em qualquer texto de incidente:

  1. Dizer o que aconteceu do ponto de vista do assinante, nunca do sistema. "O devocional não foi enviado", não "houve falha no processamento da fila".
  2. Dizer o que já foi feito. No passado, não no futuro.
  3. Dizer o que ele pode fazer agora, com link.
  4. Dizer quando volta ao normal, se souber. Se não souber, dizer que não sabe.
  5. Pedir desculpa uma vez. Não repetir.
  6. Nunca citar fornecedor, código de erro ou detalhe técnico.
  7. Nunca prometer o que não se controla.

27.7.4 Registro de incidente #

Todo incidente de severidade alta ou crítica gera um registro em docs/incidentes/ do repositório, criado em até 48 horas, com: linha do tempo em horários locais, impacto medido em assinantes afetados, causa raiz, o que funcionou na detecção, o que falhou, e as ações corretivas com responsável e prazo. Sem busca por culpado — o objetivo é a ação corretiva.

Duas seções são obrigatórias em todo registro que envolva não entrega, e o registro não é aprovado sem elas:

## Recuperação da entrega
As mensagens não entregues foram reenviadas? Sim | Não | Parcial.
Quando. Quantas. Se não, a justificativa explícita, aprovada por Produto.

## Comunicação
Os assinantes afetados foram avisados? Sim | Não.
Canal e texto utilizado (Seção 27.7.3). Havendo exposição de dado pessoal,
referência ao rito da Seção 22.7.8 e à decisão sobre comunicação à Autoridade
e aos titulares.

Sem essa exigência, o desfecho comum é o pior possível: o incidente é resolvido às 09:40, o relatório é escrito no prazo, e ninguém decidiu se as 8.000 pessoas recebem o devocional atrasado — a fila já foi limpa na mitigação, e elas simplesmente não receberam nada e não foram avisadas. Em uma base religiosa diária, esse silêncio é o que gera cancelamento.

Uma ação corretiva obrigatória em todo registro: qual alerta teria detectado isso antes? Se a resposta for "nenhum", criar o alerta é parte do fechamento.

27.8 Interruptor geral #

27.8.1 O que é #

Um mecanismo único que interrompe todo envio de mensagem para assinantes de forma imediata, sem derrubar a aplicação, sem perder jobs e sem exigir deploy. Existe para o momento em que se percebe que o sistema está fazendo algo errado e cada segundo de funcionamento piora o dano: envio para quem não deu opt-in, conteúdo errado publicado, loop de reenvio, qualidade do número despencando.

Estado guardado em dois lugares: settings sob a chave ops.kill_switch (verdade durável) e Redis sob killswitch:global (leitura rápida). O Redis é cache; na inicialização e a cada 60 segundos o worker reconcilia a partir de settings. Isso garante que uma perda de Redis não religue os envios sozinha (Seção 25.9.3).

27.8.2 Como ligar #

cd /opt/palavra-diaria
docker compose exec -T worker ops kill-switch:on --reason="envio para nao-optantes detectado"

Saída:

{
  "killSwitch": "ON",
  "reason": "envio para nao-optantes detectado",
  "activatedBy": "ops-cli",
  "activatedAt": "2026-08-25T09:07:22.481Z",
  "effect": {
    "queuesHalted": ["send.dispatch", "send.followup", "media.upload"],
    "jobsInFlight": 6,
    "jobsMovedToHalted": 1834
  }
}

Caminho alternativo, para quando o CLI não estiver disponível (worker fora do ar, por exemplo):

# Direto no banco. Vale imediatamente na próxima leitura de reconciliação.
# Todas as colunas NOT NULL de settings (Seção 6.23) vão no INSERT: sem elas o
# comando aborta justamente no pior momento, em ambiente recém-provisionado.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "INSERT INTO settings (key, value, value_type, default_value, description, group_name, is_secret, editable_by)
   VALUES ('ops.kill_switch',
           '{\"enabled\":true,\"reason\":\"emergencia manual\"}'::jsonb,
           'JSON',
           '{\"enabled\":false,\"reason\":null}'::jsonb,
           'Interruptor geral de envio. Quando ligado, nenhuma mensagem sai.',
           'ops', false, 'OWNER')
   ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();"

# Efeito imediato no Redis, sem depender do worker.
docker compose exec -T redis redis-cli -a "$REDIS_PASSWORD" SET killswitch:global 1

A linha ops.kill_switch é criada pelo seed obrigatório da Seção 6.32.4; este INSERT traz todas as colunas apenas para funcionar também em ambiente recém-provisionado, em que o seed possa não ter rodado. A chave tem exatamente este nome, no formato grupo.chave do catálogo da Seção 26.8.1 — não existe send.pause_all.

Último recurso, quando nada mais responde: docker compose stop worker. Isso para os envios, mas deixa jobs em active com bloqueio pendente, o que exige ops queue:unstall depois. É pior do que o interruptor, e por isso é o último recurso.

27.8.3 O efeito exato #

Componente Comportamento com o interruptor ligado
Filas send.dispatch, send.followup e media.upload O worker lê o interruptor imediatamente antes de cada chamada ao provedor e devolve o job para delayed com moveToDelayed, sem consumir tentativa — o mesmo mecanismo da Seção 27.3.1. Jobs em waiting e delayed permanecem; nada é descartado
Jobs em curso Terminam a chamada atual (não se aborta uma requisição já enviada, para não gerar ambiguidade sobre o envio) e não pegam o próximo
Jobs pendentes do lote Marcados como HALTED em delivery_attempts; a chave de idempotência é preservada
Job de planejamento das 05:40 Executa e planeja normalmente, mas não enfileira. O plano fica pronto para quando religar
Webhooks de entrada Continuam funcionando. Eventos são persistidos. Asaas e Meta não recebem erro
Processamento de pagamento Continua. Liberar acesso não envia mensagem ao assinante enquanto o interruptor está ligado; a mensagem de boas-vindas fica pendente
Mensagens de OTP e de confirmação de opt-out Continuam sendo enviadas. São exceções explícitas: bloquear o OTP impediria as pessoas de entrar no painel, e bloquear a confirmação de opt-out seria uma falha de conformidade
Painel web e painel administrativo Funcionam normalmente, com faixa vermelha no topo do painel administrativo indicando o interruptor ligado, o motivo e há quanto tempo
Geração de TTS Continua. Preparar conteúdo não faz mal

Como as duas exceções passam. O interruptor não é implementado pausando filas do BullMQ, e não existe uma fila whatsapp-critical — as filas do sistema são exatamente as treze da Seção 18.8. A verificação acontece dentro do worker, no instante anterior à chamada ao provedor, e recusa todo envio cuja message_key (Seção 19.2) não seja otp.code ou optout.confirmed. Essas duas passam; todo o resto é adiado. A decisão é deliberada: pausar a fila inteira bloquearia também as duas mensagens que jamais podem parar, e criar uma fila só para elas duplicaria o catálogo de filas para resolver um problema de guarda, não de roteamento. Nenhuma mensagem de conteúdo é exceção.

27.8.4 Como religar #

# 1. Conferir o que está represado antes de soltar.
docker compose exec -T worker ops kill-switch:status
docker compose exec -T worker ops queue:stats --all
{
  "killSwitch": "ON",
  "reason": "envio para nao-optantes detectado",
  "activatedAt": "2026-08-25T09:07:22.481Z",
  "durationMinutes": 47,
  "halted": { "send.dispatch": 1834, "send.followup": 41, "media.upload": 12 }
}
# 2. Simular o que sairia ao religar. Passo obrigatório.
docker compose exec -T worker ops send:plan --date=$(TZ=America/Sao_Paulo date +%F) --dry-run

# 3. Religar.
docker compose exec -T worker ops kill-switch:off --reason="causa corrigida, chamado #789"

# 4. Retomar em ritmo reduzido, para observar antes de abrir tudo.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=5
docker compose restart worker
docker compose exec -T worker ops send:resume --batch=<id> --limit=50

# 5. Verificar as 50 primeiras.
docker compose exec -T postgres psql -U app -d palavra_diaria -c \
  "SELECT status, error_code, count(*) FROM delivery_attempts
    WHERE devotional_date = current_date GROUP BY 1,2 ORDER BY 3 DESC;"

# 6. Se estiver correto, liberar o restante na taxa normal.
docker compose exec -T worker ops config:set --key=send.rate_per_second --value=20
docker compose restart worker
docker compose exec -T worker ops send:resume --batch=<id>

Regra de horário no religamento: se já passou das 12:00 local, não retomar o lote do dia. Religar o interruptor sem retomar o lote é feito com --no-resume:

docker compose exec -T worker ops kill-switch:off --reason="..." --no-resume
docker compose exec -T worker ops send:abandon --batch=<id> --reason="fora da janela util"

Os assinantes leem o devocional do dia no painel web. O envio volta ao normal no dia seguinte.

27.8.5 Regras de uso #

  1. Ligar é sempre seguro. Nenhum dado se perde, nenhuma cobrança é afetada, nenhum webhook falha. Na dúvida, ligar e diagnosticar com calma.
  2. --reason é obrigatório. O comando recusa executar sem motivo, e o texto vai para admin_audit_log e para o alerta.
  3. Toda ativação gera alerta de severidade alta, mesmo quando feita de propósito. Isso garante que ninguém deixe o interruptor ligado sem que o resto perceba.
  4. Alerta de lembrete a cada 30 minutos enquanto estiver ligado, com o motivo e a duração. É a proteção contra o cenário do R-01, em que o lote não dispara porque o interruptor ficou esquecido.
  5. Religar exige a simulação do passo 2. Não é opcional. É o que evita religar e mandar 3.000 mensagens erradas de uma vez.
  6. Ligar e religar são registrados em admin_audit_log com operador, motivo, horário e duração.

28. Plano de Execução e Milestones #

Esta seção sequencia todo o trabalho de construção do sistema para um único agente de IA executor trabalhando sozinho, do repositório vazio ao lançamento em produção. O plano é organizado em 14 milestones (M0 a M13) mais uma trilha externa (X e Y) que corre em paralelo e depende de terceiros.

O plano não usa datas. Usa pontos de esforço relativo (escala Fibonacci: 1, 2, 3, 5, 8, 13, 21), porque a velocidade de um agente executor varia muito com o ambiente e com o tempo de espera de terceiros. Datas aparecem apenas onde são impostas por terceiros (aprovação de template pela Meta, verificação de negócio), e ali são tratadas como risco, não como cronograma.

28.1 Princípios que governam o sequenciamento #

  1. O risco externo é atacado primeiro, mesmo antes do código que o consome. Verificação de negócio na Meta e aprovação de templates levam dias e não são aceleráveis. A trilha externa começa no primeiro dia útil do projeto, em paralelo com M0.
  2. Nada é construído sobre um schema instável. O modelo de dados (Seção 6) é congelado em M1 e só muda por migration aditiva depois disso.
  3. Cada milestone termina em um estado executável e verificável por comando. Não existe milestone que só "prepara terreno" sem produzir evidência automatizável.
  4. O caminho crítico do valor é entregar o devocional. Cobrança é uma camada sobre um produto que já funciona, não o contrário.
  5. Vertical antes de horizontal. Prefere-se uma fatia fina completa (schema → serviço → rota → tela → teste) a uma camada inteira sem consumidor.
  6. Toda integração externa nasce com simulador local. Nenhum milestone pode depender de um serviço externo estar disponível para ser concluído (ver Seção 24 para o simulador).

28.2 Visão geral dos milestones #

ID Título Objetivo em uma frase Depende de Esforço
M0 Fundação do repositório e ferramentas Monorepo instalável, tipado, lintado e conteinerizado, com CI verde em um commit vazio de funcionalidade. 5
M1 Banco e schema Todas as tabelas da Seção 6 criadas por migration, já com criptografia de campo e índices cegos, seeds e client tipado. M0 13
M2 Autenticação e sessões Assinante entra por OTP e administrador entra por senha mais TOTP, com sessões revogáveis. M1 8
M3 Cadastro, opt-in e onboarding Um número de telefone real vira assinante FREE com consentimento registrado e opt-in confirmado. M2 8
M4 Painel administrativo e conteúdo editorial Editor humano cria, revisa, agenda e publica devocionais com versionamento e calendário. M2 13
M5 Pipeline de áudio Devocional aprovado vira OGG/Opus, MP3 e MP4, armazenados e prontos para envio. M4 13
M6 Integração WhatsApp e motor de envio O lote diário sai às 06:00 e entrega texto e áudio dentro das regras da plataforma. M3, M5, X4 21
M7 Pagamentos Asaas e ciclo de assinatura Assinante compra, é cobrado por ciclo e perde o acesso imediatamente ao falhar o pagamento. M6, Y1 13
M8 Painel do assinante Assinante gerencia conta, acervo, assinatura, preferências e dados sozinho. M7 8
M9 Landing page e página de vendas Visitante entende o produto, ouve uma amostra e chega ao checkout. M8 8
M10 Métricas, observabilidade e alertas Operador enxerga entrega, receita, custo e falhas, e é avisado antes do assinante reclamar. M7 8
M11 Endurecimento de segurança e LGPD Todos os controles da Seção 22 exceto a criptografia de campo e os índices cegos, entregues em M1, implementados e verificáveis. M10 5
M12 Testes E2E e carga A suíte completa da Seção 24 passa e o alvo de escala é comprovado em teste. M11 8
M13 Preparação de produção e lançamento Sistema em produção, com backup restaurável, rollback testado e go-live aprovado. M12 5

Total: 136 pontos de construção, mais a trilha externa (esforço humano baixo, tempo de espera alto). M1 sobe de 8 para 13 e M11 cai de 8 para 5 porque a criptografia de campo e os índices cegos são decisão de schema, não de endurecimento, e por isso nascem na migration inicial.

Trilha externa, executada por um humano com poder de assinar contrato e comprovar identidade da empresa:

ID Título Objetivo Depende de Espera típica
X0 Conta Meta Business Business Manager criado, com domínio verificado. horas
X1 Verificação de negócio Empresa verificada na Meta com documentos oficiais. X0 2 a 15 dias
X2 WABA e número WhatsApp Business Account criada, número dedicado registrado e nome de exibição aprovado. X1 1 a 5 dias
X3 Submissão de templates Os oito templates da Seção 17 submetidos nas categorias corretas. X2 minutos
X4 Aprovação dos templates Templates com status APPROVED na Meta. X3 1 minuto a 3 dias por template
Y0 Conta Asaas sandbox Chave de sandbox emitida e webhook apontado para o ambiente de desenvolvimento. horas
Y1 Conta Asaas produção Conta aprovada, chave de produção emitida, webhook de produção configurado. Y0 1 a 5 dias
Z0 Contas de apoio Provedor de TTS, storage S3-compatível, e-mail transacional, DNS e servidor. horas

28.3 Diagrama de dependências #

TRILHA EXTERNA (humano, inicia no dia 1, não bloqueia M0..M5)

  X0 conta Meta ──► X1 verificação ──► X2 WABA + número ──► X3 submissão ──► X4 aprovação
                                                                                  │
  Y0 Asaas sandbox ──────────────────────────► Y1 Asaas produção                  │
                     │                              │                             │
  Z0 TTS + storage + e-mail + DNS + servidor        │                             │
                     │                              │                             │
                     ▼                              ▼                             ▼
TRILHA DE CONSTRUÇÃO (agente executor)

              ┌──────┐
              │  M0  │ fundação do repositório
              └───┬──┘
                  │
              ┌───▼──┐
              │  M1  │ banco e schema  ◄── congela o modelo de dados,
              └───┬──┘                     já com criptografia de campo
                  │                        e índices cegos (Seção 22.5)
        ┌─────────┴─────────┐
    ┌───▼──┐            ┌───▼──┐
    │  M2  │ auth       │  M4  │ admin + editorial
    └───┬──┘            └───┬──┘
        │                   │
    ┌───▼──┐            ┌───▼──┐
    │  M3  │ cadastro   │  M5  │ pipeline de áudio
    └───┬──┘            └───┬──┘
        │                   │
        └─────────┬─────────┘
                  │            (X4 exigido para o caminho real;
              ┌───▼──┐          simulador cobre o desenvolvimento)
              │  M6  │ WhatsApp + motor de envio
              └───┬──┘
        ┌─────────┴─────────┐
    ┌───▼──┐            ┌───▼───┐
    │  M7  │ pagamentos │  M10  │ métricas e alertas
    └───┬──┘            └───┬───┘
        │                   │
    ┌───▼──┐                │
    │  M8  │ painel         │
    └───┬──┘                │
        │                   │
    ┌───▼──┐                │
    │  M9  │ landing        │
    └───┬──┘                │
        └─────────┬─────────┘
              ┌───▼───┐
              │  M11  │ segurança e LGPD
              └───┬───┘
              ┌───▼───┐
              │  M12  │ E2E e carga
              └───┬───┘
              ┌───▼───┐
              │  M13  │ produção e lançamento
              └───────┘

28.4 Milestones detalhados #

28.4.1 M0 — Fundação do repositório e ferramentas #

Objetivo. Ter um monorepo instalável, tipado, lintado, formatado, conteinerizado e com integração contínua verde antes de escrever qualquer regra de negócio.

Pré-requisitos. Nenhum milestone anterior. Requer apenas o runtime, o gerenciador de pacotes e o Docker instalados nas linhas de versão declaradas na Seção 4.

Tarefas.

  1. Criar o repositório Git com branch padrão main, .gitignore, .gitattributes e LICENSE.
  2. Inicializar o workspace do gerenciador de pacotes com pnpm-workspace.yaml cobrindo apps/* e packages/*.
  3. Criar a árvore de diretórios exata da Seção 4: apps/web, apps/worker, apps/ops, packages/db, packages/core, packages/integrations.
  4. Configurar TypeScript com um tsconfig.base.json na raiz em modo estrito e tsconfig.json por pacote com project references.
  5. Configurar ESLint e Prettier exatamente como especificado na Seção 5, incluindo a regra que proíbe comentários TODO e FIXME no código versionado.
  6. Instalar e configurar o executor de testes unitários da Seção 4, com um teste trivial em packages/core só para provar que a suíte roda.
  7. Criar packages/core/src/env.ts com o schema de validação de ambiente da Seção 26, ainda com poucas variáveis, e fazer apps/web e apps/worker falharem o boot quando o ambiente for inválido.
  8. Criar docker-compose.yml de desenvolvimento com os serviços de banco e de fila, volumes nomeados e healthchecks.
  9. Criar .env.example completo conforme a Seção 26 e um scripts/bootstrap.sh que copia o exemplo, sobe os contêineres e espera os healthchecks.
  10. Criar README.md com pré-requisitos, comandos de desenvolvimento e a estrutura do monorepo.
  11. Criar DECISIONS.md vazio, com o cabeçalho e o formato de registro definido na Seção 30.4.
  12. Criar o workflow de integração contínua descrito na Seção 25 com os jobs de lint, typecheck, teste e build.
  13. Configurar o padrão de commits e o hook de verificação de mensagem conforme a Seção 5.

Artefatos. pnpm-workspace.yaml, tsconfig.base.json, eslint.config.js, prettier.config.js, docker-compose.yml, .env.example, README.md, DECISIONS.md, scripts/bootstrap.sh, .github/workflows/ci.yml, árvore vazia de apps/ e packages/.

Critérios de saída.

pnpm install --frozen-lockfile        # instala sem erro
pnpm -r typecheck                     # zero erro de tipo em todos os pacotes
pnpm -r lint                          # zero aviso, zero erro
pnpm -r test                          # suíte trivial passa
pnpm -r build                         # build de todos os pacotes conclui
docker compose up -d && docker compose ps --format json | grep -c healthy   # todos healthy
bash scripts/bootstrap.sh             # termina com código 0 em máquina limpa

A execução do workflow de CI no commit correspondente deve terminar com sucesso.

Não se faz neste milestone. Nenhuma tabela, nenhuma rota de API, nenhuma tela, nenhuma integração externa, nenhum job de fila. Nada de Dockerfile de produção — ele pertence a M13.

28.4.2 M1 — Banco e schema #

Objetivo. Materializar integralmente o modelo de dados da Seção 6, com migrations reversíveis, seeds determinísticos, criptografia de campo já embutida e um client tipado consumível pelos três aplicativos.

Pré-requisitos. M0 concluído.

Tarefas.

  1. Escrever packages/db/prisma/schema.prisma com todos os models, enums, mapeamentos, índices e relações exatamente como a Seção 6 define. Nenhuma tabela fora da lista fechada daquela seção.
  2. Gerar a migration inicial e conferir o SQL produzido contra o DDL de referência da Seção 6, corrigindo divergências de tipo, nullability, default e ON DELETE.
  3. Adicionar as constraints CHECK que o gerador não emite, em migration manual complementar.
  4. Implementar o particionamento mensal de message_logs e a função de criação automática de partição futura, conforme a Seção 6.
  5. Criar packages/db/src/client.ts com o singleton do client, incluindo logging de query lenta ligado ao logger da Seção 23.
  6. Implementar o gerador de identificadores ULID em packages/core/src/id.ts e os prefixos textuais de API definidos na Seção 6.
  7. Escrever os seeds obrigatórios: planos, administrador inicial, definições dos oito templates do WhatsApp com os components idênticos aos da Seção 17.5, as 45 chaves de settings no formato grupo.chave e as feature flags, com os valores concretos da Seção 6.32.
  8. Implementar a criptografia de campo em envelope e os índices cegos determinísticos da Seção 22.5 já na migration inicial, e não em um marco posterior: subscribers.phone_e164 / phone_hmac, subscribers.wa_id / wa_id_hmac, subscribers.email / email_hmac e subscriber_profiles.cpf / cpf_hmac, com os módulos packages/core/src/crypto/field-encryption.ts e packages/core/src/crypto/blind-index.ts. As colunas cifradas não recebem CHECK de formato de conteúdo — o banco não vê o valor; a validação de E.164 fica no Zod da borda (Seção 5.6).
  9. Escrever o repositório de assinantes de modo que toda busca por telefone, wa_id, CPF ou e-mail passe pela coluna de índice cego. Nenhuma consulta compara a coluna cifrada. Uma regra de lint reprova WHERE phone_e164 =, WHERE wa_id = e equivalentes.
  10. Escrever factories e fixtures de teste em packages/db/src/testing/ conforme a Seção 24.
  11. Escrever os testes de integração que sobem o banco em contêiner, aplicam as migrations, rodam os seeds e verificam índices e constraints, incluindo I-37 e I-41 da Seção 24.5.3.
  12. Implementar em apps/ops os comandos db:seed, db:reset e db:check, este último cobrindo também a verificação de privilégio de tabela da Seção 25.11.4.
  13. Documentar em packages/db/README.md a ordem das migrations e o procedimento de migration destrutiva em duas fases descrito na Seção 25.

Artefatos. packages/db/prisma/schema.prisma, packages/db/prisma/migrations/**, packages/db/src/client.ts, packages/db/src/seed.ts, packages/db/src/testing/**, packages/core/src/id.ts, packages/core/src/crypto/field-encryption.ts, packages/core/src/crypto/blind-index.ts, comandos de apps/ops.

Critérios de saída.

pnpm --filter @app/db migrate:deploy                  # aplica todas as migrations do zero
pnpm --filter @app/db seed                            # idempotente: rodar duas vezes não duplica
pnpm --filter @app/db test                            # testes de schema passam
pnpm --filter @app/db test:encryption                 # grava e relê um assinante
pnpm --filter @app/ops start db:check                 # confere as 28 tabelas, índices e FKs
pnpm --filter @app/db exec prisma migrate diff --from-migrations --to-schema-datamodel --exit-code

O comando test:encryption é bloqueante e verificável sem interpretação: ele grava um assinante com telefone conhecido, lê a linha crua no banco e falha se phone_e164 contiver o telefone em claro; falha se phone_hmac não tiver 64 caracteres hexadecimais; e falha se a busca por phone_hmac não encontrar a linha. O último comando deve sair com código 0, provando que não há deriva entre schema e migrations.

Não se faz neste milestone. Nenhuma lógica de negócio, nenhuma rota, nenhum job. Não se escreve resolveEntitlements ainda — apenas as colunas que ele lê. A criptografia entra aqui só como schema e repositório: os controles de cabeçalho, política de conteúdo, retenção e direitos do titular continuam sendo de M11.

28.4.3 M2 — Autenticação e sessões #

Objetivo. Permitir que um assinante entre com código de acesso e que um administrador entre com senha e segundo fator, com sessões emitidas, rotacionadas e revogáveis.

Pré-requisitos. M1 concluído. O envio real do código por WhatsApp ainda não existe: em M2 o código é entregue pelo simulador local descrito na Seção 24 e devolvido exclusivamente pela rota interna de teste GET /api/internal/test/otp, protegida pelo perfil de compilação da Seção 24.7.1. O código nunca é registrado em log — a proibição da regra 12 da Seção 30.3 vale também em desenvolvimento, porque um logger.debug acrescentado para depurar sobrevive ao commit e o teste secrets-never-logged.test.ts da Seção 24.4.2.1 reprova o build.

Tarefas.

  1. Implementar o envelope de resposta, o catálogo de erros e o requestId da Seção 7 em apps/web/src/lib/http/.
  2. Implementar o middleware de rate limiting por token bucket em Redis, com os headers de resposta especificados na Seção 7.
  3. Implementar a normalização de telefone brasileiro em packages/core/src/phone.ts, com a busca em cascata da Seção 11, e cobrir com a tabela extensa de casos da Seção 24.
  4. Implementar geração, hash, verificação, expiração e invalidação de códigos de acesso conforme a Seção 8, persistindo em otp_codes.
  5. Implementar a emissão e verificação do token de sessão com assinatura EdDSA, o cookie de sessão com as flags da Seção 8, e a persistência em sessions.
  6. Implementar rotação de sessão, revogação individual e "sair de todos os dispositivos".
  7. Implementar o login administrativo: verificação de senha com o algoritmo e os parâmetros de custo da Seção 8, segundo fator obrigatório, códigos de recuperação, bloqueio após tentativas e desbloqueio.
  8. Implementar as guardas de autorização por papel e a verificação de propriedade de recurso, com a distinção entre 403 e 404 definida na Seção 8.
  9. Implementar o fluxo de impersonação de assinante pelo administrador, com registro obrigatório em admin_audit_log.
  10. Implementar as rotas de autenticação listadas no inventário da Seção 7.
  11. Implementar o fallback de acesso por link mágico enviado por e-mail, para assinantes com e-mail verificado.
  12. Escrever os testes de integração de todos os caminhos de sucesso e de todos os erros catalogados.

Artefatos. apps/web/src/lib/http/**, apps/web/src/lib/auth/**, packages/core/src/phone.ts, packages/core/src/otp.ts, rotas em apps/web/src/app/api/auth/**, testes correspondentes.

Critérios de saída.

pnpm --filter @app/web test:integration -- auth
curl -s -X POST localhost:3000/api/auth/otp/request -d '{"phone":"11987654321"}' | jq '.data.expiresAt'
curl -s -X POST localhost:3000/api/auth/otp/verify  -d '{"phone":"...","code":"000000"}' | jq '.error.code'

A verificação com código inválido deve retornar OTP_INVALID com status 401; código expirado devolve OTP_EXPIRED com status 410, e nunca 401, para que o cliente possa habilitar o botão de reenvio sem ambiguidade. Seis tentativas seguidas devem retornar OTP_ATTEMPTS_EXCEEDED. Quatro solicitações na mesma hora devem retornar o erro de limite com status 429 e os headers de limite preenchidos, com resposta idêntica para número cadastrado e não cadastrado.

Não se faz neste milestone. Nenhuma tela de login com acabamento visual — apenas o formulário funcional mínimo. Nenhuma mensagem real de WhatsApp. Nenhum cadastro novo: M2 autentica quem já existe no banco por seed.

28.4.4 M3 — Cadastro, opt-in e onboarding #

Objetivo. Transformar um visitante em assinante FREE com consentimento registrado, telefone verificado e opt-in confirmado, sem o qual nenhum devocional pode ser enviado.

Pré-requisitos. M2 concluído.

Tarefas.

  1. Implementar o formulário de cadastro com as validações campo a campo e as mensagens em português definidas na Seção 11.
  2. Implementar a rota de criação de assinante, com a rejeição de DDI diferente de 55 e as mensagens de erro correspondentes.
  3. Implementar o registro imutável de consentimento em consent_events, com o texto versionado, IP, agente de usuário, canal e timestamp.
  4. Implementar a máquina de estados do assinante da Seção 11, com a tabela de transições permitidas aplicada no serviço, não no controlador.
  5. Implementar o tratamento dos casos de borda da Seção 11: número já ativo, número com opt-out, número excluído, código expirado, abandono no meio do fluxo, corrida entre duas abas.
  6. Implementar a etapa de confirmação ativa de opt-in, com o registro de opt_in_confirmed_at, ainda acionada pelo simulador local.
  7. Implementar a regra de welcome_backfill: quem confirma depois do horário de envio recebe o devocional do dia uma única vez.
  8. Implementar resolveEntitlements em packages/core/src/entitlements.ts como fonte única da verdade, conforme a Seção 13, e proibir qualquer recálculo local por regra de lint.
  9. Implementar as telas do fluxo de cadastro com os textos de interface da Seção 10.
  10. Escrever os testes unitários de resolveEntitlements e os testes de integração do fluxo completo.

Artefatos. apps/web/src/app/(public)/cadastro/**, apps/web/src/app/api/signups/**, packages/core/src/entitlements.ts, packages/core/src/subscriber-state.ts, testes.

Critérios de saída.

pnpm --filter @app/web test:integration -- signup
pnpm --filter @app/core test -- entitlements
psql "$DATABASE_URL" -c "select count(*) from consent_events where subscriber_id = :id"  -- >= 1

Um cadastro completo pelo simulador deve terminar com subscribers.tier = 'FREE', opt_in_confirmed_at preenchido e exatamente um registro em consent_events. Repetir o mesmo telefone deve retornar SUBSCRIBER_ALREADY_EXISTS com status 409, sem criar segundo registro.

Não se faz neste milestone. Nenhum envio de devocional. Nenhum checkout. A landing ainda não existe: o formulário é acessado por rota direta.

28.4.5 M4 — Painel administrativo e conteúdo editorial #

Objetivo. Dar ao editor humano a ferramenta completa para escrever, versionar, agendar e publicar devocionais dentro dos limites reais da plataforma de mensagens.

Pré-requisitos. M2 concluído. Não depende de M3.

Tarefas.

  1. Implementar o layout do painel administrativo, a navegação e as guardas de papel conforme a Seção 15.
  2. Implementar o editor de devocional com todos os campos da Seção 15 e os contadores de caracteres com os limites reais.
  3. Implementar a geração determinística do teaser a partir do texto, com truncamento e sanitização que garantem ausência de quebra de linha, de tabulação e de quatro ou mais espaços consecutivos, e no máximo 300 caracteres.
  4. Implementar a pré-visualização fiel de como a mensagem aparece no aplicativo de mensagens, incluindo cabeçalho, corpo, rodapé e botões.
  5. Implementar a máquina de estados editorial com as guardas da Seção 15, incluindo a que impede publicar sem áudio pronto quando existirem assinantes pagos.
  6. Implementar o versionamento por revisão em devotional_revisions, com comparação e restauração.
  7. Implementar o calendário editorial mensal, com reagendamento, duplicação, indicação de dias vazios e alerta de menos de sete dias de conteúdo agendado.
  8. Implementar a importação em lote por arquivo CSV, com validação linha a linha e relatório de erros.
  9. Implementar a gestão de assinantes: busca, ficha completa, histórico de mensagens e de pagamentos, e as ações administrativas da Seção 15, todas com auditoria obrigatória.
  10. Implementar a tela de auditoria com filtros e exportação.
  11. Implementar a marcação de devocional de reserva (evergreen) usada pela regra de prontidão da Seção 18.
  12. Escrever os testes de integração das transições editoriais e os testes unitários da sanitização do teaser.

Artefatos. apps/web/src/app/admin/**, apps/web/src/app/api/admin/**, packages/core/src/teaser.ts, packages/core/src/devotional-state.ts, testes.

Critérios de saída.

pnpm --filter @app/web test:integration -- admin
pnpm --filter @app/core test -- teaser
pnpm --filter @app/web test:e2e -- admin-publish   # cenário: criar, revisar, agendar, publicar

O teste de propriedade do teaser deve provar, sobre entradas aleatórias, que a saída nunca contém \n, \t nem quatro espaços seguidos, e nunca excede 300 caracteres. Tentar publicar um devocional sem áudio com assinantes pagos existentes deve retornar DEVOTIONAL_AUDIO_REQUIRED com status 409.

Não se faz neste milestone. Nenhuma geração de áudio de verdade — o campo de áudio fica visível e vazio. Nenhum envio. Nenhuma métrica.

28.4.6 M5 — Pipeline de áudio #

Objetivo. Converter um devocional aprovado em áudio narrado de qualidade, nos formatos exigidos, armazenado com segurança e pronto para envio.

Pré-requisitos. M4 concluído. Requer Z0 para credenciais reais de síntese de voz e de armazenamento; sem elas, o pipeline roda contra o simulador local.

Tarefas.

  1. Implementar buildNarrationScript() em packages/core/src/narration.ts, com a remoção de marcação, a leitura por extenso das referências bíblicas e as regras de expansão da Seção 16.
  2. Implementar a interface TtsProvider em packages/integrations/src/tts/ e o adaptador do provedor primário com os parâmetros de voz exatos da Seção 16.
  3. Implementar o adaptador do provedor de fallback e a regra de acionamento após três falhas, registrando o provedor efetivamente usado.
  4. Implementar a transcodificação com os comandos exatos da Seção 16, produzindo o formato de mensagem de voz e o formato do player web, com normalização de volume e verificação de duração.
  5. Implementar a regra de tamanho: alvo prático de duração, limite duro de mídia, reencode automático acima do limiar intermediário e o comportamento se ainda assim exceder.
  6. Implementar a geração do arquivo de vídeo usado no caminho de fallback do quarto dia sem interação.
  7. Implementar o cliente de armazenamento com a convenção de chaves da Seção 16, bucket privado e URLs assinadas de curta duração para o player web.
  8. Implementar o job tts.generate com concorrência, tentativas, backoff e timeout conforme a Seção 18. Não existe fila de mensagens mortas: job que esgota as tentativas fica no estado failed da própria fila e grava job_runs com status = 'DEAD'.
  9. Implementar a invalidação e regeneração de áudio quando o texto muda depois de o áudio ficar pronto, com retorno do devocional ao estado pendente de áudio.
  10. Implementar as verificações automáticas de qualidade: duração mínima e máxima, detecção de áudio silencioso e detecção de truncamento.
  11. Implementar o player de áudio do painel administrativo para revisão antes da publicação.
  12. Escrever os testes do roteiro de narração, incluindo a tabela de expansão de abreviações e números.

Artefatos. packages/core/src/narration.ts, packages/integrations/src/tts/**, packages/integrations/src/storage/**, apps/worker/src/jobs/tts-generate.ts, apps/worker/src/media/ffmpeg.ts, testes.

Critérios de saída.

pnpm --filter @app/core test -- narration
pnpm --filter @app/worker test:integration -- tts
pnpm --filter @app/ops start audio:generate --devotional <id>
ffprobe -v error -show_entries stream=codec_name,channels,sample_rate -of json <arquivo.ogg>

O ffprobe deve reportar o codec, a contagem de canais e a taxa de amostragem exigidos pela Seção 16. O arquivo gerado deve ficar abaixo do limite duro de mídia. Derrubar o provedor primário no simulador deve resultar em audio_assets.provider gravado com o fallback, sem intervenção manual.

Não se faz neste milestone. Nenhum upload para a plataforma de mensagens — isso pertence a M6. Nenhuma narração humana. Nenhuma geração de texto por inteligência artificial.

28.4.7 M6 — Integração WhatsApp e motor de envio #

Objetivo. Entregar o devocional do dia a todos os destinatários elegíveis, dentro das regras da plataforma, de forma idempotente, observável e reprocessável.

Pré-requisitos. M3 e M5 concluídos. Para o caminho real, exige X4 (templates aprovados). Enquanto X4 não chega, todo o milestone é desenvolvido e testado contra o simulador local.

Tarefas.

  1. Implementar a camada WhatsAppProvider em packages/integrations/src/whatsapp/ com os métodos de envio de template, texto livre, áudio, vídeo e resposta interativa.
  2. Implementar o upload de mídia com a regra dura de uma única vez por devocional, o registro do identificador de mídia, o controle de validade e o reupload automático quando expirado.
  3. Implementar a verificação do webhook de entrada e a validação da assinatura do corpo cru em tempo constante, conforme a Seção 17.
  4. Implementar a persistência bruta do evento de entrada e o processamento assíncrono em fila, com resposta imediata ao emissor.
  5. Implementar a abertura da janela de atendimento a partir de qualquer mensagem recebida, atualizando o campo de expiração da janela e disparando a entrega completa em segundos.
  6. Implementar o job de planejamento do lote com a montagem da coorte, as exclusões e a idempotência por chave, gravando em send_batches e delivery_attempts.
  7. Implementar a regra de prontidão do conteúdo com o alerta ao operador e o mecanismo de reserva completo definido na Seção 18.
  8. Implementar o job de disparo com o limitador de taxa, as tentativas, o backoff e a classificação de erro por código da plataforma, e a revalidação obrigatória do assinante imediatamente antes de cada chamada ao provedor: relê deleted_at, blocked_at, opt_out_at e tier por chave primária e aplica a regra da Seção 18.5. tier_at_send é registro histórico, nunca autoridade; sem essa releitura, o congelamento das 05:40 concede na prática um dia de carência a todo inadimplente.
  9. Implementar o atalho de janela aberta, que pula o convite por template e entrega o pacote completo direto.
  10. Implementar a fila de entrega pendente, o prazo de expiração no mesmo dia e o contador de dias sem interação.
  11. Implementar o fallback por template com cabeçalho de vídeo no quarto dia sem interação.
  12. Implementar o catálogo de mensagens e o roteador de palavras-chave de entrada da Seção 19, incluindo saída, retorno, pausa e reenvio.
  13. Implementar a persistência dos estados de mensagem em message_logs com timestamps próprios.
  14. Implementar a tela de envios do painel administrativo: progresso ao vivo, contagem por status, falhas com motivo, reprocessar e cancelar lote.
  15. Implementar os comandos de backfill e reprocessamento em apps/ops.
  16. Escrever os testes do motor de envio com relógio controlado, sem enviar nenhuma mensagem real.

Artefatos. packages/integrations/src/whatsapp/**, apps/worker/src/jobs/send-plan.ts, send-dispatch.ts, send-followup.ts, apps/web/src/app/api/webhooks/whatsapp/route.ts, apps/web/src/app/admin/envios/**, packages/core/src/messages/**, testes.

Critérios de saída.

pnpm --filter @app/worker test:integration -- send-engine
pnpm --filter @app/ops start send:plan --date 2026-09-06 --dry-run
pnpm --filter @app/ops start send:replay --batch <id> --dry-run
psql "$DATABASE_URL" -c "select count(*) from delivery_attempts where subscriber_id=:s and devotional_date=:d"

A última consulta deve retornar exatamente 1 mesmo depois de reexecutar o planejamento três vezes. O teste de lote sintético com 3.000 destinatários deve concluir dentro da janela de capacidade declarada na Seção 18.14, do ponto de vista de tempo de API simulado. Uma mensagem de entrada com assinatura inválida deve ser rejeitada com status 401 e não gerar registro em inbound_messages.

Não se faz neste milestone. Nenhuma cobrança. Nenhum assinante pago real — o tier PAID é exercitado por concessão manual de acesso pelo administrador, recurso já disponível desde M4.

28.4.8 M7 — Pagamentos Asaas e ciclo de assinatura #

Objetivo. Vender a assinatura, cobrar por ciclo, refletir cada evento de pagamento no acesso do assinante e revogar o acesso pago imediatamente quando o pagamento falha.

Pré-requisitos. M6 concluído e Y0 disponível. O caminho de produção exige Y1.

Tarefas.

  1. Implementar o cliente tipado do provedor de pagamento em packages/integrations/src/asaas/, com autenticação por header, timeouts, política de tentativas com backoff e jitter, e disjuntor conforme a Seção 27.
  2. Implementar a validação de CPF com o algoritmo de dígitos verificadores e as mensagens de erro da Seção 12.
  3. Implementar a criação e a sincronização do cliente no provedor, mapeando para as nossas tabelas conforme a Seção 12.
  4. Implementar o checkout com cartão, com tokenização, sem persistir nem registrar em log qualquer dado sensível do cartão.
  5. Implementar o checkout com PIX, com código copia-e-cola, imagem do código e expiração.
  6. Implementar o endpoint de webhook do provedor com comparação em tempo constante do token, persistência bruta em payment_events, deduplicação por identificador de evento e resposta rápida.
  7. Implementar o processador assíncrono e idempotente dos doze eventos obrigatórios listados na Seção 12, com o efeito exato de cada um sobre o estado da assinatura e sobre o tier.
  8. Implementar a regra dura de revogação imediata: os eventos de falha rebaixam o assinante na mesma transação, sem carência.
  9. Implementar o cancelamento voluntário com manutenção do acesso até o fim do período já pago, distinto da revogação por inadimplência.
  10. Implementar os lembretes de cobrança antes do vencimento, com os textos e canais da Seção 19.
  11. Implementar a reconciliação diária, o relatório de divergências e a regra de que o provedor de pagamento vence em caso de conflito.
  12. Implementar a prevenção de duas assinaturas ativas para o mesmo assinante e o procedimento de correção se acontecer.
  13. Implementar reembolso e contestação com efeito imediato no acesso e comunicação ao assinante.
  14. Escrever os testes de contrato contra o simulador, cobrindo evento duplicado, evento fora de ordem e pagamento confirmado após cancelamento.

Artefatos. packages/integrations/src/asaas/**, apps/web/src/app/api/webhooks/asaas/route.ts, apps/web/src/app/(public)/checkout/**, apps/worker/src/jobs/billing-webhook.ts, billing-reconcile.ts, packages/core/src/subscription-state.ts, testes.

Critérios de saída.

pnpm --filter @app/web test:integration -- billing
pnpm --filter @app/worker test:integration -- reconcile
pnpm --filter @app/ops start billing:simulate --event PAYMENT_OVERDUE --subscription <id>
psql "$DATABASE_URL" -c "select s.status, sub.tier from subscriptions s join subscribers sub on ... "

Após simular o evento de inadimplência, a consulta deve mostrar a assinatura expirada e o tier rebaixado, no mesmo instante, sem job intermediário. Reenviar o mesmo evento cinco vezes deve produzir exatamente um registro processado e nenhuma mudança adicional de estado. O tempo de resposta do endpoint de webhook deve ficar abaixo do limite declarado na Seção 9.12, medido no teste de integração.

Não se faz neste milestone. Nenhum cupom, nenhum boleto, nenhuma nota fiscal, nenhum prorrateio, nenhum split — todos fora de escopo pela Seção 2.

28.4.9 M8 — Painel do assinante #

Objetivo. Dar ao assinante autonomia completa sobre conteúdo, assinatura, preferências e dados, sem depender de atendimento humano.

Pré-requisitos. M7 concluído.

Tarefas.

  1. Implementar o layout, a navegação e os estados vazio, de carregamento e de erro das rotas do painel definidas na Seção 14.
  2. Implementar a tela do devocional do dia, com o player de áudio restrito ao tier pago e o aviso de áudio em geração.
  3. Implementar o reenvio manual pelo painel respeitando o limite diário do tier.
  4. Implementar o acervo com paginação por cursor, limite por tier, busca por texto e por data, e download de áudio por URL assinada de curta duração.
  5. Implementar a tela de assinatura com plano, valor, forma de pagamento, próxima cobrança, histórico de pagamentos, upgrade e cancelamento em duas etapas com pesquisa de motivo.
  6. Implementar a tela de perfil, incluindo a troca de número com re-verificação completa e invalidação de sessões.
  7. Implementar a tela de preferências com pausa temporária de 1 a 30 dias, reativação e opt-out, deixando explícita a distinção entre parar mensagens e cancelar a cobrança.
  8. Implementar a tela de dados pessoais com exportação em JSON, solicitação de exclusão confirmada por código e histórico de consentimentos.
  9. Implementar a revalidação periódica do estado de servidor no cliente, sem canal persistente, nos intervalos definidos na Seção 14.
  10. Escrever os testes E2E de acervo, reenvio, pausa, opt-out e cancelamento.

Artefatos. apps/web/src/app/app/**, apps/web/src/app/api/me/**, componentes de player e de acervo, testes E2E.

Critérios de saída.

pnpm --filter @app/web test:e2e -- subscriber-panel
curl -s -H "Cookie: __Host-session=<free>" localhost:3000/api/devotionals | jq '.data | length'

Um assinante do tier gratuito deve enxergar apenas os últimos sete dias de acervo e receber FEATURE_NOT_AVAILABLE com status 403 ao pedir a URL de áudio. O segundo reenvio no mesmo dia por um assinante gratuito deve retornar RESEND_LIMIT_REACHED com status 429.

Não se faz neste milestone. Nenhuma alteração no motor de envio. Nenhuma tela pública de marketing.

28.4.10 M9 — Landing page e página de vendas #

Objetivo. Converter visitante em assinante, com amostra real de áudio, comparativo de planos derivado da matriz de entitlements e desempenho dentro do orçamento declarado.

Pré-requisitos. M8 concluído. O produto precisa existir antes de ser vendido.

Tarefas.

  1. Implementar as rotas públicas da Seção 9 com os textos aprovados da Seção 10.
  2. Implementar os blocos da home na ordem especificada, com os dados dinâmicos que cada um consome.
  3. Renderizar o comparativo de planos a partir da matriz de entitlements da Seção 13, sem duplicar regras no componente.
  4. Implementar o player de amostra com o devocional público de demonstração, incluindo estados de carregamento, erro e acessibilidade.
  5. Implementar o formulário de captura e o encaminhamento para o fluxo de cadastro de M3.
  6. Implementar os metadados por rota, os dados estruturados, o mapa do site e o arquivo de robôs conforme a Seção 9.
  7. Implementar o consentimento de cookies e o comportamento sem consentimento definido na Seção 9.
  8. Implementar os eventos de conversão listados na Seção 9 e verificados pela Seção 21.
  9. Implementar as páginas de erro e a página de manutenção.
  10. Implementar as páginas legais com a estrutura obrigatória da Seção 22.
  11. Ajustar imagens, fontes e estratégia de renderização até o orçamento de performance da Seção 9 ser cumprido.
  12. Rodar auditoria de acessibilidade e corrigir todas as violações de nível AA.

Artefatos. apps/web/src/app/(public)/**, apps/web/src/components/marketing/**, apps/web/public/robots.txt, sitemap.xml, páginas legais.

Critérios de saída.

pnpm --filter @app/web test:e2e -- landing
pnpm --filter @app/web exec playwright test tests/a11y --grep "wcag"
pnpm --filter @app/web exec lhci autorun --collect.url=https://staging.<dominio>/

A auditoria de acessibilidade automatizada deve retornar zero violação de nível AA. A auditoria de performance deve cumprir as metas de carregamento declaradas na Seção 9.12. Alterar a matriz de entitlements deve alterar o comparativo de planos sem edição de componente — verificado por teste.

Não se faz neste milestone. Nenhum teste A/B, nenhuma campanha, nenhum cupom.

28.4.11 M10 — Métricas, observabilidade e alertas #

Objetivo. Tornar o sistema legível para o operador: números de produto, sinais técnicos e alertas acionáveis antes de o assinante reclamar.

Pré-requisitos. M7 concluído. Pode ser feito em paralelo a M8 e M9 se houver mais de um executor.

Tarefas.

  1. Implementar o logger estruturado com os campos obrigatórios e a redação de dados sensíveis da Seção 23.
  2. Implementar a correlação de identificador de requisição entre aplicativo web, fila e worker.
  3. Implementar o endpoint de métricas técnicas e o catálogo completo de contadores, histogramas e medidores da Seção 23.
  4. Implementar os health checks de vivacidade e de prontidão com o comportamento em dependência degradada.
  5. Implementar o rastreamento distribuído com propagação de contexto entre web, fila e worker.
  6. Implementar o job de agregação diária que popula daily_metrics, com o horário, o reprocessamento histórico e o tratamento de fuso da Seção 21.
  7. Implementar as telas do dashboard administrativo com os gráficos e filtros definidos na Seção 21.
  8. Implementar as exportações em CSV e JSON com os limites definidos na Seção 21.
  9. Implementar os alertas da Seção 23, com condição, severidade, canal e destinatário, e ligar cada um ao runbook correspondente da Seção 27.
  10. Implementar os alertas de negócio derivados de métrica: custo diário acima do orçado, queda de conversão e pico de opt-out.
  11. Escrever os testes que provam que a agregação não conta duas vezes e que um dia recalculado substitui o anterior.

Artefatos. apps/worker/src/jobs/metrics-rollup.ts, apps/web/src/app/admin/metricas/**, packages/core/src/metrics/**, configuração de alertas, painéis de monitoramento.

Critérios de saída.

curl -s localhost:3001/api/internal/health | jq '.status'   # "ok" e nada mais
curl -s -H "Authorization: Bearer $METRICS_TOKEN" localhost:3001/api/internal/ready | jq '.checks'
curl -s -H "Authorization: Bearer $METRICS_TOKEN" localhost:3001/api/internal/metrics | grep -c '^app_'
pnpm --filter @app/ops start metrics:rollup --date 2026-09-06 --force

Reexecutar a agregação do mesmo dia duas vezes deve manter os valores idênticos. Um lote diário que não inicia até o limite definido na Seção 23 deve gerar alerta no canal configurado, verificado por teste de integração com relógio controlado.

Não se faz neste milestone. Nenhuma ferramenta de análise de produto de terceiros além da decidida na Seção 21. Nenhum plantão fora do horário definido na Seção 23.

28.4.12 M11 — Endurecimento de segurança e LGPD #

Objetivo. Fechar a superfície de ataque e implementar todos os direitos do titular com prazo e evidência. Todos os controles da Seção 22 exceto a criptografia de campo e os índices cegos, que foram entregues em M1.

Pré-requisitos. M10 concluído. Todas as funcionalidades já existem; aqui elas são endurecidas.

Tarefas.

  1. Aplicar os cabeçalhos de segurança com os valores exatos da Seção 22, incluindo a política de conteúdo completa para as origens realmente usadas — com a variante das páginas com formulário, que precisa liberar o verificador anti-bot em script-src, em frame-src e em connect-src, e com o domínio de mídia em connect-src, sem o que o cadastro não acontece e o áudio pago não toca.
  2. Conferir que a criptografia de campo e os índices cegos entregues em M1 continuam íntegros: rodar test:encryption e a varredura de information_schema do teste I-37. Nenhuma coluna é convertida aqui — não existe migração de dados em claro para cifrado, porque nunca houve dado em claro.
  3. Implementar a rotação das chaves de cifra e de índice cego em duas fases, com as chaves anteriores aceitas apenas em leitura durante a transição.
  4. Implementar a proteção anti-bot da landing e do cadastro na modalidade decidida na Seção 22, e acrescentar o destino da verificação à lista de destinos permitidos do cliente HTTP com proteção contra requisição forjada do servidor.
  5. Implementar a detecção de cadastro em massa e o bloqueio de faixas abusivas.
  6. Revisar e apertar todos os limites de taxa das rotas sensíveis conforme a Seção 7.
  7. Implementar a exportação de dados do titular no formato e no prazo da Seção 22, baixada de dentro do painel sob sessão plena, nunca por link portador enviado ao canal de mensagens.
  8. Implementar a anonimização com o script exato da Seção 22, cobrindo todas as tabelas que guardam identificador pessoal — inclusive os registros de mensagem —, e o job de retenção que a executa no prazo. Criar os papéis de banco nomeados e codificar as exceções dentro do gatilho de imutabilidade dos eventos de consentimento: um controle de integridade não pode impedir o cumprimento de um direito do titular, e nenhum gatilho é desabilitado em momento algum.
  9. Implementar as políticas de retenção por tabela e o job de limpeza correspondente.
  10. Publicar os termos de uso e a política de privacidade com todas as cláusulas obrigatórias, o encarregado de dados e o canal público.
  11. Implementar o registro e a consulta do histórico de consentimento pelo titular.
  12. Executar a varredura de dependências e a revisão de segredos, e rotacionar tudo que tiver sido usado em desenvolvimento.
  13. Percorrer o checklist de segurança pré-lançamento da Seção 22 item a item.

Artefatos. apps/web/src/middleware.ts, packages/core/src/crypto/**, apps/worker/src/jobs/maintenance-cleanup.ts, páginas legais atualizadas, relatório do checklist.

Critérios de saída.

pnpm audit --audit-level=high            # zero vulnerabilidade alta ou crítica
pnpm --filter @app/web test:integration -- security-headers
pnpm --filter @app/worker test:integration -- lgpd
pnpm --filter @app/ops start lgpd:export --subscriber <id> | jq 'keys'
pnpm --filter @app/ops start lgpd:anonymize --subscriber <id> --dry-run

A verificação de cabeçalhos deve provar que a política de conteúdo não usa diretiva permissiva de execução de script, e o cenário E2E de violações da Seção 24.7.3 deve fechar com zero violação no console em todas as rotas com formulário. A exportação deve conter todas as categorias inventariadas na Seção 22. A anonimização simulada deve listar exatamente as colunas previstas, nem mais nem menos, e o teste I-38 deve provar que ela conclui com o gatilho de imutabilidade ativo.

Não se faz neste milestone. Nenhuma funcionalidade nova. Nenhuma mudança de escopo.

28.4.13 M12 — Testes E2E e carga #

Objetivo. Provar, por execução automatizada, que o sistema inteiro cumpre os critérios da Seção 29 e sustenta o alvo de escala da Seção 18.14.

Pré-requisitos. M11 concluído.

Tarefas.

  1. Completar a suíte E2E com todos os cenários listados na Seção 24.
  2. Ligar a suíte E2E ao pipeline de integração contínua com ambiente efêmero.
  3. Implementar o gerador de assinantes sintéticos e proibir por configuração o uso de dados reais em ambiente de teste.
  4. Executar o teste de carga do lote diário nos três patamares de escala da Seção 24 e registrar os resultados.
  5. Executar o teste de carga das rotas de API e verificar o percentil de resposta declarado na Seção 9.12.
  6. Executar o teste de falha injetada de cada dependência externa e verificar a degradação graciosa da Seção 27.
  7. Executar o teste de restauração de backup e cronometrar o tempo de recuperação.
  8. Rodar o checklist de testes manuais obrigatórios da Seção 24 com número real, número de teste da plataforma de mensagens e conta de sandbox do provedor de pagamento.
  9. Corrigir toda falha encontrada e reexecutar a suíte completa.
  10. Preencher a matriz de rastreabilidade da Seção 29.23 com o resultado de cada critério.

Artefatos. tests/e2e/**, tests/load/**, relatório de carga, relatório de restauração, matriz de rastreabilidade preenchida.

Critérios de saída.

pnpm test:e2e                       # 100% dos cenários passam
pnpm test:load -- --profile 3000    # dentro da janela declarada na Seção 18.14
pnpm test:load -- --profile 50000   # conclui sem erro, com o tempo registrado
pnpm --filter @app/ops start backup:restore --to-scratch --verify

Todos os critérios marcados como bloqueadores na Seção 29 devem estar verdes. Nenhum critério pode ficar sem resultado registrado.

Não se faz neste milestone. Nenhuma correção de escopo disfarçada de correção de bug. Se um teste revelar funcionalidade faltante, registra-se a decisão e resolve-se dentro do milestone dono daquela funcionalidade.

28.4.14 M13 — Preparação de produção e lançamento #

Objetivo. Colocar o sistema em produção com deploy reversível, backup restaurável e operação documentada.

Pré-requisitos. M12 concluído, X4 com todos os templates aprovados e Y1 com a conta de produção do provedor de pagamento ativa.

Tarefas.

  1. Provisionar o servidor de produção conforme os requisitos da Seção 25 e aplicar o endurecimento do sistema operacional.
  2. Escrever os Dockerfile multi-estágio de produção do aplicativo web e do worker.
  3. Escrever o docker-compose.yml de produção e o arquivo de configuração do proxy reverso, com TLS automático.
  4. Configurar os subdomínios, os redirecionamentos e as regras de firewall da Seção 25.
  5. Configurar o backup automatizado com destino externo, criptografia e a retenção declarada na Seção 25, e executar uma restauração real de verificação.
  6. Configurar o pipeline de deploy com aprovação manual, verificação de saúde antes de trocar o tráfego e o comando exato de rollback.
  7. Injetar todos os segredos de produção pelo mecanismo definido na Seção 26, sem nenhum segredo em código nem em imagem.
  8. Apontar os webhooks de produção da plataforma de mensagens e do provedor de pagamento para os endereços definitivos e verificar a entrega.
  9. Rodar as migrations em produção com lock e conferir a ausência de deriva.
  10. Executar o seed de produção: planos, administrador inicial, definições de template e configurações de runtime.
  11. Executar um envio de validação para um grupo restrito de números internos, com o interruptor geral ligado, e conferir o pacote completo.
  12. Percorrer o checklist de go-live da Seção 28.9.
  13. Ligar o tráfego público e iniciar o acompanhamento dos primeiros 30 dias da Seção 28.10.

Artefatos. apps/web/Dockerfile, apps/worker/Dockerfile, docker-compose.prod.yml, Caddyfile, .github/workflows/deploy.yml, docs/runbooks/ com os runbooks da Seção 27, relatório de restauração de backup.

Critérios de saída.

curl -sI https://<dominio> | head -1                       # 200
curl -s  https://api.<dominio>/api/internal/health | jq '.status'   # "ok"
curl -s -H "Authorization: Bearer $METRICS_TOKEN" \
         https://api.<dominio>/api/internal/ready | jq '.status'    # "ok"
curl -s -o /dev/null -w '%{http_code}' https://api.<dominio>/api/internal/ready  # 404 sem token
docker compose -f docker-compose.prod.yml ps               # todos healthy
pnpm --filter @app/ops start backup:restore --verify       # restauração conclui e valida
pnpm --filter @app/ops start release:rollback --dry-run    # rollback simulado conclui

O envio de validação deve produzir um lote com 100% de sucesso para os números internos, com convite por template, texto completo e áudio entregues na ordem correta.

Não se faz neste milestone. Nenhuma funcionalidade nova. Nenhuma migration destrutiva. Nenhuma campanha de aquisição antes de o primeiro lote real fechar em verde.

28.5 Por que esta ordem #

Por que o motor de envio (M6) vem antes do pagamento (M7). Três razões concretas.

  1. O valor do produto está na entrega, não na cobrança. Um sistema que entrega devocional sem cobrar é um produto gratuito funcional. Um sistema que cobra sem entregar é uma fraude operacional. Construir a entrega primeiro garante que, se o projeto for interrompido em qualquer ponto, o que existe é utilizável.
  2. O risco técnico está concentrado na plataforma de mensagens. A janela de atendimento de 24 horas, a proibição de áudio no cabeçalho de template, o limite de caracteres em parâmetro e a classificação de categoria são restrições externas que moldam o produto inteiro (ver Seção 17). O provedor de pagamento é uma API REST convencional, com contrato estável e comportamento previsível. Atacar o risco alto primeiro evita descobrir tarde que o desenho não fecha.
  3. O tier pago pode ser exercitado sem cobrança real. A concessão manual de acesso pago pelo administrador, entregue em M4, permite testar todo o comportamento de entitlement pago em M6 sem um centavo transacionado. A dependência inversa não existe: não há como testar cobrança de forma útil sem ter o que entregar ao pagante.

Por que a landing (M9) vem depois do produto funcionar. A página de vendas promete comportamento concreto: horário fixo, áudio narrado, cancelamento pelo painel, encerramento imediato do acesso ao falhar o pagamento. Escrever a promessa antes de o comportamento existir produz promessa desalinhada, que depois vira retrabalho ou reclamação. Além disso, a landing consome três coisas que só existem depois: a amostra de áudio real (M5), o comparativo derivado da matriz de entitlements (M3 e M7) e o encaminhamento para o checkout (M7). Por fim, a landing é o item de menor risco técnico do projeto — deixá-la por último é deslocar o trabalho fácil para o fim, quando a energia de resolução de problema já foi gasta onde importava.

Por que segurança e LGPD (M11) vêm depois e não antes. Os controles de M11 são transversais: cabeçalhos, retenção, anonimização, exportação. Aplicá-los sobre um sistema incompleto significa reaplicá-los a cada funcionalidade nova. Isso não é postergar segurança: as práticas seguras de base (validação na borda, ORM parametrizado, segredo fora do código, hash de senha correto, verificação de assinatura de webhook) são obrigatórias desde M0 e estão escritas nas regras invioláveis da Seção 30.3. M11 é o endurecimento e a conformidade formal, não a introdução do primeiro controle.

Por que a criptografia de campo é exceção e vive em M1. Criptografia de campo e índice cego são decisões de schema, não de endurecimento: elas trocam o tipo e o índice de subscribers.phone_e164, criam phone_hmac, wa_id_hmac, cpf_hmac e email_hmac, e mudam a forma de toda busca por identidade. Adiá-las para M11 obrigaria a reescrever toda consulta por telefone escrita entre M2 e M10, além da normalização de telefone da Seção 11.4 e do roteamento de webhook da Seção 17.8. Por isso elas nascem na migration inicial, com critério de saída verificável em M1, e M11 apenas confere que continuam íntegras.

Por que a trilha externa começa no dia 1. Verificação de negócio e aprovação de template não têm caminho rápido. Se a submissão de templates esperar M6, o projeto para por dias no momento de maior custo de espera. Iniciando X0 junto com M0, a aprovação chega enquanto M1 a M5 são construídos, e M6 encontra o caminho livre.

28.6 Paralelização com mais de um executor #

Com um único executor, a ordem é estritamente M0 → M1 → M2 → M3 → M4 → M5 → M6 → M7 → M8 → M9 → M10 → M11 → M12 → M13.

Com dois executores, o ganho real é este:

Executor A:  M0 ─ M1 ─ M2 ─ M3 ──────── M6 ─ M7 ─ M8 ─── M11 ─ M12 ─ M13
Executor B:            └─ M4 ─ M5 ──────┘      └─ M9 ─ M10 ┘
  • M4 e M5 só dependem de M1 e M2 e não tocam nada de M3. São o par mais seguro para paralelizar: o painel administrativo e o pipeline de áudio compartilham apenas o modelo devotionals, e o contrato entre eles é a transição de estado editorial.
  • M9 e M10 não se tocam: um é superfície pública, o outro é agregação e alertas. Ambos dependem de M7.
  • M8 e M10 também paralelizam, com atenção a daily_metrics, que o painel do assinante não lê.

Com três executores, acrescenta-se a extração de M9 para um terceiro fluxo, iniciando a estrutura da landing com dados falsos assim que o copy da Seção 10 estiver disponível, e substituindo os dados falsos por reais quando M7 fechar.

O que nunca deve ser paralelizado.

  • M1 com qualquer outro milestone. O schema é a fundação compartilhada; duas mãos nele produzem migrations conflitantes.
  • M6 com M7. Ambos escrevem em subscribers.tier e em delivery_attempts por caminhos diferentes, e a regra de revogação imediata precisa de um único autor.
  • M11 com qualquer coisa. O endurecimento assume um alvo estável.

Protocolo de coordenação entre executores. Cada executor trabalha em branch própria a partir de main. Migrations são serializadas: apenas o executor dono de M1, ou o dono de um milestone que exija migration aditiva, cria arquivos em packages/db/prisma/migrations/, e qualquer necessidade de coluna nova é anunciada em DECISIONS.md antes de ser criada. Nenhum executor edita arquivo fora do escopo do seu milestone sem registrar a razão.

28.7 Esforço relativo por milestone #

Milestone Pontos Peso relativo Comentário sobre o dimensionamento
M0 5 4% Trabalho mecânico e bem conhecido, mas extenso em número de arquivos.
M1 13 10% Vinte e sete tabelas, particionamento, seeds, criptografia de campo e índices cegos. Volume alto; a parte não trivial é o índice cego que preserva a busca por telefone.
M2 8 6% Dois fluxos de autenticação distintos, com muitos casos de erro.
M3 8 6% Máquina de estados do assinante e casos de borda densos.
M4 13 10% O maior volume de interface do projeto, com editor, calendário e gestão.
M5 13 10% Integração com dois provedores, processamento de mídia e regras de qualidade.
M6 21 16% O núcleo do produto e o maior risco. Quatro caminhos de entrega, fila, idempotência.
M7 13 10% Doze eventos de webhook, reconciliação e a regra de revogação imediata.
M8 8 6% Interface sobre serviços já existentes.
M9 8 6% Volume de conteúdo e exigências de desempenho e acessibilidade.
M10 8 6% Catálogo grande de métricas e alertas, com pouca lógica difícil.
M11 5 4% Cabeçalhos, retenção, anonimização e direitos do titular. A criptografia de coluna, que era a parte não trivial, já veio em M1.
M12 8 6% Execução e correção, com cauda imprevisível.
M13 5 4% Procedimento, não invenção.

M6 sozinho representa aproximadamente um sexto do esforço total. Qualquer plano que trate o motor de envio como "só mandar mensagem" está subdimensionado.

28.8 Riscos de execução e mitigação #

# Risco Milestone afetado Probabilidade Impacto Mitigação
R1 Verificação de negócio na Meta demora ou é recusada por documentação divergente. X1, bloqueia M6 real Alta Alto Iniciar no dia 1. Conferir antes que razão social, CNPJ, endereço e site coincidem exatamente com os documentos. Manter o desenvolvimento contra o simulador local até a liberação.
R2 Template rejeitado pela Meta por conteúdo ou por categoria. X4, bloqueia M6 real Alta Médio Submeter os oito templates cedo e em lote. Manter variantes alternativas de redação prontas. Se devocional_diario_v1 for reclassificado para categoria de marketing, o sistema continua funcionando e apenas o custo muda — registrar a categoria efetiva e seguir.
R3 Número de mensagens é limitado por tier inicial baixo. M6, M13 Média Alto Planejar o crescimento de tier com envios consistentes e qualidade alta. Monitorar o volume planejado contra o tier e alertar antes de atingir o limite, conforme a Seção 17.
R4 Qualidade do número cai por marcação de assinantes. M13 e pós-lançamento Média Alto Opt-in em duas etapas obrigatório, rodapé de saída em todo convite, palavras-chave de saída com efeito imediato, e o alerta de qualidade da Seção 23. Número reserva provisionado conforme a Seção 27.
R5 Conta do provedor de pagamento demora a ser aprovada para produção. Y1, bloqueia M13 Média Alto Iniciar em paralelo com X0. Desenvolver e testar inteiramente contra sandbox. O lançamento pode ocorrer com o tier gratuito aberto e o checkout desativado por flag, caso a aprovação atrase.
R6 Fila de webhooks do provedor de pagamento é pausada por lentidão do nosso endpoint. M7 Média Alto Handler que apenas persiste e enfileira, com resposta abaixo do limite da Seção 9.12, mais o alerta de fila pausada e o runbook correspondente da Seção 27.
R7 Custo de síntese de voz acima do previsto. M5 Média Médio Áudio gerado uma única vez por devocional, nunca por assinante. Alerta de custo diário acima do orçado na Seção 23. Provedor de fallback com custo distinto já integrado.
R8 Áudio gerado com qualidade ruim, truncado ou silencioso. M5 Média Médio Verificações automáticas de duração, silêncio e truncamento antes de publicar, mais revisão humana opcional no painel administrativo.
R9 Lote diário não conclui na janela alvo. M6 Baixa Alto Limitador de taxa configurável, teste de carga nos três patamares em M12 e alerta de lote não concluído.
R10 Envio duplicado para o mesmo assinante. M6 Média Alto Índice único em delivery_attempts sobre a chave de envio, testado explicitamente por reexecução tripla do planejamento.
R11 Assinante inadimplente continua recebendo áudio. M7 Baixa Alto Rebaixamento na mesma transação do processamento do webhook, mais verificação de entitlement no momento do envio, não no momento do planejamento.
R12 Deriva entre schema e migrations. M1 e adiante Média Médio Verificação de deriva no pipeline de integração contínua, falhando o build.
R13 Regra de entitlement recalculada localmente em algum ponto. M3 em diante Média Médio Função única em packages/core, regra de lint proibindo comparação direta com o campo de tier fora dela, e teste que cobre a matriz completa.
R14 Segredo vazado em log ou em imagem de contêiner. qualquer Baixa Crítico Redação obrigatória no logger, varredura de segredos no pipeline, e o procedimento de resposta a vazamento da Seção 22.
R15 Mensagem real enviada a partir do ambiente de desenvolvimento. M6 Média Alto O provedor real só é instanciado quando o ambiente é de produção; em qualquer outro, o simulador é obrigatório e o boot falha se credenciais de produção forem detectadas fora dela.
R16 Conteúdo do dia ausente às 05:00. M6 e pós-lançamento Média Alto Alerta de menos de sete dias agendados, verificação de prontidão às 05:00 e o mecanismo de devocional de reserva da Seção 18.
R17 Executor cria tabela fora da lista fechada. M1 em diante Média Alto Regra inviolável da Seção 30.3 e verificação automatizada que compara o schema com a lista da Seção 6.
R18 Restauração de backup nunca testada. M13 Baixa Crítico Restauração real obrigatória como critério de saída de M13, e teste trimestral recorrente conforme a Seção 25.

O que se faz enquanto a Meta não responde. Este é o bloqueio externo mais provável e mais longo do projeto. A resposta é operacional, não passiva:

  1. Todo M6 é construído contra o simulador local da Seção 24, que reproduz o contrato de envio, os estados de mensagem, os webhooks de entrada e os códigos de erro da plataforma.
  2. Os testes de idempotência, de limitador de taxa, de janela de atendimento e de fallback rodam integralmente sem a plataforma real.
  3. Enquanto X4 não chega, o executor avança para M7, M8 e M10, que não dependem de aprovação de template.
  4. Assim que os templates são aprovados, executa-se apenas a bateria de validação real: um envio de convite, uma abertura de janela, um pacote completo e um fallback de vídeo, para números internos.
  5. Se um template específico for rejeitado, o produto ainda pode lançar sem ele, desde que devocional_diario_v1 e codigo_acesso_v1 estejam aprovados. Os demais têm caminhos alternativos: lembretes por e-mail, confirmações dentro da janela de atendimento. Essa degradação está prevista na Seção 27.

28.9 Checklist de go-live #

Cada item é verificável. Nenhum item aceita "provavelmente".

Plataforma de mensagens (depende de terceiro).

  1. Verificação de negócio concluída, com status verificado no gerenciador de negócios.
  2. Número dedicado registrado, com nome de exibição aprovado e foto de perfil publicada.
  3. devocional_diario_v1 com status aprovado, categoria efetiva registrada no sistema.
  4. codigo_acesso_v1 com status aprovado.
  5. devocional_diario_video_v1, boas_vindas_v1, lembrete_pagamento_v1, pagamento_confirmado_v1, acesso_encerrado_v1 e reativacao_v1 com status registrado; ausência de qualquer um documentada com o caminho alternativo ativo.
  6. Webhook de produção verificado, com assinatura validada em pelo menos um evento real.
  7. Tier de mensagens atual lido pelo sistema e comparado ao volume planejado do primeiro dia.
  8. Token de acesso de longa duração emitido, armazenado como segredo e com data de rotação registrada.

Provedor de pagamento (depende de terceiro). 9. Conta de produção aprovada e chave emitida. 10. Webhook de produção configurado, com token próprio e resposta verificada abaixo do limite de tempo. 11. Uma cobrança real de valor baixo criada, paga e estornada, com todos os eventos refletidos corretamente no banco. 12. Reconciliação diária executada uma vez em produção sem divergência.

Infraestrutura. 13. Certificado TLS válido em todos os subdomínios, com renovação automática confirmada. 14. Firewall com apenas as portas previstas abertas. 15. Backup automatizado executado, com destino externo e criptografia confirmados. 16. Restauração de backup executada e validada, com o tempo registrado. 17. Rollback simulado com sucesso. 18. Todos os contêineres reportando saudável por mais de 24 horas consecutivas.

Aplicação. 19. Migrations aplicadas sem deriva. 20. Seeds de produção executados: planos, administrador inicial, definições de template e configurações de runtime. 21. Nenhuma variável obrigatória faltando, verificado pela validação de ambiente no boot. 22. Nenhum segredo presente em código, em imagem ou em log. 23. Todos os critérios bloqueadores da Seção 29 verdes, conferidos pela contagem verificável declarada na Seção 29.23. 24. Interruptor geral de envios testado: desliga, verifica que nada sai, religa. A chave é ops.kill_switch, existe no seed e o comando de emergência traz todas as colunas obrigatórias. 25. Bucket de mídia conferido por requisição sem assinatura, respondendo 403, inclusive no prefixo de exportações. 26. Cadeia de alerta validada de ponta a ponta pelos itens M-23 e M-24 da Seção 24.11, incluindo o alerta externo com o servidor desligado. 27. Salvaguarda de transferência internacional comprovada e arquivada para cada destinatário que recebe dado pessoal.

Conteúdo. 28. No mínimo 30 dias de devocionais agendados com status publicado ou pronto. 29. No mínimo 3 devocionais de reserva marcados como conteúdo perene. 30. Áudio gerado e verificado para todos os devocionais dos próximos 7 dias. 31. Devocional público de demonstração publicado na landing.

Legal e conformidade. 32. Termos de uso e política de privacidade publicados, com data de vigência e versão. 33. Encarregado de dados nomeado, com canal público funcionando e resposta testada. 34. Texto de consentimento versionado em uso e gravado em cada novo cadastro. 35. Versão bíblica em uso conferida quanto a direitos: o código gravado é ALMEIDA_1911 ou BIBLIA_LIVRE, e a sigla ARC não aparece como código nem como atribuição em nenhuma entrega.

Operação. 36. Todos os alertas da Seção 23 configurados, com um disparo de teste recebido no canal correto. 37. Runbooks da Seção 27 acessíveis ao operador de plantão. 38. Envio de validação para números internos concluído com 100% de sucesso. 39. Painel de envios exibindo o lote de validação com contagens corretas.

28.10 Plano dos primeiros 30 dias após o lançamento #

Dias 1 a 3 — vigilância intensa.

Monitorar a cada lote: horário real de início e de término do disparo; taxa de entrega; taxa de falha por código de erro; taxa de abertura da janela de atendimento; qualidade do número; consumo do tier de mensagens.

Ajustes esperados neste período: calibrar o limitador de taxa de envio se o lote encostar na janela alvo; corrigir o texto do teaser se a taxa de abertura da janela ficar abaixo de 25%; ajustar o horário de planejamento se o enfileiramento não terminar com folga antes do disparo.

Dias 4 a 10 — validação do fallback e do ciclo de cobrança.

O caminho de fallback por vídeo só aparece a partir do quarto dia sem interação: verificar que ele dispara para o público correto, que o arquivo de vídeo é aceito e que o custo por mensagem correspondente é registrado. Verificar o primeiro ciclo de cobrança por PIX, incluindo lembretes antes do vencimento e o comportamento em caso de não pagamento. Confirmar que a revogação imediata funciona em produção sem carência acidental.

Dias 11 a 20 — conversão e custo.

Acompanhar a taxa de conversão do tier gratuito para o pago e o tempo médio até a conversão. Acompanhar o custo por assinante por mês, decomposto entre mensagens, síntese de voz e infraestrutura. Comparar o custo real de mensagem com a faixa prevista para a categoria efetiva do template. Ajustar o dia de envio do tier gratuito se a taxa de abertura de domingo ficar sistematicamente abaixo da média dos demais dias.

Dias 21 a 30 — retenção e estabilização.

Acompanhar churn, motivos de cancelamento agregados e taxa de opt-out. Revisar o trabalho morto — jobs no estado failed com linha job_runs.status = 'DEAD' — e reprocessar o que for reprocessável. Revisar todos os alertas disparados no período e eliminar os que geraram ruído sem ação. Executar a primeira análise pós-incidente, se houver incidente. Fechar a lista de ajustes de conteúdo com o editor.

Números que se observa todos os dias, sem exceção.

Indicador Fonte Faixa saudável Ação se sair da faixa
Lote iniciado no horário Painel de envios até 06:05 Runbook de lote não disparado, Seção 27.
Lote concluído Painel de envios até 06:30 Runbook de lote parado no meio, Seção 27.
Taxa de entrega Métrica da Seção 21 acima de 95% Investigar por código de erro; excluir da conta o código esperado e recorrente no Brasil.
Taxa de falha Métrica da Seção 21 abaixo de 5% Runbook de taxa de falha alta, Seção 27.
Qualidade do número Webhook de conta verde Alerta imediato em amarelo; plano de contingência em vermelho.
Taxa de abertura da janela Métrica da Seção 21 acima de 30% Revisar teaser e horário; aumentar uso do atalho de janela aberta.
Custo diário de mensagens Métrica da Seção 21 dentro do orçado Revisar proporção de fallback de vídeo, que é o item mais caro.
Assinaturas ativas e churn Métrica da Seção 21 churn abaixo de 8% ao mês Revisar motivos agregados de cancelamento.
Trabalho morto (job_runs.status = 'DEAD') Painel de filas zero linhas novas no dia Inspecionar e reprocessar conforme a Seção 27.

Gatilhos de reversão. Cada gatilho tem uma ação definida, e a ação é executada sem discussão quando o gatilho é atingido.

Gatilho Ação imediata
Qualidade do número em vermelho. Acionar o interruptor geral de envios, suspender o disparo diário, aplicar o runbook de qualidade vermelha e só religar depois de o indicador voltar.
Taxa de falha acima de 20% em um lote. Cancelar o lote em andamento, reprocessar apenas após diagnóstico, comunicar os assinantes afetados conforme a Seção 27.
Qualquer assinante recebendo o mesmo devocional duas vezes. Suspender o disparo, auditar delivery_attempts, corrigir a chave de idempotência antes de religar.
Assinante inadimplente recebendo conteúdo pago. Suspender o disparo do pacote de áudio, forçar reconciliação, corrigir o ponto de verificação de entitlement.
Dado pessoal exposto em log ou em resposta de API. Executar o procedimento de resposta a vazamento da Seção 22, incluindo avaliação de notificação à autoridade.
Fila de webhooks do provedor de pagamento pausada por mais de 30 minutos. Aplicar o runbook correspondente, executar reconciliação manual e só então reativar a fila.
Erro 5xx acima do limiar por mais de 15 minutos. Rollback para a versão anterior com o comando definido na Seção 25, e diagnóstico depois.
Custo diário acima de duas vezes o orçado. Desativar por flag o fallback de vídeo, que é o maior componente unitário, e reavaliar.

O que não se muda nos primeiros 30 dias. Preço, horário de envio, regra de revogação imediata e desenho de entrega em duas etapas. Mudar qualquer um desses no primeiro mês destrói a capacidade de interpretar os números. Ajustes de texto, de teaser e de calendário editorial são livres.

29. Critérios de Aceitação #

Esta seção define o que significa "funciona" para cada área do produto. Todos os critérios são escritos no formato Dado / Quando / Então e são observáveis do lado de fora: verificam comportamento visível por interface, por resposta de API, por conteúdo de mensagem entregue ou por estado consultável, nunca por detalhe interno de implementação.

29.1 Como ler e usar esta seção #

  • Cada critério tem identificador estável no formato AC-NNN. O identificador nunca é reutilizado nem renumerado.
  • Critérios marcados com [BLOQUEADOR] impedem o lançamento. Nenhum bloqueador pode estar vermelho na conclusão de M12 nem no go-live da Seção 28.9.
  • Onde um valor numérico aparece, ele é o valor efetivo do sistema. Se a Seção 1 alterar um padrão configurável, o critério passa a valer com o novo valor, sem alteração de texto.
  • Um critério é considerado atendido quando existe teste automatizado que o exercita ou, quando isso é impossível por depender de terceiro, quando existe evidência registrada da verificação manual descrita na Seção 24.
  • A matriz de rastreabilidade da Seção 29.23 liga cada funcionalidade à seção que a especifica e aos critérios que a cobrem.

29.2 Cadastro e opt-in #

AC-001 · [BLOQUEADOR]Dado um visitante com número de celular brasileiro válido não cadastrado, quando ele preenche nome, telefone e marca o consentimento e envia o formulário, então o sistema cria o assinante no estado de verificação pendente, registra um evento de consentimento com o texto versionado, o IP, o agente de usuário e o horário, e envia o código de acesso ao número informado.

AC-002Dado um visitante que não marcou o checkbox de consentimento, quando ele tenta enviar o formulário, então o envio é bloqueado, o campo é destacado, a mensagem em português indica que o consentimento é obrigatório e nenhum registro é criado no sistema.

AC-003 · [BLOQUEADOR]Dado um número informado em qualquer formato de escrita usual no Brasil, com ou sem o nono dígito, com ou sem parênteses, hífen, espaço ou prefixo de país, quando o cadastro é processado, então o número é armazenado em formato internacional canônico com o nono dígito, e duas grafias diferentes do mesmo número nunca produzem dois assinantes.

AC-004Dado um número com código de país diferente do Brasil, quando o visitante tenta se cadastrar, então a requisição é recusada com o código de erro de país não suportado e uma mensagem clara em português explicando que o serviço atende apenas números brasileiros.

AC-005Dado um número já cadastrado e ativo, quando alguém tenta cadastrá-lo novamente, então a resposta indica que o número já existe, nenhum registro duplicado é criado e o visitante é orientado a entrar em vez de se cadastrar.

AC-006Dado um número que já fez opt-out, quando ele é cadastrado novamente com consentimento explícito, então o assinante existente é reativado, um novo evento de consentimento é registrado e o histórico anterior é preservado.

AC-007Dado um número de um assinante excluído por solicitação do titular, quando ele se cadastra de novo, então um assinante novo é criado sem qualquer dado do anterior, e o acervo anterior não é recuperado.

AC-008 · [BLOQUEADOR]Dado um assinante que verificou o telefone mas ainda não confirmou o opt-in no aplicativo de mensagens, quando o lote diário é planejado, então ele não é incluído em nenhum envio, sob nenhuma circunstância.

AC-009 · [BLOQUEADOR]Dado um assinante que acabou de confirmar o opt-in, quando a confirmação ocorre depois do horário de envio do dia e ele é elegível a receber naquele dia, então ele recebe o devocional do dia uma única vez de forma imediata e entra no ciclo normal a partir do dia seguinte, sem receber duas vezes.

AC-010Dado um assinante que confirmou o opt-in antes do horário de envio, quando o lote do dia é disparado, então ele recebe apenas pelo fluxo normal e não recebe envio imediato adicional.

AC-011Dado dois formulários de cadastro abertos em abas diferentes com o mesmo número, quando ambos são enviados quase ao mesmo tempo, então exatamente um assinante é criado e o segundo envio recebe o erro de número já existente, sem violação de restrição no banco visível ao usuário.

AC-012Dado um visitante que informou e-mail, quando o e-mail já pertence a outro assinante, então o cadastro é recusado com mensagem específica sobre o e-mail, e o telefone não é consumido.

AC-013Dado um nome com caracteres não permitidos ou com menos caracteres que o mínimo, quando o formulário é enviado, então a validação retorna erro apontando o campo name com a mensagem em português, e nenhum outro campo é processado antes da correção.

29.3 Autenticação do assinante #

AC-014 · [BLOQUEADOR]Dado um assinante cadastrado, quando ele solicita o código de acesso, então um código numérico de seis dígitos é entregue no aplicativo de mensagens, válido por dez minutos, e o mesmo código nunca é reaproveitado após uso bem-sucedido.

AC-015 · [BLOQUEADOR]Dado um código de acesso válido e dentro do prazo, quando o assinante o informa, então uma sessão é criada, o cookie de sessão é definido com as flags de segurança exigidas e o assinante é levado ao painel.

AC-016Dado um código expirado, quando o assinante o informa, então a resposta traz OTP_EXPIRED com status 410, e a interface habilita o reenvio. O status 410 distingue código expirado — recurso que existiu e não existe mais — de código inválido, que é 401; a distinção é o que permite ao cliente oferecer o reenvio sem ambiguidade, e vale em todos os pontos do sistema.

AC-017Dado cinco tentativas incorretas para o mesmo código, quando o assinante tenta pela sexta vez, então o código é invalidado, a resposta traz o erro de tentativas excedidas e uma nova solicitação passa a ser necessária.

AC-018Dado um número que já solicitou três códigos na última hora, quando ele solicita o quarto, então a resposta é 429 com o erro de limite de taxa e os cabeçalhos de limite preenchidos com o instante de liberação.

AC-019 · [BLOQUEADOR]Dado um número cadastrado e um não cadastrado, quando cada um recebe 200 pedidos de código em ambiente de homologação, então a diferença entre as medianas dos dois conjuntos de latência é inferior a 15 ms e a diferença entre os percentis 95 é inferior a 40 ms, e as respostas são idênticas em código HTTP, corpo e error.code. Como verificar: script que alterna os dois números, descarta as 20 primeiras medições de aquecimento e imprime as duas distribuições.

AC-020Dado um assinante com e-mail verificado, quando ele escolhe entrar por e-mail, então um link de uso único é enviado, válido pelo prazo definido, e o uso do link cria sessão equivalente à do código de acesso.

AC-021Dado um assinante com sessão ativa em três dispositivos, quando ele aciona "sair de todos os dispositivos", então todas as sessões são revogadas e qualquer requisição autenticada subsequente com os cookies antigos retorna 401.

AC-022Dado um assinante autenticado que troca o número de telefone, quando a re-verificação do novo número é concluída, então todas as sessões anteriores são invalidadas e uma nova sessão é emitida.

AC-023Dado um assinante que foi rebaixado de pago para gratuito enquanto tinha sessão aberta, quando ele recarrega qualquer tela do painel, então as capacidades exibidas refletem o tier gratuito imediatamente, sem exigir novo login.

AC-024Dado um cookie de sessão adulterado, quando ele é enviado em qualquer rota autenticada, então a resposta é 401 e o evento é registrado em log com o identificador de requisição, sem expor detalhe do motivo ao cliente.

29.4 Autenticação do administrador #

AC-025 · [BLOQUEADOR]Dado um administrador com credenciais corretas, quando ele entra com e-mail e senha, então o sistema exige o segundo fator antes de emitir qualquer sessão, sem exceção e sem opção de pular.

AC-026 · [BLOQUEADOR]Dado um administrador que informou o segundo fator correto, quando a verificação conclui, então uma sessão administrativa é emitida com prazo mais curto que o de assinante, conforme a Seção 8.

AC-027Dado um administrador que errou a senha o número de vezes definido como limite, quando ele tenta novamente, então a conta é bloqueada temporariamente, a resposta traz o erro de conta bloqueada e o desbloqueio segue o procedimento da Seção 8.

AC-028Dado um código de recuperação de segundo fator, quando ele é usado com sucesso, então ele é consumido e não pode ser reutilizado.

AC-029Dado um usuário com papel de editor, quando ele acessa uma rota administrativa restrita a papéis superiores, então a resposta é 403 quando a existência do recurso não é sensível, e 404 quando revelar a existência seria vazamento, conforme a regra da Seção 8.

AC-030 · [BLOQUEADOR]Dado o único proprietário do sistema, quando alguém tenta removê-lo ou rebaixá-lo, então a operação é recusada com erro específico e o sistema permanece com pelo menos um proprietário.

AC-031 · [BLOQUEADOR]Dado um administrador que assume a identidade de um assinante para suporte, quando a impersonação começa e termina, então ambos os eventos ficam registrados na trilha de auditoria com autor, alvo, motivo e horário, e a sessão impersonada é limitada em duração.

AC-032Dado qualquer mudança de papel de um usuário, quando ela é efetivada, então um registro de auditoria imutável é criado com o papel anterior, o novo, o autor e o horário.

29.5 Entitlements de plano gratuito e pago #

AC-033 · [BLOQUEADOR]Dado um assinante do tier gratuito, quando o lote diário é planejado em um dia que não é o dia de envio gratuito configurado, então ele não é incluído no lote.

AC-034 · [BLOQUEADOR]Dado um assinante do tier gratuito, quando o lote é planejado no dia de envio gratuito configurado, então ele é incluído e recebe apenas o texto completo, nunca o áudio.

AC-035 · [BLOQUEADOR]Dado um assinante do tier pago, quando o lote diário é planejado em qualquer dia da semana, então ele é incluído e recebe texto e áudio.

AC-036 · [BLOQUEADOR]Dado um assinante do tier gratuito, quando ele solicita a URL de áudio de qualquer devocional por qualquer rota, então a resposta é 403 com o erro de recurso indisponível para o plano, e nenhuma URL assinada é emitida.

AC-037Dado um assinante do tier gratuito no painel, quando ele abre o acervo, então apenas os devocionais dos últimos sete dias aparecem, com aviso claro de que o acervo completo pertence ao plano pago.

AC-038Dado um assinante do tier pago, quando ele abre o acervo, então todos os devocionais desde a primeira assinatura paga dele aparecem, paginados por cursor. Quem foi pago, deixou de ser e voltou vê tudo desde a primeira vez.

AC-039Dado um assinante do tier gratuito que já usou o reenvio manual do dia, quando ele tenta reenviar de novo no mesmo dia, então a resposta é 429 com o erro de limite de reenvio atingido.

AC-040Dado um assinante do tier pago que já usou três reenvios no dia, quando ele tenta o quarto, então a resposta é 429 com o mesmo erro de limite, e o contador zera na virada do dia no fuso operacional.

AC-041 · [BLOQUEADOR]Dado qualquer ponto do sistema que precise decidir uma capacidade, quando a decisão é tomada, então ela vem da função única de resolução de entitlements, e nenhum outro trecho compara o campo de tier diretamente — verificado por regra de lint e por teste.

AC-042Dado um assinante que faz upgrade no meio do dia, quando o pagamento é confirmado, então as capacidades pagas passam a valer imediatamente no painel e no próximo envio, sem esperar o próximo ciclo.

29.6 Editor e calendário editorial #

AC-043 · [BLOQUEADOR]Dado um editor autenticado, quando ele cria um devocional com título, referência bíblica, versão, texto bíblico, reflexão, oração e data de agendamento, então o devocional é salvo no estado de rascunho e uma revisão é criada.

AC-044Dado um devocional salvo várias vezes, quando o editor abre o histórico, então cada salvamento aparece como revisão distinta, com autor e horário, e é possível comparar e restaurar qualquer uma.

AC-045 · [BLOQUEADOR]Dado um texto de reflexão de qualquer tamanho, quando o teaser é gerado automaticamente, então o resultado tem no máximo 300 caracteres e não contém quebra de linha, tabulação nem quatro ou mais espaços consecutivos.

AC-046 · [BLOQUEADOR]Dado um editor que cola manualmente um teaser com quebra de linha, quando ele tenta salvar, então a validação recusa com mensagem específica e o devocional não avança de estado.

AC-047Dado um devocional em edição, quando o editor abre a pré-visualização, então ele vê a representação fiel do convite: cabeçalho, corpo com os dois parâmetros substituídos, rodapé e os dois botões, exatamente como o assinante receberá.

AC-048 · [BLOQUEADOR]Dado um devocional sem áudio pronto e a existência de assinantes pagos ativos, quando o editor tenta publicá-lo, então a transição é recusada com o erro de áudio obrigatório e o estado permanece o anterior.

AC-049Dado um devocional publicado, quando o editor altera qualquer campo que compõe o roteiro de narração, então o áudio existente é invalidado, o devocional volta ao estado de áudio pendente e uma nova geração é enfileirada automaticamente.

AC-050Dado o calendário editorial aberto, quando existem menos de sete dias futuros com conteúdo agendado, então um alerta visível aparece no painel e o alerta operacional correspondente é disparado no canal configurado.

AC-051Dado um devocional agendado, quando o editor o arrasta para outra data livre, então o agendamento é atualizado e o calendário reflete a mudança sem recarregar a página.

AC-052Dado duas tentativas de agendar devocionais diferentes para a mesma data, quando a segunda é salva, então a operação é recusada com o erro de data já ocupada, preservando a regra de um devocional por dia.

AC-053Dado um arquivo CSV de importação com linhas válidas e inválidas, quando ele é enviado, então as linhas válidas são importadas, as inválidas são listadas com número da linha e motivo, e nenhuma linha inválida gera registro parcial.

AC-054Dado um devocional marcado como conteúdo de reserva, quando o calendário é consultado, então ele aparece separado do calendário regular e não ocupa data.

AC-055Dado qualquer ação administrativa sobre conteúdo ou sobre assinante, quando ela é executada, então um registro de auditoria é criado com autor, ação, alvo, valores anterior e novo, e horário.

29.7 Geração e qualidade do áudio #

AC-056 · [BLOQUEADOR]Dado um devocional que atinge o estado de pronto, quando a transição ocorre, então um trabalho de geração de áudio é enfileirado automaticamente, sem ação humana.

AC-057 · [BLOQUEADOR]Dado um roteiro de narração montado a partir do devocional, quando a referência bíblica abreviada aparece no texto, então ela é expandida por extenso na narração, de modo que a referência abreviada de capítulo e versículo é lida como livro, capítulo e versículo.

AC-058 · [BLOQUEADOR]Dado a geração concluída, quando o arquivo destinado à mensagem de voz é inspecionado, então ele está no formato, no número de canais e na taxa de amostragem exigidos pela Seção 16, e é renderizado pelo aplicativo de mensagens como mensagem de voz com forma de onda.

AC-059Dado a geração concluída, quando o arquivo destinado ao player web é inspecionado, então ele está no formato e na taxa definidos na Seção 16 e toca no navegador sem download completo prévio.

AC-060 · [BLOQUEADOR]Dado o provedor primário de síntese de voz indisponível, quando três tentativas falham, então o provedor de fallback é acionado automaticamente, o áudio é gerado e o provedor efetivamente usado fica registrado no ativo de áudio.

AC-061Dado um áudio gerado acima do limiar intermediário de tamanho, quando a verificação de tamanho roda, então o arquivo é reencodado com a taxa reduzida definida na Seção 16 e um aviso é registrado.

AC-062 · [BLOQUEADOR]Dado um áudio gerado, quando ele excede o limite duro de tamanho de mídia da plataforma mesmo após reencode, então o devocional não avança para publicado, um alerta é disparado e nenhum envio de áudio é tentado.

AC-063Dado um áudio gerado com duração fora da faixa aceitável, quando a verificação automática roda, então o devocional permanece pendente de revisão e o editor é avisado no painel.

AC-064Dado um áudio integralmente silencioso ou truncado, quando a verificação automática roda, então ele é rejeitado, a geração é repetida uma vez e, persistindo, um alerta é disparado.

AC-065 · [BLOQUEADOR]Dado um devocional com áudio pronto, quando o envio do dia ocorre para mil assinantes pagos, então o upload de mídia para a plataforma acontece uma única vez e o mesmo identificador de mídia é reutilizado em todos os envios.

AC-066Dado um identificador de mídia expirado pelo prazo de validade da plataforma, quando um envio precisa dele, então o sistema faz o reupload automaticamente e prossegue sem falhar o envio.

AC-067Dado um assinante pago no painel web, quando ele pede para ouvir o áudio, então uma URL assinada de curta duração é emitida, e a mesma URL deixa de funcionar após o prazo definido na Seção 16.

29.8 Envio diário e motor de entrega #

AC-068 · [BLOQUEADOR]Dado um dia com devocional publicado e áudio pronto, quando o relógio atinge 05:40 no fuso America/Sao_Paulo, então um lote é criado com exatamente os assinantes elegíveis, e cada destinatário tem uma tentativa de entrega registrada com a chave de idempotência do dia. O job repetível está registrado com o padrão cron correspondente e com o fuso nomeado; deslocamento numérico fixo reprova o critério.

AC-069 · [BLOQUEADOR]Dado um planejamento já executado para uma data, quando ele é executado de novo, duas ou três vezes, então nenhum destinatário ganha tentativa adicional e o lote permanece com a mesma composição.

AC-070 · [BLOQUEADOR]Dado um lote planejado e pronto, quando o relógio do sistema atinge 06:00:00 no fuso America/Sao_Paulo, então a primeira chamada ao provedor de mensagens ocorre entre 06:00:00 e 06:00:30 nesse fuso, nenhuma chamada ocorre antes de 06:00:00, e o convite por template é enviado a cada destinatário sem janela de atendimento aberta, respeitando o limite de taxa configurado. Como verificar: executar em homologação com relógio injetado, registrar o instante de cada chamada ao provedor de gravação e comparar com o fuso nomeado; repetir com o servidor configurado em UTC e confirmar resultado idêntico.

AC-071 · [BLOQUEADOR]Dado um destinatário com janela de atendimento aberta no momento do disparo, quando o envio ocorre, então o sistema pula o convite por template e envia diretamente o pacote completo, e nenhuma cobrança de mensagem de template é gerada para ele.

AC-072Dado um lote de três mil destinatários, quando o disparo executa, então ele conclui dentro da janela de tempo declarada na Seção 18.14, medido do primeiro ao último envio aceito pela plataforma.

AC-073 · [BLOQUEADOR]Dado o horário de verificação de prontidão, quando não existe devocional publicado para o dia, então um alerta é disparado imediatamente ao operador e o sistema usa o conteúdo de reserva marcado como perene.

AC-074 · [BLOQUEADOR]Dado o horário de verificação de prontidão, quando não existe devocional do dia nem conteúdo de reserva disponível, então nenhum envio é feito, o lote não é criado e o alerta de conteúdo ausente permanece ativo até resolução — o sistema nunca envia conteúdo incompleto.

AC-075Dado um envio que falha com erro classificado como transitório, quando a política de tentativas roda, então o envio é repetido com espera exponencial e variação aleatória, até o número máximo de tentativas definido na Seção 27.

AC-076Dado um envio que falha com erro classificado como permanente, quando a classificação ocorre, então nenhuma nova tentativa é feita, a falha é registrada com o código da plataforma e o destinatário entra no lote do dia seguinte.

AC-077 · [BLOQUEADOR]Dado um envio recusado com o código de limitação de entrega esperado e recorrente no Brasil, quando a classificação ocorre, então ele não é contado como falha de entrega nas métricas e o destinatário é reagendado para o dia seguinte.

AC-078Dado um lote em andamento, quando o administrador abre a tela de envios, então ele vê progresso, contagem por status e a lista de falhas com motivo, atualizados sem recarregar a página.

AC-079Dado um lote com falhas, quando o administrador aciona reprocessar falhas, então apenas os destinatários com falha são reprocessados e nenhum destinatário já entregue recebe segunda mensagem.

AC-080 · [BLOQUEADOR]Dado um lote em andamento, quando o administrador aciona o cancelamento do lote, então nenhuma nova mensagem sai a partir daquele instante e o lote fica marcado como cancelado com o horário e o autor.

AC-081 · [BLOQUEADOR]Dado o interruptor geral de envios acionado, quando qualquer trabalho de envio tenta executar, então ele termina sem enviar nada, registra o motivo e o estado é visível ao operador.

29.9 Janela de atendimento e entrega completa #

AC-082 · [BLOQUEADOR]Dado um assinante que recebeu o convite por template, quando ele toca no botão de abrir o devocional, então o sistema envia, em ordem, o texto completo, o áudio quando o tier permite, e a mensagem de fechamento com a data do próximo envio.

AC-083 · [BLOQUEADOR]Dado um assinante que responde qualquer mensagem em vez de tocar no botão, quando a mensagem é recebida, então a janela de atendimento é aberta e o pacote completo do dia é entregue igualmente.

AC-084Dado a interação do assinante, quando o pacote é disparado, então a primeira mensagem do pacote sai dentro do tempo alvo de reação declarado na Seção 18, medido do recebimento do evento até o aceite pela plataforma.

AC-085 · [BLOQUEADOR]Dado um assinante do tier gratuito que abre a janela, quando o pacote é entregue, então ele recebe o texto completo e a mensagem de fechamento, e nunca o áudio.

AC-086Dado um assinante que toca no botão de adiar, quando o evento é recebido, então a janela é aberta, nenhum pacote é enviado naquele instante e o sistema entrega o pacote quando ele voltar a interagir, dentro do mesmo dia.

AC-087Dado um assinante que interage duas vezes no mesmo dia, quando a segunda interação ocorre, então o pacote do dia não é enviado novamente, e a resposta é a mensagem de ajuda ou a confirmação apropriada da Seção 19.

AC-088Dado um assinante que recebeu o convite e nunca interagiu, quando o dia termina no fuso operacional, então a entrega pendente expira, é registrada como não aberta e o contador de dias sem interação é incrementado.

AC-089Dado um texto de devocional mais longo que o limite de mensagem livre da plataforma, quando o pacote é montado, então o sistema divide o conteúdo em mensagens sequenciais respeitando o limite, sem cortar palavra ao meio e sem perder trecho.

29.10 Fallback de vídeo #

AC-090 · [BLOQUEADOR]Dado um assinante pago que não abriu a janela por três dias consecutivos, quando o envio do quarto dia é planejado, então o sistema seleciona o template com cabeçalho de vídeo em vez do convite padrão.

AC-091Dado o fallback de vídeo selecionado, quando a mídia é preparada, então o arquivo de vídeo combina a capa estática do dia com a faixa de áudio, dentro do limite de tamanho da plataforma.

AC-092Dado um assinante que recebe o fallback de vídeo e interage, quando a interação ocorre, então o contador de dias sem interação é zerado e o envio do dia seguinte volta ao convite padrão.

AC-093Dado um assinante do tier gratuito com muitos dias sem interação, quando o envio é planejado, então o fallback de vídeo nunca é usado para ele, porque o tier gratuito não recebe áudio.

29.11 Checkout com cartão e com PIX #

AC-094 · [BLOQUEADOR]Dado um assinante autenticado no tier gratuito, quando ele escolhe o plano mensal e conclui o pagamento com cartão, então a assinatura é criada no provedor de pagamento com o ciclo correto e o identificador externo apontando para o nosso registro.

AC-095 · [BLOQUEADOR]Dado o pagamento com cartão confirmado, quando o evento correspondente é processado, então o assinante passa ao tier pago, a assinatura fica ativa, a data de fim do período corrente é gravada e a mensagem de confirmação é enviada.

AC-096 · [BLOQUEADOR]Dado um checkout com cartão, quando a requisição é processada, então nenhum dado de cartão é persistido em qualquer tabela nem aparece em qualquer registro de log, verificado por inspeção automatizada dos logs do teste.

AC-097Dado um CPF com dígitos verificadores inválidos, quando o checkout é enviado, então a validação local recusa antes de qualquer chamada externa, com o erro específico de CPF inválido.

AC-098Dado um cartão recusado pelo emissor, quando o provedor responde a recusa, então o assinante vê a mensagem em português correspondente, permanece no tier gratuito e pode tentar de novo sem duplicar assinatura.

AC-099 · [BLOQUEADOR]Dado um assinante que escolhe PIX, quando a cobrança é criada, então ele recebe o código copia-e-cola e a imagem do código, com o prazo de expiração exibido.

AC-100Dado uma cobrança PIX paga, quando o evento de confirmação chega, então o acesso pago é liberado no mesmo processamento, e o assinante recebe a mensagem de confirmação.

AC-101Dado uma cobrança PIX não paga, quando o vencimento passa, então o assinante não é promovido, permanece no tier gratuito e recebe a comunicação prevista na Seção 19.

AC-102 · [BLOQUEADOR]Dado um assinante com assinatura ativa, quando ele tenta criar uma segunda assinatura, então a operação é recusada com o erro de assinatura já existente e nenhuma cobrança é criada no provedor.

AC-103Dado um assinante do plano mensal, quando ele muda para o plano anual, então a regra exata de troca definida na Seção 13 é aplicada, sem prorrateio, e o novo ciclo começa conforme especificado.

29.12 Webhooks de pagamento e revogação de acesso #

AC-104 · [BLOQUEADOR]Dado um evento de webhook do provedor de pagamento, quando ele chega ao endpoint, então a resposta 200 é devolvida abaixo do limite de tempo declarado na Seção 9.12, tendo apenas persistido o evento e enfileirado o processamento.

AC-105 · [BLOQUEADOR]Dado um webhook com token de autenticação incorreto, quando ele chega, então a resposta é 401, nada é persistido e a comparação do token não vaza informação por diferença de tempo.

AC-106 · [BLOQUEADOR]Dado o mesmo evento entregue cinco vezes, quando todos são processados, então o efeito no estado do sistema é idêntico ao de uma única entrega, e apenas um registro de evento é considerado processado.

AC-107 · [BLOQUEADOR]Dado um assinante pago ativo, quando o evento de pagamento vencido é processado, então na mesma transação a assinatura passa ao estado de expirada e o assinante é rebaixado ao tier gratuito, sem nenhum período de tolerância.

AC-108 · [BLOQUEADOR]Dado o rebaixamento por inadimplência ocorrido às 10:00 de um dia, quando o assinante interage no mesmo dia após o rebaixamento, então ele recebe apenas o texto, nunca o áudio, mesmo que o pacote do dia já tivesse sido planejado quando ele ainda era pagante.

AC-109 · [BLOQUEADOR]Dado um evento de pagamento removido, estornado ou de contestação, quando ele é processado, então a revogação é imediata, com o estado específico de cada caso conforme a Seção 12, e o assinante recebe a comunicação prevista.

AC-110Dado um evento que chega fora de ordem, com um estado anterior ao já registrado, quando ele é processado, então ele não regride o estado da assinatura e o descarte é registrado com o motivo.

AC-111Dado um pagamento confirmado que chega depois do cancelamento da assinatura, quando ele é processado, então o sistema aplica a regra definida na Seção 13 e registra a divergência para conferência na reconciliação.

AC-112 · [BLOQUEADOR]Dado a reconciliação diária, quando o estado no provedor de pagamento difere do nosso, então o nosso estado é corrigido para refletir o do provedor, a correção é registrada e um relatório de divergências é gerado.

AC-113Dado uma assinatura existente no provedor sem assinante correspondente no nosso banco, quando a reconciliação roda, então a divergência é registrada, um alerta é disparado e nenhum assinante é criado automaticamente.

AC-114Dado a fila de webhooks do provedor pausada por falhas consecutivas, quando a condição é detectada, então o alerta correspondente é disparado e o runbook da Seção 27 está acessível a partir dele.

29.13 Cancelamento voluntário #

AC-115 · [BLOQUEADOR]Dado um assinante pago que cancela pelo painel, quando ele confirma nas duas etapas, então a assinatura é marcada para não renovar, o acesso pago permanece até o fim do período já pago e a data exata de término é exibida e confirmada por mensagem.

AC-116 · [BLOQUEADOR]Dado um cancelamento voluntário com fim de período em uma data futura, quando essa data chega, então o assinante é rebaixado ao tier gratuito e passa a receber apenas no dia de envio gratuito.

AC-117Dado o fluxo de cancelamento, quando o assinante avança da primeira para a segunda etapa, então a pesquisa de motivo com opções fechadas e campo livre é apresentada e a resposta é registrada para as métricas de motivo.

AC-118Dado o fluxo de cancelamento, quando a oferta de retenção é apresentada, então ela é oferecida uma única vez por assinante e a recusa leva direto à confirmação.

AC-119Dado um assinante que cancelou e ainda está dentro do período pago, quando ele reativa a assinatura, então nenhuma cobrança nova é criada antes do fim do período corrente e a renovação volta a ficar ativa.

AC-120Dado um assinante que cancelou, quando ele acessa o painel após o fim do período pago, então o acervo visível passa a ser limitado ao do tier gratuito, e o histórico anterior não é apagado do banco.

29.14 Opt-out, reativação e pausa #

AC-121 · [BLOQUEADOR]Dado um assinante que envia uma das sete palavras-chave de saída reconhecidas, em qualquer combinação de maiúsculas, minúsculas e acentos, e em qualquer estado — inclusive pausado, bloqueado, em atendimento humano ou em modo silencioso pela proteção anti-laço —, quando a mensagem é processada, então opt_out_at fica preenchido em menos de 60 segundos contados a partir da chegada do webhook, nenhuma mensagem posterior é enviada e uma confirmação é enviada uma única vez. Como verificar: injetar cada palavra-chave em cada estado possível do assinante e conferir opt_out_at e o provedor de gravação; o reconhecimento da palavra de saída é a primeira operação executada sobre toda mensagem de entrada.

AC-122 · [BLOQUEADOR]Dado um assinante que fez opt-out, quando qualquer lote é planejado, então ele nunca é incluído, nem em envio de conteúdo, nem em campanha de retorno, nem em qualquer outra mensagem pelo canal de mensagens.

AC-123Dado um assinante pagante que faz opt-out pelo canal de mensagens, quando a confirmação é enviada, então ela avisa explicitamente que os envios pararam e que a cobrança do ciclo seguinte está suspensa, informa que a assinatura não foi cancelada e diz como voltar a receber ou como encerrar de vez. Passados 30 dias sem reativação, a assinatura é encerrada ao fim do período já pago, com aviso.

AC-124Dado um assinante que fez opt-out, quando ele envia a palavra-chave de retorno, então ele é reativado, um novo registro de consentimento é criado e ele volta a receber no próximo ciclo do seu tier.

AC-125Dado um assinante que aciona a pausa temporária por um número de dias entre um e trinta, quando o lote é planejado dentro do período de pausa, então ele não é incluído.

AC-126Dado uma pausa que chega ao fim, quando o primeiro lote após o término é planejado, então o assinante volta a ser incluído e recebe a mensagem de retorno prevista na Seção 19.

AC-127Dado um pedido de pausa com valor fora da faixa permitida, quando ele é enviado, então a validação recusa com o erro específico e nenhuma pausa é registrada.

AC-128Dado um assinante pausado, quando ele acessa o painel, então o estado de pausa e a data de retorno aparecem em destaque, com a ação de retomar imediatamente disponível.

AC-129 · [BLOQUEADOR]Dado o opt-out e o cancelamento de assinatura, quando o assinante executa um deles, então o outro não é executado implicitamente, e a interface deixa a distinção explícita antes da confirmação.

29.15 Painel do assinante #

AC-130Dado um assinante autenticado, quando ele abre a tela do dia, então vê o devocional de hoje com título, referência, texto bíblico, reflexão e oração, e o player de áudio apenas se o tier permitir.

AC-131Dado um devocional cujo áudio ainda está em geração, quando o assinante pago abre a tela do dia, então ele vê o aviso de áudio em preparo em vez de um player quebrado.

AC-132Dado o acervo aberto, quando o assinante rola até o fim da página, então a próxima página é carregada por cursor, sem duplicar nem pular itens quando novos devocionais são publicados durante a navegação.

AC-133Dado a tela de assinatura, quando o assinante a abre, então vê plano, valor, forma de pagamento, data da próxima cobrança e o histórico de pagamentos com status.

AC-134Dado a tela de perfil, quando o assinante altera o nome ou o e-mail, então a alteração é validada, salva e refletida imediatamente, e o e-mail alterado exige nova verificação antes de servir de canal de acesso.

AC-135Dado qualquer rota do painel, quando um assinante tenta acessar dado de outro assinante manipulando identificadores, então a resposta é 404 e nada do outro assinante é exposto.

AC-136Dado o painel aberto por tempo prolongado, quando o estado de servidor muda, então a interface se atualiza pelo mecanismo de revalidação periódica definido na Seção 14, sem canal persistente.

AC-137Dado qualquer tela do painel, quando os dados ainda não chegaram, quando a lista está vazia ou quando ocorre erro, então existe um estado visual próprio para cada uma das três situações, com texto em português e ação de recuperação quando aplicável.

29.16 Painel administrativo #

AC-138Dado um administrador na busca de assinantes, quando ele pesquisa por telefone em qualquer grafia, por nome parcial ou por e-mail, então o assinante correto é encontrado.

AC-139Dado a ficha de um assinante, quando o administrador a abre, então vê estado, tier, histórico de mensagens, histórico de pagamentos, consentimentos e as ações administrativas disponíveis.

AC-140 · [BLOQUEADOR]Dado o administrador concedendo acesso pago por cortesia por um número de dias, quando a concessão é feita, então o assinante passa ao tier pago, a expiração é registrada, a ação é auditada e, ao expirar, ele volta ao tier gratuito automaticamente.

AC-141Dado um assinante com divergência de assinatura, quando o administrador aciona a re-sincronização com o provedor de pagamento, então o estado local é corrigido conforme o provedor e a ação é auditada.

AC-142Dado a tela de templates, quando o administrador aciona a sincronização, então o status de aprovação de cada template na plataforma é atualizado localmente, incluindo o motivo de rejeição quando houver.

AC-143Dado a tela de auditoria, quando o administrador aplica filtros por autor, ação e período, então os resultados correspondem e a exportação em CSV contém exatamente as linhas filtradas.

AC-144Dado um usuário com papel de editor, quando ele abre o painel, então apenas as telas permitidas ao seu papel aparecem na navegação, e o acesso direto por URL às demais é recusado.

AC-145Dado o administrador aplicando opt-out em nome de um assinante por solicitação de suporte, quando a ação é executada, então ela exige confirmação, é auditada com o motivo e produz o mesmo efeito do opt-out feito pelo próprio assinante.

29.17 Exportação e exclusão de dados #

AC-146 · [BLOQUEADOR]Dado um assinante autenticado, quando ele solicita a exportação dos seus dados, então um arquivo JSON é disponibilizado contendo todas as categorias de dado pessoal inventariadas na Seção 22, dentro do prazo declarado.

AC-147Dado a exportação concluída, quando o assinante a baixa, então o download exige sessão válida e a URL expira após o prazo definido.

AC-148 · [BLOQUEADOR]Dado um assinante que solicita a exclusão da conta, quando ele confirma com o código enviado ao seu número, então a solicitação é registrada, o acesso é encerrado e a anonimização ocorre dentro do prazo declarado na Seção 22.

AC-149 · [BLOQUEADOR]Dado a anonimização executada, quando o banco é inspecionado, então nome, telefone, e-mail e documento estão irreversivelmente substituídos, enquanto os registros retidos por obrigação legal permanecem com o vínculo pseudonimizado, exatamente conforme a Seção 22.

AC-150Dado um assinante com assinatura ativa que pede exclusão, quando a solicitação é processada, então a assinatura é cancelada no provedor de pagamento antes da anonimização, e isso é informado ao titular.

AC-151Dado o histórico de consentimentos, quando o assinante o abre, então vê cada evento com data, canal, versão do texto e a íntegra do texto aceito naquela versão.

AC-152Dado o canal público do encarregado de dados, quando uma solicitação chega por ele, então existe procedimento registrado e prazo declarado de resposta conforme a Seção 22.

AC-153Dado as políticas de retenção configuradas, quando o trabalho de manutenção roda, então os registros que ultrapassaram o prazo de cada tabela são removidos ou anonimizados conforme a Seção 22, e a execução é registrada.

29.18 Métricas e dashboard #

AC-154Dado um dia encerrado, quando o trabalho de agregação roda, então a linha correspondente de métricas diárias é gravada com todos os indicadores definidos na Seção 21.

AC-155Dado a agregação de um dia já processado, quando ela é reexecutada, então os valores permanecem idênticos e nenhuma contagem é duplicada.

AC-156Dado o dashboard aberto, quando o administrador escolhe um período, então todos os gráficos e números refletem o mesmo período e a comparação com o período anterior é exibida.

AC-157Dado a métrica de taxa de entrega, quando ela é calculada, então ela usa exatamente a fórmula declarada na Seção 21 e o resultado bate com a contagem manual sobre os registros de mensagem do período.

AC-158Dado a métrica de receita recorrente mensal, quando existem assinaturas anuais ativas, então o valor anual entra normalizado por doze, conforme a fórmula da Seção 21.

AC-159Dado qualquer relatório exportável, quando o administrador o exporta, então o arquivo contém os mesmos números exibidos na tela, no mesmo período e com o mesmo fuso de agregação.

29.19 Segurança #

AC-160 · [BLOQUEADOR]Dado qualquer resposta HTTP da aplicação, quando os cabeçalhos são inspecionados, então todos os cabeçalhos de segurança da Seção 22 estão presentes com os valores exatos, incluindo a política de conteúdo sem diretiva permissiva de execução de script.

AC-161 · [BLOQUEADOR]Dado um webhook de entrada do canal de mensagens, quando a assinatura do corpo cru não confere, então a requisição é recusada, nada é persistido e o evento é registrado.

AC-162 · [BLOQUEADOR]Dado qualquer registro de log produzido em qualquer ambiente, quando os logs são inspecionados, então telefone, documento, código de acesso, token e dado de cartão nunca aparecem em texto claro.

AC-163 · [BLOQUEADOR]Dado o código-fonte e as imagens de contêiner, quando eles são varridos, então nenhum segredo de produção está presente.

AC-164Dado a coluna de telefone criptografada em nível de aplicação, quando uma busca por telefone é feita, então ela retorna o resultado correto pelo mecanismo de hash determinístico definido na Seção 22, sem descriptografar a tabela inteira.

AC-165Dado um formulário público, quando um agente automatizado tenta enviá-lo em volume, então a proteção anti-bot e o limite de taxa impedem a criação em massa e o bloqueio é registrado.

AC-166Dado qualquer rota que aceite entrada, quando a entrada não corresponde ao schema esperado, então a resposta é 422 com o detalhamento por campo previsto no envelope da Seção 7, sem expor detalhe interno.

AC-167Dado o upload de arquivo no painel administrativo, quando o arquivo não é de um tipo permitido ou excede o tamanho limite, então ele é recusado com erro específico, e a verificação considera o conteúdo real e não apenas a extensão.

AC-168Dado todo o tráfego público, quando ele é inspecionado, então ele ocorre exclusivamente por conexão cifrada, e requisições em texto claro são redirecionadas.

29.20 Observabilidade e alertas #

AC-169Dado qualquer requisição HTTP, quando ela é processada, então o identificador de requisição aparece no cabeçalho da resposta, no envelope e em todos os registros de log da requisição.

AC-170Dado uma operação que atravessa aplicação web, fila e worker, quando os logs são consultados pelo identificador de requisição, então todos os registros das três camadas são recuperados juntos.

AC-171 · [BLOQUEADOR]Dado o endpoint de prontidão consultado com o token do monitor externo, quando o banco, a fila ou o armazenamento estão indisponíveis, então ele responde com falha e discrimina qual dependência falhou; e quando o mesmo endpoint é consultado sem o token, então ele responde 404 e não revela nenhuma dependência. O endpoint de vivacidade continua público e devolve apenas o estado geral, sem versão, sem nome de serviço e sem tempo de atividade.

AC-172Dado o endpoint de métricas técnicas, quando ele é consultado, então todos os indicadores catalogados na Seção 23 estão expostos, com os rótulos previstos.

AC-173 · [BLOQUEADOR]Dado o horário limite de início do lote diário, quando o lote não começou, então o alerta correspondente é disparado no canal configurado, com link para o runbook da Seção 27.

AC-174Dado a taxa de falha de envio acima do limiar definido, quando a condição persiste pelo período configurado, então o alerta é disparado com a decomposição por código de erro.

AC-175 · [BLOQUEADOR]Dado a qualidade do número de envio caindo para o nível de atenção ou crítico, quando o evento chega, então o alerta é disparado imediatamente e o runbook correspondente está acessível.

AC-176Dado uma fila crescendo acima do limiar ou com trabalho travado além do tempo máximo, quando a condição é detectada, então o alerta é disparado e o trabalho morto é inspecionável pelo operador pelo comando de operação.

AC-177Dado um incidente encerrado, quando o prazo definido na Seção 23 passa, então existe um relatório pós-incidente registrado com causa, impacto, linha do tempo e ações preventivas.

29.21 Resiliência e degradação #

AC-178 · [BLOQUEADOR]Dado a plataforma de mensagens indisponível, quando o lote tenta executar, então o disjuntor abre, os envios ficam na fila sem serem descartados, o alerta é disparado e o sistema retoma automaticamente quando o serviço volta.

AC-179Dado o provedor de pagamento indisponível, quando um checkout é tentado, então o assinante vê mensagem clara de indisponibilidade temporária, nenhuma assinatura parcial é criada e nenhum acesso é concedido.

AC-180Dado o armazenamento de mídia indisponível, quando o painel tenta emitir uma URL assinada, então o erro é tratado com mensagem específica e o restante do painel continua funcionando.

AC-181Dado o serviço de fila indisponível, quando o sistema tenta enfileirar, então a falha é registrada, o alerta é disparado e nenhuma confirmação falsa de envio é dada ao usuário.

AC-182Dado trabalhos que esgotaram todas as tentativas, quando eles falham definitivamente, então eles ficam no estado failed da própria fila, uma linha é gravada em job_runs com status = 'DEAD', o alerta é disparado e o reprocessamento manual está disponível pelo comando de operação. Não existe fila de mensagens mortas separada: job_runs é a dead-letter do sistema.

29.22 Critérios acrescentados na revisão de integração #

Os critérios abaixo cobrem comportamentos que o restante desta seção descrevia de forma não verificável, ou não descrevia. Cada um nasceu de uma falha concreta que a suíte anterior deixaria passar em verde.

AC-183 · [BLOQUEADOR]Limite de acervo imposto no servidor. Dado um assinante do plano gratuito com 40 devocionais publicados nos últimos 40 dias, quando ele chama diretamente a rota de listagem do acervo com o maior limite aceito e percorre todos os cursores, então recebe no máximo os 7 dias mais recentes; e quando chama a rota de leitura de um devocional de 30 dias atrás, recebe 403. Como verificar: com sessão de assinante gratuito e cliente HTTP, sem usar a interface. AC-037 verifica a tela; este verifica a regra.

AC-184 · [BLOQUEADOR]Idempotência do disparo. Dado um lote de 100 itens, quando o processo do motor é encerrado abruptamente após 50 envios confirmados e reiniciado, então cada assinante recebe exatamente uma mensagem de cada tipo naquele dia; e quando o mesmo job é reentregue pela fila, nenhuma mensagem adicional é enviada. Como verificar: contagem no provedor de gravação e SELECT count(*) ... GROUP BY subscriber_id, date, kind HAVING count(*) > 1 devolvendo zero linhas. AC-069 prova a idempotência do planejamento; esta prova a do disparo.

AC-185 · [BLOQUEADOR]Opt-out durante o disparo. Dado um lote em execução, quando um assinante ainda não processado envia uma palavra de saída, então ele não recebe nenhuma mensagem daquele lote e opt_out_at fica preenchido em menos de 60 segundos a partir do webhook. Como verificar: injetar o webhook de entrada durante o disparo e conferir o provedor de gravação.

AC-186 · [BLOQUEADOR]Revogação sem carência alcança o lote. Dado um assinante pago planejado às 05:40, quando o evento de inadimplência é processado às 05:52, então ele recebe às 06:00 apenas o conteúdo do plano gratuito e nenhum áudio, com o rebaixamento registrado na tentativa de entrega. O nível de acesso é relido no instante do disparo, nunca aproveitado do planejamento.

AC-187 · [BLOQUEADOR]Pedido de código não é oráculo de enumeração. Dado um número cadastrado e um não cadastrado, quando cada um recebe quatro pedidos de código em sequência, então as quatro respostas são idênticas em código HTTP, corpo e error.code para os dois números. Número bloqueado também responde como o caminho normal, sem erro próprio e sem enviar mensagem.

AC-188 · [BLOQUEADOR]Cookies de sessão são aceitos pelo navegador. Dado qualquer resposta que emita cookie, quando os cabeçalhos Set-Cookie são inspecionados, então todo cookie cujo nome comece com __Host- tem Secure, tem Path=/ e não tem Domain; e o cookie de proteção contra requisição forjada tem um único nome em todo o sistema. Como verificar: teste de integração sobre os cabeçalhos emitidos, mais um cenário de navegador real que confirma que a sessão sobrevive à expiração do token de acesso. Um cookie __Host- com outro Path é descartado silenciosamente pelo navegador, e o sintoma é o assinante ser deslogado a cada 15 minutos.

AC-189 · [BLOQUEADOR]Impersonação é somente leitura, sem exceção. Dado um administrador em sessão de impersonação, quando ele tenta qualquer método não seguro em qualquer rota executável sob sessão de assinante — inclusive cancelamento de assinatura, reativação, troca de plano, troca de cartão, reenvio, pedido de eliminação e opt-out —, então a requisição é recusada antes de chegar ao handler. Como verificar: um teste de arquitetura enumera todo POST, PATCH, PUT e DELETE cuja guarda seja a de assinante — o critério é a guarda, não o prefixo da rota — e falha o build se algum não estiver protegido.

AC-190 · [BLOQUEADOR]A eliminação elimina em todas as tabelas. Dado um titular que exerceu o direito de eliminação, quando o banco é inspecionado após a execução, então nenhuma tabela retém telefone, wa_id, CPF, e-mail ou trecho de conteúdo de mensagem do titular em texto claro — inclusive os registros de mensagem e as mensagens recebidas. O que a lei obriga a reter é retido de forma pseudonimizada, ligado apenas ao identificador interno. Como verificar: gerar tráfego em todas as tabelas, executar a anonimização e falhar se uma busca pelo telefone, pelo wa_id ou pelo e-mail original devolver qualquer linha em qualquer tabela.

AC-191 · [BLOQUEADOR]A política de conteúdo não bloqueia o próprio produto. Dado a política de segurança de conteúdo em modo de imposição, quando um visitante abre cada rota com formulário e um assinante pago abre a tela do dia com o player, então o console do navegador não registra nenhuma violação, o verificador anti-bot renderiza e o áudio toca. Como verificar: cenário de navegador que coleta as violações do console em cada rota e falha se houver qualquer uma.

AC-192 · [BLOQUEADOR]Corpo de webhook inválido não derruba o canal de eventos. Dado um webhook autenticado cujo corpo não corresponde ao schema esperado, quando ele é recebido, então a resposta é 200 com corpo vazio, o ocorrido é persistido com o motivo da rejeição, o corpo cru é guardado para reprocessamento e o alerta correspondente dispara acima do limiar. Resposta 4xx ou 5xx leva o provedor a desativar a assinatura do webhook, e perder o canal de eventos é pior do que engolir um corpo malformado. A única resposta não-2xx permitida é 413 para corpo acima do limite, medido antes da leitura.

29.23 Matriz de rastreabilidade #

Funcionalidade do produto, seção que a especifica em detalhe e critérios que a cobrem.

Funcionalidade Seção dona Critérios
Decisões configuráveis e padrões 1 AC-033, AC-034, AC-039, AC-040, AC-068, AC-070
Escopo e fora de escopo 2 AC-093, AC-103
Papéis e permissões 3 AC-029, AC-030, AC-031, AC-032, AC-144, AC-189
Arquitetura e stack 4 AC-170, AC-171, AC-172
Convenções de código 5 AC-041, AC-162, AC-166, AC-188
Modelo de dados 6 AC-003, AC-011, AC-069, AC-106, AC-149, AC-153, AC-164, AC-190
Contratos e erros de API 7 AC-016, AC-018, AC-024, AC-135, AC-166, AC-169, AC-192
Autenticação e sessões 8 AC-014 a AC-032, AC-187, AC-188, AC-189
Landing e página de vendas 9 AC-165, AC-168, AC-191
Copy aprovado 10 AC-002, AC-004, AC-013, AC-098, AC-123, AC-137
Cadastro, opt-in e onboarding 11 AC-001 a AC-013
Integração de pagamentos 12 AC-094 a AC-114, AC-179
Ciclo de vida e entitlements 13 AC-033 a AC-042, AC-107, AC-108, AC-115 a AC-120, AC-140, AC-183, AC-186
Painel do assinante 14 AC-130 a AC-137, AC-146, AC-147, AC-151, AC-183
Painel administrativo 15 AC-043 a AC-055, AC-078 a AC-080, AC-138 a AC-145
Geração de áudio e mídia 16 AC-056 a AC-067, AC-091, AC-180
Integração do canal de mensagens 17 AC-058, AC-065, AC-066, AC-070, AC-071, AC-077, AC-090, AC-142, AC-161, AC-175, AC-178
Motor de envio diário 18 AC-068 a AC-089, AC-173, AC-174, AC-184, AC-185, AC-186
Catálogo de mensagens e conversas 19 AC-082 a AC-087, AC-121 a AC-129, AC-185
Cancelamento, opt-out e retenção 20 AC-115 a AC-129, AC-150
Métricas e dashboard 21 AC-154 a AC-159
Segurança, privacidade e LGPD 22 AC-146 a AC-153, AC-160 a AC-168, AC-187 a AC-191
Observabilidade e alertas 23 AC-169 a AC-177
Testes e QA 24 cobertura de execução de todos os critérios
Infraestrutura e deploy 25 AC-168, AC-171, AC-181
Configuração e variáveis 26 AC-163
Erros, resiliência e runbooks 27 AC-075, AC-076, AC-114, AC-178 a AC-182, AC-192

Contagem. 192 critérios numerados, de AC-001 a AC-192, sem lacuna na sequência, dos quais 78 são bloqueadores de lançamento. Todo bloqueador precisa de evidência automatizada ou de registro de verificação manual antes do go-live da Seção 28.9.

Como conferir a contagem, sem ambiguidade e sem contar a frase explicativa da Seção 29.1:

# Aponte <especificacao> para o arquivo desta especificação.
# Total de critérios numerados: precisa devolver 192
grep -oE '^\*\*AC-[0-9]{3}\*\*' <especificacao> | sort -u | wc -l

# Bloqueadores: precisa devolver 78
grep -cE '^\*\*AC-[0-9]{3}\*\*.*\[BLOQUEADOR\]' <especificacao>

O segundo comando ancora a busca no início da linha do critério. Um grep que procure apenas a palavra entre colchetes conta também a linha da Seção 29.1 que descreve a marcação, e devolve um a mais — foi assim que a contagem anterior divergiu. Se qualquer um dos dois números não bater, a diferença é defeito de documento e é corrigida antes do go-live.

30. Instruções para o Agente Executor #

Esta seção é dirigida diretamente ao agente de inteligência artificial que vai construir o sistema inteiro, sozinho, do repositório vazio até produção, tendo como entrada apenas este documento. Você não pode fazer perguntas. Não há ninguém para responder. Tudo que você precisa decidir está aqui ou é decidível por você seguindo o procedimento da Seção 30.4.

Leia esta seção antes de escrever a primeira linha de código, e releia a Seção 30.3 no início de cada milestone.

30.1 Como ler este documento e em que ordem #

Este documento não é para ser lido linearmente do início ao fim antes de começar. Leia em três passadas.

Passada 1 — orientação (leitura completa, rápida). Leia as Seções 1, 2, 3, 4 e 5, inteiras e com atenção. Elas definem o que o produto é, para quem, com que tecnologia e com que convenções. Depois, percorra o restante lendo apenas os títulos de subseção e as tabelas. O objetivo desta passada é saber onde cada assunto mora, não memorizar detalhe.

Passada 2 — fundação (leitura profunda, antes de M0 e M1). Leia integralmente as Seções 4, 5, 6, 7 e 26. Estas cinco seções são a espinha do sistema: arquitetura, convenções, schema, contrato de API e configuração. Nada do que você construir depois pode contrariá-las. Se você só puder decorar cinco seções, sejam estas.

Passada 3 — sob demanda (no início de cada milestone). Antes de iniciar um milestone da Seção 28, leia integralmente as seções que o milestone referencia, e apenas elas. Ao terminar, leia os critérios de aceitação correspondentes na Seção 29 e confirme que cada um está atendido antes de considerar o milestone concluído.

Regras de leitura que valem sempre:

  • Onde este documento diz "conforme a Seção N", vá ler a Seção N. Não improvise a partir do resumo.
  • Quando duas seções parecerem discordar, a seção dona do assunto vence. A tabela da Seção 30.2 diz quem é dona do quê.
  • Nenhuma seção redefine o que outra já definiu. Se você encontrar o que parece ser uma redefinição, trate como referência e siga a dona.

30.1.1 Precedência, em ordem, quando duas passagens discordarem #

"A seção dona vence" resolve a maior parte dos casos, mas não todos. Quando não resolver, aplique esta escada, na ordem, sem pular degrau:

  1. A Seção 30 vence sobre qualquer outra. As regras invioláveis de 30.3 são o teto.
  2. A seção dona do assunto vence sobre qualquer seção que apenas o mencione. As donas estão na tabela de 30.2. As mais disputadas, explicitamente: Seção 6 para schema; Seção 7 para contrato de API e catálogo de erros; Seção 8 para autenticação e sessão; Seção 13 para planos e entitlements; Seção 18 para filas e motor de envio; Seção 22 para segurança, criptografia e LGPD; Seção 26 para nome e formato de variável de ambiente e de chave de configuração; Seção 29 para critério de aceitação.
  3. Entre duas passagens da mesma seção, vence a que estiver em bloco de código normativo sobre a que estiver em prosa.
  4. Persistindo o conflito, vence a alternativa mais restritiva em segurança e privacidade, e você registra a decisão em DECISIONS.md citando as duas passagens em conflito e a escolha feita.

Nunca invente um terceiro valor e nunca deixe o item pela metade.

Um caso específico que já é decidido, para você não gastar tempo com ele: um critério de aceitação que só possa ser verificado depois do lançamento — porque depende de tráfego real, de aprovação de terceiro ou de um ciclo de cobrança completo — é marcado como pós-lançamento e não bloqueia o marco anterior. A lista desses critérios é fixa e está na Seção 29. Exigir que um marco de homologação prove algo que só existe em produção é um impasse artificial, e impasse trava execução.

30.2 Quem é dono de quê #

Cada assunto tem exatamente uma seção dona. A dona é a fonte da verdade. As demais apenas referenciam.

Assunto Seção dona O que a dona decide
Padrões configuráveis pelo dono do produto 1 Valor padrão de cada decisão aberta e onde ela impacta.
Escopo do MVP e lista de fora de escopo 2 O que se constrói e, sobretudo, o que não se constrói.
Papéis, permissões e matriz de acesso 3 Enum de papéis, o que cada papel pode e não pode fazer.
Versões de dependência e linhas de versão 4 Toda versão citada no projeto. Nenhuma outra seção declara versão.
Arquitetura, monorepo e limites de escala 4 Estrutura de diretórios, responsabilidade de cada app e pacote.
Convenções de nome, camadas, log, erro em código 5 Como o código é escrito e organizado.
Tabelas, colunas, índices, chaves e migrations 6 O schema inteiro. Lista fechada de tabelas.
Envelope de resposta, catálogo de erros, paginação 7 Formato de toda resposta HTTP e todo código de erro.
Rate limiting, idempotência de rota, cabeçalhos 7 Limites, chaves de idempotência e cabeçalhos de segurança de API.
Inventário de rotas 7 Lista mestra de todas as rotas e o papel exigido em cada uma.
Login, sessão, cookie, segundo fator, autorização 8 Todo mecanismo de identidade e de acesso.
Estrutura e comportamento das páginas públicas 9 Blocos, rotas públicas, SEO, performance, acessibilidade.
Texto de marketing e de interface 10 Todo texto voltado ao público e todo microcopy.
Fluxo de cadastro, opt-in e estados do assinante 11 Máquina de estados do assinante e casos de borda do cadastro.
Provedor de pagamento, checkout e webhooks de cobrança 12 Toda chamada ao provedor e todo evento tratado.
Estados da assinatura, inadimplência e entitlements 13 Máquina de estados da assinatura e a matriz de capacidades.
Telas e rotas do painel do assinante 14 Comportamento de cada tela do assinante.
Telas do painel administrativo e fluxo editorial 15 Editor, calendário, gestão de assinantes, auditoria.
Síntese de voz, transcodificação, storage e mídia 16 Pipeline de áudio de ponta a ponta.
Regras da plataforma de mensagens e templates 17 Janela de atendimento, categorias, templates, códigos de erro.
Agendamento, lote, filas e idempotência de envio 18 Motor de envio diário e todas as filas.
Texto de cada mensagem e palavras-chave de entrada 19 Catálogo de mensagens e fluxos conversacionais.
Opt-out, pausa, cancelamento e retenção 20 Os dois caminhos de saída e o que cada um faz.
Definição e fórmula de cada métrica 21 Toda métrica de produto e a agregação diária.
Segurança, privacidade, LGPD e retenção legal 22 Controles, bases legais, direitos do titular, anonimização.
Log, métrica técnica, tracing, health check, alerta 23 Toda instrumentação e todo alerta.
Testes, simulador local e definição de pronto de tarefa 24 Estratégia de teste e critérios de aceite de mudança.
Servidor, contêineres, deploy, backup, continuidade 25 Infraestrutura e entrega.
Variáveis de ambiente e configuração de runtime 26 Toda variável e todo parâmetro configurável.
Taxonomia de erro, retry, disjuntor e runbooks 27 Resiliência e procedimento operacional.
Sequência de construção e milestones 28 Ordem do trabalho e critérios de saída de cada etapa.
Critérios de aceitação e rastreabilidade 29 O que significa estar pronto, do lado de fora.
Procedimento de execução do agente 30 Como você trabalha.
Glossário e referências externas 31 Definição de termo e onde consultar documentação oficial.

30.3 Regras invioláveis #

Estas regras não admitem exceção, interpretação criativa nem "só desta vez". Violar qualquer uma delas invalida o trabalho do milestone.

  1. Não invente tabela. O conjunto de 28 tabelas é fechado na Seção 6.1.7. Se você precisar persistir algo que não cabe em nenhuma delas, use a tabela de configuração chave-valor prevista naquela seção e registre a decisão conforme a Seção 30.4. Nunca crie uma tabela nova.
  2. Não invente coluna sem migration aditiva registrada. Coluna nova exige migration própria, justificativa em DECISIONS.md e atualização do schema declarativo. Nunca altere uma migration já aplicada.
  3. Não redefina o envelope de API. Sucesso, erro, paginação, formato de data, formato de dinheiro e identificador de requisição são definidos na Seção 7 e valem para toda rota, sem variação por conveniência.
  4. Não crie código de erro fora do catálogo. Se um caso novo aparecer, use o código genérico apropriado do catálogo da Seção 7. Se for realmente novo, adicione ao catálogo e registre a decisão — nunca invente um código solto no meio de um arquivo.
  5. Não pine versões exatas. Instale a release estável corrente de cada dependência, confirme que a linha major corresponde ao declarado na Seção 4 e deixe o arquivo de lock registrar as versões resolvidas. Não escreva versão exata em prosa, em comentário nem em documentação.
  6. Não deixe pendência aberta. Nada de TODO, FIXME, TBD, "a definir", função vazia, rota que retorna dado falso ou tela com texto de espaço reservado. Se você não sabe, decida pelo procedimento da Seção 30.4 e implemente a decisão.
  7. Não crie segredo em código. Nenhuma chave, token, senha ou credencial no repositório, em comentário, em teste, em arquivo de exemplo com valor real ou em imagem de contêiner. Segredos vêm exclusivamente do ambiente, validados no boot conforme a Seção 26.
  8. Não envie mensagem real em ambiente que não seja produção. O provedor real do canal de mensagens só pode ser instanciado quando o ambiente é de produção. Em qualquer outro ambiente, o simulador local é obrigatório. Faça o boot falhar se credenciais de produção forem detectadas fora de produção.
  9. Não altere a regra de revogação imediata. Falha de pagamento rebaixa o assinante ao tier gratuito na mesma transação do processamento do evento. Não existe carência, tolerância, período de graça, retentativa silenciosa que mantenha o acesso, nem "só até o fim do dia". A única situação em que o acesso pago sobrevive é o cancelamento voluntário dentro do período já pago, que é coisa diferente e está na Seção 13.
  10. Não recalcule entitlement localmente. Toda decisão de capacidade passa pela função única definida na Seção 13. Nenhum componente, rota, consulta ou trabalho de fila compara o campo de tier diretamente.
  11. Não faça upload de mídia por assinante. O upload para a plataforma de mensagens acontece uma vez por devocional. Reutilize o identificador de mídia.
  12. Não registre dado pessoal sensível em log. Telefone, documento, código de acesso, token e dado de cartão são redigidos pelo logger conforme a Seção 23. Não contorne a redação nem em depuração temporária.
  13. Não use deslocamento fixo de fuso. Toda conversão de horário usa a biblioteca de fuso da Seção 4 com o fuso operacional nomeado. Nunca escreva um deslocamento numérico fixo.
  14. Não use identificador sequencial nem aleatório universal. Identificadores são gerados na aplicação no formato definido na Seção 6.
  15. Não implemente item que a Seção 2 declara fora de escopo. Nem parcialmente, nem "só a estrutura para depois", nem escondido atrás de uma flag desligada.
  16. Não confunda opt-out com cancelamento. São dois caminhos distintos com efeitos distintos, definidos na Seção 20. Um nunca dispara o outro implicitamente.
  17. Não publique devocional sem áudio quando houver assinantes pagos ativos. A guarda está na Seção 15 e é obrigatória.
  18. Não envie conteúdo incompleto. Se o devocional do dia não estiver pronto e não houver conteúdo de reserva, não envie nada e alerte. Melhor um dia sem mensagem do que uma mensagem quebrada.

30.4 Como resolver ambiguidade #

Você vai encontrar situações não cobertas explicitamente. Isso é esperado. Nunca pare, nunca pergunte, nunca deixe pendência. Siga estes três passos, nesta ordem.

Passo 1 — procure a resposta no documento. Consulte a tabela de donos da Seção 30.2 e leia a seção dona do assunto por inteiro. A maior parte das ambiguidades aparentes é uma seção lida pela metade. Verifique também a Seção 1, que traz padrões explícitos para decisões abertas, e a Seção 2, que pode declarar o assunto fora de escopo — nesse caso a resposta é não fazer.

Passo 2 — derive da decisão mais próxima. Se a resposta não estiver escrita, encontre a decisão análoga mais próxima e estenda a mesma lógica. Ordem de preferência para a decisão derivada:

  1. A opção que preserva a regra de revogação imediata e a integridade do entitlement.
  2. A opção que não envia mensagem quando há dúvida sobre elegibilidade ou sobre conteúdo — o erro de omissão é recuperável, o de envio indevido não é.
  3. A opção mais simples de reverter, incluindo migration aditiva em vez de destrutiva.
  4. A opção que respeita o limite mais restritivo da plataforma externa envolvida.
  5. A opção que mantém a consistência com o padrão já usado em outro ponto do sistema.
  6. A opção mais barata de operar, quando as anteriores empatam.

Passo 3 — registre e siga em frente. Grave a decisão em DECISIONS.md, na raiz do repositório, e continue trabalhando no mesmo instante. O registro é obrigatório e o formato é fixo:

### D-007 — Comportamento quando o assinante troca de número no meio de um lote

- **Data:** 2026-09-14
- **Milestone:** M6
- **Contexto:** O documento especifica a troca de número na Seção 14 e o planejamento
  do lote na Seção 18, mas não cruza os dois casos.
- **Alternativas consideradas:**
  1. Enviar para o número antigo, já registrado na tentativa de entrega.
  2. Enviar para o número novo, relendo o assinante no momento do disparo.
  3. Pular o destinatário no dia da troca.
- **Decisão:** Alternativa 2. O disparo relê o assinante e usa o número corrente.
- **Justificativa:** A Seção 30.4 prioriza não enviar para destino incorreto; o número
  antigo pode já pertencer a outra pessoa, o que seria envio indevido a terceiro.
- **Impacto:** `apps/worker/src/jobs/send-dispatch.ts`; nenhuma mudança de schema.
- **Critério afetado:** nenhum critério existente é invalidado.

Regras do registro:

  • Numeração sequencial, D-001 em diante, nunca reutilizada.
  • Uma decisão por entrada, com as alternativas realmente consideradas.
  • Se uma decisão posterior substituir uma anterior, não apague a antiga: adicione a nova e marque a anterior como substituída, indicando o número que a substitui.
  • Se a decisão exigir mudança de comportamento em uma seção deste documento, diga isso explicitamente no campo de impacto. O documento não é editado por você; a divergência fica registrada.
  • Nunca registre em DECISIONS.md algo que já está decidido no documento. Isso não é ambiguidade, é leitura incompleta.

30.5 Ordem de trabalho dentro de um milestone #

Sempre a mesma sequência, em fatias verticais finas. Nunca construa uma camada inteira sem consumidor.

  1. Schema. Se o milestone exige mudança de dados, ela vem primeiro: migration aditiva, schema declarativo atualizado, verificação de ausência de deriva.
  2. Tipos e schemas de validação. Defina os tipos do domínio e os schemas de validação compartilhados entre a aplicação web e o worker, no pacote de domínio. Eles são o contrato de tudo que vem depois.
  3. Serviços de domínio. Implemente a regra de negócio pura, sem HTTP, sem fila, sem interface. Testável com chamada direta de função.
  4. Integrações. Se o milestone toca serviço externo, implemente o cliente tipado no pacote de integrações, com timeout, tentativas e disjuntor, e o duplo correspondente no simulador local.
  5. Rotas e trabalhos de fila. Exponha o serviço pelo transporte adequado, com validação na borda, envelope de resposta e todos os códigos de erro previstos.
  6. Interface. Só depois de a rota existir e responder corretamente. Construa os estados de carregamento, vazio e erro junto com o estado feliz, não depois.
  7. Testes. Unitários para a regra pura, de integração para a rota e o trabalho de fila, ponta a ponta para o fluxo do usuário. Escreva o teste do caso de erro antes de considerar o caso feliz pronto.
  8. Verificação. Rode a bateria completa da Seção 30.7 e confirme os critérios da Seção 29 que o milestone cobre.

Dentro de cada passo, prefira terminar um caminho completo a começar três. Um fluxo inteiro funcionando vale mais que três fluxos pela metade.

30.6 Padrão de commit e de pull request #

Branches. Trabalhe em branch nomeada pelo milestone e pelo assunto, no formato m6/send-engine-dispatch. Nunca faça commit direto no branch padrão.

Commits. Siga a convenção de commits definida na Seção 5. Um commit por unidade lógica coerente. Nunca misture migration com refatoração, nem correção com funcionalidade nova.

feat(worker): planeja lote diário com idempotência por assinante e data

Implementa o trabalho de planejamento descrito na Seção 18: monta a coorte,
aplica as exclusões de opt-out, pausa e exclusão, e grava as tentativas de
entrega com a chave única do dia.

Cobre AC-068 e AC-069.

Regras:

  • Assunto no imperativo, em português, com no máximo 72 caracteres.
  • Corpo explicando o porquê, não o quê. O diff já mostra o quê.
  • Rodapé citando os critérios da Seção 29 cobertos pelo commit, quando houver.
  • Nunca cite número de seção errado. Se você não sabe qual critério o commit cobre, provavelmente o commit não deveria existir ainda.

Pull request. Um por milestone, ou um por fatia vertical coerente quando o milestone for grande. O corpo do pull request tem sempre estas partes:

**Milestone**
M6 — Integração do canal de mensagens e motor de envio (parcial: planejamento e disparo)

**O que muda**
Descrição objetiva do comportamento novo, do ponto de vista de quem usa o sistema.

**Seções do documento implementadas**
Seção 17.4, Seção 18.2, Seção 18.5.

**Critérios de aceitação cobertos**
AC-068, AC-069, AC-070, AC-071, AC-081.

**Como verificar**
Comandos exatos que um revisor pode rodar, com a saída esperada.

**Decisões registradas**
D-007, D-008.

**Risco e reversão**
O que pode quebrar e como reverter.

O pull request só entra quando todos os comandos da Seção 30.7 passam e todos os critérios listados estão verdes.

30.7 Como verificar o próprio trabalho #

Antes de considerar qualquer tarefa pronta, rode esta bateria por inteiro e exija saída limpa. Não existe "passa quase tudo".

# 1. Integridade do workspace
pnpm install --frozen-lockfile

# 2. Tipos — zero erro, em todos os pacotes
pnpm -r typecheck

# 3. Estilo e regras de código — zero erro, zero aviso
pnpm -r lint

# 4. Formatação — nada fora do padrão
pnpm exec prettier --check .

# 5. Sem pendência textual proibida no código versionado
! grep -rInE '\b(TODO|FIXME|XXX|TBD|a definir)\b' apps packages --include='*.ts' --include='*.tsx'

# 6. Sem deriva entre schema declarativo e migrations
pnpm --filter @app/db exec prisma migrate diff \
  --from-migrations ./prisma/migrations \
  --to-schema-datamodel ./prisma/schema.prisma \
  --exit-code

# 7. Testes unitários e de integração
pnpm -r test

# 8. Build de produção de todos os aplicativos
pnpm -r build

# 9. Varredura de dependências — nada alto ou crítico
pnpm audit --audit-level=high

# 10. Nenhum segredo no repositório (mesma ferramenta do pipeline, Seção 24.3)
gitleaks detect --no-banner --redact

# 11. Ponta a ponta (a partir de M8; obrigatório em M12 e M13)
pnpm test:e2e

Regras de verificação:

  • Se um comando falhar, corrija a causa. Nunca silencie a regra, nunca marque o teste como ignorado, nunca acrescente exceção de lint para fazer o comando passar.
  • Se um teste for legitimamente inaplicável, remova-o com justificativa registrada em DECISIONS.md. Não o deixe desativado.
  • Rode a bateria em ambiente limpo antes de abrir o pull request: contêineres recriados, banco recriado a partir das migrations, seeds reaplicados.
  • Depois da bateria, confira manualmente os critérios da Seção 29 que o milestone cobre. Um critério que nenhum teste exercita é um critério não atendido.

30.8 Quando um serviço externo não estiver disponível #

Nenhum milestone pode ficar bloqueado por indisponibilidade de terceiro. O simulador local descrito na Seção 24 existe exatamente para isso e é a única forma permitida de desenvolver contra serviços externos fora de produção.

Regra geral. Todo cliente de serviço externo mora no pacote de integrações atrás de uma interface. A escolha entre implementação real e simulada é feita no ponto de composição, a partir do ambiente, e nunca dentro da regra de negócio. Nenhum serviço de domínio sabe se está falando com o mundo real.

Plataforma de mensagens. Use o simulador enquanto os templates não estiverem aprovados e sempre que o ambiente não for produção. O simulador precisa reproduzir: aceite de envio com identificador de mensagem, sequência de estados de mensagem, webhook de entrada com assinatura válida, abertura de janela de atendimento, e os códigos de erro nomeados na Seção 17, incluindo o código de limitação de entrega esperado no Brasil, o de fora da janela e o de limite de taxa. Sem cobrir esses cenários, o simulador não serve.

Provedor de pagamento. Use o ambiente de teste oficial do provedor quando ele estiver disponível, porque ele reproduz o contrato real melhor que qualquer simulação. Quando ele não estiver disponível, use o simulador local, que precisa emitir os doze eventos obrigatórios da Seção 12, permitir emissão fora de ordem, permitir entrega duplicada do mesmo evento e permitir simular vencimento e estorno.

Síntese de voz. O simulador devolve um arquivo de áudio pré-gravado curto, com duração e formato válidos, para que a transcodificação e as verificações de qualidade sejam exercitadas de verdade. Nunca devolva um arquivo vazio: isso mascara a verificação de áudio silencioso.

Armazenamento de mídia. Use um serviço compatível local em contêiner. O código nunca deve depender de comportamento específico de um fornecedor.

E-mail transacional. Em ambiente que não seja produção, as mensagens são gravadas em arquivo e expostas em uma tela de inspeção local. Nada sai para a internet.

Manutenção do simulador. O simulador fica defasado se ninguém cuidar. A Seção 24 define o mecanismo: os testes de contrato validam as respostas simuladas contra as respostas reais gravadas, e o pipeline falha quando a gravação real e a simulada divergem em forma. Quando você tocar em um cliente de integração, atualize o simulador no mesmo commit.

O que nunca fazer. Nunca aponte o ambiente local para credenciais de produção. Nunca envie mensagem real para número que não seja de teste interno. Nunca crie cobrança real para validar comportamento. Nunca desligue a validação de assinatura de webhook "porque o simulador não assina" — o simulador assina.

30.9 Contas e credenciais que dependem de um humano #

Você não consegue criar estas contas sozinho: elas exigem identidade jurídica, documento, cartão ou aceite de contrato. Peça-as no formato de bloqueio registrado, siga trabalhando no que não depende delas e retome quando chegarem. A coluna "quando é necessária" indica o milestone que fica bloqueado sem ela.

# Conta ou credencial Quem cria Quando é necessária O que fica bloqueado sem ela Alternativa enquanto não chega
1 Domínio registrado e DNS Dono do produto Antes de M13, e antes de X1 Verificação de negócio e TLS de produção Trabalhar em domínio local
2 Conta de gerenciamento de negócios na plataforma de mensagens Dono do produto Início do projeto (X0) Toda a trilha externa Simulador local
3 Verificação de negócio aprovada Dono do produto, com documentos da empresa Início do projeto (X1) Envio real e crescimento de tier Simulador local
4 Conta de mensagens e número dedicado Dono do produto X2, antes de M13 Envio real Número de teste da plataforma
5 Token de acesso de longa duração da plataforma de mensagens Dono do produto Antes de M13 Envio real em produção Simulador local
6 Segredo de aplicativo para validação de assinatura de webhook Dono do produto Junto com o item 5 Recebimento real de mensagens Simulador local assinado
7 Templates aprovados Submetidos pelo sistema, aprovados pela plataforma (X4) Antes de M13 Envio real do convite e do código de acesso Simulador local
8 Conta de teste do provedor de pagamento Dono do produto Antes de M7 (Y0) Testes de checkout com contrato real Simulador local de pagamento
9 Conta de produção do provedor de pagamento e chave Dono do produto, com dados bancários Antes de M13 (Y1) Cobrança real Lançar com checkout desativado por flag
10 Token do webhook do provedor de pagamento Dono do produto Junto com o item 9 Processamento real de eventos de cobrança Simulador local
11 Conta do provedor primário de síntese de voz Dono do produto Antes de M5 Áudio com a voz definitiva Simulador com áudio de exemplo
12 Conta do provedor de fallback de síntese de voz Dono do produto Antes de M13 Resiliência do pipeline de áudio Fallback simulado
13 Armazenamento compatível com o padrão de objetos e credenciais Dono do produto Antes de M5 em produção Persistência de mídia em produção Serviço compatível local
14 Conta de e-mail transacional e domínio verificado para envio Dono do produto Antes de M8 Link mágico e e-mails transacionais reais Captura local de e-mail
15 Servidor de produção com acesso administrativo Dono do produto Antes de M13 Todo o M13 Ambiente local em contêineres
16 Destino externo de backup e suas credenciais Dono do produto Antes de M13 Backup fora do servidor Backup local temporário
17 Canal de alertas operacionais Dono do produto Antes de M10 Entrega real de alerta Alerta gravado em log
18 Razão social, CNPJ, endereço e nome do encarregado de dados Dono do produto Antes de M9 Páginas legais e verificação de negócio Marcadores explícitos, substituídos antes do go-live

Quando um item da tabela estiver faltando no momento em que você precisa dele: registre o bloqueio em DECISIONS.md como decisão de contorno, ative o caminho alternativo da última coluna, siga para o próximo trabalho não bloqueado da Seção 28.6 e deixe o ponto de troca isolado em um único lugar do código, para que a substituição seja trivial.

30.10 Definição de pronto #

Uma tarefa está pronta quando: o comportamento especificado existe; os casos de erro estão tratados com os códigos do catálogo; existe teste automatizado cobrindo o caso feliz e ao menos um caso de erro; a bateria da Seção 30.7 passa inteira; e nenhuma pendência textual proibida foi introduzida.

Um milestone está pronto quando: todas as suas tarefas estão prontas; todos os artefatos listados existem no repositório; todos os critérios de saída da Seção 28.4 correspondente foram executados e passaram; todos os critérios da Seção 29 mapeados ao milestone estão verdes; e as decisões tomadas no período estão registradas em DECISIONS.md.

O projeto inteiro está pronto quando, e somente quando, todos os itens abaixo forem verdadeiros:

  1. Os catorze milestones da Seção 28 estão concluídos pelos seus próprios critérios de saída.
  2. Os 192 critérios da Seção 29 têm resultado registrado, e os 78 marcados como bloqueadores estão verdes. As duas contagens são conferidas pelos comandos da Seção 29.23.
  3. O checklist de go-live da Seção 28.9 está integralmente cumprido, incluindo os itens que dependem de terceiros.
  4. O sistema está em produção, com todos os contêineres saudáveis por mais de 24 horas.
  5. Um lote diário real foi disparado, concluído dentro da janela alvo, com taxa de entrega dentro da faixa saudável da Seção 28.10.
  6. Um assinante real completou o percurso inteiro: cadastro, opt-in, recebimento gratuito, compra, recebimento pago com áudio, cancelamento e rebaixamento.
  7. Um pagamento real falhou, de propósito ou naturalmente, e o acesso foi revogado imediatamente, sem carência, comprovado no banco e no envio seguinte.
  8. Um backup foi restaurado com sucesso e o tempo de recuperação foi registrado.
  9. Um rollback foi executado com sucesso em ambiente de produção ou em réplica fiel dele.
  10. Todos os alertas da Seção 23 dispararam ao menos uma vez em teste, no canal correto.
  11. O direito de acesso e o de eliminação foram exercidos de ponta a ponta por um titular de teste, com evidência.
  12. Nenhum segredo consta do repositório, das imagens ou dos logs.
  13. DECISIONS.md está completo e coerente com o código.
  14. A documentação de operação existe, com os runbooks da Seção 27 acessíveis ao operador.

Faltando qualquer um dos catorze, o projeto não está pronto. Não negocie com esta lista.

30.11 Erros comuns a evitar #

Estes erros são específicos deste produto e todos já custaram caro em sistemas parecidos. Leia a lista antes de M6 e de novo antes de M7.

  1. Tentar enviar áudio como primeira mensagem do dia. O cabeçalho de template não aceita áudio. Todo desenho que dependa disso está errado. O caminho correto é o desenho em duas etapas da Seção 17.
  2. Colocar o devocional inteiro dentro de um parâmetro de template. Parâmetro não aceita quebra de linha, tabulação nem quatro espaços seguidos, e o corpo tem limite de caracteres bem menor que o texto completo. Use o teaser sanitizado.
  3. Esquecer de sanitizar o teaser vindo de conteúdo editado por humano. Um editor cola texto de um processador de texto e traz caracteres invisíveis. A sanitização precisa ser aplicada na escrita e verificada de novo antes do envio.
  4. Fazer upload do áudio uma vez por assinante. Isso multiplica custo e tempo por milhares. O upload é uma vez por devocional.
  5. Ignorar a validade do identificador de mídia. Ele expira. Sem verificação e reupload automático, os envios começam a falhar em massa depois de algumas semanas, e o sintoma aparece longe da causa.
  6. Verificar entitlement no planejamento e não no disparo. Um assinante rebaixado às 05:50 não pode receber áudio às 06:00. Relê o assinante por chave primária imediatamente antes de chamar o provedor, dentro do mesmo trabalho de fila, e aplica o resultado: excluído ou bloqueado não recebe nada, com opt-out não recebe nada, e rebaixado recebe só o texto. O campo de tier gravado no planejamento é registro histórico, nunca autoridade. Sem essa releitura o produto concede, na prática, um dia de carência a todo inadimplente — exatamente o que a regra 9 proíbe.
  7. Implementar carência por engano. Um retry silencioso de cobrança, um estado intermediário, ou um "aguarda o fim do dia" reintroduzem carência sem que ninguém perceba. A revogação é na mesma transação.
  8. Confundir cancelamento voluntário com inadimplência. Um mantém o acesso até o fim do período pago, o outro corta na hora. Implementar os dois pelo mesmo caminho quebra os dois.
  9. Deixar o handler de webhook fazer trabalho pesado. O provedor de pagamento pausa a fila se o endpoint demorar. Persista o evento bruto, enfileire e responda.
  10. Não deduplicar evento de webhook. Reentrega é normal, não exceção. Sem chave única por identificador de evento, um pagamento vira dois e um cancelamento vira dois.
  11. Assumir que o identificador do contato no canal de mensagens é igual ao número cadastrado. No Brasil, o nono dígito faz os dois divergirem. Sem a busca em cascata da Seção 11, mensagens de entrada não encontram o assinante e a janela nunca abre.
  12. Normalizar telefone com expressão regular caseira. Use a biblioteca prevista, com região padrão brasileira. Toda tentativa artesanal falha em algum caso de discagem.
  13. Usar deslocamento fixo de fuso no agendador. Use o fuso nomeado. Deslocamento fixo é uma bomba-relógio silenciosa.
  14. Planejar o lote pelo horário do servidor em vez do horário operacional. O servidor pode estar em outro fuso. A data do devocional é sempre a data local operacional.
  15. Não tornar o planejamento idempotente. Uma reexecução do agendador, um reinício de contêiner ou um disparo manual duplicam o envio. A chave única resolve; a ausência dela é irrecuperável do ponto de vista do assinante.
  16. Contar como falha o código de limitação de entrega esperado no Brasil. Isso polui a métrica de entrega, dispara alerta falso e faz o operador perseguir um problema que não existe.
  17. Repetir envio para erro permanente. Número inexistente não melhora com retry. Classifique o erro antes de decidir repetir.
  18. Enviar mensagem de saída mais de uma vez. Quem pediu para sair e recebe três confirmações marca o número como incômodo, e a qualidade do número cai.
  19. Responder automaticamente a mensagens automáticas. Isso cria laço infinito. Aplique o limite anti-laço da Seção 19.
  20. Deixar a landing prometer o que o produto não faz. Horário personalizado, período de teste, aplicativo, grupo, pedido de oração — nada disso existe. O texto aprovado da Seção 10 é a única fonte.
  21. Renderizar o comparativo de planos com valores fixos no componente. Ele deriva da matriz de entitlements. Fixar valores garante que a página e o sistema divirjam na primeira mudança.
  22. Expor URL pública e permanente de mídia. As URLs são assinadas e de curta duração. Uma URL permanente vaza conteúdo pago.
  23. Persistir ou registrar em log dado de cartão. Nem para depurar, nem por um minuto, nem mascarado pela metade.
  24. Coletar o documento fiscal sem tratá-lo como dado pessoal. Ele entra no inventário, na criptografia e na anonimização, conforme a Seção 22.
  25. Anonimizar apagando linha. Registros com retenção legal precisam sobreviver pseudonimizados. Apagar quebra a obrigação fiscal e a rastreabilidade de consentimento.
  26. Publicar devocional sem áudio existindo assinante pagante. O pagante recebe menos do que comprou e a falha só aparece às 06:00.
  27. Deixar o calendário editorial esvaziar. Sem o alerta de menos de sete dias agendados, o problema aparece na madrugada, quando ninguém está olhando.
  28. Tratar o conteúdo de reserva como opcional. Ele é o que separa um dia sem conteúdo de um incidente visível para milhares de pessoas.
  29. Rodar migration destrutiva em uma fase. Sempre duas fases, conforme a Seção 25.
  30. Deixar o backup sem restauração testada. Backup nunca restaurado é backup inexistente.
  31. Criar tabela nova por conveniência. A lista é fechada. Use a tabela de configuração chave-valor e registre a decisão.
  32. Escrever versão exata de dependência em prosa ou em comentário. Só a linha major, e só na seção dona.
  33. Deixar tela sem estado vazio e sem estado de erro. O assinante que abre o painel no primeiro dia vê exatamente o estado vazio.
  34. Traduzir identificador técnico. Tabela, coluna, rota, variável de ambiente, campo de JSON, valor de enum e mensagem de log ficam em inglês. Texto para pessoa fica em português.
  35. Adicionar funcionalidade não pedida. Se está na lista de fora de escopo, não existe. Se não está no documento, não é para construir agora.
  36. Emitir cookie com prefixo __Host- e caminho estreito. O prefixo exige Secure, Path=/ e ausência de Domain. Qualquer outro caminho faz o navegador descartar o cookie sem erro, e o sintoma em produção é o usuário deslogando sozinho, com causa raiz invisível para quem lê o log do servidor.
  37. Dar dois nomes à mesma coisa. Um segredo, uma fila, uma classe de erro, uma chave de configuração e um cookie têm exatamente um nome cada. Dois nomes para a mesma coisa é a falha mais barata de cometer e a mais cara de encontrar: o processo não sobe, a fila não consome, o 403 aparece em toda escrita, e nada disso se parece com um erro de nome.
  38. Criar fila de mensagens mortas. Não existe. Trabalho morto é job no estado failed da própria fila mais linha em job_runs com estado morto. Criar uma fila paralela produz dois inventários de falha que divergem no primeiro incidente.
  39. Responder erro a um webhook malformado. Autenticado o remetente, qualquer falha posterior responde 2xx e é persistida. Resposta 4xx ou 5xx leva o provedor a desativar a assinatura do webhook, e é por esse canal que a revogação de acesso pago acontece.
  40. Confiar em um identificador de dispositivo derivado de cabeçalho. User-agent e idioma são controlados pelo cliente e têm entropia baixíssima. Servem como sinal informativo, nunca como fator de autenticação nem como decisão de autorização.
  41. Deixar um controle de integridade impedir um direito do titular. Tabela somente-inserção precisa de um caminho privilegiado, explícito e auditado, para a anonimização exigida por lei e para o expurgo por retenção. Um gatilho que bloqueia tudo faz a eliminação falhar em silêncio e o prazo legal vencer.
  42. Entregar conta, documento ou histórico a um número só porque ele respondeu. Números são reciclados pelas operadoras. Mudança do identificador de contato associado a um telefone invalida as sessões daquele assinante e exige nova verificação por código antes de liberar qualquer dado pessoal.

31. Glossário e Referências Externas #

31.1 Glossário #

Termos usados ao longo do documento, com a definição que vale aqui. Onde o termo tem significado específico neste produto, a definição prevalece sobre o uso geral do mercado.

31.1.1 Termos de negócio e de produto #

Termo Definição
Devocional Peça de conteúdo cristão diária composta por título, referência bíblica, texto bíblico, reflexão e oração. É a unidade de entrega do produto. Um por dia de calendário.
Teaser Resumo curto do devocional, com no máximo 300 caracteres, sem quebra de linha, sem tabulação e sem quatro ou mais espaços seguidos. Existe porque parâmetro de template não aceita esses caracteres. Ver Seção 15.
Pacote completo Conjunto de mensagens livres enviado depois que a janela de atendimento abre: texto integral, áudio quando o tier permite, e mensagem de fechamento. Ver Seção 17.
Convite Primeira mensagem do dia, enviada por template aprovado, com título, teaser e botões de ação. Não contém o devocional inteiro.
Acervo Coleção de devocionais anteriores acessível no painel do assinante. Limitado a sete dias no tier gratuito e completo, desde a primeira assinatura paga, no tier pago. O limite é imposto no servidor, não apenas na interface. Ver Seção 13.6.1.
Freemium Modelo comercial em que existe um plano gratuito permanente com capacidades reduzidas e um plano pago com capacidades completas. Não há período de teste do plano pago.
Tier Nível de acesso do assinante. Existem exatamente dois: gratuito e pago. Não há plano intermediário.
Entitlement Capacidade concedida a um assinante em função do seu tier. Resolvido por uma função única, conforme a Seção 13.
Opt-in Consentimento explícito para receber mensagens. Neste produto é obrigatoriamente em duas etapas: marcação no formulário web e confirmação ativa no canal de mensagens.
Opt-out Pedido de parar de receber mensagens. Tem efeito imediato sobre os envios e suspende a cobrança do ciclo seguinte, sem cancelar a assinatura de imediato — um pedido acidental não destrói a contratação. Passados 30 dias sem reativação, a assinatura é encerrada ao fim do período já pago. Ver Seção 20.
Pausa temporária Suspensão voluntária dos envios por um período de 1 a 30 dias, com retorno automático. Diferente de opt-out.
Cancelamento voluntário Pedido de parar de pagar. O acesso pago permanece até o fim do período já pago. Não é carência.
Inadimplência Situação em que uma cobrança não foi paga. Neste produto, causa revogação imediata do acesso pago.
Carência Período de tolerância em que o acesso pago sobreviveria à falha de pagamento. Não existe neste produto. O termo aparece apenas para ser negado.
Revogação imediata Rebaixamento do assinante ao tier gratuito na mesma transação em que o evento de falha de pagamento é processado. Regra dura da Seção 13.
Cortesia Concessão manual de acesso pago por um número de dias, feita por um administrador, com auditoria e expiração automática.
Conteúdo de reserva Devocional atemporal, marcado como perene, usado quando o conteúdo do dia não está pronto. Não ocupa data no calendário.
Lote de envio Conjunto de destinatários planejado para um dia específico, com uma tentativa de entrega registrada por assinante.
Coorte de destinatários Lista de assinantes elegíveis a receber em um dia, depois de aplicadas todas as exclusões.
Fallback de vídeo Envio por template com cabeçalho de vídeo, usado no quarto dia consecutivo sem interação de um assinante pago, para entregar áudio sem depender de interação.
Reenvio manual Ação do assinante que solicita novamente o devocional do dia, limitada por tier.
Churn Proporção de assinaturas que deixaram o estado ativo em um período sobre as ativas no início do período. Fórmula na Seção 21.
MRR Receita recorrente mensal. Soma normalizada por mês das assinaturas ativas, com o plano anual dividido por doze. Fórmula na Seção 21.
ARR Receita recorrente anual. Projeção anualizada do MRR.
ARPU Receita média por assinante ativo no período.
LTV Valor estimado que um assinante gera ao longo do seu ciclo de vida.
Taxa de conversão Proporção de assinantes que passaram do tier gratuito ao pago em um período.
Taxa de abertura da janela Proporção de assinantes que interagiram com o convite sobre os que o receberam. Indicador central deste produto.
Win-back Campanha de recuperação de quem cancelou. Proibida pelo canal de mensagens para quem fez opt-out.
Encarregado de dados Pessoa nomeada como canal entre titulares, autoridade e a empresa. Ver Seção 22.

31.1.2 Termos do canal de mensagens #

Termo Definição
WABA Conta de negócios do WhatsApp. Entidade que agrupa números, templates e configurações do canal de mensagens.
Cloud API Interface oficial hospedada pela Meta para envio e recebimento de mensagens, usada diretamente neste produto, sem intermediário.
BSP Provedor de solução de negócios. Intermediário credenciado que revende acesso ao canal. Não é usado aqui, mas a camada de abstração permite adotar um sem reescrever o motor de envio.
Graph API Interface HTTP da Meta sobre a qual a Cloud API é exposta. Versionada por caminho de URL.
Janela de atendimento Período de 24 horas aberto por qualquer mensagem enviada pelo usuário, dentro do qual mensagens livres são permitidas e não têm custo por mensagem. Conceito central do desenho do produto.
Mensagem livre Mensagem enviada dentro da janela de atendimento, sem template, com até 4096 caracteres de texto ou mídia.
Template Modelo de mensagem previamente aprovado pela Meta, obrigatório para iniciar conversa fora da janela de atendimento.
Categoria de template Classificação que determina regras e custo. As categorias relevantes aqui são utilidade, marketing, autenticação e atendimento.
Parâmetro de template Valor variável injetado no corpo ou no cabeçalho de um template. Não pode conter quebra de linha, tabulação nem quatro ou mais espaços consecutivos.
Cabeçalho de template Parte superior opcional de um template. Aceita texto, imagem, documento, vídeo e localização. Não aceita áudio — restrição que define todo o desenho de entrega.
Botão de resposta rápida Botão de template que, ao ser tocado, envia uma carga útil ao sistema e abre a janela de atendimento.
Quality rating Classificação de qualidade do número, derivada do comportamento dos destinatários. Cair para o nível crítico ameaça a operação.
Messaging tier Limite de contatos únicos que o número pode iniciar conversa em 24 horas. Evolui com volume e qualidade.
wa_id Identificador do contato no canal de mensagens, retornado pela plataforma. Pode divergir do número cadastrado por causa do nono dígito no Brasil.
Phone number ID Identificador do número de envio dentro da conta de negócios, usado no caminho das chamadas de envio e de mídia.
Media ID Identificador de um arquivo previamente enviado à plataforma, reutilizável em vários envios e com validade limitada. Ver Seção 16.
Media API Interface de envio e recuperação de arquivos da plataforma de mensagens.
Assinatura de webhook Cabeçalho com o resumo criptográfico do corpo bruto da requisição, usado para provar que o evento veio da plataforma. Verificado em tempo constante.
Token de verificação Valor combinado usado na verificação inicial do endpoint de webhook.
Verificação de negócio Processo em que a Meta confirma a existência legal da empresa. Pré-requisito para limites maiores e para operação estável.
Nome de exibição Nome que o assinante vê como remetente. Passa por aprovação.

31.1.3 Termos do provedor de pagamento #

Termo Definição
Customer Registro do pagador no provedor de pagamento. Exige nome e documento, o que obriga a coleta de CPF no checkout.
Subscription Registro de assinatura recorrente no provedor, com ciclo, valor e forma de cobrança.
Payment Cobrança individual gerada por uma assinatura ou avulsa.
billingType Campo que define a forma de cobrança. Neste produto, cartão de crédito ou PIX. Boleto está fora de escopo.
cycle Periodicidade da assinatura. Neste produto, mensal ou anual.
cpfCnpj Documento fiscal do pagador, obrigatório para criar o registro de cliente. Tratado como dado pessoal conforme a Seção 22.
externalReference Campo livre usado para gravar o nosso identificador de assinatura dentro do registro do provedor, permitindo correlação segura.
creditCardToken Referência opaca a um cartão tokenizado. Substitui o dado do cartão em cobranças futuras.
Fila de webhooks Mecanismo do provedor que entrega eventos em ordem e pausa após falhas consecutivas do nosso endpoint. Motivo pelo qual o handler responde rápido.
PIX copia-e-cola Representação textual do código de pagamento instantâneo, que o pagador cola no aplicativo do banco.
Reconciliação Comparação diária entre o estado das assinaturas e cobranças no provedor e o estado no nosso banco, com correção automática. O provedor vence em caso de divergência.
Chargeback Contestação de cobrança feita pelo portador do cartão junto ao emissor. Causa revogação imediata do acesso.
Estorno Devolução de um pagamento já recebido. Causa revogação imediata do acesso.

31.1.4 Termos técnicos do projeto #

Termo Definição
Monorepo Repositório único que contém os três aplicativos e os três pacotes compartilhados do sistema. Ver Seção 4.
Worker Processo separado que executa trabalhos de fila e tarefas agendadas. Não atende tráfego público.
Agendador Componente que registra os trabalhos repetíveis com o fuso operacional e dispara o planejamento e o envio nos horários definidos.
ULID Identificador de 26 caracteres, ordenável por tempo de criação, gerado na aplicação. Formato canônico de todas as chaves primárias. Ver Seção 6.
Envelope de API Formato fixo de toda resposta HTTP interna, com bloco de dados ou de erro e bloco de metadados. Definido na Seção 7.
requestId Identificador único de uma requisição, ecoado em cabeçalho, presente no envelope e em todos os registros de log correlacionados.
Paginação por cursor Estratégia de paginação baseada em um marcador opaco, estável sob inserção concorrente. Única permitida neste projeto.
Idempotência Propriedade de uma operação que produz o mesmo efeito quando executada uma ou várias vezes com a mesma entrada.
Chave de idempotência Valor que identifica unicamente uma operação, garantido por índice único no banco. Neste produto, existe uma para planejamento diário e uma para envio por assinante e data.
Disjuntor Mecanismo que interrompe chamadas a uma dependência externa após um número de falhas, evitando desperdício e cascata, e que testa a recuperação periodicamente. Ver Seção 27.
Trabalho morto Job que esgotou todas as tentativas. Fica no estado failed da própria fila e grava uma linha de estado morto em job_runs, que é a dead-letter do sistema. Inspecionável e reprocessável por comando de operação, com alerta associado. Não existe fila de mensagens mortas separada. Ver Seção 18.8.
Backoff exponencial Estratégia de espera crescente entre tentativas.
Jitter Variação aleatória aplicada à espera entre tentativas, para evitar que muitos clientes tentem no mesmo instante.
Limitador de taxa Componente que restringe quantas operações ocorrem por unidade de tempo. Usado no envio e nas rotas públicas.
Roteiro de narração Texto linear construído a partir do devocional para ser lido pela síntese de voz, com marcação removida e referências expandidas por extenso. Ver Seção 16.
Síntese de voz Conversão de texto em áudio falado por um provedor externo. Há um provedor primário e um de fallback.
Transcodificação Conversão do áudio gerado para os formatos exigidos: um para mensagem de voz no canal de mensagens e outro para o player web.
Normalização de volume Ajuste do áudio para um nível percebido consistente entre devocionais.
URL assinada Endereço temporário que autoriza o acesso a um arquivo em armazenamento privado, com prazo curto de validade.
Soft delete Marcação de exclusão sem remoção física da linha. Permitido apenas nas três tabelas indicadas na Seção 6.
Migration aditiva Alteração de schema que apenas acrescenta, sem remover nem renomear, e por isso é segura de aplicar antes do deploy do código.
Migration destrutiva Alteração que remove ou renomeia estrutura. Sempre em duas fases, conforme a Seção 25.
Deriva de schema Divergência entre o schema declarativo e o conjunto de migrations aplicadas. Detectada no pipeline e tratada como falha de build.
Feature flag Chave que liga ou desliga um comportamento em tempo de execução, sem novo deploy. Catálogo na Seção 26.
Configuração de runtime Parâmetro editável pelo administrador durante a operação, armazenado no banco, distinto de variável de ambiente.
Simulador local Implementação local dos serviços externos, usada em desenvolvimento e teste, obrigatória fora de produção. Ver Seção 24.
Interruptor geral Comando que interrompe todos os envios imediatamente, com efeito global e reversão explícita. Ver Seção 27.
Backfill Reprocessamento de um período passado, executado por comando de operação.
Health check de vivacidade Verificação de que o processo está vivo.
Health check de prontidão Verificação de que o processo consegue atender, incluindo suas dependências.
Trilha de auditoria Registro imutável de ações administrativas, com autor, alvo, valores anterior e novo, e horário.
Criptografia de campo Cifra aplicada pela aplicação, e não pelo banco, sobre o valor de uma coluna antes de gravar. O que fica armazenado é um envelope; o banco nunca vê o valor. Por isso a coluna cifrada não recebe restrição de formato de conteúdo e a validação de formato acontece na borda. Ver Seção 22.5.
Índice cego Coluna auxiliar que guarda o resumo criptográfico com chave do valor original, permitindo busca por igualdade e unicidade sem armazenar o valor em claro. Toda busca por telefone, identificador de contato, documento ou e-mail passa por ela. Ver Seções 6.3 e 22.5.
Revalidação no disparo Releitura do estado do assinante por chave primária imediatamente antes de chamar o provedor de mensagens. O nível de acesso gravado no planejamento é registro histórico, nunca autoridade. Ver Seção 18.5.
Classe de erro da aplicação AppError, definida em packages/core/src/errors.ts. É a única classe de erro de negócio do sistema; nenhum outro nome é aceito. Ver Seção 5.7.

31.1.5 Termos de proteção de dados #

Termo Definição
LGPD Lei Geral de Proteção de Dados Pessoais, Lei nº 13.709 de 2018, que rege o tratamento de dados pessoais no Brasil.
Titular Pessoa natural a quem os dados pessoais se referem. Neste produto, o assinante.
Controlador Quem decide sobre o tratamento dos dados. Neste produto, a empresa que opera o serviço.
Operador Quem trata dados em nome do controlador. Neste produto, os fornecedores de mensagens, pagamento, síntese de voz, armazenamento e e-mail.
Base legal Fundamento jurídico que autoriza um tratamento. Aqui, principalmente consentimento e execução de contrato, com obrigação legal para dados fiscais. Ver Seção 22.
Consentimento Manifestação livre, informada e inequívoca do titular. Registrado de forma imutável e versionada.
Execução de contrato Base legal que autoriza o tratamento necessário para entregar o serviço contratado pelo assinante pagante.
Legítimo interesse Base legal aplicável a tratamentos de segurança e prevenção a fraude, com avaliação registrada.
Encarregado Pessoa indicada como canal de comunicação entre controlador, titulares e autoridade. Contato público obrigatório.
ANPD Autoridade Nacional de Proteção de Dados. Órgão responsável por fiscalizar a aplicação da lei e destinatário de notificações de incidente.
Anonimização Transformação que impede a identificação do titular de forma irreversível. Alcança todas as tabelas que guardam identificador pessoal, inclusive os registros de mensagem: nenhuma retém telefone, identificador de contato, documento ou e-mail em claro depois dela. O que a lei obriga a reter é preservado de forma pseudonimizada, ligado apenas ao identificador interno.
Pseudonimização Substituição de identificadores diretos por referências, mantendo utilidade e vínculo interno controlado.
Portabilidade Direito do titular de obter seus dados em formato estruturado e interoperável. Atendido por exportação em JSON.
Eliminação Direito do titular de ter seus dados excluídos, respeitadas as retenções legais.
Transferência internacional Envio de dados pessoais para fora do país, o que ocorre com os fornecedores externos usados. Exige salvaguarda e informação ao titular.
Incidente de segurança Evento que compromete confidencialidade, integridade ou disponibilidade de dados pessoais, com procedimento e prazos de comunicação.
Retenção Período pelo qual cada categoria de dado é mantida, com justificativa legal por tabela. Ver Seção 22.

31.1.6 Termos de qualidade e acessibilidade #

Termo Definição
WCAG 2.2 Diretrizes de acessibilidade para conteúdo web. O produto adota o nível AA como exigência.
Nível AA Conjunto intermediário de critérios de acessibilidade, incluindo contraste mínimo, foco visível e operação por teclado.
Leitor de tela Software que converte a interface em fala ou braile. A interface precisa ser navegável por ele.
Movimento reduzido Preferência do sistema operacional que pede menos animação. Respeitada pela interface.
LCP Tempo até o maior elemento de conteúdo ser renderizado. Meta declarada na Seção 9.12.
CLS Medida de deslocamento visual inesperado durante o carregamento.
INP Medida da capacidade de resposta da interface à interação do usuário.

31.2 Referências externas #

Documentação oficial que o agente executor deve consultar durante a construção. A coluna "o que buscar" indica exatamente para que serve cada fonte neste projeto. Consulte sempre a versão corrente publicada pelo fornecedor: o comportamento real da plataforma prevalece sobre qualquer descrição aqui.

Fonte Endereço O que buscar nela
WhatsApp Cloud API developers.facebook.com/docs/whatsapp/cloud-api Endpoints de envio, formato de payload por tipo de mensagem, upload e recuperação de mídia, e configuração de webhook.
Referência de mensagens do WhatsApp developers.facebook.com/docs/whatsapp/cloud-api/reference/messages Estrutura exata dos objetos de texto, áudio, vídeo, template e resposta interativa.
Templates de mensagem developers.facebook.com/docs/whatsapp/message-templates Regras de criação, categorias, componentes, restrições de parâmetro e processo de aprovação.
Categorização de template developers.facebook.com/docs/whatsapp/updates-to-pricing Como a categoria é atribuída, o que muda no custo e como a reclassificação ocorre.
Janela de atendimento e tipos de conversa developers.facebook.com/docs/whatsapp/pricing Definição da janela de 24 horas, o que é cobrado e o que não é.
Códigos de erro da plataforma de mensagens developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes Significado de cada código, se é retentável e a ação recomendada. Base da tabela da Seção 17.
Webhooks da plataforma de mensagens developers.facebook.com/docs/graph-api/webhooks Verificação do endpoint, validação de assinatura do corpo bruto e formato dos eventos.
Versionamento da Graph API developers.facebook.com/docs/graph-api/guides/versioning Ciclo de vida das versões, prazo de descontinuação e como confirmar a versão corrente no momento do build.
Limites de taxa da plataforma de mensagens developers.facebook.com/docs/graph-api/overview/rate-limiting Tetos de throughput e comportamento sob limitação.
Política de mensagens de negócios da Meta business.whatsapp.com/policy O que é permitido enviar, exigência de opt-in comprovável e causas de suspensão.
Política de comércio da Meta transparency.meta.com/policies/commerce-policies Restrições de conteúdo comercializado pelo canal.
Qualidade e limites de número developers.facebook.com/docs/whatsapp/messaging-limits Como o limite de contatos únicos evolui e o que faz a qualidade cair.
Verificação de negócio na Meta developers.facebook.com/docs/development/release/business-verification Documentos exigidos, prazos e causas comuns de recusa.
API do provedor de pagamento docs.asaas.com Endpoints de cliente, assinatura e cobrança, autenticação por cabeçalho e ambientes de produção e teste.
Webhooks do provedor de pagamento docs.asaas.com/docs/webhooks Lista de eventos, formato do payload, autenticação do webhook e comportamento de pausa da fila.
Cobrança recorrente e PIX docs.asaas.com/reference Criação de assinatura, ciclos, geração de código PIX e tokenização de cartão.
Provedor primário de síntese de voz elevenlabs.io/docs Endpoint de conversão, modelos multilíngues, parâmetros de voz, formatos de saída e limites por requisição.
Provedor de fallback de síntese de voz cloud.google.com/text-to-speech/docs Vozes em português do Brasil, uso de marcação de fala e formatos de saída.
Framework web nextjs.org/docs Roteamento por aplicação, renderização, manipuladores de rota, cache e revalidação.
Biblioteca de interface react.dev/reference/react Componentes, hooks e limites entre componentes de servidor e de cliente.
ORM e migrations prisma.io/docs Modelagem, geração de migrations, verificação de deriva e uso do cliente tipado.
Fila e agendamento docs.bullmq.io Filas, trabalhadores, trabalhos repetíveis com fuso, limitador de taxa, tentativas e estado failed de job esgotado.
Banco de dados postgresql.org/docs Tipos, índices, particionamento por intervalo, restrições e manutenção de tabelas de alto volume.
Armazenamento em memória redis.io/docs Persistência, política de memória e estruturas usadas pelo limitador de taxa.
Processamento de áudio e vídeo ffmpeg.org/documentation.html Codecs, filtros de normalização de volume, geração de vídeo a partir de imagem e faixa de áudio, e inspeção de mídia.
Validação de dados zod.dev Definição de schemas compartilhados, inferência de tipo e formatação de erro por campo.
Testes unitários e de integração vitest.dev Configuração, isolamento, controle de relógio e cobertura.
Testes ponta a ponta playwright.dev/docs/intro Cenários de navegador, fixtures, espera determinística e auditoria de acessibilidade.
Normalização de telefone gitlab.com/catamphetamine/libphonenumber-js Análise, validação e formatação internacional de números, com região padrão brasileira.
Assinatura de tokens github.com/panva/jose Emissão e verificação de tokens assinados e gerenciamento de chaves.
Registro de log estruturado getpino.io/#/docs/api Campos padrão, níveis, serializadores e redação de campos sensíveis.
Datas e fuso horário date-fns.org/docs Conversão entre horário local operacional e horário universal, sem deslocamento fixo.
Armazenamento de objetos docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest Operações de envio e recuperação de objeto e geração de URL assinada.
Proxy reverso e TLS caddyserver.com/docs Configuração de domínios, certificados automáticos e cabeçalhos.
Contêineres docs.docker.com/compose Definição de serviços, redes, volumes e verificações de saúde.
Métricas técnicas prometheus.io/docs Tipos de métrica, convenções de nome e formato de exposição.
Tracing distribuído opentelemetry.io/docs Instrumentação, propagação de contexto entre processos e amostragem.
Convenção de commits conventionalcommits.org Formato de mensagem de commit adotado pelo projeto.
Especificação de identificadores github.com/ulid/spec Formato, ordenação por tempo e garantias de unicidade.
Acessibilidade w3.org/TR/WCAG22 Critérios de sucesso do nível AA e técnicas de conformidade.
Lei de proteção de dados planalto.gov.br/ccivil_03/_ato2015-2018/2018/lei/l13709.htm Texto integral da Lei nº 13.709 de 2018: bases legais, direitos do titular e obrigações.
Autoridade de proteção de dados gov.br/anpd Orientações, prazos de comunicação de incidente e regulamentos aplicáveis.
Documentação de e-mail transacional resend.com/docs Envio, verificação de domínio e tratamento de eventos de entrega.

31.3 Convenções de leitura deste documento #

  1. Numeração. As seções são numeradas de 1 a 31 e a numeração é fixa. Subseções seguem o padrão hierárquico decimal. Referências cruzadas sempre citam número de seção, nunca nome de arquivo.
  2. Idioma. A prosa, os títulos, as tabelas explicativas, os critérios de aceitação e todo texto voltado a pessoas estão em português do Brasil. Identificadores técnicos — tabelas, colunas, rotas, variáveis de ambiente, campos de JSON, valores de enum, nomes de função e mensagens de log — permanecem em inglês. Essa separação é deliberada e deve ser preservada no código.
  3. Blocos de código. Todo bloco declara a linguagem. Blocos marcados como comandos de terminal são executáveis como estão, exceto onde há um marcador entre sinais de menor e maior, que indica um valor que você substitui.
  4. Autoridade. Cada assunto tem uma seção dona, listada na Seção 30.2. Em caso de aparente conflito, a dona vence.
  5. Ausência de pendência. O documento não contém marcador de decisão adiada. Onde uma escolha era possível, ela foi feita e está registrada. Se você encontrar algo que pareça indefinido, aplique o procedimento da Seção 30.4.
  6. Valores configuráveis. Números como preço, dia de envio do plano gratuito e limites de reenvio são padrões declarados na Seção 1 e alteráveis sem mudança de código. As demais seções os citam como valor corrente, não como valor imutável.
  7. Restrições externas. Onde o documento afirma um limite de plataforma de terceiro, o comportamento real da plataforma no momento do build prevalece. Se ele mudar, ajuste o código, registre a decisão e não contorne a restrição.

31.4 Índice remissivo #

Conceitos mais consultados e as seções onde são especificados. O primeiro número é a seção dona.

Conceito Seções
Acervo e limite por tier 13, 14, 29
Alertas operacionais 23, 27, 28
Anonimização de dados 22, 20, 29
Áudio: formato e transcodificação 16, 17, 29
Áudio: provedor primário e fallback 16, 27
Auditoria administrativa 3, 15, 23
Backup e restauração 25, 27, 28
Bases legais de tratamento 22, 19
Cancelamento voluntário 20, 13, 14
Carência (inexistência) 13, 12, 20, 30
Catálogo de erros de API 7, 27
Checkout com cartão 12, 9, 29
Checkout com PIX 12, 9, 29
Códigos de erro da plataforma de mensagens 17, 18, 27
Consentimento e registro imutável 22, 11, 19
Cortesia de acesso pago 13, 15
Cursor de paginação 7, 14
Dashboard de métricas 21, 15
Devocional: campos e estados 15, 6
Direitos do titular 22, 14
Disjuntor e degradação 27, 23
Encarregado de dados 22, 1
Entitlements: matriz e função 13, 3, 14
Envelope de resposta 7, 5
Envio diário: cronograma 18, 28
Escopo e fora de escopo 2, 30
Estados da assinatura 13, 12
Estados do assinante 11, 6
Exportação de dados 22, 14
Fallback de vídeo 17, 16, 18
Feature flags 26, 6
Filas e trabalhos 18, 16, 12
Criptografia de campo e índice cego 22, 6, 28
Trabalho morto e reprocessamento 18, 27, 25
Precedência entre seções 30, 31
Fuso horário e agendamento 18, 5, 21
Idempotência de envio 18, 6
Idempotência de webhook 12, 7
Impersonação de assinante 8, 3
Índices e chaves do banco 6
Interruptor geral de envios 27, 18, 28
Janela de atendimento 17, 18, 19
Landing page: blocos e SEO 9, 10
Limites de taxa de API 7, 22
Logs e redação de dados sensíveis 23, 22
Máquina de estados editorial 15, 16
Mensagens: catálogo e textos 19, 10
Migrations e deriva de schema 6, 25, 30
Normalização de telefone 11, 6, 24
Onboarding e primeira entrega 11, 19
Opt-in em duas etapas 11, 19, 22
Opt-out e palavras-chave 20, 19
Painel administrativo 15, 3
Painel do assinante 14, 13
Pausa temporária 20, 14
Permissões por papel 3, 8
Planos e preços 13, 1, 10
Prontidão de conteúdo e reserva 18, 15
Reconciliação de pagamentos 12, 27
Reenvio manual 14, 18, 13
Retenção de dados por tabela 22, 6
Revogação imediata de acesso 13, 12, 30
Runbooks operacionais 27, 23
Segredos e rotação 26, 22
Sessões e cookies 8, 22
Simulador de serviços externos 24, 30
Tabelas do banco 6
Teaser: geração e sanitização 15, 17, 24
Templates: definição e aprovação 17, 15, 28
Testes: pirâmide e cobertura 24, 30
Upload de mídia e validade 16, 17
URLs assinadas 16, 14, 22
Variáveis de ambiente 26, 25
Verificação por código de acesso 8, 11, 19

Fim da especificação. A ordem de precedência entre seções, em caso de conflito aparente, é a da Seção 30.2: vence sempre a seção declarada dona do assunto.


Generated at GenerateSpecs.com — one idea in, one buildable spec out.

Licensed under CC BY 4.0. Use it for anything — just credit GenerateSpecs.com.