From e7501a14b03e0ba66a738e1a36680567df64bab0 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Wed, 1 Jul 2026 15:21:28 -0500 Subject: [PATCH] 0.2.23: link GP entities to their investor account for auto asset balances Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account, so its Assets tab shows its real capital-account balance in each fund, pulled live from the eNAV capital accounts instead of manual entry. - entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/ Response fields; validated to be an investor account. - Edit-entity form gains a "Linked investor account" picker for GP/mgmt. - Assets tab now auto-lists the linked account's balance per fund (the earlier manual-stakes API remains but is no longer used by the UI). Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend tsc + vite build clean. --- .../c9d0e1f2a3b4_entity_linked_user.py | 29 ++ backend/ten31portal/models.py | 3 + backend/ten31portal/routers/entity_router.py | 12 + backend/ten31portal/schemas.py | 3 + backend/tests/test_entity_link.py | 31 ++ deploy/package.json | 2 +- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_23.ts | 13 + frontend/public/sw.js | 2 +- frontend/src/api.ts | 1 + frontend/src/pages/EntityAssets.tsx | 291 ++++++------------ frontend/src/pages/EntityOverview.tsx | 30 +- 12 files changed, 223 insertions(+), 199 deletions(-) create mode 100644 backend/alembic/versions/c9d0e1f2a3b4_entity_linked_user.py create mode 100644 backend/tests/test_entity_link.py create mode 100644 deploy/startos/install/versions/v_0_2_23.ts diff --git a/backend/alembic/versions/c9d0e1f2a3b4_entity_linked_user.py b/backend/alembic/versions/c9d0e1f2a3b4_entity_linked_user.py new file mode 100644 index 0000000..6009172 --- /dev/null +++ b/backend/alembic/versions/c9d0e1f2a3b4_entity_linked_user.py @@ -0,0 +1,29 @@ +"""add entities.linked_user_id (link a GP entity to its investor account) + +Revision ID: c9d0e1f2a3b4 +Revises: b8c9d0e1f2a3 +Create Date: 2026-07-01 10:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c9d0e1f2a3b4' +down_revision: Union[str, None] = 'b8c9d0e1f2a3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('entities', schema=None) as batch_op: + batch_op.add_column(sa.Column('linked_user_id', sa.Integer(), nullable=True)) + op.create_index('ix_entities_linked_user_id', 'entities', ['linked_user_id']) + + +def downgrade() -> None: + op.drop_index('ix_entities_linked_user_id', table_name='entities') + with op.batch_alter_table('entities', schema=None) as batch_op: + batch_op.drop_column('linked_user_id') diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 7ca2732..7c57bbe 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -87,6 +87,9 @@ class Entity(SQLModel, table=True): vintage_year: int | None = None fund_size_cents: int | None = None status: EntityStatus = Field(default=EntityStatus.active) + # For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to + # its investor account so its Assets view can pull real per-fund balances from the eNAV. + linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True) created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index e82480c..9687558 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -174,12 +174,22 @@ def get_entity( return EntityResponse.model_validate(entity, from_attributes=True) +def _validate_linked_user(linked_user_id: int | None, session: Session) -> None: + """A linked account (for a GP entity that is also an LP) must be an investor account.""" + if linked_user_id is None: + return + linked = session.get(User, linked_user_id) + if linked is None or linked.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Linked account must be an investor account.") + + @router.post("", status_code=201) def create_entity( body: EntityCreate, user: User = Depends(require_writer), session: Session = Depends(get_session), ) -> EntityResponse: + _validate_linked_user(body.linked_user_id, session) entity = Entity(**body.model_dump()) session.add(entity) session.flush() @@ -200,6 +210,8 @@ def update_entity( if entity is None: raise HTTPException(status_code=404, detail="Entity not found") changes = body.model_dump(exclude_unset=True) + if "linked_user_id" in changes: + _validate_linked_user(changes["linked_user_id"], session) for key, val in changes.items(): setattr(entity, key, val) session.add(entity) diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index 1c2de97..62e0093 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -91,6 +91,7 @@ class EntityCreate(BaseModel): type: EntityType vintage_year: int | None = None fund_size_cents: int | None = None + linked_user_id: int | None = None class EntityUpdate(BaseModel): @@ -99,6 +100,7 @@ class EntityUpdate(BaseModel): vintage_year: int | None = None fund_size_cents: int | None = None status: EntityStatus | None = None + linked_user_id: int | None = None class EntityResponse(BaseModel): @@ -108,6 +110,7 @@ class EntityResponse(BaseModel): vintage_year: int | None fund_size_cents: int | None status: EntityStatus + linked_user_id: int | None = None created_at: datetime diff --git a/backend/tests/test_entity_link.py b/backend/tests/test_entity_link.py new file mode 100644 index 0000000..dd3a0aa --- /dev/null +++ b/backend/tests/test_entity_link.py @@ -0,0 +1,31 @@ +"""Linking a GP entity to its investor account (so Assets can pull real balances).""" + +from tests.conftest import make_user +from ten31portal.models import Entity, EntityType, UserRole + + +def test_link_entity_to_investor(auth_client, session): + inv = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC") + gp = Entity(name="Ten31 LLC", type=EntityType.gp) + session.add(gp) + session.commit() + session.refresh(gp) + + linked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": inv.id}) + assert linked.status_code == 200, linked.text + assert linked.json()["linked_user_id"] == inv.id + + # Unlink. + unlinked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": None}) + assert unlinked.status_code == 200 + assert unlinked.json()["linked_user_id"] is None + + +def test_link_rejects_non_investor(auth_client, session): + staff = make_user(session, username="ops2", role=UserRole.operations) + gp = Entity(name="Mgmt Co", type=EntityType.mgmt_co) + session.add(gp) + session.commit() + session.refresh(gp) + resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id}) + assert resp.status_code == 400 diff --git a/deploy/package.json b/deploy/package.json index ed68705..8b2db4d 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.22", + "version": "0.2.23", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 4ec7624..573191b 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_22 as current } from './v_0_2_22' +export { v_0_2_23 as current } from './v_0_2_23' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' @@ -21,4 +21,5 @@ import { v_0_2_18 } from './v_0_2_18' import { v_0_2_19 } from './v_0_2_19' import { v_0_2_20 } from './v_0_2_20' import { v_0_2_21 } from './v_0_2_21' -export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21] +import { v_0_2_22 } from './v_0_2_22' +export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22] diff --git a/deploy/startos/install/versions/v_0_2_23.ts b/deploy/startos/install/versions/v_0_2_23.ts new file mode 100644 index 0000000..fed4a56 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_23.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_23 = VersionInfo.of({ + version: '0.2.23:0', + releaseNotes: { + en_US: + 'You can link a GP or management-company entity to its investor account. Its Assets tab then shows its real capital-account balance in each fund, pulled from the eNAV, with no double entry. Set this on the entity\'s Edit form.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 01ffb68..9b5b9cd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.22' +const CACHE = 'ten31-portal-0.2.23' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index fc3ec7b..f87c275 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -152,6 +152,7 @@ export interface Entity { vintage_year: number | null; fund_size_cents: number | null; status: EntityStatus; + linked_user_id: number | null; created_at: string; } diff --git a/frontend/src/pages/EntityAssets.tsx b/frontend/src/pages/EntityAssets.tsx index 7cf2fa7..e6beb66 100644 --- a/frontend/src/pages/EntityAssets.tsx +++ b/frontend/src/pages/EntityAssets.tsx @@ -1,40 +1,42 @@ import { useEffect, useMemo, useState } from "react"; -import { useParams } from "react-router-dom"; -import { api, canEditRound, type Entity, type EntityStake } from "../api"; -import { useAuth } from "../context/AuthContext"; -import { formatMoney } from "../format"; +import { Link, useParams } from "react-router-dom"; +import { api, type CapitalAccount, type Entity } from "../api"; +import { formatDate, formatMoney } from "../format"; import EntityHeader from "../components/EntityHeader"; -// A GP / management company's assets: its ownership interest in the funds it manages. +// A GP / management company's assets: its capital-account balance in each fund it holds, +// pulled live from the linked investor account (the same LLC on the eNAV / LP side). export default function EntityAssets() { const { id } = useParams<{ id: string }>(); - const { user } = useAuth(); const [entity, setEntity] = useState(null); - const [stakes, setStakes] = useState([]); - const [allEntities, setAllEntities] = useState([]); + const [accounts, setAccounts] = useState([]); + const [entities, setEntities] = useState([]); const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); const [error, setError] = useState(""); const entityId = id ? parseInt(id) : 0; - const isWriter = !!user && canEditRound(user.role); useEffect(() => { if (!entityId) return; load(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [entityId]); async function load() { setLoading(true); try { - const [ent, stk, all] = await Promise.all([ - api.getEntity(entityId), - api.listStakes(entityId), - api.listEntities(), - ]); + const ent = await api.getEntity(entityId); setEntity(ent); - setStakes(stk); - setAllEntities(all); + if (ent.linked_user_id != null) { + const [accts, ents] = await Promise.all([ + api.listCapitalAccounts({ investor_user_id: ent.linked_user_id }), + api.listEntities(), + ]); + setAccounts(accts); + setEntities(ents); + } else { + setAccounts([]); + } } catch (e: any) { setError(e.message || "Failed to load"); } finally { @@ -42,196 +44,97 @@ export default function EntityAssets() { } } - // Funds/SPVs available to add (exclude self and ones already staked). - const candidates = useMemo(() => { - const staked = new Set(stakes.map((s) => s.fund_entity_id)); - return allEntities.filter( - (e) => e.id !== entityId && !staked.has(e.id) && (e.type === "fund" || e.type === "spv"), - ); - }, [allEntities, stakes, entityId]); + const fundName = useMemo(() => { + const m = new Map(entities.map((e) => [e.id, e.name])); + return (id: number) => m.get(id) ?? `Fund ${id}`; + }, [entities]); - const totalValue = stakes.reduce((s, k) => s + (k.value_cents ?? 0), 0); + // Latest statement per fund for the linked account. + const rows = useMemo(() => { + const byFund = new Map(); + for (const a of accounts) { + const g = byFund.get(a.entity_id) ?? []; + g.push(a); + byFund.set(a.entity_id, g); + } + return [...byFund.entries()] + .map(([fundId, accts]) => { + const latest = [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0]; + return { fundId, latest }; + }) + .sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents); + }, [accounts]); + + const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0); if (loading || !entity) return
Loading...
; - async function remove(stakeId: number) { - if (!confirm("Remove this stake?")) return; - try { - await api.deleteStake(entityId, stakeId); - load(); - } catch (e: any) { - setError(e.message || "Failed to remove"); - } - } - return (
- {error &&

{error}

} -
-

Fund interests

- {isWriter && candidates.length > 0 && ( - - )} -
+

Fund balances

- {showForm && ( - setShowForm(false)} - onSaved={() => { - setShowForm(false); - load(); - }} - /> - )} - -
- - - - - - - - - {isWriter && - - - {stakes.map((s) => ( - - - - - - - {isWriter && ( - - )} - - ))} - {stakes.length > 0 && ( - - - - - )} - {stakes.length === 0 && ( + {entity.linked_user_id == null ? ( +
+

+ This entity isn't linked to an investor account yet, so there are no balances to show. +

+

+ Link it on the{" "} + + Overview tab → Edit entity + {" "} + (choose its investor account). Its capital-account balance in each fund will then + appear here automatically. +

+
+ ) : ( +
+
Fund / SPVTypeOwnershipValueNote} -
{s.fund_name ?? s.fund_entity_id}{s.fund_type} - {s.ownership_pct != null ? `${s.ownership_pct}%` : "—"} - - {s.value_cents != null ? formatMoney(s.value_cents) : "—"} - {s.note || "—"} - -
Total{formatMoney(totalValue)} -
+ - + + + + - )} - -
- No fund interests recorded yet. - Fund / SPVAs ofCommitmentBalance
-
-
- ); -} - -function AddStakeForm({ - entityId, - candidates, - onClose, - onSaved, -}: { - entityId: number; - candidates: Entity[]; - onClose: () => void; - onSaved: () => void; -}) { - const [fundId, setFundId] = useState(""); - const [pct, setPct] = useState(""); - const [valueDollars, setValueDollars] = useState(""); - const [note, setNote] = useState(""); - const [error, setError] = useState(""); - const [saving, setSaving] = useState(false); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - if (fundId === "") { - setError("Choose a fund."); - return; - } - setSaving(true); - setError(""); - try { - await api.createStake(entityId, { - fund_entity_id: Number(fundId), - ownership_pct: pct ? parseFloat(pct) : null, - value_dollars: valueDollars ? parseFloat(valueDollars.replace(/[,$]/g, "")) : null, - note: note.trim() || null, - }); - onSaved(); - } catch (err: any) { - setError(err.message || "Failed to add stake"); - } finally { - setSaving(false); - } - } - - const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm"; - - return ( -
-

Add a fund interest

-
-
- - + + + {rows.map((r) => ( + + + + {fundName(r.fundId)} + + + {formatDate(r.latest.as_of_date)} + + {formatMoney(r.latest.commitment_cents)} + + + {formatMoney(r.latest.ending_balance_cents)} + + + ))} + {rows.length > 0 && ( + + Total balance + {formatMoney(total)} + + )} + {rows.length === 0 && ( + + + The linked account has no capital-account balances yet. + + + )} + +
-
-
- - setPct(e.target.value)} placeholder="20" /> -
-
- - setValueDollars(e.target.value)} placeholder="1,000,000" /> -
-
-
- - setNote(e.target.value)} /> -
- {error &&

{error}

} -
- - -
-
+ )}
); } diff --git a/frontend/src/pages/EntityOverview.tsx b/frontend/src/pages/EntityOverview.tsx index a574ba2..e26d5dd 100644 --- a/frontend/src/pages/EntityOverview.tsx +++ b/frontend/src/pages/EntityOverview.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api"; +import { api, canEditRound, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api"; import { useAuth } from "../context/AuthContext"; import { formatMoney, formatDate, formatQuarter } from "../format"; import EntityHeader from "../components/EntityHeader"; @@ -149,10 +149,20 @@ function EditEntityForm({ const [fundSizeDollars, setFundSizeDollars] = useState( entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "", ); + const [linkedUserId, setLinkedUserId] = useState(entity.linked_user_id ?? ""); + const [investors, setInvestors] = useState([]); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm"; + // A GP / management company can be linked to its own investor account, so its Assets view + // shows its real per-fund balances. + const isGp = type === "gp" || type === "mgmt_co"; + useEffect(() => { + if (!isGp) return; + api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {}); + }, [isGp]); + async function submit(e: React.FormEvent) { e.preventDefault(); if (!name.trim()) { @@ -170,6 +180,7 @@ function EditEntityForm({ fund_size_cents: fundSizeDollars ? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100) : null, + linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null, }; const updated = await api.updateEntity(entity.id, data); onSaved(updated); @@ -216,6 +227,23 @@ function EditEntityForm({ setFundSizeDollars(e.target.value)} placeholder="3,300,000" /> + {isGp && ( +
+ + +
+ )} {error &&

{error}

}