Files
Ten31-Portal/backend/ten31portal/routers/capital_account_router.py
T
Jonathan KirkwoodandClaude Fable 5 eac3262f29 0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center
- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:01:50 +02:00

180 lines
6.7 KiB
Python

"""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"}