← Home

PocketLens

Self hosted personal finance app.

PocketLens links your bank and brokerage accounts, shows what you're worth, and tracks where the money went. There's a SwiftUI iOS app and a React web app; both read the same Postgres database directly, and a Python service keeps that database in step with the banks.

The three pieces

No app server sits between the clients and the data. Row-level security in Postgres does that job: every table is scoped to the signed-in user, so the clients can hold the anon key safely and query the database directly. The backend holds the service-role key and is the only thing that can cross user boundaries.

Balances on the iOS app, from a demo account: net worth over six months and per-institution accounts

Bring your own bank connection

Plaid's free tier caps how many bank connections one developer account can hold, which puts a hard ceiling on users. So each user can supply their own Plaid developer keys.

The backend hosts a guided setup page that walks through Plaid signup and takes the keys pasted in as raw text — it pulls the client ID and secret out of whatever you paste, probes them against Plaid to check they work, and tells you specifically if you pasted the wrong environment's secret. Keys are encrypted with Fernet before they touch Postgres, as are the per-bank access tokens. The encryption key lives only in the backend environment, so a database dump on its own is ciphertext.

Session tokens on those hosted pages arrive in the URL fragment, never the query string, so they can't land in proxy logs or browser history.

Syncing without lying

Transactions arrive by webhook, not polling. Each bank connection points at the backend, and an event triggers a sync of just that one connection. Every webhook's signature is verified before anything runs — and verified with the key of the Plaid account that owns the item, so a forged event naming someone else's connection can't validate.

Pulling is cursor-based: Plaid hands back added, modified, and removed since the last position. That maps onto three rules that matter more than they look:

That split — bank-owned columns versus user-owned columns — is the rule the whole sync is built around. Every write path checks it against the same list.

A scheduled job runs twice a day as a safety net for missed webhooks, and refreshes balances, which have no reliable "this changed" event.

Fixing what the cursor can't see

Cursor sync only ever sees deltas. If a row goes missing — a dropped webhook, a bug since fixed, manual surgery on the database — nothing in the incremental path will ever notice. It's wrong forever.

So a nightly reconciler re-fetches the full window with no cursor, diffs it against what's stored, and heals: insert what's missing, correct the bank-owned fields that disagree, delete what's genuinely gone.

Deleting is where this gets dangerous, because "absent from a snapshot" is much weaker evidence than "Plaid told me it was removed." A truncated response would otherwise wipe real data. So deletes are guarded three ways: refused outright on an empty snapshot, limited to the date window the snapshot actually covers, and capped per connection — beyond that it logs an error instead of deleting. Any drift found at all logs a warning, because drift after a sync means something upstream is leaking, and healing it is only the mitigation.

Transfers, which are not spending

Moving $2,000 from checking to savings is not $2,000 of spending, but it arrives as two transactions and will wreck a monthly total if you count both.

The sync auto-links opposite, equal amounts in different accounts within five days of each other when one carries a transfer signal from the bank. Linked pairs drop out of totals. A leg with no partner yet is marked one-sided and upgrades to a pair if its counterpart shows up in a later sync.

The trap: banks tag Zelle and Venmo payments to people, ATM withdrawals, and cash-advance apps as transfers too — and those are real spending. So a lone leg is only treated as moved money when the detailed category is an unambiguous self-move, like a credit card payment or a savings transfer. Genuinely ambiguous pairs aren't guessed at; they go to a suggestions UI and the user decides.

Brokerage accounts need separate handling, because their transactions come from a different Plaid endpoint entirely. Only external cash movements are ingested — deposits, withdrawals, contributions. Buys and sells are deliberately skipped, since a stock purchase pulled into the transactions table would read as spending. Each ingested leg is stamped with a synthesized transfer signal so the existing matcher pairs it with the bank side without any special-casing.

Categorizing

Three layers, cheapest first:

  1. Keyword rules in priority order, applied to newly added transactions during sync.
  2. Merchant memory — categorize a merchant once and later transactions from it follow.
  3. Client-side suggestion that maps the bank's own category taxonomy onto ours, used to pre-fill the review flow rather than to decide silently.

Income is inferred from the sign of the amount, with transfers exempt — money coming back in from savings is not income.

The transactions list, each row carrying its category and account

Everything downstream

Once transactions are correct, the rest falls out of them: category budgets with month-to-date spend, net worth snapshots written daily from balances plus manually tracked accounts, recurring contributions posted on their own schedule, and alerts — budget thresholds, large charges, low balances, failed syncs — evaluated at the end of each sync, plus daily, weekly and monthly digests. Alerts become rows in a notifications table that both clients render as an inbox, and each new row is pushed to the phone.

Category budgets with month-to-date spend against each limit

Alerts are best-effort by construction: the whole evaluation is wrapped so that a failure logs and moves on. A broken notification must never fail a sync that already wrote good data.


More writeups