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>
This commit is contained in:
Jonathan Kirkwood
2026-07-12 13:01:50 +02:00
co-authored by Claude Fable 5
parent 0822eca887
commit eac3262f29
22 changed files with 823 additions and 12 deletions
@@ -1,5 +1,7 @@
"""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
@@ -10,7 +12,7 @@ from ten31portal.auth import (
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
BtcPrice, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
)
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
@@ -37,6 +39,33 @@ def exit_dates(session: Session, rows) -> dict:
}
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,
@@ -74,11 +103,14 @@ def list_statements(
)
).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