"""Capital account statements: admin entry, investor read of their own figures.""" from bisect import bisect_right 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 ( BtcPrice, 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() } def btc_marks(session: Session, rows) -> tuple[dict, dict]: """BTC/USD marks for the bitcoin-denominated view. Returns ({statement_id: price at its as-of date}, {entity_id: price at the fund's close date}) — "price at" meaning the newest uploaded price on or before that date, so a quarter-end-only CSV is enough. Empty when no prices are uploaded; a fund without a close_date has no entry and the portal hides its BTC view. """ if not rows: return {}, {} prices = session.exec(select(BtcPrice).order_by(col(BtcPrice.date))).all() if not prices: return {}, {} dates = [p.date for p in prices] def price_at(d): i = bisect_right(dates, d) return prices[i - 1].price_cents if i else None asof = {r.id: price_at(r.as_of_date) for r in rows} entities = session.exec( select(Entity).where(col(Entity.id).in_({r.entity_id for r in rows})) ).all() close = {e.id: price_at(e.close_date) for e in entities if e.close_date is not None} return asof, close @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) btc_asof, btc_close = btc_marks(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)) data.btc_price_cents = btc_asof.get(r.id) data.btc_close_price_cents = btc_close.get(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"}