Merge pull request #1956 from melgarafael/docs/spec-cobranca-do-revendedor

docs(adr): ADR-0004 — cobrança do revendedor, e a spec que a detalha
This commit is contained in:
Rafael Melgaço
2026-09-30 13:31:44 -03:00
committed by GitHub
11 changed files with 10106 additions and 17 deletions
+2 -2
View File
@@ -58,7 +58,7 @@ Os pedidos de feature dessa comunidade empurraram o produto na direção que hoj
## Modelo do projeto (sem letra miúda)
- **O software é 100% open source (MIT), completo, sem versão paga.** Não vendemos assinatura. Não existe feature travada.
- **O software é 100% open source (MIT), completo, sem versão paga.** Nós não vendemos assinatura; quem instala pode cobrar os próprios clientes ([ADR-0004](docs/adr/0004-cobranca-do-revendedor.md)). Não existe feature travada.
- **A monetização é por infraestrutura:** o projeto é desenvolvido em parceria com a **HostGator** — o caminho recomendado de produção é a VPS deles (datacenter em São Paulo), instalada pelo `hostgator-setup-kit` com 1 comando. Assinar pelo link de parceiro apoia o projeto e sai mais barato pra quem assina.
- **O caminho genérico nunca é sabotado:** `docker compose` e o kit self-host funcionam em qualquer VPS. A parceria é o caminho recomendado, nunca o único. (Regra de ouro do open source sustentável: percepção de pegadinha mata a marca.)
@@ -76,4 +76,4 @@ Ser a resposta padrão — do Google, do ChatGPT, do Reddit e do dev brasileiro
---
*Última revisão: 2026-07-19 — reposicionamento e-commerce → multi-nicho / AI Sales OS.*
*Última revisão: 2026-09-29 — quem instala pode cobrar os próprios clientes (ADR-0004). Anterior: 2026-07-19 — reposicionamento e-commerce → multi-nicho / AI Sales OS.*
+204
View File
@@ -0,0 +1,204 @@
# ADR-0004 — Cobrança do revendedor: o dono da instalação cobra as empresas que atende
- **Status:** aceito em 2026-09-29 pelo dono do produto, junto com o desenho e as decisões D-1…D-14 dele
- **Data:** 2026-09-29
- **Contexto medido em:** `9b63075bf` (topo de `origin/main` em 29/09/2026); o peso das tabelas, num Postgres 17 descartável no mesmo dia
- **Lei que muda quando aceita:** [`docs/doctrine/operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) — §0, §1, a brecha "Faturamento e planos" da §3 e as proibições 2 e 3 da §4 — e, **só para este caso**, a condição 2 da [ADR-0002](0002-tabelas-de-modulo-num-banco-so.md)
- **Desenho que a detalha:** [`docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md`](../superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md)
---
## Contexto
O produto tinha duas respostas escritas para "quem cobra quem":
1. **O mantenedor não vende assinatura.** O software é MIT, completo e sem versão paga; o projeto
se sustenta por infraestrutura, na parceria de VPS ([`VISION.md`](../../VISION.md), "Modelo do
projeto").
2. **O operador de agentes cobra retainer por cliente operado.** Decidido em 13/09/2026
([`operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) §0 e a spec 19). O faturamento
desse eixo continua proibido antes do primeiro operador que paga (§4, proibição 2).
Uma terceira pessoa já existia e não tinha ferramenta: **quem instala o sistema para outras
empresas e cobra por isso**. O guia de marca própria autoriza desde sempre — "você pode modificar,
hospedar para terceiros, revender e cobrar o que quiser" ([`docs/white-label.md`](../white-label.md)) —,
e a instalação já é multiempresa, com marca por instalação e por organização. O que falta é o dono
da instalação transformá-la num SaaS próprio: criar planos, dar teste grátis às empresas novas,
receber por um checkout hospedado, avisar quem atrasa, suspender depois de uma tolerância e liberar
sozinho quando o pagamento entra.
Três fatos moldam a decisão:
| Fato | Onde foi medido |
|---|---|
| A suspensão de empresa que existe hoje troca `organizations.status` (com data, motivo e autor) e nada mais corta: a IA, as automações e o envio de mensagens não leem esse status. As exceções que já o leem, como a rodada de campanhas, estão enumeradas junto com os pontos de corte que faltam | o `update` de status em `app/api/v1/admin/tenants/[id]/suspend/route.ts`; §4 do desenho |
| As travas que um plano precisa — pessoas, números conectados, teste grátis na criação da empresa — têm de morar em tabelas do núcleo: `user_organizations`, `channel_sessions`, `organizations` | §2.6 e §5 do desenho |
| A VPS de quem instala não compila código: ela baixa imagem pronta, e o `update.sh` regrava a imagem a cada atualização | `hostgator-setup-kit/update.sh`; [doutrina de packaging](../doctrine/packaging.md) |
### A tentativa anterior (PR #307)
O PR #307 ("Pivot SaaS pago (Genesisia Contabilidade): billing Asaas + fundação contábil") propôs
outra coisa: uma **instância hospedada paga**, sob uma marca, vendendo assinatura — o eixo 1
invertido. Trazia a própria ADR, no arquivo `0002-pivot-saas-pago.md`, com o número que a `main`
deu depois à ADR-0002 das tabelas de módulo. Foi fechado sem merge em 24/08/2026. Esta ADR não o
retoma: aqui quem cobra é quem instala, nunca o projeto. O que os dois desenhos têm em comum —
desligado por padrão, Asaas como provedor, suspensão pelo `organizations.status` — é convergência
registrada, não herança de código.
---
## Decisão
### D1 — Existe um terceiro eixo de dinheiro, e ele é de quem instala
| Eixo | Quem cobra | De quem, e como | Onde está decidido |
|---|---|---|---|
| 1 | o mantenedor | de ninguém: não vende assinatura; o projeto vive de infraestrutura | [`VISION.md`](../../VISION.md) |
| 2 | o operador de agentes | das empresas que ele opera: retainer por cliente operado | [`operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) §0; spec 19 |
| 3 | o dono de uma instalação (administrador da plataforma) | das empresas da própria instalação: planos, teste grátis, checkout hospedado (Stripe ou Asaas), régua de atraso | esta ADR e o desenho |
Os três convivem. O eixo 3 não autoriza o eixo 1 a vender nada, e não constrói o faturamento do
eixo 2.
### D2 — Capacidade do núcleo, com chave da instalação, desligada por padrão
- A cobrança entra no código de toda instalação e chega pelo `update.sh` como qualquer correção.
Fica desligada até o dono da instalação ligá-la em `/admin/sistema`. A chave mora em
`platform_config`, no mesmo trilho das chaves de instalação que `lib/instalacao/modulos.ts` já lê,
e só o valor `ligado` a liga (falha fechada).
- Com a chave desligada, **nada do que existe muda**: as rotas da cobrança respondem 404, a tela do
dono some do menu, nenhum limite vale, o cron sai sem auditar, e o formulário de nova empresa, o
rótulo de plano, a tela de cobrança da empresa e o menu seguem idênticos. Quem administra a
instalação vê só um interruptor a mais em `/admin/sistema`; quem opera uma empresa não vê
diferença nenhuma.
- As duas tabelas da cobrança (planos e assinaturas) nascem vazias no banco de **toda**
instalação, inclusive de quem nunca liga a chave. O peso está medido na seção seguinte.
### D3 — Empresa sem assinatura é isenta
Não há estado "isenta": empresa sem linha de assinatura não tem cobrança, limite nem régua. Isso
cobre de uma vez a empresa do próprio dono, toda empresa que existia antes de ligar a chave e quem
o dono isentar.
### D4 — O provedor é insumo, não fonte de regra
Teste grátis, régua de atraso e limites moram no nosso banco. O provedor responde só "há assinatura
viva e paga?", "está devendo?" e "foi cancelada?", e responde por releitura na API dele, nunca pelo
corpo de um aviso recebido. Um provedor por vez para assinatura nova; as antigas seguem no provedor
em que nasceram (decisão D-8).
### D5 — A suspensão que suspende vem antes, e vale sem a chave
Uma empresa suspensa — pelo dono, por motivo administrativo, ou pela régua, por falta de
pagamento — para de gastar e de falar: nenhuma IA roda, nada sai, a sessão cai numa tela própria,
token e MCP recebem 403. Mensagem que chega continua gravada, e a LGPD nunca é bloqueada. Isso
conserta a suspensão administrativa de hoje, vale para toda instalação e é o primeiro PR da
entrega, antes de qualquer linha de cobrança.
### D6 — As decisões de produto estão no desenho
As 14 decisões do dono (D-1…D-14: prazo de tolerância, troca de plano só na virada paga, convites
fora do limite, o que a suspensão corta e o que deixa passar, entre outras) estão na tabela do topo
do desenho. Esta ADR registra só o que muda doutrina: a D-1 (seção seguinte) e o alcance das
proibições da doutrina de operação de agentes.
---
## Relação com a ADR-0002
A ADR-0002 separa dois destinos para tabela nova: o **núcleo**, pela tripla de sempre (migration,
apêndice do baseline, MANIFEST), e o **módulo opcional com dados**, cujas tabelas só nascem quando o
módulo é instalado, por uma função provisionadora sem parâmetro. A condição 2 do dono sustenta a
separação: "quem não usa o módulo não carrega as tabelas dele".
A cobrança é classificada como **capacidade do núcleo com chave da instalação**, pelo mesmo caminho
do caixa, que a ADR-0002 registra como "já decidido como núcleo e entra pela tripla de sempre" (seção
"O que esta ADR não decide"). O motivo é estrutural:
- as travas de pessoas e de números e o teste grátis automático são gatilhos em tabelas do núcleo
(`user_organizations`, `channel_sessions`, `organizations`) que **consultam** as tabelas da
cobrança;
- a D4 da ADR-0002 reprova, por invariante, função provisionadora cujo corpo referencie tabela de
fora do módulo — e a provisionadora da cobrança teria de criar esses gatilhos no núcleo;
- a saída que sobra — tabelas criadas pela provisionadora e gatilhos no baseline que toleram a
ausência delas com `to_regclass` e SQL dinâmico, no espírito da D7 — poria SQL dinâmico em
gatilhos quentes, no caminho de todo convite aceito e de toda conexão de número, contra duas
tabelas pequenas. Recusada.
**O custo, medido.** As duas tabelas vazias, com os índices do desenho (§2.2 e §2.3), ocupam
**48 kB** (49152 bytes) num Postgres 17 descartável — a mesma bancada em que a
ADR-0002 mediu ~368 KB para as cinco tabelas vazias da comanda, com 18 índices.
**Isto revisa a condição 2, só para este caso.** A linha "Tabelas no baseline para todos" da D9 da
ADR-0002 diz que a reconsideraria "se o dono revisar a condição 2". O dono revisou, em 29/09/2026,
para a cobrança do revendedor (decisão D-1). A condição 2 continua valendo para todo módulo com
dados — a comanda, os honorários e os próximos —, e a exceção não vira precedente automático: outra
capacidade que queira o mesmo caminho precisa de decisão própria, com o peso medido na mão.
---
## Relação com a doutrina de operação de agentes
- **Invariante 1** (software livre, operação cobrada) segue intacta: nada do software fica atrás de
pagamento ao projeto. A cobrança é uma ferramenta que quem instala usa ou não; desligada, a
instalação é a mesma de antes.
- **Invariante 2** (quem opera sozinho não paga) segue intacta: o caminho self-host continua
completo, e a empresa que instala para si nunca liga a chave. Quem paga, no eixo 3, é o cliente de
um revendedor, pelo serviço que o revendedor presta, na instalação do revendedor.
- **Proibição 2** (faturamento antes do primeiro operador que paga) passa a dizer de quem é: do
**operador de agentes**. O faturamento do retainer continua não construído, e a brecha
"Faturamento e planos" continua aberta para ele.
- **Proibição 3** (licença ou assento) passa a dizer de quem é: do **mantenedor e do operador de
agentes**. Planos, preço e limites — inclusive de pessoas — do revendedor são configuração dele.
Todo limite nasce nulo, isto é, sem teto, até ele definir um.
---
## Consequências
- **Quem instala para si:** nenhum passo novo; um interruptor a mais em `/admin/sistema`; duas
tabelas vazias no banco.
- **Quem revende:** liga a chave, conecta Stripe ou Asaas pela tela, cria planos e testa em modo de
teste antes de publicar. Nenhuma edição manual de arquivo e nenhum fork: as atualizações chegam
pelo `update.sh`.
- **O projeto:** passa a manter os adaptadores de dois provedores e a deriva das APIs deles. É o
preço de a cobrança sobreviver à atualização.
- **Toda instalação, com ou sem cobrança:** ganha a suspensão que de fato suspende (D5).
- **Doutrina e documentos:** a doutrina de operação de agentes nomeia o eixo de cada proibição e
troca a régua da brecha de faturamento por uma pergunta; a spec 19 diz que o console de agência
segue sem faturamento; a VISION separa "nós não vendemos assinatura" de "quem instala pode cobrar
os próprios clientes"; o catálogo de regras de negócio ganha o estado medido das regras de
cobrança.
---
## Alternativas consideradas e recusadas
| Alternativa | Por que não | Reconsideraríamos se |
|---|---|---|
| **Fork, ou prompts que editam o código do revendedor** | A VPS baixa imagem pronta e o `update.sh` a regrava: a primeira atualização apaga a cobrança do fork, ou o fork deixa de receber atualização. É o que a doutrina de packaging proíbe exigir de quem opera | nunca: é o motivo de a capacidade morar no núcleo |
| **Sidecar** — um serviço de cobrança à parte, ao lado da instalação | Serviço que não é imagem publicada do compose nunca é atualizado (doutrina de packaging). E ele não alcança onde a cobrança precisa agir: as travas moram em gatilhos do núcleo, e a suspensão precisa de pontos de corte dentro da IA, do envio e da sessão; de fora, o sidecar só chamaria a rota de suspender, que hoje não suspende | o núcleo expor um contrato de corte por organização que um serviço externo possa acionar, com prova dos dois lados |
| **Extensão de pacote** | A lista fechada de capacidades de extensão deixa cobrança de fora, e pacote não traz SQL, credencial nem código ([`extensoes.md`](../doctrine/extensoes.md), não-negociável 2) | — |
| **Tabelas pela função provisionadora da ADR-0002, com gatilhos tolerando a ausência delas** | SQL dinâmico em gatilhos quentes de `user_organizations` e `channel_sessions` (seção "Relação com a ADR-0002") | as travas deixarem de morar em tabelas do núcleo |
| **Instância hospedada paga pelo projeto** (PR #307) | Inverte o eixo 1: contraria o "não vendemos assinatura" da VISION e a invariante 1 da doutrina de operação de agentes | decisão do dono de mudar o modelo do projeto — outra ADR, não esta |
| **Dois provedores aceitos ao mesmo tempo para assinatura nova** | Uma escolha a mais para o revendedor leigo e para o cliente final; trocar de provedor já não quebra quem assinou (D-8) | os revendedores pedirem, com o caso em mãos |
---
## O que esta ADR não decide
- **O faturamento do operador de agentes** (eixo 2): segue na proibição 2.
- **Preço de qualquer coisa:** planos e valores são do revendedor.
- **Nota fiscal, cupom, proração, Pix Automático e Mercado Pago:** fora da primeira entrega (§1.2 do
desenho).
- **O endurecimento geral** do acesso de suporte só-leitura nas policies do banco e o fechamento da
escrita direta ao banco por membro de empresa suspensa (riscos residuais 1 e 2 do desenho;
decisão D-12).
## Aceite
**Aceita em 2026-09-29 pelo dono do produto**, com o desenho e as decisões D-1…D-14, todas pela
recomendação. O aceite não implementa nada. A ordem de construção está na §14 do desenho: a
suspensão que suspende; planos e limites; contrato, Stripe e régua; Asaas; o guia de instalação no
Coolify; o material para revendedores. Para ver o que já chegou à `main`:
`git log --oneline origin/main -- lib/cobranca lib/organizacao/operante.ts`.
@@ -471,6 +471,7 @@ owner: Rafael Melgaço
- **Regra**: GIVEN qualquer ação que consome recurso (mensagem enviada/recebida, chamada LLM, storage usage); WHEN ocorre; THEN entrada em `usage_events` com `tenant_id`, `metric_type`, `quantity`, `cost_cents` (calculado), `recorded_at`.
- **Enforcement**: Workers de cada subsistema (WhatsApp send/recv, IA invocation, storage upload).
- **Exceção**: Nenhuma.
- **Estado**: **não construída.** A tabela `usage_events` não existe (`grep -c usage_events supabase/baseline.sql`). O custo por organização que existe é o de IA, em `llm_calls.cost_cents` (B-02). A cobrança do revendedor ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)) cobra plano fixo, não consumo, e não depende desta regra; para o operador de agentes, a unidade decidida é retainer, não consumo (`docs/doctrine/operacao-de-agentes.md` §0).
### B-02 — Custo de IA é rateado por tenant
- **Origem**: Sub-PRD 05 §3.9 + IA-10
@@ -478,6 +479,7 @@ owner: Rafael Melgaço
- **Regra**: GIVEN invocação LLM via Vercel AI Gateway; WHEN o evento de billing chega do Gateway; THEN o custo é atribuído ao `tenant_id` do agent que originou a chamada.
- **Enforcement**: Worker de billing IA.
- **Exceção**: Custos administrativos da plataforma (super-admin testando, suporte) são debitados ao tenant `internal_deskcomm`.
- **Estado**: cumprida por outro mecanismo. Não há evento de billing vindo do Gateway nem worker de billing: o próprio runtime grava o custo de cada chamada em `llm_calls.cost_cents`, com o `organization_id` de onde ela roda (`lib/agent-engine/edge/llm/run-model-call.ts`, `lib/ai/log-invocation.ts`), em centavos de **dólar**, e `fn_gasto_de_ia_do_mes` é a única soma (vigiada por `tests/unit/orcamento-uma-regua-de-gasto.test.ts`). A exceção não existe: não há tenant `internal_deskcomm` (`grep -rn internal_deskcomm lib app workers supabase/baseline.sql`). É essa soma que o teto de IA do plano do revendedor consome ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)).
### B-03 — Storage de mídia tem retenção configurável por tenant
- **Origem**: PRD-Mestre §7.3
@@ -493,6 +495,7 @@ owner: Rafael Melgaço
- **Regra**: GIVEN tenant fazendo chamadas via API; WHEN ultrapassa 100 RPS; THEN próxima chamada retorna 429 com `Retry-After` e `X-RateLimit-*` headers.
- **Enforcement**: Upstash Redis sliding window.
- **Override**: Cliente enterprise pode contratar plano com RPS maior; ajuste em `tenants.rate_limit_config`.
- **Estado**: **não cumprida como escrita.** A coluna `organizations.rate_limit_rps` (padrão 100) existe no schema e nada a lê; `tenants.rate_limit_config` não existe; nenhum teto de 100 RPS por organização é aplicado. O teto real da API é de escrita, por token e por organização, numa janela fixa (`grep -n 'TETO_\|JANELA_' lib/mcp/rate-limit.ts`), aplicado rota a rota por quem chama `tetoDeEscritaDoToken` (`lib/api/auth-dual.ts`) ou o contador de `/api/v1/messages`; nem toda rota com Bearer o chama (`grep -rlE 'tetoDeEscritaDoToken|TETO_DE_ESCRITA' app/api`). A cobrança do revendedor ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)) não vende nem limita RPS; o desenho dela prevê remover a coluna sem leitor (`grep -n rate_limit_rps supabase/baseline.sql` diz se ela ainda existe).
### B-05 — Sync inicial Nuvemshop respeita rate limit do upstream
- **Origem**: Sub-PRD 06 §3.11
+1 -1
View File
@@ -156,7 +156,7 @@ preserve o trabalho do contribuidor e registre a dependência.
| Pacote JSON estrito com cards de orientação e as portas de navegação da lista fechada (ADR-0003) | Execução de código de terceiros em executor isolado | Prova: a escolha do executor é por evidência (PROG-017 §7 e §14) |
| Instalar, atualizar, trocar, desfazer a última troca, remover e reinstalar | Histórico de mais de um passo | Recusado por escrito na spec; volta pelo catálogo |
| Uma versão por instalação, ativação por organização | Versão por organização | Recusada sem necessidade comprovada (PROG-017 §5) |
| Nenhum dado de domínio de extensão | Schema próprio de extensão | Módulo nativo oficial: migration + baseline + MANIFEST; tabelas de módulo opcional num banco só, criadas ao instalar o módulo: [ADR-0002](../adr/0002-tabelas-de-modulo-num-banco-so.md), **aceita em 17/09/2026, ainda não construída**; dados de extensão de terceiro: marco 4 (PROG-017 §8) |
| Nenhum dado de domínio de extensão | Schema próprio de extensão | Módulo nativo oficial: migration + baseline + MANIFEST; tabelas de módulo opcional num banco só, criadas ao instalar o módulo: [ADR-0002](../adr/0002-tabelas-de-modulo-num-banco-so.md), **aceita em 17/09/2026 e construída** — a instalação e a reaplicação vieram na migration 0340 e o primeiro módulo a usá-las foi `honorarios` (migration 0480); as provisionadoras em vigor: `grep -n 'create or replace function public.fn_[a-z_]*_provisionar' supabase/baseline.sql`; dados de extensão de terceiro: marco 4 (PROG-017 §8) |
| Recibos, auditoria por organização na remoção (menos quando a resposta se perde e a repetição não reaplica), Atividade recente | Dependências entre extensões; downloads e avaliações | Prova (PROG-017 §5 e §12; DEC-004 §3) |
| Nenhuma telemetria de extensões | Relato de uso enviado pela VPS | Decisão própria antes (DEC-004 §3; PROG-017 §12) |
+34 -11
View File
@@ -29,8 +29,10 @@ delas** — a doutrina proíbe preencher lacuna com suposição plausível. Trê
A primeira linha foi fechada em 2026-09-13 e é a que destrava o resto: **retainer por cliente
operado**. O trabalho imediato passa a ser o console de agência
(`docs/specs/19-spec-console-de-agencia.md`), e não um medidor de consumo — cobrar por consumo
exigiria construir medidor → fatura antes do primeiro real, e hoje não existe nenhuma tabela de
plano, fatura ou assinatura no schema.
exigiria construir medidor → fatura antes do primeiro real, e o schema não tem tabela que fature o
operador de agentes (a régua é a pergunta da brecha "Faturamento e planos", §3). A cobrança que o
**dono de uma instalação** faz das empresas que atende é outro eixo, decidido à parte na
[ADR-0004](../adr/0004-cobranca-do-revendedor.md), e não é esta linha.
---
@@ -46,9 +48,10 @@ e ele nunca é sabotado — a mesma regra de ouro que já vale para a parceria d
**Invariante 3.** Nenhum serviço de produção constrói na máquina do cliente, e a atualização
nunca exige edição manual de arquivo. Vale integralmente a lei de [`packaging.md`](./packaging.md).
**Consequência prática:** cobrar por licença ou por assento **contraria** a promessa MIT escrita
em `VISION.md` e exigiria reescrever a identidade do projeto. Cobrar pela operação não exige nada
disso.
**Consequência prática:** o projeto — mantenedor ou operador de agentes — cobrar por licença ou por
assento **contraria** a promessa MIT escrita em `VISION.md` e exigiria reescrever a identidade do
projeto. O dono de uma instalação que cobra as empresas dela é outro eixo
([ADR-0004](../adr/0004-cobranca-do-revendedor.md)). Cobrar pela operação não exige nada disso.
---
@@ -93,20 +96,35 @@ não é aceite; **a prova é o comportamento visto**, não o teste que prova a s
| Brecha | Critério de aceite | Como se prova |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------- |
| **Faturamento e planos** — zero tabelas de plano, fatura ou assinatura no schema | Um operador emite cobrança de N clientes sem planilha paralela | comando abaixo, que hoje devolve 0 |
| **Faturamento e planos do operador de agentes** (eixo 2, o retainer) — não há tabela que fature o operador; a cobrança do revendedor (eixo 3, [ADR-0004](../adr/0004-cobranca-do-revendedor.md)) é outra coisa e não fecha esta brecha | Um operador emite cobrança de N clientes sem planilha paralela | a pergunta abaixo, respondida tabela a tabela |
| **Console de agência multi-cliente** — `app/admin/` é do administrador da instalação, com sessão de suporte auditada (`docs/support-sessions.md`), não uma carteira | Operar 3 clientes (modelo de agente, saúde, transferência) numa tela só | Playwright contra o app rodando |
| **Pacotes de agente por nicho** — hoje são procedimento manual descrito nas skills | Criar um agente novo para um cliente a partir de um pacote, sem editar arquivo | sessão real + diff do agente publicado |
| **Aceite por cliente** — existe avaliação (`lib/ai/agents/avaliar-resposta-de-teste.ts`, `scripts/flywheel-judge-live.ts`), não um portão de entrega por conta | Um agente só é entregue e cobrado com o conjunto de avaliação aprovado | rodada de avaliação com resultado registrado |
| **MCP público** — declarado fora do MVP em `docs/specs/11-spec-mcp-server-internal.md` | Cliente pluga o próprio agente com uma API key | token real + chamada de tool observada |
| **SLA e observabilidade por conta** — `lib/agent-engine/obs/metrics.ts` e `lib/agent-engine/health/circuit.ts` existem, falta a superfície por cliente | Um cliente vê a saúde do próprio agente sem acesso interno | tela real por organização |
A régua da primeira linha — **hoje devolve 0**, e continua devolvendo 0 até existir:
A régua da primeira linha é uma **pergunta**, não uma contagem. Liste as tabelas do schema com
cara de cobrança:
```bash
grep -oiE 'create table (if not exists )?public\.[a-z_]+' supabase/baseline.sql \
| grep -icE 'invoice|^create table (if not exists )?public\.(billing|plans|subscriptions|quota|credits)$'
grep -oiE 'create table (if not exists )?"?public"?\."?[a-z_]+' supabase/baseline.sql \
| tr -d '"' | grep -iE 'invoice|billing|fatura|plan|assinatura|subscription|cobranca|quota|credit'
```
e responda, para cada linha: **esta tabela fatura o retainer de um cliente operado?** A brecha só
fecha quando alguma responder "sim". Duas classes de linha respondem "não" e não fecham nada:
- tabela de outro domínio que casa o nome — `account_plans` é o plano de contas do caixa,
`push_subscriptions` é inscrição de notificação;
- as tabelas `cobranca_*`, quando existirem: são a cobrança **do revendedor** (eixo 3,
[ADR-0004](../adr/0004-cobranca-do-revendedor.md)) — o dono da instalação cobrando as empresas
que atende, com plano fixo. Não medem nem faturam operação de agentes.
A régua anterior devolvia zero, e hoje esse zero é verdadeiro — mas por sorte: ela só via
`public.x` sem aspas (o trecho do dump, escrito `"public"."x"`, ficava fora) e só casava nomes em
inglês. As tabelas `cobranca_*` passariam por ela invisíveis pelo nome, e o zero seguiria lido como
"a brecha está aberta" pelo motivo errado.
---
## 4. Proibido
@@ -115,8 +133,13 @@ grep -oiE 'create table (if not exists )?public\.[a-z_]+' supabase/baseline.sql
`lib/agent-engine/`. Tirar o runtime de volta é o refactor mais caro do repositório e não se
paga com prova comercial nenhuma. O CRM como função já se obtém via MCP, sem mover arquivo.
2. **Não construir faturamento antes do primeiro operador que paga.** Sem cliente pagante, o
desenho do medidor é adivinhação.
3. **Não cobrar por licença nem por assento** (invariantes 1 e 2).
desenho do medidor é adivinhação. Vale para o faturamento do **operador de agentes** (o
retainer, §0), não para a cobrança do revendedor, que é outro eixo e tem decisão própria
([ADR-0004](../adr/0004-cobranca-do-revendedor.md)).
3. **Não cobrar por licença nem por assento** (invariantes 1 e 2). A proibição é do
**mantenedor e do operador de agentes**. Quem instala e cobra as empresas da própria instalação
escolhe planos, preço e limites — inclusive de pessoas — como configuração dele, e todo limite
nasce nulo, isto é, sem teto, até ele definir um (ADR-0004).
4. **Não inventar preço, SLA nem número.** O primeiro cliente real define os três.
5. **Não perseguir certificação** (SOC 2, ISO 27001), multi-região ou idioma adicional enquanto
não houver demanda enterprise em mãos — está declarado fora de escopo de propósito.
+6
View File
@@ -364,6 +364,12 @@ Três colunas de apoio:
Sem tabela de planos — não temos planos. Um bloco só, honesto.
> ⚠️ **De quem é esta promessa.** "Não existe cobrança por usuário" e "não temos planos" são
> promessas **do projeto** sobre o software: nem o mantenedor nem uma versão paga cobram por
> pessoa. Quem instala e revende pode cobrar os próprios clientes com planos que limitam pessoas —
> é a instalação dele, não a nossa ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)). Esta seção
> fala com quem instala para si; não estenda a promessa ao cliente final de um revendedor.
---
## 12. FAQ CITÁVEL
+2
View File
@@ -98,6 +98,8 @@ Detalham schema SQL e payloads exatos. **Consulte antes de modelar qualquer cois
| [`specs/19`](specs/19-spec-console-de-agencia.md) | **Console de Agência** — operar N organizações clientes; unidade de cobrança decidida (retainer por cliente operado). Lei em [`doctrine/operacao-de-agentes.md`](doctrine/operacao-de-agentes.md) |
| [`adr/0001-packaging-e-distribuicao.md`](adr/0001-packaging-e-distribuicao.md) | ADR do packaging: namespace, os 3 packages, e o que foi recusado |
| [`adr/0002-tabelas-de-modulo-num-banco-so.md`](adr/0002-tabelas-de-modulo-num-banco-so.md) | **Aceita em 17/09/2026.** Tabelas de módulo opcional: um banco só, `public`, criadas por função provisionadora fixa quando o módulo é instalado |
| [`adr/0003-perfil-declarativo-v2-portas-nomeadas-e-vitrine.md`](adr/0003-perfil-declarativo-v2-portas-nomeadas-e-vitrine.md) | **Aceita em 17/09/2026.** Perfil declarativo v2 das extensões: portas nomeadas e o metadado de loja no catálogo |
| [`adr/0004-cobranca-do-revendedor.md`](adr/0004-cobranca-do-revendedor.md) | **Aceita em 29/09/2026.** Cobrança do revendedor: o terceiro eixo de dinheiro — o dono da instalação cobra as empresas que atende; capacidade do núcleo com chave, desligada por padrão; revisa a condição 2 da ADR-0002 só para este caso |
| [`architecture/agent-turn.html`](architecture/agent-turn.html) | Diagrama do turno do agente (inbound → guardrails → outbound) |
| [`specs/pre-go-live-whatsapp.md`](specs/pre-go-live-whatsapp.md) | Modo de teste do WhatsApp por canal: lista de telefones, abertura ao público e compatibilidade com autorização por origem |
| [`specs/19`](specs/19-spec-console-de-agencia.md) | **Console de Agência** — operar N organizações clientes; unidade de cobrança decidida (retainer por cliente operado). Lei em [`doctrine/operacao-de-agentes.md`](doctrine/operacao-de-agentes.md) |
+6 -3
View File
@@ -36,8 +36,8 @@ descrito nas skills `deskcomm-cliente-novo` e `deskcomm-metricas`.
1. **Unidade de cobrança: retainer por cliente operado** (dono do produto, 2026-09-13). O trabalho
imediato é **este console**, não um medidor de consumo — cobro por consumo exigiria construir
medidor → fatura antes do primeiro real, e não existe nenhuma tabela de plano, fatura ou
assinatura no schema.
medidor → fatura antes do primeiro real, e o schema não tem tabela que fature o operador (a
régua está na brecha "Faturamento e planos" da doutrina, §3).
2. **O software permanece MIT e completo.** O que se cobra é a operação (invariantes 1 e 2 da
doutrina). Nada aqui fica atrás de pagamento para quem opera sozinho.
3. **A marca do cliente é do cliente.** `platform_branding` é da instalação,
@@ -88,7 +88,10 @@ descrito nas skills `deskcomm-cliente-novo` e `deskcomm-metricas`.
**Dentro:** carteira por cliente (custo do período, saúde, agente publicado e sua versão); entrada
auditada; aplicação de pacote de agente; registro de aceite por cliente.
**Fora (de propósito):** faturamento/planos/cotas (§1.2 decisão 1); console de revenda; SOC 2, ISO
**Fora (de propósito):** faturamento/planos/cotas do operador (§1.2 decisão 1) — o console segue
sem faturamento mesmo com a cobrança do revendedor
([ADR-0004](../adr/0004-cobranca-do-revendedor.md)), que é o dono da instalação cobrando as empresas
que atende e não fatura retainer; console de revenda; SOC 2, ISO
27001, multi-região e idioma adicional (fora de escopo por decisão escrita no
`docs/prd/00-prd-master.md` §7.4); substituir `lib/agent-engine/` (proibido pela §4 da doutrina).
@@ -0,0 +1,988 @@
# ADR-0004 (PR 0 da cobrança do revendedor) — Plano de implementação
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Registrar, só em documentação, o terceiro eixo de dinheiro do produto (o dono de uma instalação cobra as empresas que atende) como a ADR-0004, e reconciliar com ela todo documento que hoje diz o contrário ou diz de um jeito que envelhece quando a cobrança chegar.
**Architecture:** Um arquivo novo (`docs/adr/0004-cobranca-do-revendedor.md`) com o peso das duas tabelas vazias medido num Postgres 17 descartável, mais edições pontuais (old → new) em sete documentos. Nenhum código, nenhuma migration, nenhum fragmento `.changes/`. O "teste" de cada tarefa é uma sonda `grep` com contagem esperada antes (vermelho) e depois (verde), mais o gate que já existe para documentos de autoridade, `tests/unit/documentacao-aponta-para-o-que-existe.test.ts`, que reprova link relativo morto e caminho em crase que não existe.
**Tech Stack:** Markdown; `grep`; Docker com `pgvector/pgvector:pg17` (a imagem que o `test:db` usa, já presente localmente) para a medição; Vitest (`pnpm exec vitest run`, `pnpm test:unit`).
**Spec:** `docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md` (§14 PR 0; decisão D-1; §1.1, §1.2, §2.2, §2.3, §16 item 2.A).
## Global Constraints
- Trabalho na worktree existente `/Users/rafaelmelgaco/deskcomm-saas/spec`, branch `docs/spec-cobranca-do-revendedor`. O PR 0 leva **a spec e a ADR juntas**: a ADR linka a spec, e o gate de links reprova link para arquivo que não existe na branch. Todo `cd` é `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1`.
- Número da ADR: `0004` (medido em 29/09/2026: `docs/adr/` da `origin/main` tem 0001, 0002 e 0003; nenhum dos 15 PRs abertos toca `docs/adr/`). Reconfira no Task 1, Step 1.
- O texto da ADR usa "capacidade do núcleo com chave da instalação" para a cobrança; **nunca** a classifica como "módulo". A palavra "módulo" só aparece para nomear o conceito da ADR-0002 (para contrastá-lo), módulos reais (comanda, honorários) ou o caminho de código existente `lib/instalacao/modulos.ts`.
- Documento de autoridade (`docs/adr`, `docs/doctrine`, `docs/index.md`, entre outros — lista em `tests/unit/documentacao-aponta-para-o-que-existe.test.ts`, constante `AUTORIDADE`) **não** cita em crase caminho de arquivo que ainda não existe (`lib/organizacao/operante.ts`, `lib/cobranca/vocabulario.ts`, o arquivo do PR #307 com prefixo `docs/adr/`...). Diretório sem extensão (`lib/cobranca/`) não é cobrado pelo gate e pode aparecer.
- Afirmação de estado que envelhece vira comando (`git log ... -- lib/cobranca`, `grep -n ...`), nunca "a cobrança já existe" nem "hoje devolve 0".
- Não editar a ADR-0002 nem a ADR-0003 (registro histórico). A revisão da condição 2 mora na ADR-0004.
- Não rodar `prettier --write` em nenhum `.md` (o `format:check` não está no CI e reformataria arquivos inteiros; linha de tabela desalinhada renderiza igual).
- Sem fragmento `.changes/`: PR só de documentação não muda comportamento visível a quem opera uma VPS (DoD 17; `docs/doctrine/versionamento.md`, "Todo PR que muda comportamento traz um arquivo em `.changes/`"; `lib/release/fragmento.ts` só valida forma, não cobra presença).
- Commits: conventional em pt-br, corpo via heredoc `<<'FIM'` (crase e `$` em aspas duplas são executados pelo shell), terminando com a linha `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- `git push` e abertura de PR são ação de impacto externo: **exigem confirmação explícita do dono do produto no momento do envio**. A aprovação do plano não cobre publicação.
## Review Focus
1. **Leitor que conclui que o projeto passou a vender assinatura.** Toda frase editada nomeia o eixo (mantenedor / operador de agentes / dono da instalação); VISION, LP e a doutrina continuam dizendo, sem ambiguidade, que o projeto não vende assinatura. Pinado pelas sondas dos Tasks 2 e 3 (a frase "Nós não vendemos assinatura" continua presente).
2. **Link ou caminho em crase para arquivo que só existirá nos PRs seguintes**, em documento de autoridade. Pinado por `pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts` ao fim de cada task que toca documento de autoridade (Tasks 1, 2, 5).
3. **A régua nova da brecha "Faturamento e planos" lida como fechada quando as tabelas `cobranca_*` aparecerem.** A pergunta nova classifica essas tabelas como eixo 3 por escrito, e o Task 2 roda a sonda nova com um controle positivo (`cobranca_planos` sintético aparece na saída).
4. **Placeholder de medição esquecido na ADR** (`{{PESO_...}}`). Pinado pelo Step 7 do Task 1 (`grep -c '{{PESO'` → `0`).
5. **"Módulo" classificando a cobrança**, o que reabriria a discussão da ADR-0002. Pinado pelo Step 8 do Task 1 (cada ocorrência revisada contra o critério das Global Constraints).
---
### Task 1: A ADR-0004, com o peso medido, e o índice que aponta para ela
**Files:**
- Create: `docs/adr/0004-cobranca-do-revendedor.md`
- Modify: `docs/index.md:100` (duas linhas novas logo depois da linha da ADR-0002)
- Test: `tests/unit/documentacao-aponta-para-o-que-existe.test.ts` (existente, não muda)
**Interfaces:**
- Consumes: a spec (§14 PR 0, D-1, §2.2, §2.3, §16 2.A); `docs/adr/0002-tabelas-de-modulo-num-banco-so.md` (condição 2, D4, D7, D9, "O que esta ADR não decide" sobre o caixa).
- Produces: o arquivo `docs/adr/0004-cobranca-do-revendedor.md`, que os Tasks 2–4 linkam como `../adr/0004-cobranca-do-revendedor.md` (de `docs/<pasta>/`) ou `docs/adr/0004-cobranca-do-revendedor.md` (da raiz), com as seções "Relação com a ADR-0002" e "Relação com a doutrina de operação de agentes".
- [ ] **Step 1: Branch limpa, atualizada com a main, e número livre**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git status --short # esperado: vazio (árvore limpa; se não, PARE — não é sua)
git fetch origin
git merge origin/main -m "chore: traz a main para a branch da spec da cobrança"
git ls-tree --name-only origin/main docs/adr/
gh pr list --state open --limit 200 --json number,title,files \
--jq '.[] | select([.files[].path] | any(startswith("docs/adr/"))) | "\(.number) \(.title)"'
```
Esperado: o merge entra sem conflito (a branch só acrescenta `docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md`); `ls-tree` lista só `0001-…`, `0002-…`, `0003-…`; o `gh` não imprime nada. Se aparecer `0004-*` na main ou num PR aberto, use o próximo número livre e troque `0004` em todo este plano. (Ressalva: `--json files` corta em 100 arquivos por PR; se algum PR aberto tiver mais que isso, confira-o com `git diff --name-only origin/main...refs/pull/<N>/head -- docs/adr`.)
- [ ] **Step 2: Os documentos citados não andaram desde a medição deste plano**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git diff --stat 9b63075bf origin/main -- docs/doctrine/operacao-de-agentes.md \
docs/specs/19-spec-console-de-agencia.md VISION.md docs/growth/lp-plano.md \
docs/business-rules/00-business-rules-catalog.md docs/doctrine/extensoes.md docs/index.md \
docs/adr supabase/baseline.sql docs/white-label.md lib/instalacao/modulos.ts \
lib/mcp/rate-limit.ts lib/api/auth-dual.ts "app/api/v1/admin/tenants/[id]/suspend/route.ts"
```
Esperado: saída vazia. Se algum arquivo aparecer, releia o trecho dele que este plano edita ou cita antes de seguir, e troque `9b63075bf` na linha "Contexto medido em" da ADR pelo `git rev-parse --short origin/main` do dia.
- [ ] **Step 3: Escrever o teste que falha — o índice aponta para a ADR que ainda não existe**
Em `docs/index.md`, logo depois da linha que começa com ``| [`adr/0002-tabelas-de-modulo-num-banco-so.md`]`` (linha 100 hoje), acrescente:
old:
```markdown
| [`adr/0002-tabelas-de-modulo-num-banco-so.md`](adr/0002-tabelas-de-modulo-num-banco-so.md) | **Aceita em 17/09/2026.** Tabelas de módulo opcional: um banco só, `public`, criadas por função provisionadora fixa quando o módulo é instalado |
```
new:
```markdown
| [`adr/0002-tabelas-de-modulo-num-banco-so.md`](adr/0002-tabelas-de-modulo-num-banco-so.md) | **Aceita em 17/09/2026.** Tabelas de módulo opcional: um banco só, `public`, criadas por função provisionadora fixa quando o módulo é instalado |
| [`adr/0003-perfil-declarativo-v2-portas-nomeadas-e-vitrine.md`](adr/0003-perfil-declarativo-v2-portas-nomeadas-e-vitrine.md) | **Aceita em 17/09/2026.** Perfil declarativo v2 das extensões: portas nomeadas e o metadado de loja no catálogo |
| [`adr/0004-cobranca-do-revendedor.md`](adr/0004-cobranca-do-revendedor.md) | **Aceita em 29/09/2026.** Cobrança do revendedor: o terceiro eixo de dinheiro — o dono da instalação cobra as empresas que atende; capacidade do núcleo com chave, desligada por padrão; revisa a condição 2 da ADR-0002 só para este caso |
```
(A linha da ADR-0003 entra junto porque o índice pulava dela; a tabela de ADRs fica completa.)
- [ ] **Step 4: Rodar o teste e ver falhar**
Run: `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1; pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts`
Expected: FAIL em "nenhum link relativo aponta para arquivo que não existe", com a linha `docs/index.md → adr/0004-cobranca-do-revendedor.md` (e só ela). Se falhar com outra linha, ela já estava morta antes deste plano: anote e não conserte aqui.
- [ ] **Step 5: Medir o peso das duas tabelas vazias num Postgres 17 descartável**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
docker run -d --rm --name adr0004-peso -e POSTGRES_PASSWORD=descartavel pgvector/pgvector:pg17
# o entrypoint sobe um servidor temporário e depois o definitivo: espere o 2º "ready"
until [ "$(docker logs adr0004-peso 2>&1 | grep -c 'ready to accept connections')" -ge 2 ]; do sleep 1; done
docker exec -i adr0004-peso psql -U postgres -v ON_ERROR_STOP=1 -At <<'SQL'
-- Esteios mínimos para o DDL da spec compilar fora do baseline
create role anon; create role authenticated; create role service_role;
create table public.organizations (id uuid primary key default gen_random_uuid());
create function public.fn_role_at_least(p_org uuid, p_min text) returns boolean
language sql stable as $$ select false $$;
-- §2.2 da spec, verbatim
create table if not exists public.cobranca_planos (
id uuid primary key default gen_random_uuid(),
nome text not null check (char_length(nome) between 1 and 60),
preco_cents bigint not null check (preco_cents >= 500),
moeda text not null default 'BRL' check (moeda = 'BRL'),
intervalo text not null check (intervalo in ('mes','ano')),
trial_dias integer not null default 14 check (trial_dias between 0 and 90),
max_assentos integer check (max_assentos >= 1),
max_canais integer check (max_canais >= 1),
teto_ia_usd_cents integer check (teto_ia_usd_cents >= 100),
padrao_no_cadastro boolean not null default false,
arquivado_em timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid
);
create unique index if not exists cobranca_planos_um_padrao
on public.cobranca_planos ((true)) where padrao_no_cadastro and arquivado_em is null;
alter table public.cobranca_planos enable row level security;
revoke all on public.cobranca_planos from anon, authenticated;
grant select, insert, update, delete on public.cobranca_planos to service_role;
-- §2.3 da spec, verbatim
create table if not exists public.cobranca_assinaturas (
organization_id uuid primary key references public.organizations(id) on delete cascade,
plano_id uuid not null references public.cobranca_planos(id) on delete restrict,
plano_agendado_id uuid references public.cobranca_planos(id) on delete restrict,
estado text not null default 'trial' check (estado in ('trial','ativa','em_atraso','cancelada')),
trial_ate timestamptz,
provedor text check (provedor in ('stripe','asaas')),
modo text check (modo in ('teste','producao')),
provedor_cliente_id text,
provedor_assinatura_id text,
vencida_desde timestamptz,
proximo_vencimento timestamptz,
cancela_no_fim boolean not null default false,
prazo_extra_ate timestamptz,
ultimo_aviso text check (ultimo_aviso in ('trial_acabando','venceu','suspende_em_breve','suspensa')),
ultimo_aviso_em timestamptz,
checkout_url text, checkout_expira_em timestamptz,
relida_em timestamptz,
assinaturas_vivas integer not null default 0,
ultimo_erro text check (ultimo_erro in ('credencial_invalida','provedor_fora','pagamento_de_assinatura_cancelada','leitura_invalida')),
ultimo_erro_em timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check ((provedor is null) = (provedor_cliente_id is null))
);
create unique index if not exists cobranca_assinaturas_cliente
on public.cobranca_assinaturas (provedor, provedor_cliente_id) where provedor is not null;
alter table public.cobranca_assinaturas enable row level security;
create policy tenant_isolation_cobranca_assinaturas_select on public.cobranca_assinaturas
for select to authenticated using (public.fn_role_at_least(organization_id, 'admin'));
revoke all on public.cobranca_assinaturas from anon, authenticated;
grant select on public.cobranca_assinaturas to authenticated;
grant select, insert, update, delete on public.cobranca_assinaturas to service_role;
-- A medição
select c.relname, pg_total_relation_size(c.oid)
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname in ('cobranca_planos','cobranca_assinaturas')
order by 1;
select sum(pg_total_relation_size(c.oid)), pg_size_pretty(sum(pg_total_relation_size(c.oid)))
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname in ('cobranca_planos','cobranca_assinaturas');
SQL
echo "exit=$?"
docker stop adr0004-peso
```
Expected: `exit=0` e três linhas no fim. Estimativa (não é o número a publicar): cada tabela vazia pesa três páginas de 8 KB — dois índices btree e o índice da TOAST —, então `cobranca_assinaturas|24576`, `cobranca_planos|24576` e `49152|48 kB`. **O que vai para a ADR é o impresso.** Anote os dois valores da última linha (bytes e legível). Se o psql abortar (`exit` ≠ 0) — por exemplo no índice `((true))` —, isso é defeito do DDL da spec (§2.2): pare e reporte ao dono do plano com a mensagem de erro; não mude o DDL por conta própria.
- [ ] **Step 6: Escrever a ADR**
Crie `docs/adr/0004-cobranca-do-revendedor.md` com o texto abaixo, inteiro:
```markdown
# ADR-0004 — Cobrança do revendedor: o dono da instalação cobra as empresas que atende
- **Status:** aceito em 2026-09-29 pelo dono do produto, junto com o desenho e as decisões D-1…D-14 dele
- **Data:** 2026-09-29
- **Contexto medido em:** `9b63075bf` (topo de `origin/main` em 29/09/2026); o peso das tabelas, num Postgres 17 descartável no mesmo dia
- **Lei que muda quando aceita:** [`docs/doctrine/operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) — §0, a brecha "Faturamento e planos" da §3 e as proibições 2 e 3 da §4 — e, **só para este caso**, a condição 2 da [ADR-0002](0002-tabelas-de-modulo-num-banco-so.md)
- **Desenho que a detalha:** [`docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md`](../superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md)
---
## Contexto
O produto tinha duas respostas escritas para "quem cobra quem":
1. **O mantenedor não vende assinatura.** O software é MIT, completo e sem versão paga; o projeto
se sustenta por infraestrutura, na parceria de VPS ([`VISION.md`](../../VISION.md), "Modelo do
projeto").
2. **O operador de agentes cobra retainer por cliente operado.** Decidido em 13/09/2026
([`operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) §0 e a spec 19). O faturamento
desse eixo continua proibido antes do primeiro operador que paga (§4, proibição 2).
Uma terceira pessoa já existia e não tinha ferramenta: **quem instala o sistema para outras
empresas e cobra por isso**. O guia de marca própria autoriza desde sempre — "você pode modificar,
hospedar para terceiros, revender e cobrar o que quiser" ([`docs/white-label.md`](../white-label.md)) —,
e a instalação já é multiempresa, com marca por instalação e por organização. O que falta é o dono
da instalação transformá-la num SaaS próprio: criar planos, dar teste grátis às empresas novas,
receber por um checkout hospedado, avisar quem atrasa, suspender depois de uma tolerância e liberar
sozinho quando o pagamento entra.
Três fatos moldam a decisão:
| Fato | Onde foi medido |
|---|---|
| A suspensão de empresa que existe hoje troca `organizations.status` e mais nada: a IA, as automações e os envios da empresa suspensa não leem esse status | o `update` de status em `app/api/v1/admin/tenants/[id]/suspend/route.ts`; os pontos de corte que faltam estão enumerados na §4 do desenho |
| As travas que um plano precisa — pessoas, números conectados, teste grátis na criação da empresa — têm de morar em tabelas do núcleo: `user_organizations`, `channel_sessions`, `organizations` | §2.6 e §5 do desenho |
| A VPS de quem instala não compila código: ela baixa imagem pronta, e o `update.sh` regrava a imagem a cada atualização | `hostgator-setup-kit/update.sh`; [doutrina de packaging](../doctrine/packaging.md) |
### A tentativa anterior (PR #307)
O PR #307 ("Pivot SaaS pago (Genesisia Contabilidade): billing Asaas + fundação contábil") propôs
outra coisa: uma **instância hospedada paga**, sob uma marca, vendendo assinatura — o eixo 1
invertido. Trazia a própria ADR, no arquivo `0002-pivot-saas-pago.md`, com o número que a `main`
deu depois à ADR-0002 das tabelas de módulo. Foi fechado sem merge em 24/08/2026. Esta ADR não o
retoma: aqui quem cobra é quem instala, nunca o projeto. O que os dois desenhos têm em comum —
desligado por padrão, Asaas como provedor, suspensão pelo `organizations.status` — é convergência
registrada, não herança de código.
---
## Decisão
### D1 — Existe um terceiro eixo de dinheiro, e ele é de quem instala
| Eixo | Quem cobra | De quem, e como | Onde está decidido |
|---|---|---|---|
| 1 | o mantenedor | de ninguém: não vende assinatura; o projeto vive de infraestrutura | [`VISION.md`](../../VISION.md) |
| 2 | o operador de agentes | das empresas que ele opera: retainer por cliente operado | [`operacao-de-agentes.md`](../doctrine/operacao-de-agentes.md) §0; spec 19 |
| 3 | o dono de uma instalação (administrador da plataforma) | das empresas da própria instalação: planos, teste grátis, checkout hospedado (Stripe ou Asaas), régua de atraso | esta ADR e o desenho |
Os três convivem. O eixo 3 não autoriza o eixo 1 a vender nada, e não constrói o faturamento do
eixo 2.
### D2 — Capacidade do núcleo, com chave da instalação, desligada por padrão
- A cobrança entra no código de toda instalação e chega pelo `update.sh` como qualquer correção.
Fica desligada até o dono da instalação ligá-la em `/admin/sistema`. A chave mora em
`platform_config`, no mesmo trilho das chaves de instalação que `lib/instalacao/modulos.ts` já lê,
e só o valor `ligado` a liga (falha fechada).
- Com a chave desligada, **nada do que existe muda**: as rotas da cobrança respondem 404, a tela do
dono some do menu, nenhum limite vale, o cron sai sem auditar, e o formulário de nova empresa, o
rótulo de plano, a tela de cobrança da empresa e o menu seguem idênticos. Quem opera uma empresa
só vê um interruptor a mais.
- As duas tabelas da cobrança (planos e assinaturas) nascem vazias no banco de **toda**
instalação, inclusive de quem nunca liga a chave. O peso está medido na seção seguinte.
### D3 — Empresa sem assinatura é isenta
Não há estado "isenta": empresa sem linha de assinatura não tem cobrança, limite nem régua. Isso
cobre de uma vez a empresa do próprio dono, toda empresa que existia antes de ligar a chave e quem
o dono isentar.
### D4 — O provedor é insumo, não fonte de regra
Teste grátis, régua de atraso e limites moram no nosso banco. O provedor responde só "há assinatura
viva e paga?", "está devendo?" e "foi cancelada?", e responde por releitura na API dele, nunca pelo
corpo de um aviso recebido. Um provedor por vez para assinatura nova; as antigas seguem no provedor
em que nasceram (decisão D-8).
### D5 — A suspensão que suspende vem antes, e vale sem a chave
Uma empresa suspensa — pelo dono, por motivo administrativo, ou pela régua, por falta de
pagamento — para de gastar e de falar: nenhuma IA roda, nada sai, a sessão cai numa tela própria,
token e MCP recebem 403. Mensagem que chega continua gravada, e a LGPD nunca é bloqueada. Isso
conserta a suspensão administrativa de hoje, vale para toda instalação e é o primeiro PR da
entrega, antes de qualquer linha de cobrança.
### D6 — As decisões de produto estão no desenho
As 14 decisões do dono (D-1…D-14: prazo de tolerância, troca de plano só na virada paga, convites
fora do limite, o que a suspensão corta e o que deixa passar, entre outras) estão na tabela do topo
do desenho. Esta ADR registra só o que muda doutrina: a D-1 (seção seguinte) e o alcance das
proibições da doutrina de operação de agentes.
---
## Relação com a ADR-0002
A ADR-0002 separa dois destinos para tabela nova: o **núcleo**, pela tripla de sempre (migration,
apêndice do baseline, MANIFEST), e o **módulo opcional com dados**, cujas tabelas só nascem quando o
módulo é instalado, por uma função provisionadora sem parâmetro. A condição 2 do dono sustenta a
separação: "quem não usa o módulo não carrega as tabelas dele".
A cobrança é classificada como **capacidade do núcleo com chave da instalação**, pelo mesmo caminho
do caixa, que a ADR-0002 registra como "já decidido como núcleo e entra pela tripla de sempre" (seção
"O que esta ADR não decide"). O motivo é estrutural:
- as travas de pessoas e de números e o teste grátis automático são gatilhos em tabelas do núcleo
(`user_organizations`, `channel_sessions`, `organizations`) que **consultam** as tabelas da
cobrança;
- a D4 da ADR-0002 reprova, por invariante, função provisionadora cujo corpo referencie tabela de
fora do módulo — e a provisionadora da cobrança teria de criar esses gatilhos no núcleo;
- a saída que sobra — tabelas criadas pela provisionadora e gatilhos no baseline que toleram a
ausência delas com `to_regclass` e SQL dinâmico, no espírito da D7 — poria SQL dinâmico em
gatilhos quentes, no caminho de todo convite aceito e de toda conexão de número, contra duas
tabelas pequenas. Recusada.
**O custo, medido.** As duas tabelas vazias, com os índices do desenho (§2.2 e §2.3), ocupam
**{{PESO_LEGIVEL}}** ({{PESO_BYTES}} bytes) num Postgres 17 descartável — a mesma bancada em que a
ADR-0002 mediu ~368 KB para as cinco tabelas vazias da comanda, com 18 índices.
**Isto revisa a condição 2, só para este caso.** A linha "Tabelas no baseline para todos" da D9 da
ADR-0002 diz que a reconsideraria "se o dono revisar a condição 2". O dono revisou, em 29/09/2026,
para a cobrança do revendedor (decisão D-1). A condição 2 continua valendo para todo módulo com
dados — a comanda, os honorários e os próximos —, e a exceção não vira precedente automático: outra
capacidade que queira o mesmo caminho precisa de decisão própria, com o peso medido na mão.
---
## Relação com a doutrina de operação de agentes
- **Invariante 1** (software livre, operação cobrada) segue intacta: nada do software fica atrás de
pagamento ao projeto. A cobrança é uma ferramenta que quem instala usa ou não; desligada, a
instalação é a mesma de antes.
- **Invariante 2** (quem opera sozinho não paga) segue intacta: o caminho self-host continua
completo, e a empresa que instala para si nunca liga a chave. Quem paga, no eixo 3, é o cliente de
um revendedor, pelo serviço que o revendedor presta, na instalação do revendedor.
- **Proibição 2** (faturamento antes do primeiro operador que paga) passa a dizer de quem é: do
**operador de agentes**. O faturamento do retainer continua não construído, e a brecha
"Faturamento e planos" continua aberta para ele.
- **Proibição 3** (licença ou assento) passa a dizer de quem é: do **mantenedor e do operador de
agentes**. Planos, preço e limites — inclusive de pessoas — do revendedor são configuração dele.
Todo limite nasce nulo, isto é, sem teto, até ele definir um.
---
## Consequências
- **Quem instala para si:** nenhum passo novo; um interruptor a mais em `/admin/sistema`; duas
tabelas vazias no banco.
- **Quem revende:** liga a chave, conecta Stripe ou Asaas pela tela, cria planos e testa em modo de
teste antes de publicar. Nenhuma edição manual de arquivo e nenhum fork: as atualizações chegam
pelo `update.sh`.
- **O projeto:** passa a manter os adaptadores de dois provedores e a deriva das APIs deles. É o
preço de a cobrança sobreviver à atualização.
- **Toda instalação, com ou sem cobrança:** ganha a suspensão que de fato suspende (D5).
- **Doutrina e documentos:** a doutrina de operação de agentes nomeia o eixo de cada proibição e
troca a régua da brecha de faturamento por uma pergunta; a spec 19 diz que o console de agência
segue sem faturamento; a VISION separa "nós não vendemos assinatura" de "quem instala pode cobrar
os próprios clientes"; o catálogo de regras de negócio ganha o estado medido das regras de
cobrança.
---
## Alternativas consideradas e recusadas
| Alternativa | Por que não | Reconsideraríamos se |
|---|---|---|
| **Fork, ou prompts que editam o código do revendedor** | A VPS baixa imagem pronta e o `update.sh` a regrava: a primeira atualização apaga a cobrança do fork, ou o fork deixa de receber atualização. É o que a doutrina de packaging proíbe exigir de quem opera | nunca: é o motivo de a capacidade morar no núcleo |
| **Sidecar** — um serviço de cobrança à parte, ao lado da instalação | Serviço que não é imagem publicada do compose nunca é atualizado (doutrina de packaging). E ele não alcança onde a cobrança precisa agir: as travas moram em gatilhos do núcleo, e a suspensão precisa de pontos de corte dentro da IA, do envio e da sessão; de fora, o sidecar só chamaria a rota de suspender, que hoje não suspende | o núcleo expor um contrato de corte por organização que um serviço externo possa acionar, com prova dos dois lados |
| **Extensão de pacote** | A lista fechada de capacidades de extensão deixa cobrança de fora, e pacote não traz SQL, credencial nem código ([`extensoes.md`](../doctrine/extensoes.md), não-negociável 2) | — |
| **Tabelas pela função provisionadora da ADR-0002, com gatilhos tolerando a ausência delas** | SQL dinâmico em gatilhos quentes de `user_organizations` e `channel_sessions` (seção "Relação com a ADR-0002") | as travas deixarem de morar em tabelas do núcleo |
| **Instância hospedada paga pelo projeto** (PR #307) | Inverte o eixo 1: contraria o "não vendemos assinatura" da VISION e a invariante 1 da doutrina de operação de agentes | decisão do dono de mudar o modelo do projeto — outra ADR, não esta |
| **Dois provedores aceitos ao mesmo tempo para assinatura nova** | Uma escolha a mais para o revendedor leigo e para o cliente final; trocar de provedor já não quebra quem assinou (D-8) | os revendedores pedirem, com o caso em mãos |
---
## O que esta ADR não decide
- **O faturamento do operador de agentes** (eixo 2): segue na proibição 2.
- **Preço de qualquer coisa:** planos e valores são do revendedor.
- **Nota fiscal, cupom, proração, Pix Automático e Mercado Pago:** fora da primeira entrega (§1.2 do
desenho).
- **O endurecimento geral** do acesso de suporte só-leitura nas policies do banco e o fechamento da
escrita direta ao banco por membro de empresa suspensa (riscos residuais 1 e 2 do desenho;
decisão D-12).
## Aceite
**Aceita em 2026-09-29 pelo dono do produto**, com o desenho e as decisões D-1…D-14, todas pela
recomendação. O aceite não implementa nada. A ordem de construção está na §14 do desenho: a
suspensão que suspende; planos e limites; contrato, Stripe e régua; Asaas; o guia de instalação no
Coolify; o material para revendedores. Para ver o que já chegou à `main`:
`git log --oneline origin/main -- lib/cobranca lib/organizacao`.
```
- [ ] **Step 7: Pôr o número medido no lugar dos marcadores**
Troque, com o Edit, `{{PESO_LEGIVEL}}` pelo valor legível da última linha do Step 5 (ex.: `48 kB`) e `{{PESO_BYTES}}` pelos bytes (ex.: `49152`). Confira:
Run: `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1; grep -c '{{PESO' docs/adr/0004-cobranca-do-revendedor.md`
Expected: `0`
- [ ] **Step 8: Conferir que a ADR não chama a cobrança de "módulo"**
Run: `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1; grep -n -i 'módulo' docs/adr/0004-cobranca-do-revendedor.md`
Expected: toda linha impressa está (a) no cabeçalho ("tabelas de módulo" da ADR-0002), (b) na seção "Relação com a ADR-0002" nomeando o conceito da ADR-0002 ou módulos reais (comanda, honorários), (c) no caminho `lib/instalacao/modulos.ts`, ou (d) na linha da alternativa recusada da provisionadora. Nenhuma diz que a cobrança **é** um módulo. Se alguma disser, reescreva com "capacidade do núcleo com chave".
- [ ] **Step 9: Rodar o teste e ver passar**
Run: `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1; pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts`
Expected: PASS, 4 testes. (Os links da ADR para `../superpowers/specs/...`, `../../VISION.md`, `../white-label.md`, `../doctrine/*.md` e `0002-…md`, e os caminhos em crase `app/api/v1/admin/tenants/[id]/suspend/route.ts`, `hostgator-setup-kit/update.sh` e `lib/instalacao/modulos.ts`, existem.)
- [ ] **Step 10: Commit**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git add docs/adr/0004-cobranca-do-revendedor.md docs/index.md
git commit -F - <<'FIM'
docs(adr): ADR-0004 — cobrança do revendedor, o terceiro eixo de dinheiro
Registra que o dono de uma instalação pode cobrar as empresas que atende,
como capacidade do núcleo com chave da instalação, desligada por padrão.
Classifica a cobrança fora do caminho da provisionadora da ADR-0002 e
registra a revisão da condição 2 só para este caso (decisão D-1), com o
peso medido das duas tabelas vazias num Postgres 17 descartável.
O índice passa a listar a ADR-0003, que faltava, e a ADR-0004.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FIM
```
---
### Task 2: A doutrina de operação de agentes diz de quem é cada proibição
**Files:**
- Modify: `docs/doctrine/operacao-de-agentes.md:29-33` (prosa da §0), `:96` (linha da brecha), `:103-108` (a régua), `:117-119` (proibições 2 e 3)
- Test: `tests/unit/documentacao-aponta-para-o-que-existe.test.ts`
**Interfaces:**
- Consumes: `docs/adr/0004-cobranca-do-revendedor.md` (Task 1), linkada como `../adr/0004-cobranca-do-revendedor.md`.
- Produces: a pergunta que substitui a régua da brecha "Faturamento e planos" — o Task 3 aponta para ela ("a régua está na brecha 'Faturamento e planos' da doutrina, §3").
- [ ] **Step 1: A sonda que falha**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
f=docs/doctrine/operacao-de-agentes.md
grep -c 'hoje devolve 0' $f; grep -c 'não existe nenhuma tabela de' $f; grep -c 'ADR-0004' $f
```
Expected (hoje): `2`, `1`, `0`. A meta do task é `0`, `0`, `5`.
- [ ] **Step 2: Prosa da §0 (linhas 29-33)**
old:
```markdown
A primeira linha foi fechada em 2026-09-13 e é a que destrava o resto: **retainer por cliente
operado**. O trabalho imediato passa a ser o console de agência
(`docs/specs/19-spec-console-de-agencia.md`), e não um medidor de consumo — cobrar por consumo
exigiria construir medidor → fatura antes do primeiro real, e hoje não existe nenhuma tabela de
plano, fatura ou assinatura no schema.
```
new:
```markdown
A primeira linha foi fechada em 2026-09-13 e é a que destrava o resto: **retainer por cliente
operado**. O trabalho imediato passa a ser o console de agência
(`docs/specs/19-spec-console-de-agencia.md`), e não um medidor de consumo — cobrar por consumo
exigiria construir medidor → fatura antes do primeiro real, e o schema não tem tabela que fature o
operador de agentes (a régua é a pergunta da brecha "Faturamento e planos", §3). A cobrança que o
**dono de uma instalação** faz das empresas que atende é outro eixo, decidido à parte na
[ADR-0004](../adr/0004-cobranca-do-revendedor.md), e não é esta linha.
```
- [ ] **Step 3: A linha da brecha (linha 96)**
Duas trocas dentro da mesma linha da tabela (substrings únicas; não mexa no preenchimento de espaços):
old:
```markdown
| **Faturamento e planos** — zero tabelas de plano, fatura ou assinatura no schema
```
new:
```markdown
| **Faturamento e planos do operador de agentes** (eixo 2, o retainer) — não há tabela que fature o operador; a cobrança do revendedor (eixo 3, [ADR-0004](../adr/0004-cobranca-do-revendedor.md)) é outra coisa e não fecha esta brecha
```
old:
```markdown
comando abaixo, que hoje devolve 0
```
new:
```markdown
a pergunta abaixo, respondida tabela a tabela
```
- [ ] **Step 4: A régua (linhas 103-108)**
old:
````markdown
A régua da primeira linha — **hoje devolve 0**, e continua devolvendo 0 até existir:
```bash
grep -oiE 'create table (if not exists )?public\.[a-z_]+' supabase/baseline.sql \
| grep -icE 'invoice|^create table (if not exists )?public\.(billing|plans|subscriptions|quota|credits)$'
```
````
new:
````markdown
A régua da primeira linha é uma **pergunta**, não uma contagem. Liste as tabelas do schema com
cara de cobrança:
```bash
grep -oiE 'create table (if not exists )?"?public"?\."?[a-z_]+' supabase/baseline.sql \
| tr -d '"' | grep -iE 'invoice|billing|fatura|plan|assinatura|subscription|cobranca|quota|credit'
```
e responda, para cada linha: **esta tabela fatura o retainer de um cliente operado?** A brecha só
fecha quando alguma responder "sim". Duas classes de linha respondem "não" e não fecham nada:
- tabela de outro domínio que casa o nome — `account_plans` é o plano de contas do caixa,
`push_subscriptions` é inscrição de notificação;
- as tabelas `cobranca_*`, quando existirem: são a cobrança **do revendedor** (eixo 3,
[ADR-0004](../adr/0004-cobranca-do-revendedor.md)) — o dono da instalação cobrando as empresas
que atende, com plano fixo. Não medem nem faturam operação de agentes.
A régua anterior contava zero por dois motivos, e nenhum deles era "não existe": ela só via
`public.x` sem aspas — o trecho do dump, escrito `"public"."x"`, ficava fora — e só casava nomes em
inglês. As tabelas `cobranca_*` passariam por ela invisíveis, e o zero seguiria lido como "a brecha
está aberta" pelo motivo errado.
````
- [ ] **Step 5: Proibições 2 e 3 (linhas 117-119)**
old:
```markdown
2. **Não construir faturamento antes do primeiro operador que paga.** Sem cliente pagante, o
desenho do medidor é adivinhação.
3. **Não cobrar por licença nem por assento** (invariantes 1 e 2).
```
new:
```markdown
2. **Não construir faturamento antes do primeiro operador que paga.** Sem cliente pagante, o
desenho do medidor é adivinhação. Vale para o faturamento do **operador de agentes** (o
retainer, §0), não para a cobrança do revendedor, que é outro eixo e tem decisão própria
([ADR-0004](../adr/0004-cobranca-do-revendedor.md)).
3. **Não cobrar por licença nem por assento** (invariantes 1 e 2). A proibição é do
**mantenedor e do operador de agentes**. Quem instala e cobra as empresas da própria instalação
escolhe planos, preço e limites — inclusive de pessoas — como configuração dele, e todo limite
nasce nulo, isto é, sem teto, até ele definir um (ADR-0004).
```
- [ ] **Step 6: A sonda passa, e a régua nova vê o que deve ver**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
f=docs/doctrine/operacao-de-agentes.md
grep -c 'hoje devolve 0' $f; grep -c 'não existe nenhuma tabela de' $f; grep -c 'ADR-0004' $f
# a régua nova contra o baseline real
grep -oiE 'create table (if not exists )?"?public"?\."?[a-z_]+' supabase/baseline.sql \
| tr -d '"' | grep -iE 'invoice|billing|fatura|plan|assinatura|subscription|cobranca|quota|credit'
# controle positivo: a régua enxerga cobranca_* nas duas grafias do dump
printf 'CREATE TABLE IF NOT EXISTS "public"."cobranca_planos" (\ncreate table if not exists public.cobranca_assinaturas (\n' \
| grep -oiE 'create table (if not exists )?"?public"?\."?[a-z_]+' \
| tr -d '"' | grep -iE 'invoice|billing|fatura|plan|assinatura|subscription|cobranca|quota|credit'
```
Expected: `0`, `0`, `5`; depois `create table if not exists public.push_subscriptions` e `create table if not exists public.account_plans` (as duas classes "não" que o texto nomeia); depois `CREATE TABLE IF NOT EXISTS public.cobranca_planos` e `create table if not exists public.cobranca_assinaturas`. Se a régua contra o baseline imprimir uma terceira tabela, classifique-a no texto antes de seguir.
- [ ] **Step 7: Rodar o gate de links**
Run: `cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1; pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts`
Expected: PASS, 4 testes.
- [ ] **Step 8: Commit**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git add docs/doctrine/operacao-de-agentes.md
git commit -F - <<'FIM'
docs(doutrina): as proibições de faturamento dizem de quem são
A proibição 2 (faturamento antes do primeiro operador que paga) e a 3
(licença ou assento) passam a nomear o eixo: mantenedor e operador de
agentes. Planos e limites do revendedor são configuração dele (ADR-0004).
A régua da brecha "Faturamento e planos" era uma contagem que devolvia
zero por instrumento cego — só via public.x sem aspas e só nomes em
inglês — e continuaria zero com as tabelas cobranca_*. Vira uma pergunta
respondida tabela a tabela, que classifica essas tabelas como eixo 3.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FIM
```
---
### Task 3: "Não cobramos" é promessa do projeto — spec 19, VISION e o plano da LP
**Files:**
- Modify: `docs/specs/19-spec-console-de-agencia.md:37-40` e `:91`
- Modify: `VISION.md:61` e `:79`
- Modify: `docs/growth/lp-plano.md:365`
**Interfaces:**
- Consumes: `docs/adr/0004-cobranca-do-revendedor.md` (Task 1); a pergunta da brecha (Task 2).
- Produces: nada que outro task consuma.
- [ ] **Step 1: A sonda que falha**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
grep -c 'não existe nenhuma tabela de plano, fatura ou' docs/specs/19-spec-console-de-agencia.md
grep -c 'ADR-0004' docs/specs/19-spec-console-de-agencia.md VISION.md docs/growth/lp-plano.md
grep -c 'Não vendemos assinatura\|não vendemos assinatura' VISION.md
```
Expected (hoje): `1`; `…:0` nos três; `1`. Meta: `0`; `1`, `2`, `1`; `1` (a promessa do projeto continua lá).
- [ ] **Step 2: Spec 19, decisão 1 (linhas 37-40)**
old:
```markdown
imediato é **este console**, não um medidor de consumo — cobro por consumo exigiria construir
medidor → fatura antes do primeiro real, e não existe nenhuma tabela de plano, fatura ou
assinatura no schema.
```
new:
```markdown
imediato é **este console**, não um medidor de consumo — cobro por consumo exigiria construir
medidor → fatura antes do primeiro real, e o schema não tem tabela que fature o operador (a
régua está na brecha "Faturamento e planos" da doutrina, §3).
```
- [ ] **Step 3: Spec 19, escopo (linha 91)**
old:
```markdown
**Fora (de propósito):** faturamento/planos/cotas (§1.2 decisão 1); console de revenda; SOC 2, ISO
```
new:
```markdown
**Fora (de propósito):** faturamento/planos/cotas do operador (§1.2 decisão 1) — o console segue
sem faturamento mesmo com a cobrança do revendedor
([ADR-0004](../adr/0004-cobranca-do-revendedor.md)), que é o dono da instalação cobrando as empresas
que atende e não fatura retainer; console de revenda; SOC 2, ISO
```
- [ ] **Step 4: VISION (linhas 61 e 79)**
old:
```markdown
- **O software é 100% open source (MIT), completo, sem versão paga.** Não vendemos assinatura. Não existe feature travada.
```
new:
```markdown
- **O software é 100% open source (MIT), completo, sem versão paga.** Nós não vendemos assinatura; quem instala pode cobrar os próprios clientes ([ADR-0004](docs/adr/0004-cobranca-do-revendedor.md)). Não existe feature travada.
```
old:
```markdown
*Última revisão: 2026-07-19 — reposicionamento e-commerce → multi-nicho / AI Sales OS.*
```
new:
```markdown
*Última revisão: 2026-09-29 — quem instala pode cobrar os próprios clientes (ADR-0004). Anterior: 2026-07-19 — reposicionamento e-commerce → multi-nicho / AI Sales OS.*
```
- [ ] **Step 5: Plano da LP, §11 (depois da linha 365)**
old:
```markdown
Sem tabela de planos — não temos planos. Um bloco só, honesto.
```
new:
```markdown
Sem tabela de planos — não temos planos. Um bloco só, honesto.
> ⚠️ **De quem é esta promessa.** "Não existe cobrança por usuário" e "não temos planos" são
> promessas **do projeto** sobre o software: nem o mantenedor nem uma versão paga cobram por
> pessoa. Quem instala e revende pode cobrar os próprios clientes com planos que limitam pessoas —
> é a instalação dele, não a nossa ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)). Esta seção
> fala com quem instala para si; não estenda a promessa ao cliente final de um revendedor.
```
- [ ] **Step 6: A sonda passa**
Run: o mesmo bloco do Step 1.
Expected: `0`; `docs/specs/19-spec-console-de-agencia.md:1`, `VISION.md:2`, `docs/growth/lp-plano.md:1`; `1`.
- [ ] **Step 7: Os links resolvem**
Estes três arquivos não estão na lista `AUTORIDADE` do gate, então confira à mão:
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
test -f docs/specs/../adr/0004-cobranca-do-revendedor.md && test -f docs/adr/0004-cobranca-do-revendedor.md \
&& test -f docs/growth/../adr/0004-cobranca-do-revendedor.md && echo links-ok
```
Expected: `links-ok`
- [ ] **Step 8: Commit**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git add docs/specs/19-spec-console-de-agencia.md VISION.md docs/growth/lp-plano.md
git commit -F - <<'FIM'
docs: a promessa de não cobrar é do projeto, não de quem revende
A VISION separa "nós não vendemos assinatura" de "quem instala pode
cobrar os próprios clientes" (ADR-0004). A spec 19 diz que o console de
agência segue sem faturamento do operador, e troca a afirmação "não
existe tabela de plano ou assinatura", que envelheceria com as tabelas
da cobrança, pela régua da doutrina. O plano da LP marca "sem cobrança
por usuário" como promessa do projeto.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FIM
```
---
### Task 4: Catálogo de regras — o estado medido de B-01, B-02 e B-04
**Files:**
- Modify: `docs/business-rules/00-business-rules-catalog.md:472-473` (B-01), `:480` (B-02), `:495` (B-04)
B-03 (retenção de mídia) e B-05 (sync inicial da Nuvemshop) **não** tratam de cobrança e não são tocadas.
**Interfaces:**
- Consumes: `docs/adr/0004-cobranca-do-revendedor.md` (Task 1).
- Produces: nada que outro task consuma.
- [ ] **Step 1: Medir o que as três regras afirmam (a sonda que "falha" contra o texto)**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
grep -c 'usage_events' supabase/baseline.sql # B-01: a tabela
grep -rn 'usage_events' lib app workers components hooks scripts | grep -v database.types | wc -l
grep -rln 'internal_deskcomm' lib app workers scripts supabase/baseline.sql | wc -l # B-02: o tenant
grep -n 'insert into llm_calls' lib/agent-engine/edge/llm/run-model-call.ts # B-02: quem grava
grep -n 'create or replace function public.fn_gasto_de_ia_do_mes' supabase/baseline.sql
grep -rn 'rate_limit_rps\|rate_limit_config' lib app workers components hooks scripts | grep -v database.types | wc -l # B-04
grep -n '"rate_limit_rps"' supabase/baseline.sql
grep -n 'export const TETO_\|export const JANELA_' lib/mcp/rate-limit.ts
grep -c '^- \*\*Estado\*\*' docs/business-rules/00-business-rules-catalog.md
```
Expected: `0`; `0`; `0`; duas linhas (`insert into llm_calls`, ~738 e ~925); uma linha (~13111); `0`; uma linha (`"rate_limit_rps" integer DEFAULT 100 NOT NULL`, ~1756); três linhas (`TETO_POR_ORGANIZACAO = 600`, `TETO_DE_ESCRITA = 30`, `JANELA_SEGUNDOS = 60`); `1` (só a B-03 tem linha de estado). Se algum número divergir, o texto abaixo precisa ser reescrito com o que foi medido — não publique o que não bate.
- [ ] **Step 2: B-01**
old:
```markdown
- **Enforcement**: Workers de cada subsistema (WhatsApp send/recv, IA invocation, storage upload).
- **Exceção**: Nenhuma.
```
new:
```markdown
- **Enforcement**: Workers de cada subsistema (WhatsApp send/recv, IA invocation, storage upload).
- **Exceção**: Nenhuma.
- **Estado**: **não construída.** A tabela `usage_events` não existe (`grep -c usage_events supabase/baseline.sql`). O custo por organização que existe é o de IA, em `llm_calls.cost_cents` (B-02). A cobrança do revendedor ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)) cobra plano fixo, não consumo, e não depende desta regra; para o operador de agentes, a unidade decidida é retainer, não consumo (`docs/doctrine/operacao-de-agentes.md` §0).
```
- [ ] **Step 3: B-02**
old:
```markdown
- **Exceção**: Custos administrativos da plataforma (super-admin testando, suporte) são debitados ao tenant `internal_deskcomm`.
```
new:
```markdown
- **Exceção**: Custos administrativos da plataforma (super-admin testando, suporte) são debitados ao tenant `internal_deskcomm`.
- **Estado**: cumprida por outro mecanismo. Não há evento de billing vindo do Gateway nem worker de billing: o próprio runtime grava o custo de cada chamada em `llm_calls.cost_cents`, com o `organization_id` de onde ela roda (`lib/agent-engine/edge/llm/run-model-call.ts`, `lib/ai/log-invocation.ts`), em centavos de **dólar**, e `fn_gasto_de_ia_do_mes` é a única soma (vigiada por `tests/unit/orcamento-uma-regua-de-gasto.test.ts`). A exceção não existe: não há tenant `internal_deskcomm` (`grep -rn internal_deskcomm lib app workers supabase/baseline.sql`). É essa soma que o teto de IA do plano do revendedor consome ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)).
```
- [ ] **Step 4: B-04**
old:
```markdown
- **Override**: Cliente enterprise pode contratar plano com RPS maior; ajuste em `tenants.rate_limit_config`.
```
new:
```markdown
- **Override**: Cliente enterprise pode contratar plano com RPS maior; ajuste em `tenants.rate_limit_config`.
- **Estado**: **não cumprida como escrita.** A coluna `organizations.rate_limit_rps` (padrão 100) existe no schema e nada a lê; `tenants.rate_limit_config` não existe; nenhum teto de 100 RPS por organização é aplicado. O teto real da API é de escrita, por token e por organização, numa janela fixa (`grep -n 'TETO_\|JANELA_' lib/mcp/rate-limit.ts`), aplicado às rotas com Bearer por `lib/api/auth-dual.ts`. A cobrança do revendedor ([ADR-0004](../adr/0004-cobranca-do-revendedor.md)) não vende nem limita RPS; o desenho dela prevê remover a coluna sem leitor (`grep -n rate_limit_rps supabase/baseline.sql` diz se ela ainda existe).
```
- [ ] **Step 5: A sonda passa**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
f=docs/business-rules/00-business-rules-catalog.md
grep -c '^- \*\*Estado\*\*' $f; grep -c 'ADR-0004' $f
test -f docs/business-rules/../adr/0004-cobranca-do-revendedor.md && echo link-ok
git diff --stat -- $f
```
Expected: `4`; `3`; `link-ok`; `1 file changed, 3 insertions(+)` (só acréscimos; B-03 e B-05 intactas).
- [ ] **Step 6: Commit**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git add docs/business-rules/00-business-rules-catalog.md
git commit -F - <<'FIM'
docs(regras): estado medido das regras de cobrança B-01, B-02 e B-04
B-01: usage_events nunca existiu. B-02: o rateio de IA por organização é
cumprido pelo runtime em llm_calls.cost_cents e somado por
fn_gasto_de_ia_do_mes, sem worker de billing nem tenant interno. B-04: a
coluna rate_limit_rps não tem leitor e o teto real da API é por token e
por organização. B-03 e B-05 não tratam de cobrança e ficam como estão.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FIM
```
---
### Task 5: Doutrina de extensões — a máquina da ADR-0002 já está construída
**Files:**
- Modify: `docs/doctrine/extensoes.md:159`
- Test: `tests/unit/documentacao-aponta-para-o-que-existe.test.ts`
**Interfaces:**
- Consumes: nada dos tasks anteriores.
- Produces: nada.
- [ ] **Step 1: Medir (a sonda que falha)**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
grep -c 'ainda não construída' docs/doctrine/extensoes.md
grep -n 'create or replace function public.fn_modulo_instalar' supabase/baseline.sql
grep -n 'create or replace function public.fn_[a-z_]*_provisionar' supabase/baseline.sql
grep -n '^-- ---- módulo instalado: instalar e reaplicar\|^-- ---- honorários: primeiro módulo oficial' supabase/baseline.sql
```
Expected: `1`; uma linha (~33108); uma linha (`fn_honorarios_provisionar`, ~36796); duas linhas de rótulo, com `(migration 0340)` e `(migration 0480)`. Se `fn_modulo_instalar` não aparecer, **não** edite: a afirmação da doutrina segue verdadeira e o task acaba aqui.
- [ ] **Step 2: Trocar a afirmação por fato e comando**
old:
```markdown
[ADR-0002](../adr/0002-tabelas-de-modulo-num-banco-so.md), **aceita em 17/09/2026, ainda não construída**; dados de extensão de terceiro: marco 4 (PROG-017 §8) |
```
new:
```markdown
[ADR-0002](../adr/0002-tabelas-de-modulo-num-banco-so.md), **aceita em 17/09/2026 e construída** — a instalação e a reaplicação vieram na migration 0340 e o primeiro módulo a usá-las foi `honorarios` (migration 0480); as provisionadoras em vigor: `grep -n 'create or replace function public.fn_[a-z_]*_provisionar' supabase/baseline.sql`; dados de extensão de terceiro: marco 4 (PROG-017 §8) |
```
- [ ] **Step 3: A sonda passa e o gate segue verde**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
grep -c 'ainda não construída' docs/doctrine/extensoes.md
pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts
```
Expected: `0`; PASS, 4 testes.
- [ ] **Step 4: Commit**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git add docs/doctrine/extensoes.md
git commit -F - <<'FIM'
docs(extensoes): a máquina da ADR-0002 já está construída
"Aceita, ainda não construída" era nota de pendência vencida: a
instalação e a reaplicação de módulo vieram na migration 0340 e os
honorários (0480) já usam a provisionadora. A afirmação vira fato mais o
comando que lista as provisionadoras em vigor.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FIM
```
---
### Task 6: A suíte inteira, a ausência de fragmento e o PR
**Files:**
- Nenhum arquivo novo. Test: `pnpm test:unit` (a suíte inteira, sem caminho).
**Interfaces:**
- Consumes: os commits dos Tasks 1–5.
- Produces: o PR 0.
- [ ] **Step 1: Os gates que varrem documentos, isolados**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
pnpm exec vitest run tests/unit/documentacao-aponta-para-o-que-existe.test.ts \
tests/unit/traducao-nao-defasa.test.ts tests/unit/evidencia-citada.test.ts \
tests/unit/evidencia-no-caminho-versionado.test.ts tests/unit/handoff-na-raiz-nao-volta.test.ts
```
Expected: `Test Files 5 passed (5)`. (O selo de tradução guarda só `docs/white-label.md`, que este PR não toca; os de evidência varrem `docs/superpowers/specs/` — onde a spec desta branch está — atrás do caminho de evidência que o `.gitignore` ignora e de imagem não versionada: a spec cita `evidence/`, que é o caminho certo.)
- [ ] **Step 2: A suíte inteira, com a saída guardada**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
pnpm test:unit > /tmp/vt-pr0.log 2>&1; echo "exit=$?"
grep -aE "Test Files|Tests " /tmp/vt-pr0.log | tail -2
r=$(grep -aE "^ *Tests " /tmp/vt-pr0.log | tail -1 | grep -oE "[0-9]+ failed" | head -1)
g=$(grep -acE "^ *FAIL " /tmp/vt-pr0.log)
echo "rodapé: ${r:-0 failed} | grep contou: $g"
grep -aE "^ *FAIL " /tmp/vt-pr0.log | sed 's/ > .*//' | sort | uniq -c
```
Expected: `exit=0`, rodapé sem `failed`, `rodapé: 0 failed | grep contou: 0`. Exceção conhecida e não sua: `lib/ai/dispatcher/rate-limit.test.ts` falha em 5 casos quando o `.env.local` tem `UPSTASH_REDIS_REST_URL`/`TOKEN` apontando para um Redis que não está de pé (CLAUDE.md, "Vermelho local que NÃO é seu"). Qualquer outro vermelho: este PR só mexe em `.md`, então rode `git stash`-free o diferencial — `git switch --detach origin/main && pnpm exec vitest run <arquivo> ; git switch -` — antes de atribuí-lo a este PR. Se as duas linhas do rodapé e do `grep` não baterem, rode de novo com `--reporter=verbose` em vez de concluir pelo silêncio.
- [ ] **Step 3: Nenhum fragmento, nenhum arquivo fora de `docs/` e `VISION.md`**
Run:
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git diff --name-only origin/main...HEAD
```
Expected, exatamente:
```
VISION.md
docs/adr/0004-cobranca-do-revendedor.md
docs/business-rules/00-business-rules-catalog.md
docs/doctrine/extensoes.md
docs/doctrine/operacao-de-agentes.md
docs/growth/lp-plano.md
docs/index.md
docs/specs/19-spec-console-de-agencia.md
docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md
```
Nada em `.changes/`: PR só de documentação não muda comportamento visível a quem opera uma VPS (DoD 17), e o CI valida a forma dos fragmentos, não a presença.
- [ ] **Step 4: Push e PR (ação externa — PARE e peça confirmação explícita ao dono antes)**
```bash
cd /Users/rafaelmelgaco/deskcomm-saas/spec || exit 1
git push -u origin docs/spec-cobranca-do-revendedor
gh pr create --base main --head docs/spec-cobranca-do-revendedor \
--title "docs(adr): ADR-0004 — cobrança do revendedor, e a spec que a detalha" \
--body-file - <<'FIM'
## O que é
PR 0 da cobrança do revendedor, só documentação. Traz juntas a spec aprovada
(`docs/superpowers/specs/2026-09-29-cobranca-do-revendedor-design.md`) e a
ADR-0004, que registra o terceiro eixo de dinheiro do produto: o dono de uma
instalação cobra as empresas que atende, como capacidade do núcleo com chave da
instalação, desligada por padrão.
## O que muda na doutrina
- ADR-0004: classificação fora do caminho da provisionadora da ADR-0002, com o
peso medido das duas tabelas vazias; revisão da condição 2 da ADR-0002 só
para este caso (decisão D-1 do dono); alternativas recusadas (fork, sidecar,
extensão, provisionadora com `to_regclass`, a instância hospedada do #307).
- `operacao-de-agentes.md`: proibições 2 e 3 nomeiam o eixo (mantenedor e
operador de agentes); a régua da brecha "Faturamento e planos" deixa de ser
uma contagem cega e vira uma pergunta respondida tabela a tabela.
- Spec 19, VISION e o plano da LP: "não cobramos" é promessa do projeto; o
console de agência segue sem faturamento.
- Catálogo de regras: estado medido de B-01, B-02 e B-04 (B-03 e B-05 não são
de cobrança).
- `extensoes.md`: a máquina da ADR-0002 já está construída (0340, 0480).
- Índice: ADR-0003 (faltava) e ADR-0004.
## Prova
- `pnpm test:unit` inteiro: rodapé colado abaixo.
- `tests/unit/documentacao-aponta-para-o-que-existe.test.ts`: verde (a ADR e a
doutrina só apontam para o que existe).
- Sem fragmento `.changes/`: nada muda para quem opera uma VPS.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
FIM
```
Depois de criado, cole no PR, como comentário, as duas linhas do rodapé do Step 2 (`grep -aE "Test Files|Tests " /tmp/vt-pr0.log | tail -2`) e o valor do peso medido no Task 1, com o SHA do commit da ADR (`git rev-parse --short HEAD`).
Expected: `gh` imprime a URL do PR.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,877 @@
# Cobrança do revendedor — especificação
- **Status:** aprovada pelo dono do produto em 29/09/2026 (desenho e as 14 decisões D-1…D-14, todas pela recomendação).
- **Base de leitura:** toda referência `arquivo:linha` foi medida na `main` em `76355d4b9`. Ao implementar cada PR, reconfira a linha no SHA do dia: o conteúdo citado é a autoridade, o número da linha envelhece.
- **Escopo:** a capacidade de o dono de uma instalação transformá-la num SaaS próprio e cobrar as empresas que atende (Stripe e Asaas na 1ª entrega), mais a suspensão de organização que de fato suspende, que é pré-requisito dela e conserta um defeito atual.
- **Entrega:** 7 PRs em ordem (§14). Cada PR recebe o seu próprio plano de implementação.
- **Material para os alunos (PRs 4 e 5):** guia Coolify, `docs/saas/`, skill `deskcomm-saas` e roteiros de aula — escritos **depois** do código, descrevendo o que existe.
## Decisões do dono (29/09/2026)
| # | Pergunta | Decisão | Por quê |
|---|---|---|---|
| D-1 | A cobrança entra como capacidade do NÚCLEO, com as duas tabelas (planos e assinaturas) vazias no banco de toda instalação, inclusive de quem nunca liga a cobrança? Isso revisa, só para este caso, a condição 2 da ADR-0002 ('quem não usa o módulo não carrega as tabelas dele'). | **Sim, núcleo, registrado na ADR-0004 como exceção explícita à condição 2.** | As travas de pessoas e de números, e o teste grátis automático, precisam morar nas tabelas centrais (membros, canais, empresas). A regra da ADR-0002 proíbe que o módulo instalável mexa nessas tabelas. O custo são duas tabelas vazias; a alternativa põe código dinâmico no caminho de todo convite e toda conexão de número. |
| D-2 | O rótulo de plano antigo (standard/pro/enterprise), que hoje aparece no painel de cada empresa, deve ser apagado de todas as instalações? | **Não apagar. Com a cobrança desligada, nada muda; com ela ligada, o rótulo vira só leitura, como 'Rótulo antigo'.** | Apagar some com um dado que o operador via, e pela regra de versões isso exige uma versão 'major' com aviso de ação. Mantê-lo custa nada e cumpre a promessa de que quem não usa a cobrança não vê diferença. |
| D-3 | Quando o cliente troca de plano, o plano novo vale na hora ou só na próxima cobrança paga? | **Só na próxima cobrança paga, nesta primeira entrega, tanto para subir quanto para descer. Upgrade imediato cobrando a diferença fica para depois, se os revendedores pedirem.** | Se vale na hora e o preço só muda no ciclo seguinte, o cliente sobe no dia 1, usa o plano caro (inclusive a IA paga pelo revendedor) e desce no dia 28, pagando sempre o barato. Cobrar a diferença na hora é mais código em cada provedor. |
| D-4 | Mudar para um plano menor do que o uso atual (ex.: tem 5 pessoas e o plano novo permite 3): bloquear ou deixar e só impedir de crescer? | **Bloquear na nossa tela, com a lista do que remover. Se o uso crescer entre o pedido e a virada do ciclo, o plano novo entra e só impede crescer.** | Permitir cria o estado 'tenho 5 de 3 e não consigo convidar' sem o cliente entender. Bloquear é uma mensagem clara. |
| D-5 | Quanto tempo entre o vencimento e a suspensão? | **7 dias por padrão, ajustável de 5 a 30 em /admin/cobranca. Ninguém é suspenso sem um aviso final da dívida atual enviado pelo menos 48 horas antes.** | Boleto e Pix levam até 1 dia útil para compensar, e um fim de semana entra no meio. Abaixo de 5 dias, quem pagou no vencimento seria suspenso. |
| D-6 | O dono pode reativar à mão uma empresa suspensa por falta de pagamento, com a dívida ainda aberta? | **Só por 'Dar prazo até DD/MM' (máximo 60 dias, reativa na hora) ou 'Tornar isenta'. O botão genérico 'Reativar' fica só para a suspensão administrativa.** | Sem prazo, a régua suspenderia de novo na hora seguinte e o dono acharia que o botão não funciona. Proibir tiraria dele a negociação com o cliente. |
| D-7 | Ao publicar (trocar a chave de teste pela de produção), o que acontece com as empresas que assinaram em modo de teste? | **Voltam para teste grátis com os dias do plano, com aviso na tela antes de confirmar.** | As assinaturas de teste não existem na conta de produção. Suspender puniria quem testou; manter como paga daria acesso grátis a quem pagou com cartão de teste. |
| D-8 | A instalação pode aceitar Stripe e Asaas ao mesmo tempo para novas assinaturas? | **Não. Um provedor escolhido para assinaturas novas; as antigas seguem no provedor em que nasceram, e a chave de um provedor com assinaturas vivas não pode ser apagada.** | É uma escolha a menos para o revendedor leigo e para o cliente final. Trocar de provedor não quebra quem já assinou. |
| D-9 | O teto de IA do plano conta o gasto do mês inteiro (inclusive o de empresas que usam chave de IA própria) e o interruptor de emergência de orçamento de IA também o desliga? | **Conta o mês inteiro, mas só bloqueia chamadas feitas com a chave de IA da instalação. O interruptor de emergência desliga também o teto do plano.** | Separar o gasto por chave exige mudar a medição única de gasto de IA. O erro do atalho só aparece em empresa que usa as duas chaves, e é a favor do revendedor. O teto protege o bolso do dono, então o interruptor único dele deve valer para os dois. |
| D-10 | Convites ainda não aceitos contam como pessoa no limite do plano? | **Não. Só membros ativos contam. Ao convidar com o limite já cheio, a tela avisa antes de mandar o e-mail; a trava de verdade é no aceite.** | O convite pendente fica guardado com prazo (`team_invites.expires_at`, `baseline.sql:23688-23700`, migration 0238), mas contá-lo prenderia vagas por convites esquecidos até expirarem ou serem revogados. |
| D-11 | As páginas públicas de anúncio e o link de rastreio continuam funcionando com a empresa suspensa? | **Sim.** | São entrada, sem custo para o revendedor: a mensagem chega e fica gravada, mas nada responde. Cortá-las desperdiça a verba de anúncio do cliente final durante uma compensação de boleto. |
| D-12 | Membros de uma empresa suspensa podem continuar gravando dados de negócio (contatos, notas) por acesso técnico direto ao banco, fora das telas? | **Sim, nesta entrega. Telas, API, token e MCP ficam barrados, e nada que custe dinheiro ou saia para fora funciona. O estado da empresa e os limites já estão travados no banco.** | Fechar esse acesso exige mexer na regra que protege todas as tabelas, e ela também sustenta a LGPD e a entrada de mensagens. É o maior raio de mudança do desenho, para um caminho que o cliente leigo não usa e que não custa nada ao revendedor. |
| D-13 | Durante o teste grátis, o cliente pode escolher qualquer plano (e já usar o limite de IA do plano maior) ou fica no plano padrão até pagar? | **Pode escolher qualquer plano, com a troca valendo na hora durante o teste. O dono controla o risco pela duração do teste e pelo cadastro com aprovação.** | Testar o plano que vai comprar é o que o cliente espera. Travar no padrão exige uma regra a mais e uma explicação a mais na tela. O custo máximo é a IA de um teste, que o dono limita pelos dias. |
| D-14 | Quem cancela mantém o acesso até o fim do período já pago, sem reembolso proporcional? | **Sim. Acesso até o fim do período pago e nenhum reembolso automático. Estorno, se houver, é feito pelo dono no painel do provedor.** | É o padrão de mercado e o que os dois provedores fazem sem configuração. Reembolso proporcional exigiria cálculo e estorno por provedor. |
Decisão anterior, da mesma sessão: caminho **B** — a capacidade entra no núcleo, desligada por padrão, para que quem instala receba atualizações pelo `update.sh` (fork ou prompts que editam o código perderiam a cobrança na primeira atualização, porque a VPS baixa imagem pronta). Provedores da 1ª entrega: **Stripe e Asaas** (Stripe BR não faz Pix recorrente nem emite nota; o Asaas faz os dois).
---
## 1. Visão e fronteiras
### 1.1 O que é
É o **terceiro eixo** de dinheiro do produto:
- **Eixo 1:** o mantenedor não vende assinatura (`VISION.md:61-62`).
- **Eixo 2:** o operador de agentes cobra retainer por cliente operado (`docs/doctrine/operacao-de-agentes.md:24-33`).
- **Eixo 3 (novo):** o **dono de uma instalação** (platform admin) cobra as **empresas da própria instalação**, o que `docs/white-label.md:5-7` já autoriza. Ele cria planos; cada empresa nova ganha teste grátis, paga por checkout hospedado (Stripe ou Asaas), é avisada quando atrasa, é suspensa depois de uma tolerância e volta sozinha quando paga.
### 1.2 Fronteiras duras
**Capacidade do NÚCLEO com chave da instalação, desligada por padrão.** Não é "módulo de tabela" da ADR-0002 (ver §14, PR 0, e decisão D-1).
- Mecanismo: a flag `MODULO_COBRANCA` em `platform_config`, lida por `lib/instalacao/modulos.ts` (só `ligado` liga; falha fechada: `modulos.ts:1-27, 101, 164`), no mesmo trilho de `MODULOS_OPCIONAIS_POR_FLAG` (`modulos.ts:70`). O interruptor fica em `/admin/sistema`.
- As duas tabelas (`cobranca_planos`, `cobranca_assinaturas`) vão para o baseline de toda instalação, vazias. Motivo: os gatilhos de limite e de trial moram em tabelas do núcleo (`user_organizations`, `channel_sessions`, `organizations`) e consultam as tabelas da cobrança; a D4 da ADR-0002 (`docs/adr/0002-tabelas-de-modulo-num-banco-so.md:93-120`) reprova provisionadora cujo corpo toque tabela de fora do módulo, e o precedente de "núcleo pela tripla de sempre" é o caixa (`0002…:179`).
- Não é extensão de pacote: a lista fechada de capacidades exclui cobrança (`docs/doctrine/extensoes.md:56-62`; a régua núcleo × extensão está em `:21-38`).
- Com a chave desligada:
- rotas de cobrança e webhook respondem 404;
- `/admin/cobranca` some do menu admin;
- gatilhos de limite devolvem "sem limite"; o cron sai sem auditar;
- **nada do que já existe muda**: o campo "Plano" do novo tenant, o badge `settings.plan`, a tela `/app/settings/billing` e o menu seguem idênticos; a capacidade **não** aparece na tela "Recursos opcionais" da empresa (§9).
- O self-hoster de empresa única vê só um interruptor a mais em `/admin/sistema`.
**Empresa sem linha em `cobranca_assinaturas` é isenta** de cobrança, limite e régua. Cobre de uma vez: a org do dono (o `install.sh` a cria com a chave desligada, `hostgator-setup-kit/install.sh:2099`), toda org que existia antes de ligar, e quem o dono isentar. Não há estado `isenta` nem `fn_org_do_dono`.
**A PR 1 (suspensão que suspende) não depende da chave.** Conserta a suspensão administrativa que já existe e hoje só tira a pessoa da tela.
**O provedor é insumo, não fonte de regra.** Trial, régua e limites moram no nosso banco. O provedor responde: "há assinatura viva e paga?", "está devendo?", "foi cancelada?".
**Sem SDK.** `fetch` + `node:crypto`. A imagem do worker copia o repositório inteiro (`Dockerfile.worker:16-20`).
**Fora de escopo:** nota fiscal; cupom; proração e upgrade imediato cobrando diferença (decisão D-3); Pix Automático; dois provedores ativos ao mesmo tempo para checkout novo; limite de agentes, funis, contatos, mensagens, armazenamento e campanhas (ponto pronto quando pedirem: `fn_publish_ai_agent_version`, `lib/ai/agents/publish.ts:51`); moeda diferente de BRL; Mercado Pago (PR própria, mesmo contrato).
### 1.3 Semântica de "suspensa" (única, para os dois motivos)
| O quê | Com a org suspensa |
|---|---|
| Mensagem que CHEGA (WAHA, Meta, canal, captação, Nuvemshop) | Continua gravada. Nenhum webhook de entrada muda. |
| IA (texto, voz, sentimento, RAG de conversa), automações, follow-up, campanha, prospecção, webhook de saída, conversão de anúncio, lembrete | **Nada roda e nada sai.** Evento consumido como `skipped`, job vira `failed`, mensagem `queued` vira `failed`. |
| Sessão (tela, server action, API de sessão) | Redireciona para `/account-suspended` ou 403 `org_suspended`. Exceções: rotas de LGPD e de cobrança. |
| Bearer `dsk_` e MCP | 403. |
| LGPD | Nunca bloqueada. |
| Reativação | Zero rajada; um item na Central lista as conversas que receberam mensagem, para revisão humana. |
| Landings `/api/v1/anuncios/*/[org]` e `/api/v1/rastreio/[id]` | Continuam (decisão D-11). |
---
## 2. Modelo de dados
### 2.1 `public.organizations` (existente, `supabase/baseline.sql:1747-1772`)
**Nova coluna `suspended_kind`** (PR 1):
- `add column if not exists suspended_kind text` com `check (suspended_kind in ('administrativa','cobranca'))`.
- Backfill antes do CHECK: `update organizations set suspended_kind='administrativa' where status='suspended' and suspended_kind is null`.
- **Sem CHECK de coerência** com `status`: `workers/lgpd-redact-worker.ts:376-379` troca para `redacted` sem limpar o motivo. Regra de leitura no cabeçalho de `lib/organizacao/operante.ts`: `suspended_kind` só significa algo com `status='suspended'`.
**Novo gatilho `trg_organizacao_estado_so_pelo_servidor` (PR 1)** — `BEFORE INSERT OR UPDATE ON public.organizations`, função `fn_organizacao_estado_so_pelo_servidor()` (`security invoker`, `set search_path=''`):
- se `current_user in ('authenticated','anon')`:
- `TG_OP='INSERT'` → `raise ... using errcode='42501'`;
- `UPDATE` que muda `status`, `suspended_kind`, `suspended_at`, `suspended_reason`, `suspended_by` ou `created_by` (`is distinct from`) → 42501.
- Por quê: `orgs_write_platform_admin` (`baseline.sql:4195`) aceita qualquer `fn_is_platform_admin()`, que ignora o scope (`baseline.sql:325-333`), e `authenticated` tem `GRANT ALL` (`baseline.sql:4686-4687`). Sem o gatilho, um `support_readonly` faria pelo PostgREST: `PATCH status='active'` numa suspensa por cobrança; troca de `suspended_kind` de `administrativa` para `cobranca` (e o próximo pagamento desfaria uma suspensão por fraude); `INSERT` de org isenta de trial.
- Não quebra nenhum escritor legítimo: todo INSERT de org usa o admin client (`lib/auth/provision.ts:107-109, 287-289`) ou definer (`fn_create_tenant_with_owner`); `updateTenant` usa o admin client (`app/actions/settings/updateTenant.ts:52`) e não toca nenhuma das colunas guardadas (`:68-81`); suspend/reactivate passam a ser funções definer (§3.1); o `lgpd-redact-worker` usa service_role.
**`settings.plan` NÃO é apagado.** Com a chave desligada, tudo segue igual: o formulário grava `settings.plan` e o badge (`components/admin/tenants/TenantOverview.tsx:98-99,118-119`) o mostra. Com a chave ligada, o formulário passa a oferecer os planos de cobrança e deixa de gravar `settings.plan`; o card de cobrança aparece e o valor antigo, se houver, vira a linha "Rótulo antigo: X" (só leitura). Apagar exigiria major (`docs/doctrine/versionamento.md:36-39`, "algo que existia sumiu") — decisão D-2.
**Colunas mortas (PR 2):** `drop column if exists ai_budget_cents, drop column if exists rate_limit_rps`. Zero leitores em app, lib, workers, components, hooks e scripts (só `lib/database.types.ts`); nada visível ao operador; o rollback de imagem pelo `agent.sh` não quebra (a imagem anterior não as lê). Atualizar `docs/specs/01-spec-platform-base.md:101-102` (DoD 16).
A RLS de `organizations` não muda.
### 2.2 `public.cobranca_planos` (da instalação, sem `organization_id`)
```sql
create table if not exists public.cobranca_planos (
id uuid primary key default gen_random_uuid(),
nome text not null check (char_length(nome) between 1 and 60),
preco_cents bigint not null check (preco_cents >= 500), -- R$5: mínimo de boleto nos dois provedores
moeda text not null default 'BRL' check (moeda = 'BRL'), -- Asaas não tem moeda; boleto Stripe só BRL
intervalo text not null check (intervalo in ('mes','ano')),
trial_dias integer not null default 14 check (trial_dias between 0 and 90),
max_assentos integer check (max_assentos >= 1), -- null = sem limite
max_canais integer check (max_canais >= 1),
teto_ia_usd_cents integer check (teto_ia_usd_cents >= 100), -- moeda de fn_gasto_de_ia_do_mes (baseline.sql:13111)
padrao_no_cadastro boolean not null default false,
arquivado_em timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid
);
create unique index if not exists cobranca_planos_um_padrao
on public.cobranca_planos ((true)) where padrao_no_cadastro and arquivado_em is null;
alter table public.cobranca_planos enable row level security; -- zero policies: molde de platform_config (baseline.sql:43458-43470)
revoke all on public.cobranca_planos from anon, authenticated;
grant select, insert, update, delete on public.cobranca_planos to service_role;
```
- Limites em colunas, não jsonb (anti-pattern 6). Nome distinto de `account_plans` (`baseline.sql:33414`).
- Preço e intervalo imutáveis enquanto houver assinatura apontando para o plano (nem como `plano_agendado_id`): 409 "arquive e crie outro".
- Alargar a moeda depois é alargamento de CHECK puro.
### 2.3 `public.cobranca_assinaturas` (tenant-aware, uma linha por org)
```sql
create table if not exists public.cobranca_assinaturas (
organization_id uuid primary key references public.organizations(id) on delete cascade,
plano_id uuid not null references public.cobranca_planos(id) on delete restrict,
plano_agendado_id uuid references public.cobranca_planos(id) on delete restrict, -- vale na virada do ciclo (§7e)
estado text not null default 'trial' check (estado in ('trial','ativa','em_atraso','cancelada')),
trial_ate timestamptz,
provedor text check (provedor in ('stripe','asaas')),
modo text check (modo in ('teste','producao')),
provedor_cliente_id text,
provedor_assinatura_id text,
vencida_desde timestamptz, -- MONOTÔNICO: só recua (least) ou zera quando volta a ativa/trial
proximo_vencimento timestamptz, -- fim do período pago; só sobrescrito por valor lido não nulo
cancela_no_fim boolean not null default false,
prazo_extra_ate timestamptz,
ultimo_aviso text check (ultimo_aviso in ('trial_acabando','venceu','suspende_em_breve','suspensa')),
ultimo_aviso_em timestamptz,
checkout_url text, checkout_expira_em timestamptz, -- reuso e reserva anti-clique-duplo (§7b)
relida_em timestamptz, -- instante da última leitura APLICADA do provedor
assinaturas_vivas integer not null default 0, -- >1 = cobrança dupla, mostrado ao dono (§9)
ultimo_erro text check (ultimo_erro in ('credencial_invalida','provedor_fora','pagamento_de_assinatura_cancelada','leitura_invalida')),
ultimo_erro_em timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check ((provedor is null) = (provedor_cliente_id is null))
);
create unique index if not exists cobranca_assinaturas_cliente
on public.cobranca_assinaturas (provedor, provedor_cliente_id) where provedor is not null;
alter table public.cobranca_assinaturas enable row level security;
create policy tenant_isolation_cobranca_assinaturas_select on public.cobranca_assinaturas
for select to authenticated using (public.fn_role_at_least(organization_id, 'admin')); -- spec 13 §4
revoke all on public.cobranca_assinaturas from anon, authenticated;
grant select on public.cobranca_assinaturas to authenticated;
grant select, insert, update, delete on public.cobranca_assinaturas to service_role;
```
- Sem índice parcial por estado: a tabela tem uma linha por org (dezenas a centenas); a varredura da reconciliação é barata e o predicado dela muda (§8). Índice só depois de medir.
- Grants explícitos: o default ACL do baseline daria ALL a `authenticated`; com o revoke, só `service_role` escreve.
- Convivência com as varreduras: RLS nasce ligada, então `fn_proteger_tabelas_de_organizacao` (`baseline.sql:6946`) não planta a policy ampla; sem escrita de `authenticated`, `fn_aplicar_travas_de_suporte` (`baseline.sql:28192`) não planta `support_write_*`.
- LGPD: CPF/CNPJ vai direto ao provedor e **não** é guardado aqui. A linha guarda só ponteiros.
### 2.4 `public.webhook_events_log` (existente, `baseline.sql:1890-1912`)
- O **bloco único** do CHECK de provider (`baseline.sql:14432-14437`) ganha `'stripe','asaas'` (alargamento puro, regra da issue #159).
- `create unique index if not exists uniq_webhook_events_log_cobranca on public.webhook_events_log (provider, external_id) where provider in ('stripe','asaas')`. Hoje não há linha desses provedores.
- **Forma da linha de cobrança** (a policy de leitura vale para qualquer membro, sem papel: `baseline.sql:4346`, comentário do buraco em `:15092-15095`; `GRANT SELECT` em `:4722`):
- `organization_id = NULL` sempre (a org viaja só no `emit_event`); linha com org nula é invisível ao tenant pela própria policy.
- `raw_body = {"id": <evento>, "type": <tipo>}` — nunca o corpo do provedor. `raw_body` nasceu `NOT NULL` (`baseline.sql:1898`), mas o apêndice o afrouxou (`baseline.sql:14816-14817`; NULL = corpo descartado pela retenção), então o ponteiro mínimo é escolha nossa, não exigência do schema; o corpo é só ponteiro: toda decisão vem da releitura.
- `headers = NULL`; `signature_header` = `Stripe-Signature` (é assinatura, não credencial); nunca o `asaas-access-token`. Cinto: `'asaas-access-token'` entra em `PROIBIDOS` de `lib/channels/arquivo-de-webhook.ts:37`.
- `status`: `received` → `processed` (com `processed_at`) depois do `emit_event`; `error` com `error_message='cliente_desconhecido'` quando não há org.
- `webhook-replay` filtra `provider='waha'` (`lib/channels/reprocessar-arquivo-de-webhook.ts:123`): não toca estas linhas.
- Retenção (`lib/channels/retencao-do-arquivo.ts:157,175-184`): esvazia o corpo em D+7 e **apaga a linha em D+90** (`baseline.sql:15132`). Nada da cobrança depende da linha depois do processamento (§9 explica o checklist).
### 2.5 `public.agent_inbox_items` (existente)
O **bloco único** do CHECK de kind (`baseline.sql:~9996-10000`) ganha:
- **PR 1:** `'org_reativada'` — revisão pós-reativação, qualquer motivo (administrativa inclusive).
- **PR 3a:** `'cobranca'` — avisos da régua ao cliente e aviso de 80% do teto de IA.
A última migration que reconstrói a constraint também muda (`tests/unit/kind-check-migration-x-baseline.test.ts`); par `InboxKind` em `tests/invariants/vocabulario-banco-x-typescript.test.ts:124`. O bloqueio pelo teto de IA reusa `budget_exceeded` com `ref_kind='plano'`.
A Central é da **org**, nunca canal para o dono da instalação. O que é do dono (cobrança dupla, credencial inválida, pagamento de assinatura cancelada) é **estado** em `cobranca_assinaturas` mostrado em `/admin/cobranca` (§9).
### 2.6 Funções SQL novas
Todas com `revoke execute ... from public, anon;` + `grant` só a quem precisa (doutrina 9, `tests/invariants/hardening-definer-varredura.test.ts`), no apêndice **antes** da VARREDURA anon (`baseline.sql:42990`, `tests/unit/varredura-anon-e-o-ultimo-bloco.test.ts`). Gatilhos com `set search_path = ''` e nomes qualificados, molde `fn_teto_de_tokens_ativos` (`baseline.sql:38711-38747`).
| Função | PR | Tipo | O que faz | Grant |
|---|---|---|---|---|
| `fn_org_operante(p_org uuid) → boolean` | 1 | sql stable invoker | `coalesce((select status='active' from public.organizations where id=p_org), false)` | service_role |
| `fn_organizacao_estado_so_pelo_servidor()` | 1 | trigger invoker | §2.1 | revoke de todos |
| `fn_suspender_organizacao(p_org, p_kind, p_motivo, p_ator) → jsonb` | 1 | plpgsql definer | §3.1 | service_role |
| `fn_reativar_organizacao(p_org, p_kind_exigido, p_ator) → jsonb` | 1 | plpgsql definer | §3.1 | service_role |
| `fn_cobranca_ligada() → boolean` | 2 | sql stable definer | `exists(select 1 from public.platform_config where chave='MODULO_COBRANCA' and valor='ligado')` | service_role |
| `fn_limite_do_plano(p_org uuid, p_recurso text) → integer` | 2 | plpgsql stable definer | null se chave desligada, sem linha ou plano sem teto; senão `'assentos'` → `max_assentos`, `'canais'` → `max_canais`, `'ia_usd_cents'` → `teto_ia_usd_cents`; outro valor → `raise ... using errcode='22023'`. Os três literais viram constante `RECURSOS_DO_PLANO` em `lib/cobranca/vocabulario.ts` | service_role |
| `fn_cobranca_liberar_suspensoes(p_ator) → integer` | 2 | plpgsql definer | reativa toda org `suspended{cobranca}` via `fn_reativar_organizacao` | service_role |
| `fn_trava_assentos_do_plano()` | 2 | trigger definer | §5 | revoke de todos |
| `fn_trava_canais_do_plano()` | 2 | trigger definer | §5 | revoke de todos |
| `fn_trial_na_criacao_da_org()` | 2 | trigger definer AFTER INSERT ON organizations | ver abaixo | revoke de todos |
**`fn_trial_na_criacao_da_org`:** insere `(org, plano padrão, 'trial', now()+trial_dias)` quando: `fn_cobranca_ligada()`; existe plano `padrao_no_cadastro` não arquivado; `new.created_by is not null`; `new.created_by` não é platform admin ativo. Como o INSERT de org por sessão agora é recusado (§2.1), `created_by` só vem de caminho de servidor: `ensureTenantForUser` (`lib/auth/provision.ts:86-160`), as 4 portas do cadastro e `provisionExternalTenant` (`provision.ts:288-317`). O tenant criado pelo admin recebe o plano explícito do formulário.
**Redefinida:** `fn_create_tenant_with_owner` (`baseline.sql:19215-19262`) — continua gravando `settings.plan` quando `p_request->>'plan'` vier (chave desligada, comportamento atual); aceita `p_request->>'plano_id'` e, com a chave ligada, cria a assinatura `trial` na mesma transação; com a chave desligada, recusa `plano_id`.
**Não redefinidas, de propósito:**
- `fn_support_write_allowed` (`baseline.sql:18642-18647`): é chamada por `emit_event` para todo chamador (`:28451`); pôr o status nela derrubaria LGPD, a entrada de mensagens e o próprio `cobranca.sinal`.
- `emit_event`: ver §16, achado 1.5.
### 2.7 Vocabulário
Constantes em `lib/cobranca/vocabulario.ts` (`ESTADOS_DA_ASSINATURA`, `PROVEDORES_DE_COBRANCA`, `MODOS`, `INTERVALOS`, `AVISOS_DA_REGUA`, `ERROS_DE_LEITURA`) e `TIPOS_DE_SUSPENSAO` em `lib/organizacao/operante.ts`. Toda coluna nova com CHECK entra em `tests/invariants/vocabulario-banco-x-typescript.test.ts`.
### 2.8 Contagens
- Assentos: `count` em `user_organizations` por org (dezenas de linhas). Sem índice antes de medir.
- Canais: `channel_sessions` não arquivadas da org.
---
## 3. Máquinas de estado
### 3.1 Organização: a verdade de "pode operar"
```
active ──fn_suspender_organizacao(kind)──▶ suspended{administrativa|cobranca}
suspended ──fn_reativar_organizacao(kind_exigido)──▶ active
redacted / archived: inalterados, não operantes
```
| Transição | Quem dispara | Guarda |
|---|---|---|
| active → suspended{administrativa} | platform admin por `requirePlatformAdminEscrita()` em `POST /api/v1/admin/tenants/[id]/suspend` | motivo de 10 a 500 caracteres |
| suspended{cobranca} → suspended{administrativa} | idem | a administrativa prevalece (troca o kind) |
| active → suspended{cobranca} | régua (§3.2) | só org com assinatura; sem linha → `{changed:false, motivo:'org_isenta'}` |
| suspended{administrativa} + pedido de cobrança | régua | no-op |
| suspended{cobranca} → active | sincronizar/régua que leem pagamento; dono em "Dar prazo" ou "Tornar isenta"; `fn_cobranca_liberar_suspensoes` | `p_kind_exigido='cobranca'` recusa kind administrativo |
| suspended{administrativa} → active | platform admin em `/reactivate` | `/reactivate` sobre kind `cobranca` → 409 `suspensao_de_cobranca` ("use Dar prazo ou Tornar isenta") |
A escrita **só** acontece por essas duas funções definer; o gatilho de §2.1 garante isso contra o PostgREST.
**`fn_suspender_organizacao`** (uma transação, `select ... for update` na org; conserta a não atomicidade de `suspend/route.ts:55-115` (leitura em 55-59, UPDATE em 71-80, `event_log` solto e sem await em 105-115)):
1. Já suspensa: mesmo kind → `{changed:false}`; cobrança → administrativa troca o kind; administrativa → cobrança é no-op.
2. `update organizations set status='suspended', suspended_kind, suspended_reason, suspended_at=now(), suspended_by`.
3. Anti-backlog: `update job_queue set status='failed', last_error='org_nao_operante' where organization_id=p_org and status='pending'` (`'failed'` é o terminal de veto, `lib/agent-engine/queue/queue.ts:28, 338-353`; não `'dead'`, que alerta `job_dead`).
4. Fecha o redrive: `update messages set status='failed', error_code='org_suspensa' where organization_id=p_org and status='queued'`.
5. `insert into event_log` de `tenant.suspended` na mesma transação (continua em `fn_event_log_e_registro`, `baseline.sql:24523-24524`: nasce `done`, sem consumidor).
**`fn_reativar_organizacao`:**
1. Guarda de kind. 2. Lê `suspended_at` antes de zerar. 3. `status='active'`, `suspended_*=null`. 4. Cinto: jobs `pending` antigos da org → `failed`. 5. Se N conversas têm `last_inbound_at >= suspended_at`, **um** item na Central com `kind='org_reativada'`, `severity='warn'`: "N conversas receberam mensagem enquanto a conta estava suspensa. A IA não respondeu nem vai responder sozinha a elas. Revise na Fila." Sem `force_human`. 6. `event_log tenant.reactivated`. 7. Na assinatura (se houver): `ultimo_aviso=null, ultimo_aviso_em=null`.
### 3.2 Assinatura: a verdade do provedor, traduzida
```
(sem linha) = isenta
(nasce) ──gatilho/atribuição──▶ trial
trial ──1º pagamento confirmado──▶ ativa
trial ──trial_ate passou sem pagamento confirmado──▶ em_atraso (vencida_desde = trial_ate)
ativa ──provedor diz que deve──▶ em_atraso
em_atraso ──provedor diz que está em dia──▶ ativa
ativa|em_atraso ──cancelada / sem assinatura viva paga──▶ cancelada (cancela_no_fim mantém 'ativa' até proximo_vencimento)
cancelada ──nova assinatura com 1º pagamento confirmado──▶ ativa
```
`suspensa` **não** é estado da assinatura (fonte única: `organizations.status/suspended_kind`).
**Quem grava `estado`:** só `sincronizar(org)` (a partir do objeto relido na API) e a régua nas transições de tempo. Nunca o corpo do webhook; nunca as rotas de plano (§7e, §7g).
**Tradução pura** (`lib/cobranca/estado.ts`), a partir de `Situacao` (§6):
| Situação lida (em ordem) | Estado |
|---|---|
| `cancelada && !cancelaNoFim` | `cancelada` |
| `existe && emAtraso` | `em_atraso` |
| `existe` | `ativa` |
| `!existe && jaPagou` (cancelou, ou reassinou e o 1º pagamento da nova ainda não confirmou) | `cancelada` |
| `!existe && emTesteNoProvedorAte > agora` (checkout concluído durante o teste; a 1ª cobrança é do provedor) | `trial`, mesmo com `trial_ate` local vencido |
| `!existe && !jaPagou`, dentro do trial | `trial` |
| `!existe && !jaPagou`, trial vencido | `em_atraso`, `vencida_desde = trial_ate` |
**Regras de gravação** (em `sincronizar`, na fase curta de §7c):
- `vencida_desde`: se o estado é `em_atraso`, `least(coalesce(vencida_desde_local, x), x)` com `x = situacao.vencidaDesde ?? now()` (trial: `trial_ate`). Só vira `null` quando o estado passa a `ativa` ou `trial`. Resultado: cancelar e reassinar **não** reinicia o relógio da dívida.
- `proximo_vencimento`: só sobrescrito por valor lido **não nulo** (cancelamento no Asaas não apaga o período pago).
- Toda transição **para** `ativa` ou `trial` zera `ultimo_aviso` e `ultimo_aviso_em`.
- `plano_agendado_id`: aplicado (vira `plano_id`) quando `proximo_vencimento` avança e o estado é `ativa` (o período novo foi pago). Durante o teste grátis (`trial_ate > now()`, com ou sem provedor), a troca é imediata (decisão D-13, §7e).
- `assinaturas_vivas`, `relida_em = lido_em`, `ultimo_erro = null` (ou `pagamento_de_assinatura_cancelada` se `situacao.pagamentoSemAssinaturaViva`).
**Régua pura** (`lib/cobranca/regua.ts`, sem banco). Entradas: assinatura, org, plano, `agora` injetável, `toleranciaDias = max(5, configurada)` (piso no código: boleto Stripe ~1 dia útil, `CONFIRMED→RECEIVED` e compensação Asaas, mais um fim de semana).
**Filtro por org** (antes de tudo):
- org `active` ou `suspended{cobranca}` → avalia.
- org `suspended{administrativa}` → nenhuma ação, nenhum aviso.
- org `redacted`/`archived` com provedor e estado ≠ `cancelada` → `cancelarNoFim` uma vez + audit `cobranca.assinatura_cancelada` com `motivo:'org_redigida'`; nenhum aviso.
**Dívida corrente:** `debito_desde = vencida_desde` (`em_atraso`) ou `proximo_vencimento` já passado (`cancelada`; se `proximo_vencimento` é nulo, a primeira avaliação grava `vencida_desde = agora` e daí em diante `debito_desde = vencida_desde` — sem valor persistido, `agora` muda a cada rodada e nenhum aviso chega a "pertencer" à dívida). Um aviso "pertence à dívida" só se `ultimo_aviso_em >= debito_desde`.
`limite = max(debito_desde + (em_atraso ? tolerância : 0), prazo_extra_ate)`
| Situação | Ação |
|---|---|
| estado `ativa` ou `trial` vigente, org em suspended{cobranca} | **reativar** |
| em dívida, org ativa, `agora ≥ limite`, `ultimo_aviso='suspende_em_breve'` da dívida corrente, escrito há ≥ 48h, e (com provedor) `relida_em ≥ agora − 1h` | **suspender{cobranca}** |
| em dívida e `agora ≥ limite − 2 dias`, aviso final da dívida corrente não dado | aviso `suspende_em_breve` (critical) |
| `em_atraso`, nenhum aviso da dívida corrente | aviso `venceu` (warn) |
| `trial` e `trial_ate − 3 dias ≤ agora`, aviso não dado | aviso `trial_acabando` |
**Garantias** (cada uma tem caso em `regua.test.ts`):
- Ninguém é suspenso sem aviso final **desta dívida** gravado há ≥ 48h junto com o item `kind='cobranca'` na Central, na mesma transação do `ultimo_aviso` (é o que D-5 chama de "enviado"; o e-mail é melhor esforço, §7d) — inclusive quem pagou depois do aviso no mês anterior e a régua acordou tarde (VPS fora do ar, webhook perdido).
- Nunca se suspende com estado velho (releitura de < 1h com provedor). Leitura falha nunca suspende.
- Trial sem checkout é suspenso pela régua; não vira acesso eterno.
- Clicar em "Assinar" e não pagar não reativa (Asaas e Stripe: sem 1º pagamento confirmado, `existe=false`).
- Boleto emitido e ainda válido nunca conta como atraso (Stripe: só `past_due`/`unpaid`; Asaas: só `OVERDUE`).
---
## 4. Predicado "org operante": definição única e choke points
**Definição:** `operante ⇔ organizations.status = 'active'` — mesma régua dos porteiros SQL existentes (`baseline.sql:19164, 22134, 34925`). `redacted`, `archived` e status futuro ficam não operantes (falha fechada).
- SQL: `public.fn_org_operante(uuid)`.
- TS: `lib/organizacao/operante.ts` exporta `STATUS_OPERANTE`, `ehOperante(status)`, `idsDeOrgsParadas(admin)` (`select id from organizations where status <> 'active'`), `assertOrgOperante(db, orgId)`, `class OrgNaoOperanteError` (`org_suspended`, terminal).
- **Cerca `tests/unit/org-operante-uma-regua.test.ts`** — por AST, no molde de `cron-audita-so-quando-ha-efeito`: mira só **decisões** (argumento de `redirect`, `fail`, `return` de gate, filtro de seleção em `app/api/v1/cron/**`, `lib/**/worker*`, `workers/**`) que comparem `status` de org com literal fora de `operante.ts`. Exibição (`app/admin/(protected)/tenants/[id]/_client.tsx:43`, TenantActions) usa `ehOperante`/`suspended_kind` e não é decisão. SQL em string (ex.: `lib/agent-engine/agent/org-memory.ts:33`, outra tabela) fica fora. Allowlist que só encolhe: `app/actions/shell/setActiveOrg.ts:21` ("troca de org só para ativa; molde anterior ao predicado"). Controles positivo e negativo no próprio teste.
| # | Onde (arquivo:linha) | Caminho coberto | Comportamento com org não operante |
|---|---|---|---|
| 1 | `lib/auth/server.ts:190`, embed de `loadAuthUser` | toda sessão | Embed traz `status, suspended_kind`. `UserOrgMembership` ganha `org_status`, `suspended_kind`. **E** o select de `platform_admins` (`server.ts:174-176`) passa a `user_id, scope, revoked_at`; `AuthUser` ganha `platform_admin_scope` |
| 2 | `lib/auth/server.ts:74`, `escolherMembroAtivo` | org ativa sem cookie | Sem cookie, prefere membership operante; com cookie na suspensa, mantém (para poder pagar) |
| 3 | `lib/auth/server.ts:284-303`, `resolveActiveOrg` | 110 arquivos de páginas, layouts e actions (83 `page.tsx`, 3 `layout.tsx`, 24 actions; `grep -rl resolveActiveOrg app | grep -v '\.test\.'`); `app/onboarding/layout.tsx:15-16`; 22 rotas | Corpo atual vira `orgAtivaSemPortao(user)`; `resolveActiveOrg` = esse corpo + `redirect("/account-suspended")` se não operante (precedente: `redirect("/support-ended")`). Fecha actions (ex.: `unpauseAgentAction`, `app/app/ai/agents/_actions.ts:31-40,108-112`), o escape para `/onboarding` e as páginas |
| 4 | `lib/auth/require-role.ts:62-79` e `:88` | rotas `/api/v1` de sessão | (a) Não operante: `fail("org_suspended", ..., 403)`, salvo `opts.permiteOrgSuspensa === true`. (b) **Bypass de platform admin (`:88`) deixa de ser incondicional:** `allowPlatformAdmin?: boolean \| "leitura"`. `true` libera só `scope==='full'` **e** `!mfaEmDivida()`; `"leitura"` libera qualquer scope. Os handlers `GET` hoje com `true` (ex.: `app/api/v1/audit/route.ts`, `lgpd/requests/route.ts`) passam a `"leitura"` para não tirar leitura do `support_readonly`. Fecha: `support_readonly` anonimizando contato (`app/api/v1/lgpd/anonymize/route.ts:93-98`), aprovando LGPD, criando canal ou pareando voz fora de sessão de acompanhamento |
| 5 | `app/app/layout.tsx:92-116` | render de `/app/*` | Suspensão ANTES de onboarding; erro de leitura de `organizations` passa a **lançar** (hoje falha aberta com `orgRow` nulo) |
| 6 | `lib/mcp/auth.ts:131-135`, `resolveApiToken` | Bearer `dsk_` (`lib/api/auth-dual.ts:96-138`), `/api/mcp` | Select ganha `organizations!inner(status)`; não operante → `ApiTokenError("org_suspended")` → 403; não debita o balde de falhas |
| 7 | `lib/ai/elegibilidade/gate.ts:112`, `decidirElegibilidade` | toda a IA que consulta o gate (`edge/crm/drain.ts`, `agent/inbound-turn.ts`, `workers/ai-response-worker.ts`, `ai-sentiment-worker.ts`, `lib/ai/handoff/orchestrator.ts`, `lib/ai/runtime/agent.ts:416`, `lib/followup/enviar-texto-fixo.ts`, `lib/followup/silence-sweep.ts`, `lib/agenda/meet-delivery.ts`). Não consultam o gate: `send-ai-message.ts` (saída pela linha 12) e `prospecting/worker.ts` (linha 13) só autorizam o contato e leem o pré-go-live; `wacalls/events-bridge.ts` só grava `bot_silenced_until`; `resolve-turn-agent.ts` só o cita em comentário | Primeiro veto `org_nao_operante`. `orgStatus` campo **obrigatório** de `EstadoDeElegibilidade` (`gate.ts:57`); o typecheck obriga os 4 montadores: `consulta-pg.ts:35-54`, `consulta-supabase.ts:53-67`, `silence-sweep.ts:378`, `meet-delivery.ts:36` |
| 8 | `lib/event-log/dispatcher.ts:51-56` e `:81`; `lib/event-log/drain.ts:257-284` | barramento nos 3 drivers (loop do worker `workers/agent-worker/main.ts:368`, cron `event-log-drain`, `lib/relogio/executar.ts:102`) | `EventHandler` ganha `naOrgParada: "roda" \| "pula"` obrigatório. O drain lê o status das orgs do lote numa query `in (...)`. `pula` em org parada → `{status:"skipped", detail:"org_nao_operante"}` (`dispatcher.ts:45`), vai para `consumed_by`: evento `done`, **não volta** na reativação. Classificação abaixo |
| 9 | `lib/agent-engine/edge/crm/drain.ts:219-226` | mensagem → job | Query traz também `status`; não operante → `'processado'` sem job |
| 10 | `lib/agent-engine/cron/scheduler.ts:207-212`, `fireOneDue` | follow-up por lead | Select traz `public.fn_org_operante(organization_id) as operante`; não operante: avança `next_run_at` sem enfileirar; one-shot vira `enabled=false` |
| 11 | `lib/agent-engine/edge/crm/session-reconciler.ts:373-379` | redrive direto ao WAHA (`:390`) | Select ganha `fn_org_operante`; falso → `messages` `failed`/`org_suspensa`, molde `pre_go_live` (`:381-388`) |
| 12 | `app/api/v1/messages/_handler.ts:367-373` | saída de ~20 chamadores | `assertOrgOperante` no topo → 403 `org_suspended`; terminal no settle de `lib/followup/enviar-texto-fixo.ts:~152-156`; um teste por chamador com settle próprio |
| 13 | `lib/prospecting/worker.ts:338-341` | busca paga + LLM | Exclui `idsDeOrgsParadas` |
| 14 | `lib/campanhas/rodada.ts:99-116, 163-172` | campanhas | Passa a `idsDeOrgsParadas`; **corrige o comentário falso** de `:99-101` |
| 15 | `workers/voice-agent/index.ts:243` | voz em tempo real | `select status from organizations`; não operante → `socket.end()`, log `voz_org_suspensa` |
| 16 | `app/api/v1/cron/kb-conversations-batch/route.ts` | embedding de conversas | Exclui `idsDeOrgsParadas` |
| 17 | cerca `tests/unit/cron-respeita-org-operante.test.ts` sobre as 38 rotas de `app/api/v1/cron/` | crons | Importa o predicado **ou** consta de allowlist com justificativa ≥ 20 caracteres que só encolhe (ex.: `webhook-replay`, `data-retention`, `lgpd-sla-watcher`, `storage-redaction`, `event-log-drain`, `followup-flow-worker`, `agenda-reminder`, `lead-time-triggers`, `lead-date-field-due`). A rota `cobranca` (PR 3a) importa `lib/organizacao/operante.ts` para o filtro de §3.2 e por isso não entra na allowlist |
| 18 | `fn_suspender_organizacao` | jobs `pending` e mensagens `queued` | §3.1; dispensa filtro no `CLAIM_SQL` (`queue.ts:126-148`) |
| 19 | `app/api/v1/admin/tenants/[id]/suspend/route.ts:30-115` e `reactivate/route.ts:65-120` | escrita manual de status | `requirePlatformAdminEscrita()`; body segue `{reason}` (10–500), **sem** `tipo`: `suspend` chama `fn_suspender_organizacao(id,'administrativa',reason,ator)`; `reactivate` chama `fn_reativar_organizacao(id,'administrativa',ator)`, com o `reason` só no audit, e kind `cobranca` → 409 `suspensao_de_cobranca`; as duas devolvem `ok(<jsonb da função>)`, isto é, `{changed, motivo?}` |
| 20 | `app/account-suspended/page.tsx:22-59` (fora de `app/app/`; `lib/auth/public-paths.ts:138`) | tela do suspenso | Vira o hub (§9). **Não depende de `x-pathname`** (gravado depois de `NextResponse.next`, `proxy.ts:16` vs `:38-39`; `app/onboarding/_components/Stepper.tsx:29-31`) |
| 21 | `app/api/v1/lgpd/**` e `app/api/v1/cobranca/**` | o que o suspenso ainda faz | `requireRole(..., { permiteOrgSuspensa: true })`; cerca AST `tests/unit/org-suspensa-so-nas-rotas-permitidas.test.ts` |
| 22 | `trg_organizacao_estado_so_pelo_servidor` (§2.1) | PostgREST com JWT | recusa escrita de status/suspensão/`created_by` e INSERT de org |
**Classificação dos handlers** (lista literal num teste):
- **`roda`:** `followupReactivityHandler`, `campanhaRespostaHandler`, `avisoDeEtapaHandler`, `casoNaCentralHandler`, `mediaPersistHandler`, `lgpdExportHandler`, `lgpdRedactHandler`, `cobrancaSinalHandler`.
- **`pula`:** `aiResponseHandler`, `aiSentimentHandler`, `aiHandoffFromSentimentHandler`, `ragIndexerHandler`, `mediaDeriveHandler`, `automationRulesHandler`, `followupGatilho{Retorno,Etapa,Lead,Caso,Presenca}Handler`, `webPushInboundHandler`, `avisoDeCasoAoSuporteHandler`, `avisoDePropostaNoWhatsAppHandler`, `conversaoDeVendaHandler`, `conversaoDeQualificacaoHandler`.
**Já respeitam:** `setActiveOrg.ts:17-22`; `fn_accept_team_invite` (`baseline.sql:19164`); `fn_start_support`/`fn_support_context` (`:18627`, `:18677`); `fn_reply_delivery_policy` (`:22134`); `fn_meet_delivery_current` (`:34925`).
**Deliberadamente NÃO gatilhados** (lista no cabeçalho de `operante.ts`): webhooks de entrada (`waha`, `waha/[token]`, `meta/[token]`, `channel/[token]`, `in/[token]`, `channels/official/webhook`, `nuvemshop/[event]`, `lib/channels/inbound.ts`); landings `anuncios/{google,meta}/[org]` e `rastreio/[id]`; `recover-stuck-messages`; sync/push do Google Agenda e `contact-avatars`; leitura via RLS, Realtime e Storage; escrita de dados de negócio via PostgREST por membro de org suspensa (decisão D-12).
**Scope `support_readonly` (critério da PR 1).** `requirePlatformAdmin` (`lib/auth/requirePlatformAdmin.ts:34-67`) devolve o scope mas não o impõe; conferem `full` só `app/api/v1/admin/tenants/route.ts:166-171` e o helper próprio das extensões, `requireExtensionPlatformFor` (`lib/extensions/http.ts:70`, usado pelas rotas `app/api/v1/extensions/**`).
- **Helper** `requirePlatformAdminEscrita()` no mesmo arquivo: `requirePlatformAdmin()` + `scope==='full'` (403 `forbidden_scope`) + `mfaEmDivida()` (`lib/auth/server.ts:414`, 403 `mfa_required`).
- `app/api/v1/system/update/route.ts:30` (hoje só `user.is_platform_admin`) passa a usar o helper.
- **Cerca `tests/unit/admin-escrita-exige-scope-full.test.ts`, pelo mecanismo, em `app/**` inteiro:**
- todo handler exportado `POST|PATCH|PUT|DELETE` que chama `requirePlatformAdmin(` ou lê `.is_platform_admin` precisa chamar `requirePlatformAdminEscrita(`;
- todo arquivo `"use server"` que importa `requirePlatformAdmin` precisa usar a versão de escrita;
- `allowPlatformAdmin: "leitura"` só aparece em handler `GET`;
- allowlist que só encolhe, com justificativa.
---
## 5. Limites do plano
Valem só quando `fn_limite_do_plano(org, recurso)` não é nulo.
**Troca de plano só na virada do ciclo** (§7e), salvo durante o teste grátis, em que vale na hora (decisão D-13). Downgrade abaixo do uso é recusado na nossa tela, com a lista do que remover; se o uso crescer entre o agendamento e a virada, o plano novo entra assim mesmo e só bloqueia crescer. O portal da Stripe é configurado **sem** troca de plano.
| Limite | Onde é imposto | Mensagem |
|---|---|---|
| **Assentos** | **Contagem:** `revoked_at is null and not provisional_until_handover and user_id <> new.user_id`.<br><br>**Gatilho** `trg_trava_assentos_do_plano BEFORE INSERT OR UPDATE OF revoked_at, provisional_until_handover, organization_id ON public.user_organizations`:<br>1. **Linha provisória só pelo servidor:** se `new.provisional_until_handover` e `current_user in ('authenticated','anon')` → 42501. O único escritor legítimo é `fn_create_tenant_with_owner` (`baseline.sql:19253`); sem isso, um admin de tenant inseriria membros provisórios pelo PostgREST (`user_orgs_insert` `:4308`, `GRANT ALL` `:4709-4711`, coluna sem revoke `:43073`), cada um invisível à contagem e com acesso pleno (`fn_user_org_ids` só olha `revoked_at`, `:18651-18654`).<br>2. **Conta quando:** INSERT ativo; `revoked_at` volta a nulo; `provisional_until_handover` vira `false` num ativo; `organization_id` muda num ativo.<br>3. `pg_advisory_xact_lock(hashtextextended(new.organization_id::text, 2282))`; acima do teto `raise ... using errcode='PT402'` (precedente `PT409`, `baseline.sql:38711-38747`).<br><br>Caminhos alcançados: `fn_accept_team_invite` (`:19174-19182`), `app/api/v1/team/[user_id]/reactivate/route.ts:79-84`, `lib/auth/provision.ts:126,308,405`, `fn_create_tenant_with_owner`, PostgREST | **Aceite de convite** (`lib/auth/aplicar-convite.ts:78-86`: `PT402` → motivo `limite_do_plano`): "A empresa que te convidou atingiu o limite de pessoas do plano. Avise quem te convidou."<br>**Reativar membro:** 409 `plan_limit_reached`, "Seu plano permite N pessoas e todas as vagas estão ocupadas. Remova alguém em Equipe ou troque de plano em Configurações › Plano e cobrança."<br>**Emitir convite** (`app/api/v1/team/invite/route.ts:91-133`): mesma mensagem antes do e-mail (aviso, não autoridade) |
| **Canais de mensagem** | **Contagem:** `archived_at is null and provider <> 'wacalls'` (um teste compara com `PROVIDERS_DE_MENSAGEM`, `lib/channels/capabilities.ts:155-161`).<br><br>**Gatilho** `trg_trava_canais_do_plano BEFORE INSERT OR UPDATE OF archived_at, provider, organization_id ON public.channel_sessions`, com a mesma chave de lock de `fn_reserve_channel_connection` (`hashtextextended(p_org::text,2281)`, `baseline.sql:23627`). Estourado → `PT402`.<br><br>Caminhos: `lib/channels/connect-waha.ts:38`, `app/api/v1/channels/official/route.ts:308`, `lib/channels/connect.ts:230`, `lib/channels/social/store.ts:147`, `lib/channels/graph-parceiro/session.ts:132`, ressurreições (`connect.ts:224-231`, `lib/channels/reactivate.ts:82`) | `lib/cobranca/limites.ts: traduzirLimiteDoPlano(err)` nas rotas `channel-sessions`, `onboarding/whatsapp/session`, `channels/{official,partner,social,graph-partner}`: 409 `plan_limit_reached`, "Seu plano permite N números conectados. Arquive um número em Conexões ou troque de plano." |
| **Teto de IA do plano** (US$ centavos/mês) | Separado de `ai_budgets` (orçamento que a org escolhe, `app/api/v1/ai/budget/route.ts:104, 195-227`). **Só vincula com `origemDaChave === 'chave_da_instalacao'`** (`lib/agent-engine/edge/llm/credentials.ts:370-387`).<br><br>**`SQL_CONFIG_COM_ORCAMENTO` (`credentials.ts:231-240`) NÃO muda.** O catch dele (`:311-313`) troca para a query legada em qualquer erro e desliga o orçamento de toda org (`:325-327`); pendurar a função nova ali amarraria o orçamento existente a ela.<br><br>**Consulta própria** `lerTetoDoPlano(db, org)` em `lib/agent-engine/edge/llm/orcamento.ts`: `select public.fn_limite_do_plano($1,'ia_usd_cents') as teto, public.fn_gasto_de_ia_do_mes($1) as gasto`, feita **só** quando a origem é a chave da instalação, com try/catch próprio: erro → teto indisponível (log com SQLSTATE), chamada segue, orçamento da org intacto.<br><br>**Decisão pura** `decidirTetoDoPlano({tetoUsdCents, gastoUsdCents, origemDaChave, purpose})`: `PURPOSES_ISENTOS` isentos; `gasto ≥ teto` → bloquear.<br><br>**Engine:** em `aplicarOrcamento` (`run-model-call.ts:~307-318`), a ordem vira: `chave==='off'` (alavanca de emergência `AI_BUDGET_ENFORCEMENT`) → retorna; teto do plano; `orcamentoIndisponivelPorque` → retorna; `modo==='off'` → retorna; orçamento da org. Instalação sem a chave de cobrança: `fn_limite_do_plano` devolve nulo e não há consulta de gasto.<br><br>**Worker legado:** `workers/ai-response-worker.ts`, dentro de `vetoPorTetoDeGasto` (`:403-454`), antes de `enforcement_mode === "off"` (`:426`). A origem é `resolverModeloDoPonto(...).origem === "padrao"` (`lib/ai/gateway-binding.ts:42, 105, 121, 132`), que hoje só é resolvida em `:228`, depois do veto (`:198`). O teto do plano precisa rodar depois dessa resolução, ou a resolução sobe para antes do veto. (`lib/ai/runtime/agent.ts:339-358` é o runtime `runAgent`, outro caminho, acionado pelo dispatcher que hoje é no-op.) | `LlmBudgetExceededError` existente (`run-model-call.ts:59`) → handoff humano (`HANDOFF_REASON_ORCAMENTO`, `orcamento.ts:87`). Item `budget_exceeded` `ref_kind='plano'`, `ref_id=<org>`. Exige três ajustes: (1) `"plano"` entra em `InboxRefKind` e nos `refs` de `budget_exceeded` em `lib/ai/inbox-destino.ts:37,60`, porque hoje ref fora da lista vira "Este contexto não está disponível para você" (`:211-212`); (2) o dedup de `run-model-call.ts:381-384` (e o do worker legado) passa a filtrar `ref_kind`, senão o item do plano cala o do orçamento da org e vice-versa; (3) `retratarAvisos` (`app/api/v1/ai/budget/route.ts:324-330`) passa a filtrar `ref_kind='ai_budget'`, senão afrouxar o orçamento da org fecha o aviso do plano. Texto: "O uso de IA incluído no plano acabou neste mês. As conversas foram para a equipe. Troque de plano, cadastre uma chave de IA própria ou aguarde o próximo mês."<br>**80%:** cron da cobrança (§8), item `kind='cobranca'` `ref_kind='teto_ia'`, um por mês |
Régua de gasto única: `fn_gasto_de_ia_do_mes` (vigiada por `tests/unit/orcamento-uma-regua-de-gasto.test.ts`); soma o mês inteiro, inclusive BYOK (decisão D-9).
---
## 6. Contrato de provedor e adaptadores
`lib/cobranca/provedores/contrato.ts`:
```ts
export const PROVEDORES_DE_COBRANCA = ["stripe", "asaas"] as const;
export type ProvedorDeCobranca = (typeof PROVEDORES_DE_COBRANCA)[number];
export type Modo = "teste" | "producao";
export interface PlanoParaProvedor {
id: string; nome: string; precoCents: number; intervalo: "mes" | "ano"; // moeda sempre BRL (§2.2)
}
/** O que o webhook AUTORIZA: acordar a leitura. Nunca é fonte de estado. */
export interface SinalDoWebhook { eventoId: string; tipo: string; clienteRef: string | null }
/** A única verdade que o provedor nos dá — relida na API. */
export interface Situacao {
assinaturaRef: string | null; // principal: a mais recente NÃO terminal; senão a mais recente
existe: boolean; // principal não terminal E com o 1º pagamento confirmado
assinaturasVivas: number; // não terminais, inclusive as à espera do 1º pagamento
cancelada: boolean; // nenhuma não terminal e a mais recente é terminal
cancelaNoFim: boolean;
emAtraso: boolean; // o provedor diz que a principal está devendo
vencidaDesde: Date | null; // só quando o provedor sabe a data (Asaas); Stripe: null
proximoVencimento: Date | null;
jaPagou: boolean; // houve pagamento confirmado (> 0) em qualquer assinatura do cliente
emTesteNoProvedorAte: Date | null; // principal em teste no provedor (Stripe `trialing`): fim do teste lá; Asaas: sempre null
pagamentoSemAssinaturaViva: boolean;// pagou fatura de assinatura já terminal
linkDePagamento: string | null; // só de cobrança da principal NÃO terminal
statusBruto: string; // diagnóstico, vai para o audit
}
/** Só status HTTP e código do provedor. Nunca URL, header ou corpo (vão para log/Sentry). */
export class ErroDoProvedor extends Error {
constructor(readonly status: number | null, readonly codigo: string,
readonly transitorio: boolean, readonly credencialInvalida = false) {
super(`provedor ${status ?? "sem_resposta"} ${codigo}`);
}
}
export interface AdaptadorDeCobranca {
readonly id: ProvedorDeCobranca;
testarChave(): Promise<{ ok: true; modo: Modo } | { ok: false; motivo: "chave_invalida" | "sem_permissao" | "provedor_fora" }>;
prepararWebhook(url: string, emailDoDono: string): Promise<{ segredo: string } | { manual: { url: string; segredo: string; eventos: string[] } }>;
/** null = inválido. Corpo CRU. Tempo constante. Nunca lança. */
verificarWebhook(corpoCru: string, headers: Headers, segredo: string, agora: Date): SinalDoWebhook | null;
garantirCliente(org: { id: string; nome: string; email: string; documento: string | null }): Promise<string>;
iniciarAssinatura(p: { clienteRef: string; orgId: string; plano: PlanoParaProvedor; trialAte: Date | null;
urlDeVolta: string; chaveIdempotencia: string }): Promise<{ url: string; expiraEm: Date | null; assinaturaRef: string | null }>;
lerSituacao(p: { clienteRef: string }): Promise<Situacao>;
/** "Vale a partir da próxima cobrança gerada." Pode recusar com ErroDoProvedor não transitório. */
trocarPlano(p: { assinaturaRef: string; plano: PlanoParaProvedor }): Promise<void>;
cancelarNoFim(assinaturaRef: string): Promise<void>;
urlDeGerenciar(p: { clienteRef: string; urlDeVolta: string }): Promise<string | null>;
}
```
- `fetch` injetado; chave lida por `valorDaInstalacao()` (`lib/instalacao/config.ts:152`) a cada uso; toda resposta por Zod lendo só os campos usados.
- Base de URL com override `COBRANCA_API_BASE_URL_TESTE` (§10), válido só para loopback.
- Leitura **por cliente** (mapa `(provedor, provedor_cliente_id) → org` é nosso). A org nunca sai do corpo.
- **Sentry:** `scrubMessage` (`lib/sentry/scrub.ts:76-88`) ganha `/\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}/`, `/whsec_[A-Za-z0-9]{8,}/`, `/\$aact_[A-Za-z0-9_]{8,}/` → `[CHAVE]`, com um vetor por formato.
### 6.1 Stripe (`lib/cobranca/provedores/stripe.ts`)
**Base e cabeçalhos.** `https://api.stripe.com/v1`, form-encoded, `Authorization: Bearer sk_|rk_…`, `Stripe-Version` fixado numa constante; todo POST com `Idempotency-Key`.
**`testarChave`.** `GET /v1/balance`; `livemode` define o modo.
**`prepararWebhook`.**
1. `GET /v1/webhook_endpoints`, remove os da mesma URL.
2. `POST /v1/webhook_endpoints` com `checkout.session.completed`, `customer.subscription.created|updated|deleted|paused|resumed`, `invoice.paid`, `invoice.payment_failed`. **Nunca** `invoice.created` (não-2xx atrasa em até 72h a finalização).
3. `POST /v1/billing_portal/configurations` com `payment_method_update`, `invoice_history`, `subscription_cancel{mode:at_period_end}`, **sem** `subscription_update`; id em `STRIPE_PORTAL_CONFIG_ID` (diagnóstico, §10).
**`verificarWebhook`.** `Stripe-Signature: t=..,v1=..[,v1=..]`; HMAC-SHA256(`whsec`, `${t}.${corpoCru}`) contra **cada** `v1` com `timingSafeEqual` (rotação de 24h); ignora `v0`; tolerância 300 s. `eventoId = evt.id`, `clienteRef = data.object.customer`.
**`garantirCliente`.** `POST /v1/customers` com `email`, `name`, `metadata[organization_id]`.
**`iniciarAssinatura`.** Produto `POST /v1/products` `id=dc_plano_<uuid>` (`resource_already_exists` = ok); `POST /v1/checkout/sessions` com `mode=subscription`, `customer`, `client_reference_id=<org>`, `line_items[0][price_data]{currency=brl, unit_amount, recurring[interval], product}`, `quantity=1`, `subscription_data[metadata][organization_id|plano_id]`, `subscription_data[trial_end]` só com ≥ 48h de trial local, `success_url`/`cancel_url`. Formas de pagamento: as da conta (BR: cartão e boleto; sem Pix recorrente).
**`lerSituacao`** — o estado vem do **status da assinatura**:
1. `GET /v1/subscriptions?customer=&status=all&limit=10`. Terminal: `canceled`, `incomplete_expired`. Principal = mais recente não terminal.
2. `existe` = principal ∈ {`active`, `past_due`, `unpaid`, `paused`}. `trialing` (checkout concluído com `subscription_data[trial_end]`, nenhuma cobrança ainda) e `incomplete` (1º pagamento pendente, 23h) → `existe=false`, mas contam em `assinaturasVivas`. Com `trialing`, a tradução mantém `trial` até a 1ª fatura paga, como exige o contrato de `existe`. `emTesteNoProvedorAte` = `trial_end` da principal quando ela está `trialing`; senão `null`.
3. `emAtraso` = principal ∈ {`past_due`, `unpaid`, `paused`}. Cobre os três destinos que o painel oferece depois das tentativas (cancelar → terminal; `unpaid`; manter `past_due`). `vencidaDesde = null`: `due_date` é sempre nulo em `charge_automatically` e `created` é o dia da emissão, não do vencimento; `sincronizar` usa o instante em que viu o atraso pela primeira vez (≤ 6h de erro pela reconciliação, a favor do cliente).
4. `cancelaNoFim` = `cancel_at_period_end`; `proximoVencimento` = `current_period_end` do **item** da principal (a PR 3a confere contra a versão fixada em `Stripe-Version`, lendo uma assinatura criada na conta de teste, e o caso entra em `stripe.test.ts`).
5. `jaPagou`: `GET /v1/invoices?customer=&status=paid&limit=10`, alguma com `amount_paid > 0` (a fatura de trial de R$0 não conta).
6. `linkDePagamento`: `GET /v1/invoices?subscription=<principal>&status=open` → `hosted_invoice_url` da mais antiga; principal terminal ou ausente → `null` (a tela oferece "Assinar de novo").
7. `pagamentoSemAssinaturaViva`: a fatura paga mais recente (`amount_paid > 0`) é de assinatura terminal e foi paga depois de `ended_at` dela.
**`trocarPlano`.** Chamado no **agendamento**: `POST /v1/subscriptions/{id}` com `items[0][id]`, `items[0][price_data]{…product=dc_plano_<novo>}`, `proration_behavior=none`. A renovação seguinte já sai com o preço novo, e o plano local vira na mesma virada (§3.2).
**`cancelarNoFim`.** `cancel_at_period_end=true`. **`urlDeGerenciar`.** `POST /v1/billing_portal/sessions` com `customer`, `return_url`, `configuration`.
### 6.2 Asaas (`lib/cobranca/provedores/asaas.ts`)
**Base e cabeçalhos.** `https://api.asaas.com/v3` / `https://api-sandbox.asaas.com/v3` (a PR 3b confere as duas bases com `testarChave` no sandbox antes do merge), header `access_token`, `User-Agent` exigido.
**Datas.** `dueDate`/`nextDueDate` são data civil: convertidas para **23:59:59 America/Sao_Paulo** (`'2026-10-05'` → `2026-10-06T02:59:59Z`), porque o cliente paga até o fim do dia.
**`testarChave`.** `GET /customers?limit=1` nas duas bases; modo = base que autenticou.
**`prepararWebhook`.** `authToken` aleatório de 32 bytes (`ASAAS_WEBHOOK_TOKEN`); `POST /webhooks` com `{name, url, email, enabled:true, interrupted:false, sendType:"SEQUENTIALLY", authToken}` e eventos `PAYMENT_CONFIRMED`, `PAYMENT_RECEIVED`, `PAYMENT_OVERDUE`, `PAYMENT_DELETED`, `PAYMENT_RESTORED`, `PAYMENT_REFUNDED`, `PAYMENT_CHARGEBACK_REQUESTED`, `SUBSCRIPTION_CREATED`, `SUBSCRIPTION_UPDATED`, `SUBSCRIPTION_INACTIVATED`, `SUBSCRIPTION_DELETED`. Criação por API confirmada no sandbox antes da PR 3b; se falhar, devolve `manual`.
**`verificarWebhook`.** `asaas-access-token` em tempo constante; sem HMAC nem timestamp: corpo vale só como ponteiro. `eventoId = body.id`; `clienteRef = body.payment?.customer ?? body.subscription?.customer`.
**`garantirCliente`.** Exige CPF/CNPJ validado por dígito verificador, pré-preenchido de `organizations.cnpj`, **não gravado**; `GET /customers?externalReference=<org>` ou `POST /customers`.
**`iniciarAssinatura`.** Antes, `GET /subscriptions?customer=&status=ACTIVE`: se houver, reaproveita. `POST /subscriptions` com `{customer, billingType:"UNDEFINED", value, cycle, nextDueDate:max(hoje, trialAte), description, externalReference:<org>}`; `GET /subscriptions/{id}/payments` → `invoiceUrl` da primeira.
**`lerSituacao`:**
1. `GET /subscriptions?customer=&includeDeleted=true` (sem o parâmetro, a removida some da lista e o cancelamento viraria "trial vencido"). Terminal: `deleted:true`, `INACTIVE`, `EXPIRED`. Principal = mais recente não terminal.
2. `GET /payments?subscription=<principal>`: `existe` = principal não terminal **e** alguma cobrança `CONFIRMED`/`RECEIVED`/`RECEIVED_IN_CASH`. A assinatura nasce `ACTIVE` antes de qualquer pagamento; assinatura só com `PENDING` é o "incomplete" do Asaas (`existe=false`, conta em `assinaturasVivas`). Fecha o "clicar em Pagar agora reativa sem pagar".
3. `emAtraso` = alguma `OVERDUE` da principal, ou `REFUNDED`/`CHARGEBACK_REQUESTED` da cobrança do período corrente. `vencidaDesde` = menor `dueDate` dessas (fim do dia SP).
4. `CONFIRMED` (cartão pago, saldo não disponível) já conta como pago.
5. `proximoVencimento` = maior `dueDate` entre as cobranças confirmadas (`CONFIRMED`/`RECEIVED`/`RECEIVED_IN_CASH`) de qualquer assinatura do cliente, mais um `cycle`: é o fim do período pago. **Não** usar `nextDueDate`: na resposta ele é o vencimento da próxima cobrança ainda não gerada, e o Asaas gera cada cobrança 40 dias antes do vencimento (padrão; 14 ou 7 configuráveis). Ele fica um ciclo além do período pago e avança quando a cobrança é gerada, não quando é paga. Isso anteciparia a virada de `plano_agendado_id` (§3.2) e daria acesso além do pago no cancelamento (§7f). `sincronizar` só sobrescreve com não nulo.
6. `jaPagou`: `GET /payments?customer=&status=<s>&limit=1` para cada `s` em `RECEIVED`, `CONFIRMED` e `RECEIVED_IN_CASH` (o mesmo conjunto de pago do passo 2).
7. `linkDePagamento` = `invoiceUrl` da `OVERDUE` mais antiga ou da `PENDING`, só da principal não terminal.
8. `pagamentoSemAssinaturaViva`: cobrança confirmada de assinatura removida depois da remoção.
**`trocarPlano`.** Chamado no agendamento. **Guarda:** se a principal tem cobrança `OVERDUE`, ou `PENDING` com `dueDate` ≤ hoje (o período em uso ainda não pago), recusa com `ErroDoProvedor(null,'pagamento_do_periodo_pendente',false)` → tela: "Aguarde a confirmação do pagamento atual para trocar de plano." Passada a guarda, `PUT /subscriptions/{id}` com `{value, description, updatePendingPayments:true}`. As pendentes que sobram são todas de períodos futuros (com a geração 40 dias antes, um plano mensal pode ter duas ao mesmo tempo), e todas devem levar o valor novo. Não usar `dueDate < nextDueDate` como guarda: `nextDueDate` é o vencimento da próxima cobrança ainda não gerada, então toda cobrança existente passa na condição e a troca seria recusada sempre que houvesse qualquer pendente.
**`cancelarNoFim`.** `DELETE /subscriptions/{id}`; o `proximo_vencimento` gravado segura o acesso. **`urlDeGerenciar`.** `linkDePagamento`.
### 6.3 Mercado Pago (depois, mesmo encaixe)
`x-signature` é HMAC do manifest (`id:;request-id:;ts:`), não do corpo — reler é o desenho. `preapproval.status`: `cancelled` → terminal; auto-cancelamento após 3 recusas cai em `cancelada`. `existe` exige pagamento autorizado.
### 6.4 Registro
`lib/cobranca/provedores/index.ts: adaptador(id)` — um mapa.
---
## 7. Fluxos ponta a ponta
**Regra comum: nenhuma chamada HTTP ao provedor dentro de transação ou com lock de banco aberto** (anti-pattern 9, estendido). Leitura/escrita externa primeiro; depois transação curta com compare-and-set; e-mails depois do commit.
### (a) Revendedor liga, conecta, testa e publica
1. **Ligar** em `/admin/sistema › Cobrança dos seus clientes` → `gravarModulo` (`lib/instalacao/modulos.ts:210`). Aviso: "Empresas que já existem ficam isentas; você escolhe quem passa a pagar."
2. **Conectar** em `/admin/cobranca › Conexão` (rota `POST /api/v1/admin/cobranca/conexao`, `requirePlatformAdminEscrita()`):
- `urlPublicaUsavel(env.NEXT_PUBLIC_APP_URL)` **e** protocolo `https:`. `urlPublicaUsavel` (`lib/escalacao/url-publica.ts:53`) aceita `http:` (`ESQUEMAS`, `:30, 61`), e a Stripe só registra endpoint HTTPS em produção. O valor é lido de `lib/env` em runtime, nunca `process.env.NEXT_PUBLIC_*`, cujo valor inlinado no build é `https://placeholder.invalid`, `Dockerfile:27`). Recusa: "Seu sistema precisa estar num endereço https público para receber avisos de pagamento." **Exceção única:** com `COBRANCA_API_BASE_URL_TESTE` válida (loopback, o provedor é o stub), aceita URL de loopback — teste nos dois sentidos.
- `testarChave` + `prepararWebhook`; grava cifrado por `gravarPelaTela` (`lib/instalacao/config.ts:193`), que falha fechada sem chave de cifra.
- **Guarda de servidor:** não troca de provedor nem apaga a chave de um provedor que tenha linha em `cobranca_assinaturas` com `provedor` = ele e `modo='producao'` → 409 `provedor_com_assinaturas`. Trocar a chave do **mesmo** provedor de `teste` para `producao` é a Publicação (passo 5), feita por esta mesma rota: havendo linhas `modo='teste'` e sem `confirmar_publicacao:true` no corpo → 409 `publicacao_requer_confirmacao` com `details.assinaturas_de_teste` (contagem) para a tela listar; com a confirmação, grava a chave nova e aplica o passo 5 às linhas `modo='teste'` depois de `testarChave`/`prepararWebhook`. Corpo: `{ provedor: 'stripe'|'asaas', chave: string, confirmar_publicacao?: boolean }`; resposta `ok({ modo, webhook: 'automatico' | { manual: { url, segredo, eventos } } })`.
- Mostra o selo **MODO DE TESTE** ou **PRODUÇÃO** pelo que o provedor respondeu.
- Audita `cobranca.provedor_conectado` (last4 antigo e novo) e manda e-mail a todo platform admin `scope='full'`: "A chave de cobrança da instalação foi trocada (…1234 → …5678)."
- Esta rota é o **único** escritor das chaves de credencial e de provedor da cobrança (`STRIPE_*`, `ASAAS_*`, `COBRANCA_PROVEDOR`); `COBRANCA_TOLERANCIA_DIAS` é escrita só pela aba Régua de `/admin/cobranca`, com `requirePlatformAdminEscrita()`. Fora delas, `salvarConfiguracaoDaInstalacao`/`voltarConfiguracaoAoPadrao` (`app/actions/admin/salvarConfiguracaoDaInstalacao.ts:59-76, 116-122`) recusam chave com `telaDona === "cobranca"`.
3. **Planos:** cria um e marca "plano do cadastro (teste grátis de N dias)".
4. **Checklist** em `/admin/cobranca`:
- [x] chave conectada;
- [x] aviso de pagamento recebido — `max(received_at)` em `webhook_events_log` do provedor **ou** existe assinatura `ativa` com provedor (prova que o caminho funcionou; sobrevive à poda de D+90);
- [ ] compra de teste concluída ("crie uma empresa de teste e pague com o cartão 4242…" / "pague o boleto no sandbox");
- [ ] e-mail configurado (aponta para `/admin/email`).
5. **Publicar** (trocar a chave de teste pela de produção): a tela lista as assinaturas `modo='teste'` e avisa que voltam a `trial`; ao confirmar, zera `provedor`, `modo`, `provedor_cliente_id`, `provedor_assinatura_id`, `checkout_url`, `checkout_expira_em`, `vencida_desde`, `ultimo_aviso` e `ultimo_aviso_em` (o CHECK de §2.3 exige zerar provedor e cliente juntos), `estado='trial'`, `trial_ate=now()+trial_dias`, audita `cobranca.modo_publicado`.
### (b) Cadastro → trial → checkout → ativa
1. Cadastro em modo `aberto` (`lib/auth/politica-de-cadastro.ts:176`) → `ensureTenantForUser` → `fn_trial_na_criacao_da_org` cria o `trial`.
2. `/app` mostra aos admins a faixa "Teste grátis: faltam N dias · Assinar" (só com `cobranca` em `modulosLigados`).
3. "Assinar" → `POST /api/v1/cobranca/assinatura/checkout` (`requireRole('admin', {permiteOrgSuspensa:true})`, org da sessão, `comIdempotencia`, `lib/api/idempotency.ts`), corpo `{ documento?: string }` (só dígitos, 11 ou 14, DV validado → senão 422 `documento_invalido`; obrigatório quando `COBRANCA_PROVEDOR='asaas'` → 422 `documento_obrigatorio`; a tela pré-preenche de `organizations.cnpj`; nunca gravado). `garantirCliente` recebe `nome = organizations.legal_name` e `email` = e-mail do usuário da sessão (a org não tem coluna de e-mail):
- **Fase 1 (transação curta, `for update`):** `checkout_url` válido → devolve o mesmo; `checkout_expira_em > now()` com `checkout_url` nulo → 409 `checkout_em_preparo` ("já estamos gerando seu link"); senão grava `checkout_url=null, checkout_expira_em=now()+2min` (reserva) e comita.
- **Fase 2 (fora de transação):** `lerSituacao`; se `assinaturasVivas > 0` (inclui `incomplete` e Asaas com `PENDING`) → limpa a reserva e 409 `pagamento_em_andamento` ("você já tem um pagamento em andamento") com `details.link_de_pagamento`; senão `garantirCliente`, `iniciarAssinatura`. Falha do provedor → 503 `provedor_indisponivel` (transitório) ou 502 `provedor_recusou`. Os códigos novos entram em `lib/api/errors.ts`.
- **Fase 3 (update condicional):** grava `provedor`, `modo`, `provedor_cliente_id`, `checkout_url`, `checkout_expira_em`; audita `cobranca.checkout_iniciado`; devolve `{url}`. Falha na fase 2 limpa a reserva.
4. Volta (`?voltou=1`) → `POST /api/v1/cobranca/assinatura/sincronizar` (1 por 30 s por org, `checkRateLimit`): "Confirmando seu pagamento…". Cartão → `ativa`; boleto/Pix pendente → "Aguardando o pagamento. O boleto leva até 1 dia útil." `checkout_url` é limpo quando a sessão aparece concluída.
### (c) Webhook → sinal → estado
`POST /api/v1/webhooks/cobranca/[provedor]` (prefixo público, `lib/auth/public-paths.ts:35`; Caddy/Traefik só bloqueiam `/api/v1/webhooks/waha`, `docker-compose.traefik.yml:99-105`):
1. 404 se provedor fora da lista, chave desligada ou sem segredo (molde `app/api/v1/tenants/provision/route.ts:43-110`).
2. `checkRateLimit` por IP, 120/min.
3. `await req.text()` (≤ 1 MB) → `verificarWebhook`; inválido → 401 + `logger.warn` + contador.
4. `insert webhook_events_log` na forma de §2.4 (org nula, sem cabeçalhos, corpo `{id,type}`). `23505`: se a linha existente está `processed` → 200 (duplicado); se está `received` → segue para o passo 5 (o emit anterior falhou; reemitir é inofensivo porque o consumidor relê).
5. Org por `cobranca_assinaturas(provedor, provedor_cliente_id)`. Sem org → `status='error'`, `cliente_desconhecido`, 200 (a reconciliação cura). Com org → `emit_event('cobranca.sinal', org, {provedor, evento_id})` → `update ... status='processed', processed_at=now()` → 200. Falha no emit → 503 (o provedor reentrega e cai no ramo `received`).
**Consumidor `cobrancaSinalHandler`** (`naOrgParada:"roda"`):
- **Coalescer:** `relida_em > now() − 30s` → `{status:"retry", retry_at: relida_em + 30s}` (`dispatcher.ts:45-48`; o drain reagenda sem contar tentativa). Um `cobranca.sinal` forjado por membro via `emit_event` custa, no máximo, o mesmo que o botão "Já paguei".
- Chama `sincronizar(org)` (`lib/cobranca/sincronizar.ts`):
1. **Fora de transação:** `lido_em = now()`; `lerSituacao`. Falha → `ultimo_erro`/`ultimo_erro_em` (`credencial_invalida` / `provedor_fora` / `leitura_invalida`) numa escrita curta, estado intacto, `retry` do drain.
2. **Transação curta** com `pg_advisory_xact_lock(hashtextextended(org::text, 2283))` e `select ... for update`: se `relida_em >= lido_em`, descarta (uma leitura mais nova já foi aplicada). Senão: tradução + regras de gravação (§3.2), audit `cobranca.estado_mudou` com `statusBruto` **só se mudou**, régua e ação por funções SQL idempotentes; os avisos são gravados por `update ... set ultimo_aviso=$1, ultimo_aviso_em=now() where ... returning`.
3. **Depois do commit:** e-mails das linhas de aviso ganhas.
- Evento que morre → `event_dead` na Central pelo drain existente.
### (d) Falha → tolerância → aviso → suspensão → pagamento → reativação
1. `invoice.payment_failed`/`customer.subscription.updated` (`past_due`) ou `PAYMENT_OVERDUE` → (c) → `em_atraso`.
2. **Avisos** (só org `active` ou `suspended{cobranca}`):
- `venceu`: "Não identificamos o pagamento de DD/MM. Se pagou boleto, aguarde a compensação."
- Canais: e-mail aos admins da org por `sendEmail` (`lib/email/roteador.ts:110`) com `marcaDaSaida(org)` (`lib/branding/saida.ts:184`), no idioma de `organizations.locale` e datas no `organizations.timezone`; item `kind='cobranca'` na Central; faixa vermelha em `/app`. E-mail que falha não é reenviado (Central e faixa seguem).
3. `suspende_em_breve` (critical): "Sua conta será suspensa em DD/MM se o pagamento não for confirmado."
4. No limite (§3.2), no cron: releitura → `fn_suspender_organizacao(org,'cobranca',…)` → e-mail "Conta suspensa por falta de pagamento" → audit `cobranca.org_suspensa`.
5. `/account-suspended`: admin vê "Pagar agora" (`linkDePagamento` ou portal) e "Já paguei" (`sincronizar`); sem link (principal terminal) vê "Assinar de novo"; não admin lê "Avise o administrador da sua empresa".
6. `invoice.paid`/`PAYMENT_CONFIRMED` → (c) → `ativa` → reativa → item `org_reativada` + e-mail "conta liberada" + audit `cobranca.org_reativada`. Nada sai em rajada.
### (e) Troca de plano (upgrade e downgrade)
`POST /api/v1/cobranca/assinatura/plano {plano_id}`, só admin:
- só planos não arquivados do mesmo intervalo (senão 422 `plano_invalido`); recusa com estado `em_atraso`/`cancelada` → 409 `pagamento_pendente` ("regularize o pagamento antes"); recusa do Asaas `pagamento_do_periodo_pendente` → 409 com esse mesmo código;
- downgrade: uso atual precisa caber, senão 409 `plan_limit_reached` com `details: { excedente: { assentos?: number, canais?: number } }` (só os > 0), que a tela transforma em "remova 2 pessoas e 1 número"; a mesma checagem vale no `PATCH` admin de §7g;
- **durante o teste grátis (`trial_ate > now()`, com ou sem provedor — decisão D-13):** troca imediata de `plano_id`; com provedor, chama também `trocarPlano` para que a primeira cobrança saia com o preço novo (no Asaas, a guarda `pagamento_do_periodo_pendente` não se aplica aqui: a cobrança pendente é a primeira, com vencimento em `trial_ate`, e é ela que deve levar o valor novo);
- **depois do teste, com provedor:** grava `plano_agendado_id`, chama `trocarPlano` (fora de transação; Asaas pode recusar com `pagamento_do_periodo_pendente`), e o plano local vira na **virada do ciclo paga** (§3.2). Tela: "O novo plano vale a partir de DD/MM." Nem upgrade nem downgrade mudam limite antes de pagos — fecha o subir no dia 1 e descer no dia 28;
- audita `cobranca.plano_trocado` (agendado e aplicado).
### (f) Cancelamento
`POST /api/v1/cobranca/assinatura/cancelar`: "Você mantém o acesso até DD/MM." → `cancelarNoFim`. Stripe: `cancela_no_fim=true`, estado segue `ativa`; Asaas: `cancelada` (lida com `includeDeleted`), `proximo_vencimento` mantido. Em `proximo_vencimento`, a régua avisa e suspende 48h depois ("Você cancelou. Assine de novo para voltar"). Reassinar pela mesma tela, inclusive do hub.
### (g) Platform admin, pelo card de cobrança no tenant
Tudo com `requirePlatformAdminEscrita()` e audit:
- **Rotas do dono** (todas `requirePlatformAdminEscrita()`, 404 com a chave desligada, audit):
- PR 2: `POST /api/v1/admin/cobranca/planos` `{nome, preco_cents, intervalo, trial_dias, max_assentos?, max_canais?, teto_ia_usd_cents?, padrao_no_cadastro?}` → `plano_salvo`; `PATCH /api/v1/admin/cobranca/planos/[id]` (mesmos campos + `arquivado: boolean`) → `plano_salvo`/`plano_arquivado`; mudar `preco_cents` ou `intervalo` com assinatura em `plano_id` ou `plano_agendado_id` → 409 `plano_com_assinantes` ("arquive e crie outro"); dois padrões → 409 `state_conflict` (índice `cobranca_planos_um_padrao`).
- PR 2: `POST /api/v1/admin/tenants/[id]/assinatura` `{plano_id}` (sem linha) cria `trial` com os dias do plano; linha existente → 409 `state_conflict`. `PATCH` na mesma rota `{plano_id}` = trocar plano (regras de §7e). `POST .../assinatura/prazo` `{ate}` (≤ 60 dias; senão 422) → `prazo_concedido`. `DELETE .../assinatura` = tornar isenta → `isencao_definida`.
- PR 3a: `PATCH /api/v1/admin/cobranca/regua` `{tolerancia_dias}` (5–30; fora → 422), única escritora de `COBRANCA_TOLERANCIA_DIAS`; a aba Régua entra na PR 3a.
- **Atribuir plano** (sem linha): cria `trial` com os dias do plano.
- **Trocar plano** (com linha): o **mesmo caminho de (e)** — `trocarPlano` com provedor, agendado para a virada; **nunca** mexe em `estado`, `trial_ate` ou `vencida_desde`.
- **Dar prazo até DD/MM** (≤ 60 dias): grava `prazo_extra_ate`; se a suspensão é de cobrança, `fn_reativar_organizacao(org,'cobranca')`.
- **Tornar isenta:** linha com `provedor is null` (o único caso possível na PR 2) → apaga direto; com provedor (PR 3a em diante) → apaga só se `lerSituacao` mostra `assinaturasVivas = 0`, senão 409 `assinatura_viva_no_provedor` ("cancele no provedor antes"); leitura que falha → 503, nada apagado. Reativa se a suspensão era de cobrança.
- **Suspender/reativar administrativa:** §4, item 19.
### (h) Desligar a chave
`/admin/sistema` mostra "N empresas suspensas por falta de pagamento serão liberadas" (N = `count` de `organizations` com `status='suspended' and suspended_kind='cobranca'`, lido pela página) e, na PR 2, `app/actions/settings/updateModuloDaInstalacao.ts` ganha o ramo `modulo==='cobranca' && !ligado`: depois de `gravarModulo`, `rpc('fn_cobranca_liberar_suspensoes', {p_ator})` e audit `cobranca.modulo_desligado` com `{liberadas}`. A action passa a `requirePlatformAdminEscrita()`. Limites e régua deixam de valer; nada é cancelado no provedor (a tela avisa).
---
## 8. Crons e workers
| Peça | Rota / lugar | Frequência | Idempotência | Audit |
|---|---|---|---|---|
| **Cron da cobrança** | `app/api/v1/cron/cobranca/route.ts` + `43 * * * *\|120\|api/v1/cron/cobranca` em `CRONS` (`docker/scheduler/entrypoint.sh:59-130`; comentário fora da string, sem crase nem `$`, `:56-58`) | horária | `autorizaCron` (`lib/auth/cron-auth.ts:25-44`); chave desligada → `ok({pulado:'modulo_desligado'})` | Só com efeito: uma linha `cobranca.rodada` com contagens (`cron-audita-so-quando-ha-efeito`, `cron-routes-scheduled`) |
| Consumidor `cobranca.sinal` | `lib/cobranca/sinal.handler.ts` em `lib/event-log/register-handlers.ts`, `naOrgParada:"roda"` | segundos (loop do worker; `event-log-drain` como rede) | coalescer por `relida_em`; compare-and-set; lock curto | `cobranca.estado_mudou`, `org_suspensa`, `org_reativada`, só quando muda |
| Sincronização sob demanda | `POST /api/v1/cobranca/assinatura/sincronizar` | 1/30 s por org | a mesma `sincronizar()` | igual |
**Passos do cron:**
1. **Reconciliação** — até 50 linhas com `provedor is not null`, ordenadas por `relida_em nulls first`, que satisfaçam `(relida_em is null or relida_em < now() - interval '6 hours')` **e** uma de:
- `estado <> 'cancelada'`;
- `estado = 'cancelada'` e a org está `suspended{cobranca}` (pode ter reassinado e pago por boleto com o webhook perdido);
- `estado = 'cancelada'` e `checkout_expira_em > now() - interval '30 days'` (checkout recente pode ter virado pagamento).
Somam-se as candidatas à suspensão sem releitura de < 1h. Cobre a fila do Asaas interrompida depois de 15 falhas, a Stripe desistindo em 3 dias e a VPS fora do ar. Cada leitura é `sincronizar()` (HTTP fora de transação).
2. **Transições de tempo e régua** para as demais linhas, só com o banco. A exceção é `redacted`/`archived` → `cancelarNoFim`, que é chamada HTTP ao provedor e segue a regra comum de §7 (fora de transação, antes da escrita curta).
3. **Aviso de IA a 80%** para planos com teto.
Sem "pulso" gravado: a saúde do cron aparece derivada (§9).
**Cadência:** uma hora basta (reativação é por evento; suspensão é régua em dias). O cron novo chega à VPS na release seguinte (`update.sh:616-656` grava `SCHEDULER_IMAGE`).
---
## 9. Telas (com porta)
### Admin da plataforma
**`/admin/sistema`** — nova linha "Cobrança dos seus clientes":
- `MODULOS_OPCIONAIS`, `MODULOS_OPCIONAIS_POR_FLAG`, `CHAVE_DO_MODULO.cobranca = "MODULO_COBRANCA"` (`lib/instalacao/modulos.ts:50-94`); `MODULOS_NA_TELA` (`app/admin/(protected)/sistema/_form.tsx:190`); `TEXTO_DO_MODULO` (`lib/recursos-opcionais/catalogo.ts:99`, `Record` exaustivo);
- **novo** `MODULOS_SO_DA_INSTALACAO: readonly ModuloOpcional[] = ["cobranca"]` em `modulos.ts`, filtrado em `app/app/settings/recursos/page.tsx:54`: a empresa (manager+) **não** vê "Cobrança dos seus clientes — Desligado por quem administra o servidor". Teste afirma a ausência;
- em `MODULOS_AINDA_NAO_LIGAVEIS` (`modulos.ts:204`) durante a PR 2; sai na PR 3a.
**`/admin/cobranca`** (`app/admin/(protected)/cobranca/page.tsx`), abas:
- **Visão geral:** checklist de publicação (§7a); selo TESTE/PRODUÇÃO; "último aviso do provedor há X" (`max(received_at)`; "nenhum nos últimos 90 dias" além da poda); **"última leitura bem-sucedida há X"** = `max(relida_em)` das linhas com provedor que a reconciliação seleciona (§8; fica fora a `cancelada` antiga de org ativa), vermelho > 7h (é a saúde do cron: a reconciliação relê essas linhas a cada 6h); **problemas do dono**, derivados do estado: "N clientes com leitura falhando: chave inválida" (`ultimo_erro='credencial_invalida'`), "N clientes com duas assinaturas ativas — cancele uma no painel do provedor" (`assinaturas_vivas > 1`), "N pagamentos de assinatura cancelada — dê prazo ou estorne" (`ultimo_erro='pagamento_de_assinatura_cancelada'`); eventos `error`/`dead`.
- **Conexão:** provedor; chave (`last4`); "Testar e conectar"; URL do webhook; modo manual do Asaas.
- **Planos:** criar, editar, marcar padrão, arquivar; recusa preço < R$ 5; avisa que preço e intervalo travam com assinante.
- **Régua:** tolerância (piso 5, teto 30, padrão 7).
- **Clientes:** empresa, plano (e agendado), estado, vencida desde, próximo vencimento, última leitura (vermelho > 26h, só nas linhas que a reconciliação seleciona, §8), último erro. Ações: dar prazo, isentar, trocar plano, copiar link, abrir no tenant.
- **Porta:** `components/admin/AdminSidebar.tsx:34-110`; `NavItem` ganha `modulo?`; o layout admin passa `modulosLigados`; entrada `{href:'/admin/cobranca', label:'Cobrança', modulo:'cobranca'}`. Vigiado por `tests/unit/admin-navegacao-completude.test.ts`.
**`/admin/tenants/[id]`:** com a chave desligada, `TenantOverview.tsx:98-99,118-119` segue igual. Ligada: card "Cobrança" (plano, agendado, estado, atribuir/trocar, dar prazo, isentar) e "Rótulo antigo: X" se `settings.plan` existir. `TenantActions.tsx` mostra o **tipo** da suspensão; "Reativar" só para a administrativa; para a de cobrança, "Dar prazo" e "Isentar".
**`/admin/tenants/new`:** chave desligada → formulário atual (`app/admin/(protected)/tenants/new/_form.tsx:88,281-298`, enum `plan`). Ligada → select de `cobranca_planos` + "Sem cobrança (isenta)". `lib/schemas/tenant-creation.ts:14,19` ganha `plano_id: z.string().uuid().optional()` ao lado de `plan`; audit metadata (`app/api/v1/admin/tenants/route.ts:222`) acompanha.
### Cliente final
**`/app/settings/billing`** — chave desligada: placeholder atual (`app/app/settings/billing/page.tsx:17-52`) intocado. Ligada: `components/cobranca/PainelDaAssinatura.tsx`:
- estado em linguagem simples ("Teste grátis até…", "Em dia", "Em atraso desde…", "Cancelada, acesso até…", "Novo plano a partir de…");
- uso contra limites (pessoas x/y, números x/y, IA US$ gasto/teto);
- ações: Assinar / Pagar agora, Já paguei, Gerenciar pagamento, Trocar de plano, Cancelar, Assinar de novo;
- faixa MODO DE TESTE quando for o caso.
- **Porta:** entrada existente `lib/navigation/catalogo.ts:971-979` (sem `modulo`, para não sumir com a chave desligada), `minRole:"admin"`. Hoje o label é "Billing" (dicionário `es` "Facturación", `zh-CN` "账单") e "Plano e cobrança." é só a descrição. O label passa a "Plano e cobrança", com entrada nova no dicionário (`es` "Plan y facturación", mais `zh-CN`), para que a mensagem de §5 ("Configurações › Plano e cobrança") aponte para um item que existe.
**Faixa em `app/app/layout.tsx`** (trial ≤ 7 dias, `em_atraso`, cancelamento agendado), lida só com `cobranca` em `modulosLigados`.
### Suspenso
**`/account-suspended`** (fora de `app/app/`) vira o hub: `requireAuth()` + `orgAtivaSemPortao()`.
- Org operante → redireciona para `/app`.
- kind `cobranca` + chave ligada + admin → `PainelDaAssinatura`.
- Não admin → "Avise o administrador da sua empresa".
- kind `administrativa` → texto atual com `emailDeSuporte()`.
- Todos: "Pedidos de LGPD" reusando `app/app/lgpd/requests/RequestsTable.tsx` (extrair a parte de dados se depender do layout de `/app`), e "Trocar de empresa" quando há outra org ativa.
- **Porta:** redirect do §4 item 3, links do e-mail e da Central.
### Onboarding
Nada novo: trial automático, faixa como porta.
### i18n
Todo texto novo de tela, menu e e-mail entra no dicionário com `es` (e `zh-CN` onde o painel exige) **no mesmo PR** da tela; e-mails no idioma de `organizations.locale`.
---
## 10. Configuração
| Item | Onde mora | Cifrado? | Catálogo (`lib/instalacao/catalogo.ts`) | Padrão |
|---|---|---|---|---|
| Liga/desliga | `platform_config.MODULO_COBRANCA`, tela `/admin/sistema` | não | (mecanismo de módulos, não catálogo) | ausente = desligado |
| `COBRANCA_PROVEDOR`, `COBRANCA_TOLERANCIA_DIAS` | `platform_config` | não | `controle:'edita'`, `telaDona:'cobranca'` | tolerância 7 (piso 5 no código) |
| `STRIPE_SECRET_KEY` (`rk_` recomendada), `STRIPE_WEBHOOK_SECRET`, `ASAAS_API_KEY`, `ASAAS_WEBHOOK_TOKEN` | `platform_config` `eh_segredo=true`, AES-256-GCM, chave `AI_CRED_AES_KEY` fora do banco (`baseline.sql:43400-43450`; `config.ts:193-245`) | sim; tela vê `last4`; nunca em query string | `controle:'edita'`, `telaDona:'cobranca'` | sem chave de cifra, nada é gravado |
| `STRIPE_PORTAL_CONFIG_ID` | `platform_config`, escrito só pela máquina | não | `controle:'diagnostico'` com motivo; nunca lido por `valorDaInstalacao` | — |
| `COBRANCA_API_BASE_URL_TESTE` | `lib/env.ts`, `z.string().optional().default("")`; só loopback (senão ignorada, com log); em `.env.example` (`env-example-sync`), **não** em `.env.hostgator.example` | — | — | vazio = URLs oficiais |
- `telaDona` (`catalogo.ts:73`) ganha `"cobranca"` (`chave-da-instalacao-mora-numa-tela-so.test.ts`); leitores usam `valorDaInstalacao` (`painel-nao-promete-o-que-nao-cumpre.test.ts`).
- **Escrita das chaves `telaDona:'cobranca'` só pela rota de Conexão/Régua** (§7a); as ações genéricas recusam, com teste. Sem isso, a ação genérica (que só confere `controle`) trocaria a chave Stripe sem `testarChave`, sem a guarda de assinaturas vivas e com rastro de uma linha só.
- Sem edição manual: `update.sh` não muda; URL do webhook sai de `NEXT_PUBLIC_APP_URL`; `SUPPORT_EMAIL` reusado.
- Trocar `AI_CRED_AES_KEY`: segredos ilegíveis → leitura falha como `credencial_invalida` → estado intacto, nenhuma suspensão, e a Visão geral mostra.
---
## 11. Migrations, baseline, MANIFEST e tipos
**Números:** o próximo livre **no merge** — a main vai até `0491`, e o teto é a main **mais** as cabeças de todo PR aberto (número e carimbo).
**Regras:** idempotente (`if not exists`, `create or replace`, `drop trigger if exists` + `create`, `drop constraint if exists` + backfill + `add`); sem `BEGIN`/`COMMIT` nem temp table; apêndice rotulado antes da VARREDURA anon (`baseline.sql:42990`); tabelas antes das chamadas finais de `fn_proteger_tabelas_de_organizacao`/`fn_aplicar_travas_de_suporte` (`:43777`, `:43787`).
| PR | Migration (slug) | Conteúdo |
|---|---|---|
| 1 | `_org_operante_e_suspensao_tipada` | `suspended_kind` (backfill antes do CHECK); `fn_org_operante`; `fn_organizacao_estado_so_pelo_servidor` + gatilho; `fn_suspender_organizacao`/`fn_reativar_organizacao`; kind `'org_reativada'` no bloco único do CHECK de `agent_inbox_items` + reconstrução; revoke das duas origens em cada função |
| 2 | `_cobranca_planos_e_assinaturas` | as 2 tabelas (RLS, policy, revokes, grants); `fn_cobranca_ligada`, `fn_limite_do_plano`, `fn_cobranca_liberar_suspensoes`; os 3 gatilhos; `create or replace fn_create_tenant_with_owner` (mantém `settings.plan`); `create or replace` de `fn_suspender_organizacao` (ganha a guarda `org_isenta` do kind `cobranca`) e de `fn_reativar_organizacao` (ganha o passo 7, zerar `ultimo_aviso` na assinatura) — na PR 1 as duas **não** citam `cobranca_assinaturas`, que ainda não existe (plpgsql só resolve a relação ao executar, e o `UPDATE` do passo 7 daria `42P01` em toda reativação); revoke das duas origens repetido; `drop column if exists ai_budget_cents, rate_limit_rps` |
| 3a | `_cobranca_webhook_e_avisos` | `'stripe','asaas'` no bloco único do CHECK de provider; `uniq_webhook_events_log_cobranca`; kind `'cobranca'` no bloco único de `agent_inbox_items` + reconstrução |
- **MANIFEST:** uma linha por migration; `merge=union` → conferir duplicata depois de **cada** merge.
- **Tipos:** regenerar `lib/database.types.ts`.
- **Vocabulário:** `suspended_kind`, `estado`, `provedor`, `modo`, `ultimo_aviso`, `ultimo_erro`, `intervalo`, `moeda` no invariante.
- **Prova:** `pnpm test:db` (install `ON_ERROR_STOP=1` + update); CI em pg15 e pg17. `supabase/config.toml:29` diz `major_version = 15` e o baseline não usa `GRANT MAINTAIN`; o CLAUDE.md diz o contrário e é corrigido na PR 1 (DoD 16).
- **Fragmentos `.changes/`:**
- PR 1: `corrigido` / `nada_mudou` — "suspender uma empresa passa a calar a IA e os envios dela; quem tem acesso só de leitura ao painel deixa de poder alterar dados".
- PR 2: `alterado` / `nada_mudou` — a capacidade existe no código mas ainda não pode ser ligada (`MODULOS_AINDA_NAO_LIGAVEIS`), e nada que o operador vê muda. PRs 3a e 3b: `adicionado` / `capacidade_nova`. Válido porque, com a chave desligada, nada que existia some nem muda de forma (§2.1, §9); as colunas removidas não têm leitor nem tela.
- **Docs (DoD 16):** `docs/specs/01-spec-platform-base.md:101-102`; comentário de `lib/campanhas/rodada.ts:100-102`; CLAUDE.md (pg15).
---
## 12. Testes
### Unit (`pnpm test:unit`, a suíte inteira, sem caminho)
**Cobrança:**
- `lib/cobranca/regua.test.ts`: trial vencendo; trial vencido sem provedor suspende; boleto aberto não vencido não suspende; piso de 5 dias com configuração 0; `prazo_extra_ate`; aviso final ≥ 48h; releitura > 1h bloqueia; `cancelada` com e sem `proximo_vencimento`; reativação; guarda do kind; **pagou depois do aviso final, no mês seguinte a régua acorda depois do limite → primeiro avisa, só suspende 48h depois**; org `suspended{administrativa}` não recebe aviso; org `redacted` → `cancelarNoFim`, sem aviso.
- `lib/cobranca/estado.test.ts`: tradução; `vencida_desde` monotônico; **cancelar e reassinar não zera `vencida_desde`**; `proximo_vencimento` não é apagado por leitura nula; `ultimo_aviso` zera na volta a `ativa`/`trial`; `plano_agendado_id` aplicado só na virada paga.
- `stripe.test.ts`: segundo `v1` aceito, `v0` recusado, `t` fora de 300 s, corpo alterado; `testarChave` por `livemode`; **um caso por status**: `unpaid` sem fatura aberta → em atraso; `paused` → em atraso; `past_due` → em atraso; `active` com fatura aberta de boleto dentro da validade → em dia; `incomplete` → `existe=false` e `assinaturasVivas=1`; fatura de trial R$0 não conta em `jaPagou`; **auto-cancelada + reassinatura paga + fatura antiga aberta → `ativa`** (faturas pela assinatura); `linkDePagamento` nulo com principal terminal.
- `asaas.test.ts`: token em tempo constante; `includeDeleted=true` na URL; **cancelar (DELETE) no dia 2 de mês pago → `cancelada` com acesso até o fim do período**; **assinatura ACTIVE só com `PENDING` → `existe=false` ("suspenso clica Assinar e não paga → continua suspenso")**; `CONFIRMED` conta como pago; `REFUNDED`/chargeback reabrem; `'2026-10-05'` → `2026-10-06T02:59:59Z` e texto "05/10"; `trocarPlano` recusa com período corrente pendente e manda `updatePendingPayments:true` quando pago; CPF/CNPJ.
- `lib/sentry/scrub.test.ts`: um vetor por formato (`sk_live_`, `rk_test_`, `whsec_`, `$aact_`).
**Predicado, autorização e limites:**
- `lib/agent-engine/edge/llm/orcamento.test.ts` (novo; as decisões atuais de orçamento moram em `tests/unit/orcamento-decisao.test.ts`) (`decidirTetoDoPlano`): BYOK nunca bloqueia; purpose isento segue; `modo 'off'` da org não desliga o teto; `chave 'off'` desliga.
- `credentials`/`run-model-call`: **injetar `42883` na consulta do teto prova que o `modo` de `ai_budgets` continua aplicado** e que `SQL_CONFIG_COM_ORCAMENTO` não mudou.
- `gate.test.ts`: `orgStatus` não operante vence todos os vetos.
- `tests/unit/dispatcher-org-parada.test.ts`: `pula` + org parada → `skipped` em `consumed_by`; lista literal dos `roda`.
- `require-role.test.ts`: 403 `org_suspended`; `permiteOrgSuspensa` libera; **`allowPlatformAdmin:true` com `support_readonly` → 403; com `full` e MFA em dívida → 403; `"leitura"` libera `support_readonly`.**
- `resolveApiToken`: recusa sem debitar o balde.
- `sendMessageHandler`: `OrgNaoOperanteError` terminal no settle.
- Ações genéricas de configuração recusam chave `telaDona:'cobranca'`.
- Rota de conexão: localhost recusado sem `COBRANCA_API_BASE_URL_TESTE`, aceito com ela; `placeholder.invalid` recusado.
- Webhook: `23505` com linha `received` reemite; com `processed` responde 200 sem reemitir; linha gravada sem cabeçalhos e com corpo `{id,type}`.
- Handler de sinal: `relida_em` há 10 s → `retry` com `retry_at`.
- Recursos opcionais da empresa não listam `cobranca`.
**Cercas novas:** `org-operante-uma-regua` (AST de decisões), `org-suspensa-so-nas-rotas-permitidas`, `admin-escrita-exige-scope-full` (pelo mecanismo, `app/**`), `cron-respeita-org-operante`.
**Existentes que passam a cobrar:** `cron-routes-scheduled`, `cron-audita-so-quando-ha-efeito`, `suspensao-nao-dispara-campanha`, `evento-de-fato-nao-fica-pendente`, `painel-nao-promete-o-que-nao-cumpre`, `chave-da-instalacao-mora-numa-tela-so`, `navegacao-completude`, `admin-navegacao-completude`, `env-example-sync`, `kind-check-migration-x-baseline`, `orcamento-uma-regua-de-gasto`, **`i18n-espanhol-cobre-a-tela`, `i18n-catalogo-do-menu`**, `skills-embutidas` (PR 5).
### Invariantes (`pnpm test:db`, Postgres real, baseline install + update)
1. **Isolamento com 2 tenants** (`cobranca-isolamento.test.ts`; `cobranca_assinaturas` em `TABLES` de `rls-isolation.test.ts`): admin de A lê só A; `agent` de A não lê; `authenticated` não escreve (PATCH pelo PostgREST com JWT real); `anon` nada; `cobranca_planos` invisível; **viewer de A lê 0 linhas de `webhook_events_log` com provider in (`stripe`,`asaas`)**.
2. **Estado da org só pelo servidor:** com JWT de platform admin `support_readonly` **e** de `full`, `PATCH organizations set status`, troca de `suspended_kind` e `INSERT organizations` pelo PostgREST → 42501; `fn_suspender_organizacao` (service_role) funciona; `updateTenant` pela sessão (nome, fuso) segue funcionando.
3. **Org suspensa:** `emit_event` com service_role e com o admin da org continua funcionando; LGPD approve emite; B ativa segue normal.
4. **`fn_suspender`/`fn_reativar`:** jobs `pending` → `failed`; `queued` → `failed`; administrativa prevalece; idempotência; item `org_reativada` com a contagem; `event_log` na mesma transação; `redacted` com `suspended_kind` residual não quebra.
5. **Assentos:** teto 2 → 3º INSERT `PT402`; **duas conexões concorrentes** → exatamente uma passa; provisório inserido por `fn_create_tenant_with_owner` não conta; **INSERT provisório via PostgREST (JWT de admin do tenant) → 42501; `UPDATE provisional_until_handover=false` acima do teto → `PT402`; `UPDATE organization_id` conta**; chave desligada ou sem linha = sem limite; seeds de e2e passam.
6. **Canais:** idem, com desarquivar, `UPDATE organization_id`, `wacalls` fora, lock compartilhado com `fn_reserve_channel_connection`.
7. **Trial na criação:** chave ligada + padrão → `trial`; `created_by` platform admin → nada; chave desligada → nada; `fn_create_tenant_with_owner` com `plano_id` → linha; sem `plano_id` e chave desligada → `settings.plan` gravado como hoje.
8. **Deduplicação:** mesmo `(provider, external_id)` → `23505`.
9. **Fila e agendador:** `CLAIM_SQL` não enxerga job de org suspensa; `fireOneDue` avança sem enfileirar.
10. **Reconciliação:** linha `cancelada` de org `suspended{cobranca}` e linha `cancelada` com checkout de 10 dias atrás entram na seleção; `cancelada` antiga de org ativa não.
11. **Varreduras existentes:** `hardening-definer-varredura`, `vocabulario-banco-x-typescript`, `travas-de-suporte-cobrem-toda-tabela-na-instalacao`.
### E2E pela tela
Ambiente: `baseline.sql` + `bootstrap-owner`, `next build`/`next start`, envs opcionais ausentes.
**No CI (`SPECS_PARTE_*`):**
- **`cobranca-suspensao-e-limites.spec.ts`** (sem provedor): dono liga, cria plano (1 pessoa, 1 número), atribui a B; convite em B recusado com a mensagem do plano; dono suspende B; admin de B cai em `/account-suspended` com LGPD e o texto da suspensão administrativa (`emailDeSuporte()`, §9); `/app/inbox` redireciona; token API de B → 403; mensagem por `webhooks/in/[token]` real **é gravada** e nenhuma `llm_calls` nem outbound nasce; `support_readonly` clica em Suspender e vê o erro; dono reativa e B volta com zero saídas e o item de revisão.
- **`cobranca-revendedor.spec.ts` [P0]** com **receiver real**: stub HTTP local (`tests/e2e/fixtures/provedor-de-cobranca.ts`) fala o subconjunto Stripe/Asaas e **envia avisos assinados de verdade** à rota real (`COBRANCA_API_BASE_URL_TESTE` em loopback; a exceção de URL de §7a). Roteiro: conectar; checklist; cadastro → faixa de trial → Assinar com plano de `trial_dias=0` → "Em dia" (com teste grátis, o painel mostra "Teste grátis até DD/MM · 1ª cobrança agendada" até a virada paga); stub marca `past_due` → cron drenado pelo endpoint → avisos → aviso final → `agora` injetado **só na função pura** → suspensão; no hub, "Pagar agora" com o stub ainda pendente **não** reativa; "Já paguei" com o stub pago → reativada; trocar de plano mostra "vale a partir de DD/MM".
- **`cobranca-desligada.spec.ts`:** o self-hoster de empresa única vê o formulário de novo tenant, o badge e a tela Billing **como antes**, nenhuma faixa, nenhum item de admin, e "Recursos opcionais" sem cobrança.
**Fora do CI** (`FORA_DO_CI`, "precisa de chave de teste do provedor e URL pública"): `cobranca-stripe-teste.spec.ts` (`sk_test_` + `stripe listen`, `4242…` e `4000 0000 0000 0341`); `cobranca-asaas-sandbox.spec.ts`.
Evidência em `evidence/`; jornadas em `docs/testing/user-journey-map.md` (P0: primeira cobrança).
---
## 13. Sistema Vivo
| Invariante | Artefato concreto |
|---|---|
| **Entrada** | `POST /api/v1/webhooks/cobranca/{stripe,asaas}`; cron `api/v1/cron/cobranca`; `POST /api/v1/cobranca/assinatura/{checkout,sincronizar,plano,cancelar,gerenciar}` (`gerenciar` → `ok({url})` de `urlDeGerenciar`, `null` → 409 `sem_portal`); `POST /api/v1/admin/cobranca/conexao` e ações de `/admin/cobranca` e `/admin/tenants/[id]`; gatilho `fn_trial_na_criacao_da_org` |
| **Saída** | `organizations.status/suspended_kind`; `cobranca_assinaturas.estado/plano_id/ultimo_erro/assinaturas_vivas`; `agent_inbox_items` `org_reativada`, `cobranca`, `budget_exceeded ref_kind='plano'`; e-mails por `lib/email/roteador.ts` (cliente e dono na troca de chave); `PT402` → 409 `plan_limit_reached`; 403 `org_suspended` |
| **Log/atividade** | `webhook_events_log` (ponteiro); `event_log cobranca.sinal`, `tenant.suspended`, `tenant.reactivated`; ações no fim de `lib/audit/actions.ts`: `cobranca.provedor_conectado`, `plano_salvo`, `plano_arquivado`, `checkout_iniciado`, `estado_mudou`, `plano_trocado`, `assinatura_cancelada`, `prazo_concedido`, `isencao_definida`, `modo_publicado`, `org_suspensa`, `org_reativada`, `rodada`, `modulo_desligado`; `logger` `cobranca.*` |
| **Tela** | `/admin/cobranca` (saúde, problemas do dono, clientes); card no tenant; `/app/settings/billing`; faixa em `/app`; `/account-suspended` |
| **Porta** | `AdminSidebar` com `modulo:'cobranca'`; `catalogo.ts:971`; interruptor em `/admin/sistema`; redirect do suspenso; links do e-mail e da Central |
| **Onde se configura** | Ligar em `/admin/sistema`; chaves, régua e planos em `/admin/cobranca`; checklist diz o que falta; o cliente lê "o administrador ainda não conectou a cobrança" |
| **Anti-morte** | (1) reconciliação por idade de leitura, incluindo `cancelada` suspensa e checkout recente; (2) "última leitura bem-sucedida" vermelha > 7h (saúde do cron) e por cliente > 26h; (3) "último aviso do provedor há X"; (4) `ultimo_erro` e `assinaturas_vivas` na Visão geral; (5) `cobranca.sinal` morto → `event_dead`; (6) webhook de cliente desconhecido `status='error'`; (7) cercas `cron-respeita-org-operante` e `cron-routes-scheduled` |
| **Laço de retorno** | **Suspendeu quem pagou:** sinal, reconciliação (que agora alcança a `cancelada` suspensa) ou "Já paguei" leem `ativa` e reativam; `estado_mudou` guarda o `statusBruto`. **Leitura falhou:** estado intacto, `ultimo_erro` na tela, retry. **Régua dura demais:** dar prazo (reativa na hora) ou ajustar tolerância; piso de 5 dias e aviso de 48h da dívida corrente. **Limite errado:** edita o plano. **Pagamento tentou desfazer administrativa:** recusado pelo kind; e o gatilho de §2.1 impede trocar o kind pela porta dos fundos. **Pagou assinatura cancelada:** aparece ao dono. **Chave desligada:** libera todos |
| **Continuidade IA↔humano** | Reativação não reprocessa acúmulo; item `org_reativada` leva o humano às conversas. Teto do plano manda para humano pelo caminho existente |
| **Mapa vivo** | `docs/architecture/cobranca-do-revendedor.architecture.json`, nós que espelham os mapas `recursos-opcionais`, `teto-de-orcamento`, `organizacoes-e-acesso` e `central-avisos`, e um nó para a tabela `event_log` (não há mapa dela em `docs/architecture/`), cada um com ≥ 2 arestas reais dentro do mapa novo |
---
## 14. Ordem de entrega em PRs
### PR 0: ADR-0004 (`docs/adr/0004-cobranca-do-revendedor.md`), só docs
- Registra o terceiro eixo: nem o mantenedor faturando, nem a "instância hospedada" do ADR do PR #307 (fechado sem merge; colide em número com o ADR-0002 da main).
- **Seção "Relação com a ADR-0002":** classifica a cobrança como **capacidade do núcleo com chave da instalação** (precedente do caixa, `0002…:179`), não módulo de tabela: os gatilhos de limite e trial moram em tabelas do núcleo e consultam as tabelas da cobrança; a D4 reprova provisionadora que toque tabela de fora do módulo; a alternativa (tabelas pela provisionadora + gatilhos no baseline tolerando ausência por `to_regclass`, D7) poria SQL dinâmico em gatilhos quentes de `user_organizations` e `channel_sessions`. Registra o peso medido das duas tabelas vazias (medido na própria PR 0 por quem a escreve: aplicar o DDL de §2.2 e §2.3, com os índices, num Postgres 17 descartável — o ambiente da ADR-0002 — e somar `pg_total_relation_size` das duas tabelas; a ADR-0002 mediu ~368 KB para cinco) e que isso é **revisão da condição 2 para este caso, aprovada pelo dono** (decisão D-1). O texto usa "capacidade do núcleo com chave", nunca "módulo".
- Reconcilia `docs/doctrine/operacao-de-agentes.md` §4 (`:115-120`): proibições 2 e 3 valem para mantenedor e operador de agentes; assento e plano do revendedor são configuração dele, com limites nulos por padrão.
- **`operacao-de-agentes.md:96,103-107`** (e a mesma afirmação em prosa em `:32-33`, "hoje não existe nenhuma tabela de plano, fatura ou assinatura no schema"): a brecha "Faturamento e planos" passa a dizer que falta o faturamento do **operador de agentes** (eixo 2), distinto da cobrança do revendedor (eixo 3); o grep que "hoje devolve 0" é trocado por uma pergunta que o eixo 2 responde (as tabelas `cobranca_*` não podem ficar invisíveis a ele por acidente de nome).
- `docs/specs/19-spec-console-de-agencia.md:91`: console de agência segue sem faturamento.
- `VISION.md:61-62`: "nós não vendemos assinatura; quem instala pode cobrar os próprios clientes".
- `docs/growth/lp-plano.md:361` ("sem cobrança por usuário") é promessa do mantenedor.
- Revisa, regra a regra (medir antes), a seção 7 "Billing & Uso" de `docs/business-rules/00-business-rules-catalog.md:466-502`. B-03 (retenção de mídia, "cumprida desde a migration 0432") e B-05 (sync inicial da Nuvemshop) não tratam de cobrança e **não** são marcadas obsoletas. B-01 (`usage_events`, tabela que não existe no baseline), B-02 e B-04 são conferidas contra o código. B-04 é a regra de `rate_limit_rps` (`docs/specs/01-spec-platform-base.md:101`), coluna que a PR 2 apaga.
- Corrige `docs/doctrine/extensoes.md:159` ("ainda não construída") se a máquina da ADR-0002 já existe (`fn_modulo_instalar`, `baseline.sql:33108-33168`, presente na main em `76355d4b9`) — a PR 0 reconfere no SHA do dia com `grep -n 'fn_modulo_instalar' supabase/baseline.sql` antes de trocar o texto.
### PR 1: suspensão que suspende de verdade (sem chave, toda instalação)
§4 inteiro; migration 1 (inclui o gatilho de estado da org e o kind `org_reativada`); `operante.ts`; veto no gate; `naOrgParada` nos 23 handlers; `requireRole` com scope e `"leitura"`; `requirePlatformAdminEscrita` + cerca pelo mecanismo; `system/update`; hub `/account-suspended` (LGPD + troca de org); comentário de `rodada.ts`; invariantes 2–4 (arquivo `tests/invariants/org-suspensa.test.ts`) e o e2e `tests/e2e/suspensao-administrativa.spec.ts` em `SPECS_PARTE_*` (sem chave nem plano: dono suspende B; admin de B cai em `/account-suspended` com LGPD; `/app/inbox` redireciona; token API de B → 403; mensagem por `webhooks/in/[token]` é gravada e nenhuma `llm_calls` nem outbound nasce; `support_readonly` clica em Suspender e vê o erro; dono reativa e B volta com zero saídas e o item `org_reativada`). As etapas de plano do `cobranca-suspensao-e-limites.spec.ts` ficam para a PR 2; `pnpm test:db` local; fragmento `corrigido`.
### PR 2: planos e limites
Migration 2; chave (em `MODULOS_AINDA_NAO_LIGAVEIS`) e `MODULOS_SO_DA_INSTALACAO`; **prova com a chave travada na tela** (`updateModuloDaInstalacao.ts:50` recusa ligar): invariantes 5–7 e o `cobranca-suspensao-e-limites.spec.ts` gravam `platform_config.MODULO_COBRANCA='ligado'` direto no banco pelo fixture, e o passo "dono liga pela tela" do spec só entra na PR 3a, quando a chave sai da lista; `/admin/cobranca` com Planos e Clientes (atribuir, trocar em trial, prazo, isentar); gatilhos de assentos (com a trava de provisório) e canais; teto de IA em consulta separada; formulário de novo tenant com os dois modos; billing só leitura + faixa de trial; colunas mortas; spec 01.
### PR 3a: contrato, Stripe e régua
Migration 3; `lib/cobranca/*` (contrato, Stripe, estado, régua, sincronizar em fases); webhook, handler com coalescer, cron com a reconciliação ampliada, avisos; checkout em fases, portal, pagamento no hub; troca de plano agendada; Conexão (única escritora das chaves, e-mail aos admins), checklist, publicação; problemas do dono na Visão geral; scrub do Sentry; stub e e2e; a chave sai de `MODULOS_AINDA_NAO_LIGAVEIS`.
### PR 3b: Asaas
Adaptador com `includeDeleted`, `existe` por pagamento confirmado, datas no fim do dia SP, guarda do `trocarPlano`; CPF/CNPJ; token; webhook por API confirmado no sandbox (ou manual); e2e fora do CI.
### PR 4: guia Coolify (`docs/saas/coolify.md`)
Caminho suportado: `install.sh` por SSH na VPS do Coolify (detecta `coolify-proxy`, grava `REVERSE_PROXY=traefik`, descobre a rede `coolify` e os entrypoints `http`/`https`: `install.sh:587-592, 651-744, 982-1041, 1418-1450`). Banco **Supabase Cloud** (o single-server força Caddy em 80/443, `install-single-server.sh:158-170`). Não usar o compose pelo painel (profiles não confiáveis, um arquivo só, sem `update.sh` nem migrator). Conferir 307 e que `/api/v1/webhooks/cobranca/*` passa sem herdar `deskcomm-waha-block`. Atualizar pelo `update.sh`. Dimensionamento: medido na PR 4 por quem escreve o guia, numa VPS Coolify real instalada pelo `install.sh`, com `docker stats --no-stream` (RAM e CPU de `app`, `worker`, `scheduler` e WAHA) em repouso e com uma conversa de teste; o guia publica o número com a data e o SHA.
### PR 5: kit SaaS
`docs/saas/` (jornada do revendedor; Stripe × Asaas — "Stripe BR = cartão + boleto, sem Pix recorrente; Pix recorrente = Asaas"; chaves passo a passo; testar e publicar; régua; troca de plano na virada; nota fiscal fora; termos com o cliente final; `docs/saas/aulas/`). Skill `.agents/skills/deskcomm-saas/` (`SKILL.md` ≤ 500 linhas, description ≤ ~800 caracteres, `agents/openai.yaml`, citação nas 4 portas, `pnpm skills:sync`, "rode X para ver" em vez de números).
---
## 15. Riscos residuais
1. **Escrita de dados de negócio via PostgREST, Realtime e Storage por membro de org suspensa** segue pela RLS (decisão D-12). Nada que custe ou saia passa por aí; o estado da org e os limites estão travados no banco. Endurecimento futuro: varredura de policies restritivas `org_operante_write_*`, sem tocar `fn_support_write_allowed`.
2. **`support_readonly` ainda escreve pelo PostgREST onde a policy aceita `fn_is_platform_admin()`** fora das colunas travadas (ex.: `user_orgs_insert`, `baseline.sql:4308`; campos de exibição de `organizations`). A cobrança está fechada (gatilho de §2.1 e de assentos); o endurecimento geral (`fn_is_platform_admin_full` nas policies) é outro PR.
3. **15 rotas chamam `resolveActiveOrg` sem `requireRole`** (ex.: `app/api/v1/conversations/route.ts`): org suspensa recebe 307 HTML em vez de 403 JSON. Nenhum acesso se abre.
4. **Jobs `running` no instante da suspensão** terminam o turno; gate e assert barram a saída; uma chamada de LLM em voo pode ser cobrada.
5. **Webhook do Asaas fraco** (token estático): corpo é só ponteiro, nunca guardado; decisão sempre por releitura.
6. **Provedor fora do ar:** estado congela, nenhuma suspensão sem releitura fresca.
7. **Estorno/chargeback:** Asaas reabre por `lerSituacao`; Stripe só se a assinatura voltar a `past_due` (sem `charge.dispute` nesta entrega).
8. **Abuso de trial:** mitigação `signup_mode = com_aprovacao` (`lib/auth/politica-de-cadastro.ts:66`). Trocar para o plano maior durante o trial dá o teto de IA dele no trial (decisão D-13).
9. **Teto de IA soma BYOK** (org mista bate antes).
10. **Bloqueio pelo teto de IA** deixa `force_human` sem retomada automática (`orcamento.ts:102-117`).
11. **Deriva de API:** Stripe (`Stripe-Version`, `current_period_end` no item, portal na conta); Asaas (base do sandbox, `User-Agent`, webhook por API, `includeDeleted`). Conferido no dia de cada PR.
12. **Cron novo só chega na release;** num Coolify operado pelo painel, nunca (por isso a PR 4).
13. **Duas assinaturas vivas** criadas fora do fluxo: mostradas ao dono, não canceladas sozinhas.
14. **Desligar a chave não cancela nada no provedor.**
15. **~20 chamadores de `sendMessageHandler`** não lidos um a um quanto ao settle; o gate barra a IA antes; teste por chamador com settle próprio fecha os conhecidos.
16. **Saúde do cron em instalação só com trial** (sem provedor): "última leitura" não se aplica; a suspensão por trial vencido depende do scheduler, cuja presença é garantida por `cron-routes-scheduled`, não observada na tela.
17. **Stripe `vencida_desde` = instante em que vimos o atraso** (até 6h depois do real, a favor do cliente).
---
## 16. Alternativas consideradas e recusadas
O desenho passou por três críticos adversariais (segurança e isolamento; doutrina, empacotamento e atualização; dinheiro e estado). Todo achado bloqueador e alto foi aplicado. Abaixo, o que foi recusado **no todo ou em parte**, com a evidência. A numeração (1.x, 2.x, 3.x) é a do crítico que levantou o achado.
**1.1 (crítico 1, alto — support_readonly pelo PostgREST): recusada a parte (2), `fn_is_platform_admin_full()` nas policies `orgs_write_platform_admin` e `user_orgs_insert/update`.** Aplicada a parte (1), o gatilho de §2.1, que fecha os três ataques citados (a, b, c) **para qualquer scope**. Com o gatilho, o `full` também não escreve essas colunas pelo PostgREST (todo caminho legítimo já é service_role ou definer: `lib/auth/provision.ts:107-109,287-289`; `app/actions/settings/updateTenant.ts:68-80` não toca as colunas). Trocar as policies muda o poder do `support_readonly` sobre dados que não são de cobrança (nome, fuso, membros) — é endurecimento geral e fica como risco 2, fora do escopo desta capacidade. Os assentos inseridos por essa via contam no gatilho de §5.
**1.2 (crítico 1, alto — bypass do `requireRole`): recusado só o detalhe "liberar bypass para métodos que não são GET".** `requireRole` não recebe o método (`lib/auth/require-role.ts:52-53`). No lugar: `allowPlatformAdmin: true` passa a exigir `full` + MFA, e `"leitura"` libera qualquer scope, com a cerca garantindo que `"leitura"` só existe em handler `GET`. Mesmo efeito, sem ler o método em runtime.
**1.4 (crítico 1, médio — linha do webhook): recusado `raw_body NULL`.** `raw_body` é `NOT NULL` (`supabase/baseline.sql:1898`); anulá-lo exigiria alterar a coluna para todos os provedores. Aplicado equivalente: corpo `{id,type}` sempre (nenhuma PII, nem de cliente desconhecido), org nula, cabeçalhos nulos, `asaas-access-token` em `PROIBIDOS`.
**1.5 (crítico 1, médio — `cobranca.sinal` forjável): recusada a reserva de `cobranca.sinal` em `emit_event`.** Aplicados o coalescer (`retry` com `retry_at` quando `relida_em` < 30 s, `lib/event-log/dispatcher.ts:45-48`) e o HTTP fora da transação. Com isso, um sinal forjado custa no máximo uma leitura por 30 s por org — o mesmo que o botão "Já paguei" já permite ao mesmo usuário —, e não segura lock nem conexão. Redefinir `emit_event` exige copiar o corpo vigente derivado, e o próprio baseline registra que copiar a definição errada já reintroduziu comportamento revogado (`supabase/baseline.sql:28395-28400`, migration 0224). Risco alto por ganho nulo depois do coalescer.
**2.J (crítico 2, baixo — pulso em `platform_config`): recusado guardar o pulso em qualquer lugar.** Ele é derivado: "última leitura bem-sucedida" = `max(relida_em)`, vermelha > 7h, já que a reconciliação relê a cada 6h toda linha que ela seleciona (§8). `STRIPE_PORTAL_CONFIG_ID` foi aceito como `diagnostico`. Limite registrado no risco 16.
**2.K (crítico 2, baixo — poda de D+90): recusado gravar `primeiro/ultimo_aviso_recebido_em`.** Aplicada a correção da frase de §2.4. O checklist passa a aceitar também "existe assinatura `ativa` com provedor" como prova de que o caminho funcionou, e isso sobrevive à poda sem armazenamento novo. "Último aviso há X" diz "nenhum nos últimos 90 dias" além do horizonte, o que é verdade.
**2.A (crítico 2, alto — ADR-0002): recusada a opção (b)** (provisionadora + gatilhos que toleram tabela ausente via `to_regclass`). Motivo: SQL dinâmico dentro dos gatilhos quentes de `user_organizations` e `channel_sessions`, contra duas tabelas pequenas. Aplicada a opção (a), com a revisão da condição 2 levada ao dono (decisão D-1).
**2.C (crítico 2, alto — `settings.plan`): recusada a alternativa "apagar e declarar `exige_acao`".** Aplicada a outra saída, não destrutiva: nada muda com a chave desligada. Apagar passa a ser a decisão D-2.
**3.3 (crítico 3, alto — Stripe `unpaid`/`paused`): recusada a consulta adicional de faturas `uncollectible`.** Aplicado o mapeamento pelo status da assinatura (`past_due`, `unpaid`, `paused` → atraso), que cobre os três destinos que o painel oferece depois das tentativas. Contar `uncollectible` como vencida marcaria para sempre, como devedora, uma org cuja dívida o revendedor perdoou no painel (a fatura continua `uncollectible` com a assinatura já `active`).
**3.4 (crítico 3, alto — `vencidaDesde` na Stripe): recusada a conta `finalized_at + boleto.expires_after_days`.** Aplicado: só há atraso com status `past_due`/`unpaid`/`paused`, e `vencida_desde` é o instante em que vimos isso pela primeira vez (monotônico). A conta proposta depende do método de pagamento de cada fatura e da configuração de boleto da assinatura. Isso é mais código e mais deriva de API para ganhar no máximo 6 horas, e esse erro favorece o cliente.
**3.8 (crítico 3, alto — fraude de upgrade/downgrade): recusado o upgrade imediato cobrando a diferença** (`proration_behavior=always_invoice` ou cobrança avulsa no Asaas). Aplicado o caminho menor que o próprio achado recomenda: toda troca só vale na virada paga do ciclo. O upgrade imediato fica como decisão D-3.
**3.10 (crítico 3, médio — Asaas `updatePendingPayments:true`): recusado trocar para `false`.** O Asaas gera a cobrança do próximo ciclo com até ~40 dias de antecedência (premissa de pesquisa não versionada: a PR 3b a confirma no sandbox antes do merge — cria assinatura mensal, lista `GET /payments?subscription=` e lê `nextDueDate` antes e depois da geração da cobrança seguinte — e registra no PR se `nextDueDate` é o vencimento da cobrança pendente ou o do ciclo seguinte; a guarda de §6.2 e `proximoVencimento` dependem dessa resposta). Com `false`, essa cobrança já gerada ficaria com o preço antigo, e o plano novo seria cobrado um ciclo depois de valer. Aplicado no lugar: `trocarPlano` recusa enquanto a cobrança do período corrente estiver pendente. Depois dessa guarda, a única cobrança pendente é a do próximo período, e é ela que deve levar o valor novo. O defeito apontado (mudar o boleto que o cliente já tem ou já pagou do ciclo corrente) fica fechado.
**3.14 (crítico 3, baixo — insert do log + emit): recusada a RPC `security definer` única.** Aplicada a alternativa barata que o próprio achado cita: o `23505` sobre uma linha ainda `received` reemite o sinal, e a linha passa a `processed` depois do emit. Não nasce função definer nova (nem revoke, nem entrada na varredura) para um caso que a releitura já torna inofensivo.
**Médio aceito além do pedido:** o e-mail a todo platform admin `full` na troca de chave (crítico 1, achado 6) foi incluído porque custa uma chamada a `sendEmail`.