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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user