Um CLI Rust de binário único que transforma o extrato bancário de uma família em um banco de dados financeiro consultável, auditável e reportável — com app web local e pipeline para agentes de IA. “finanças da casa, inteligência de verdade.”
💡 Referências sublinhadas — ADRs e arquivos de código — abrem em modal na página, com link para o fonte no GitHub.
phai conecta ao agregador open-finance Pluggy, normaliza tudo num
store relacional e produz relatórios legíveis por humanos (padrão) ou JSON estruturado
(--raw) consumido por agentes e dashboards.
Bancos mostram lançamentos brutos. Uma família quer saber para onde o dinheiro realmente vai, em regime de caixa, sem mentir centavos.
Todo relatório, resposta de agente e tela deriva do store. Não há cache paralelo nem estado derivado que possa divergir.
Formato humano (agrupado, emoji, legível no celular) é o padrão; JSON é um par, não um penduricalho. Pipeline diário via WhatsApp.
rules
em runtime ou em config privada.
Binário único, zero cerimônia. Sem Postgres, sem Redis, sem servidor de dashboard. SQLite embarcado no binário; BigQuery por HTTPS. Migrations embutidas em tempo de compilação.
main.rs — árvore de subcomandos claphuman_format.rs — formatadores WhatsAppserve.rs — servidor web local (axum)enrich.rs, forecast_cmd.rs, pulse.rsupdate.rs — self-update atômicomcp.rs — servidor MCP read-onlymodels.rs — records do domíniostorage/ — trait FinanceStore + 2 implspluggy.rs — cliente REST + HMACrules.rs — engine de classificaçãosplits.rs, installments.rsidempotency.rs, invite.rsenrichment/ — LLM + heurísticas + CNPJ + fuzzyschema/sqlite/ — backend localschema/bigquery/ — backend produçãoTEXT · BigQuery NUMERICCREATE … IF NOT EXISTS, MERGESe o relatório discorda do banco, o relatório está errado.
Features que exigem processo longo, sidecar ou build na máquina do usuário são rejeitadas por padrão.
rust_decimal de ponta a ponta. Nunca f64. Erro de ponto flutuante em finanças é visível em segundos.
Cada mutação emite um AuditEvent append-only. O dataset é um log replayável.
O CLI nunca ramifica por backend. Semântica não casa → feature não embarca.
Nada pessoal no source compartilhado. Vazamento por heurística “inofensiva” é irrecuperável.
Todo relatório tem formato humano e --raw JSON.
Zero cerimônia de release. Merge na main → versão + changelog automáticos.
# phai sync pluggy Pluggy.list_items() → fetch_transactions() // TransactionRecord[] (Decimal) → store.existing_transaction_ids(ids) // BTreeSet<String> → store.upsert_transactions(novos + alterados) // idempotente, ON CONFLICT → store.insert_audit_events([Sync{…}]) // 1 evento por write → resumo humano | --json-summary
Chaves de idempotência derivadas do payload Pluggy em idempotency.rs.
Re-rodar sync é seguro: não duplica.
| Camada | Tecnologia | Nota |
|---|---|---|
| CLI | clap 4.5 (derive) | Árvore de subcomandos a partir de structs |
| Local | rusqlite 0.33 (bundled) | SQLite estático no binário |
| Cloud | BigQuery REST + service-account JWT | Sem SDK — REST manual mantém o grafo de deps fino |
| Dinheiro | rust_decimal 1.36 | Ponta a ponta |
| HTTP | reqwest (rustls) | Pluggy + BigQuery |
| Async | tokio rt-multi-thread | Trait de storage é async fn |
| IA | rig-core | Sugestões de classificação assistidas por LLM |
| Web | LiveStore + React (Vite) | Client-only, embarcado via include_dir! |
| Release | release-please + GitHub Actions | Tarballs assinados (minisign) + SHA-256 |
Shapes intencionalmente planos: uma linha entra, uma linha sai. Join e formatação acontecem em views SQL, não em structs.
FinanceStore — a única costuraasync, Send + Sync, ~50 métodos, 2 implementações. É larga de propósito:
o domínio é finito (não é ORM genérico), e relatórios são métodos de primeira classe
(daily_pulse, card_summary, cashflow…) porque empacotam a regra de negócio
que distingue “valor bruto” de “o que você deve ver”.
validate_table_name (allowlist).Decimal — nunca f64, nunca string-fingindo-decimal.AuditEvent — o caller insere; testes asseguram a linha de auditoria.ON CONFLICT/MERGE, nunca insert+dedup na aplicação.| Campo | Tipo | Significado |
|---|---|---|
id | String | Id estável — id Pluggy ou chave de idempotência derivada |
account_id | String | FK para AccountRecord |
posted_at | DateTime<Utc> | Data efetiva |
amount | Decimal | Sinalizado; despesa negativa, receita positiva. Normalização de sinal de cartão acontece nas views |
raw_description | String | Texto original do banco — usado por rules, auditoria, busca técnica |
description | Option<String> | Descrição humana curta do que foi comprado |
merchant_name | Option<String> | Nome limpo do estabelecimento (agrupamento/enriquecimento) |
purpose | Option<String> | Intenção/motivo humano da compra |
category | Option<String> | Categoria efetiva (rules + overrides); precedência resolvida nas views |
metadata | JSON | Payload do agregador — parcelas Pluggy, MCC, moeda original |
“Anatomia da transação” = os campos humanos (description/merchant_name/purpose)
separados do raw_description técnico — ADR-0014.
pub struct AuditEvent {
pub id: Uuid, // v7 — ordenável cronologicamente
pub actor_id: String, // quem disparou (CLI actor, agente, device)
pub action: String, // "tx.categorize", "sync.pluggy", "split.apply"
pub entity: String, // "transaction", "rule", "budget"
pub entity_id: String,
pub payload: Value, // snapshot before/after
pub created_at: DateTime<Utc>,
}
Gramática entity.verb: tx.upsert, tx.split.apply,
rule.upsert, forecast.upsert, sync.pluggy, import.legacy.
Conta bancária/cartão + snapshots de saldo ao longo do tempo. Cartões carregam
billing_closing_day/billing_due_day em metadata_json — base do regime de caixa.
Engine ordenado first-match-wins: condições sobre raw_description, conta, faixa de valor e data.
Rules são dados de runtime, não código — único caminho para lógica pessoal.
Lançamentos previstos + templates recorrentes (chave natural idempotente, ADR-0022).
Materialização determinística (tpl-{id}-{yyyymm}) faz MERGE, nunca empilha.
Um lançamento dividido em N linhas categorizadas; soma exata em Decimal.
Itens de recibo habilitam report item-prices. BigQuery-only hoje
Envelopes por categoria/mês; status orçado vs realizado.
Cada transação classificável por tier de comprometimento (fixo/variável…), com override
por transação (ADR-0030/0032). Tabela transaction_tier (migration 040).
idempotency.rs deriva chaves estáveis de payloads. Detalhe relevante (achado na auditoria):
a chave de forecast usa actor + descrição + data + valor + conta + categoria + recorrência + template_id —
status não entra. Por isso duas previsões que só diferem no vocabulário de status
(ativo vs active) geram a mesma chave e deduplicam corretamente.
Relatórios não leem tabelas cruas — leem uma única cadeia de views que assa a regra de negócio. É a regra que impede os números de fluxo de caixa de regredirem (ADR-0026). CLI, gráfico de cashflow e app web leem a mesma cadeia.
cash_month canônico: compra no cartão cai no mês em que a fatura é PAGAbilling_closing_day/billing_due_day
(phai account set-billing-cycle); sem isso, cai no mês-calendário do lançamento. (ADR-0025)credit-card-payment, transfer-internal,
same-person-transfer) são a única lista de exclusão, aplicada nas views — pagar fatura
ou mover entre duas contas próprias rastreadas nunca dupla-conta. Dinheiro de conta não rastreada
(ex.: repasse de salário) é renda real e não é excluído.FinanceStore::cashflow_reportable é um SELECT … FROM v_cashflow fino.
Regra nova vai na view ou na lista de exclusão — nunca numa query Rust ou na camada web.phai é a única fonte de verdade operacional. Agentes (OpenClaw, Claude, ChatGPT, Codex) preferem relatórios padrão a formatar à mão.
description quando presente; senão merchant_name; senão raw_description.classifier_trace é debug técnico — nunca aparece em relatório para a família.| Usuário pergunta | Fonte | Resposta |
|---|---|---|
| “fatura em aberto”, “atual”, “em andamento” | v_card_open_now / cards_open_now() | Saldo aberto mais recente por cartão — no máximo 1 linha por cartão |
| “como fecharam”, “fatura fechada”, “última fatura” | ciclos fechados | Default = último ciclo totalmente fechado; reporta total fechado = total_charges − open_amount primeiro |
| período | month_ref | É o ciclo de cobrança em que a fatura fecha (via billing_closing_day), não o mês-calendário do lançamento |
Ex.: compra em 28/mar com closing-day 3 vive no ciclo que fecha em 3/abr → month_ref = 2026-04.
O enriquecimento sugere; a tabela rules persiste. Estágios, todos opcionais:
pluggy_map → traduz dicas de categoria do Pluggy para a taxonomia local cnpj → consulta CNPJ (BrasilAPI) para enriquecer descrições heuristics → padrões genéricos NÃO-pessoais (moeda, parcela, sufixos comuns) fuzzy → casa descrição desconhecida com histórico rotulado (nucleo) llm → último recurso via rig-core; prompt.rs controla o system prompt rule_gen → propõe um RuleRecord de uma sugestão confiante; usuário aceita/rejeita
Replicação de revisão humana (ADR-0033): transações recorrentes herdam
description/purpose/categoria confiável de histórico do mesmo merchant — replicação
direta com scoring tolerante a valor, não criação automática de regra.
Formato humano agrupado, emoji, legível no celular. --raw JSON · --csv · --notify-summary.
phai serve — dashboard React em loopback. Ver seção 6.
ADR-0027: expõe self-exec read-only para assistentes de IA.
phai serve)LiveStore + React (Vite), client-only (sem backend de sync LiveStore),
embarcado no binário via include_dir!("web/dist"). Writes descarregam na ponte Rust
(/api/*), que é o sistema de registro BigQuery/SQLite (ADR-0023).
Sem autenticação por requisição — a fronteira de segurança é:
Protege: acesso remoto, DNS rebinding, CSRF cross-site, clickjacking. Fora de escopo (aceito): processo local malicioso — que já tem o filesystem do usuário.
| View | Arquivo | O que faz |
|---|---|---|
| Dashboard | views/Dashboard.tsx | Hero + gráfico de planejamento; deriva séries client-side (memoizado) |
| Gráfico | views/PlanningChart.tsx | Barras/linhas de cashflow; linha de saldo (sólida realizada, tracejada prevista); navegação por teclado |
| Mês | views/MonthDetail.tsx | Transações do mês, filtros, forecast section, planejados manuais |
| Cartões | views/cards/CardDetailPanel.tsx | Badges OPEN/CLOSED/SETTLED + tooltips (regra REPORTING_UX) |
| Categorias | views/categorias/CategoryTreemap.tsx | Treemap de gasto por categoria |
| Plano | views/plano/WarPlanPanel.tsx | Metas via envelopes, sliders, simulação |
| Onboarding | views/Onboarding.tsx | Gate de ativação multi-device (convite criptografado) |
Convite auto-contido criptografado por senha: empacota coordenadas BigQuery + chave de service-account
num único token PHAI1E-…. Argon2id deriva a chave; XChaCha20-Poly1305
cifra o payload. O blob é uma credencial. Fluxo zero-CLI: convite → install → onboarding web.
lib/derivations.ts (~1100 linhas) é o cérebro do front: filterTransactions,
groupByCategory, computeMonthSums, buildWarPlan, commitmentTier,
buildEnvelopeWrites. Funções puras memoizadas nos call-sites. Tudo deriva do store —
consistente com o princípio #1.
37 ADRs em docs/adr/. Os que mais explicam o sistema:
| ADR | Decisão | Consequência |
|---|---|---|
| 0001 | Binário único Rust | Sem runtime externo; tudo embarcado |
| 0002 | FinanceStore dual-backend | SQLite local + BigQuery prod atrás de 1 trait |
| 0003 | rust_decimal ponta a ponta | Zero float em dinheiro |
| 0005 | Audit events append-only | Dataset replayável; correções seguras |
| 0006 | Migrations espelhadas idempotentes | Paridade dos 2 backends revisável lado a lado |
| 0007 | Self-update atômico + SHA-256 | rename atômico + execv com sentinela anti-loop |
| 0010 / 0025 | Ciclo de cobrança + bill explosion | Regime de caixa: fatura explode no mês pago |
| 0014 / 0015 | Anatomia da transação + replicação | Campos humanos separados do texto técnico |
| 0016 / 0022 | Automação de forecast + chave natural | Templates recorrentes idempotentes |
| 0023 | App web LiveStore client-only | Sem servidor de sync; ponte Rust é o registro |
| 0026 | Cadeia de views canônica única | Números de cashflow não regridem |
| 0030 / 0032 | Commitment tiers + override por tx | Classificação de comprometimento granular |
| 0034 | Convite criptografado auto-contido | Ativação multi-device zero-CLI |
| 0036 | Ponte serve sem auth por requisição | Loopback + Host/Origin é a fronteira |
split_bigquery_only_error(). Portar exige
migration SQLite de split, tradução MERGE→UPSERT em 5 métodos, e reproduzir
item_prices sem a camada analítica do BigQuery (provável FTS5). É a única quebra deliberada do ADR-0002.
Auditoria de 2026-06-24 + 9 correções entregues. A base é sólida: cripto e SQL bem feitos, servidor loopback com defesas reais.
S1 cap em params Argon2 de envelope não-confiável (anti memory-bomb). S3 body limit explícito 512KB. SQL 100% bound + allowlist de tabela. Cripto: Argon2id+XChaCha20+AEAD.
B3 panic em card_open_bill_due_date com month_ref malformado (“2026-13”).
Dedup forecast confirmado correto + cobertura nova.
P1 maximumBytesBilled=20GiB (limita custo GCP).
P2/P3 cache moka LRU 256 + invalidação granular por recurso.
bump quinn-proto 0.11.15 (RUSTSEC-2026-0185) — destravou o cargo audit em todo branch.
filterTransactions — em sync mas sem teste (issue #227).