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>
148 lines
5.5 KiB
Python
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"}
|