Skip to content

Finances Public Release Plan: Multi-Tenant Bank Sync via FIN Bridge (SimpleFIN)

Recommendation: Ship a free manual/CSV tier now, paid self-serve bank-sync tier next, and make the one blocking change first: move the SimpleFIN access URL out of the single shared ~/.forge-secrets/simplefin.env file into a per-workspace, AES-256-GCM-encrypted Postgres row (reusing the exact core.user_llm_keys keystore precedent). The data layer is already multi-tenant on workspace_id; the only thing hardcoded to Justin is the credential source and the job identity. Fix those two seams and the finance app becomes a product.

The good news, stated plainly: about 80% of the multi-tenancy work is already done. Every finance.* table keys on workspace_id, RLS policies already isolate rows by core.memberships, and transactions are already idempotent on (workspace_id, simplefin_txn_id). The blockers are narrow and specific, listed below.


1. Current State (grounded in the codebase)

What is already per-workspace (no change needed)

  • Schema is workspace-keyed. finance.job_runs, finance.alert_state, finance.user_rules (and by the sync job's queries, finance.transactions, finance.accounts, finance.balances) all carry workspace_id bigint NOT NULL REFERENCES core.workspaces(id) ON DELETE CASCADE. See db/migrations/20260706T020500_finances_native_job_runs_alert_state.sql and db/migrations/20260706T031500_finances_native_user_rules.sql.
  • Idempotency is workspace-scoped. lib/finance-jobs/sync.ts upserts transactions with on conflict (workspace_id, simplefin_txn_id) do nothing (line ~152). Balances append-only, account labels preserved across re-sync. Same data twice equals same end state, per workspace.
  • RLS already enforces isolation. Each finance table runs ENABLE ROW LEVEL SECURITY with CREATE POLICY workspace_isolation ... USING (workspace_id IN (SELECT m.workspace_id FROM core.memberships m ...)). Jobs execute inside asUser(me, ...) (lib/rls.ts), so a workspace can only ever see its own rows. This is the single most important fact: the tenant boundary already exists and is enforced at the database, not just in app code.
  • Jobs take workspaceId as context. lib/finance-jobs.ts defines JobCtx = { me: Identity; workspaceId: number }; every job (sync, categorize, recurring, holdings, alerts, snapshot, receipts) reads const ws = ctx.workspaceId and scopes all SQL to it. Nothing in the job bodies assumes a specific workspace number.

What is hardcoded to Justin (the actual blockers)

  • B1 (the big one): ONE shared SimpleFIN access URL. lib/finance-simplefin.ts reads simplefinEnv().SIMPLEFIN_ACCESS_URL from ~/.forge-secrets/simplefin.env (via lib/secrets.ts simplefinEnv()). This is a single global bank credential for Justin. There is no per-workspace dimension to the credential at all. fetchAccounts() has no workspaceId parameter. This is THE constraint: every external user needs their own access URL, and today there is exactly one, in a flat file on the server.
  • B2: Claim flow is an out-of-band Python CLI. Token claiming "stays in the Python CLI" (forge/scripts/forge_simplefin_client.py --claim '<token>'), a one-time manual operation Justin runs by hand. There is no in-app connect-your-bank flow. The Next.js module "only consumes the access URL."
  • B3: Job identity is hardcoded to the owner. lib/finance-jobs.ts jobIdentity() resolves CAPTURE_EMAIL ?? "[email protected]" via core.login(). Timers POST to app/api/finance/jobs/[job]/route.ts with a static FINANCE_JOBS_TOKEN and run as this single owner. There is no per-workspace fan-out; one timer, one identity, one workspace.
  • B4: The connect route is owner-only. app/finances/connect/route.ts gates on isPersonalAppsOwner(me) plus a passphrase, then runs the full pipeline inline for the owner's single store. lib/personal-apps.ts hardwires PERSONAL_APPS_OWNER_EMAIL ?? "[email protected]"; a PersonalAppForbiddenError fires for anyone else.
  • B5: Business-specific heuristics. sync.ts SCOPE_HINTS hardcodes Justin's brands (SIP, GUS, WIEB, NOVA, JWVR) and guessOwner() looks for "Justin"/"Krystal". Harmless for other users (falls back to personal), but it means auto-categorization ships Justin-flavored until generalized. Cosmetic, not a blocker.

Summary: the storage and isolation layer is product-ready. The credential layer, the claim UX, and the job scheduler are single-tenant. Close B1 through B4 and you have a multi-tenant finance product.


2. The Core Problem: Per-User FIN Bridge Connections

Each external user must bring their own SimpleFIN (FIN Bridge) connection. SimpleFIN's model makes this clean because the credential is per-connection by design.

SimpleFIN / FIN Bridge claim model (verified against simplefin.org protocol)

  1. User connects their bank at the bridge. The user goes to bridge.simplefin.org, creates an account, links their bank(s), and generates a setup token at bridge.simplefin.org/simplefin/create. The setup token is a base64-encoded URL (the "claim URL").
  2. App exchanges the token once. Base64-decode the setup token to get the claim URL, then POST to it (empty body, Content-Length: 0). The response body is the access URL: https://<user>:<pass>@bridge.simplefin.org/..., i.e. a URL with HTTP Basic auth credentials baked in. This exchange is one-time: once claimed, the setup token is dead and cannot be reused.
  3. App stores the access URL and uses it forever. All subsequent reads (GET {access_url}/accounts?...) return accounts + transactions, read-only by protocol (no writes, no bill pay). Rate limit: intended for daily updates, ~24 requests/day/connection.

Why this fits us well: the access URL is a self-contained, per-user, read-only, revocable credential. The user does the bank-linking work at the bridge (we never see bank passwords, never do OAuth-to-bank ourselves). Our job is: (a) collect the setup token in-app, (b) POST-claim it server-side, (c) store the resulting access URL encrypted, per workspace, (d) fan the sync jobs out over every workspace that has one. The existing finance-simplefin.ts client already speaks the protocol correctly (real User-Agent to dodge the CDN 403, surfaces errors[] from re-auth-needed banks); it just needs the access URL passed in per call instead of read from a global file.

Cost note: SimpleFIN Bridge is a paid consumer service (roughly $1.50/month per user at their published rate, billed to the end user at the bridge, or bundled if we resell). This directly shapes pricing (Section 5): we are not the SimpleFIN payer for self-serve users; the user pays the bridge and hands us a token.


3. Options

A user, inside CreatorTrack, pastes their FIN Bridge setup token (or is deep-linked out to bridge.simplefin.org and back). The app claims it server-side, stores the access URL AES-256-GCM-encrypted in a new finance.workspace_connections row keyed on workspace_id, and a per-workspace scheduler runs sync -> categorize -> recurring -> holdings -> alerts -> snapshot for every connected workspace.

Must build: - New table finance.workspace_connections (workspace_id PK-part, provider, ciphertext, iv, tag, status, last_synced_at, ...), RLS-isolated like the rest of finance.*. Mirror core.user_llm_keys exactly: ciphertext/iv/tag base64, AES-256-GCM, key derived from AUTH_SECRET via the existing lib/llm/keystore.ts pattern. Plaintext access URL never lands in the DB and is never returned to the client. - Port the --claim step from forge_simplefin_client.py into a server action (base64-decode, POST, capture access URL). This is ~15 lines; the protocol is trivial. - Refactor lib/finance-simplefin.ts fetchAccounts() to take an access URL argument (or a workspaceId it resolves + decrypts) instead of calling simplefinEnv(). Backward-compat: if a workspace has no row, fall back to the env file for Justin during transition. - New in-app connect UI replacing the owner-only passphrase gate in ConnectPanel.tsx / app/finances/connect/route.ts: any authenticated workspace member (owner role) can connect. - Per-workspace fan-out: a dispatcher that selects all finance.workspace_connections with status='active' and runs the job pipeline per workspace under that workspace owner's identity, replacing the single hardcoded jobIdentity().

Security/secret implications: access URLs are per-user bank credentials; they MUST be encrypted at rest (doctrine: never plaintext outside ~/.forge-secrets, and per-user secrets cannot live in a shared env file at all). The core.user_llm_keys precedent is the blessed pattern. Blast radius of one leaked ciphertext row is bounded: it is meaningless without server-side AUTH_SECRET, and it is read-only bank data (no money movement possible via SimpleFIN).

Job-scheduling implications: move from one global systemd timer to a fan-out driver. Two sub-options: (a) one timer hits a new app/api/finance/jobs/run-all that loops workspaces server-side (simplest, fine to low hundreds of users), or (b) enqueue per-workspace job rows. Start with (a). Respect SimpleFIN's ~24 req/day/connection by syncing each workspace at most a few times daily.

Effort: L (but front-loaded on P0/P1 below; the job bodies do not change).

Option 2: Invite-only / manual provisioning (Justin claims each token by hand)

Justin runs the existing forge_simplefin_client.py --claim per user, then an admin tool writes the encrypted access URL into finance.workspace_connections for that workspace. No in-app claim flow; onboarding is a support conversation.

Must build: the encrypted table (same as Option 1) + a tiny admin write path + the job fan-out. Skips the in-app connect UX and the server-side claim port. Security: identical storage requirement (still per-workspace encrypted; do NOT keep piling users into one env file). Scheduling: same fan-out as Option 1. Effort: M. Good as a beta/design-partner stage before Option 1's self-serve UI. It de-risks the storage + fan-out changes while keeping onboarding controlled.

Option 3: Free manual / CSV tier (no bank connection at all)

A tier where users import transactions via CSV upload or manual entry into finance.transactions (source 'manual' / 'bankcsv', which the schema and the pickSuperseded cross-source reconcile in sync.ts already anticipate). No SimpleFIN, no per-user credential, no bridge cost. All the downstream apps (categorize, recurring, alerts, snapshots, net worth) work unchanged because they read finance.* regardless of source.

Must build: a CSV import endpoint + a manual add-transaction form. No secret storage, no claim flow, no fan-out for sync (other jobs still fan out). Security: lowest risk, no bank credentials held. Effort: S/M. This is the smallest thing that makes the app usable by strangers, and it is the free acquisition tier under the paid sync tier.

Recommendation

Ship Option 3 first (free, unblocks public signups immediately, zero credential risk), run Option 2 as an invite beta to validate storage + fan-out with real other users, then graduate to Option 1 for self-serve paid sync. Options 2 and 1 share the same P0/P1 foundation, so Option 2 is not throwaway.


4. Security & Privacy

  • Isolation is real and DB-enforced today. RLS workspace_isolation policies + asUser() mean tenant A cannot read tenant B's finance.* rows even through an app bug, as long as every query path runs under the session identity (it does). Verify before launch: confirm finance.transactions, finance.accounts, finance.balances each have RLS enabled (the job_runs/alert_state/user_rules migrations do; audit the core transaction-table migration db/migrations/20260706T222729_feat_finances_buildout_categorize_rules_engine.sql and any earlier finance-table migration to be certain the base tables are not missing a policy). This is a hard launch gate.
  • Access URL encryption is mandatory. Per-user SimpleFIN access URLs are bank-read credentials embedding Basic auth. Doctrine forbids plaintext credentials outside ~/.forge-secrets, and a shared env file cannot express per-user secrets anyway. Store AES-256-GCM (ciphertext/iv/tag), key from AUTH_SECRET, exactly like core.user_llm_keys (lib/llm/keystore.ts). Never return the plaintext URL to the client; expose only a hasConnection/status boolean, mirroring user_llm_keys' hasKey.
  • Blast radius if one token leaks: read-only by protocol (no transfers, no bill pay), bounded to one user's accounts, revocable by the user at the bridge, and meaningless as ciphertext without server AUTH_SECRET. Worst case is disclosure of one user's transaction history, not fund loss. Mitigate with: rotate on suspicion, per-connection revoke button, and alerting on errors[] (re-auth needed) surfaced from fetchAccounts.
  • Bank/transaction data and email bodies are sensitive. Same untrusted-and-sensitive posture as the rest of Forge: transaction descriptions and receipt/email reconcile data are private financial records. Keep them workspace-scoped, never log full access URLs, and keep the finance store off any unauthenticated endpoint.
  • Do not weaken the owner gate carelessly. Today isPersonalAppsOwner conflates "workspace owner" with "Justin." When generalizing, gate connect on workspace-owner role for the acting workspace, not on a global owner email.

5. Billing / Pricing Sketch

Tier Price idea What they get Our cost
Free (manual/CSV) $0 Manual + CSV import, all categorize/recurring/alerts/net-worth apps ~$0 (compute only)
Sync (paid) ~$5-8/mo Everything + automatic daily FIN Bridge bank sync SimpleFIN ~$1.50/user/mo + compute
  • Who pays SimpleFIN: cleanest is the user pays the bridge directly and hands us a setup token (we carry no per-user bridge cost, only our subscription margin). Alternative is we resell/bundle and eat the ~$1.50, which requires us to manage bridge accounts, more ops. Start with user-pays-bridge for self-serve.
  • Free tier is the acquisition funnel; paid sync is the value unlock. Gate the connect-a-bank action behind the paid tier; manual entry stays free.
  • Keep pricing out of P0/P1; it is P3.

6. Phased Rollout (smallest shippable first)

  • P0 (foundation, no user-facing change): per-workspace encrypted credential storage. Create finance.workspace_connections (RLS-isolated, AES-256-GCM per core.user_llm_keys). Refactor fetchAccounts() to accept an access URL / resolve+decrypt by workspaceId, with env-file fallback for Justin's existing workspace so nothing breaks. Migrate Justin's current SIMPLEFIN_ACCESS_URL into an encrypted row. This is the smallest first step and it removes B1 without touching UX. Verify by re-running Justin's sync end to end against the DB-stored credential.
  • P0.5 (launch gate): audit RLS on base finance tables. Confirm every finance.* table (transactions/accounts/balances) has workspace_isolation. Add any missing policy. Cross-tenant read test.
  • P1: in-app connect flow (self-serve claim). Port the --claim step server-side; build the connect UI (paste setup token or bridge deep-link + return). Replace the owner-only + passphrase gate in app/finances/connect/route.ts with workspace-owner auth. Ship first to invite-only (Option 2) using P0 storage.
  • P2: per-workspace job fan-out. Replace hardcoded jobIdentity() / single timer with a driver that iterates active finance.workspace_connections and runs the pipeline per workspace under that workspace's owner identity. Respect SimpleFIN rate limits. Generalize SCOPE_HINTS/guessOwner (make brand hints workspace-configurable or drop to personal default).
  • P2.5: free manual/CSV tier (Option 3). CSV import + manual entry so non-bank users can use the product. Can slot earlier (it is S/M and dependency-free) if public signups are wanted before sync is ready.
  • P3: billing. Tier gating (connect-a-bank behind paid), Stripe or existing billing, SimpleFIN cost handling.

Smallest shippable value to strangers: P2.5 (free manual tier). Smallest architectural unblock: P0 (encrypted per-workspace credential). Do P0 first regardless, since P1/P2 depend on it.


7. Open Questions for Justin

  1. User-pays-bridge vs. we-resell SimpleFIN? Determines whether we manage bridge accounts and eat ~$1.50/user or just accept setup tokens. Recommend user-pays for self-serve.
  2. Connect UX: paste-token vs. bridge redirect? SimpleFIN supports an app-flow redirect; do we want the polished redirect or the simpler paste-your-setup-token box for beta?
  3. Free tier at launch, or invite-only paid sync first? I.e. do we lead with Option 3 (public, manual) or Option 2 (closed beta, real bank sync)?
  4. Keep Justin's finances on the shared env file forever, or migrate him to an encrypted row in P0? Recommend migrating him so there is exactly one code path (single source of truth).
  5. Multi-account households (Justin + Krystal today): does a workspace map to one person or a household? Affects whether one workspace holds multiple FIN Bridge connections (the table should allow N connections per workspace either way).
  6. How Justin-specific are the auto-categorize heuristics allowed to stay? Generalizing SCOPE_HINTS/brand tagging is optional for launch but shapes P2 effort.
  7. Support/liability posture: holding other people's bank-transaction data raises a support and trust bar (breach disclosure, data-deletion on account close via the existing ON DELETE CASCADE). Is Justin comfortable operating a financial-data product, or keep it invite-only among trusted users?

URL: https://mkdocs.justinsforge.com/memory/plans/finances-public-release-2026-07-07/