Skip to content

CreatorTrack finance module (CANONICAL since 2026-07-05)

The money system lives natively in CreatorTrack; finance.* in the core Postgres (lifeos, CT109) is the source of truth. The Flask hub and finance.db are RETIRED (see reference_finances_app for history + archive locations). Boundary contract + design language: creatortrack/docs/FINANCES-APP-SPEC.md. Build plan: finances-native-rebuild-2026-07-05 (Phases A-C done). Phase D COMPLETE 2026-07-06 (plan): all 13 surfaces native on the Suite kit (fs-* in components/finance/surfaces/native.css; Flask-era finances.css + Outfit DELETED). Single door: the finance NODE face only; /finances/* page routes are redirect shims (lib/finance-node.ts). Detail views are side peeks (shared SidePeek) + &full=1 full screens sharing one body component (Tx/Rec/Account/SymProfile). Investments has Yahoo-backed symbol detail + portfolio history (lib/finance-yahoo.ts, fail-quiet). Receipts capture is LIVE in-app (/finances/receipts/upload -> finance.receipts -> reconcile job). Icon chips: dark circles, white 2.4-stroke glyphs; logos urlfill: cover, library icons url: contain.

Per-workspace scoping (LIVE 2026-07-08, merged 9b25975 + deployed to prod :3070)

Finance was historically a single-store-per-user model: all finance.* rows lived in the user's lowest-membership workspace and surfaced identically in EVERY workspace they belonged to, because lib/finance.ts reads were pure-RLS and RLS admits rows from any membership workspace, never the active one. fin-connect made finance accessible per-workspace but left the read layer unscoped (symptom Justin hit: merchant logos + all finance data bleeding across workspaces). Fix: the ACTIVE workspace now scopes finance.

  • Mechanism: lib/finance-ws.ts asFinanceUser(id, fn) (drop-in for asUser) resolves the active workspace (resolveActiveFinanceWorkspace = getActiveWorkspaceId Referer>cookie>first, home-store fallback) and stamps it into the transaction-local GUC app.finance_ws (set in lib/rls.ts asUser's new {financeWs} opt). All 28 lib/finance.ts reads use asFinanceUser
  • the shared financeWsFilter (workspace_id = current_setting('app.finance_ws', true)::bigint); joined queries qualify the predicate to the primary finance table's alias. RLS stays underneath as the cross-tenant backstop, so a missed filter can only UNDER-scope (bug persists), never leak.
  • Writes (setMerchantIcon, setMerchantIconAuto/AutoExact, budgets + categorize-rules route create/update/delete) target resolveActiveFinanceWorkspace, not the old always-lowest-membership. manual-values POST intentionally inherits its parent account's workspace.
  • Data: no migration; ws67 (Personal) keeps everything, brand workspaces start empty until data is connected/created there.
  • Verified on dev slot (prod-accurate clone): as Justin, Personal (ws67) finances renders NET WORTH $215,461 + real transactions; Gus The Bass (ws85) renders NET WORTH $0 / "No transactions" / all accounts $0. Query proof: financeWsFilter under ws67 = 345 icons / 55 accts / 8837 txns, ws70 = 0/0/0.
  • financeAccessAllowed(me) = !!me (lib/finance-access.ts): any member gets Finances in any workspace; scoping (not gating) is what keeps a brand workspace empty.

Finance sharing v2 (feat/finance-share-settings, built 2026-07-14, IN REVIEW)

Consent-based per-app sharing so Krystal can see Justin's ws67 finances without joining the workspace. The 2026-07-08 v1 (finance.shares + share_invites + RLS branch) existed at the DB layer only; the per-workspace scoping change orphaned it (read layer never routed a co-owner). v2 completes it:

  • Consent: shares carry status pending/accepted/declined; only ACCEPTED opens the store (core.finance_shared_workspaces filters). Recipient gets an actionable bell notification (type finance_share_invite, Accept/Decline via /api/finance/shares/[id]/accept|decline).
  • Roles: viewer (read-only) and coowner (read-write). finance.* RLS split into per-command policies (workspace_isolation_read/insert/update/delete); write side demands member OR accepted coowner. App layer 403s via financeWriteAllowed (lib/finance-ws.ts).
  • Routing: resolveActiveFinanceWorkspace checks accepted share FIRST; while connected, the recipient's Finances shows the shared store in EVERY workspace; Disconnect (deletes own row) returns them to their own empty store. Owner never holds a share to his own ws.
  • UI: sidebar "Connect a bank" replaced by "Settings" popup (FinanceSettingsPanel): Bank connections section (connect targets the RESOLVED store; coowner allowed, viewer not) + Sharing section (email + Can view / Can view & edit + send; list/revoke; recipient disconnect) + "shared by " strip in the shell for recipients.
  • Unknown email parks in finance.share_invites; claimed to a PENDING share + notification at first login (both auth providers; claim runs on every login, idempotent bell dedupe finshare:<id>).
  • Also fixed: readActiveWsCookie now survives no-request scope (cookies() threw in cron/tsx contexts).
  • Migration 20260714T131842_...finance_share_consent_status_and_role_gated_writes.sql flips EXISTING share rows to pending: Justin must RE-SEND from Finances > Settings > Sharing after ship so Krystal gets the consent bell. Krystal's active account = [email protected] (user 83); the krystalwiebelhaus invite is unclaimed (she hasn't logged in with it).
  • E2E: scripts/dev-verify-finance-share.ts (29 checks, ALL PASS on ct_warm_slot_1); dev slot 1 (:3067) holds the branch.

Where things are

Piece Location
Surfaces (13 tabs) components/finance/surfaces/* rendered ONLY by the node face (components/apps/finance/FinanceApp.tsx); app/finances/* = redirect shims + API routes
Ingest jobs (TS) lib/finance-jobs/ (sync, categorize, recurring, holdings, alerts, snapshot, receipts)
Job runner POST /api/finance/jobs/<job>, bearer FINANCE_JOBS_TOKEN (creatortrack-auth.env); every run in finance.job_runs; failures notify
Write routes /api/finance/transactions|budgets|manual-values, /finances/recurring/action, /finances/categorize/apply, /finances/icons/*, /finances/sync, /finances/connect
Icons lib/finance-icons.ts; files in forge/data/finance/icons, served at /finance-icon/<file>. ALWAYS key by iconKey (= recurring norm-key), NEVER raw merchant_norm
SimpleFIN client lib/finance-simplefin.ts (access URL in ~/.forge-secrets/simplefin.env; claim flow stays in the Python CLI)
Timers (user units) forge-creatortrack-finance-sync (07/13/19h, pipeline sync->categorize->recurring->holdings->alerts), -snapshot (Sun 18h), -receipts (08h); driver scripts/forge_creatortrack_finance_jobs.sh; copies in forge/scripts/systemd/

Data rules carried over

  • Transactions idempotent on (workspace_id, simplefin_txn_id); balances append-only; user edits sticky (user_category, recurring user_edited).
  • Cross-source dedup: bankcsv twin supersedes simplefin within +/-3 days by cents bucket; reads filter superseded=false.
  • Alerts dedup once-per-key in finance.alert_state (90d prune); feed in finance.alert_feed; legacy alerts_state.json keys imported so nothing re-paged.
  • User categorize rules in finance.user_rules (410 imported from the old JSON).
  • Zero-balances sync run FAILS loud (net-worth freeze guard).

ERPNext books contract

forge_erpnext_daily_post.py runs FINANCE_SOURCE=lifeos. Migrated rows keep FINDB-<legacy_findb_id> cheque keys; PG-native rows present as FINDB-<1000000 + pg id> (legacy ids top at 98,684; never collides; reversible). The receipts job matches with coalesce(legacy_findb_id, 1000000+id). NEVER re-run the historical --apply seeding.

Old hostnames

finances.justinsforge.com and receipts.justinsforge.com tunnel to :3070 and host-redirect (next.config) to app.creatortrack.ai/finances. Remove once traffic dies.

Gotchas

  • Yahoo quote/quoteSummary need cookie+crumb (chart endpoint doesn't); SSGA xlsx read by a hand-rolled node:zlib zip reader (NO fflate: branch-only deps get pruned from the shared cross-worktree node_modules by other agents' npm runs).
  • Token routes must be listed in proxy.ts PUBLIC_PATHS (cookie-exempt, bearer-gated).
  • tsx testing: env -u PGPASSWORD PGDATABASE=ct_<branch> npx tsx <file>.mts (PGPASSWORD in the shell overrides the app role's password via PG_OVERRIDE_KEYS); server-only imports need lazy import under tsx.
  • Drizzle sql expands arrays as tuples: in ${arr}, never = any(${arr}).
  • PNC Bank needs re-auth at bridge.simplefin.org (SimpleFIN warns every sync).

[Claude Code, 2026-07-05]