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
@@ -3,6 +3,7 @@
import os
import secrets
import time
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
@@ -78,6 +79,13 @@ def login(
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled")
# Anyone still signing in with the shared default password gets flagged so the portal
# forces them to set their own before doing anything else (covers accounts created
# before the flag existed).
if body.password == config.DEFAULT_INVESTOR_PASSWORD and not user.must_change_password:
user.must_change_password = True
session.add(user)
session.commit()
if user.totp_enabled:
# Password accepted, but don't sign the session in yet — park the login until
# /login/verify-totp confirms the second factor.
@@ -155,7 +163,10 @@ def change_password(
raise HTTPException(status_code=400, detail="Current password is incorrect.")
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
if body.new_password == config.DEFAULT_INVESTOR_PASSWORD:
raise HTTPException(status_code=400, detail="Please choose a password of your own.")
user.password_hash = hash_password(body.new_password)
user.must_change_password = False
session.add(user)
session.commit()
# Once the built-in admin sets their own password, the initial one from first boot is stale.
@@ -167,6 +178,19 @@ def change_password(
return {"status": "ok"}
@router.post("/onboarded")
def mark_onboarded(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Stamp the first-login welcome flow as finished (or skipped) so it stops showing."""
if user.onboarded_at is None:
user.onboarded_at = datetime.utcnow()
session.add(user)
session.commit()
return {"status": "ok"}
# --- Two-factor enrollment (per-user opt-in) ---
@router.post("/totp/setup")
@@ -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
@@ -325,6 +325,8 @@ def commit_import(
role=UserRole.investor,
login_enabled=True,
external_investor_id=inv.external_id,
# First login forces a change while they're on the shared default.
must_change_password=(pw == config.DEFAULT_INVESTOR_PASSWORD),
)
session.add(user)
session.flush()
+3 -1
View File
@@ -294,7 +294,9 @@ def update_entity(
setattr(entity, key, val)
session.add(entity)
session.flush()
record_audit(session, user.id, "update", "entity", entity.id, changes)
# mode="json" so date fields (close_date) serialize into the audit JSON column.
record_audit(session, user.id, "update", "entity", entity.id,
body.model_dump(exclude_unset=True, mode="json"))
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
+130 -1
View File
@@ -21,9 +21,10 @@ from ten31portal.audit import record_audit
from ten31portal.auth import require_role
from ten31portal.database import get_session
from ten31portal.models import (
Entity, EntityType, Holding, Position,
BtcPrice, Entity, EntityType, Holding, Position,
User, UserRole, Valuation, ValuationRound, RoundStatus,
)
from ten31portal.schemas import BtcPricesImportResult, BtcPricesStatus
router = APIRouter(prefix="/api/import", tags=["import"])
@@ -716,3 +717,131 @@ def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list
})
return holdings_preview, positions_preview, errors
# --- BTC prices (bitcoin-denominated view) ---
_BTC_DATE_HEADERS = ("date", "time", "day", "snapped_at")
_BTC_PRICE_HEADERS = ("close", "price", "usd", "last", "rate")
_BTC_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%Y/%m/%d", "%d-%b-%Y", "%b %d, %Y")
def _parse_btc_date(raw: str) -> date | None:
s = raw.strip().strip('"')
if not s:
return None
# Timestamps like "2026-01-01 00:00:00 UTC" — the date part is enough.
for sep in (" ", "T"):
if sep in s and len(s.split(sep)[0]) >= 8:
s = s.split(sep)[0]
break
for fmt in _BTC_DATE_FORMATS:
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def _parse_btc_price(raw: str) -> int | None:
s = raw.strip().strip('"').replace("$", "").replace(",", "").replace(" ", "")
if not s:
return None
try:
value = float(s)
except ValueError:
return None
if value <= 0:
return None
return round(value * 100)
def _btc_status(session: Session) -> dict[str, Any]:
rows = session.exec(select(BtcPrice).order_by(col(BtcPrice.date))).all()
latest = rows[-1] if rows else None
return {
"count": len(rows),
"first_date": rows[0].date if rows else None,
"last_date": latest.date if latest else None,
"latest_price_cents": latest.price_cents if latest else None,
}
@router.get("/btc-prices")
def btc_prices_status(
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
session: Session = Depends(get_session),
) -> BtcPricesStatus:
return BtcPricesStatus(**_btc_status(session))
@router.post("/btc-prices")
def import_btc_prices(
file: UploadFile = File(...),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
session: Session = Depends(get_session),
) -> BtcPricesImportResult:
"""Upsert BTC/USD prices from a CSV (a date column and a close/price column).
Column headers are auto-detected; a headerless two-column file works too. Re-uploading
overwrites prices for dates already stored, so corrections are just another upload.
"""
try:
file_bytes = storage.read_capped(file)
except storage.UploadTooLarge as exc:
raise HTTPException(status_code=413, detail=str(exc))
try:
text = file_bytes.decode("utf-8-sig")
except UnicodeDecodeError:
text = file_bytes.decode("latin-1")
rows = [r for r in csv.reader(io.StringIO(text)) if any(c.strip() for c in r)]
if not rows:
raise HTTPException(status_code=400, detail="The file is empty.")
# Column detection: match headers when present, else assume date,price.
date_idx, price_idx, start = 0, 1, 0
header = [c.strip().lower() for c in rows[0]]
if not (_parse_btc_date(rows[0][0]) and len(rows[0]) > 1):
for i, name in enumerate(header):
if any(k in name for k in _BTC_DATE_HEADERS):
date_idx = i
break
for key in _BTC_PRICE_HEADERS: # 'close' preferred over generic 'price'
hit = next((i for i, name in enumerate(header) if key in name and i != date_idx), None)
if hit is not None:
price_idx = hit
break
start = 1
existing = {p.date: p for p in session.exec(select(BtcPrice)).all()}
imported = 0
skipped = 0
for r in rows[start:]:
if len(r) <= max(date_idx, price_idx):
skipped += 1
continue
d = _parse_btc_date(r[date_idx])
price = _parse_btc_price(r[price_idx])
if d is None or price is None:
skipped += 1
continue
row = existing.get(d)
if row is None:
row = BtcPrice(date=d, price_cents=price)
existing[d] = row
else:
row.price_cents = price
session.add(row)
imported += 1
if imported == 0:
raise HTTPException(
status_code=400,
detail="No prices found — expected a CSV with a date column and a price column.",
)
record_audit(session, user.id, "import_btc_prices", "btc_prices", None,
detail={"imported": imported, "skipped": skipped, "filename": file.filename})
session.commit()
return BtcPricesImportResult(imported=imported, skipped_rows=skipped, **_btc_status(session))
+8 -3
View File
@@ -144,14 +144,17 @@ def investor_view(
col(User.id).in_({r.investor_user_id for r in cap_rows})
)
).all()) if cap_rows else {}
# Mirror the investor's own portal exactly — including exit status, so an exited
# position shows its badge here too instead of a phantom -100%.
from ten31portal.routers.capital_account_router import exit_dates
# Mirror the investor's own portal exactly — including exit status and BTC marks,
# so this view never drifts from what the LP actually sees.
from ten31portal.routers.capital_account_router import btc_marks, exit_dates
exits = exit_dates(session, cap_rows)
btc_asof, btc_close = btc_marks(session, cap_rows)
for r in cap_rows:
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
d.investor_name = names.get(r.investor_user_id)
d.exited_on = exits.get((r.investor_user_id, r.entity_id))
d.btc_price_cents = btc_asof.get(r.id)
d.btc_close_price_cents = btc_close.get(r.entity_id)
caps.append(d)
doc_rows = session.exec(
@@ -373,6 +376,8 @@ def reset_password(
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user.password_hash = hash_password(body.password)
# Admin handed them a real password — no forced change on next login.
user.must_change_password = False
user.login_enabled = True # setting a password enables login
session.add(user)
record_audit(session, admin.id, "reset_password", "user", user_id, None)