Especificação aprovada pelo dono do produto em 29/09/2026 para o kit SaaS:
o dono de uma instalação cobra as empresas que atende (planos, limites,
régua de inadimplência, checkout hospedado), e a suspensão de organização
passa a suspender de verdade — hoje ela só tira a pessoa da tela, e a IA,
as automações, os follow-ups, a voz e a API seguem rodando.
Desenho produzido por painel de 3 abordagens, 2 juízes, síntese, 3 críticos
adversariais e revisão; depois, 5 verificadores conferiram as citações
arquivo:linha contra a main (76355d4b9) e 58 correções foram aplicadas.
Entrega em 7 PRs (§14), cada um com plano próprio.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 KiB
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:linhafoi medida namainem76355d4b9. 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/, skilldeskcomm-saase 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-7já 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_COBRANCAemplatform_config, lida porlib/instalacao/modulos.ts(sóligadoliga; falha fechada:modulos.ts:1-27, 101, 164), no mesmo trilho deMODULOS_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/cobrancasome 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/billinge 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 textcomcheck (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-379troca pararedactedsem limpar o motivo. Regra de leitura no cabeçalho delib/organizacao/operante.ts:suspended_kindsó significa algo comstatus='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';UPDATEque mudastatus,suspended_kind,suspended_at,suspended_reason,suspended_byoucreated_by(is distinct from) → 42501.
- Por quê:
orgs_write_platform_admin(baseline.sql:4195) aceita qualquerfn_is_platform_admin(), que ignora o scope (baseline.sql:325-333), eauthenticatedtemGRANT ALL(baseline.sql:4686-4687). Sem o gatilho, umsupport_readonlyfaria pelo PostgREST:PATCH status='active'numa suspensa por cobrança; troca desuspended_kinddeadministrativaparacobranca(e o próximo pagamento desfaria uma suspensão por fraude);INSERTde 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);updateTenantusa 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); olgpd-redact-workerusa 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)
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)
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_roleescreve. - 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 deauthenticated,fn_aplicar_travas_de_suporte(baseline.sql:28192) não plantasupport_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 SELECTem:4722):organization_id = NULLsempre (a org viaja só noemit_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_bodynasceuNOT 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 oasaas-access-token. Cinto:'asaas-access-token'entra emPROIBIDOSdelib/channels/arquivo-de-webhook.ts:37.status:received→processed(comprocessed_at) depois doemit_event;errorcomerror_message='cliente_desconhecido'quando não há org.
webhook-replayfiltraprovider='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 poremit_eventpara todo chamador (:28451); pôr o status nela derrubaria LGPD, a entrada de mensagens e o própriocobranca.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:
countemuser_organizationspor org (dezenas de linhas). Sem índice antes de medir. - Canais:
channel_sessionsnã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)):
- Já suspensa: mesmo kind →
{changed:false}; cobrança → administrativa troca o kind; administrativa → cobrança é no-op. update organizations set status='suspended', suspended_kind, suspended_reason, suspended_at=now(), suspended_by.- 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 alertajob_dead). - Fecha o redrive:
update messages set status='failed', error_code='org_suspensa' where organization_id=p_org and status='queued'. insert into event_logdetenant.suspendedna mesma transação (continua emfn_event_log_e_registro,baseline.sql:24523-24524: nascedone, sem consumidor).
fn_reativar_organizacao:
- Guarda de kind. 2. Lê
suspended_atantes de zerar. 3.status='active',suspended_*=null. 4. Cinto: jobspendingantigos da org →failed. 5. Se N conversas têmlast_inbound_at >= suspended_at, um item na Central comkind='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." Semforce_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)comx = situacao.vencidaDesde ?? now()(trial:trial_ate). Só viranullquando o estado passa aativaoutrial. 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
ativaoutrialzeraultimo_avisoeultimo_aviso_em. plano_agendado_id: aplicado (viraplano_id) quandoproximo_vencimentoavanç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(oupagamento_de_assinatura_canceladasesituacao.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
activeoususpended{cobranca}→ avalia. - org
suspended{administrativa}→ nenhuma ação, nenhum aviso. - org
redacted/archivedcom provedor e estado ≠cancelada→cancelarNoFimuma vez + auditcobranca.assinatura_canceladacommotivo:'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 doultimo_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.tsexportaSTATUS_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 decron-audita-so-quando-ha-efeito: mira só decisões (argumento deredirect,fail,returnde gate, filtro de seleção emapp/api/v1/cron/**,lib/**/worker*,workers/**) que comparemstatusde org com literal fora deoperante.ts. Exibição (app/admin/(protected)/tenants/[id]/_client.tsx:43, TenantActions) usaehOperante/suspended_kinde 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 |
| 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'(403forbidden_scope) +mfaEmDivida()(lib/auth/server.ts:414, 403mfa_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, emapp/**inteiro:- todo handler exportado
POST|PATCH|PUT|DELETEque chamarequirePlatformAdmin(ou lê.is_platform_adminprecisa chamarrequirePlatformAdminEscrita(; - todo arquivo
"use server"que importarequirePlatformAdminprecisa usar a versão de escrita; allowPlatformAdmin: "leitura"só aparece em handlerGET;- allowlist que só encolhe, com justificativa.
- todo handler exportado
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.Gatilho trg_trava_assentos_do_plano BEFORE INSERT OR UPDATE OF revoked_at, provisional_until_handover, organization_id ON public.user_organizations: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).2. Conta quando: INSERT ativo; revoked_at volta a nulo; provisional_until_handover vira false num ativo; organization_id muda num ativo.3. pg_advisory_xact_lock(hashtextextended(new.organization_id::text, 2282)); acima do teto raise ... using errcode='PT402' (precedente PT409, baseline.sql:38711-38747).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."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."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).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.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).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.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.Decisão pura decidirTetoDoPlano({tetoUsdCents, gastoUsdCents, origemDaChave, purpose}): PURPOSES_ISENTOS isentos; gasto ≥ teto → bloquear.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.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."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:
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>;
}
fetchinjetado; chave lida porvalorDaInstalacao()(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.
GET /v1/webhook_endpoints, remove os da mesma URL.POST /v1/webhook_endpointscomcheckout.session.completed,customer.subscription.created|updated|deleted|paused|resumed,invoice.paid,invoice.payment_failed. Nuncainvoice.created(não-2xx atrasa em até 72h a finalização).POST /v1/billing_portal/configurationscompayment_method_update,invoice_history,subscription_cancel{mode:at_period_end}, semsubscription_update; id emSTRIPE_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:
GET /v1/subscriptions?customer=&status=all&limit=10. Terminal:canceled,incomplete_expired. Principal = mais recente não terminal.existe= principal ∈ {active,past_due,unpaid,paused}.trialing(checkout concluído comsubscription_data[trial_end], nenhuma cobrança ainda) eincomplete(1º pagamento pendente, 23h) →existe=false, mas contam emassinaturasVivas. Comtrialing, a tradução mantémtrialaté a 1ª fatura paga, como exige o contrato deexiste.emTesteNoProvedorAte=trial_endda principal quando ela estátrialing; senãonull.emAtraso= principal ∈ {past_due,unpaid,paused}. Cobre os três destinos que o painel oferece depois das tentativas (cancelar → terminal;unpaid; manterpast_due).vencidaDesde = null:due_dateé sempre nulo emcharge_automaticallyecreatedé o dia da emissão, não do vencimento;sincronizarusa o instante em que viu o atraso pela primeira vez (≤ 6h de erro pela reconciliação, a favor do cliente).cancelaNoFim=cancel_at_period_end;proximoVencimento=current_period_enddo item da principal (a PR 3a confere contra a versão fixada emStripe-Version, lendo uma assinatura criada na conta de teste, e o caso entra emstripe.test.ts).jaPagou:GET /v1/invoices?customer=&status=paid&limit=10, alguma comamount_paid > 0(a fatura de trial de R$0 não conta).linkDePagamento:GET /v1/invoices?subscription=<principal>&status=open→hosted_invoice_urlda mais antiga; principal terminal ou ausente →null(a tela oferece "Assinar de novo").pagamentoSemAssinaturaViva: a fatura paga mais recente (amount_paid > 0) é de assinatura terminal e foi paga depois deended_atdela.
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:
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.GET /payments?subscription=<principal>:existe= principal não terminal e alguma cobrançaCONFIRMED/RECEIVED/RECEIVED_IN_CASH. A assinatura nasceACTIVEantes de qualquer pagamento; assinatura só comPENDINGé o "incomplete" do Asaas (existe=false, conta emassinaturasVivas). Fecha o "clicar em Pagar agora reativa sem pagar".emAtraso= algumaOVERDUEda principal, ouREFUNDED/CHARGEBACK_REQUESTEDda cobrança do período corrente.vencidaDesde= menordueDatedessas (fim do dia SP).CONFIRMED(cartão pago, saldo não disponível) já conta como pago.proximoVencimento= maiordueDateentre as cobranças confirmadas (CONFIRMED/RECEIVED/RECEIVED_IN_CASH) de qualquer assinatura do cliente, mais umcycle: é o fim do período pago. Não usarnextDueDate: 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 deplano_agendado_id(§3.2) e daria acesso além do pago no cancelamento (§7f).sincronizarsó sobrescreve com não nulo.jaPagou:GET /payments?customer=&status=<s>&limit=1para cadasemRECEIVED,CONFIRMEDeRECEIVED_IN_CASH(o mesmo conjunto de pago do passo 2).linkDePagamento=invoiceUrldaOVERDUEmais antiga ou daPENDING, só da principal não terminal.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
- 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." - Conectar em
/admin/cobranca › Conexão(rotaPOST /api/v1/admin/cobranca/conexao,requirePlatformAdminEscrita()):urlPublicaUsavel(env.NEXT_PUBLIC_APP_URL)e protocolohttps:.urlPublicaUsavel(lib/escalacao/url-publica.ts:53) aceitahttp:(ESQUEMAS,:30, 61), e a Stripe só registra endpoint HTTPS em produção. O valor é lido delib/envem runtime, nuncaprocess.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: comCOBRANCA_API_BASE_URL_TESTEválida (loopback, o provedor é o stub), aceita URL de loopback — teste nos dois sentidos.testarChave+prepararWebhook; grava cifrado porgravarPelaTela(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_assinaturascomprovedor= ele emodo='producao'→ 409provedor_com_assinaturas. Trocar a chave do mesmo provedor detesteparaproducaoé a Publicação (passo 5), feita por esta mesma rota: havendo linhasmodo='teste'e semconfirmar_publicacao:trueno corpo → 409publicacao_requer_confirmacaocomdetails.assinaturas_de_teste(contagem) para a tela listar; com a confirmação, grava a chave nova e aplica o passo 5 às linhasmodo='teste'depois detestarChave/prepararWebhook. Corpo:{ provedor: 'stripe'|'asaas', chave: string, confirmar_publicacao?: boolean }; respostaok({ 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 adminscope='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, comrequirePlatformAdminEscrita(). Fora delas,salvarConfiguracaoDaInstalacao/voltarConfiguracaoAoPadrao(app/actions/admin/salvarConfiguracaoDaInstalacao.ts:59-76, 116-122) recusam chave comtelaDona === "cobranca".
- Planos: cria um e marca "plano do cadastro (teste grátis de N dias)".
- Checklist em
/admin/cobranca:- chave conectada;
- aviso de pagamento recebido —
max(received_at)emwebhook_events_logdo provedor ou existe assinaturaativacom 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).
- Publicar (trocar a chave de teste pela de produção): a tela lista as assinaturas
modo='teste'e avisa que voltam atrial; ao confirmar, zeraprovedor,modo,provedor_cliente_id,provedor_assinatura_id,checkout_url,checkout_expira_em,vencida_desde,ultimo_avisoeultimo_aviso_em(o CHECK de §2.3 exige zerar provedor e cliente juntos),estado='trial',trial_ate=now()+trial_dias, auditacobranca.modo_publicado.
(b) Cadastro → trial → checkout → ativa
- Cadastro em modo
aberto(lib/auth/politica-de-cadastro.ts:176) →ensureTenantForUser→fn_trial_na_criacao_da_orgcria otrial. /appmostra aos admins a faixa "Teste grátis: faltam N dias · Assinar" (só comcobrancaemmodulosLigados).- "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 422documento_invalido; obrigatório quandoCOBRANCA_PROVEDOR='asaas'→ 422documento_obrigatorio; a tela pré-preenche deorganizations.cnpj; nunca gravado).garantirClienterecebenome = organizations.legal_nameeemail= 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_urlválido → devolve o mesmo;checkout_expira_em > now()comcheckout_urlnulo → 409checkout_em_preparo("já estamos gerando seu link"); senão gravacheckout_url=null, checkout_expira_em=now()+2min(reserva) e comita. - Fase 2 (fora de transação):
lerSituacao; seassinaturasVivas > 0(incluiincompletee Asaas comPENDING) → limpa a reserva e 409pagamento_em_andamento("você já tem um pagamento em andamento") comdetails.link_de_pagamento; senãogarantirCliente,iniciarAssinatura. Falha do provedor → 503provedor_indisponivel(transitório) ou 502provedor_recusou. Os códigos novos entram emlib/api/errors.ts. - Fase 3 (update condicional): grava
provedor,modo,provedor_cliente_id,checkout_url,checkout_expira_em; auditacobranca.checkout_iniciado; devolve{url}. Falha na fase 2 limpa a reserva.
- Fase 1 (transação curta,
- 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):
- 404 se provedor fora da lista, chave desligada ou sem segredo (molde
app/api/v1/tenants/provision/route.ts:43-110). checkRateLimitpor IP, 120/min.await req.text()(≤ 1 MB) →verificarWebhook; inválido → 401 +logger.warn+ contador.insert webhook_events_logna 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ê).- 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 ramoreceived).
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). Umcobranca.sinalforjado por membro viaemit_eventcusta, no máximo, o mesmo que o botão "Já paguei". - Chama
sincronizar(org)(lib/cobranca/sincronizar.ts):- 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,retrydo drain. - Transação curta com
pg_advisory_xact_lock(hashtextextended(org::text, 2283))eselect ... for update: serelida_em >= lido_em, descarta (uma leitura mais nova já foi aplicada). Senão: tradução + regras de gravação (§3.2), auditcobranca.estado_mudoucomstatusBrutosó se mudou, régua e ação por funções SQL idempotentes; os avisos são gravados porupdate ... set ultimo_aviso=$1, ultimo_aviso_em=now() where ... returning. - Depois do commit: e-mails das linhas de aviso ganhas.
- Fora de transação:
- Evento que morre →
event_deadna Central pelo drain existente.
(d) Falha → tolerância → aviso → suspensão → pagamento → reativação
invoice.payment_failed/customer.subscription.updated(past_due) ouPAYMENT_OVERDUE→ (c) →em_atraso.- Avisos (só org
activeoususpended{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) commarcaDaSaida(org)(lib/branding/saida.ts:184), no idioma deorganizations.localee datas noorganizations.timezone; itemkind='cobranca'na Central; faixa vermelha em/app. E-mail que falha não é reenviado (Central e faixa seguem).
suspende_em_breve(critical): "Sua conta será suspensa em DD/MM se o pagamento não for confirmado."- No limite (§3.2), no cron: releitura →
fn_suspender_organizacao(org,'cobranca',…)→ e-mail "Conta suspensa por falta de pagamento" → auditcobranca.org_suspensa. /account-suspended: admin vê "Pagar agora" (linkDePagamentoou portal) e "Já paguei" (sincronizar); sem link (principal terminal) vê "Assinar de novo"; não admin lê "Avise o administrador da sua empresa".invoice.paid/PAYMENT_CONFIRMED→ (c) →ativa→ reativa → itemorg_reativada+ e-mail "conta liberada" + auditcobranca.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 estadoem_atraso/cancelada→ 409pagamento_pendente("regularize o pagamento antes"); recusa do Asaaspagamento_do_periodo_pendente→ 409 com esse mesmo código; - downgrade: uso atual precisa caber, senão 409
plan_limit_reachedcomdetails: { excedente: { assentos?: number, canais?: number } }(só os > 0), que a tela transforma em "remova 2 pessoas e 1 número"; a mesma checagem vale noPATCHadmin de §7g; - durante o teste grátis (
trial_ate > now(), com ou sem provedor — decisão D-13): troca imediata deplano_id; com provedor, chama tambémtrocarPlanopara que a primeira cobrança saia com o preço novo (no Asaas, a guardapagamento_do_periodo_pendentenão se aplica aqui: a cobrança pendente é a primeira, com vencimento emtrial_ate, e é ela que deve levar o valor novo); - depois do teste, com provedor: grava
plano_agendado_id, chamatrocarPlano(fora de transação; Asaas pode recusar compagamento_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; mudarpreco_centsouintervalocom assinatura emplano_idouplano_agendado_id→ 409plano_com_assinantes("arquive e crie outro"); dois padrões → 409state_conflict(índicecobranca_planos_um_padrao). - PR 2:
POST /api/v1/admin/tenants/[id]/assinatura{plano_id}(sem linha) criatrialcom os dias do plano; linha existente → 409state_conflict.PATCHna 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 deCOBRANCA_TOLERANCIA_DIAS; a aba Régua entra na PR 3a.
- PR 2:
- Atribuir plano (sem linha): cria
trialcom os dias do plano. - Trocar plano (com linha): o mesmo caminho de (e) —
trocarPlanocom provedor, agendado para a virada; nunca mexe emestado,trial_ateouvencida_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ó selerSituacaomostraassinaturasVivas = 0, senão 409assinatura_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:
- Reconciliação — até 50 linhas com
provedor is not null, ordenadas porrelida_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'echeckout_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).
- 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). - 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,Recordexaustivo);- novo
MODULOS_SO_DA_INSTALACAO: readonly ModuloOpcional[] = ["cobranca"]emmodulos.ts, filtrado emapp/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 acanceladaantiga 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'); eventoserror/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;NavItemganhamodulo?; o layout admin passamodulosLigados; entrada{href:'/admin/cobranca', label:'Cobrança', modulo:'cobranca'}. Vigiado portests/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(semmodulo, para não sumir com a chave desligada),minRole:"admin". Hoje o label é "Billing" (dicionárioes"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", maiszh-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 comemailDeSuporte(). - 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 usamvalorDaInstalacao(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ó conferecontrole) trocaria a chave Stripe semtestarChave, sem a guarda de assinaturas vivas e com rastro de uma linha só. - Sem edição manual:
update.shnão muda; URL do webhook sai deNEXT_PUBLIC_APP_URL;SUPPORT_EMAILreusado. - Trocar
AI_CRED_AES_KEY: segredos ilegíveis → leitura falha comocredencial_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,moedano invariante. - Prova:
pnpm test:db(installON_ERROR_STOP=1+ update); CI em pg15 e pg17.supabase/config.toml:29dizmajor_version = 15e o baseline não usaGRANT 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.
- PR 1:
- Docs (DoD 16):
docs/specs/01-spec-platform-base.md:101-102; comentário delib/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;canceladacom e semproximo_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; orgsuspended{administrativa}não recebe aviso; orgredacted→cancelarNoFim, sem aviso.lib/cobranca/estado.test.ts: tradução;vencida_desdemonotônico; cancelar e reassinar não zeravencida_desde;proximo_vencimentonão é apagado por leitura nula;ultimo_avisozera na volta aativa/trial;plano_agendado_idaplicado só na virada paga.stripe.test.ts: segundov1aceito,v0recusado,tfora de 300 s, corpo alterado;testarChaveporlivemode; um caso por status:unpaidsem fatura aberta → em atraso;paused→ em atraso;past_due→ em atraso;activecom fatura aberta de boleto dentro da validade → em dia;incomplete→existe=falseeassinaturasVivas=1; fatura de trial R$0 não conta emjaPagou; auto-cancelada + reassinatura paga + fatura antiga aberta →ativa(faturas pela assinatura);linkDePagamentonulo com principal terminal.asaas.test.ts: token em tempo constante;includeDeleted=truena URL; cancelar (DELETE) no dia 2 de mês pago →canceladacom acesso até o fim do período; assinatura ACTIVE só comPENDING→existe=false("suspenso clica Assinar e não paga → continua suspenso");CONFIRMEDconta como pago;REFUNDED/chargeback reabrem;'2026-10-05'→2026-10-06T02:59:59Ze texto "05/10";trocarPlanorecusa com período corrente pendente e mandaupdatePendingPayments:truequando 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 emtests/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: injetar42883na consulta do teto prova que omododeai_budgetscontinua aplicado e queSQL_CONFIG_COM_ORCAMENTOnão mudou.gate.test.ts:orgStatusnão operante vence todos os vetos.tests/unit/dispatcher-org-parada.test.ts:pula+ org parada →skippedemconsumed_by; lista literal dosroda.require-role.test.ts: 403org_suspended;permiteOrgSuspensalibera;allowPlatformAdmin:truecomsupport_readonly→ 403; comfulle MFA em dívida → 403;"leitura"liberasupport_readonly.resolveApiToken: recusa sem debitar o balde.sendMessageHandler:OrgNaoOperanteErrorterminal 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.invalidrecusado. - Webhook:
23505com linhareceivedreemite; comprocessedresponde 200 sem reemitir; linha gravada sem cabeçalhos e com corpo{id,type}. - Handler de sinal:
relida_emhá 10 s →retrycomretry_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)
- Isolamento com 2 tenants (
cobranca-isolamento.test.ts;cobranca_assinaturasemTABLESderls-isolation.test.ts): admin de A lê só A;agentde A não lê;authenticatednão escreve (PATCH pelo PostgREST com JWT real);anonnada;cobranca_planosinvisível; viewer de A lê 0 linhas dewebhook_events_logcom provider in (stripe,asaas). - Estado da org só pelo servidor: com JWT de platform admin
support_readonlye defull,PATCH organizations set status, troca desuspended_kindeINSERT organizationspelo PostgREST → 42501;fn_suspender_organizacao(service_role) funciona;updateTenantpela sessão (nome, fuso) segue funcionando. - Org suspensa:
emit_eventcom service_role e com o admin da org continua funcionando; LGPD approve emite; B ativa segue normal. fn_suspender/fn_reativar: jobspending→failed;queued→failed; administrativa prevalece; idempotência; itemorg_reativadacom a contagem;event_logna mesma transação;redactedcomsuspended_kindresidual não quebra.- Assentos: teto 2 → 3º INSERT
PT402; duas conexões concorrentes → exatamente uma passa; provisório inserido porfn_create_tenant_with_ownernão conta; INSERT provisório via PostgREST (JWT de admin do tenant) → 42501;UPDATE provisional_until_handover=falseacima do teto →PT402;UPDATE organization_idconta; chave desligada ou sem linha = sem limite; seeds de e2e passam. - Canais: idem, com desarquivar,
UPDATE organization_id,wacallsfora, lock compartilhado comfn_reserve_channel_connection. - Trial na criação: chave ligada + padrão →
trial;created_byplatform admin → nada; chave desligada → nada;fn_create_tenant_with_ownercomplano_id→ linha; semplano_ide chave desligada →settings.plangravado como hoje. - Deduplicação: mesmo
(provider, external_id)→23505. - Fila e agendador:
CLAIM_SQLnão enxerga job de org suspensa;fireOneDueavança sem enfileirar. - Reconciliação: linha
canceladade orgsuspended{cobranca}e linhacanceladacom checkout de 10 dias atrás entram na seleção;canceladaantiga de org ativa não. - 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-suspendedcom LGPD e o texto da suspensão administrativa (emailDeSuporte(), §9);/app/inboxredireciona; token API de B → 403; mensagem porwebhooks/in/[token]real é gravada e nenhumallm_callsnem outbound nasce;support_readonlyclica 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_TESTEem loopback; a exceção de URL de §7a). Roteiro: conectar; checklist; cadastro → faixa de trial → Assinar com plano detrial_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 marcapast_due→ cron drenado pelo endpoint → avisos → aviso final →agorainjetado 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 porto_regclass, D7) poria SQL dinâmico em gatilhos quentes deuser_organizationsechannel_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 somarpg_total_relation_sizedas 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 tabelascobranca_*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 derate_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 em76355d4b9) — a PR 0 reconfere no SHA do dia comgrep -n 'fn_modulo_instalar' supabase/baseline.sqlantes 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
- 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 tocarfn_support_write_allowed. support_readonlyainda escreve pelo PostgREST onde a policy aceitafn_is_platform_admin()fora das colunas travadas (ex.:user_orgs_insert,baseline.sql:4308; campos de exibição deorganizations). A cobrança está fechada (gatilho de §2.1 e de assentos); o endurecimento geral (fn_is_platform_admin_fullnas policies) é outro PR.- 15 rotas chamam
resolveActiveOrgsemrequireRole(ex.:app/api/v1/conversations/route.ts): org suspensa recebe 307 HTML em vez de 403 JSON. Nenhum acesso se abre. - Jobs
runningno instante da suspensão terminam o turno; gate e assert barram a saída; uma chamada de LLM em voo pode ser cobrada. - Webhook do Asaas fraco (token estático): corpo é só ponteiro, nunca guardado; decisão sempre por releitura.
- Provedor fora do ar: estado congela, nenhuma suspensão sem releitura fresca.
- Estorno/chargeback: Asaas reabre por
lerSituacao; Stripe só se a assinatura voltar apast_due(semcharge.disputenesta entrega). - 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). - Teto de IA soma BYOK (org mista bate antes).
- Bloqueio pelo teto de IA deixa
force_humansem retomada automática (orcamento.ts:102-117). - Deriva de API: Stripe (
Stripe-Version,current_period_endno item, portal na conta); Asaas (base do sandbox,User-Agent, webhook por API,includeDeleted). Conferido no dia de cada PR. - Cron novo só chega na release; num Coolify operado pelo painel, nunca (por isso a PR 4).
- Duas assinaturas vivas criadas fora do fluxo: mostradas ao dono, não canceladas sozinhas.
- Desligar a chave não cancela nada no provedor.
- ~20 chamadores de
sendMessageHandlernão lidos um a um quanto ao settle; o gate barra a IA antes; teste por chamador com settle próprio fecha os conhecidos. - 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. - 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.