Personal direct-bank-scraper service writing transactions and investment events to Notion. Runs on a Mac Mini, syncs daily and on-demand.
Replaces the aggregator-based notion-ai-budgeting-app (SimpleFIN + LunchFlow era). See docs/SPEC.md for the full design.
uv sync # install deps
just migrate # one-shot Notion schema migration
just sync-bank bofa # try one bank end-to-end
just serve # start the FastAPI server for on-demand syncs
just install-launchd # install the daily-sync timerThese steps cannot be automated and must be done once per machine:
brew install uvop account add
op signin
op whoami # should print your accountThis project uses a dedicated 1Password vault called Notion Finance Sync
and a service account scoped to that vault for unattended runs.
Both were created during initial setup with:
op vault create "Notion Finance Sync" --icon vault-door
op service-account create "finance-sync-svc" \
--vault "Notion Finance Sync:read_items,write_items"How the daemon gets the token (Mac Mini deploy). The service-account token is the bootstrap secret that unlocks every other secret in 1Password, so it can't live in 1Password itself and (per project policy) must not sit plaintext on disk. On macOS it goes in the login Keychain (encrypted at rest):
just store-op-token # prompts for the token (hidden); stores it in KeychainThe launchd daily job runs deploy/run_sync.sh, which reads the token from the
Keychain, exports it as OP_SERVICE_ACCOUNT_TOKEN, and then runs the sync — so
the op CLI can read bank credentials unattended. Non-secret config
(GMAIL_ADDRESS, the Keychain service name, APP_*) lives in a gitignored
.env (cp .env.example .env); never put the token in .env.
Keychain notes: the item is stored with -A (any process can read it without a
GUI prompt) because a headless launchd job can't answer a Keychain access dialog.
That's an acceptable trade-off on a dedicated single-user Mac Mini; tighten with
-T <tool> if you prefer. Because it's a LaunchAgent (runs in your user
session), the login Keychain must be unlocked — fine on an auto-login Mac Mini.
Local development / manual runs. No token needed — either run in manual
auth mode (the scraper prompts for credentials), or use your normal op signin
session and export OP_SERVICE_ACCOUNT_TOKEN=$(security find-generic-password -a "$USER" -s finance-sync-op-token -w).
For each session that needs login automation, create a 1Password Login item
in the Notion Finance Sync vault with username and password fields.
Required items (one per active session):
op://<vault>/BofA/{username,password}— covers BofA cards + checking + savings + Roth IRA + Investment Mgmt (one login)op://<vault>/Wells Fargo/{username,password}op://<vault>/U.S. Bank/{username,password}op://<vault>/Everbank/{username,password}op://<vault>/Venmo/{username,password}op://<vault>/E*Trade/{username,password}op://<vault>/Fidelity/{username,password}
Bilt is intentionally NOT in this list. Bilt verifies by sending an SMS
code to Alex's phone number — there's no username/password flow to automate.
Bilt sessions are also long-lived on personal devices (Alex rarely has to
re-login), so once the persistent profile at data/sessions/bilt/ is
established the scraper can usually proceed without any 2FA step at all.
The Bilt scraper module handles the phone-verification fallback if it does
get prompted.
CLI shortcut for one (repeat per bank, replacing placeholders):
op item create --category=login --vault="Notion Finance Sync" \
--title="Bank of America" --url="https://www.bankofamerica.com/" \
username="YOUR_USERNAME" password="YOUR_PASSWORD"Or use the 1Password app/web UI.
Create a Notion internal integration scoped to the Transactions database, then store the secret in the project vault as a Password or API Credential item titled Notion Finance Sync Notion Internal Integration Secret with a credential field.
Reference path: op://<vault>/Notion Finance Sync Notion Internal Integration Secret/credential
The email 2FA reader uses Gmail's IMAP gateway with an App Password (not OAuth).
- Enable 2FA on your Google account.
- Go to Account → Security → App Passwords.
- Create a new app password named
finance-sync. - Store the 16-character output in 1Password as a Password or API Credential item titled
Notion Finance Sync Gmail App Passwordwith acredentialfield.
Reference path: op://<vault>/Notion Finance Sync Gmail App Password/credential
The Gmail address itself is required via the GMAIL_ADDRESS env var (set it in your gitignored .env, or the deploy environment). It has no hardcoded default, to keep the personal email out of source control.
The SMS 2FA reader reads ~/Library/Messages/chat.db. macOS requires explicit Full Disk Access:
- Open System Settings → Privacy & Security → Full Disk Access
- Add the terminal app you use (Terminal.app or iTerm2) and/or the Python interpreter (
/usr/bin/env,~/.local/bin/uv) - Restart the terminal
Verify with:
sqlite3 ~/Library/Messages/chat.db "SELECT COUNT(*) FROM message"If you get Error: unable to open database file, Full Disk Access isn't granted.
just migrateThis:
- Renames
SimpleFIN ID→Transaction Source ID - Renames
SimpleFIN Account ID→Source Account ID - Adds:
Bank Category,Calculated Rewards,True Rewards,Related Transactions,Related Transactions Amount,Net Amount,Quantity,Ticker,Price Per Share,Bilt Points,Bilt Partner - Adds new select options:
Bank+= {Venmo, E*Trade, Fidelity};Account Type+= {P2P, Brokerage, 401k, IRA} - Populates the 18-category canonical taxonomy in the
Categoryselect
just install-launchdSchedules just sync to run daily at ~03:30 local time + ±20 min jitter.
Rebuilding from scratch (e.g. the Mini died)? Follow
docs/DEPLOY.md— the complete ordered runbook. This section is the summary.
The app is a Nix package (built from uv.lock via uv2nix).
darwin-rebuild switch builds it, renders config.toml from your nix options,
installs Chrome + op, and schedules a launchd agent — no repo checkout, no
uv sync, no config file to place.
1. Add the flake input:
inputs.finance-sync.url = "github:alexjmiller5/finance-sync";
inputs.finance-sync.inputs.nixpkgs.follows = "nixpkgs";2. Import the module + enable it (import at the flake-modules level so inputs
is in scope; the settings block is the non-secret config.toml as an attrset —
see config.example.toml):
# in your darwinSystem modules list: inputs.finance-sync.darwinModules.default
services.finance-sync = {
enable = true;
user = "alexmiller";
hour = 3; minute = 30; # optional (default 03:30)
settings = {
email.gmail_address = "you@example.com";
bilt.phone = "5551234567";
notion = {
transactions_database_id = "…"; transactions_data_source_id = "…";
tasks_data_source_id = "…";
property_ids = { NAME = "title"; /* … from gen_property_ids.py … */ };
};
onepassword = {
vault = "<vault-id>";
service_account_token_ref = "op://Personal/<token item>/password";
bank_items = { bofa = "Bank of America"; /* … */ };
};
};
};darwin-rebuild switch then builds everything, wraps the app in a signed
/Applications/NotionFinanceSync.app, and creates the com.alexmiller.finance-sync.daily
launchd user agent (which runs the .app). State (Chrome profiles, snapshots, logs)
lives in ~/Library/Application Support/finance-sync/.
The OP service-account token is provided via agenix:
age-encrypted in your nix-config (recipients = the Mini host key + your laptop key),
decrypted at activation to /run/agenix/op-token, which the sync reads (Keychain
fallback retained). Set services.finance-sync.tokenFile = config.age.secrets.op-token.path;.
3. One-time manual steps Nix can't do (TCC/SIP-protected, secret, or
interactive): iPhone → Text Message Forwarding to the Mini; encrypt the OP token
(agenix -e); grant Full Disk Access to NotionFinanceSync.app (the stable signing
cert that makes this survive rebuilds is created automatically at activation); run
each bank's first login once (--bank <bank> --interactive). See docs/DEPLOY.md.
Requirements: a nix-darwin host with nix-homebrew (Chrome cask) and
allowUnfree for the 1password-cli. See nix/darwin.nix for all module options;
regenerate property_ids with uv run scripts/gen_property_ids.py if you recreate
the database.
Phase 1: per-bank SeleniumBase scrapers (serial) → Notion. Phase 2: enrichers (Bilt portal, BofA rewards, Wells rewards) correlate to existing rows. Phase 3: health check, create Notion tasks for banks failing 3x today.
See docs/SPEC.md for the full design.
# All banks
curl -X POST http://127.0.0.1:8765/sync
# One bank
curl -X POST http://127.0.0.1:8765/sync/bofaA bank that fails 3x in one day creates a Notion task. The task suggests:
uv run python scripts/sync.py --bank <name> --interactive--interactive runs the same sync flow but pauses with a terminal prompt whenever automation hits a wall (unsolvable CAPTCHA, novel security challenge, etc.). You handle the human bit, hit ENTER, automation resumes. Same profile persists either way.