Files
Ten31-Portal/backend/ten31portal/routers/entity_router.py
T
Jonathan Kirkwood e7501a14b0 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.
2026-07-01 15:21:28 -05:00

316 lines
12 KiB
Python

"""Entity CRUD and rollup endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, literal
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,
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
)
from ten31portal.schemas import (
EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate,
PartnerResponse,
)
router = APIRouter(prefix="/api/entities", tags=["entities"])
class EntityRollupItem(BaseModel):
id: int
name: str
type: str
vintage_year: int | None
fund_size_cents: int | None
status: str
committed_cents: int # total LP commitments (latest per investor)
invested_cents: int
last_signed_value_cents: int
@router.get("/rollup")
def entity_rollup(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityRollupItem]:
"""Per-entity invested and last-signed-value in a single pass."""
allowed = accessible_entity_ids(user, session)
entities = session.exec(select(Entity)).all()
if allowed is not None:
entities = [e for e in entities if e.id in allowed]
result: list[EntityRollupItem] = []
for ent in entities:
# Sum cost_cents across all positions in this entity's holdings
invested_row = session.exec(
select(func.coalesce(func.sum(Position.cost_cents), 0))
.join(Holding, Position.holding_id == Holding.id)
.where(Holding.entity_id == ent.id)
).one()
invested_cents = int(invested_row)
# Find the latest approved round for this entity
latest_round = session.exec(
select(ValuationRound)
.where(
ValuationRound.entity_id == ent.id,
ValuationRound.status == RoundStatus.approved,
)
.order_by(ValuationRound.quarter_end.desc()) # type: ignore[union-attr]
).first()
last_signed_value_cents = 0
if latest_round:
val_sum = session.exec(
select(func.coalesce(func.sum(Valuation.value_cents), 0))
.where(Valuation.round_id == latest_round.id)
).one()
last_signed_value_cents = int(val_sum)
# Total committed capital = each investor's most recent commitment for this entity.
stmts = session.exec(
select(CapitalAccountStatement)
.where(CapitalAccountStatement.entity_id == ent.id)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).all()
committed_cents = 0
seen_investors: set[int] = set()
for st in stmts:
if st.investor_user_id in seen_investors:
continue
seen_investors.add(st.investor_user_id)
committed_cents += st.commitment_cents
result.append(EntityRollupItem(
id=ent.id,
name=ent.name,
type=ent.type.value,
vintage_year=ent.vintage_year,
fund_size_cents=ent.fund_size_cents,
status=ent.status.value,
committed_cents=committed_cents,
invested_cents=invested_cents,
last_signed_value_cents=last_signed_value_cents,
))
return result
@router.get("/{entity_id}/partners")
def list_partners(
entity_id: int,
user: User = Depends(require_internal),
session: Session = Depends(get_session),
) -> list[PartnerResponse]:
"""Members (investors) granted access to this entity, with their latest capital value."""
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
members = session.exec(
select(User)
.join(EntityAccess, EntityAccess.user_id == User.id)
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
.order_by(User.name) # type: ignore[arg-type]
).all()
result: list[PartnerResponse] = []
for m in members:
stmts = session.exec(
select(CapitalAccountStatement)
.where(
CapitalAccountStatement.entity_id == entity_id,
CapitalAccountStatement.investor_user_id == m.id,
)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).all()
latest = stmts[0] if stmts else None
result.append(PartnerResponse(
user_id=m.id,
name=m.name,
username=m.username,
external_investor_id=m.external_investor_id,
is_active=m.is_active,
login_enabled=m.login_enabled,
latest_commitment_cents=latest.commitment_cents if latest else None,
latest_contributions_cents=latest.contributions_cents if latest else None,
latest_distributions_cents=latest.distributions_cents if latest else None,
latest_value_cents=latest.ending_balance_cents if latest else None,
latest_as_of=latest.as_of_date if latest else None,
statements_count=len(stmts),
))
return result
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityResponse]:
allowed = accessible_entity_ids(user, session)
rows = session.exec(select(Entity)).all()
if allowed is not None:
rows = [r for r in rows if r.id in allowed]
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{entity_id}")
def get_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> EntityResponse:
allowed = accessible_entity_ids(user, session)
if allowed is not None and entity_id not in allowed:
raise HTTPException(status_code=404, detail="Entity not found")
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
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()
record_audit(session, user.id, "create", "entity", entity.id, body.model_dump())
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@router.patch("/{entity_id}")
def update_entity(
entity_id: int,
body: EntityUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = session.get(Entity, entity_id)
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)
session.flush()
record_audit(session, user.id, "update", "entity", entity.id, changes)
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages ---
def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse:
data = EntityStakeResponse.model_validate(stake, from_attributes=True)
fund = funds.get(stake.fund_entity_id)
if fund is not None:
data.fund_name = fund.name
data.fund_type = fund.type
return data
@router.get("/{entity_id}/stakes")
def list_stakes(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityStakeResponse]:
"""The funds this entity holds a stake in (e.g. a GP's interest in its funds)."""
allowed = accessible_entity_ids(user, session)
if allowed is not None and entity_id not in allowed:
raise HTTPException(status_code=404, detail="Entity not found")
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
rows = session.exec(
select(EntityStake).where(EntityStake.holder_entity_id == entity_id)
).all()
funds = {
f.id: f for f in session.exec(
select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows}))
).all()
} if rows else {}
return [_stake_response(r, funds) for r in rows]
@router.post("/{entity_id}/stakes", status_code=201)
def create_stake(
entity_id: int,
body: EntityStakeCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityStakeResponse:
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
if body.fund_entity_id == entity_id:
raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.")
fund = session.get(Entity, body.fund_entity_id)
if fund is None:
raise HTTPException(status_code=404, detail="Fund not found")
if session.exec(
select(EntityStake).where(
EntityStake.holder_entity_id == entity_id,
EntityStake.fund_entity_id == body.fund_entity_id,
)
).first():
raise HTTPException(status_code=409, detail="A stake in this fund already exists.")
stake = EntityStake(
holder_entity_id=entity_id,
fund_entity_id=body.fund_entity_id,
ownership_pct=body.ownership_pct,
value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None,
note=body.note,
)
session.add(stake)
session.flush()
record_audit(session, user.id, "create", "entity_stake", stake.id, {
"holder_entity_id": entity_id,
"fund_entity_id": body.fund_entity_id,
})
session.commit()
session.refresh(stake)
return _stake_response(stake, {fund.id: fund})
@router.delete("/{entity_id}/stakes/{stake_id}")
def delete_stake(
entity_id: int,
stake_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
stake = session.get(EntityStake, stake_id)
if stake is None or stake.holder_entity_id != entity_id:
raise HTTPException(status_code=404, detail="Stake not found")
record_audit(session, user.id, "delete", "entity_stake", stake_id, {
"holder_entity_id": entity_id,
"fund_entity_id": stake.fund_entity_id,
})
session.delete(stake)
session.commit()
return {"status": "deleted"}