Skip to content

Plan: CreatorTrack Finances per-workspace scoping

URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-finances-per-workspace-scoping-2026-07-08/

Date: 2026-07-08 Approved spec: Make CreatorTrack Finances per-workspace. Today all finance data (345 merchant_icons + accounts/transactions/budgets/balances) lives in workspace 67 (Personal) and surfaces identically in all 6 of Justin's workspaces because the finance read layer is pure-RLS and never consults the active-workspace cookie. Scope every read in lib/finance.ts and its API routes to the active workspace, switch writes from resolveFinanceWorkspace (always 67) to the active workspace, and keep RLS as the cross-tenant backstop. ws67 keeps all existing data as Personal finance; brand workspaces (Gus The Bass ws85, etc.) start empty until data is created/connected there. Justin's user id=1, memberships 67/74/77/78/82/85.

Chosen mechanism: A Postgres session GUC (app.finance_ws) set once inside asUser, read by a shared SQL fragment ws_filter(), instead of threading an activeWorkspaceId param through all 28 read signatures. Minimizes blast radius, stays idempotent, and RLS remains untouched underneath.

Out of scope: - The generic icon-picker library (lib/icon-library.ts, data/workspace/icons/) stays a shared personal palette. - Finance sharing / co-owner UX (feat/finances-shared-krystal, not deployed) beyond preserving its RLS backstop. - Moving/migrating any existing finance rows between workspaces. - SimpleFIN per-workspace connect flow (already shipped by fin-connect).

Pre-flight

  • Dev slot via scripts/forge_creatortrack_dev_base.sh (worktree + dedicated clone DB + dev-auth), branch feat/finances-per-workspace. Run forge_workspace_migrate.py after bootstrap (base clone lags merged migrations). Commit early/often; merge ONCE via integrator; teardown slot after.
  • Confirm baseline read-only: select workspace_id, count(*) from finance.merchant_icons group by 1; on the clone should show all rows in one ws before changes.

Task list

Task 1: Active-workspace resolver for the finance layer

  • Files: lib/finance-ws.ts (new)
  • What: Add resolveActiveFinanceWorkspace(id: Identity): Promise<number> = getActiveWorkspaceId(id) (from lib/active-workspace.ts) if set AND the user is a member of it, else fall back to resolveFinanceWorkspace(id) (home store). Never throws when a cookie is stale; always returns a workspace the caller belongs to.
  • Verification: cd /home/justinwieb/creatortrack && npx tsx -e "import('./lib/finance-ws.ts').then(m=>console.log(typeof m.resolveActiveFinanceWorkspace))" prints function.
  • Commit: feat(finance): active-workspace resolver with home-store fallback

Task 2: Set the finance workspace GUC inside asUser

  • Files: lib/rls.ts, lib/finance-ws.ts
  • What: Add an opt-in asFinanceUser(id, fn) wrapper (or an asUser option) that, after stamping app.user_id, resolves the active finance workspace and runs select set_config('app.finance_ws', <ws>, true) on the same tx. Expose a financeWsFilter SQL helper returning sql\workspace_id = current_setting('app.finance_ws')::bigint``. RLS is unchanged and still runs underneath.
  • Verification: npx tsx scripts/_smoke_finance_ws.ts (temp smoke, deleted after) sets a fake active ws and asserts current_setting('app.finance_ws') matches inside the tx.
  • Commit: feat(finance): asFinanceUser sets app.finance_ws GUC + shared ws filter

Task 3: Scope the icon + account + balance reads

  • Files: lib/finance.ts
  • What: Route getMerchantIconMap, listAccounts, getAccountDetail, getAccountSummary, getNetWorthSections, getNwGrid, getLastSync through asFinanceUser and add and ${financeWsFilter} to their queries.
  • Verification: On dev slot as Justin with active ws=85 (Gus): curl -fsS <slot>/api/finance/accounts returns []; with active ws=67 returns the real accounts.
  • Commit: feat(finance): scope icon/account/balance reads to active workspace

Task 4: Scope the transaction reads

  • Files: lib/finance.ts
  • What: Same treatment for queryTransactions, listTransactions, getRecentTransactions, getTransactionDetail, getCategorizeQueue, getReceipts, receiptImagePath.
  • Verification: curl -fsS <slot>/api/finance/transactions returns empty on ws85, full list on ws67. tsc --noEmit clean.
  • Commit: feat(finance): scope transaction/receipt reads to active workspace

Task 5: Scope budgets, recurring, spend/income/pnl analytics

  • Files: lib/finance.ts
  • What: Same for getFinanceOverview, getBudgetData, listBudgets, listRecurring, getRecurringSections, getRecurringDetail, getSpendOverview, getMonthFlows, getSpendInsights, getIncomeInsights, getBizPnl, getInvestments, getSymbolPosition, getAlerts.
  • Verification: curl -fsS <slot>/api/finance/budgets and /recurring empty on ws85, populated on ws67. Overview endpoint 200s on both. tsc --noEmit clean.
  • Commit: feat(finance): scope budget/recurring/analytics reads to active workspace

Task 6: Point finance writes at the active workspace

  • Files: lib/finance-icons.ts, lib/finance-logos.ts
  • What: setMerchantIcon, setMerchantIconAuto, setMerchantIconAutoExact (and any manual account/txn create in the routes) resolve the insert workspace_id via resolveActiveFinanceWorkspace instead of resolveFinanceWorkspace / order by workspace_id limit 1. On-conflict keys stay (workspace_id, merchant_key); upsert stays idempotent.
  • Verification: On dev slot with active ws=85, set a merchant logo via the UI/API, then select workspace_id from finance.merchant_icons where merchant_key=<k> returns 85; the same key under ws67 is untouched.
  • Commit: feat(finance): finance writes target the active workspace

Task 7: End-to-end browser verification on the dev slot

  • Files: none (verification only)
  • What: Playwright on the slot as Justin: Personal shows all data + logos; switch to Gus The Bass → Finances renders empty (no bleed, no orphaned logos); create an account + set a logo in Gus → shows only in Gus; switch back to Personal → unchanged. Confirm no "transactions with blank logos" state anywhere.
  • Verification: Screenshot both workspaces' Finances; ws85 empty, ws67 full; the Gus-created row absent from ws67.
  • Commit: test(finance): verified per-workspace scoping end-to-end on dev slot

Task 8: Register and document

  • Files: MEMORY.md, memory/general/reference_creatortrack_finance_module.md (update the CANONICAL note to record per-workspace read scoping + app.finance_ws GUC), memory/general/reference_finances_icon_persistence.md (note workspace scoping)
  • What: Update the finance-module reference and the icon-persistence reference; add/refresh the MEMORY.md index line. Run the eval harness.
  • Verification: bash /home/justinwieb/forge/scripts/forge_eval_run.sh passes; grep -q app.finance_ws /home/justinwieb/forge/memory/general/reference_creatortrack_finance_module.md.
  • Commit: docs(finance): register per-workspace scoping in memory + refs

Merge

Merge ONCE via integrator after all tasks verified on the slot; then teardown the dev slot (forge_creatortrack_agent_teardown.sh / let the reaper collect it).