Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.
New features:
- Investor capital-over-time chart (value, paid-in, distributions per
quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
(GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
categorized correctly.
Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
@@ -3,16 +3,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, literal
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, get_current_user, require_internal, require_writer,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
Entity, EntityStatus, Holding, Position,
|
||||
Valuation, ValuationRound, RoundStatus, User,
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
|
||||
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
|
||||
)
|
||||
from ten31portal.schemas import (
|
||||
EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate,
|
||||
PartnerResponse,
|
||||
)
|
||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||
|
||||
@@ -24,6 +29,7 @@ class EntityRollupItem(BaseModel):
|
||||
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
|
||||
|
||||
@@ -34,7 +40,10 @@ def entity_rollup(
|
||||
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:
|
||||
@@ -64,6 +73,20 @@ def entity_rollup(
|
||||
).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,
|
||||
@@ -71,6 +94,7 @@ def entity_rollup(
|
||||
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,
|
||||
))
|
||||
@@ -78,12 +102,60 @@ def entity_rollup(
|
||||
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]
|
||||
|
||||
|
||||
@@ -93,6 +165,9 @@ def get_entity(
|
||||
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")
|
||||
@@ -133,3 +208,96 @@ def update_entity(
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user