Arquitetura, Modelo de Dados & UX

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.”

v5.28.2 Rust 2021 · MSRV 1.90 ~53k LOC Rust + web 2 crates · 41 migrations 37 ADRs gerado 2026-06-24

💡 Referências sublinhadas — ADRs e arquivos de código — abrem em modal na página, com link para o fonte no GitHub.

01 Visão do produto

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.

PROBLEMA

Extrato ≠ entendimento

Bancos mostram lançamentos brutos. Uma família quer saber para onde o dinheiro realmente vai, em regime de caixa, sem mentir centavos.

ABORDAGEM

O banco é a verdade

Todo relatório, resposta de agente e tela deriva do store. Não há cache paralelo nem estado derivado que possa divergir.

ENTREGA

Humano + agente

Formato humano (agrupado, emoji, legível no celular) é o padrão; JSON é um par, não um penduricalho. Pipeline diário via WhatsApp.

Quem usa. Single-user/família. O autor roda em dinheiro real. É open source — por isso privacidade é regra de código: nenhum nome de contraparte, rótulo de conta ou “fingerprint” de extrato entra no código compartilhado. Classificação vive na tabela rules em runtime ou em config privada.

02 Arquitetura

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.

Mapa de camadas

🖥️ phai-cli binário
  • main.rs — árvore de subcomandos clap
  • human_format.rs — formatadores WhatsApp
  • serve.rs — servidor web local (axum)
  • enrich.rs, forecast_cmd.rs, pulse.rs
  • update.rs — self-update atômico
  • mcp.rs — servidor MCP read-only
  • web/ — app LiveStore+React embarcado
⚙️ phai-core domínio
  • models.rs — records do domínio
  • storage/ — trait FinanceStore + 2 impls
  • pluggy.rs — cliente REST + HMAC
  • rules.rs — engine de classificação
  • splits.rs, installments.rs
  • idempotency.rs, invite.rs
  • enrichment/ — LLM + heurísticas + CNPJ + fuzzy
🗄️ Persistência dual
  • schema/sqlite/ — backend local
  • schema/bigquery/ — backend produção
  • 41 migrations espelhadas, prefixo numérico compartilhado
  • Decimal: SQLite TEXT · BigQuery NUMERIC
  • Idempotentes: CREATE … IF NOT EXISTS, MERGE

Os 8 princípios que governam tudo

1 · O banco é a verdade

Se o relatório discorda do banco, o relatório está errado.

2 · Binário único

Features que exigem processo longo, sidecar ou build na máquina do usuário são rejeitadas por padrão.

3 · Decimal sempre

rust_decimal de ponta a ponta. Nunca f64. Erro de ponto flutuante em finanças é visível em segundos.

4 · Todo write é evento

Cada mutação emite um AuditEvent append-only. O dataset é um log replayável.

5 · Dual backend atrás de 1 trait

O CLI nunca ramifica por backend. Semântica não casa → feature não embarca.

6 · Privacidade é regra de código

Nada pessoal no source compartilhado. Vazamento por heurística “inofensiva” é irrecuperável.

7 · Humano por padrão, agente sob demanda

Todo relatório tem formato humano e --raw JSON.

8 · Conventional Commits + Release Please

Zero cerimônia de release. Merge na main → versão + changelog automáticos.

Fluxo de sync (Pluggy → store)

# 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.

Stack técnico

CamadaTecnologiaNota
CLIclap 4.5 (derive)Árvore de subcomandos a partir de structs
Localrusqlite 0.33 (bundled)SQLite estático no binário
CloudBigQuery REST + service-account JWTSem SDK — REST manual mantém o grafo de deps fino
Dinheirorust_decimal 1.36Ponta a ponta
HTTPreqwest (rustls)Pluggy + BigQuery
Asynctokio rt-multi-threadTrait de storage é async fn
IArig-coreSugestões de classificação assistidas por LLM
WebLiveStore + React (Vite)Client-only, embarcado via include_dir!
Releaserelease-please + GitHub ActionsTarballs assinados (minisign) + SHA-256

03 Modelo de dados

Shapes intencionalmente planos: uma linha entra, uma linha sai. Join e formatação acontecem em views SQL, não em structs.

A trait FinanceStore — a única costura

async, 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”.

upsert_transactionsexisting_transaction_ids cashflow / cashflow_monthcard_summary / cards_open_now monthly_spendforecast_vs_actual daily_pulseapply_transaction_split set_commitment_tierinsert_audit_events validate_table_name 🔒

Invariantes da trait

  1. Nenhum valor interpolado em SQL. Todo parâmetro é bound. O único identificador dinâmico permitido é nome de tabela, e passa por validate_table_name (allowlist).
  2. Relatórios retornam Decimal — nunca f64, nunca string-fingindo-decimal.
  3. Todo write emite AuditEvent — o caller insere; testes asseguram a linha de auditoria.
  4. Upserts idempotentesON CONFLICT/MERGE, nunca insert+dedup na aplicação.
  5. Migrations append-only — prefixos monotônicos; migration liberada nunca é editada, só superseded.

Entidades centrais

TransactionRecord — a unidade atômica

CampoTipoSignificado
idStringId estável — id Pluggy ou chave de idempotência derivada
account_idStringFK para AccountRecord
posted_atDateTime<Utc>Data efetiva
amountDecimalSinalizado; despesa negativa, receita positiva. Normalização de sinal de cartão acontece nas views
raw_descriptionStringTexto original do banco — usado por rules, auditoria, busca técnica
descriptionOption<String>Descrição humana curta do que foi comprado
merchant_nameOption<String>Nome limpo do estabelecimento (agrupamento/enriquecimento)
purposeOption<String>Intenção/motivo humano da compra
categoryOption<String>Categoria efetiva (rules + overrides); precedência resolvida nas views
metadataJSONPayload 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.

AuditEvent — log append-only de toda mutação

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.

Outras entidades do domínio

CONTAS & SALDOS

AccountRecord · AccountSnapshotRecord

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.

CLASSIFICAÇÃO

RuleRecord · CategoryRecord

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.

PREVISÃO

ForecastRecord · ForecastTemplateRecord

Lançamentos previstos + templates recorrentes (chave natural idempotente, ADR-0022). Materialização determinística (tpl-{id}-{yyyymm}) faz MERGE, nunca empilha.

DIVISÃO

TransactionSplitPayload · lines · items

Um lançamento dividido em N linhas categorizadas; soma exata em Decimal. Itens de recibo habilitam report item-prices. BigQuery-only hoje

ORÇAMENTO

CategoryBudgetRecord · BudgetStatusRow

Envelopes por categoria/mês; status orçado vs realizado.

COMPROMETIMENTO

Commitment tiers

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).

Idempotência

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.

04 A cadeia de views canônica

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.

transactions
linhas cruas — Pluggy / OFX / legado / manual
v_transactions_effective
expansão de splits + display labels
v_transactions_reportable
dedup: descarta linha legado/OFX sombreada por linha Pluggy (anti-join)
v_transactions_cashbasis
+ cash_month canônico: compra no cartão cai no mês em que a fatura é PAGA
├─ v_cashflow — receita/despesa/líquido mensal (regime de caixa)
└─ v_monthly_spend — gasto mensal por categoria

O que isso garante

Por que importa. Centralizar dedup + classificação dentro da cadeia é o que estabiliza os números. Qualquer relatório que re-derive isso por fora é um bug esperando para acontecer.

05 UX — a voz dos relatórios

phai é a única fonte de verdade operacional. Agentes (OpenClaw, Claude, ChatGPT, Codex) preferem relatórios padrão a formatar à mão.

Regras de naming & classificação

Desambiguação de fatura (a sutileza que mais confunde)

Usuário perguntaFonteResposta
“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 fechadosDefault = último ciclo totalmente fechado; reporta total fechado = total_charges − open_amount primeiro
períodomonth_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.

Pipeline de enriquecimento (propõe, não escreve)

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.

Superfícies de interação

CLI

Terminal / WhatsApp

Formato humano agrupado, emoji, legível no celular. --raw JSON · --csv · --notify-summary.

SERVE

App web local

phai serve — dashboard React em loopback. Ver seção 6.

MCP

Servidor read-only

ADR-0027: expõe self-exec read-only para assistentes de IA.

06 O app web (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).

Modelo de segurança da ponte (ADR-0036)

Sem autenticação por requisição — a fronteira de segurança é:

bind 127.0.0.1 apenas Host allowlist (anti-DNS-rebinding) Origin/CSRF allowlist CSP + headers de segurança body limit 512KB

Protege: acesso remoto, DNS rebinding, CSRF cross-site, clickjacking. Fora de escopo (aceito): processo local malicioso — que já tem o filesystem do usuário.

Telas principais

ViewArquivoO que faz
Dashboardviews/Dashboard.tsxHero + gráfico de planejamento; deriva séries client-side (memoizado)
Gráficoviews/PlanningChart.tsxBarras/linhas de cashflow; linha de saldo (sólida realizada, tracejada prevista); navegação por teclado
Mêsviews/MonthDetail.tsxTransações do mês, filtros, forecast section, planejados manuais
Cartõesviews/cards/CardDetailPanel.tsxBadges OPEN/CLOSED/SETTLED + tooltips (regra REPORTING_UX)
Categoriasviews/categorias/CategoryTreemap.tsxTreemap de gasto por categoria
Planoviews/plano/WarPlanPanel.tsxMetas via envelopes, sliders, simulação
Onboardingviews/Onboarding.tsxGate de ativação multi-device (convite criptografado)

Ativação multi-device (ADR-0034)

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.

Derivações client-side

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.

07 Decisões arquiteturais que moldam o resto

37 ADRs em docs/adr/. Os que mais explicam o sistema:

ADRDecisãoConsequência
0001Binário único RustSem runtime externo; tudo embarcado
0002FinanceStore dual-backendSQLite local + BigQuery prod atrás de 1 trait
0003rust_decimal ponta a pontaZero float em dinheiro
0005Audit events append-onlyDataset replayável; correções seguras
0006Migrations espelhadas idempotentesParidade dos 2 backends revisável lado a lado
0007Self-update atômico + SHA-256rename atômico + execv com sentinela anti-loop
0010 / 0025Ciclo de cobrança + bill explosionRegime de caixa: fatura explode no mês pago
0014 / 0015Anatomia da transação + replicaçãoCampos humanos separados do texto técnico
0016 / 0022Automação de forecast + chave naturalTemplates recorrentes idempotentes
0023App web LiveStore client-onlySem servidor de sync; ponte Rust é o registro
0026Cadeia de views canônica únicaNúmeros de cashflow não regridem
0030 / 0032Commitment tiers + override por txClassificação de comprometimento granular
0034Convite criptografado auto-contidoAtivação multi-device zero-CLI
0036Ponte serve sem auth por requisiçãoLoopback + Host/Origin é a fronteira
Dívida de paridade conhecida. Splits + analytics de itens de recibo são BigQuery-only hoje. O SQLite retorna split_bigquery_only_error(). Portar exige migration SQLite de split, tradução MERGEUPSERT 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.

08 Estado atual — release v5.28.2

Auditoria de 2026-06-24 + 9 correções entregues. A base é sólida: cripto e SQL bem feitos, servidor loopback com defesas reais.

SEGURANÇA

Sólida, 2 hardenings

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.

BUGS

1 crash real corrigido

B3 panic em card_open_bill_due_date com month_ref malformado (“2026-13”). Dedup forecast confirmado correto + cobertura nova.

PERFORMANCE

Cache + custo BQ

P1 maximumBytesBilled=20GiB (limita custo GCP). P2/P3 cache moka LRU 256 + invalidação granular por recurso.

DEPENDÊNCIAS

RUSTSEC fechado

bump quinn-proto 0.11.15 (RUSTSEC-2026-0185) — destravou o cargo audit em todo branch.

Gates do projeto

cargo fmt --check clippy -D warnings test --workspace (563 ✓) cargo audit cargo deny licenses sentrux gate (quality ratchet)

Pontos de atenção em aberto