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.tsasFinanceUser(id, fn)(drop-in forasUser) resolves the active workspace (resolveActiveFinanceWorkspace=getActiveWorkspaceIdReferer>cookie>first, home-store fallback) and stamps it into the transaction-local GUCapp.finance_ws(set inlib/rls.tsasUser's new{financeWs}opt). All 28lib/finance.tsreads useasFinanceUser - 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) targetresolveActiveFinanceWorkspace, not the old always-lowest-membership.manual-valuesPOST 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:
financeWsFilterunder 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) andcoowner(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 viafinanceWriteAllowed(lib/finance-ws.ts). - Routing:
resolveActiveFinanceWorkspacechecks 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:
readActiveWsCookienow survives no-request scope (cookies() threw in cron/tsx contexts). - Migration
20260714T131842_...finance_share_consent_status_and_role_gated_writes.sqlflips 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, recurringuser_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 infinance.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.tsPUBLIC_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-onlyimports need lazy import under tsx. - Drizzle
sqlexpands 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]