Deep dive into PlaidBar's design decisions and implementation details.
- Privacy first — All data stays on the user's machine. No cloud, no sync, no telemetry.
- Secret isolation — Plaid credentials and access tokens never touch the app binary. The companion server owns all secrets.
- Offline resilience — The app caches everything locally. It works without network access using cached data.
- Single toolchain — Both app and server are Swift. One language, one package manager, lower barrier for contributors.
Plaid's security model requires that client_secret and access_token never exist in client-side code. A browser extension or Electron app would face the same constraint. Our solution: a lightweight local server that holds all secrets and proxies API calls.
User clicks "Refresh"
│
▼
┌─ PlaidBar.app ──────────────────────┐
│ GET http://127.0.0.1:8484/api/accounts │
└──────────────────┬──────────────────┘
│ localhost only
┌──────────────────▼──────────────────┐
│ PlaidBarServer │
│ 1. Load access_token from SQLite │
│ 2. POST https://plaid.com/accounts/get │
│ 3. Transform → AccountDTO │
│ 4. Return JSON to app │
└──────────────────┬──────────────────┘
│ HTTPS
┌──────────────────▼──────────────────┐
│ Plaid API │
└─────────────────────────────────────┘
Benefits:
- Security: Secrets never in app memory
- Restartability: Server and app restart independently
- Extensibility: Future CLI tools or iOS app can use the same server
- Testability: Server API is testable with
curl
Both processes are started together via Scripts/run.sh:
swift run PlaidBarServer --sandbox & # Background
swift run PlaidBar & # Background
wait # Ctrl+C stops bothIn production, the server would run as a LaunchAgent for auto-start at login.
Zero external dependencies. Contains:
| File | Purpose |
|---|---|
AccountDTO.swift |
Account model + AccountType enum |
BalanceDTO.swift |
Balance with computed effectiveBalance and utilizationPercent |
TransactionDTO.swift |
Transaction with isIncome, displayName, displayAmount |
SpendingCategory.swift |
17 categories mapped to Plaid's personal_finance_category.primary |
LinkResponse.swift |
Link token response + item status models |
SyncResponse.swift |
Transaction sync response (added/modified/removed) |
ServerStatus.swift |
Server health + PlaidEnvironment enum |
Formatters.swift |
Currency (full/abbreviated/compact), date, percentage formatting |
Constants.swift |
Ports, intervals, thresholds, keychain keys |
All types are Codable, Sendable, and Hashable where appropriate.
The companion server. Binds to 127.0.0.1:8484 by default, or the
PLAIDBAR_SERVER_PORT / --port override when configured.
Routes:
GET /health → 200 OK
POST /api/link/create → { linkToken, linkUrl }
GET /oauth/callback?state=... → Hosted Link success/error page
GET /api/accounts → [AccountDTO]
GET /api/accounts/balances → [AccountDTO] (real-time)
DELETE /api/accounts/:itemId → 204 No Content
GET /api/transactions/sync → SyncResponse
GET /api/status → ServerStatus
GET /api/items → [ItemStatus]
Storage (SQLite via Fluent):
-- items: stores Plaid access tokens
CREATE TABLE items (
id TEXT PRIMARY KEY, -- Plaid item_id
access_token TEXT NOT NULL,
institution_id TEXT,
institution_name TEXT,
status TEXT NOT NULL, -- connected | login_required | error
created_at DATETIME,
updated_at DATETIME
);
-- sync_cursors: tracks transaction sync position per item
CREATE TABLE sync_cursors (
item_id TEXT PRIMARY KEY,
cursor TEXT NOT NULL,
updated_at DATETIME
);Plaid Client:
An actor-based HTTP client using Foundation URLSession. Handles:
- Link token creation
- Public → access token exchange
- Account and balance fetching
- Incremental transaction sync (cursor-based)
- Item removal
All Plaid requests use snake_case JSON encoding/decoding to match Plaid's API convention.
A menu bar-only app (LSUIElement = true) using MenuBarExtra with .window style.
State Management:
@Observable @MainActor
final class AppState {
var accounts: [AccountDTO] = []
var transactions: [TransactionDTO] = []
// ... computed: netBalance, transactionsByDate, spendingByCategory
}Single @Observable state object injected via SwiftUI @Environment. No Combine, no ObservableObject.
View Hierarchy:
MenuBarExtra
├── MenuBarLabel (icon + balance text)
└── MainPopover
├── SetupView (if !isSetupComplete)
└── TabContainer
├── AccountsView (grouped by type, net balance)
├── TransactionsView (search, group by date)
├── SpendingView (donut chart, category breakdown)
└── CreditView (utilization bars, warnings)
Background Refresh:
A Task runs every 15 minutes to refresh account balances (free cached endpoint) and every 30 minutes for transaction sync (cursor-based incremental).
1. User clicks "Add Account"
2. App → POST /api/link/create → Server
3. Server creates a one-time local state and POSTs /link/token/create → Plaid
4. Server returns { linkToken, linkUrl }
5. App opens linkUrl in Safari
6. User completes Plaid Link in browser
7. Plaid redirects to localhost:8484/oauth/callback?state=xxx
8. Server consumes the one-time state and fetches the completed Link session
9. Server exchanges public_token → access_token
10. Server stores access_token in SQLite
11. App refreshes → new accounts appear
Uses Plaid's cursor-based /transactions/sync for incremental updates:
1. App → GET /api/transactions/sync
2. Server loads cursor from SQLite (or "" for first sync)
3. Server → POST /transactions/sync → Plaid
4. Plaid returns { added, modified, removed, nextCursor, hasMore }
5. Server saves nextCursor, transforms → TransactionDTOs
6. App merges: appends added, updates modified, removes deleted
7. If hasMore, repeat from step 2
First sync pulls ~90 days of history. Subsequent syncs are incremental (typically 0-5 new transactions).
Swift 6 strict concurrency throughout:
| Component | Isolation |
|---|---|
AppState |
@MainActor (UI state) |
ServerClient |
actor (network calls) |
PlaidClient |
actor (Plaid API calls) |
TokenStore |
actor (database access) |
| All DTOs | Sendable structs |
| Route handlers | @Sendable closures |
No data races by construction.
- Environment variables:
PLAID_CLIENT_ID,PLAID_SECRET,PLAID_ENV,PLAIDBAR_SERVER_PORT,PLAIDBAR_DATA_DIR - Optional config file from
--config, using the sameKEY=valuenames as the environment - CLI overrides:
--port,--sandbox - Defaults: production mode and port 8484 unless overridden
When a config file is provided, its values override the inherited process environment. Explicit CLI flags still win so one-off launches can safely override a checked local config.
The menu bar app does not read the server config file directly. If server config
changes PLAIDBAR_SERVER_PORT or PLAIDBAR_DATA_DIR, the same values must be in
the app process environment so ServerClient reaches the correct server and
auth-token path.
~/.plaidbar/
├── plaidbar-sandbox.sqlite # Sandbox items + sync cursors
├── plaidbar-production.sqlite # Production items + sync cursors
└── auth-token # App ↔ server shared secret
On upgrade, a legacy plaidbar.sqlite, its SQLite sidecar files, and its
matching transaction cache are copied into an environment-scoped database only
when the legacy environment is explicit
(PLAIDBAR_MIGRATE_LEGACY_DATABASE=sandbox|production) or can be inferred from
the existing transaction-cache context. Ambiguous legacy databases stay
untouched to avoid sandbox/production token crossover. Explicit migration backs
up any existing scoped SQLite store and transaction cache before copying legacy
data, then writes a migration marker so restarts do not reapply stale legacy
data.
Unit tests span 3 suites, all using Swift Testing framework:
| Suite | Coverage |
|---|---|
| PlaidBarCoreTests | DTOs, formatters, constants, Codable roundtrips |
| PlaidBarServerTests | Plaid response decoding, config, type conversion |
| PlaidBarTests | Business logic: net balance, spending aggregation, filtering |
Server integration tests (starting Hummingbird, making HTTP calls) are planned for v0.2.
- LaunchAgent: Ship a plist for auto-starting the server at login
- Webhooks: Plaid can push updates instead of polling — requires a tunnel or relay service
- iOS Companion: The server API is already REST; an iOS app could connect via Tailscale/local network
- Multiple providers: Abstract the Plaid client behind a protocol to support Teller, MX, etc.