personal-finance
Local-first personal finance tracker. Imports bank transactions from CSV
exports, categorizes them with substring rules, detects internal transfers,
tracks net worth via nightly balance snapshots, and serves a dashboard +
review UI at /personal-finance over Tailscale. Launchd-scheduled nightly
rollup with an iMessage ping when uncategorized transactions pile up.
Status
Phase 1 (MVP) shipped. CSV import, transfer detection, rule-based
categorization, nightly rollup, dashboard, and /personal-finance* routes
on webpage-server.py all work end-to-end with the test suite green
(30 pytest cases in tests/). Phases 1.5 (LLM categorization), 2 (Plaid),
and 3 (budgets + tax export + anomaly detection) are still in PLAN.md
and unimplemented.
Scope
personal-finance answers one question: "where did my money go this month, and what still needs categorizing?"
At the end of every day, launchd kicks rollup, which:
1. Re-applies all active category rules.
2. Writes one balance snapshot per account.
3. Recomputes month-to-date spend, income, and uncategorized count.
4. If there are uncategorized rows and ≥24h since the last ping,
sends an iMessage pointing at /personal-finance/review.
Ingestion is manual CSV-in-the-loop by design: Mark downloads a CSV from
each bank's website, runs personal-finance.py import --account <id>
<file.csv>, and the dedup layer handles idempotency so re-running never
creates duplicates.
Out of scope (Phase 1): - Multi-user, multi-currency. - Automated scraping or brokerage API sync — CSV is the interface. - Budgets / budget alerts (Phase 3). - LLM-based auto-categorization (Phase 1.5). - Plaid / live sync (Phase 2). - Tax export, anomaly detection, Zestimate real-estate tracking (Phase 3).
Investment / brokerage PnL lives in ../day-trading/, not here.
Architecture
Pipeline (one rollup run)
flock .lock ← single-run enforcement; second invocation exits
↓
migrate ← db._user_version + SCHEMA_V1 idempotent apply
↓
ensure_accounts ← UPSERT every [[accounts]] row from config.toml
↓
apply_rules ← categorize.apply_rules over NULL-category rows
↓
snapshot_balances ← one balance_snapshots row per account per day
↓
month_summary ← spend_total, income_total, uncat, category_breakdown
↓
should_ping? ← ≥24h since rollup_state.last_ping_ts AND uncat>0
↓ yes
ping_text ← "Finance: $X spent this month. N txns need categorizing: <url>"
↓
deliver.send_imessage ← bb-send.sh +phone "<text>"; record_ping on success
Manual CSV flow (personal-finance.py import):
src.csv
↓
_archive_csv ← copy to imports/<sha1>.csv (content-addressed)
↓
_check_header ← KeyError fast if mapper columns missing
↓
for row in DictReader ← mapper.date_col + amount_col + description_col
↓
parse_date + parse_amount + apply_sign
↓
content_hash ← sha1(acct + date + amt + desc + balance_after)
↓
INSERT OR IGNORE ← UNIQUE(acct, bank_txn_id) + UNIQUE(acct, content_hash)
↓
detect_transfers ← opposite-sign, ±$0.01, ±3 days, different accounts
Components
| File | Role |
|---|---|
personal-finance.py |
CLI entrypoint (PEP 723 inline deps). Subcommand dispatcher + flock + logging. |
config.py |
~/.config/personal-finance/config.toml loader; AccountConfig, CsvMapper, CATEGORIES, path helpers, env overrides. |
db.py |
connect() + WAL + migrate() (v1) + transaction() contextmanager. |
ingest/base.py |
Transaction dataclass + compute_content_hash (includes running balance when available). |
ingest/mappers.py |
parse_date, parse_amount (handles $, ,, parens, spaces), apply_sign (debit_negative/debit_positive), parse_optional_balance. |
ingest/csv_import.py |
import_csv() — archives raw CSV, validates header, parses rows, dedups via dual UNIQUE constraints. Returns ImportResult. |
transfers.py |
detect_transfers() — O(n) pair walker with transfer_pair_id = min(id_a, id_b) for stable reruns. |
categorize.py |
apply_rules() + create_rule() — case-insensitive substring on description_raw. First-write-wins. |
rollup.py |
month_summary, category_breakdown, current_balances, net_worth, net_worth_delta_30d, snapshot_balances, recent_transactions, uncategorized_rows, 24h ping throttle, run(). |
deliver.py |
send_imessage(text, phone) via bb-send.sh with one retry. |
render.py |
Jinja2 render_dashboard + render_review. Mirrors newsfeed's dark palette. |
templates/*.html.j2 |
_base.html.j2, dashboard.html.j2, review.html.j2. Inline CSS. |
tests/conftest.py |
pytest fixture that builds a tmp DB, reloads modules, seeds config + accounts. |
tests/test_*.py |
30 cases across db, ingest, transfers, categorize, rollup. |
DB schema (SQLite, WAL)
One file: ~/Library/Application Support/personal-finance/finance.db.
Migrations tracked via PRAGMA user_version; current version = 1.
| Table | Purpose |
|---|---|
accounts |
Config-seeded. id (slug), name, kind, institution, currency, is_active, notes. |
transactions |
All imported rows. account_id, posted_date, amount (signed, negative=out), currency, description_raw, description, category, is_transfer, transfer_pair_id, source, source_file, bank_txn_id, content_hash, imported_at. Dedup: UNIQUE(account_id, bank_txn_id) + UNIQUE(account_id, content_hash). Indexes: (posted_date DESC), (account_id, posted_date DESC), (category, posted_date DESC). |
category_rules |
pattern, category, priority (default 100), is_active, created_at, notes. |
balance_snapshots |
One row per (account_id, snapshot_date). UNIQUE(account_id, snapshot_date) so rerunning the same day is a no-op. |
rollup_state |
Single-row table (id=1) with last_ping_ts for 24h throttle. |
Interface
CLI (personal-finance.py)
All subcommands read config from ~/.config/personal-finance/config.toml
unless FINANCE_CONFIG or FINANCE_CONFIG_DIR is set. Entry point uses
uv run --script (PEP 723 inline deps); no global pip install.
personal-finance.py import --account <id> <file.csv>
Ingest a bank CSV export. Requires an [[accounts]] row matching <id>
and a [csv_mappers.<institution>] block in config.toml. Archives the
raw CSV under imports/<sha1>.csv. Dedups by (account_id, bank_txn_id)
first, then (account_id, content_hash) — so two legitimate same-day
duplicates both land when the bank exports a running balance column.
Auto-runs detect_transfers after insert. Idempotent.
personal-finance.py review
Apply all active category rules to NULL-category rows, then print the
most-recent 20 uncategorized transactions to stdout + a total count.
Safe to run repeatedly; first-write-wins semantics mean re-running does
not re-categorize.
personal-finance.py rollup [--dry-run]
Full nightly pipeline: apply_rules → snapshot_balances → month_summary
→ iMessage ping (if uncat>0 and ≥24h since last ping). Acquires a
flock — concurrent runs are rejected. Launchd entry point.
--dry-run prints the iMessage preview instead of sending AND does NOT
record_ping (so a real run immediately after still fires).
personal-finance.py snapshot
One-off balance snapshot without the rest of the pipeline. Writes one
balance_snapshots row per configured account for today.
personal-finance.py doctor
Print config path, DB path, imports dir, log dir, resolved [[accounts]]
with mapper binding status, csv_mappers, db user_version, transaction
/account/rule/snapshot counts, uncategorized count, bb-send.sh presence.
personal-finance.py unpair <id>
Clear is_transfer/transfer_pair_id on a transaction + its pair partner.
Use when the transfer detector falsely paired two unrelated rows.
Environment variables:
| Var | Purpose |
|---|---|
FINANCE_CONFIG_DIR |
Override ~/.config/personal-finance (also moves config.toml discovery). |
FINANCE_CONFIG |
Override config.toml path directly. |
FINANCE_DRY_RUN=1 |
Not wired to CLI today; reserved. Use rollup --dry-run instead. |
FINANCE_VERBOSE=1 |
DEBUG-level logging to stderr. |
HTTP endpoints (served by webpage-server.py on :8899)
personal-finance does not run its own HTTP server. The sibling
../webpage-server/ project serves ~/www/ and hosts the
/personal-finance* routes, loading the finance package modules on
first request via a namespace-isolated _finance_load_all() that avoids
colliding with newsfeed's own config.py / db.py / render.py /
deliver.py.
| Method + path | Purpose | Auth |
|---|---|---|
GET /personal-finance |
Dashboard: month summary, category breakdown, per-account balances, net worth + 30-day delta, recent transactions. | None (LAN/Tailscale trust). |
GET /personal-finance/review |
Uncategorized rows + inline rule-creation form. | None. |
GET /personal-finance/api/summary |
JSON: month summary + balances. | X-Finance-Token header required. |
POST /personal-finance/api/rule |
Create rule + re-apply engine. Form POST → 303 redirect to /review?flash=…; JSON POST → {rule_id} with token required. |
Form: none; JSON: X-Finance-Token required. |
The X-Finance-Token shared secret is reused from newsfeed's
~/Library/Application Support/newsfeed/feedback_token.txt — same file,
different header, which keeps the secrets surface area small.
iMessage (via bb-send.sh to cfg.phone)
- Nightly rollup:
Finance: $X spent this month. N transactions need categorizing: <review_url>. Sent only when uncategorized>0 AND ≥24h since last ping. Review URL resolves to the Tailscale Funnel (https://marks-mac-mini.tail20af9f.ts.net/personal-finance/review) unless[delivery] public_url_baseis overridden to an explicit URL.
Dependencies
Python (PEP 723 inline, resolved by uv run)
Declared in personal-finance.py's script header. ./personal-finance.py
<cmd> resolves everything via uv automatically — no global pip install
needed.
jinja2>=3.1
tomli>=2.0 ; python_version < '3.11'
Stdlib-only for the core: sqlite3, csv, hashlib, dataclasses,
argparse, logging, fcntl, subprocess, tomllib (3.11+).
External services
- BlueBubbles on
localhost:1234— iMessage delivery via/Users/mark/bin/bb-send.sh(shared with newsfeed). - Tailscale Funnel — public HTTPS hostname used for the iMessage deep
link. Neither daemon nor CLI is required by personal-finance itself;
webpage-server.pybinds the port.
External services (separate repos)
webpage-server.py— lives in the~/agentsmonorepo, required in prod. Serves~/www/and hosts the/personal-finance*routes. It loads THIS package by path (FINANCE_PKG=~/personal-finance) and dispatches every computation into it — the dashboard/review/api handlers there are thin HTTP plumbing, not finance logic. No cross-repo coordination beyond keeping that path pointed at~/personal-finance.
Data sources
Phase 1: manual CSV exports from the bank's website. No scraping, no
brokerage API, no Plaid. Mark downloads, runs personal-finance.py import
--account <id> <file.csv>, dedup handles idempotency.
Phase 2+ (deferred — not built):
- Plaid /transactions/sync, /investments/holdings/get.
- Anthropic API (Claude Haiku) for LLM-assisted categorization of stubborn
rows.
Configuration
All configuration lives under these paths. Secrets never commit.
| Path | Role | Tracked in git? |
|---|---|---|
~/.config/personal-finance/config.toml |
Phone, timezone, default currency, accounts, csv_mappers, delivery. Template: examples/config.toml. |
Template yes; live copy no. |
~/Library/Application Support/personal-finance/finance.db |
SQLite + WAL. Everything persistent. | No — state. |
~/Library/Application Support/personal-finance/imports/<sha1>.csv |
Raw CSV archive, content-addressed. Lets you re-run imports with a fixed mapper if a bank renames a column. | No — state. |
~/Library/Application Support/personal-finance/.lock |
Pipeline-wide flock. | No. |
~/Library/Application Support/newsfeed/feedback_token.txt |
Shared secret reused as X-Finance-Token. Written by newsfeed at run start. |
No. |
~/Library/Logs/personal-finance/finance-<YYYYMMDD>.log |
Rotating log, 5MB × 7. | No — logs. |
~/Library/Logs/personal-finance/launchd.log |
launchd stdout/stderr. | No — logs. |
~/Library/LaunchAgents/com.mark.personal-finance.plist |
Nightly rollup at 22:00 local. | No (managed by install.sh). |
Example — minimal config.toml
phone = "+17076556006"
timezone = "America/New_York"
default_currency = "USD"
[delivery]
public_url_base = "auto" # "auto" | "lan" | explicit URL
webpage_port = 8899
[[accounts]]
id = "chase_checking"
name = "Chase Checking"
kind = "checking" # checking|savings|credit|investment|realestate
institution = "chase"
[csv_mappers.chase]
date_col = "Posting Date"
amount_col = "Amount"
description_col = "Description"
txn_id_col = "Transaction ID" # optional; enables strong dedup
balance_col = "Balance" # optional; hardens content_hash
sign = "debit_negative" # or "debit_positive" for Amex-style
sign semantics:
- debit_negative — most banks (Chase checking etc.): -50.00 in the CSV
for a $50 charge. Pass through unchanged.
- debit_positive — Amex and a few other credit cards: 50.00 for a
$50 charge. Flip the sign so charges become negative.
See examples/config.toml for the authoritative template.
Category set is hard-coded in config.CATEGORIES:
groceries, dining, transport, utilities, rent, entertainment,
shopping, health, travel, income, tax, savings, other.
create_rule validates against this list; extending it requires a code
change + a fresh rule-engine pass.
Deployment
install.sh at this repo's root is the deploy entry point:
cd ~/personal-finance
./install.sh
It:
1. Symlinks ~/personal-finance/personal-finance.py into ~/bin/.
2. Copies examples/com.mark.personal-finance.plist to
~/Library/LaunchAgents/ (plists copied, not linked — launchd
distrusts symlinked plists).
3. Seeds ~/.config/personal-finance/config.toml from examples/ only
when the live copy is missing (hand-edits never clobbered).
4. Leaves ~/Library/Application Support/personal-finance/ alone —
DB and imports are user data.
The dashboard is served by the webpage-server.py in the ~/agents
monorepo, which loads this package by path (FINANCE_PKG=~/personal-finance).
That path is the only coupling — keep it pointed here and the
/personal-finance* routes keep working.
Launchd schedule
com.mark.personal-finance.plist runs personal-finance.py rollup at
22:00 local daily (StartCalendarInterval). RunAtLoad=false,
KeepAlive=false, ProcessType=Background, Nice=5.
FINANCE_CONFIG_DIR=/Users/mark/.config/personal-finance in
EnvironmentVariables.
Uninstall
launchctl unload ~/Library/LaunchAgents/com.mark.personal-finance.plist
rm ~/Library/LaunchAgents/com.mark.personal-finance.plist
# Data survives — remove manually if desired:
# rm -rf ~/Library/Application\ Support/personal-finance
Operations
Run once, immediately
launchctl start com.mark.personal-finance # via launchd
./personal-finance.py rollup # direct
./personal-finance.py rollup --dry-run # preview without sending
Logs
tail -f ~/Library/Logs/personal-finance/finance-$(date +%Y%m%d).log
tail -f ~/Library/Logs/personal-finance/launchd.log
Healthcheck
./personal-finance.py doctor
# config path / db path / accounts / mappers / db user_version
# / counts (txn/accounts/rules/snapshots/uncategorized) / bb-send.sh status
Import a CSV
./personal-finance.py import --account chase_checking ~/Downloads/chase.CSV
# "12 inserted, 0 skipped (dup), 12 rows parsed from chase.CSV"
Create a categorization rule
# terminal
python3 -c 'import sys; sys.path.insert(0,"/Users/mark/personal-finance"); \
import db, categorize; c=db.connect(); db.migrate(c); \
print(categorize.create_rule(c,"STARBUCKS","dining"))'
# or via HTTP from the /review UI
# POST /personal-finance/api/rule pattern=STARBUCKS category=dining
Unpair a false-positive transfer
./personal-finance.py unpair 42
# "unpaired 42 + 43"
Recent SQL snippets
sqlite3 ~/Library/Application\ Support/personal-finance/finance.db \
"SELECT category, COUNT(*), ROUND(SUM(-amount),2) AS spent
FROM transactions
WHERE amount<0 AND is_transfer=0
AND posted_date >= date('now','start of month')
GROUP BY category
ORDER BY spent DESC;"
Re-running after a bank column rename
Raw CSVs live under imports/<sha1>.csv. If a bank renames Amount to
Transaction Amount, update the mapper in config.toml and
re-import — dedup via content_hash stops the rows re-inserting.
Running the test suite
cd ~/personal-finance
uv run --python 3.11 --with jinja2 --with pytest python3 -m pytest tests/ -v
# 30 passed
Open questions
Resolved to ship Phase 1:
- Which accounts first? — Chase checking + Chase savings. Add Amex by
dropping a
[[accounts]]block + a[csv_mappers.amex]block withsign = "debit_positive". - Category taxonomy. — Locked to the 13 values in
config.CATEGORIES. Extending requires a code change; document it in the README if you add one. - Dashboard auth on LAN-only GETs. — Skipped per PLAN.
GETHTML routes are unauthenticated (Tailscale trust boundary); writes and JSON reads requireX-Finance-Token.
Still open (deferred to later phases):
- Phase 1.5 LLM model — Claude Haiku 4.5 assumed; re-confirm merchant accuracy on real data before wiring.
- Plaid yes/no — Non-trivial (dev approval, privacy policy, webhook verification). Defer until CSV-forever clearly insufficient.
- Tax-export target — TurboTax CSV vs QuickBooks IIF vs plain Schedule-C mapped CSV. Decide before Phase 3.
- Real-estate tracking — Manual
set-balanceonly, or a Zillow scraper behind--zillow? Scrape is fragile; decide up-front if/when Phase 3 starts.