Forge Finanças
SaaS de controle financeiro para quem tem CNPJ e vida pessoal ao mesmo tempo: PF e PJ no mesmo lugar, com o imposto tratado como conta a pagar e não como categoria.
- Em produção local, atrás de proxy
- 96 endpoints
- 714 testes
- 2FA + LGPD
- .NET 9
- C#
- EF Core 9
- PostgreSQL 17
- React 19
- TypeScript
- TanStack Query
- Tailwind CSS
- Hangfire
- OpenTelemetry
- Docker
- Caddy
- xUnit
- Vitest
O problema
Quem abre um MEI não deixa de ter vida pessoal no dia seguinte. O mercado de software financeiro, porém, insiste que sim: de um lado os aplicativos de finanças pessoais, que sabem quanto você gastou no mercado e não fazem ideia do que é um DAS; do outro os ERPs e sistemas de contabilidade, que sabem apurar imposto e tratam o aluguel da sua casa como se não existisse.
O micro-empresário brasileiro — MEI, autônomo, freelancer, a LTDA de duas pessoas — resolve isso com duas ferramentas e uma planilha de cola entre elas. E a pergunta que ele mais precisa responder não cabe em nenhuma das duas: “depois de pagar o imposto, quanto sobra pra mim?”
Este projeto ataca essa lacuna. O escopo é controle gerencial com apuração tributária embutida, para o CNPJ e para a pessoa, no mesmo lugar — sem a ambição de ERP nem a de contabilidade fiscal completa.
Uma nota sobre a origem: este SaaS foi construído com o Forge — a infraestrutura
de geração de projetos que abre este portfólio. É o track APP dela rodando inteiro, do
design.md ao container em produção.
Um household, dois perfis
A decisão que organiza o produto inteiro cabe numa frase: a conta é da casa, o lançamento é de
um perfil. Você tem um household — que pode ser você sozinho ou você e mais alguém — e dentro
dele cadastra perfis: um PF, e quantos PJ precisar, cada um com seu regime tributário (MEI,
Simples Nacional, Lucro Presumido, Autônomo).
Cada lançamento pode ser marcado para um perfil, e é isso que transforma a mesma base de dados em duas visões que não se contaminam: o que entra na apuração do DAS e o que entra no Carnê-Leão são recortes da mesma tabela, não bancos separados que precisam ser reconciliados na mão.
O mesmo cuidado aparece em detalhes pequenos do formulário: ao escolher “Cartão de Crédito” como tipo de conta, o campo deixa de se chamar “Saldo inicial” e passa a ser “Dívida inicial — quanto você já deve neste cartão”, e dois campos de ciclo aparecem. A interface sabe o domínio em vez de servir um formulário genérico com nomes de banco de dados.
O trabalho que ninguém quer fazer
Nenhum controle financeiro morre por falta de funcionalidade. Morre porque a pessoa parou de digitar. Então o caminho mais importante do produto é o que vai do extrato do banco até o lançamento categorizado sem passar pelo teclado.
O leitor foi escrito para extrato de banco brasileiro de verdade, não para o CSV ideal de um
tutorial: aceita ; ou , como separador decidindo pela contagem no cabeçalho, entende
dd/mm/aaaa e aaaa-mm-dd, engole o prefixo R$ e o ponto de milhar de 1.234,56, e encontra a
coluna certa mesmo que o banco a tenha chamado de “Histórico”, “Memo”, “Lançamento” ou
“Descripción” — a comparação é feita sem acento e sem caixa. Linha que não vira data e valor
válidos é descartada em silêncio, em vez de virar um lançamento errado no meio de duzentos certos.
Depois do import entra o motor de regras.
Na conta que montei para estes prints, nove regras classificaram os vinte e dois lançamentos
importados de uma vez — inclusive duas por expressão regular, uma para separar assinatura de
software (INTERNET FIBRA|ASSINATURA|FIGMA) e outra para reconhecer entrada de nota fiscal
(PIX RECEBIDO.*NF\s*\d+) e mandá-la para Serviços Prestados, que é a categoria que alimenta a
apuração.
A árvore de categorias que vem semeada já carrega a tese do produto: ao lado de Moradia, Alimentação e Saúde existem Custos Operacionais — com Contabilidade, Marketing e Software — e Impostos e Taxas, com Carnê-Leão e DAS / MEI como subcategorias de verdade. A separação PF e PJ não é um discurso da página de vendas; está no dado padrão que o usuário recebe no primeiro minuto.
Imposto não é categoria — é conta a pagar com data
Aqui está o que nenhum aplicativo de finanças pessoais faz e o que nenhum ERP barato faz bem.
O sistema calcula DAS-MEI (nas três naturezas: comércio, serviços e comércio com serviços), Carnê-Leão pela tabela progressiva e Simples Nacional pelos anexos III e V. E calcula com valores oficiais, não com aproximação: o salário mínimo de 2026 está fixado em R$ 1.621,00 com o decreto citado no próprio código (12.797/2025), e o teste do DAS-MEI de serviços afirma o resultado exato — R$ 86,05, decomposto em INSS de 5% sobre o mínimo (R$ 81,05) mais ISS de R$ 5,00. Trocar a tabela de um ano quebra o teste, que é exatamente o que se quer de uma regra que muda por decreto.
Calculado o valor, ele vira obrigação com vencimento, e daí sai o que mais parece útil no uso diário: um calendário tributário do ano inteiro.
Três coisas ficam claras nessa tela e nenhuma delas é óbvia de construir. A primeira é que existe um calendário de dias úteis brasileiro no sistema, com feriados nacionais, e ele é consultável por API própria. A segunda é que uma obrigação pode ser marcada como paga e tem uma guia DARF gerável em PDF. A terceira é que existe um job diário que avisa antes do vencimento — porque a multa por atraso do DAS não perdoa quem esqueceu.
O isolamento que não depende do WHERE
Multi-tenancy em SaaS financeiro tem um modo de falhar que acaba com a empresa: uma query esquece o filtro do inquilino e alguém vê o extrato de outra pessoa. A defesa usual — “todo mundo lembra de filtrar” — é uma promessa, não um mecanismo.
Aqui são duas camadas independentes, e nenhuma delas depende de o desenvolvedor lembrar.
FORCE importa: sem ele a política não vale para o dono da tabela, que é justamente a conexão da aplicação. E o valor entra na sessão por chamada parametrizada com escopo de transação — nunca por concatenação de string em SQL.Das 28 tabelas do schema, 19 estão sob FORCE ROW LEVEL SECURITY, com a política escrita em
USING e WITH CHECK — ou seja, ela impede tanto ler linha alheia quanto gravar linha com o
household de outro. Treze arquivos de teste de integração exercitam vazamento entre households, e
o arquivo dedicado a isso é um gate obrigatório da suíte.
O mesmo rigor vale para o dado mais sensível do cadastro. CPF e CNPJ são guardados com AES-256-GCM e chave derivada por household (HKDF, com o identificador do household como dado autenticado adicional), e ao lado do campo cifrado mora um HMAC-SHA256 do mesmo valor, que existe só para permitir busca sem descriptografar nada. A tela diz isso ao usuário no momento em que ele digita: “Armazenado criptografado (AES-GCM). Nunca exibido em claro após salvar.”
O que já é produto, e não protótipo
A diferença entre um app de portfólio e algo que dá para cobrar por está quase toda em coisas que ninguém demonstra numa tela bonita.
Estão prontos e testados: autenticação com segundo fator TOTP; planos com limites aplicados no servidor — ao tentar criar a quarta conta no plano gratuito, a API responde com recusa e a interface exibe um aviso com link de upgrade, em vez de estourar um erro cru; tokens de API pessoais e webhooks assinados com HMAC com política de retentativa, que é o que permite a alguém integrar o próprio sistema; exportação e exclusão de conta para LGPD, com anonimização; relatórios em PDF e XLSX gerados de forma assíncrona; e trilha de auditoria.
Três decisões de produto merecem destaque separado.
O token de acesso nunca toca o localStorage. Só o estado não-sensível é persistido no
navegador; o token vive em memória. É uma escolha que custa conveniência — recarregar a página pede
login de novo — e compra imunidade à classe inteira de ataque em que um XSS rouba a sessão. Descobri
isso na prática enquanto navegava para tirar os prints, o que é a melhor forma de confirmar que a
regra é real.
Nada além do proxy é exposto. Em produção o docker ps mostra uma única porta publicada, a do
Caddy. O backend .NET e o Postgres não têm porta mapeada para o host — só a rede interna do compose
os alcança. O mesmo Caddy serve a SPA e faz proxy da API no mesmo domínio, o que faz o cookie de
refresh SameSite=Strict funcionar e elimina CORS do caminho, e ainda aplica HSTS, nosniff,
X-Frame-Options e Referrer-Policy em toda resposta.
Existe um assistente por WhatsApp. Uma mensagem em português — “gastei 45 no mercado” — passa por um interpretador de intenção rodando em Claude Haiku e vira lançamento, consulta de saldo ou consulta de gastos, sempre escopada ao household do número vinculado, com verificação de vínculo. É o caminho de entrada mais curto que existe para o problema que mata este tipo de produto: a pessoa parar de registrar.
As decisões e o que cada uma custou
Nenhuma das escolhas abaixo é gratuita, e o projeto registra o preço de cada uma.
Banco compartilhado com RLS, não um schema por inquilino. Compra operação simples, uma migration só e custo por cliente baixo o bastante para um SaaS de ticket pequeno fechar a conta. O preço é que uma falha de política vaza dados — e é por isso que existe a segunda camada e o teste de vazamento como gate.
Dinheiro em NUMERIC(18,4) com a moeda ao lado, em vez de inteiro em centavos. Ler e exibir
sem conversão, e multimoeda desde o primeiro dia. O lado ruim são uns 10% a mais de espaço que um
BIGINT, que num sistema deste porte é troco.
Categorias em ltree. Árvore sem limite de profundidade, e a agregação de uma subárvore
inteira numa query só em vez de uma consulta por nível. Em troca, mudar um nó de pai vira
atualização em massa, e o projeto fica preso ao Postgres — uma dependência que já estava na conta
quando a decisão foi tomada.
CQRS com handlers explícitos por injeção, sem MediatR. Esta é a que costuma ser tomada por hábito: o MediatR passou a exigir licença comercial a partir da versão 12. Trocar a biblioteca por chamadas diretas custou escrever os comportamentos transversais à mão, como filtros de endpoint — e removeu do produto uma dependência que poderia virar uma fatura. De quebra, o caminho do código ficou literal: dá para ler do endpoint até o banco sem procurar quem assina o quê.
Cobrança com a maquinaria pronta e o meio de pagamento adiado. Os limites de plano já são aplicados e testados, e as tabelas de plano e assinatura já existem — plugar um gateway depois não migra dado. O preço é honesto e está dito: não há receita real até esse plug acontecer.
Estado
No ar num deploy de produção local, atrás de proxy e com migrations aplicadas no boot. A API expõe 96 endpoints em 71 caminhos, sobre 28 tabelas e 26 migrations do EF Core. São cerca de 45 mil linhas de C# no backend e 15 mil de TypeScript no frontend, com 518 métodos de teste em xUnit (unidade, integração e testes de arquitetura) e 196 casos em Vitest, além de suítes Playwright para os fluxos de autenticação e de lançamentos.
A visita guiada acima foi feita numa conta de teste populada do zero pela própria interface: dois perfis, três contas, um extrato de 22 linhas importado por CSV e nove regras de categorização — que é, com alguma sorte, o mesmo caminho que um usuário novo percorre na primeira hora.
Quer conversar sobre este projeto — ou sobre construir algo assim? Fale comigoou escreva para contato@daniel-dac.com.