diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 9687558..b7976af 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -7,7 +7,8 @@ from sqlmodel import Session, col, select from ten31portal.audit import record_audit from ten31portal.auth import ( - accessible_entity_ids, get_current_user, require_internal, require_writer, + accessible_entity_ids, get_current_user, household_user_ids, require_internal, + require_writer, ) from ten31portal.database import get_session from ten31portal.models import ( @@ -15,8 +16,8 @@ from ten31portal.models import ( Position, UserRole, Valuation, ValuationRound, RoundStatus, User, ) from ten31portal.schemas import ( - EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate, - PartnerResponse, + AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse, + EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerResponse, ) router = APIRouter(prefix="/api/entities", tags=["entities"]) @@ -222,6 +223,45 @@ def update_entity( return EntityResponse.model_validate(entity, from_attributes=True) +@router.get("/{entity_id}/asset-balances") +def asset_balances( + entity_id: int, + user: User = Depends(require_internal), + session: Session = Depends(get_session), +) -> AssetBalancesResponse: + """A GP/mgmt entity's assets = the linked account's capital balances across the funds. + + Household-aware: if the linked account has other legal names linked to it (as the eNAV + often splits one LLC across names), their balances are included too. + """ + entity = session.get(Entity, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + if entity.linked_user_id is None: + return AssetBalancesResponse() + linked = session.get(User, entity.linked_user_id) + if linked is None: + return AssetBalancesResponse(linked_user_id=entity.linked_user_id) + + household = household_user_ids(linked, session) + rows = session.exec( + select(CapitalAccountStatement) + .where(col(CapitalAccountStatement.investor_user_id).in_(household)) + .order_by(col(CapitalAccountStatement.as_of_date).desc()) + ).all() + names = dict(session.exec( + select(User.id, User.name).where(col(User.id).in_({r.investor_user_id for r in rows})) + ).all()) if rows else {} + balances: list[CapitalAccountResponse] = [] + for r in rows: + d = CapitalAccountResponse.model_validate(r, from_attributes=True) + d.investor_name = names.get(r.investor_user_id) + balances.append(d) + return AssetBalancesResponse( + linked_user_id=linked.id, linked_name=linked.name, balances=balances, + ) + + # --- Entity stakes: a GP/mgmt entity's interest in the funds it manages --- def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse: diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index 62e0093..62c901a 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -350,6 +350,13 @@ class InvestorViewResponse(BaseModel): documents: list[DocumentResponse] = [] +class AssetBalancesResponse(BaseModel): + """A GP/mgmt entity's assets: the linked account's capital balances across the funds.""" + linked_user_id: int | None = None + linked_name: str | None = None + balances: list[CapitalAccountResponse] = [] + + # --- Audit --- class AuditLogResponse(BaseModel): diff --git a/backend/tests/test_entity_link.py b/backend/tests/test_entity_link.py index dd3a0aa..68b1afc 100644 --- a/backend/tests/test_entity_link.py +++ b/backend/tests/test_entity_link.py @@ -1,7 +1,9 @@ """Linking a GP entity to its investor account (so Assets can pull real balances).""" +from datetime import date + from tests.conftest import make_user -from ten31portal.models import Entity, EntityType, UserRole +from ten31portal.models import CapitalAccountStatement, Entity, EntityType, UserRole def test_link_entity_to_investor(auth_client, session): @@ -29,3 +31,46 @@ def test_link_rejects_non_investor(auth_client, session): session.refresh(gp) resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id}) assert resp.status_code == 400 + + +def test_asset_balances_include_household(auth_client, session): + """Balances under linked (household) names of the linked account are included.""" + primary = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC") + secondary = make_user( + session, username="ten31llc_trust", role=UserRole.investor, + name="Ten31 LLC Trust", primary_account_id=primary.id, + ) + f1 = Entity(name="LTPF I", type=EntityType.fund) + f2 = Entity(name="LTPF II", type=EntityType.fund) + gp = Entity(name="Ten31 LLC", type=EntityType.gp, linked_user_id=primary.id) + session.add_all([f1, f2, gp]) + session.commit() + for x in (f1, f2, gp): + session.refresh(x) + + session.add(CapitalAccountStatement( + entity_id=f1.id, investor_user_id=primary.id, as_of_date=date(2026, 3, 31), + ending_balance_cents=600_000, + )) + # This balance sits under the linked secondary name, not the primary. + session.add(CapitalAccountStatement( + entity_id=f2.id, investor_user_id=secondary.id, as_of_date=date(2026, 3, 31), + ending_balance_cents=400_000, + )) + session.commit() + + resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["linked_name"] == "Ten31 LLC" + assert {b["entity_id"] for b in body["balances"]} == {f1.id, f2.id} + + +def test_asset_balances_unlinked_is_empty(auth_client, session): + gp = Entity(name="Mgmt", type=EntityType.mgmt_co) + session.add(gp) + session.commit() + session.refresh(gp) + resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances") + assert resp.status_code == 200 + assert resp.json()["balances"] == [] diff --git a/deploy/package.json b/deploy/package.json index 8b2db4d..6da75ca 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.23", + "version": "0.2.24", "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 573191b..28dc87d 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_23 as current } from './v_0_2_23' +export { v_0_2_24 as current } from './v_0_2_24' 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' @@ -22,4 +22,5 @@ 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' 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] +import { v_0_2_23 } from './v_0_2_23' +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, v_0_2_23] diff --git a/deploy/startos/install/versions/v_0_2_24.ts b/deploy/startos/install/versions/v_0_2_24.ts new file mode 100644 index 0000000..5d668c5 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_24.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_24 = VersionInfo.of({ + version: '0.2.24:0', + releaseNotes: { + en_US: + 'A GP or management entity\'s linked capital balance now shows on its Overview page too, not only the Assets tab. The Assets view also includes balances held under the linked account\'s other legal names (as the eNAV often splits one LLC across names), and shows a clear message when a linked account has no balances on file.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 9b5b9cd..48bf62f 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.23' +const CACHE = 'ten31-portal-0.2.24' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f87c275..6aaf3fe 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -225,6 +225,12 @@ export interface InvestorView { documents: PortalDocument[]; } +export interface AssetBalances { + linked_user_id: number | null; + linked_name: string | null; + balances: CapitalAccount[]; +} + // --- API helpers --- export class ApiError extends Error { @@ -299,6 +305,10 @@ export const api = { deleteStake: (entityId: number, stakeId: number) => request<{ status: string }>(`/api/entities/${entityId}/stakes/${stakeId}`, { method: "DELETE" }), + // A GP/mgmt entity's assets: its linked account's capital balances across the funds. + entityAssetBalances: (entityId: number) => + request(`/api/entities/${entityId}/asset-balances`), + // Investor View (admin read-only reconstruction of an investor's portal) investorView: (userId: number) => request(`/api/users/${userId}/investor-view`), diff --git a/frontend/src/pages/EntityAssets.tsx b/frontend/src/pages/EntityAssets.tsx index e6beb66..e2a3e6c 100644 --- a/frontend/src/pages/EntityAssets.tsx +++ b/frontend/src/pages/EntityAssets.tsx @@ -1,15 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; -import { api, type CapitalAccount, type Entity } from "../api"; +import { api, type AssetBalances, type CapitalAccount, type Entity } from "../api"; import { formatDate, formatMoney } from "../format"; import EntityHeader from "../components/EntityHeader"; // 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). +// pulled live from the linked investor account (household-aware). export default function EntityAssets() { const { id } = useParams<{ id: string }>(); const [entity, setEntity] = useState(null); - const [accounts, setAccounts] = useState([]); + const [data, setData] = useState(null); const [entities, setEntities] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -25,18 +25,14 @@ export default function EntityAssets() { async function load() { setLoading(true); try { - const ent = await api.getEntity(entityId); + const [ent, bal, ents] = await Promise.all([ + api.getEntity(entityId), + api.entityAssetBalances(entityId), + api.listEntities(), + ]); setEntity(ent); - 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([]); - } + setData(bal); + setEntities(ents); } catch (e: any) { setError(e.message || "Failed to load"); } finally { @@ -46,24 +42,24 @@ export default function EntityAssets() { const fundName = useMemo(() => { const m = new Map(entities.map((e) => [e.id, e.name])); - return (id: number) => m.get(id) ?? `Fund ${id}`; + return (fid: number) => m.get(fid) ?? `Fund ${fid}`; }, [entities]); - // Latest statement per fund for the linked account. + // Latest statement per fund across the linked household. const rows = useMemo(() => { const byFund = new Map(); - for (const a of accounts) { + for (const a of data?.balances ?? []) { 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 }; - }) + .map(([fundId, accts]) => ({ + fundId, + latest: [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0], + })) .sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents); - }, [accounts]); + }, [data]); const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0); @@ -78,9 +74,7 @@ export default function EntityAssets() { {entity.linked_user_id == null ? (
-

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

+

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

Link it on the{" "} @@ -90,6 +84,17 @@ export default function EntityAssets() { appear here automatically.

+ ) : rows.length === 0 ? ( +
+

+ Linked to {data?.linked_name ?? "an account"}, but + that account has no capital-account balances on file. +

+

+ Confirm you linked the account that appears in the eNAV (Overview → Edit entity), and + that its capital accounts have been imported. +

+
) : (
@@ -118,19 +123,10 @@ export default function EntityAssets() { ))} - {rows.length > 0 && ( - - - - - )} - {rows.length === 0 && ( - - - - )} + + + +
Total balance{formatMoney(total)}
- The linked account has no capital-account balances yet. -
Total balance{formatMoney(total)}
diff --git a/frontend/src/pages/EntityOverview.tsx b/frontend/src/pages/EntityOverview.tsx index e26d5dd..4586324 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 User, type ValuationRound } from "../api"; +import { useEffect, useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api, canEditRound, type AssetBalances, 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"; @@ -20,11 +20,24 @@ export default function EntityOverview() { const [totalInvested, setTotalInvested] = useState(0); const [lastValue, setLastValue] = useState(0); const [latestRound, setLatestRound] = useState(null); + const [assetBalances, setAssetBalances] = useState(null); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(false); const isWriter = !!user && canEditRound(user.role); + // For a GP/mgmt entity linked to an investor account, total its balance across funds. + const linkedTotal = useMemo(() => { + const byFund = new Map(); + for (const a of assetBalances?.balances ?? []) { + const cur = byFund.get(a.entity_id); + if (!cur || a.as_of_date > cur.as_of) byFund.set(a.entity_id, { as_of: a.as_of_date, bal: a.ending_balance_cents }); + } + let total = 0; + byFund.forEach((v) => { total += v.bal; }); + return { total, funds: byFund.size }; + }, [assetBalances]); + useEffect(() => { if (!id) return; loadData(parseInt(id)); @@ -36,6 +49,10 @@ export default function EntityOverview() { const ent = await api.getEntity(entityId); setEntity(ent); + if ((ent.type === "gp" || ent.type === "mgmt_co") && ent.linked_user_id != null) { + api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {}); + } + const holds = await api.listHoldings(entityId); setHoldings(holds); @@ -112,6 +129,25 @@ export default function EntityOverview() { /> + {/* Linked fund balances (GP / mgmt entities) */} + {assetBalances && linkedTotal.funds > 0 && ( +
+
+
+
Capital balance across funds (linked account)
+
{formatMoney(linkedTotal.total)}
+
+ {linkedTotal.funds} fund{linkedTotal.funds === 1 ? "" : "s"} + {assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""} +
+
+ + View by fund → + +
+
+ )} + {/* Current quarter status */} {latestRound && (