0.2.24: household-aware GP asset balances + show on Overview

Fixes a linked GP entity showing an empty Assets tab when its capital is
held under the linked account's other legal names (the eNAV often splits
one LLC across names).

- New GET /api/entities/{id}/asset-balances: household-aware, returns the
  linked account's (and its linked names') capital balances per fund, plus
  the linked account name for a clear empty state.
- Assets tab uses it; shows "linked to X but no balances on file" instead
  of a blank table when the wrong account is linked.
- GP/mgmt Overview now surfaces the total linked balance across funds with
  a "View by fund" link, so assets are visible without opening the tab.

Verified: 15/15 backend tests (incl. household case); frontend tsc + vite
build clean.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 16:14:58 -05:00
parent e7501a14b0
commit 33776d42f4
10 changed files with 197 additions and 49 deletions
+43 -3
View File
@@ -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:
+7
View File
@@ -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):
+46 -1
View File
@@ -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"] == []
+1 -1
View File
@@ -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",
+3 -2
View File
@@ -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]
@@ -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 }) => {},
},
})
+1 -1
View File
@@ -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())
+10
View File
@@ -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<AssetBalances>(`/api/entities/${entityId}/asset-balances`),
// Investor View (admin read-only reconstruction of an investor's portal)
investorView: (userId: number) => request<InvestorView>(`/api/users/${userId}/investor-view`),
+34 -38
View File
@@ -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<Entity | null>(null);
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
const [data, setData] = useState<AssetBalances | null>(null);
const [entities, setEntities] = useState<Entity[]>([]);
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<number, CapitalAccount[]>();
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 ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
<p>
This entity isn't linked to an investor account yet, so there are no balances to show.
</p>
<p>This entity isn't linked to an investor account yet, so there are no balances to show.</p>
<p className="mt-2 text-gray-500">
Link it on the{" "}
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
@@ -90,6 +84,17 @@ export default function EntityAssets() {
appear here automatically.
</p>
</div>
) : rows.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
<p>
Linked to <span className="font-medium">{data?.linked_name ?? "an account"}</span>, but
that account has no capital-account balances on file.
</p>
<p className="mt-2 text-gray-500">
Confirm you linked the account that appears in the eNAV (Overview Edit entity), and
that its capital accounts have been imported.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
<table className="w-full text-sm">
@@ -118,19 +123,10 @@ export default function EntityAssets() {
</td>
</tr>
))}
{rows.length > 0 && (
<tr className="bg-gray-50 font-medium">
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total balance</td>
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(total)}</td>
</tr>
)}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
The linked account has no capital-account balances yet.
</td>
</tr>
)}
<tr className="bg-gray-50 font-medium">
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total balance</td>
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(total)}</td>
</tr>
</tbody>
</table>
</div>
+39 -3
View File
@@ -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<ValuationRound | null>(null);
const [assetBalances, setAssetBalances] = useState<AssetBalances | null>(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<number, { as_of: string; bal: number }>();
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() {
/>
</div>
{/* Linked fund balances (GP / mgmt entities) */}
{assetBalances && linkedTotal.funds > 0 && (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-gray-500 mb-1">Capital balance across funds (linked account)</div>
<div className="text-2xl font-semibold text-gray-900">{formatMoney(linkedTotal.total)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{linkedTotal.funds} fund{linkedTotal.funds === 1 ? "" : "s"}
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
</div>
</div>
<Link to={`/entities/${entity.id}/assets`} className="text-sm text-orange-600 hover:text-orange-700">
View by fund
</Link>
</div>
</div>
)}
{/* Current quarter status */}
{latestRound && (
<div className="bg-white border border-gray-200 rounded-lg p-4">