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
+2
View File
@@ -78,6 +78,7 @@ def reset_password(args: argparse.Namespace) -> None:
sys.exit(1)
user.password_hash = hash_password(args.password)
user.login_enabled = True
user.must_change_password = False
is_admin = user.is_service_admin
session.add(user)
session.commit()
@@ -134,6 +135,7 @@ def enable_investor_logins(args: argparse.Namespace) -> None:
for u in users:
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
u.login_enabled = True
u.must_change_password = True
session.add(u)
session.commit()
for u in users:
+23 -1
View File
@@ -2,10 +2,11 @@
import enum
from datetime import date, datetime
from datetime import date as _date # for fields literally named "date"
from decimal import Decimal
from typing import Optional
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
from sqlmodel import Field, SQLModel, Column, Date, String, JSON, UniqueConstraint
# --- Enums ---
@@ -85,6 +86,11 @@ class User(SQLModel, table=True):
totp_enabled: bool = Field(default=False)
# JSON list of sha256 hex digests of unused one-time recovery codes.
totp_recovery_codes: str | None = Field(default=None)
# True while the account is on the shared default password — the portal forces a
# password change before anything else. Cleared by change-password / admin reset.
must_change_password: bool = Field(default=False)
# When the investor finished (or skipped) the first-login welcome flow. Null = show it.
onboarded_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -100,9 +106,25 @@ class Entity(SQLModel, table=True):
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
# Final close of the fund/SPV — the BTC entry mark: paid-in capital is valued at the BTC
# price on this date for the bitcoin-denominated view. Null = no BTC view for this fund.
close_date: date | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
class BtcPrice(SQLModel, table=True):
"""Daily (or as-uploaded) BTC/USD closing prices from the admin's CSV.
Statements are valued at the newest price on or before their as-of date, so the CSV
doesn't need every calendar day — quarter-end rows are enough."""
__tablename__ = "btc_prices"
id: int | None = Field(default=None, primary_key=True)
date: _date = Field(sa_column=Column(Date, unique=True, nullable=False))
price_cents: int
class Holding(SQLModel, table=True):
__tablename__ = "holdings"
@@ -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)
+25
View File
@@ -27,6 +27,10 @@ class UserResponse(BaseModel):
is_service_admin: bool = False
primary_account_id: int | None = None # set when this account logs in under another
totp_enabled: bool = False
# First-login flow: force a password change while on the shared default, then show the
# welcome step (2FA offer) until onboarded_at is stamped.
must_change_password: bool = False
onboarded_at: datetime | None = None
created_at: datetime
@@ -129,6 +133,7 @@ class EntityUpdate(BaseModel):
fund_size_cents: int | None = None
status: EntityStatus | None = None
linked_user_id: int | None = None
close_date: date | None = None
class EntityResponse(BaseModel):
@@ -139,6 +144,7 @@ class EntityResponse(BaseModel):
fund_size_cents: int | None
status: EntityStatus
linked_user_id: int | None = None
close_date: date | None = None
created_at: datetime
@@ -274,6 +280,25 @@ class CapitalAccountResponse(BaseModel):
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
# "Exited" badge instead of a phantom -100% and drops the position from totals.
exited_on: date | None = None
# Bitcoin-denominated view: BTC/USD at this statement's as-of date (newest uploaded price
# on or before it) and at the fund's close date (the entry mark). Null when no price or
# no close date is set — the portal simply hides the BTC view then.
btc_price_cents: int | None = None
btc_close_price_cents: int | None = None
# --- BTC prices (bitcoin-denominated view) ---
class BtcPricesStatus(BaseModel):
count: int
first_date: date | None = None
last_date: date | None = None
latest_price_cents: int | None = None
class BtcPricesImportResult(BtcPricesStatus):
imported: int # rows upserted from this file (new + updated)
skipped_rows: int # unparseable lines ignored
# --- Partners (members of an entity) ---