0.2.27-0.2.32: LP portal polish, brand palette, default investor logins

Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
  distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
  distributions-line gate
- 0.2.32: LP-facing polish pass
  * Ten31 brand palette from the logo (navy/mint); orange retired
  * portfolio summary card across funds; gain labeled "net of paid-in"
  * whole-dollar headline figures; "History · N quarters" toggle
  * documents grouped by year with a "New" badge (users.docs_seen_at)
  * eNAV-created members start on default password with login enabled;
    enable-investor-logins CLI + StartOS action for existing accounts
  * password minimum raised to 8 chars; login help line (Portal@ten31.xyz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-03 08:40:40 -05:00
co-authored by Claude Opus 4.8
parent 69f12b0519
commit 4215d4478f
47 changed files with 935 additions and 111 deletions
@@ -6,20 +6,19 @@ Fallback: a subaccounts sheet (investor names across columns, per-vehicle value
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
(without a login unless a password is given), grants entity access, and loads each member's
capital-account statement (commitment, contributions, distributions, current value).
(on the shared default password unless one is given), grants entity access, and loads each
member's capital-account statement (commitment, contributions, distributions, current value).
"""
import io
import re
import secrets
from datetime import date, datetime
import openpyxl
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session, select
from ten31portal import storage
from ten31portal import config, storage
from ten31portal.audit import record_audit
from ten31portal.auth import hash_password, require_internal_admin
from ten31portal.database import get_session
@@ -38,6 +37,39 @@ MAX_ROWS = 400
MAX_COLS = 90
def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]:
"""Remove every partner from one fund: delete its capital-account statements and the
investors' access grants to it. The investor *accounts* are kept — they usually also
belong to other funds — only their membership of THIS entity is cleared. Use to undo a
wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV
are untouched (see reset_entity_holdings for those). Caller commits.
"""
statements = 0
for s in session.exec(
select(CapitalAccountStatement).where(
CapitalAccountStatement.entity_id == entity_id
)
).all():
session.delete(s)
statements += 1
# Only drop investor memberships; a fund_administrator's access is not a "partner".
investor_ids = {
u.id for u in session.exec(
select(User).where(User.role == UserRole.investor)
).all()
}
access = 0
for a in session.exec(
select(EntityAccess).where(EntityAccess.entity_id == entity_id)
).all():
if a.user_id in investor_ids:
session.delete(a)
access += 1
return {"statements": statements, "access_grants": access}
def _slug_username(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "", name.lower())
return base or "investor"
@@ -282,14 +314,16 @@ def commit_import(
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
pw = inv.password or secrets.token_urlsafe(32)
# New members start on the shared default password (login enabled) so the admin
# can send credentials right away; each investor rotates it in the portal.
pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD
user = User(
name=inv.name,
username=inv.username,
email=inv.email or None,
password_hash=hash_password(pw),
role=UserRole.investor,
login_enabled=bool(inv.password),
login_enabled=True,
external_investor_id=inv.external_id,
)
session.add(user)