money-pilot
Aplicação de controle financeiro pessoal: contas, lançamentos, transferências, orçamento mensal, contas a pagar e a receber (com parcelamento, baixa em lote e recorrência), previsão de fluxo de caixa e relatórios. Backend em Spring Boot com API REST, e um cliente web servido pela própria aplicação.
Stack
| Camada | Tecnologia |
|---|
| Linguagem | Java 21 |
| Framework | Spring Boot 4.1.1 (Web MVC, Data JPA, Security, Validation, Actuator) |
| Banco | PostgreSQL 18 |
| Migrations | Flyway |
| Autenticação | JWT stateless de curta duração (jjwt 0.12.6) + refresh token rotativo em cookie httpOnly; senha com BCrypt |
| Documentação da API | springdoc-openapi 3.x (Swagger UI) |
| Testes | JUnit 5, MockMvc e Testcontainers (Postgres real); node --test para o cliente web |
| CI | GitHub Actions |
| Deploy | Docker Compose em VPS, imagem publicada no GHCR, HTTPS pelo Caddy |
| Build | Maven (wrapper incluso) |
| Frontend | HTML, CSS e JavaScript puro com ES modules, sem build step e sem biblioteca de gráficos |
Arquitetura
Pacotes por domínio, não por camada técnica. Cada domínio concentra entidade, repositório, records de request/response, service e controller:
br.com.desenvolvedorgustavolopes.moneyPilot
├── auth registro, verificação de conta, login, reset de senha, refresh token, filtro JWT, usuário autenticado, perfil e troca de senha e de e-mail
├── account contas e saldo
├── category categorias do usuário e do sistema
├── transaction lançamentos e transferências
├── budget orçamento por categoria e mês
├── bill títulos a pagar/receber, parcelamento, baixa e recorrência
├── report consultas agregadas, previsão de fluxo de caixa
├── notification alertas calculados (vencimentos e orçamento) e dispensa
├── config Spring Security, Clock, host canônico
└── exception exceções de domínio e handler global
Decisões que valem saber antes de mexer
Base
- Flyway é a única fonte de verdade do schema.
spring.jpa.hibernate.ddl-auto=validate em todo ambiente: entidade que não casar com a migration derruba a aplicação no boot, de propósito. Migration já aplicada nunca se edita — o Flyway compara checksum.
- Regras de estado vivem em CHECKs do Postgres, e o Hibernate não os valida. Violação chega como
DataIntegrityViolationException; o código checa antes para devolver um erro que explique o problema.
- Dinheiro é
BigDecimal com escala 2, moeda única (BRL).
- Saldo é calculado em leitura, nunca persistido:
initial_balance da conta somado às receitas menos as despesas dos lançamentos.
- Transferência é um par de lançamentos ligados por
transfer_group_id — uma saída e uma entrada, sem categoria. Não é um tipo especial de lançamento. Transferência não pode ser editada, e apagar uma perna apaga o par.
- Categorias são flat (sem hierarquia) e podem ser globais (
user_id nulo, criadas por migration) ou do usuário. Categoria global é visível para todos e não pode ser editada nem apagada.
- Todo acesso é escopado ao usuário autenticado. Recurso de outro usuário responde
404, nunca 403: a API não confirma a existência do que não é seu.
- "Hoje" vem de um
Clock injetado, fixado em America/Sao_Paulo. O servidor roda em UTC; sem isso, depois das 21h um título que vence hoje apareceria como vencido. Nos testes o Clock é substituído por um relógio fixo.
Títulos (contas a pagar e a receber)
- Título não é lançamento. Ele é um compromisso com data; só ao ser baixado gera o lançamento no extrato, com o sinal invertido conforme o tipo (
PAYABLE vira despesa, RECEIVABLE vira receita).
OVERDUE não existe no banco. O status persistido é OPEN, SETTLED ou CANCELED; "vencido" é OPEN com vencimento antes de hoje, traduzido no service.
- A baixa é à prova de duplo clique:
UPDATE ... WHERE status = 'OPEN' condicional, e zero linhas afetadas vira 409. Dois cliques nunca geram dois lançamentos.
- A baixa tem data própria (
settledOn, opcional, padrão hoje, nunca no futuro): o lançamento entra no dia em que o dinheiro saiu ou entrou, não no dia do registro. O "baixado no mês" do resumo segue essa data.
- Pagamento com juros ou multa é baixado pelo valor efetivamente pago, maior que o do título; o lançamento registra esse valor.
- Baixar por valor menor quita o título inteiro. Um título de R$ 100,00 baixado com R$ 95,00 fica
SETTLED, sem resíduo. É intencional e a tela avisa.
- Lançamento gerado por baixa não se edita nem se apaga pelo endpoint de transações (
409); o caminho é unsettle, que apaga o lançamento e reabre o título. Esses lançamentos chegam com billId e billType, e a tela os marca como pagamento ou recebimento de título.
- Parcelamento divide com arredondamento DOWN e o resíduo em centavos na primeira parcela (100,00 / 3 → 33,34 + 33,33 + 33,33). Vencimentos partem sempre da data original (
firstDueDate.plusMonths(i)), então 31/01 vira 28/02 e volta a 31/03.
- Baixa em lote é tudo ou nada (até 200 títulos) e o erro cita o id que falhou.
- Recorrência é materializada sob demanda e de forma idempotente, sem scheduler: chamar duas vezes para o mesmo mês não cria nada na segunda. O dia 31 é limitado ao último dia do mês.
Sessão
- Access token de 15 minutos, refresh token de 30 dias. O refresh é opaco (256 bits), gravado como SHA-256, entregue em cookie
httpOnly + Secure + SameSite=Strict (restrito a /api/v1/auth) e rotacionado a cada uso.
- Refresh token no corpo, só se o cliente pedir (spec 011). O navegador segue só com o cookie e a resposta dele não traz
refreshToken. Um cliente fora do navegador manda refreshTokenInBody: true no login e recebe {token, refreshToken, expiresIn} sem Set-Cookie; renovação e logout aceitam {refreshToken} no corpo, e mandar o token no corpo já liga o modo. Cookie e corpo juntos: vale o corpo e o cookie é ignorado. Rotação e detecção de reuso são as mesmas do cookie. Não há tokens de API pessoais nem CORS aberto, por decisão. A troca de senha pelo app encerra as sessões da API.
- Dinheiro nas bordas da API recusa mais de 2 casas. Todo campo de dinheiro dos requests tem
@Digits(integer = 17, fraction = 2); 10.555 ou "10,50" respondem 400 com erro no campo, em vez de o banco arredondar sem avisar.
- Reuso de refresh token já rotacionado revoga a família inteira daquele usuário — sinal de token vazado.
- O cliente faz uma única renovação compartilhada quando várias chamadas recebem
401 ao mesmo tempo; uma renovação por chamada se autoinvalidaria pela detecção de reuso.
- Limite de tentativas no login e no cadastro, em memória e por janela fixa (
AttemptLimiter). Login: 10 tentativas por IP e 3 falhas por e-mail a cada 15 min — o bloqueio por e-mail vale de qualquer IP e recusa até a senha certa; login bem-sucedido zera as falhas. Cadastro: 3 por IP por hora, contando também e-mail já cadastrado. Excedido, responde 429 com Retry-After. O IP vem do X-Forwarded-For do Caddy (server.forward-headers-strategy=framework). Os contadores zeram no restart (uma instância só); limites em app.rate-limit.*.
- Só entra quem verificou o e-mail. A conta nasce não verificada e recebe um link (
/app/#/verificar-email?token=…, 256 bits, só o SHA-256 no banco, 24 horas, uso único, uma linha por usuário: reenviar invalida o anterior). O link verifica, mas não abre sessão. Login de conta não verificada com a senha certa responde 403; com a senha errada, o 401 de sempre. Contas criadas antes da verificação existir entram como verificadas (V12).
- O cadastro responde sempre sem corpo: e-mail livre cria a conta e manda o link; e-mail de conta não verificada reaproveita a conta com o nome e a senha novos e manda outro link; e-mail de conta verificada não grava nada e avisa o dono (no máximo 1 aviso por hora). Falha no envio do link desfaz o cadastro (). O reenvio responde sempre ; para conta já verificada, manda um e-mail dizendo que ela está ativa. Limites do reenvio: 3 por e-mail e 10 por IP por hora.
Modelo
users · accounts · categories · transactions · budgets · bill_recurrences · bills · refresh_tokens · dismissed_alerts · email_change_tokens · email_verification_tokens · password_reset_tokens — migrations V1 a V14 em src/main/resources/db/migration. V6 adiciona os índices das tabelas originais; V12 marca as contas existentes como verificadas e converte os e-mails para minúsculas; V14 torna password_hash opcional e acrescenta as colunas do Google e da foto em users.
API
Base: /api/v1. Tudo exige Authorization: Bearer <token>, exceto registro, login, refresh, logout, verificação de conta, reset de senha, o login com Google (/auth/google, /auth/google/config) e a confirmação de troca de e-mail. A documentação interativa fica em /swagger-ui.html (spec em /v3/api-docs, com resumo, descrição, exemplos, erros e enums de cada rota). Para usar a API com um assistente de IA, há o guia público /ajuda/usando-com-ia.html, com o texto pronto para colar e, no fim, a parte técnica para desenvolvedores. Dinheiro é número com 2 casas e ponto decimal (50.00) e datas são aaaa-mm-dd.
| Método | Rota | O que faz |
|---|
POST | /auth/register | Cadastra e envia o link de verificação; resposta igual exista ou não a conta (202 sem corpo; 400 com erro no campo, inclusive senha fora da política; 502 se o link não puder ser enviado; 429 acima de 3 por IP/hora) |
POST | /auth/login | Devolve o access token e grava o refresh token em cookie. Com refreshTokenInBody: true no corpo (clientes fora do navegador), responde {token, refreshToken, expiresIn} e não envia cookie (401 credenciais inválidas, inclusive conta só com Google; 403 conta não verificada; 429 após muitas tentativas) |
POST | /auth/verification/confirm | Verifica a conta com o token do link (204, também para link já usado; 400 inválido ou expirado) |
POST | /auth/verification/resend | Reenvia o link para o email (sempre 202; 429 acima de 3 por e-mail ou 10 por IP por hora) |
POST | /auth/refresh | Rotaciona o refresh token (do cookie ou de {refreshToken} no corpo; se vierem os dois, vale o corpo) e devolve um novo access token; no modo corpo a resposta é {token, refreshToken, expiresIn} sem cookie |
POST | /auth/logout | Revoga o refresh token (do cookie ou de {refreshToken} no corpo) e, no modo cookie, limpa o cookie |
GET | /auth/google/config | {clientId} do Google (vazio = login com Google desligado); público |
POST | /auth/google | Entra com o ID token do Google (credential): devolve {token, email} e grava o cookie de refresh (401 token inválido; 409 e-mail de conta vinculada a outro Google; 429 por IP); público |
POST |
Listagens aceitam page, size e sort (por exemplo sort=date,asc).
/transactions filtra por accountId, categoryId, type, from e to.
/bills filtra por type, status (incluindo OVERDUE), categoryId, accountId, dueDateFrom e dueDateTo.
A previsão de fluxo de caixa parte do mesmo saldo que /accounts/{id}/balance mostra, soma os títulos em aberto bloco a bloco e joga os vencidos inteiros no primeiro bloco — dívida vencida não some da projeção por ser antiga. O último bloco pode ter menos de 7 dias (30 dias = quatro de 7 e um de 2).
A previsão mensal segue a mesma base, agrupada por mês a partir do mês corrente, e vai além: projeta as recorrências ativas que ainda não viraram título, sem persistir nada. Uma ocorrência só entra se vence a partir de hoje, está dentro da vigência da recorrência e ainda não foi materializada naquele vencimento — assim um título já gerado nunca conta duas vezes. Os vencidos também caem inteiros no primeiro mês e vêm somados à parte em overdue.
Erro sempre no mesmo formato, com fieldErrors preenchido quando a falha é de validação:
{
"timestamp": "2026-09-22T14:31:10.577Z",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"fieldErrors": { "amount": "must be greater than 0" }
}
Cliente web
Servido pela própria aplicação, a partir de src/main/resources/static: o site do produto (spec 020) na raiz / e o app em /app/. Sem framework e sem etapa de build: cada tela é um módulo em js/views/ que devolve um elemento e se re-renderiza no load().
-
Site na raiz (index.html, css/site.css, js/site.js): apresentação do produto, sempre a mesma com ou sem sessão; só o topo muda ("Entrar" e "Criar conta", ou "Abrir o app"). Um link antigo /#/rota (favoritos, e-mails já enviados) redireciona para /app/#/rota, preservando a query.
-
App em /app/ (app/index.html): WebConfig redireciona /app para /app/ e entrega o index.html. Os recursos usam caminho absoluto (/css, /js, /image). A sessão (token no localStorage, cookie de refresh em /api/v1/auth) não depende do caminho. Rotas públicas, além das da API: /, /app, /app/, /ajuda/**, /privacidade/** e /termos/**.
-
Mobile-first, com breakpoints em 600, 900, 1200 e 1440px. No celular a navegação vira uma barra no topo, tabelas viram cartões compactos e os filtros ficam recolhidos.
-
Tema claro único nas cores da marca (azul-marinho e verde da logo).
-
Previsão mensal com colunas de a pagar e a receber por mês, linha do saldo projetado e tabela mês a mês.
-
Visão geral com saldo em contas, projeção, títulos a pagar e a receber, próximos vencimentos e gráficos de previsão e de receitas × despesas — desenhados em SVG por js/charts.js, com tooltip e versão em tabela.
-
A pagar e receber com seleção em lote e prévia ao vivo da divisão das parcelas, calculada em centavos com a mesma regra do backend.
-
Campo de senha (js/password.js): botão de olho dentro do campo (login, cadastro, reset e perfil) e, nas senhas novas, medidor de força (fraca, razoável, forte) com dica, que avisa senha vazada enquanto a pessoa digita. O medidor só informa, não bloqueia o envio: quem recusa é o servidor.
-
Login com Google (js/google.js): carrega o script oficial só quando vai desenhar o botão, que aparece no login e no cadastro (e no cartão Google do perfil). Sem client ID ou com o script bloqueado, o botão simplesmente não aparece. O perfil mostra a foto do Google (ou as iniciais, também no rodapé do menu), vincula e desvincula o Google e oferece "Criar senha" a quem não tem uma.
Desenvolvimento
Novas features seguem Spec-Driven Development: cada uma tem uma pasta em specs/ com spec, plano técnico e tarefas, aprovados antes do código. As regras de trabalho, branches e commits estão no CLAUDE.md.
Rodando localmente
Pré-requisitos: JDK 21 e Docker.
docker compose up -d # sobe o Postgres em localhost:5432
./mvnw spring-boot:run # sobe a aplicação em localhost:8080
O profile local é o default e já aponta para o banco do compose. O Flyway cria o schema e semeia as categorias padrão no primeiro boot. A interface web fica em http://localhost:8080 e o Swagger em http://localhost:8080/swagger-ui.html.
O segredo JWT do profile local é um placeholder de desenvolvimento — ele não vale para nada além da sua máquina. Localmente o cookie do refresh token sai sem Secure (COOKIE_SECURE=false), porque o servidor roda em HTTP.
Variáveis de ambiente (profile prod)
| Variável | Observação |
|---|
SPRING_PROFILES_ACTIVE | prod |
JDBC_DATABASE_URL | Obrigatória. No docker-compose.prod.yml aponta para o serviço db |
JDBC_DATABASE_USERNAME | Obrigatória |
JDBC_DATABASE_PASSWORD | Obrigatória. No compose vem de POSTGRES_PASSWORD do .env |
JWT_SECRET | Obrigatória. Mínimo de 32 caracteres; segredo vazio derruba a aplicação no boot |
JWT_EXPIRATION_MS | Opcional, default 900000 (15 minutos) |
COOKIE_SECURE | Opcional, default true |
CANONICAL_HOST | Opcional. Quando definida, requisições para outro host recebem 308 para https://<CANONICAL_HOST> |
PUBLIC_URL | Obrigatória. URL pública do app com https:// e sem barra final; base dos links enviados por e-mail |
MAIL_FROM_ADDRESS | Obrigatória. Remetente dos e-mails, de um domínio verificado no Resend (SPF/DKIM) |
RESEND_API_KEY | Obrigatória. Chave da API do Resend |
SUPPORT_EMAIL | Obrigatória. Endereço que recebe os chamados de "Falar com o suporte"; sem ela o app não sobe |
PIX_KEY | Opcional. Chave Pix aleatória do apoio ao projeto; vazia, o apoio não aparece |
PIX_QR_FILE | Opcional. Caminho, dentro do container, da imagem (png ou jpg, até 512 KB) do QR code da chave |
PIX_QR_DIR | Opcional. Pasta da VPS com a imagem do QR code, montada como /pix (somente leitura); default /opt/moneypilot-pix. Ex.: PIX_QR_FILE=/pix/qr.png |
ADMIN_EMAILS | Opcional. E-mails das contas administradoras do painel de chamados, separados por vírgula; vazia, ninguém é administrador |
GOOGLE_CLIENT_ID | Opcional. Client ID OAuth (Web) do Google, que não é segredo; vazio desliga o login com Google. A origem pública do app precisa estar autorizada no Google Cloud |
E-mail transacional: o pacote mail expõe EmailSender, e as features só pedem "envie esta mensagem para esta pessoa". Em prod o envio é síncrono via Resend (API HTTP com RestClient, timeouts de 3 s e 10 s, sem dependência nova); falha vira 502 no formato ErrorResponse, sem expor a chave no log. Fora de prod nada é enviado e o conteúdo aparece no log. Sem PUBLIC_URL, MAIL_FROM_ADDRESS, RESEND_API_KEY ou SUPPORT_EMAIL em prod, o app não sobe. EmailMessage aceita um replyTo opcional, enviado como reply_to ao Resend.
As credenciais do banco e o JWT_SECRET não têm valor default no código: variável faltando é falha de boot, não comportamento silencioso. Em produção, server.forward-headers-strategy=framework faz o Spring enxergar o HTTPS terminado no Caddy — sem isso o cookie Secure não é enviado.
Deploy
Hospedado em uma VPS com Docker Compose (docker-compose.prod.yml): Postgres 18 com volume nomeado e a aplicação, com limite de memória e heap da JVM fixados porque a VPS é compartilhada com outros apps. O HTTPS fica num Caddy compartilhado da VPS, que alcança a aplicação pela rede Docker externa proxy.
O Dockerfile é multi-stage: compila com o Maven wrapper e roda só com o JRE 21, com usuário sem privilégio. O jar sai com nome fixo (target/moneyPilot.jar), então trocar a <version> do pom.xml não quebra o boot.
Publicação: todo push na main que passa nos testes publica a imagem ghcr.io/guh99998/moneypilot com as tags latest e o SHA do commit. A VPS mantém um clone do repositório na main, só para ter o compose e o .env, e baixa a imagem pronta; não compila nada.
Primeira instalação:
docker network create proxy # uma vez por VPS, compartilhada com o Caddy
cp .env.production.example .env # preencher POSTGRES_PASSWORD, JWT_SECRET, CANONICAL_HOST, PUBLIC_URL, MAIL_FROM_ADDRESS, RESEND_API_KEY, SUPPORT_EMAIL e (opcional) GOOGLE_CLIENT_ID
docker compose -f docker-compose.prod.yml up -d
A cada versão nova:
git pull # traz mudanças no compose, se houver
docker compose -f docker-compose.prod.yml exec -T db pg_dump -U moneypilot moneypilot > backup-$(date +%F).sql # antes, se houver migration nova
docker compose -f docker-compose.prod.yml pull app
docker compose -f docker-compose.prod.yml up -d app
docker compose -f docker-compose.prod.yml logs -f app # conferir o Flyway aplicando as migrations
Para voltar uma versão, use a tag com o SHA do commit anterior no lugar de latest. Migration já aplicada não é desfeita pela troca de imagem — por isso o dump antes.
Backup: um cron diário na VPS faz o dump do Postgres e o envia, criptografado, para um armazenamento externo. O dump manual acima é a garantia extra para o momento exato antes de uma migration.
Testes
./mvnw verify # testes Java (precisa do Docker rodando)
node --test "src/test/js/*.test.mjs" # testes do cliente web
Os testes de integração estendem AbstractIntegrationTest, que sobe um Postgres 18 via Testcontainers. O Flyway aplica as migrations reais e o Hibernate só valida — o mesmo contrato de produção, sem schema gerado. A suíte cobre autenticação, verificação de conta, reset de senha e rotação de refresh token, isolamento entre usuários, saldos e transferências, filtros e paginação, ciclo de vida dos títulos e baixa concorrente, arredondamento de parcelas, baixa em lote, recorrência, previsões de fluxo de caixa semanal e mensal e os relatórios.
O GitHub Actions (.github/workflows/ci.yml) roda as duas suítes a cada push e pull request.
Próximos passos
- Exclusão de conta (spec própria): quem só tem Google confirma entrando de novo com o Google.
- Ordenar títulos e lançamentos pelo nome da categoria e da conta (hoje a ordenação cobre data, descrição, valor e tipo).
- Fora do horizonte imediato, de propósito: anexo de comprovante (exige S3), fatura de cartão de crédito, importação de extrato CSV, Redis e RabbitMQ.