Files
Ten31-Portal/backend/ten31portal/routers/capital_account_router.py
T
Jonathan KirkwoodandClaude Opus 4.8 1deb6586b0 0.2.36: Investor View carries exit status + grey out fully-exited fund cards
The admin read-only Investor View built capital-account responses
without exited_on, so an exited position showed the active card with
$0s (the LP's own portal was correct). Extracted exit_dates() into
capital_account_router and stamp it in investor_view too; regression
test added. A fund card where every position is exited now renders
greyed (bg + title) so it reads as closed at a glance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:35:03 -05:00

148 lines
5.5 KiB
Python

"""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, EntityAccess, 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)
def exit_dates(session: Session, rows) -> dict:
"""(investor_user_id, entity_id) -> exited_on for every exited pair in these statements,
so a sold/transferred stake renders as "Exited" instead of a phantom -100% loss."""
if not rows:
return {}
return {
(a.user_id, a.entity_id): a.exited_on
for a in session.exec(
select(EntityAccess).where(
col(EntityAccess.user_id).in_({r.investor_user_id for r in rows}),
col(EntityAccess.exited_on).is_not(None),
)
).all()
}
@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 {}
exits = exit_dates(session, rows)
out: list[CapitalAccountResponse] = []
for r in rows:
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
data.investor_name = names.get(r.investor_user_id)
data.exited_on = exits.get((r.investor_user_id, r.entity_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"}