Carteira digital de investimentos escrita inteiramente em Rust — backend e frontend servidos pelo mesmo binário. API REST administrativa (JSON) e interface do usuário renderizada no servidor (SSR), com valores monetários exatos, operações transacionais e cotações de mercado reais.
- Carteira completa — saldo em caixa, depósito, compra e venda de ativos ao preço de mercado, custo médio ponderado por posição, lucro/prejuízo por ativo e resumo do patrimônio.
- Extrato — livro-razão imutável de transações (depósitos, compras, vendas), paginado na interface e exportável em CSV.
- Cotações reais — preços (
USD→BRL,BTC→BRL) da API pública da Coinbase, com sincronização agendada em segundo plano (e botão manual), aplicada em um únicoUPDATE(sem N+1). - Feedback nas operações — sucessos e erros de negócio viram banners acessíveis em pt-BR (flash messages), nunca JSON cru na tela.
- Operações sem recarregar a página — os formulários e a navegação da carteira trocam só o fragmento HTML do miolo via htmx (servido do próprio binário); sem JavaScript tudo continua funcionando pelo fluxo clássico de redirect (progressive enhancement), e o servidor segue dono do HTML (SSR).
- Interface multi-idioma — pt-BR e inglês, escolhidos pelo seletor da
interface (cookie) ou pelo
Accept-Languagedo navegador; mensagens de feedback acompanham o idioma. Moeda e datas ficam na convenção do dado (BRL), não da interface. - Autenticação e sessão — cadastro/login com hash de senha (argon2); sessão
com JWT de acesso curto + refresh token rotacionado e revogável
(logout mata a sessão no servidor), ambos em cookies
HttpOnly+SameSite=Strict; lockout progressivo contra força bruta e CSRF tokens em todos os formulários. - API administrativa — catálogo de ativos sob
/api/v1, autorizada por papel de usuário (sessão de admin) ou por credencial de serviço com comparação em tempo constante. - Pronto para orquestração — migrações aplicadas no boot, sondas de
liveness/readiness separadas (
/healthz,/readyz), desligamento gracioso, logs estruturados comrequest_idpor requisição (JSON opcional), traces e métricas exportáveis via OTLP e imagem Docker multi-stage.
| Tema | Decisão |
|---|---|
| Dinheiro | rust_decimal::Decimal ↔ NUMERIC no Postgres. Ponto flutuante nunca toca valor monetário. Escala canônica de 8 casas em toda gravação (MONEY_SCALE) e ROUND nos agregados do SQL: NUMERIC é ilimitado, Decimal tem 28 dígitos significativos — sem o invariante, um preço vindo de 1/taxa torna somas e produtos indecodificáveis na leitura. |
| Consistência | Compra/venda/depósito rodam em transação com FOR UPDATE; saldo insuficiente reverte tudo. O schema tem CHECKs (saldo, preço e quantidade não negativos) como última linha de defesa. |
| Modelo de dados | holdings materializa a posição atual por (usuário, ativo); transactions é o histórico imutável. Leituras triviais, escrita explícita. |
| SQL | sqlx::query_as! — toda query é checada em tempo de compilação contra o banco. Schema divergente = não compila. |
| Injeção de dependência | Extratores do Axum (Repository, User, Admin): a assinatura do handler declara o que ele exige; sem satisfazer, o handler nem roda. |
| Sessão | JWT de acesso curto (stateless) + refresh token opaco com rotação a cada uso e hash SHA-256 no banco — revogável de verdade, replay de token queimado não funciona. Renovação transparente via middleware. |
| Defesas HTTP | CSRF double-submit nos formulários, lockout com backoff no login, CSP + nosniff + X-Frame-Options + Referrer-Policy em toda resposta, HSTS atrás de HTTPS. |
| Erros | Enum único (AppError) mapeado para status HTTP corretos; falhas 5xx são logadas com causa raiz e respondidas com mensagem genérica (nada de detalhe interno na resposta). |
| Configuração | Lida e validada uma vez no boot (fail-fast): segredo ausente derruba o serviço com mensagem clara, não um 401 confuso em produção. |
| Templates | Askama — variáveis dos templates também checadas em compilação. |
| Interatividade | htmx com HTML parcial: a mesma visão da carteira renderiza a página inteira (assets.html) ou só o fragmento (wallet.html) conforme o header HX-Request; operações respondem o fragmento atualizado na própria resposta (uma requisição, flash inline, HX-Push-Url). Sem o header (sem JS, restauração de histórico), vale o PRG clássico. |
| i18n | Catálogo tipado (i18n::Strings, uma const por idioma): texto faltando é erro de compilação, e o askama checa os campos usados nos templates. Resolução: cookie lang > Accept-Language > pt-BR. |
| CSS | Compilado em build-time pelo CLI standalone do Tailwind (executável único — sem Node e sem npm, então o build não herda a cadeia de suprimentos do ecossistema JS) e versionado como o cache .sqlx, com o CI conferindo o frescor. O source(none) desliga a varredura automática: sem ele o gerador lê o próprio output e o build deixa de ser determinístico entre plataformas. |
| Cor | Paleta validada por script (banda de luminosidade, croma, separação sob daltonismo, contraste) contra a superfície real. Verde↔vermelho medem ΔE ~4,6 sob deuteranopia, então nenhuma variação é comunicada só por cor — sempre com seta ▲/▼ e sinal. O acento violeta não disputa hue com o par lucro/prejuízo. |
src/
main.rs # enxuta: tokio::main -> App::start()
app.rs # boot, AppState { db, config, ... }, /health, shutdown gracioso,
# tracing + métricas (exportação OTLP opcional)
config.rs # Config: lê e valida o ambiente uma vez (fail-fast)
i18n.rs # idiomas da interface (pt-BR/en): catálogo tipado + extrator
models.rs # Asset, UserRecord, WalletSummary, Holding, Transaction
error.rs # AppError + IntoResponse (status HTTP, censura de 5xx)
quotes.rs # cotações de mercado (Coinbase) -> preços dos ativos
repository.rs # todo o acesso ao banco (queries + transações) + testes
auth/
admin.rs # extrator Admin (sessão com role admin OU credencial de serviço)
user.rs # User/UnauthenticatedUser, hash de senha, JWT, extratores
session.rs # refresh token (rotação/revogação) + middleware de renovação
csrf.rs # proteção CSRF (double-submit cookie)
throttle.rs # lockout progressivo de login
services/
portfolio.rs # PortfolioService: visão da carteira + operações, genérico sobre
# o trait PortfolioRepository (testável sem banco)
routes/
api.rs # API REST administrativa (JSON) + OpenAPI + testes de snapshot
frontend.rs # SSR: login/logout, carteira, operações, filtros Askama
templates/ # base.html (esqueleto) + login.html + assets.html
# + wallet.html (fragmento parcial trocado pelo htmx)
migrations/ # schema versionado, up/down reversíveis
| Método | Rota | Auth | Descrição |
|---|---|---|---|
GET |
/healthz |
— | Liveness: o processo responde (não depende do banco) |
GET |
/readyz · /health |
— | Readiness: pronto para tráfego (banco respondendo) |
GET |
/login · /register |
— | Formulários de login / cadastro |
POST |
/login · /register |
— | Autentica ou cadastra; grava o cookie de sessão |
GET |
/logout |
— | Revoga a sessão no servidor e remove os cookies |
GET |
/ |
opcional | Com sessão vai para /assets; sem, para /login |
GET |
/assets |
sessão | Carteira: saldo, posições, resumo e extrato (paginado via ?page=) |
GET |
/transactions.csv |
sessão | Download do extrato completo em CSV |
GET |
/static/app.css · /static/htmx.js |
— | Assets servidos do próprio binário (sem CDN) |
GET |
/deposit · /buy · /sell |
sessão | Carteira com o formulário da operação aberto |
GET |
/lang/{code} |
— | Troca o idioma da interface (pt-BR/en) e volta para ?next= |
POST |
/deposit |
sessão | Deposita saldo (amount) |
POST |
/buy |
sessão | Compra um ativo (asset_id, quantity) ao preço atual |
POST |
/sell |
sessão | Vende um ativo (asset_id, quantity) ao preço atual |
POST |
/quotes/sync |
sessão | Atualiza os preços com cotações de mercado |
Escritas exigem sessão de um usuário com papel admin ou o header de
serviço Authorization: <ADMIN_SECRET_KEY>.
| Método | Rota | Auth | Descrição |
|---|---|---|---|
GET |
/api/v1/assets |
— | Lista os ativos |
POST |
/api/v1/assets |
admin | Cadastra um ativo ({name, unit_value}) |
PATCH |
/api/v1/assets |
admin | Atualiza um ativo ({id, name?, unit_value?}) |
GET |
/api/v1/openapi.json |
— | Especificação OpenAPI gerada do código |
Erros: 400 entrada inválida (header ausente, nome vazio, preço negativo,
quantia não positiva, saldo/posição insuficiente, username em uso), 401
credencial ou token inválido, 403 token CSRF ausente/divergente, 404
recurso inexistente, 429 lockout por excesso de tentativas de login, 502
cotação indisponível, 500 falha interna (detalhes apenas no log do servidor).
Variáveis de ambiente (ver .env.example):
| Variável | Obrigatória | Descrição |
|---|---|---|
DATABASE_URL |
sim | Conexão com o Postgres |
ADMIN_SECRET_KEY |
sim | Credencial da API administrativa |
JWT_SECRET |
sim | Chave de assinatura dos tokens de sessão |
COOKIE_SECURE |
não (false) |
Marca os cookies como Secure e liga o HSTS (use true atrás de HTTPS) |
BIND_ADDR |
não (0.0.0.0:3000) |
Endereço/porta de escuta |
SESSION_TTL_MINUTES |
não (10) |
Validade do token de acesso |
REFRESH_TTL_DAYS |
não (14) |
Validade do refresh token (sessão no servidor) |
LOG_FORMAT |
não (texto) | json emite uma linha JSON por evento (agregadores de log) |
QUOTES_SYNC_MINUTES |
não (10) |
Intervalo do job de cotações (0 desliga) |
RUST_LOG |
não (info) |
Nível de log (ex.: wallet=debug,info) |
OTEL_EXPORTER_OTLP_ENDPOINT |
não (desligado) | Endpoint OTLP (HTTP) para exportar traces e métricas — ausente, nada é exportado |
OTEL_SERVICE_NAME |
não (wallet) |
Nome do serviço no backend de observabilidade |
Pré-requisitos: Rust e Docker (ou um Postgres próprio).
# 1) subir o Postgres
docker compose up -d
# 2) configurar o ambiente (copie o exemplo e ajuste os segredos)
Copy-Item .env.example .env
# 3) rodar — as migrações são aplicadas automaticamente no boot
cargo runO cache
.sqlx/versionado permite compilar sem banco (SQLX_OFFLINE=true). Para o fluxo de desenvolvimento com banco vivo, a CLI ajuda a criar novas migrações e a regenerar o cache:cargo install sqlx-cli --no-default-features --features postgres,rustlse depoiscargo sqlx migrate add -r <nome>/cargo sqlx prepare.
docker compose --profile app up --buildBuilda a imagem de produção (multi-stage, binário único com templates e
migrações embutidos) e sobe app + banco com healthchecks. Em máquinas atrás de
proxy corporativo ou antivírus com inspeção TLS, veja
docker/extra-ca/README.md.
Abra http://localhost:3000, cadastre um usuário e use a carteira: deposite, compre/venda ativos e sincronize as cotações. A sessão persiste no cookie; token removido, adulterado ou expirado leva de volta ao login.
Com OTEL_EXPORTER_OTLP_ENDPOINT definida, cada requisição HTTP vira um trace
(span request, com os spans dos handlers #[instrument] aninhados dentro) e
alimenta o histograma http.server.request.duration (rotulado por método,
rota e status) — exportados via OTLP/HTTP para o endpoint configurado. Sem a
variável, nada disso roda: zero tentativa de conexão, zero overhead.
Para ver a exportação funcionando localmente, sem montar um backend de observabilidade de verdade:
docker compose --profile observability up -d otel-collector
$env:OTEL_EXPORTER_OTLP_ENDPOINT = 'http://localhost:4318'
cargo rundocker compose logs -f otel-collector mostra cada trace e cada ponto de
métrica recebido — o coletor só imprime, não repassa a lugar nenhum
(docker/otel-collector/config.yaml).
$admin = @{ Authorization = $env:ADMIN_SECRET_KEY }
Invoke-RestMethod http://127.0.0.1:3000/api/v1/assets
Invoke-RestMethod -Method Post http://127.0.0.1:3000/api/v1/assets -Headers $admin `
-ContentType 'application/json' -Body '{"name":"bitcoin","unit_value":10}'
Invoke-RestMethod -Method Patch http://127.0.0.1:3000/api/v1/assets -Headers $admin `
-ContentType 'application/json' -Body '{"id":1,"unit_value":20}'cargo test#[sqlx::test]cria um banco efêmero por teste (migrações aplicadas automaticamente), então os testes são isolados e paralelos.- O núcleo financeiro tem cobertura dedicada: depósito, compra, venda, custo médio ponderado, guardas de saldo/posição insuficientes e validação de entradas.
- O contrato JSON da API é congelado com insta (snapshot testing):
cargo insta reviewpara auditar mudanças de formato. - A orquestração do
PortfolioService(montagem daWalletView, propagação de erro de depósito/compra/venda) é testada contra um dublê em memória doPortfolioRepository— sem Postgres, sem#[sqlx::test].
O plano de evolução — segurança de sessão, camada de serviço, CI/CD, observabilidade e novas funcionalidades — está em ROADMAP.md.
axum (+ axum-extra), tokio, sqlx (Postgres, compile-time checked), askama, rust_decimal, password-auth (argon2), jwt-simple, subtle, reqwest, tracing, thiserror, color-eyre, serde, utoipa (OpenAPI). Em testes: insta.
- TLS do cargo: o download de dependências pode falhar com
CRYPT_E_NO_REVOCATION_CHECK; o.cargo/config.tomljá desativa só a checagem de revogação. - Postgres 18 no Docker: o volume é montado em
/var/lib/postgresql(convenção da imagem 18+). jwt-simplesemcmake: configurado compure-rustpara dispensar BoringSSL/cmake.