Aplicativo de controle de orçamento pessoal com rendas, despesas mensais, despesas futuras, rendas futuras, investimentos e suporte multi-moeda (BRL / USD / EUR). Inclui gráficos, indicadores de saúde financeira, projeção de despesas futuras, histórico mensal e widget de cotação de câmbio.
Desenvolvido por Pedro Henrique de Almeida.
A aplicação está rodando em:
Stack: Python 3.11 + Flask + PostgreSQL (Neon), deploy em Railway (Docker). Back e front servidos pelo mesmo processo (mesma origem, sem CORS aberto).
- Autenticação: cadastro e login com usuário/e-mail e senha (hash PBKDF2 + JWT).
- Multi-moeda: registre rendas, despesas e investimentos em BRL, USD ou EUR. O sistema converte tudo automaticamente para BRL no saldo e no histórico mensal.
- Widget de Câmbio no dashboard: mostra USD/BRL e EUR/BRL com timestamp e botão de refresh. Modo manual como fallback se a API estiver bloqueada.
- Rendas ativas e passivas com categoria (Salário, Freelance, Aluguel…).
- Rendas futuras (13º, PLR, restituição de IR…) com prazo em meses.
- Despesas mensais com dia de vencimento e categoria.
- Despesas futuras com prazo em meses (ex.: IPTU daqui a 6 meses).
- Investimentos com rendimento anual (% a.a.) e cálculo de rendimento mensal.
- Histórico mensal: salve um snapshot dos totais a cada mês e acompanhe a evolução da renda, despesas e saldo em um gráfico de linha.
- Gráficos: pizza de distribuição de despesas + barra renda × despesas + linha de evolução temporal.
- Saúde financeira: taxa de poupança, reserva de emergência (em meses), comprometimento da renda e classificação geral (Excelente → Crítico).
- Projeção de despesas futuras com sugestão de reserva mensal.
- Dados isolados por usuário — ninguém vê os dados de outra pessoa.
- UI responsiva com Tailwind via CDN (sem build step).
| Camada | Tecnologia |
|---|---|
| Front-end | HTML + CSS + JS (vanilla, Tailwind/Chart.js CDN) |
| Back-end | Python 3.11 / Flask 3 + flask-cors + flask-limiter |
| Banco | PostgreSQL (Neon) em prod · SQLite em dev |
| Auth | JWT (PyJWT) + PBKDF2-SHA256 para hash de senha |
| Adapter | psycopg[binary]>=3.1 (Postgres) — adapter dual-backend |
Nenhuma etapa de build. pip install + python app.py e está rodando.
Cada usuário tem uma reserva pessoal com:
- Meta em meses de despesas (padrão: 6, ajustável de 1 a 24)
- Valor atual (soma de contribuições - retiradas)
- Progresso visual com barra de cor (vermelho <30%, amarelo 30-70%, verde ≥70%)
- Recomendação mensal pra atingir a meta em 1 ano
- Contribuir / Retirar com nota (ex: "Salário outubro", "Dentista")
- Histórico das últimas 20 movimentações
| Método | Rota | Descrição |
|---|---|---|
| GET | /api/emergency-reserve |
Estado + métricas calculadas |
| PUT | /api/emergency-reserve |
Atualiza meta (target_months) + nota |
| POST | /api/emergency-reserve/contribute |
Adiciona dinheiro |
| POST | /api/emergency-reserve/withdraw |
Retira (emergência) |
| GET | /api/emergency-reserve/transactions |
Histórico (limit 1-100) |
target_amount = monthly_expenses × target_months
progress_pct = min(100, current / target_amount × 100)
recommended = max(0, (target_amount - current) / 12) # 1 ano
is_complete = current >= target_amount (e target > 0)
target_months: 1-24contribution: ≥ 0amount: > 0, ≤ R$ 1.000.000withdraw: amount ≤ current_amountnotes: max 500 chars (settings) / 200 chars (tx)
emergency_reserve (
id, user_id UNIQUE,
current_amount, target_months, monthly_contribution,
notes, updated_at
)
reserve_transactions (
id, user_id, amount, kind, -- 'contribute' ou 'withdraw'
note, created_at
)- CurrencyAPI (via jsdelivr CDN) — primária, sem rate limit, sem API key
- Banco Central do Brasil (BCB) — oficial, fallback
- Frankfurter (BCE) — fallback final
Todas as fontes são cacheadas por 24h. Refresh "lazy" — primeira request após 24h dispara nova busca.
Em alguns ambientes (como o Railway free tier), o outbound HTTP é bloqueado por padrão para domínios externos. Isso afeta as 3 fontes acima, e o botão de refresh vai mostrar a mensagem "Não foi possível buscar cotações agora".
Solução implementada: modo manual de cotação.
- Botão ✏️ no widget Câmbio (ao lado do 🔄 de refresh)
- Form com 2 inputs (USD e EUR) + botão Salvar
- Você digita os valores (Google, BCB, Wise — qualquer fonte)
- Salva no cache; o widget mostra
Atualizado: ... (manual) - O cálculo de saldo e conversão passam a usar esses valores
| Período | Fonte usada | Motivo da mudança |
|---|---|---|
| v1.x | AwesomeAPI (economia.awesomeapi.com.br) |
429 Quota Exceeded (mudaram política, exige API key) |
| v1.x | BCB + Frankfurter | Funcionavam local; Railway bloqueava outbound |
| v1.x+ | CurrencyAPI (jsdelivr) + modo manual | jsdelivr também bloqueado no Railway; modo manual cobre |
Todos os totais em BRL:
- Saldo no dashboard
- Saldos do histórico mensal
- Soma de investimentos (cálculo de rendimento mensal)
- Projeção de despesas
Itens em USD/EUR mostram o valor original + valor BRL convertido embaixo.
O app é instalável como app nativo no celular/desktop. Funciona offline com cache, splash screen e ícone próprio.
Android (Chrome/Edge)
- Abra o app no navegador
- Banner aparece: "Adicionar à tela inicial" — ou clique no botão Instalar (verde) no header
- O app fica na home screen com o ícone R$
iOS (Safari)
- Abra o app
- Botão de compartilhar (quadrado com seta) → "Adicionar à tela de início"
- Confirme o nome "FinControl"
Desktop (Chrome/Edge)
- Ícone de instalação na barra de URL
- Ou menu → "Instalar FinControl"
- Abre em janela própria (sem barra de URL)
- Instalável (home screen) com ícone R$ verde
- Splash screen com gradiente verde + logo
- Funciona offline: cache do shell + última versão dos CDNs
- Página offline customizada se o SW não tiver cache
- Shortcuts no launcher (Android): Nova renda, Nova despesa, Câmbio
- Auto-update: prompt de nova versão + auto-reload 8s
- Theme color combinando com o app (#10b981)
- Status bar translúcida (iOS) combinando com o app
manifest.json # Metadados (name, icons, shortcuts, theme_color)
sw.js # Service Worker (cache + offline)
offline.html # Fallback quando sem rede e sem cache
icons/
icon-192.png # Home screen (Android/iOS)
icon-512.png # Splash screen
icon-192.svg # Vetor (fallback + maskable)
| Recurso | Estratégia | Motivo |
|---|---|---|
API (/api/*) |
network-first → JSON "offline" | Sempre tenta fresh; offline falha gracefully |
| CDNs | stale-while-revalidate | Carrega do cache, atualiza em background |
| Front-end | network-first → cache → offline.html | Fresh quando possível, fallback robusto |
| POST/PUT/DELETE | network-only (não intercepta) | Mutations não podem ser cacheadas |
O Dockerfile precisa incluir os arquivos PWA no build. Certifique-se
que tem:
COPY manifest.json sw.js offline.html /app/
COPY icons/ /app/icons/Sem isso, o SW não registra, manifest falha com 404, e o app não fica instalável. (Já está corrigido no Dockerfile atual — vale checar depois de qualquer mudança na estrutura.)
Fin_Control/
├── index.html # App principal (protegido)
├── login.html # Tela de login
├── register.html # Tela de cadastro
├── manifest.json # PWA manifest (name, icons, shortcuts)
├── sw.js # Service Worker (cache + offline)
├── offline.html # Fallback quando sem rede
├── style.css # Estilos (inclui tema das telas de auth)
├── script.js # Lógica do app (consome a API)
├── api.js # Cliente HTTP + storage do token JWT
├── auth.js # Comportamento das telas de login/registro
├── icons/ # Ícones PWA
│ ├── icon-192.png
│ ├── icon-512.png
│ └── icon-192.svg
├── render.yaml # Deploy 1-clique no Render.com
├── security_audit.py # Auditoria automatizada (roda contra a URL em produção)
├── backend/
│ ├── app.py # Entry point Flask + hardening (CORS, headers)
│ ├── rate_limit.py # Flask-Limiter compartilhado
│ ├── database.py # Schema dual-backend (SQLite/Postgres) + helpers
│ ├── auth.py # Hash PBKDF2 + JWT + decorator @auth_required
│ ├── auth_routes.py # /api/auth/register, /login, /me, /logout
│ ├── data_routes.py # /api/data + CRUD + /api/history + /api/exchange-rates
│ ├── exchange_rates.py # Módulo de cotação (3 fontes + cache 24h + manual)
│ ├── test_api.py # Smoke test do backend
│ ├── test_e2e.py # Fluxo end-to-end
│ ├── test_new_features.py # future_incomes + monthly_history
│ ├── test_currency.py # Testes do multi-moeda
│ ├── test_bcb.py # Testes do fetch de cotação (BCB + manual)
│ ├── test_psycopg_compat.py # Compat psycopg 3 (DELETE/UPDATE/INSERT)
│ ├── requirements.txt
│ ├── Dockerfile # Imagem de produção (gunicorn) — inclui arquivos PWA
│ ├── Procfile # Heroku-style start command
│ ├── runtime.txt # Versão do Python
│ └── .gitignore
└── README.md
cd backend
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtpython app.pyO servidor escuta em
http://127.0.0.1:5000por padrão. Para mudar:PORT=8080 python app.py. O backend detecta automaticamente: semDATABASE_URL→ SQLite, comDATABASE_URL→ Postgres.
- Vá em http://127.0.0.1:5000/login.html ou
- Crie uma conta em
register.html - O servidor serve o front-end e a API pelo mesmo host (sem CORS no dev).
| Variável | Padrão | Descrição |
|---|---|---|
PORT |
5000 |
Porta HTTP |
FLASK_DEBUG |
0 |
1 para modo debug do Flask |
JWT_SECRET |
dev-secret-change-me-in-production |
Chave HMAC dos tokens (troque em prod!) |
DATABASE_URL |
(vazio) | Se começar com postgres:// ou postgresql:// → usa Postgres |
FINCONTROL_DB |
backend/fincontrol.db |
Caminho do arquivo SQLite (ignorado se DATABASE_URL setado) |
ALLOWED_ORIGINS |
https://fincontrol.pythonanywhere.com |
Origens CORS (vírgula) |
DISABLE_RATE_LIMIT |
(vazio) | 1 para desabilitar rate limit (só em testes) |
DISABLE_EXCHANGE_FETCH |
(vazio) | 1 para desabilitar fetch de cotação (só em testes) |
Todas as rotas de dados exigem o header Authorization: Bearer <token>.
Respostas em JSON. Erros vêm como {"error": "mensagem"}.
| Método | Rota | Body | Resposta |
|---|---|---|---|
| POST | /api/auth/register |
{username, email, password} |
201 {token, user} |
| POST | /api/auth/login |
{username, password} ou {email, password} |
200 {token, user} |
| GET | /api/auth/me |
— | 200 {user} |
| POST | /api/auth/logout |
— | 200 {ok: true} |
| Método | Rota | Descrição |
|---|---|---|
| GET | /api/data |
Snapshot completo (inclui exchangeRates) |
| GET | /api/<resource> |
Lista itens |
| POST | /api/<resource> |
Cria item |
| PUT | /api/<resource>/<id> |
Atualiza item |
| DELETE | /api/<resource>/<id> |
Remove item |
Recursos: incomes, passive_incomes, monthly_expenses,
future_expenses, future_incomes, investments.
Todos os recursos aceitam o campo opcional currency ("BRL", "USD", "EUR").
Default: "BRL".
// POST /api/monthly_expenses
{
"name": "Aluguel",
"value": 2200,
"category": "Moradia",
"currency": "BRL",
"due_day": 5
}
// POST /api/incomes (em USD)
{
"name": "Freela USD",
"value": 1000,
"category": "Freelance",
"currency": "USD"
}| Método | Rota | Descrição |
|---|---|---|
| GET | /api/history |
Lista snapshots salvos (mais recente primeiro) |
| POST | /api/history/snapshot |
Salva/atualiza um snapshot (recalcula totais se omitidos, convertendo para BRL) |
| PUT | /api/history/<id> |
Atualiza apenas as notas de um snapshot |
| DELETE | /api/history/<id> |
Remove um snapshot |
Exemplos:
// POST /api/history/snapshot
{
"month_year": "2026-07", // opcional: padrão = mês atual
"notes": "Mês de férias" // opcional
}| Método | Rota | Descrição |
|---|---|---|
| GET | /api/exchange-rates |
Retorna cotações cacheadas (BRL/1, USD, EUR) |
| POST | /api/exchange-rates/refresh |
Força refresh do cache (tenta APIs externas) |
| POST | /api/exchange-rates/manual |
Define cotações manualmente (fallback) |
Exemplo de body para cotação manual:
// POST /api/exchange-rates/manual
{ "USD": 5.07, "EUR": 5.85 }| Método | Rota | Descrição |
|---|---|---|
| GET | /api/health |
Status do serviço + info do DB |
O database.py detecta automaticamente o backend pela env DATABASE_URL:
postgres://oupostgresql://→ psycopg 3- caso contrário → SQLite (arquivo local)
Os placeholders ? são adaptados para %s quando o backend é Postgres,
mantendo o mesmo código SQL nas queries.
Tabelas principais:
users— contasincomes/passive_incomes— rendasmonthly_expenses/future_expenses/future_incomes— despesas e rendasinvestments— investimentosmonthly_history— snapshots mensais (totais já convertidos pra BRL)exchange_rates— cache de cotações (atualizado diariamente)
Todas as tabelas financeiras têm coluna currency (BRL/USD/EUR).
A coluna exchange_rates.code é TEXT UNIQUE; tem também um id próprio.
Idempotentes: rodam em todo boot do app, sem efeito colateral:
CREATE TABLE IF NOT EXISTSpara todasALTER TABLE ... ADD COLUMN IF NOT EXISTS(Postgres) ouPRAGMA table_info(SQLite)- Auto-repair: tabela
exchange_ratesé DROP+CREATE se schema quebrado
Seis scripts prontos, todos usam apenas a stdlib (urllib):
cd backend
python3 test_api.py # ~20 cenários: auth, validações, isolamento
python3 test_e2e.py # fluxo UI-equivalente completo
python3 test_new_features.py # future_incomes + monthly_history
python3 test_currency.py # multi-moeda (validação + conversão)
python3 test_bcb.py # fetch de cotação (BCB + manual)
python3 test_psycopg_compat.py # compat psycopg 3 (DELETE/UPDATE/INSERT)
# Pra rodar tudo
for t in test_*.py; do python3 "$t"; doneRecomendado usar DISABLE_RATE_LIMIT=1 ao rodar a suíte (a não ser que queira
testar rate limit também).
O app foi endurecido para uso público. Medidas implementadas:
- Senhas com PBKDF2-HMAC-SHA256, 200.000 iterações, salt aleatório de 16 bytes.
- JWT com
HS256e expiração de 7 dias. - Senha: 6–128 caracteres; usuário: 3-32 chars (
a-z A-Z 0-9 _ . -).
X-Content-Type-Options: nosniffX-Frame-Options: DENY(anti-clickjacking)Strict-Transport-Security: max-age=31536000; includeSubDomains(HSTS)Content-Security-Policy(CSP) restritivo, liberando só os CDNs usados pelo frontReferrer-Policy: strict-origin-when-cross-originPermissions-Policydesabilitando geolocation, câmera, microfone, etc.Serverheader reescrito paraFinControl(esconde stack)- Error handlers globais (404/405/500/Exception) retornam JSON com stacktrace nos logs
- Origens explícitas e restritas (configurável via
ALLOWED_ORIGINS). - Default:
https://fincontrol.pythonanywhere.com. - Em dev, defina
ALLOWED_ORIGINS=http://localhost:5000,http://127.0.0.1:5000.
/api/auth/logine/api/auth/register: 10 req/min e 30 req/hora por IP via Flask-Limiter.- Resposta
429 Too Many Requestsquando excede.
- Cada query SQL filtra por
user_iddo JWT — um usuário nunca vê dados de outro. - Todos os endpoints de dados exigem
Authorization: Bearer <token>; sem ele →401.
- Username regex, email regex, senha com limites.
- Currency normalizada pra UPPERCASE e validada ∈ {BRL, USD, EUR}.
- Payloads grandes / strings longas são rejeitados com
400. - Queries 100% parametrizadas (sem concatenação) — imune a SQL Injection clássico.
- Tokens
alg=nonee inválidos são rejeitados com401.
security_audit.py(na raiz do repo) faz 80+ verificações automatizadas contra uma URL alvo.
JWT_SECRET: gere um valor forte compython -c "import secrets; print(secrets.token_hex(32))". O defaultdev-secret-change-me-in-productioné apenas para dev local.ALLOWED_ORIGINS: defina o(s) domínio(s) do app, separados por vírgula.DATABASE_URL: connection string do Postgres/Neon em produção.
O Dockerfile está pronto pra Railway. O build context é a raiz do repo
(para incluir o front-end), e o CMD usa gunicorn em shell form para
interpretar $PORT.
Variáveis de ambiente obrigatórias no Railway:
DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
JWT_SECRET=$(python -c "import secrets; print(secrets.token_hex(32))")
ALLOWED_ORIGINS=https://seu-app.up.railway.appO init_db() roda em todo boot, criando/migrando as tabelas.