Em produção: https://expenses.imp-software.cloud
Dashboard pessoal pra analisar o histórico de gastos do cartão de crédito. As faturas em CSV viram um banco de compras no MongoDB, uma API NestJS agrega isso por mês e por categoria, e um front em React mostra tabelas e gráficos: quanto você gastou, com o quê, e como isso evolui mês a mês.
O parser não é acoplado a nenhum emissor — lê qualquer CSV com as colunas date,title,amount.
Foi desenvolvido e testado com as faturas exportadas do Nubank.
Para retomar de onde a última sessão parou — estado, pendências e as armadilhas de ambiente que já custaram tempo aqui —, leia
HANDOFF.md.
faturas .csv MongoDB API (Nest) Front (React)
┌──────────────┐ ┌───────────┐ ┌─────────────┐ ┌───────────────┐
│ Google Drive │──extractor─▶│ purchases │◀───────────│ /purchase │◀────────│ tabela + │
│ ou ./bills │ ▲ │ (por │ │/purchase/bill│ │ gráficos │
└──────────────┘ │ │ dono) │ └─────────────┘ └───────────────┘
│ │ └───────────┘ ▲
│ ├───────────────────────── POST /sync ┤◀──────── botão "Sincronizar"
└──────────────┘ POST /import◀──────── tela "Importar"
(CSVs do navegador)
A ingestão tem três gatilhos e um caminho só: pnpm extract na linha de comando, o botão
Sincronizar e o upload de CSVs pela tela rodam o mesmo código — veja
Quando as faturas novas entram e
Importando faturas pela tela.
Cada conta vê só os próprios dados; o Drive é do dono da instância, e as demais importam CSV pela tela — Contas.
| Área | O que faz |
|---|---|
| Aterrissagem | A raiz decide pelo visitante: quem chega de fora vê uma página que diz o que o app faz e o que ele vai pedir (os CSVs das faturas); quem já tem sessão cai direto na Visão geral. Antes o link cru levava a uma caixa de usuário e senha sem contexto nenhum. |
| Contas | Cada conta vê só os próprios dados — compras, regras, categorias, assinaturas. O cadastro é por convite, porque a instância fica exposta na internet — detalhes. |
| Ingestão | Lê as faturas em CSV do Google Drive, de uma pasta local ou do upload pela tela, categoriza as compras e grava no MongoDB. Regravar uma fatura sobrescreve o mês inteiro — rodar de novo é idempotente. |
| Importar | Sobe vários CSVs de uma vez e passa pelo mesmo pipeline do Drive, com as mesmas garantias. O mês vem do nome do arquivo e, quando ele não diz, das datas de dentro — detalhes. É o caminho de quem não tem Drive configurado, que é todo mundo menos o dono da instância. |
| Sincronização | Um botão no cabeçalho pede a ingestão sem sair da tela, e diz quando foi a última e o que ela mexeu. Vale para as duas pontas: uma extração pela linha de comando ou pelo cron aparece ali igual, porque as duas gravam o mesmo registro — detalhes. |
| Categorização | Uma escada de precedência (detalhada abaixo) que termina em outros. No topo dela ficam as suas regras; embaixo, a categoria do CSV, a herança por título e as palavras-chave. Códigos internos do emissor viram rótulos do domínio: reversal_* → estorno, tax_* → impostos, bnpl_* → parcelado. |
| Classificação | Você cria suas categorias e diz a que categoria cada estabelecimento pertence. A regra vale para todas as compras dele, passadas e futuras, e sobrevive ao reprocessamento. Reclassificar acontece em dois lugares: na tela Sem categoria, que lista o que está em outros do que mais pesa para o que menos pesa, e direto na coluna Categoria da tabela de Compras. |
| Compras | Lista filtrável por categoria, título (busca parcial) e mês da fatura, com total, quantidade e ticket médio. Filtro, ordenação, paginação e os agregados dos painéis acontecem no servidor — os painéis descrevem o filtro inteiro, e a tabela mostra uma página dele. No desktop, a própria tabela rola dentro de uma caixa com altura limitada, e o cabeçalho de colunas fica preso no topo dela — não da página —, então nunca some por trás do que vem antes na tela. Uma parcela já lançada numa fatura futura ganha o rótulo futura ao lado da data: a ordenação por data continua correta, mas o topo da lista deixa de parecer "a compra mais recente" por engano. |
| Gráficos | Gasto por mês e por categoria, em barras, acompanhando os filtros aplicados. |
| Visão geral | A home: última fatura fechada com a variação contra o mês anterior, média dos doze meses anteriores, total do ano e a composição do mês. O recorte de "fechada" é o fim do ciclo de compras, não o mês do vencimento: o que ainda não fechou — o ciclo em aberto e as parcelas lançadas à frente — aparece à parte, fora dos agregados. O cartão Fora do normal compara cada categoria contra o próprio histórico — como. O cartão Mudou de preço traz os reajustes recentes de assinatura, quando existem — como. |
| Faturas | Uma linha por mês de referência: valor pago, total gasto, número de compras e o percentual de cada categoria no mês, com o fundo da célula proporcional ao peso. As colunas de categoria saem dos próprios dados — categoria nova ganha coluna sozinha. Meses com juros ou multa vêm marcados, e o valor aparece à parte do gasto. |
| Encargo ≠ gasto | Juros, multa e saldo rolado saem do total gasto e ganham linha própria. Somá-los respondia "quanto você gastou" com dinheiro que ninguém gastou — detalhes abaixo. |
| Assinaturas | Detecta as cobranças que se repetem todo mês com preço estável e mostra a escada de preços de cada uma: quando mudou, de quanto para quanto. É o que o app do banco não faz, porque depende de anos de série contínua — como funciona. Cada uma abre num painel com o gráfico da evolução do preço, e você pode dar a ela um nome formal — Mp *Melimais vira Meli+ — que sobrevive à troca de gateway. |
| Regras | Lista todas as decisões de classificação que você tomou, com quantas compras cada uma governa de fato. O destino de uma regra se muda direto na lista, e dá para criar uma nova digitando o trecho à mão — as duas coisas reaproveitam o mesmo POST que já existia. A tela também aponta onde um punhado de regras de título exato viraria uma só por trecho, dizendo o que essa troca levaria junto — critério. |
| API | REST documentada em OpenAPI/Swagger, com validação dos filtros. |
Sobre "outros". Em julho de 2024 o emissor parou de classificar e passou a carimbar
outrosem quase tudo. O projeto trataoutroscomo "não sei", e não como categoria, o que religa a inferência — mas inferência tem limite, e é para o resto que existem as regras.
Requisitos: Node ≥ 22, pnpm 10 (via corepack enable), Docker.
Use
pnpmdireto, nãocorepack pnpm. OpackageManagerdeste repo está fixado em 10.15.0. Chamado viacorepack, um pnpm 11 instalado na máquina se recusa a trocar de versão e os scripts com--filterquebram comERR_PNPM_RECURSIVE_RUN_FIRST_FAIL. Opnpmdireto respeita opackageManagere troca de versão sozinho.
# 1. Dependências
pnpm install
# 2. Variáveis de ambiente
cp .env.example .env
# 3. Banco (MongoDB no Docker)
docker compose up -d
# 4. Dados de exemplo — 18 meses de faturas fictícias, pra app abrir com gráficos
pnpm db:seed
# 5. API + front, com hot-reload
pnpm dev- Front: http://localhost:5173
- API: http://localhost:3000 — documentação em http://localhost:3000/docs
- Health check: http://localhost:3000/health
Com os dados de exemplo você já tem a app inteira funcionando. Para usar as suas faturas, veja Carregando suas faturas.
Porta ocupada? Os dois servidores falham em vez de escolher outra porta sozinhos. Se a 3000 estiver tomada, mude
PORTno.enve ajusteVITE_API_URLpara a mesma porta; se for a 5173, quase sempre é umpnpm devque ficou de pé —lsof -nP -iTCP:5173 -sTCP:LISTENmostra qual. Falhar aqui é deliberado: com o fallback ligado, um segundopnpm devsubia na 5174 enquanto a 5173 continuava servida pelo processo antigo, e você acabava testando contra o servidor errado.
Tudo mora num .env único na raiz — os três apps leem dele (a API e o extractor por caminho
explícito, o front pelo envDir do Vite). Copie de .env.example.
| Variável | Usada por | Padrão | Para que serve |
|---|---|---|---|
MONGO_URI |
api, extractor | mongodb://localhost:27017/credit-card |
Conexão com o Mongo. O nome do banco vai na URI — é dele que os dois apps leem. Troque por uma connection string do Atlas se preferir a nuvem. |
PORT |
api | 3000 |
Porta HTTP da API. |
CORS_ORIGIN |
api | http://localhost:5173 |
Origens liberadas no CORS, separadas por vírgula. Vazio libera todas (só em dev). |
INVITE_CODE |
api | — | O código que libera o cadastro. Sem ele a API não sobe. Veja Contas. Segredo. |
OWNER_USERNAME |
api, extractor | AUTH_USERNAME |
A conta dona da instância: a única para quem /sync e o cron do Drive existem. |
AUTH_USERNAME |
migração | — | Só para migrate:multiuser, que cria a conta dona a partir dele. Veja Contas. |
AUTH_PASSWORD_HASH |
migração | — | Idem: o hash bcrypt que a conta dona herda, para o login não mudar. Gerado por pnpm --filter @expense/api hash-password. Segredo. |
SESSION_SECRET |
api | — | Assina o cookie de sessão. String aleatória longa; trocar derruba toda sessão aberta. Segredo. |
VITE_API_URL |
web | http://localhost:3000 |
Base da API usada pelo front. Precisa do prefixo VITE_ pra chegar no bundle. |
APP_URL |
api | http://localhost:5173 |
Endereço em que a pessoa abre o app — é a base do link de redefinição de senha, e não a URL da API. |
MAIL_ENV_FILE |
api | — | Arquivo com RESEND_API_KEY e EMAIL_FROM, lido na subida. Sem ele o link de redefinição vai para o log em vez do e-mail — veja Contas. |
EXTRACTOR_SOURCE |
extractor, api | drive |
De onde vêm as faturas: drive (Google Drive) ou local (pasta). |
BILLS_DIR |
extractor, api | ./bills |
Fonte local: diretório com os CSVs. |
DRIVE_FILE_QUERY |
extractor, api | name contains 'nubank' |
Fonte drive: filtro de busca (sintaxe da Drive API v3). |
GOOGLE_CREDENTIALS_PATH |
extractor, api | ./apps/extractor/drive-credentials.json |
Fonte drive: OAuth client baixado do Google Cloud Console. Segredo. |
GOOGLE_TOKEN_PATH |
extractor, api | ./apps/extractor/token.json |
Fonte drive: refresh token gerado no primeiro login. Segredo. |
As cinco de baixo valem para os dois porque o botão Sincronizar faz a API rodar a mesma ingestão
que o pnpm extract — detalhes. Elas precisam ter o mesmo valor
nos dois lados, ou o botão sincronizaria de uma fonte e o cron de outra; em produção isso é garantido
por um .env.prod único, lido pelos dois serviços. A API só lê o token.json; quem o cria é o
pnpm extract.
.env, drive-credentials.json e token.json estão no .gitignore — nenhum deles vai pro
repositório.
Cada conta vê só os próprios dados: compras, regras, categorias, apelidos de assinatura e
histórico de sincronização. O recorte não é uma convenção de código — todo documento carrega o _id
do dono, todo índice começa por ele, e todo método de serviço recebe o dono como primeiro parâmetro,
de forma que uma consulta nova não tenha como esquecê-lo. Um teste de integração
com duas contas percorre todas as rotas conferindo que nenhuma enxerga a outra.
O cadastro é por convite. A tela de login tem um "Criar conta" que pede usuário, senha e um
código, e esse código é INVITE_CODE no .env do servidor — sem ele a API se recusa a subir. A
razão é o endereço: uma instância exposta na internet sem barreira nenhuma vira cadastro aberto para
qualquer robô que ache a URL.
# O convite: qualquer string longa serve. Combine com quem você quer que entre.
openssl rand -hex 16Além dele, gere um SESSION_SECRET (openssl rand -hex 32). A sessão é um cookie httpOnly,
guardado no próprio Mongo (connect-mongo, coleção sessions) — e não em memória, porque a API sobe
com nest start --watch: cada salvamento reiniciaria o processo, e uma sessão em memória cairia
junto. Toda rota exige sessão, exceto /auth/register, /auth/login, /auth/session e /health.
Duas portas, porque são dois problemas diferentes:
- Trocar (
/conta, logado): pede a senha atual antes da nova. Não é burocracia — sem isso, um notebook desbloqueado por dois minutos basta para trocar a senha e expulsar o dono da própria conta, já que a sessão sozinha seria autorização suficiente. - Recuperar (
/esqueci): manda um link por e-mail, válido por uma hora e de uso único.
As duas derrubam as outras sessões da conta — a de quem está trocando continua. É a metade que faz a troca significar alguma coisa: sem ela, quem já estava dentro com a senha antiga continua dentro, e trocar a senha depois de ela vazar é teatro.
O que o servidor guarda de um pedido de redefinição é o SHA-256 do token, nunca o token: o que
vai no link são 32 bytes aleatórios, e um dump do banco — ou um backup no R2 — não abre a conta de
ninguém. POST /auth/forgot-password responde 204 sempre, exista ou não a conta; uma resposta
diferente para endereço conhecido e desconhecido transformaria a rota num oráculo de quem tem conta
aqui. É por isso que a tela diz "se houver uma conta com esse e-mail" em vez de confirmar.
O envio é pelo Resend, com as credenciais em MAIL_ENV_FILE — em produção, o mesmo arquivo que os
alertas de cron da VPS já usam, montado no container em vez de copiado para o .env.prod. Sem
credencial configurada nada quebra: o link é escrito no log da API. É o que mantém o fluxo inteiro
exercitável em desenvolvimento, e é assim que os testes o percorrem.
Contas criadas antes do campo de e-mail não têm endereço e não podem ser recuperadas — a tela de conta avisa. Para dar um endereço a elas:
pnpm --filter @expense/api set-email <usuario> <email>GET /auth/session responde quem está logado e se essa conta é a dona da instância
(OWNER_USERNAME). Só ela vê o botão Sincronizar, e só para ela /sync responde: a
sincronização lê as faturas de um Google Drive cujas credenciais OAuth estão no servidor e são de uma
conta Google só. As demais sobem as faturas em CSV pela tela de Importar.
Antes as credenciais moravam em AUTH_USERNAME e AUTH_PASSWORD_HASH, e nenhum documento tinha
dono. Como agora toda consulta filtra por dono, um documento sem dono não aparece para ninguém —
sem migrar, a app abriria vazia com os anos de fatura intactos no banco.
pnpm --filter @expense/api migrate:multiuserO script cria a conta dona a partir de AUTH_USERNAME + AUTH_PASSWORD_HASH (reaproveitando o hash,
então seu login não muda), carimba userId em todos os documentos das seis coleções e derruba os
índices únicos globais, que na versão multiusuário recusariam do segundo usuário a categoria que o
primeiro já tem. É idempotente e não apaga documento nenhum. Suba a API depois — o Mongoose cria os
índices compostos sozinho.
POST /import recebe vários CSVs de uma vez, e a tela Importar é a interface dele. É o caminho de
quem não tem o Google Drive configurado — na prática, qualquer conta que não seja a dona da
instância.
O que ele faz é o que a extração já fazia, pelo mesmo código: os arquivos viram faturas pelo mesmo
parser da fonte local e vão para o mesmo ingestBills que o Drive dispara. De onde saem, de graça,
as duas garantias que importam — reenviar um mês sobrescreve em vez de duplicar, e as suas regras
são reaplicadas depois da gravação, então importar de novo nunca desfaz o que você classificou.
O mês de referência sai do nome do arquivo (nubank-2026-03.csv), como sempre. Quando o nome não
traz AAAA-MM — e quase nenhum arquivo baixado do app do banco traz —, ele é deduzido pelas datas
de dentro do arquivo: o mês em que caiu a maior parte das compras, e não a mais antiga, porque toda
fatura tem lançamentos do fim do mês anterior. A resposta diz, arquivo por arquivo, qual mês valeu e
como foi decidido, justamente para você poder discordar e reenviar com o nome certo.
Mande o histórico inteiro numa leva só quando puder: a memória de categorização é compartilhada entre as faturas de uma mesma chamada, então um mês sem categoria herda do mês que tem — e isso não atravessa importações separadas.
Cada importação vira um registro em syncRuns com trigger: 'upload', ao lado dos do botão e do
cron. É o que faz a pergunta "quando isto foi atualizado?" ter resposta também para quem nunca vai
usar o Drive.
As faturas precisam estar em CSV com o cabeçalho date,category,title,amount (a coluna category
é opcional) e o nome do arquivo precisa conter o mês de referência: nubank-2024-03.csv.
Detalhes e exemplo em bills/README.md.
As duas opções abaixo são do dono da instância, que roda o extractor na máquina onde as credenciais do Drive e a pasta de faturas existem. As demais contas sobem os arquivos pela tela — Importando faturas pela tela —, e por ali o nome do arquivo é uma preferência, não uma exigência.
# no .env
EXTRACTOR_SOURCE=local
BILLS_DIR=./billsJogue os CSVs em bills/ e rode:
pnpm extractÚtil se você já guarda as faturas no Drive e quer sincronizar sem baixar nada.
- No Google Cloud Console, crie um projeto e habilite a Google Drive API.
- Em APIs & Services → Credentials, crie um OAuth client ID do tipo Desktop app e baixe o JSON.
- Salve como
apps/extractor/drive-credentials.json(há um exemplo do formato). - No
.env, deixeEXTRACTOR_SOURCE=drivee ajusteDRIVE_FILE_QUERYse seus arquivos não têm "nubank" no nome. - Rode
pnpm extract. Na primeira vez abre o navegador pra autorizar; o refresh token fica salvo emapps/extractor/token.json.
Se der erro de autenticação depois de muito tempo sem rodar, apague
token.jsone autorize de novo.
Rodar pnpm extract de novo apaga e regrava o mês inteiro de cada fatura lida. Isso é de
propósito: uma fatura corrigida sobrescreve a antiga sem deixar resto. O que você classificou não se
perde nisso porque não mora na compra — mora em categoryRules, fora do alcance do apagão, e é
reaplicado no fim de cada execução.
pnpm extract
├─ lê as faturas e regrava mês a mês ← a categoria volta a ser a da ingestão
├─ preenche `sourceCategory` onde faltava ← uma vez só, nas compras gravadas antes do campo
└─ reaplica as suas regras ← a categoria volta a ser a que você decidiu
O último passo é o mesmo código que a API roda quando você cria ou apaga uma regra na tela, então as duas rotas não têm como divergir.
Nunca sozinhas: a app não fica de olho na fonte. Baixar uma fatura no Drive não muda nada até alguém pedir a ingestão, e há três formas de pedir.
| Gatilho | Como | Quando usar |
|---|---|---|
| Botão Sincronizar | No cabeçalho da app, em qualquer tela | Você acabou de colocar uma fatura na fonte e quer vê-la agora |
pnpm extract |
Linha de comando | Desenvolvimento, ou a primeira execução — é ela que gera o token.json do Drive |
| Cron da VPS | docker compose -f docker-compose.prod.yml run --rm extractor num timer |
Deixar a base em dia sem depender de lembrar |
Os três rodam o mesmo código — @expense/ingestion — e gravam o mesmo registro de execução, na
coleção syncRuns. É por isso que a tela mostra "sincronizado há 3 h" mesmo quando quem sincronizou
foi o cron às 07:00: se cada gatilho tivesse o seu próprio rastro, uma extração automática apareceria
como "nunca sincronizado" e o botão viraria um convite a refazer trabalho já feito.
O que o botão faz por dentro:
POST /sync ← responde 202 na hora, sem esperar a extração
└─ em segundo plano: lê as faturas, regrava mês a mês, reaplica as regras
└─ grava o desfecho em `syncRuns` ← status, contagens, e o relato linha a linha
GET /sync ← a tela pergunta de 2 em 2 segundos enquanto roda
A resposta sai antes de a extração terminar de propósito: ler 95 faturas do Drive passa de um minuto, e segurar a conexão por esse tempo entregaria a decisão a um timeout de proxy — a extração continuaria rodando e o navegador mostraria erro. Um segundo pedido enquanto o primeiro roda recebe 409.
Três detalhes que valem saber:
- A tela não recarrega sozinha no fim. O popover oferece "Atualizar a tela" e espera você clicar: as telas guardam o próprio estado, e um recarregamento automático no meio da tela Sem categoria jogaria fora a regra que você estava montando.
- O consentimento do Google não acontece pela API. Ela lê o
token.json, nunca o cria — não há navegador para abrir num container. Sem o token, a sincronização falha na hora com o texto dizendo para rodarpnpm extractuma vez numa máquina com navegador e copiar o arquivo. Antes disso ser explícito, a requisição ficava pendurada até o timeout sem dizer por quê. - Uma execução que morreu no meio destrava sozinha. O registro em
runningé o que barra a segunda ingestão; passados 30 minutos ele é dado como interrompido na próxima leitura, senão um container derrubado deixaria o botão travado até alguém editar o banco na mão.
Da maior prioridade para a menor. A primeira que responde decide:
| # | Origem | Por quê nessa posição |
|---|---|---|
| 1 | payment |
Não é categoria: é o pagamento da fatura. É a única coisa que nenhuma regra alcança, nos dois sentidos — uma regra que trouxesse um pagamento para "casa" somaria a fatura inteira como se fosse consumo. |
| 2 | A sua regra | É o ponto de discordar do emissor. Ganha até da categoria que veio no CSV: se você disse que Mercadolivre*Mercadol é mercado livre, não interessa que a fatura diga "eletrônicos". |
| 3 | Código interno do emissor (reversal_*, tax_*, bnpl_*) |
Traduzido para estorno, impostos e parcelado. São rótulos comuns: entram no total como qualquer categoria e você pode reclassificá-los. |
| 4 | Categoria do CSV, quando diz alguma coisa | outros não diz. |
| 5 | Herança por título | O mesmo título já categorizado em outra fatura. Vence a categoria mais frequente; no empate, a mais recente. |
| 6 | Palavra-chave (uber → transporte, ifood → restaurante, saldo em atraso → encargos) |
O piso para uma base nova não começar inteira em outros. Regras contains cobrem o mesmo terreno sem mexer no código. |
| 7 | outros |
Vira item da tela Sem categoria. |
estorno,impostoseparceladojá foram intocáveis, e não deviam ser. O argumento era que uma regra apontando para eles quebraria o total do mês — não quebra: os três somam como qualquer categoria, e relabelar muda a composição, não o total. Quem quebra o total épayment, que fica de fora dele. O preço da proteção a mais era concreto: as compras vindas debnpl_*ficavam presas emparcelado— que diz como se pagou, não onde se gastou — e não havia como mandar um "IOF de compra internacional" paraimpostos.
Duas categorias ficam fora do total gasto:
payment— o pagamento da fatura. Nunca foi gasto.encargos— juros, multa, saldo rolado e o IOF que o atraso gera. É o custo de financiar, não consumo. Somá-los respondia "quanto você gastou" com dinheiro que ninguém gastou: no histórico de referência, um únicoSaldo em atrasode R$ 10.023 em três linhas pesava mais que qualquer compra do ano, escondido dentro deoutros. Em um dos meses, o encargo era maior que o gasto.
Ficar fora do total não é sumir: /purchase/bill devolve charges por mês, a tela de Faturas marca
os meses que tiveram encargo, e GET /purchase?category=encargos lista os lançamentos. Só payment
é invisível de verdade.
O IOF de uma compra internacional não é encargo — é imposto sobre um gasto que aconteceu, e
continua em impostos, dentro do total.
Encargo é detectado pelo título, o que erra às vezes: uma "anuidade" pode ser mensalidade de academia,
que é gasto de verdade. Por isso encargos é categoria comum, e uma regra sua tira a compra de lá.
O outro lado dessa moeda: uma regra larga demais pode arrastar encargos de volta para o total sem
querer — contains "IOF de" pega também o "IOF de atraso".
A lista de palavras-chave de encargo é a única que a reaplicação redecide, em vez de herdar da
ingestão. É a única inferência por título que muda quanto se gastou, e não só como o gasto se
reparte — mantê-la congelada significava que corrigir a lista só valia a partir do próximo
pnpm extract, inalcançável para quem não tem mais os CSVs. Agora POST /category-rule/reapply
aplica a lista de agora ao que já está no banco, nos dois sentidos: o título que a lista passou a
reconhecer entra em encargos, e o que ela deixou de reconhecer sai de lá e volta para a inferência
normal. A resposta traz financing com quantas compras mudaram por esse caminho, separado de
classified, justamente porque o total do mês muda.
Isso funciona porque nada além da palavra-chave produz encargos: o emissor não emite essa
categoria e nenhum alias aponta para ela. Então, para um lançamento que a ingestão pôs ali, refazer a
inferência pelo título é exatamente o que a ingestão faria hoje. As outras palavras-chave continuam
congeladas em sourceCategory de propósito — lá também moram a categoria que o emissor mandou e a
memória por título, que uma palavra-chave genérica não deve atropelar.
Uma regra é um par (como casar, para qual categoria):
exact— o título inteiro. É o que nasce de um clique na tabela de Compras: você apontou uma compra, não descreveu um padrão.contains— um trecho do título. É o que resolve o mesmo estabelecimento chegando em várias formas. O emissor numera parcelas no próprio título (Amazon - Parcela 2/3), então uma compra parcelada em cinco chega como cinco estabelecimentos diferentes; e a caixa alterna entre meses (Mercadolivre*Mercadol,MERCADOLIVRE*MERCADOL).
Entre regras, ganha a mais específica: exact passa na frente de contains, e entre dois contains
vence o trecho mais longo. No empate, a mais recente — se você reclassificou algo hoje, é porque a
classificação de antes não servia mais. O casamento ignora caixa e acento, e nunca é expressão
regular: título de fatura é cheio de * e +, e Mercadolivre*Mercadol como regex casaria com
coisa que não tem nada a ver.
category é a que vale; sourceCategory é a que a ingestão resolveu, antes de qualquer regra sua.
Guardar as duas é o que torna a classificação reversível: apagar uma regra devolve as compras à
sourceCategory. Sem o original ao lado, a categoria que a regra carimbou ficaria grudada para
sempre. Reaplicar é sempre sourceCategory mais as regras de agora — nunca o que estava gravado
antes —, o que também faz a operação ser idempotente e a ordem entre pnpm extract e uma mudança de
regra não importar.
"Restaurante: R$ 359" não diz se é muito. O sistema descrevia sem comparar, e três telas de leitura
não davam uma decisão. O cartão da Visão geral compara cada categoria da última fatura contra o
próprio histórico — e o normal de cada uma é diferente: na base de referência a Academia varia 7%
ao mês e lazer varia 94%, então os mesmos "+40%" significam coisas opostas nas duas.
A régua não é percentual. Um corte de "40% acima da média" acusava quatro categorias num mês da
base, e duas eram oscilação normal: serviços a −49% estava a 0,7 desvio do seu padrão, e Bebidas
a −51% estava a 0,4. A Amazon, no mesmo mês, estava a 13,6 desvios. O que decide é o desvio
relativo à variação da própria categoria; o percentual só aparece na tela, para ser lido.
| Regra | Por quê |
|---|---|
| Referência é a mediana dos 12 meses anteriores | Uma viagem de R$ 2.600 num mês levantaria a média de transporte pelo ano inteiro e esconderia justamente o mês em que o gasto fugiu. |
| Dispersão medida pelo desvio absoluto mediano | Pelo mesmo motivo: o desvio-padrão é inflado pelo próprio pico que se quer detectar. |
| ≥ 2,25 desvios | Calibrado sobre 24 meses. Era 2,5, medido sobre doze meses de uma série que atrasava um ciclo — ver abaixo. |
| ≥ R$ 150 de diferença | Percentual mente na escala pequena — uma categoria de R$ 12 que vai a R$ 30 subiu 150% e não mudou nada. |
| ≥ 6 dos 12 meses com gasto | Quem aparece em quatro meses não tem "normal", tem esporadicidade, e compará-la geraria alarme a cada compra. Corta viagem (1/12), eletrônicos (1/12), Shein (2/12), Carro e casa (4/12). |
outros fica fora |
A categoria de fallback não descreve consumo: oscila entre 0,7% e 18,1% do mês na base de referência, e o que move isso é quanto você classificou. Os quatro alertas que ela gerava em 24 meses diziam "R$ 540 em não-classificado contra R$ 12 de normal" — notícia sobre a fila de Sem categoria, e inacionável: não dá para cortar outros. |
O mês em que a categoria não aparece conta como zero, e isso é o ponto: deixar de gastar é tão
informativo quanto gastar demais. Na base de referência, Combustível acendeu dois meses seguidos
por ter ido a zero.
O desvio em si não vai para a tela. Uma categoria muito previsível tem dispersão minúscula, e aí um mês fora da curva dá z=47 — matematicamente certo e ilegível. Quem lê quer reais e percentual.
O 2,5 foi calibrado sobre doze meses de uma série errada: o recorte de "fatura fechada" atrasava um ciclo, então o mês avaliado e a calibração vinham ambos do ciclo anterior. Refeita a conta sobre 24 meses da série corrigida:
| corte | alertas/mês | meses calados | outros em 24 meses |
|---|---|---|---|
| 3,00 | 1,1 | 9 de 24 | 3 |
| 2,50 | 1,3 | 8 de 24 | 4 |
| 2,25 | 1,6 | 6 de 24 | 4 |
| 2,00 | 1,8 | 5 de 24 | 6 |
A 2,5 o cartão ficava mais quieto do que se pretendia — a mira era ~1,7 alerta por mês — e a um custo
concreto: silenciava Mercado Livre a R$ 1.392 acima do normal (z=2,44), supermercado a +R$ 584
(z=2,43) e restaurante a +R$ 357 (z=2,35). Os seis alertas que 2,25 acrescenta em 24 meses são todos
gasto real, e nenhum é outros. Descer para 2,0 acrescenta seis e dobra a presença de outros — que
agora está fora de todo jeito.
Seis meses calados em 24 é a propriedade que se queria preservar: "nada fugiu do normal" continua sendo uma resposta, não uma falha.
O que 2,25 continua silenciando, e certo: Mercado Livre a +R$ 573 num mês em que isso é 0,73
desvio — grande em reais e dentro do normal dele. Esse número está na Composição do mês, ao lado,
que é onde se lê tamanho. Este cartão responde outra pergunta.
- Mudança de patamar acende por meses seguidos. Na base de referência,
lazersaiu de ~R$ 100 para ~R$ 400 e ficou: apareceu cinco meses consecutivos, porque em cada um deles ele estava acima da mediana dos doze anteriores. É verdade, mas deixa de ser notícia — a mediana só absorve o novo nível depois de alguns meses. - Categoria nova nunca aparece. Sem histórico não há expectativa, então uma categoria que surge do nada fica de fora por definição, por maior que seja. Ela está na composição do mês, ao lado.
- O ciclo em aberto não é comparado. A comparação só olha fatura fechada, então o consumo das últimas semanas fica de fora até o ciclo virar — é a escolha certa (metade de um mês contra doze meses inteiros acusaria queda em tudo), mas significa que a notícia chega uma vez por ciclo, não quando a compra acontece.
- O corte é uma linha, e perto dela ainda sobra dinheiro. Depois de descer para 2,25, o maior
gasto silenciado que ainda tem algum sinal é
restaurantea +R$ 490 num mês (z=1,94) eAmazona +R$ 450 (z=2,07). Qualquer limiar tem essa borda — descer até pegá-los trariaoutrose oscilação de restaurante todo mês. - Categoria que fica errática deixa de acender.
Mercado Livreacendeu a z=25,3 em out/25 e a z=8,9 em nov/25; em jun/26, com +R$ 1.392, chegou só a z=2,44. Os próprios picos alargaram o "normal" dela — a mediana e o desvio absoluto se adaptam, e essa é a intenção, mas o efeito é que quem gasta de forma cada vez mais irregular vira difícil de alertar.
O que define recorrência aqui é o patamar de preço, não a cadência. Cadência sozinha não
distingue nada: Uber *Uber *Trip aparece 43 meses seguidos com 43 valores diferentes, e a Netflix
aparece 52 meses com três. Uma assinatura é uma série feita de poucos preços longos, e um degrau
é a passagem de um patamar para o outro.
Antes de agrupar, o prefixo do intermediário sai do título. O mesmo Spotify chegou como
Ebanx *Spotify, Ebw*Spotify, Ebn *Spotify e Dm *Spotify conforme o gateway mudou de nome ao
longo dos anos — oito formas para uma assinatura só. Sem juntá-las, a série se parte em oito pedaços
curtos e a escada de 2019 a 2026 (R$ 8,50 → R$ 23,90, com uma promoção de R$ 9,90 que durou dez meses
e voltou) simplesmente não existe.
Passa por assinatura quem cumpre tudo isto:
| Regra | Por quê |
|---|---|
| ≥ 6 cobranças em ≥ 6 meses distintos | Abaixo disso qualquer coisa parece padrão. |
| ≥ 3 cobranças por patamar, na média | É a regra que decide. Comercial Ovolar tem 45 compras quase mensais, mas oscilando entre R$ 21 e R$ 28: 21 patamares, média 2,1. A Netflix tem 52 cobranças em 3 patamares, média 17,3. |
| ≤ 1,5 cobrança por mês | Assinatura cobra uma vez. Tira o Ifood *Ifd*Dominos P, que alterna entre dois preços de promoção 24 vezes em 9 meses. Não é 1,0 exato porque a data desliza entre o fim de um mês e o começo do outro. |
| Valor positivo, sem sufixo de parcela | Estorno não é preço. E uma compra dividida em dez é dez cobranças mensais idênticas — a assinatura mais convincente que existe, e não é uma. |
O preço vigente é o último patamar que se repetiu, e o anterior também precisa ter se repetido. Um lançamento solitário não é preço: sem essa regra, uma taxa avulsa de R$ 9,90 antes da mensalidade de R$ 149,90 fazia a tela anunciar um reajuste de +1414%, que seria o maior número da página e não quer dizer nada.
- Parcelamento sem o sufixo.
Casasbahia.C*287604502são nove parcelas de R$ 183,84 que o emissor não numerou no título. É indistinguível de uma assinatura cancelada, e aparece como tal. Fica entre as encerradas, longe do topo da lista. - Assinatura de valor irregular escapa.
Google One(mensal de R$ 12,50 misturado com anual de R$ 149,90, mais um estorno no meio) e oiFood Club(escada 4,95 → 6,99 → 9,98 → 12,90, com só uma cobrança em cada degrau) não passam na média de 3. Afrouxar o corte para pegá-las traz junto todo fornecedor de preço oscilante, e o custo de um falso positivo — anunciar reajuste onde não houve — é maior que o de perder uma linha. - Troca de plano vira percentual sem sentido.
Google Storagesaiu de R$ 6,99 por mês para R$ 69,99 por ano: os dois valores são reais, e o+901%entre eles não significa nada. - O mesmo lugar sob nomes diferentes conta duas vezes.
Google YoutubeeDl*Google Youtubaparecem separados. Fundir por prefixo resolveria esse caso e quebraria outros —Casa de Paes FariaeCasa de Paes Faria Lj2são filiais distintas —, e na base de referência havia só nove pares desses. Não compensou.
A tela de Assinaturas mostra a escada inteira de cada uma, mas exige ir olhar. O cartão Mudou de preço da Visão geral traz só o que é notícia agora: os degraus que caíram dentro dos três ciclos fechados mais recentes. Um reajuste de seis meses atrás já foi visto, não é mais aviso — está na escada, não no cartão.
O corte usa o fim do ciclo, pelo mesmo motivo do resto da Visão geral: month nomeia o vencimento, e
o consumo vem do mês anterior. A fronteira é o fim do ciclo anterior à janela de três, para os
três ciclos entrarem inteiros em vez de o mais antigo entrar pela metade. Sem histórico suficiente
para recuar três ciclos — uma base nova — a janela vira "tudo o que existe": é o caso em que todo
degrau ainda é notícia, e o contrário silenciaria a tela justamente para quem acabou de chegar.
Assinatura encerrada fica fora mesmo com reajuste no meio do caminho: um degrau em algo que não se
paga mais não é decisão a tomar. E a ordem é pela mordida anual — (atual − anterior) × 12 —, não
pela data: um reajuste de R$ 2 por mês é R$ 24 no ano, e não compete com um de R$ 60. Percentual
sozinho não diz isso; +8,8% pode ser R$ 7 ou R$ 700, dependendo do que ele é 8,8% de.
O cartão some quando não há nada a dizer — um aviso que aparece todo dia deixa de ser aviso. Medido sobre a base de referência em 2026-07-28: das 6 assinaturas ativas, uma teve degrau nos três ciclos (Barbearia Sr Jhon, +8,8%, +R$ 84/ano).
O cartão exige abrir a tela — mas a mesma lista existe como rota própria,
GET /purchase/price-alerts, para quem quer perguntar sem abrir nada:
um cron pessoal, um atalho de celular. A API não manda notificação sozinha, só responde "o que
mudou" de um jeito que dá para plugar em qualquer canal depois.
Monorepo pnpm workspaces + Turborepo, TypeScript em tudo.
apps/
api/ @expense/api NestJS + Mongoose — endpoints, agregações e POST /sync
web/ @expense/web React + Vite + shadcn/ui — tabelas e gráficos
extractor/ @expense/extractor o comando de terminal: .env, conexão e saída no console
packages/
categorization/ @expense/categorization a escada de precedência, pura e testada
ingestion/ @expense/ingestion ler as faturas (Drive ou disco) e gravá-las
bills/ seus CSVs quando EXTRACTOR_SOURCE=local
docker-compose.yml MongoDB local (+ mongo-express opcional)
Os dois pacotes existem pelo mesmo motivo, e é o motivo de o extractor ser tão fino: a mesma coisa acontece em dois processos, e duas implementações dela divergiriam.
categorizationguarda a decisão de qual título vai para qual categoria. Ela roda na API, quando você mexe numa regra, e na ingestão, depois de regravar. Duas cópias da precedência divergiriam, e o sintoma apareceria meses depois como uma categoria que muda sozinha ao sincronizar.ingestionguarda a leitura das faturas e a ordem das operações de uma ingestão — apaga o mês, grava, backfill, reaplica. Ela roda nopnpm extract, noPOST /synce noPOST /import. Aqui a divergência seria pior que uma categoria errada: uma das três pontas esquecendo a reaplicação no fim significaria que ingerir por aquele caminho desfaz a classificação manual.
Nos dois casos o acesso ao banco fica de fora do pacote, por trás de uma interface — PurchaseStore e
BillStore. O extractor as implementa no driver cru do MongoDB; a API, em Mongoose.
É também onde o multiusuário para. Nenhum dos dois pacotes sabe que usuário existe: o dono entra
pelos stores, que já nascem presos a um userId (createPurchaseStore(model, userId)), e para
reapplyRules e ingestBills isso continua sendo simplesmente "a base". Sem esse corte, aplicar uma
regra recategorizaria as compras de todo mundo.
| Camada | Tecnologia |
|---|---|
| API | NestJS 11, Mongoose 8, class-validator, Swagger |
| Front | React 19, Vite 7, Tailwind CSS 4, shadcn/ui (Radix + lucide), Recharts, React Router 7 |
| Extractor | Node + tsx, driver oficial do MongoDB 6 |
| Ingestão | googleapis (Drive API v3) + OAuth de aplicativo instalado |
| Banco | MongoDB 8 (Docker) ou MongoDB Atlas |
| Monorepo | pnpm workspaces, Turborepo, ESLint 9 (flat config), Prettier |
purchases — uma linha por lançamento:
| Campo | Tipo | Descrição |
|---|---|---|
userId |
ObjectId | De quem é a compra — o _id em users. Está em primeiro em todos os índices desta coleção |
title |
string | Descrição da compra, como veio na fatura |
amount |
number | Valor em reais |
date |
Date | Data da compra |
category |
string | A categoria que vale; payment marca o pagamento da fatura |
sourceCategory |
string | A que a ingestão resolveu, antes das suas regras. É para onde a compra volta se a regra for apagada |
referenceMonth |
Date | Primeiro dia (UTC) do mês da fatura em que a compra apareceu |
date e referenceMonth são coisas diferentes de propósito: uma compra de 28/02 costuma cair na
fatura de março.
categoryRules — a sua classificação, guardada fora das compras justamente para sobreviver ao
reprocessamento:
| Campo | Tipo | Descrição |
|---|---|---|
kind |
exact | contains |
Casa o título inteiro ou um trecho dele |
value |
string | O título ou o trecho |
category |
string | Categoria de destino |
updatedAt |
Date | Desempate entre regras igualmente específicas |
categories — as categorias que você criou. Não é a lista completa: as que vieram das faturas
existem só como string nas compras e continuam valendo. Esta coleção guarda as que precisam existir
antes de qualquer compra usá-las — sem ela não daria para criar "mercado livre" e classificar em
seguida. GET /category devolve a união das duas.
users — uma linha por conta: username e email (únicos, em minúsculas) e passwordHash
(bcrypt, custo 12). A senha em texto puro não é gravada em lugar nenhum. O índice de email é
parcial ({ email: { $exists: true } }), senão as contas anteriores ao campo colidiriam entre si
— para um índice único comum, dois documentos sem o campo valem ambos como null.
passwordResets — um pedido de redefinição em aberto: userId, tokenHash, expiresAt e
usedAt. O token não mora aqui; veja Senha. Um índice TTL apaga os
documentos um dia depois de expirarem — faxina, não segurança: a expiração que vale é conferida na
leitura, porque o TTL do Mongo roda de minuto em minuto.
Todas as coleções de dados carregam userId — purchases, categoryRules, categories,
subscriptions, consolidationDismissals e syncRuns. Os índices únicos são compostos com ele:
duas contas podem ter a categoria "mercado", a regra ifood → delivery e um apelido para a mesma
assinatura sem colidir. O _id do usuário é o que carimba, e não o nome, para que renomear uma conta
um dia não obrigue a reescrever a base inteira.
Documentação interativa em http://localhost:3000/docs.
Lista as compras com os agregados do conjunto filtrado. Pagamentos e encargos ficam de fora — são as
duas coisas que /purchase/bill também tira do total, e deixá-los entrar aqui faria as duas telas
discordarem do mesmo mês. Estornos, ao contrário, entram com valor negativo e abatem a soma.
| Query param | Exemplo | Efeito |
|---|---|---|
category |
supermercado,transporte |
Uma ou mais categorias, separadas por vírgula. encargos só aparece se for pedido assim; payment, nunca |
title |
uber |
Busca parcial, sem diferenciar maiúsculas |
date |
2024-03-15 |
Mês da data da compra. Qualquer dia serve — o filtro cobre o mês inteiro |
month |
2024-03 |
Mês da fatura em que a compra apareceu |
page |
2 |
Página, começando em 1 |
limit |
50 |
Linhas por página. Teto de 250 — sem ele, um limite alto traria a coleção inteira e desfaria a paginação |
sort |
amount |
title, amount, category, referenceMonth ou date. Lista fechada: o valor vira chave de ordenação do Mongo |
order |
desc |
asc ou desc. O padrão é date decrescente — a tela abre no que aconteceu agora |
date e month filtram campos diferentes de propósito: uma compra de 28/02 costuma cair na fatura
de março, então date=2024-02-10 e month=2024-02 devolvem conjuntos distintos. A tela filtra por
month — o seletor se chama "Fatura".
purchases é uma página; todo o resto descreve o filtro inteiro. É a distinção que sustenta a
tela: os painéis respondem "onde o dinheiro foi neste recorte", e somá-los sobre as cinquenta linhas
visíveis diria outra coisa sem nenhum sintoma. A ordenação sempre desempata por _id, senão duas
páginas de uma coluna com empates poderiam repetir e omitir a mesma compra.
Uma entrada por mês de referência, em ordem cronológica.
{
"month": "2025-02", // mês do VENCIMENTO, não o das compras
"cycleEnd": "2025-01-26", // último dia de compra do ciclo, inferido das datas
"valuePaid": 12150.23, // a linha de categoria `payment` do mês
"total": 12150.23, // gastos menos estornos; sem o pagamento e sem os encargos
"charges": 141.16, // juros, multa e saldo rolado — fora do total, mas não escondidos
"frequency": 37, // número de compras, sem contar pagamento nem encargo
"categoriesResult": [
{ "categoryByMonth": "viagem", "totalCategory": 4665.7, "frequency": 4, "percentage": 38.4 }
],
"viagem": 38.4, // atalho: percentual por categoria, usado nas colunas da tabela
"supermercado": 4.29
}As cobranças recorrentes e o degrau de preço de cada uma, ativas primeiro e depois pelo maior degrau em módulo — uma queda importa tanto quanto uma alta, e é a que ninguém confere. É o que a tela Assinaturas lista; o critério está em Como uma assinatura é detectada.
A varredura é sobre a base inteira, sem recorte de período: a escada do Spotify começa em 2019, e qualquer janela mais curta acharia um patamar só e nenhum degrau.
{
"key": "spotify", // identidade do grupo: normalizado e sem gateway
"title": "Dm *Spotify", // a forma mais frequente do título
"name": "Spotify", // o apelido, ou null — ver POST /subscription
"titles": ["Dm *Spotify", "Ebanx*Spotify", "Ebw*Spotify"], // as 8 formas agrupadas
"charges": 84,
"months": 77,
"current": 23.9, // último patamar que se repetiu
"previous": 21.9,
"change": 9.13, // %, negativo quando o preço caiu
"since": "2025-09-14T00:00:00.000Z", // desde quando `current` vale
"lastDate": "2026-07-12T00:00:00.000Z",
"active": true, // cobrou nos últimos 2 meses
"plateaus": [ // a escada inteira, do mais antigo ao mais novo
{ "amount": 8.5, "charges": 12, "since": "2019-11-08T00:00:00.000Z" }
]
}| Rota | O que faz |
|---|---|
POST /subscription |
{ "key": "melimais", "name": "Meli+" } — batiza. Rebatizar sobrescreve |
DELETE /subscription/:key |
Tira o apelido e devolve a assinatura ao título do cartão |
A chave é o key do GET /purchase/recurring, e é por isso que ela existe: o apelido não pode se
prender ao título cru. Dm *Spotify já chegou como Ebanx*Spotify, Ebw*Spotify e outras cinco
formas, e um nome preso a uma delas se perderia na fatura em que o gateway mudasse. A chave é o
título normalizado e sem o prefixo do gateway, a mesma que agrupa a série.
O preço disso é que a chave é derivada, não fornecida: mudar stripGateway ou normalize pode
reagrupar a base e deixar um apelido apontando para uma chave que não existe mais. Um apelido órfão é
inofensivo — a tela volta ao título do cartão —, mas é silencioso.
O nome é só rótulo. Não muda categoria, total, agrupamento nem a ordem da lista, e a detecção
continua sendo função pura das compras: o POST grava numa coleção à parte e a API junta os dois na
leitura. Por isso batizar não exige que a assinatura esteja na lista hoje — quem cancelou e voltou
mantém o apelido durante o intervalo em que a série ficou curta demais para ser detectada.
O mesmo cartão Mudou de preço da Visão geral — critério aqui —, como rota própria. Existe para quem quer perguntar "o que mudou" sem abrir a tela: um cron pessoal, um atalho de celular. A API não manda nada sozinha, só responde a lista; o canal fica por conta de quem consome.
[
{
"key": "srjhon barbearia",
"label": "Barbearia Sr Jhon", // o apelido quando existe; senão, o título do cartão
"previous": 79.99,
"current": 86.99,
"change": 8.75, // %
"since": "2026-04-27T00:00:00.000Z",
"yearly": 84 // (atual − anterior) × 12
}
]Igual à mecânica interna do cartão. Assinatura sem esse degrau nos três ciclos fechados mais recentes, ou encerrada, não aparece — vazio significa "nada a dizer agora", não "sem assinatura".
Os títulos ainda em outros, agrupados por estabelecimento, do que mais pesa para o que menos pesa.
É o que a tela Sem categoria lista.
Agrupar é o que torna a faxina viável — as parcelas do mesmo lugar viram uma linha só — e a ordem por dinheiro parado é o que faz o esforço render: classificar o primeiro da lista mexe mais nos gráficos do que os vinte últimos juntos.
{
"title": "Amazon", // já sem o "- Parcela 2/3"
"titles": ["Amazon - Parcela 1/3", "Amazon"], // as formas cruas do grupo
"frequency": 7,
"total": 2669.94,
"lastDate": "2025-02-24T00:00:00.000Z",
"suggestion": { "kind": "contains", "value": "Amazon" } // a regra que resolve o grupo inteiro
}Estas são as rotas que escrevem. Criar, editar ou apagar uma regra reclassifica na mesma requisição e devolve quantas compras mudaram.
| Rota | O que faz |
|---|---|
GET /category |
As categorias em que dá para classificar, com quantas compras cada uma tem. As de tipo de transação (payment, estorno, impostos, parcelado) ficam de fora: uma regra apontando para elas seria aceita e ignorada |
POST /category |
Cria uma categoria antes de qualquer compra usá-la |
PATCH /category/:name |
Renomeia. Apontar para uma categoria que já existe mescla as duas |
DELETE /category/:name |
Apaga, se não estiver em uso. Para esvaziar uma categoria, mescle-a em outra |
GET /category-rule |
As suas regras, cada uma com quantas compras e títulos ela governa hoje |
GET /category-rule/consolidation |
Onde um punhado de regras exact viraria uma contains — critério abaixo. Vem com dismissed marcado nas que o usuário escondeu |
POST /category-rule |
Cria ou atualiza a regra. Reclassificar o mesmo título edita a que já existe, nunca empilha uma segunda |
POST /category-rule/consolidate |
Troca as exact cobertas pelo trecho por uma contains, reaplicando uma vez. Aceita exceptions — títulos a manter na categoria de agora, como exact, antes do trecho entrar |
POST /category-rule/consolidation/dismiss |
Esconde uma sugestão da lista, pelo par (categoria, trecho) — não some da API, só ganha dismissed: true |
POST /category-rule/consolidation/restore |
Desfaz o descarte acima |
PATCH /category-rule/:id |
Muda o trecho, o tipo ou o destino de uma regra que já existe, pelo id. Ao contrário do POST, que acha a regra pelo par (kind, value), este localiza por id — é a única forma de editar o próprio value sem deixar a forma antiga órfã |
DELETE /category-rule/:id |
Apaga a regra e devolve as compras dela à sourceCategory |
POST /category-rule/reapply |
Reclassifica a base com as regras e a lista de encargos de agora, sem reextrair. É o gatilho para uma mudança na lista de palavras-chave de encargo valer no que já está no banco |
// POST /category-rule
{ "kind": "contains", "value": "mercadolivre", "category": "mercado livre" }
// → { "rule": { ... }, "classified": 92, "restored": 0, "financing": 0 }classified são as compras que uma regra moveu; restored, as que voltaram à sourceCategory por
não haver mais regra; financing, as que a camada de encargo reescreveu — para dentro ou para fora
de encargos. Esta última vem separada porque muda o total gasto do mês, e não só como ele se
reparte. Os três contam compras, não títulos, e a operação é idempotente: chamar duas vezes
seguidas devolve zeros na segunda.
A tela de Regras propõe trocar um punhado de regras exact por uma contains. O gatilho é
concreto: nesta base, Shopee acumulou 57 regras apontando para títulos que só diferem no sufixo
(Shopee *Inpower, Shopee *Sieno). Cada compra nova com sufixo novo volta para a fila de
classificação, e classificá-la cria a 58ª regra — o trabalho é infinito por construção.
O candidato é um prefixo cortado em fronteira (shopee, shopee , shopee *, nunca shope),
com no mínimo 4 caracteres, e só é proposto se cobrir ao menos 3 regras. Entre dois que cobrem o
mesmo tanto, vence o mais longo — o mais específico é o que menos promete alcançar o que ninguém
previu.
O que o critério recusa é o mais útil que ele produz. Um candidato é marcado como bloqueado se
tomaria um título que hoje está numa categoria de verdade. Só outros pode ser capturado, porque
ali não há classificação a desrespeitar — e capturar dali é o ganho, não o risco. Títulos protegidos
pela própria regra exact não contam como conflito: exact continua ganhando de contains depois
da troca.
Na base de referência isso muda a resposta. contains "shopee" cobriria 50 regras e levaria junto
22 títulos que estão em vestuário, saúde, eletrônicos, estorno e supermercado — porque a
Shopee é um marketplace, e ali a classificação segue o que foi comprado, não onde. Nenhum dos 22 tem
regra própria; suas categorias vieram da ingestão. A sugestão aparece assim mesmo, com o preço à
vista, porque silenciá-la esconderia a maior alavanca da base e aplicá-la destruiria classificação
deliberada.
O candidato não precisa começar no início do título. Ebanx*Spotify e Dm *Spotify não têm prefixo
em comum nenhum — o intermediário que processa a cobrança muda de nome, o serviço não —, mas os dois
têm spotify como palavra, e é dali que sai o candidato: qualquer palavra do título, cortada em
fronteira, entra na busca, não só a que começa em zero. Na base de referência isso rendeu três
consolidações seguras novas que o prefixo sozinho não via — melimais (Mp *Melimais,
Ec *Melimais, Ec*Melimais), pizza e zoo .
Conflito não precisa ser tudo ou nada. Quando a lista de conflitos está expandida, dois botões
ficam disponíveis: Consolidar mesmo assim, que muda a categoria de quem está em conflito também, e
Manter exceções e aplicar, que cria uma regra exact para cada título de conflicts — na
categoria em que já está — antes do trecho entrar. exact sempre ganha de contains na escada de
precedência, e é isso que preserva a exceção. Medido na base de referência: as três sugestões
bloqueadas hoje têm 2, 6 e 7 conflitos — nenhuma perto do extremo de 22 do exemplo do Shopee acima —,
e o segundo botão fica indisponível acima de 15, porque criar dezenas de regras num clique só deixa
de ser uma exceção rápida e vira algo que merece revisão título a título.
- Bloqueio é aviso, não trava.
POST /category-rule/consolidatesempre aplicou o que mandarem — a tela é quem decide o que mostrar. O botão de aplicar mesmo assim mora dentro da lista de conflitos expandida, e só ali: consolidar uma bloqueada é uma decisão informada, não a mesma coisa que consolidar uma seguindo, e o que não se pode é fazê-la sem ver o preço primeiro. - Descartar não é decisão final. Quem julga que uma sugestão não vale a pena pode escondê-la sem perder a chance de rever depois — ela continua na resposta da API, marcada, e a tela guarda um atalho para desfazer.
O estado da ingestão e o pedido de uma nova — o fluxo inteiro.
POST responde 202 ao aceitar o pedido, ou 409 se já houver uma em andamento. As duas rotas
respondem 403 para quem não é a conta dona da instância: elas falam do Google Drive, que é dela
— veja Contas.
{
"running": false,
"lastRun": {
"trigger": "cli", // "manual" é o botão; "cli", o cron; "upload", o POST /import
"status": "ok", // running | ok | error
"startedAt": "2026-08-04T07:00:00.000Z",
"finishedAt": "2026-08-04T07:01:12.000Z",
"bills": 95,
"purchases": 5744,
"rules": 180,
"classified": 1620,
"restored": 0,
"financing": 12,
"log": ["Buscando as faturas no Google Drive...", " 2026-09: 61 compras"]
}
}O log é o mesmo relato que o pnpm extract imprime no terminal, guardado porque é a única pista
dos casos que não são erro e não mudam contagem nenhuma: um arquivo ignorado por ter nome fora do
padrão <ano>-<mês>, dois arquivos disputando o mesmo mês, linhas descartadas por valor ilegível. A
tela mostra dele só as linhas de aviso.
multipart/form-data com um ou mais CSVs no campo files — a entrada de faturas de quem não usa o
Drive, descrita em Importando faturas pela tela. Máximo de 120
arquivos de 2 MB cada, só .csv. Responde 409 se já houver uma ingestão em andamento para
aquela conta.
Síncrono, ao contrário do POST /sync: o 202 de lá existe porque ler 95 faturas do Drive leva mais de
um minuto e um proxy cortaria a conexão no meio. Aqui os arquivos já chegaram, e o que falta é
parsear e gravar.
{
"files": [
{ "name": "nubank-2026-03.csv", "month": "2026-03", "monthFrom": "filename", "purchases": 61, "discarded": 0 },
// Sem <ano>-<mês> no nome: o mês saiu das datas de dentro do arquivo
{ "name": "fatura (3).csv", "month": "2026-04", "monthFrom": "content", "purchases": 58, "discarded": 2 },
{ "name": "extrato.csv", "month": null, "monthFrom": null, "purchases": 0, "discarded": 0,
"skipped": "o nome não contém <ano>-<mês> e as datas de dentro do arquivo não foram lidas." }
],
"result": { "bills": 2, "purchases": 119, "rules": 12, "classified": 40, "restored": 0, "financing": 0 },
"log": ["Gravando 2 faturas no MongoDB:", " 2026-03: 61 compras"]
}{ "status": "ok", "uptime": 12.34 }Todos rodam da raiz do repositório.
| Comando | O que faz |
|---|---|
pnpm dev |
Sobe API e front juntos, com hot-reload |
pnpm build |
Compila os três apps (Turborepo) |
pnpm lint |
ESLint em todos os workspaces |
pnpm typecheck |
Checagem de tipos em todos os workspaces |
pnpm test |
Testes: funções puras, serviços contra um MongoDB em memória — detalhes — e a API HTTP inteira, guard e ValidationPipe incluídos — detalhes |
pnpm db:up / pnpm db:down |
Sobe / derruba o MongoDB |
pnpm db:seed |
Popula o banco com 18 meses de faturas fictícias (determinístico) |
pnpm extract |
Roda o extractor com as suas faturas de verdade |
pnpm reapply |
Reaplica regras e encargos sobre a base já gravada, sem reextrair — veja abaixo |
pnpm --filter @expense/api migrate:multiuser |
Uma vez só, vindo da versão de usuário único: cria a conta dona e dá dono aos dados que não têm — detalhes |
pnpm --filter @expense/api set-email <usuario> <email> |
Dá endereço a uma conta criada antes de o e-mail existir, para ela poder recuperar a senha |
Para inspecionar o banco pelo navegador: docker compose --profile tools up -d → http://localhost:8081.
Antes de mexer em qualquer coisa aqui, valia saber quando o problema chega de verdade. A base cresce 732 compras por ano (média de 2019–2025). Replicando-a até 58 mil documentos — o que levaria cerca de 72 anos neste ritmo — os números ficavam assim:
| Operação | 5.855 docs | 58.550 docs |
|---|---|---|
GET /purchase devolvendo tudo |
52 ms · 1,1 MB | 107 ms · 11,4 MB |
GET /purchase/bill |
41 ms | 123 ms → 79 ms com projeção |
updateMany por $in de títulos (reaplicação) |
2 ms | 35 ms → 5 ms com índice em title |
| Uma página de 50 ordenada, no servidor | — | 1 ms |
O índice em title serve à reaplicação, e não à busca: um $regex sem âncora e
case-insensitive não usa índice nenhum e mede 13 ms com ou sem ele, enquanto distinct e
updateMany consultam por igualdade e ficam sete vezes mais rápidos. A projeção no listBills
existe porque a agregação lê quatro campos e sem dizê-lo o driver traz também title,
sourceCategory, _id e __v.
Paginação, ordenação e agregação passaram para o servidor. Sobre a base atual a resposta de
GET /purchase caiu de 1,13 MB para 17,7 KB numa página de 50, e de 52 ms para 12 ms.
O detalhe que torna isso perigoso, e que decidiu o desenho: mover só a paginação teria sido um erro
silencioso. A ordenação era do cliente, então ordenar por valor passaria a ordenar as cinquenta
linhas visíveis — a tela mostraria "a maior compra" que é apenas a maior da página. Os dois painéis
tinham o mesmo problema: eram somados a partir da lista recebida, e passariam a descrever a página
chamando isso de "onde o dinheiro foi". Por isso a resposta separa as duas escalas — purchases é a
página, e total, sum, average, byMonth e byCategory descrevem o filtro inteiro.
Duas ordenações ganharam critério de desempate por causa disso. A das linhas desempata por _id:
sem isso, ordenar por uma coluna com milhares de empates deixaria o Mongo livre para devolver a mesma
compra na página 1 e de novo na 2, enquanto outra não apareceria em nenhuma. A das categorias
desempata pelo nome, senão duas que somam o mesmo trocam de lugar entre uma requisição e outra.
A maior parte das regras mora em função pura e é testada sem banco. Mas o que dá mais medo de mexer não é isso — é a reaplicação de regras, o upsert, o rename/merge de categoria e a redecisão da camada de encargo, e as quatro só existem falando Mongo.
Um Model dublado mentiria justamente aí. restoreSourceCategory grava com pipeline de agregação
({ $set: { category: '$sourceCategory' } }), que copia campo para campo dentro do servidor: com um
mock, o teste passaria gravando a string literal "$sourceCategory". O rename depende de três
updateMany acertando coleções diferentes. E a busca por título vira regex, onde Mercadolivre*Mercadol
sem escapar casaria com "Mercadoliv" seguido de qualquer coisa.
Então esses testes sobem um mongod de verdade, em memória, via mongodb-memory-server. O harness
está em apps/api/src/testing/mongo.ts e não sobe o Nest: os serviços recebem os Model pelo
construtor e são instanciados direto — o que se quer provar é a decisão e a escrita, não a injeção de
dependência, que o smoke test do CI já exercita.
O binário do mongod (~77 MB) é baixado uma vez e fica em cache; a suíte inteira roda em cerca de dois
segundos depois disso. No CI ele é cacheado por MONGOMS_DOWNLOAD_DIR.
Um efeito colateral: os schemas declaram
@Prop({ type: ... })explicitamente em vez de deixar o tipo ser inferido. OemitDecoratorMetadatasó existe sob o compilador do TypeScript, e os testes rodam sob esbuild, que não o emite. O schema produzido é o mesmo.
Os testes de serviço acima instanciam a classe direto pelo construtor — provam a decisão e a
escrita, mas pulam o Nest inteiro: o container de injeção de dependência, o guard de sessão, o
ValidationPipe. É exatamente aí que mora o risco que a autenticação (98370b2) introduziu — um
módulo esquecido, um decorator de rota errado, um guard que libera o que devia bloquear — e nenhum
desses erros aparece testando o serviço isolado.
apps/api/src/http.itest.ts compila o AppModule de verdade via Test.createTestingModule,
contra o mesmo mongodb-memory-server, com um convite e contas de teste criadas na hora — nunca as do
.env real — e bate nas rotas por HTTP com supertest. Cobre: o guard bloqueando sem sessão e
liberando as rotas @Public(); cadastro, login e logout abrindo e fechando a sessão de verdade; o
ValidationPipe rejeitando corpo incompleto e campo desconhecido; o /sync respondendo só ao dono da
instância; e a resolução de cada controller principal pelo container do Nest.
E o isolamento entre contas, que é o teste que justifica o multiusuário existir. Vazamento entre
contas não tem sintoma: a resposta tem o formato certo, o status é 200 e a tela mostra números
plausíveis — de outra pessoa. Nenhum teste de serviço pega isso, porque cada um roda com um usuário
só. Ali duas contas importam faturas na mesma base e cada rota é conferida contra o que a vizinha
gravou, incluindo PATCH e DELETE de regra com o id da outra, que precisam dar 404 e não 200.
Por que é um comando separado (pnpm test:http, dentro do pnpm test da API) em vez de entrar no
glob de *.test.ts: o efeito colateral do emitDecoratorMetadata citado acima, que é inofensivo
para os schemas do Mongoose, é fatal aqui. Sem a metadata de tipo, o Nest não sabe qual classe
injetar em cada parâmetro de construtor e passa undefined — silenciosamente, até algum método
tentar chamar em cima disso. esbuild (o transpilador do tsx, usado pelo resto da suíte) nunca
emite essa metadata; é uma limitação conhecida, não um bug deste projeto. Por isso este arquivo é
.itest.ts, não .test.ts, e roda sob ts-node (ts-node/register/transpile-only), que usa o
compilador de verdade do TypeScript.
Quase nunca, e é de propósito: os caminhos que você percorre já reaplicam sozinhos. Criar, editar ou
apagar uma regra reaplica na mesma requisição, e pnpm extract reaplica no fim.
Sobra um caso, que nenhum deles cobre: a tabela de palavras-chave de encargo mudou no código.
Ela é a única inferência por título que a reaplicação redecide em vez de herdar de
sourceCategory — porque é a única que muda quanto você gastou, e não só como o gasto se
reparte. Acrescentar juros rotativo à lista sem este comando só valeria a partir da próxima
extração, o que é inalcançável para quem não tem mais os CSVs.
$ pnpm reapply
Reaplicadas 255 regras:
0 compras classificadas por uma regra
0 devolvidas à categoria que veio da fatura
3 entraram ou saíram de encargos
Atenção: encargo fica fora do total gasto — os totais por mês mudaram.
Os dois primeiros números repartem o gasto; o terceiro o altera. Com a base em dia o comando diz
"nada mudou" e não escreve — é idempotente, e é a mesma operação de POST /category-rule/reapply.
Tirar uma palavra-chave também funciona, e é o caso mais delicado: devolver a compra à ingestão a devolveria a
encargos, então o motor refaz a inferência pelo título.sourceCategoryfica intocada nos dois sentidos — é a opinião congelada da fatura, e reaplicar nunca a reescreve.
- O multiusuário é de dados, não de administração. Cada conta é uma ilha: não há papéis, nem
compartilhamento entre contas, nem tela para listar, renomear ou apagar usuários — isso se faz no
banco. O único privilégio que existe é
OWNER_USERNAME, e ele vale para uma coisa só: disparar a sincronização com o Drive. - A senha se troca e se recupera; o e-mail, não.
/contatroca a senha e/esquecirecupera a conta, mas mudar o endereço de uma conta continua sendoset-emailna linha de comando. Uma tela para isso precisaria confirmar o endereço novo por e-mail antes de trocar — senão um erro de digitação tranca a recuperação da conta em silêncio —, e esse é um segundo fluxo inteiro. - Um convite só, que não expira. Revogá-lo é trocar
INVITE_CODEe reiniciar a API, o que não derruba quem já entrou. Não há convite por pessoa nem por prazo. - O Drive continua sendo de uma conta Google só. As credenciais OAuth moram no servidor e são do dono da instância; as demais contas importam CSV pela tela. Um Drive por usuário exigiria tela de consentimento, token por conta e refresh, e não é o que esta versão se propõe a fazer.
- Nada observa a fonte. O botão Sincronizar tirou o
pnpm extractpor SSH do caminho, mas não transformou a app num observador: uma fatura nova no Drive continua invisível até alguém pedir a ingestão, no botão ou no cron. Não há webhook do Drive nem varredura periódica de dentro da API — o agendamento fica com o sistema operacional da VPS, que é onde ele é fácil de ver e de desligar. A tela ao menos passou a dizer quando foi a última vez, que era a metade que faltava: antes, uma base parada e uma base em dia eram visualmente idênticas. - A sincronização não avisa quando termina. Quem clica e sai da tela não recebe nada — o resultado fica no popover, esperando alguém abrir. Mesma decisão do aviso de reajuste, logo abaixo: a API responde "o que aconteceu", e nenhum canal de push está embutido.
- A lista de encargo se corrige sem reextrair; as outras palavras-chave, não.
POST /category-rule/reapplyaplica a lista de encargo de agora ao que já está no banco, nos dois sentidos — como. Já as palavras-chave que apenas repartem o gasto (uber→ transporte,ifood→ restaurante) continuam congeladas emsourceCategory, e corrigi-las ainda depende de umpnpm extract— ou de uma regra sua, que resolve caso a caso e ganha da tabela. A diferença é deliberada:sourceCategorytambém guarda a categoria que o emissor mandou e a memória por título, e uma palavra-chave genérica não deve atropelar as duas. - A reaplicação de regras varre a coleção inteira a cada mudança, e isso é uma escolha — é o que
a mantém idempotente. Numa base pessoal some no tempo da requisição; veja
Escala para os números.
/purchasejá não faz isso: pagina, ordena e agrega no servidor. - O aviso de reajuste tem rota, mas nada manda sozinho.
GET /purchase/price-alertsdevolve os mesmos degraus do cartão da Visão geral — como — pronto para um cron pessoal ou atalho de celular perguntar sem abrir a tela. Não há push, e-mail nem qualquer canal embutido: a API responde "o que mudou", e o resto é de quem consome. O que a detecção em si erra de propósito está em Onde a detecção falha. GET /purchase/recurringvarre a coleção inteira e agrupa em memória a cada requisição, porque a escada de preços depende da série completa. Mesmo custo da reaplicação de regras, e some igual numa base pessoal.- O dia em que o ciclo fecha é inferido, não informado. O CSV não diz em que dia a fatura fecha, e
a diferença importa:
referenceMonthnomeia o mês do vencimento, e o consumo vem do mês anterior — a fatura de agosto/2026 cobre 26/06 a 26/07. A API lê a borda das próprias compras, pela mediana do dia da última compra das 24 faturas recentes (dia 26 na base de referência: 13× no 26, 9× no 25, 2× no 23), e o erro nunca é positivo — nenhuma compra passa do dia inferido. Em troca, um ciclo que fechou dias antes do usual só é reconhecido como fechado no dia inferido. Com menos de três faturas de histórico não há o que inferir e o recorte cai no mês calendário, que é o que a tela fazia antes. - Os testes cobrem as funções puras onde moram as regras — o parser de CSV, a montagem do filtro do
Mongo, a detecção de assinatura, a comparação com o histórico, o agrupamento dos gráficos —, os
serviços que escrevem, contra um MongoDB de verdade em memória
(como), e a API HTTP inteira — guard de sessão,
ValidationPipe, injeção de dependência do Nest — subida contra o mesmo banco em memória (como). Continua havendo um smoke test no CI, de ponta a ponta contra uma instância real. - A interface é escura por padrão, com alternador claro/escuro/sistema. Os tokens vivem em
apps/web/src/assets/globals.css, no padrão CSS-first do Tailwind 4.
MIT
{ "purchases": [ /* a página */ ], "total": 742, // linhas que o filtro alcança "sum": 269470.89, "average": 363.17, "page": 1, "limit": 50, "pageCount": 15, "byMonth": [ // agrupado pela DATA da compra, não pelo mês da fatura { "month": "2024-03", "total": 4820.15, "count": 61 } ], "byCategory": [ { "categoryByMonth": "supermercado", "totalCategory": 37340.21, "frequency": 214, "percentage": 17.1 } ] }