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:
@@ -0,0 +1,129 @@
|
||||
"""Capital account statements: admin entry, investor read of their own figures."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, col
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, get_current_user, household_user_ids,
|
||||
require_internal_admin,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, User, UserRole,
|
||||
)
|
||||
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
||||
|
||||
router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"])
|
||||
|
||||
|
||||
def _dollars_to_cents(dollars: float) -> int:
|
||||
return round(dollars * 100)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_statements(
|
||||
entity_id: int | None = None,
|
||||
investor_user_id: int | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[CapitalAccountResponse]:
|
||||
query = select(CapitalAccountStatement)
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
|
||||
if allowed is not None:
|
||||
# External accounts see statements for every legal name linked to their login.
|
||||
query = query.where(
|
||||
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
|
||||
)
|
||||
if allowed:
|
||||
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||
else:
|
||||
return []
|
||||
else:
|
||||
if investor_user_id is not None:
|
||||
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||
|
||||
if entity_id is not None:
|
||||
query = query.where(CapitalAccountStatement.entity_id == entity_id)
|
||||
|
||||
rows = session.exec(
|
||||
query.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||
).all()
|
||||
# Attach each statement's legal name so the portal can label/group accounts held under
|
||||
# different names without an admin-only user lookup.
|
||||
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 {}
|
||||
out: list[CapitalAccountResponse] = []
|
||||
for r in rows:
|
||||
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||
data.investor_name = names.get(r.investor_user_id)
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_statement(
|
||||
body: CapitalAccountCreate,
|
||||
admin: User = Depends(require_internal_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CapitalAccountResponse:
|
||||
if session.get(Entity, body.entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
investor = session.get(User, body.investor_user_id)
|
||||
if investor is None or investor.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account")
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == body.entity_id,
|
||||
CapitalAccountStatement.investor_user_id == body.investor_user_id,
|
||||
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A statement for this investor, fund, and date already exists.",
|
||||
)
|
||||
|
||||
stmt = CapitalAccountStatement(
|
||||
entity_id=body.entity_id,
|
||||
investor_user_id=body.investor_user_id,
|
||||
as_of_date=body.as_of_date,
|
||||
commitment_cents=_dollars_to_cents(body.commitment_dollars),
|
||||
beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars),
|
||||
contributions_cents=_dollars_to_cents(body.contributions_dollars),
|
||||
distributions_cents=_dollars_to_cents(body.distributions_dollars),
|
||||
ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars),
|
||||
document_id=body.document_id,
|
||||
)
|
||||
session.add(stmt)
|
||||
session.flush()
|
||||
record_audit(session, admin.id, "create", "capital_account", stmt.id, {
|
||||
"entity_id": body.entity_id,
|
||||
"investor_user_id": body.investor_user_id,
|
||||
"as_of_date": str(body.as_of_date),
|
||||
"ending_balance_cents": stmt.ending_balance_cents,
|
||||
})
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
return CapitalAccountResponse.model_validate(stmt, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/{statement_id}")
|
||||
def delete_statement(
|
||||
statement_id: int,
|
||||
admin: User = Depends(require_internal_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
stmt = session.get(CapitalAccountStatement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(status_code=404, detail="Statement not found")
|
||||
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
|
||||
session.delete(stmt)
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
Reference in New Issue
Block a user