- 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>
180 lines
6.7 KiB
Python
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"}
|