Skip to content

Repository files navigation

pfin

CLI-based personal finance system, usable by humans but best with an LLM assistant. Written in Rust: One binary, very fast. Light on dependencies.

Quick Summary

  • 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

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

CLI Commands

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.

Write Commands (mutate WAL + SQLite)

  • pf import <ofx_file> [--csv <csv_file>] — import OFX, create pending_imports
  • pf reconcile [--user <name>] — reconcile a pending import: create or update a transaction (JSON stdin). Requires --user or $PFIN_USER
  • pf update-txn — update an existing transaction (JSON stdin, partial update)
  • pf skip <import_id> [-r reason] — skip a pending import
  • pf unskip <import_id> — reopen a skipped import
  • pf create-account <name> -t <type> [-d <description>] — create a new account
  • pf annotate <id> "notes text" — add notes to a transaction or pending import (appends by default, --replace to overwrite)
  • pf assign <import_id> <user> [--notes "..."] — assign a pending import to a user for review
  • pf attach <txn_id> <file> [--note "..."] — attach a file to a transaction

Read Commands (read-only SQLite)

  • pf pending [options] — list pending imports (newest first, filterable)
  • pf accounts — list all accounts
  • pf balance <account> — show account balance
  • pf 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 (includes categorized_by)
  • pf query [sql] — run arbitrary read-only SQL (arg or stdin)
  • pf context <date> [--days N] — show activity around a date (±N days)

Email Commands (read-only Fastmail JMAP)

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 (includes attachments[].blob_id)
  • pf email download <blob_id> <dest> [--filename NAME] [-a account] — download attachment blob to local path
  • pf 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.pdf

Configuration: 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  # optional

Account resolution: --account NAMEPFIN_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.

Reconcile Examples

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 alice

Match import to existing transaction (minimal — just link it):

echo '{"import_id": "<uuid>", "transaction": {"id": "<txn-uuid>"}}' | pf reconcile --user alice

See pf reconcile --help for full JSON schema and update semantics.

Query Examples

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"

Development

cargo build          # Build
cargo run -- <cmd>   # Run CLI during development
cargo test           # Run tests
cargo clippy         # Lint
cargo fmt            # Format

Key Tables

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

About

CLI-based LLM-assistant-friendly personal finance system

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages