CLI-based personal finance system, usable by humans but best with an LLM assistant. Written in Rust: One binary, very fast. Light on dependencies.
- Double-entry bookkeeping (transactions have 2+ splits that sum to zero)
- WAL (JSONL, git-versioned) is the source of truth
- SQLite is a derived, disposable query layer (.gitignored, rebuilt from WAL)
- CLI tool (
pf) is the primary interface - Single-user deployment
# Install the Rust CLI (installs `pf` to ~/.cargo/bin/)
cargo install --path .
# Install the Python OFX parser (installs `pfin-parse-ofx` to ~/.local/bin/)
uv tool install .Run pf --help for the full list, or pf <command> --help for detailed usage
and JSON schemas.
Output format: Commands that return lists (accounts, pending, search,
query, context) output compact JSONL (one JSON object per line) when
outputting JSON. Single-object commands (show, balance, reconcile, etc.)
output pretty-printed JSON.
pf import <ofx_file> [--csv <csv_file>]— import OFX, create pending_importspf reconcile [--user <name>]— reconcile a pending import: create or update a transaction (JSON stdin). Requires--useror$PFIN_USERpf update-txn— update an existing transaction (JSON stdin, partial update)pf skip <import_id> [-r reason]— skip a pending importpf unskip <import_id>— reopen a skipped importpf create-account <name> -t <type> [-d <description>]— create a new accountpf annotate <id> "notes text"— add notes to a transaction or pending import (appends by default,--replaceto overwrite)pf assign <import_id> <user> [--notes "..."]— assign a pending import to a user for reviewpf attach <txn_id> <file> [--note "..."]— attach a file to a transaction
pf pending [options]— list pending imports (newest first, filterable)pf accounts— list all accountspf balance <account>— show account balancepf search <query> [-n limit] [--after DATE] [--before DATE] [--oldest-first]— full-text search transactions (newest first by default)pf show <id>— show full details of a transaction or pending import (includescategorized_by)pf query [sql]— run arbitrary read-only SQL (arg or stdin)pf context <date> [--days N]— show activity around a date (±N days)
Search Fastmail email to find order confirmations, booking receipts, etc. during transaction categorization. Supports multiple accounts.
pf email search <query> [--from X] [--to X] [--subject X] [--body X] [--mailbox X] [--has-attachment] [--after DATE] [--before DATE] [-n limit] [-a account]pf email get <id> [-a account]— get full email content by ID (includesattachments[].blob_id)pf email download <blob_id> <dest> [--filename NAME] [-a account]— download attachment blob to local pathpf email mailboxes [-a account]— list mailboxes/folders
pf email search "amazon order" --after 2026-03-01
pf email search "hilton" --from noreply@hilton.com --account spouse
pf email get Mdeadbeef1 --account alice
pf email download G1234abcd /tmp/receipts/ # writes /tmp/receipts/G1234abcd.bin
pf email download G1234abcd /tmp/ --filename receipt.pdfConfiguration: Set Fastmail API tokens (read-only, mail-scope) via env vars or
a .env file (auto-loaded via dotenvy):
PFIN_EMAIL_TOKEN_ALICE=fmu1-...
PFIN_EMAIL_TOKEN_BOB=fmu1-...
PFIN_EMAIL_DEFAULT_ACCOUNT=alice # optionalAccount resolution: --account NAME → PFIN_EMAIL_TOKEN_{NAME}, else
PFIN_EMAIL_DEFAULT_ACCOUNT, else auto-detect if only one token is set.
PFIN_ENV_FILE=<path> loads an additional dotenv file at startup (in
addition to the cwd .env). Already-set env vars take precedence, so
inline values (e.g. PFIN_DATA set by a parent process) still win.
Used by Brenn to inject per-app Fastmail tokens.
Create a new transaction from an import:
echo '{
"import_id": "<uuid>",
"transaction": {
"description": "Grocery store",
"date": "2024-01-15",
"splits": [
{"account": "Checking", "amount": "-42.50"},
{"account": "Exp:Food", "amount": "42.50"}
]
}
}' | pf reconcile --user aliceMatch import to existing transaction (minimal — just link it):
echo '{"import_id": "<uuid>", "transaction": {"id": "<txn-uuid>"}}' | pf reconcile --user aliceSee pf reconcile --help for full JSON schema and update semantics.
echo "SELECT t.date, t.description, s.amount
FROM splits s JOIN transactions t ON s.transaction_id = t.id
JOIN accounts a ON s.account_id = a.id
WHERE a.name = 'Checking' ORDER BY t.date" | pf query
pf query "SELECT status, count(*) as cnt FROM pending_imports GROUP BY status"cargo build # Build
cargo run -- <cmd> # Run CLI during development
cargo test # Run tests
cargo clippy # Lint
cargo fmt # Format| Table | Key Columns |
|---|---|
accounts |
id, name, type, description |
transactions |
id, date, description, notes, categorized_by |
splits |
id, transaction_id, account_id, amount, memo, reference, post_date |
pending_imports |
id, account_id, external_id, date, amount, payee, memo, notes, assigned_to, status |
account_mappings |
id, external_id (OFX account ID), account_id |
FTS tables: transactions_fts (description, notes), splits_fts (memo), pending_imports_fts (payee, memo, notes).