Painel Língua Brasileira · Dados: coleta, modelagem e qualidade · 2026

Da planilha ao
painel de decisão

Ingestão, modelagem e conciliação das vendas de um negócio pequeno, até a métrica responder à pergunta certa

  • PostgreSQL
  • Supabase
  • Edge Functions (Deno)
  • Node.js
  • TypeScript
  • React
  • API REST
  • webhook
  • carga incremental
  • idempotência
  • views SQL
  • RLS
  • conciliação
  • Vitest
  • node:test
  • GitHub Actions
As três métricas, com os dados fictícios do case
Minha contribuição
Direção do projeto, decisões de modelagem e erros de semântica apontados.
Entrega
Painel em produção, operado por mim e pela Jessy; 110 testes automatizados.
Linha do tempo
Primeira carga pela API em 25/07/26
Situação descrita até 03/11/26

Quanto entrou? No painel financeiro do Língua Brasileira, o negócio que tenho com a Jessy, essa pergunta tem três respostas corretas:

  • Faturamento: a venda aconteceu.
  • A receber: a venda aconteceu e o dinheiro ainda não chegou.
  • Caixa: o dinheiro está na conta da empresa.

Em 25/07/26, a primeira carga pela API da Hotmart entrou como realizado. A soma estava certa. Só que, num painel de caixa, realizado quer dizer dinheiro na conta, e esse dinheiro ainda estava na Hotmart. Apontei o erro, e a correção acabou mudando o modelo inteiro.

Este case conta o caminho desses dados, da entrada de cada venda até as três métricas. Valores do negócio e registros de clientes ficam de fora, e os exemplos com número são fictícios.

De onde vem cada número

Fluxo em três colunas. Entradas: webhook da Hotmart, API da Hotmart com sync, planilha de abertura, formulário do painel, extratos e fatura. Banco: hotmart_eventos guarda o payload cru e o resultado da decisão; financeiro_lancamentos guarda origem, status, código da transação e lixeira; crm_leads guarda clientes e contatos, ligado aos lançamentos por lead_id, que indica quem pagou. Leitura: views de conferência sobre o payload, vw_lead_receita com realizado e previsto separados por cliente, métricas em TypeScript e telas do painel.

O painel substituiu uma planilha e um CRM no Notion. A Jessy, que não programa, opera o painel comigo. A tabela de lançamentos recebe linhas de cinco lugares, do webhook ao formulário que nós duas preenchemos.

A coluna origem guarda de qual deles a linha veio. Dá pra rastrear um número até a carga que o trouxe e desfazer uma carga inteira com um delete. Correção de dado também passa por migração, em arquivo. A numeração chegou a 93, e muita coisa ali é correção de dado.

O que uma linha promete

O schema tem dois status, previsto e realizado. O dinheiro tem mais estados que isso, e descobri quantos em duas etapas.

Na correção de julho, modelei três (em garantia, liberado na Hotmart e na conta). A API não tem endpoint de saldo nem de saque, então o último estado só aparece no extrato. Em 06/08/26 chegou o primeiro extrato da carteira da Hotmart, e o "liberado" se partiu em dois:

Estado do dinheiroQuem sabestatus no painel
Comprado, em garantiaAPI: APPROVEDprevisto
Garantia vencidaAPI: COMPLETEprevisto
Creditado na carteirasó o extrato da carteiraprevisto
Sacado, na conta da empresasó o extrato bancáriorealizado

COMPLETE não quer dizer que dá pra sacar. Nas vendas em real de 2026, uma amostra pequena, o Pix chegou à carteira em cerca de 2 dias e o cartão em cerca de 30. Uma venda de cartão pode estar COMPLETE há uma semana sem o dinheiro ter chegado nem à carteira. Por isso a conciliação usa a data de efetivação do extrato. A data da venda não conversa com o banco.

A categoria teve um problema parecido. Uma escola que comprou vagas em bloco aparecia como categoria própria, ao lado dos produtos. Vi isso olhando o gráfico de receita. A fatia partida fazia a maior categoria aparecer atrás de outra. Comprador foi para a descrição, e depois ganhou coluna própria, lead_id, com uma definição fechada. Ela guarda quem pagou, nunca quem consumiu.

Duas entradas, uma venda

Por que dois caminhos pra mesma venda? O webhook entrega na hora, principalmente reembolso e chargeback (contestação da compra no cartão), que tiram dinheiro sem ninguém pedir. O sync busca na API desde a última venda lançada, menos 7 dias de folga, e pega o que o webhook perdeu.

Com duas entradas, ficou importante identificar cada venda sem erro. A chave começou como texto dentro da observação, lida por regex. Só que a observação é um campo livre do formulário, o mesmo que se usa pra promover a venda a realizado. Apagar esse texto relançaria a venda na próxima carga. O código da transação virou coluna, e a garantia passou do script para o banco:

alter table public.financeiro_lancamentos
  add column if not exists transacao text;

-- Parcial: lançamento manual e planilha de abertura não têm código.
-- Inclui a lixeira de propósito: venda apagada não volta na próxima carga.
create unique index if not exists financeiro_lancamentos_transacao_uidx
  on public.financeiro_lancamentos (transacao)
  where transacao is not null;

As linhas da planilha não têm esse código, então a deduplicação não as enxerga. Por isso, a carga ignora o que for anterior a 24/06/26. Sem esse piso, uma carga com data antiga lançaria a série de novo.

Na dúvida, a venda fica de fora, ou entra de um jeito que dá pra ver. Produto fora do mapa não entra, e o script avisa. O valor é o líquido da produtora, e sem essa comissão no payload a venda é ignorada. Venda em dólar entra zerada e marcada, com um contador na tela, porque prefiro um buraco visível a uma cotação chutada.

A fonte também surpreende. A API REST devolve COMPLETE, e o webhook manda COMPLETED, com D. Descobri no primeiro teste real, recusado por essa letra. No mesmo teste, a gravação do evento falhou por falta de GRANT, a função respondeu 200 mesmo assim e a Hotmart marcou tudo como entregue. Os eventos daquele teste se perderam. Agora a falha na gravação vira 500, e a Hotmart reentrega.

Cada evento fica guardado cru em hotmart_eventos, junto com o que a função decidiu sobre ele. Pra ler campos como UTM e cupom, usei view em vez de coluna nova. O custo é que, se a Hotmart renomear um campo, a view devolve NULL em silêncio. Aceitei porque o payload continua lá pra conferir. Como um cast ruim derrubaria a consulta inteira, a conversão para número passa por esta função:

create or replace function public.num_seguro(t text) returns numeric
language plpgsql immutable strict as $$
begin
  return t::numeric;
exception when others then
  return null;
end $$;

Conferir contra documento

Só documento promove venda para realizado. Em agosto, as origens listadas no extrato da carteira somaram, ao centavo, o saque mais a tarifa. Essa prova de composição vale mais que encontrar um valor parecido.

O caminho até aí teve erro. Um extrato de julho mostrava um único saque, e por isso voltei para previsto as vendas posteriores a ele. A decisão fazia sentido com o que eu tinha. O extrato da carteira mostrou outro saque, e as vendas que tinham prova voltaram para realizado. A que continuou sem prova ficou em previsto.

Outros defeitos apareceram na conferência:

  • Data única: a planilha de abertura carimbou todo o realizado num só dia, e meses inteiros ficaram vazios. As datas foram refeitas, lançamento a lançamento, com o documento original.
  • Fuso: toISOString() devolve o dia em UTC, então uma venda de 31/07 às 22h10 virava receita de agosto. O bug voltou uma vez, na cópia da lógica que roda em Deno.
  • Checagem por descrição: uma migração procurava a linha pelo nome que ela mesma usava. Assim, duplicou um pagamento lançado à mão com outro texto. A checagem passou a usar data, valor e tipo.
  • Duplicatas ao recriar o banco: ao passar o banco para outro projeto, migrações rodaram sobre dados que já traziam o resultado delas. O criado_em mostrou a causa, porque as linhas erradas nasceram nos minutos dessa recriação.
  • Nome como chave: um preenchimento manual de dados misturou duas pessoas com o mesmo primeiro nome. O vínculo automático, por e-mail, não errou. Update manual agora usa id.

E tem um defeito ainda aberto. O comentário do webhook diz que, se ele discordar do sync, o sync ganha. O código não faz isso. O sync pula toda transação já lançada antes de olhar o status. Ele recupera venda perdida, mas não enxerga reembolso perdido, justamente o caso que motivou o webhook. Hoje, só a conciliação manual pegaria, quando a venda não aparecesse na carteira. O caso está registrado como teste pendente, até eu decidir o conserto.

Escrever os testes achou outro defeito, e esse é a tese deste case acontecendo no próprio painel. Um reembolso apagava a venda mesmo depois de conciliada, e o caixa deixava de bater com o extrato. Agora a venda fica, e o estorno vira uma saída separada em previsto, até um documento confirmar. Um índice único garante uma saída por venda, mesmo com reentrega do evento. Conferi o histórico, e nenhuma venda conciliada chegou a ser apagada. Até 03/11/26, nenhum reembolso real tinha passado pelo fluxo novo, testado só com dados fictícios. Por isso, o valor da saída é uma estimativa marcada para conferência, e o corte das métricas assume estorno total.

Definir a métrica

O que as métricas revelaram sobre o negócio fica de fora. Mostro como elas são definidas, que foi onde boa parte dos erros morava.

O corte do faturamento tem uma fonte só:

export const ORIGENS_RETIDAS = ["api-hotmart"];

export const ehVendaFeita = (l: Lancamento, hojeISO = hoje()) =>
  l.status === "realizado" ||
  (l.status === "previsto" && l.data <= hojeISO && ORIGENS_RETIDAS.includes(l.origem));

export const ehRetido = (l: Lancamento, hojeISO = hoje()) =>
  l.status === "previsto" && l.data <= hojeISO && ORIGENS_RETIDAS.includes(l.origem);

A primeira versão olhava só previsto e data passada. Só que o formulário deixa lançar previsão à mão com qualquer data, e uma venda combinada e não paga entraria como faturamento. Por isso o corte também olha a origem. Ela responde quem garante que esse dinheiro existe.

Com dados fictícios, a mesma lista dá estas três respostas:

Venda fictíciaOnde está o dinheiroSituaçãoValor (R$)FaturamentoA receberNa conta
Pix de marçosacadorealizado100simsim
Cartão de marçosacadorealizado200simsim
Pix de abrilna carteiraprevisto50simsim
Cartão de abrilgarantia vencidaprevisto300simsim
Pix de abrilem garantiaprevisto40simsim
Previsão à mão, data passadapromessaprevisto500
Venda reembolsadadevolvidona lixeira80
Total690390300

A receita por cliente segue a mesma regra e nunca vira um número só:

create or replace view vw_lead_receita
  with (security_invoker = true) as
select
  l.id as lead_id,
  count(f.id) filter (where f.status = 'realizado') as n_vendas_realizadas,
  count(f.id) filter (where f.status = 'previsto') as n_vendas_previstas,
  count(f.id) filter (where f.valor = 0) as n_sem_valor_brl,
  coalesce(sum(f.valor) filter (where f.status = 'realizado'), 0) as receita_realizada,
  coalesce(sum(f.valor) filter (where f.status = 'previsto'), 0) as receita_prevista,
  max(f.data) filter (where f.status = 'realizado') as ultima_venda_realizada,
  max(f.data) as ultima_venda
from crm_leads l
left join financeiro_lancamentos f
  on f.lead_id = l.id
 and f.deletado_em is null
 and f.tipo = 'entrada'
where l.deletado_em is null
group by l.id;

O ponto de equilíbrio teve a derivação invertida. O painel partia da meta e deduzia o custo fixo, o que fazia sentido enquanto o custo era estimativa. Quando o custo recorrente foi medido linha a linha contra extrato e fatura, ele virou a base do cálculo, e o break-even passou a sair dele.

A taxa da plataforma também deixou de ser chute. A parte fixa da cobrança pesa mais no ticket baixo, então a taxa muda de venda para venda. O painel agora usa uma média ponderada das vendas reais. E ela é um valor mínimo, já que a taxa de saque não aparece na API de vendas.

Verificação e governança

São 110 testes automatizados, sem rede e com dados fictícios, mais um pendente de propósito, o do reembolso perdido. Eles cobrem:

  • o corte das métricas e a leitura de valores em pt-BR;
  • a data da venda no fuso de Brasília;
  • a conversão de venda em lançamento;
  • as regras do webhook, como autenticação, soft delete e o estorno de venda já conciliada.

Um teste de paridade acusa quando as regras copiadas em Node e em Deno divergem.

Teste que passa sempre não prova nada. Por isso, 41 regras e 4 cópias foram quebradas de propósito, e os testes acusaram todas. O Supabase ali é falso, então RLS e constraints ficam fora dessa suíte, e o carrinho abandonado também não entrou.

O CI também roda typecheck, build e gitleaks. Uma vez por dia, um teste bate na produção com a chave pública e falha se conseguir ler ou escrever nas tabelas sensíveis que ele vigia. Lançamentos, leads e eventos ficam atrás de RLS só para admin, e as views usam security_invoker pra não furar essa política.

Um GRANT faltando ensinou outra coisa. Sem ele, três tabelas devolviam erro de permissão, e a tela mostrava "nenhum contato recebido". Foi cerca de um mês invisível. O painel passou a separar carregando, erro e vazio de verdade.

O que este painel não resolve

A escala é pequena. É um banco transacional, sem warehouse nem orquestrador, e o sync roda na mão. As métricas são calculadas em TypeScript, fora do banco, e o SQL versionado serve à aplicação, sem análise.

O código é privado, porque comentários e histórico trazem dados do negócio. Os trechos acima foram recortados, com comentários resumidos.

Onde olhar num painel desses: antes da soma, no que cada status promete. Pergunte quem garante que aquele dinheiro existe, e em qual documento dá pra conferir.

Feito com Claude Code: eu dirigi o projeto, tomei as decisões de modelagem e apontei os erros de semântica. O código, as migrações e o rascunho deste texto foram feitos com a ferramenta. Editei o texto linha a linha e assino.