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
@@ -0,0 +1,43 @@
"""btc_prices table + entities.close_date + first-login flow columns on users
Revision ID: a3b4c5d6e7f8
Revises: f2a3b4c5d6e7
Create Date: 2026-07-12 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a3b4c5d6e7f8'
down_revision: Union[str, None] = 'f2a3b4c5d6e7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'btc_prices',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('date', sa.Date(), nullable=False, unique=True),
sa.Column('price_cents', sa.Integer(), nullable=False),
)
with op.batch_alter_table('entities', schema=None) as batch_op:
batch_op.add_column(sa.Column('close_date', sa.Date(), nullable=True))
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(
sa.Column('must_change_password', sa.Boolean(), nullable=False,
server_default=sa.false())
)
batch_op.add_column(sa.Column('onboarded_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('onboarded_at')
batch_op.drop_column('must_change_password')
with op.batch_alter_table('entities', schema=None) as batch_op:
batch_op.drop_column('close_date')
op.drop_table('btc_prices')
+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) ---
+120
View File
@@ -0,0 +1,120 @@
"""0.2.39: BTC price CSV + bitcoin-denominated marks, forced default-password change,
and the first-login onboarded watermark."""
import io
from datetime import date
from ten31portal import config
from ten31portal.models import CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole
from tests.conftest import make_user
def _fund_with_lp(session, *, close_date=None):
entity = Entity(name="LTPF X", type=EntityType.fund, close_date=close_date)
session.add(entity)
session.commit()
lp = make_user(session, username="lp", role=UserRole.investor, name="An LP")
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
session.add(CapitalAccountStatement(
entity_id=entity.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
commitment_cents=1_000_000_00, beginning_balance_cents=0,
contributions_cents=500_000_00, distributions_cents=0,
ending_balance_cents=600_000_00,
))
session.commit()
return entity, lp
def _upload_prices(client, csv_text):
return client.post(
"/api/import/btc-prices",
files={"file": ("prices.csv", io.BytesIO(csv_text.encode()), "text/csv")},
)
def test_btc_csv_import_and_marks(auth_client, session):
_fund_with_lp(session, close_date=date(2025, 6, 30))
resp = _upload_prices(
auth_client,
"Date,Close\n2025-06-30,60000\n2025-12-31,80000\n2026-03-31,100000.50\n",
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["imported"] == 3
assert body["latest_price_cents"] == 10_000_050
# The LP's statements carry both marks: as-of price and close-date price.
auth_client.post("/api/auth/logout")
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
acct = auth_client.get("/api/capital-accounts").json()[0]
assert acct["btc_price_cents"] == 10_000_050 # exact as-of match
assert acct["btc_close_price_cents"] == 6_000_000 # fund close 2025-06-30
def test_btc_price_nearest_on_or_before(auth_client, session):
# Prices only exist BEFORE the statement date → the newest one on-or-before is used.
_fund_with_lp(session, close_date=date(2025, 6, 30))
resp = _upload_prices(auth_client, "date,price\n2025-06-28,55000\n2026-03-01,90000\n")
assert resp.status_code == 200, resp.text
auth_client.post("/api/auth/logout")
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
acct = auth_client.get("/api/capital-accounts").json()[0]
assert acct["btc_price_cents"] == 9_000_000 # 2026-03-01 covers 2026-03-31
assert acct["btc_close_price_cents"] == 5_500_000 # 2025-06-28 covers the 06-30 close
def test_btc_reupload_overwrites_and_bad_file_rejected(auth_client, session):
assert _upload_prices(auth_client, "Date,Close\n2026-01-01,90000\n").status_code == 200
r = _upload_prices(auth_client, "Date,Close\n2026-01-01,95000\n")
assert r.status_code == 200
assert r.json()["count"] == 1 # upsert, not a duplicate row
assert r.json()["latest_price_cents"] == 9_500_000
assert _upload_prices(auth_client, "just some text\nwith,no,dates\n").status_code == 400
def test_default_password_forces_change(client, session):
make_user(session, username="fresh", role=UserRole.investor,
password=config.DEFAULT_INVESTOR_PASSWORD)
resp = client.post(
"/api/auth/login",
json={"login": "fresh", "password": config.DEFAULT_INVESTOR_PASSWORD},
)
assert resp.status_code == 200
assert resp.json()["must_change_password"] is True
# Choosing the shared default again is rejected; a real password clears the flag.
r = client.post("/api/auth/change-password", json={
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
"new_password": config.DEFAULT_INVESTOR_PASSWORD,
})
assert r.status_code == 400
r = client.post("/api/auth/change-password", json={
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
"new_password": "my-own-secret-1",
})
assert r.status_code == 200
assert client.get("/api/auth/me").json()["must_change_password"] is False
def test_onboarded_stamp(client, session):
make_user(session, username="lp2", role=UserRole.investor)
client.post("/api/auth/login", json={"login": "lp2", "password": "password123"})
assert client.get("/api/auth/me").json()["onboarded_at"] is None
assert client.post("/api/auth/onboarded").status_code == 200
stamped = client.get("/api/auth/me").json()["onboarded_at"]
assert stamped is not None
# Idempotent — the first stamp wins.
client.post("/api/auth/onboarded")
assert client.get("/api/auth/me").json()["onboarded_at"] == stamped
def test_entity_close_date_update(auth_client, session):
entity = Entity(name="SPV Y", type=EntityType.spv)
session.add(entity)
session.commit()
r = auth_client.patch(f"/api/entities/{entity.id}", json={"close_date": "2025-11-15"})
assert r.status_code == 200, r.text
assert r.json()["close_date"] == "2025-11-15"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
"version": "0.2.38",
"version": "0.2.39",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+3 -2
View File
@@ -1,4 +1,4 @@
export { v_0_2_38 as current } from './v_0_2_38'
export { v_0_2_39 as current } from './v_0_2_39'
import { v_0_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1'
@@ -37,4 +37,5 @@ import { v_0_2_34 } from './v_0_2_34'
import { v_0_2_35 } from './v_0_2_35'
import { v_0_2_36 } from './v_0_2_36'
import { v_0_2_37 } from './v_0_2_37'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37]
import { v_0_2_38 } from './v_0_2_38'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38]
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_39 = VersionInfo.of({
version: '0.2.39:0',
releaseNotes: {
en_US:
'Investor experience release: (1) Bitcoin-denominated view — upload a BTC price CSV on the Import page, set each fund\'s close date, and LPs see paid-in vs current value in bitcoin terms. (2) First-login flow — accounts on the shared default password must set their own, then get a welcome tour with a two-factor offer. (3) Unfunded commitment metric and a Tax documents center in the LP portal. Also carries 0.2.38: optional two-factor authentication (authenticator app + recovery codes) and the Reset Two-Factor action.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached
// Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.38'
const CACHE = 'ten31-portal-0.2.39'
self.addEventListener('install', () => self.skipWaiting())
+5
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { isInternal } from "./api";
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
import Layout from "./components/Layout";
import Login from "./pages/Login";
import EntitiesList from "./pages/EntitiesList";
@@ -50,6 +51,7 @@ function ExternalApp() {
return (
<PortalLayout>
{user?.role === "fund_administrator" ? <FundAdminHome /> : <InvestorHome />}
{user && !user.onboarded_at && <WelcomeFlow />}
</PortalLayout>
);
}
@@ -87,6 +89,9 @@ function ProtectedRoutes() {
return offline ? <OfflineNotice onRetry={retry} /> : <Login />;
}
// Still on the shared default password → nothing else until they set their own.
if (user.must_change_password) return <ForcePasswordChange />;
return isInternal(user.role) ? <InternalApp /> : <ExternalApp />;
}
+32
View File
@@ -45,6 +45,8 @@ export interface User {
is_service_admin: boolean;
primary_account_id: number | null;
totp_enabled: boolean;
must_change_password: boolean;
onboarded_at: string | null;
created_at: string;
}
@@ -154,6 +156,18 @@ export interface BatchCapitalImportResult {
total_statements: number;
}
export interface BtcPricesStatus {
count: number;
first_date: string | null;
last_date: string | null;
latest_price_cents: number | null;
}
export interface BtcPricesImportResult extends BtcPricesStatus {
imported: number;
skipped_rows: number;
}
export interface CapitalAccount {
id: number;
entity_id: number;
@@ -168,6 +182,9 @@ export interface CapitalAccount {
document_id: number | null;
created_at: string;
exited_on: string | null;
// BTC/USD marks for the bitcoin-denominated view (null = no price data / no close date)
btc_price_cents: number | null;
btc_close_price_cents: number | null;
}
export interface Entity {
@@ -178,6 +195,7 @@ export interface Entity {
fund_size_cents: number | null;
status: EntityStatus;
linked_user_id: number | null;
close_date: string | null;
created_at: string;
}
@@ -514,6 +532,20 @@ export const api = {
if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed");
return data;
},
// BTC prices (bitcoin-denominated view)
btcPricesStatus: () => request<BtcPricesStatus>("/api/import/btc-prices"),
importBtcPrices: async (file: File): Promise<BtcPricesImportResult> => {
const form = new FormData();
form.set("file", file);
const res = await fetch("/api/import/btc-prices", { method: "POST", body: form });
const data = await res.json().catch(() => ({ detail: res.statusText }));
if (!res.ok) throw new ApiError(res.status, data.detail || "Price import failed");
return data;
},
markOnboarded: () =>
request<{ status: string }>("/api/auth/onboarded", { method: "POST" }),
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
createUser: (data: {
name: string;
+130
View File
@@ -0,0 +1,130 @@
import { useState } from "react";
import { api } from "../api";
import { useAuth } from "../context/AuthContext";
import PasswordInput from "./PasswordInput";
import TwoFactorModal from "./TwoFactorModal";
/** Full-screen gate shown while the account is still on the shared default password.
* Nothing else is reachable until a personal password is set. */
export function ForcePasswordChange() {
const { user, retry, logout } = useAuth();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const save = async () => {
setError("");
if (next.length < 8) return setError("New password must be at least 8 characters.");
if (next !== confirm) return setError("New passwords don't match.");
setBusy(true);
try {
await api.changePassword(current, next);
retry(); // refreshes the user; must_change_password is now false
} catch (e: any) {
setError(e.message || "Could not change password");
setBusy(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-full max-w-md">
<div className="flex items-center gap-2.5 mb-4">
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
<h1 className="text-xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""}</h1>
</div>
<p className="text-sm text-gray-600 mb-5">
Your account is using a temporary password. Choose your own to continue only you
will know it.
</p>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-1">Temporary password</label>
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">New password</label>
<PasswordInput value={next} onChange={setNext} placeholder="minimum 8 characters" />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
<PasswordInput value={confirm} onChange={setConfirm} />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
onClick={save}
disabled={busy || !current || !next}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Saving…" : "Set password and continue"}
</button>
<button
onClick={logout}
className="w-full text-xs text-gray-400 hover:text-gray-600"
>
Sign out
</button>
</div>
</div>
</div>
);
}
/** One-time welcome overlay for external accounts: a 20-second orientation plus the 2FA
* offer. Dismissing it (either way) stamps onboarded_at so it never shows again. */
export function WelcomeFlow() {
const [done, setDone] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const finish = () => {
setDone(true);
api.markOnboarded().catch(() => {}); // best-effort; shows again next visit if it failed
};
if (done) return null;
if (enrolling) {
return <TwoFactorModal onClose={() => { setEnrolling(false); finish(); }} />;
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md p-6">
<div className="flex items-center gap-2.5 mb-3">
<img src="/ten31-logo.png" alt="" className="w-8 h-8 rounded-lg" />
<h3 className="text-lg font-semibold text-gray-900">Welcome to Ten31 Portal</h3>
</div>
<ul className="text-sm text-gray-600 space-y-2 mb-4">
<li>
<span className="font-medium text-gray-800">Your capital accounts</span> commitment,
paid-in, and current balance for each fund, updated as statements arrive.
</li>
<li>
<span className="font-medium text-gray-800">Documents</span> K-1s and fund documents
live here permanently; new ones are badged.
</li>
<li>
<span className="font-medium text-gray-800">Self-hosted by Ten31</span> your data
stays on our own infrastructure, never a third-party service.
</li>
</ul>
<p className="text-sm text-gray-600 mb-5">
One more thing worth doing: add two-factor authentication, so your account stays
safe even if your password is ever guessed.
</p>
<div className="flex justify-end gap-2">
<button onClick={finish} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
Maybe later
</button>
<button
onClick={() => setEnrolling(true)}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
>
Set up two-factor
</button>
</div>
</div>
</div>
);
}
+13
View File
@@ -234,6 +234,7 @@ function EditEntityForm({
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
);
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
const [closeDate, setCloseDate] = useState(entity.close_date ?? "");
const [investors, setInvestors] = useState<User[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
@@ -265,6 +266,7 @@ function EditEntityForm({
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
: null,
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
close_date: closeDate || null,
};
const updated = await api.updateEntity(entity.id, data);
onSaved(updated);
@@ -312,6 +314,17 @@ function EditEntityForm({
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
</div>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">
Close date (BTC entry mark paid-in capital is valued at the BTC price on this day)
</label>
<input
type="date"
className={inputCls}
value={closeDate}
onChange={(e) => setCloseDate(e.target.value)}
/>
</div>
{isGp && (
<div>
<label className="block text-sm text-gray-700 mb-1">
+78
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import {
api, isAdmin,
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
type BtcPricesStatus,
} from "../api";
import { useAuth } from "../context/AuthContext";
import { formatDate, formatMoneyExact } from "../format";
@@ -383,6 +384,8 @@ export default function Import() {
{!memberPv && !result && <BatchBackfill entities={entities} />}
{!memberPv && !result && <BtcPrices />}
{confirmReplace && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
@@ -542,3 +545,78 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
</div>
);
}
// BTC/USD price history for the bitcoin-denominated LP view: upload a CSV (date + close
// price); statements are valued at the newest price on or before their date, and paid-in
// at the price on each fund's close date (set on the fund's overview page).
function BtcPrices() {
const [status, setStatus] = useState<BtcPricesStatus | null>(null);
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
useEffect(() => {
api.btcPricesStatus().then(setStatus).catch(() => {});
}, []);
async function run() {
if (!file) return;
setError("");
setMsg("");
setBusy(true);
try {
const r = await api.importBtcPrices(file);
setStatus(r);
setMsg(
`Loaded ${r.imported} price${r.imported === 1 ? "" : "s"}` +
(r.skipped_rows ? ` (${r.skipped_rows} unreadable row${r.skipped_rows === 1 ? "" : "s"} skipped)` : "") +
".",
);
setFile(null);
} catch (e: any) {
setError(e.message || "Price import failed");
} finally {
setBusy(false);
}
}
return (
<div className="mt-10 border-t border-gray-200 pt-8">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Bitcoin prices</h2>
<p className="text-sm text-gray-500 mb-3">
Upload a CSV of BTC/USD prices (a date column and a close/price column). Investors
then see their capital in bitcoin terms; paid-in is valued at the price on each
fund's close date set that on the fund's overview page.
</p>
{status && status.count > 0 && (
<p className="text-xs text-gray-400 mb-3">
{status.count} prices on file
{status.first_date && status.last_date && (
<> · {formatDate(status.first_date)} {formatDate(status.last_date)}</>
)}
{status.latest_price_cents != null && (
<> · latest ${(status.latest_price_cents / 100).toLocaleString()}</>
)}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
accept=".csv"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="text-sm"
/>
<button
onClick={run}
disabled={busy || !file}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Loading…" : "Load prices"}
</button>
</div>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
{msg && <p className="text-sm text-green-700 mt-2">{msg}</p>}
</div>
);
}
+133
View File
@@ -5,6 +5,13 @@ import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
const pct = (p: number) => `${p >= 0 ? "+" : ""}${Math.abs(p).toFixed(1)}%`;
// Dollars-at-cents ÷ BTC-price-at-cents, formatted to a sensible precision for the size.
const formatBtc = (usdCents: number, priceCents: number) => {
const btc = usdCents / priceCents;
const dp = btc >= 10 ? 2 : btc >= 1 ? 3 : 4;
return `${btc.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })}`;
};
// The investor-facing portal, rendered purely from data. Used by the investor's own home
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
export default function InvestorPortalView({
@@ -52,6 +59,7 @@ export default function InvestorPortalView({
exitedCount={exitedPositions}
/>
)}
<TaxCenter docs={docs} entities={entities} />
{ordered.map((e) => (
<FundSection
key={e.id}
@@ -139,6 +147,83 @@ function PortfolioSummary({
);
}
// Every K-1 and tax document across the LP's funds, gathered in one place and grouped by
// year — so tax season is one stop instead of a hunt through fund cards.
function TaxCenter({ docs, entities }: { docs: PortalDocument[]; entities: Entity[] }) {
const [open, setOpen] = useState(false);
const taxDocs = useMemo(
() => docs.filter((d) => d.category === "k1" || d.category === "tax"),
[docs],
);
const entityName = useMemo(
() => new Map(entities.map((e) => [e.id, e.name])),
[entities],
);
const byYear = useMemo(() => {
const groups = new Map<number, PortalDocument[]>();
for (const d of taxDocs) {
const y = new Date(d.created_at).getFullYear();
const g = groups.get(y) ?? [];
g.push(d);
groups.set(y, g);
}
return [...groups.entries()].sort((a, b) => b[0] - a[0]);
}, [taxDocs]);
if (taxDocs.length === 0) return null;
const hasNew = taxDocs.some((d) => d.is_new);
return (
<section className="border border-gray-200 rounded-lg p-5 bg-white">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex items-center gap-2 w-full text-left"
>
<span className={`text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}></span>
<h2 className="text-base font-semibold text-gray-900">Tax documents</h2>
<span className="text-xs text-gray-400">
{taxDocs.length} document{taxDocs.length === 1 ? "" : "s"}
</span>
{hasNew && (
<span className="text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
New
</span>
)}
</button>
{open && (
<div className="mt-4 space-y-3">
{byYear.map(([year, ds]) => (
<div key={year}>
<p className="text-xs font-medium text-gray-400 mb-1">{year}</p>
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
{ds.map((d) => (
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
<span className="text-gray-900">{d.title}</span>
{d.is_new && (
<span className="ml-2 text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
New
</span>
)}
<span className="ml-2 text-xs text-gray-400">
{entityName.get(d.entity_id) ?? ""}
</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
Download
</a>
</li>
))}
</ul>
</div>
))}
</div>
)}
</section>
);
}
function FundSection({
entity,
accounts,
@@ -302,6 +387,13 @@ function CapitalBlock({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
{latest.commitment_cents > latest.contributions_cents && (
<Metric
label="Unfunded"
value={formatMoneyWhole(latest.commitment_cents - latest.contributions_cents)}
sub="callable commitment"
/>
)}
{latest.distributions_cents > 0 && (
<Metric
label="Distributions"
@@ -326,11 +418,52 @@ function CapitalBlock({
</p>
)}
<BtcTerms latest={latest} />
<HistorySection history={history} />
</div>
);
}
// The bitcoin-denominated view: paid-in capital valued at the BTC price on the fund's close
// (the entry mark set by the admin), current value at the newest statement's price. Hidden
// entirely until the admin has uploaded prices and set the fund's close date.
function BtcTerms({ latest }: { latest: CapitalAccount }) {
const closePrice = latest.btc_close_price_cents;
const asofPrice = latest.btc_price_cents;
if (!closePrice || !asofPrice || latest.contributions_cents <= 0) return null;
const paidInBtc = latest.contributions_cents / closePrice;
const totalNowBtc = (latest.ending_balance_cents + latest.distributions_cents) / asofPrice;
const btcPct = paidInBtc > 0 ? (totalNowBtc / paidInBtc - 1) * 100 : null;
return (
<div className="mt-4 border border-accent-100 bg-accent-50/50 rounded p-3">
<p className="text-xs font-medium uppercase text-gray-500">In bitcoin terms</p>
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 mt-1.5">
<span className="text-sm text-gray-600">
Paid-in{" "}
<span className="font-medium text-gray-900">
{formatBtc(latest.contributions_cents, closePrice)}
</span>{" "}
<span className="text-xs text-gray-400">at close</span>
</span>
<span className="text-sm text-gray-600">
Now{" "}
<span className="font-medium text-gray-900">
{formatBtc(latest.ending_balance_cents + latest.distributions_cents, asofPrice)}
</span>
</span>
{btcPct != null && (
<span className={`text-sm font-medium ${btcPct >= 0 ? "text-accent-600" : "text-red-600"}`}>
{pct(btcPct)}
</span>
)}
</div>
</div>
);
}
// Collapsible capital-over-time chart + statement table. Collapsed by default so the portal
// opens clean; the investor expands the trend per fund. SPVs get sparse event-driven points,
// funds quarterly ones — the chart plots whatever statements exist.
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served.
export const APP_VERSION = "0.2.38";
export const APP_VERSION = "0.2.39";