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: