From 4215d4478fc8081edcb84658c9781d6e8434af1b Mon Sep 17 00:00:00 2001
From: Jonathan Kirkwood
Date: Fri, 3 Jul 2026 08:40:40 -0500
Subject: [PATCH] 0.2.27-0.2.32: LP portal polish, brand palette, default
investor logins
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../versions/d0e1f2a3b4c5_docs_seen_at.py | 27 +++
backend/ten31portal/cli.py | 72 +++++++
backend/ten31portal/config.py | 5 +
backend/ten31portal/models.py | 4 +
backend/ten31portal/routers/auth_router.py | 4 +-
.../routers/capital_import_router.py | 46 ++++-
.../ten31portal/routers/document_router.py | 22 +-
backend/ten31portal/routers/entity_router.py | 20 ++
backend/ten31portal/schemas.py | 4 +-
backend/tests/test_clear_partners.py | 60 ++++++
backend/tests/test_lp_polish.py | 134 +++++++++++++
deploy/package.json | 2 +-
deploy/startos/actions/index.ts | 85 ++++++++
deploy/startos/install/versions/index.ts | 10 +-
deploy/startos/install/versions/v_0_2_27.ts | 13 ++
deploy/startos/install/versions/v_0_2_28.ts | 13 ++
deploy/startos/install/versions/v_0_2_29.ts | 13 ++
deploy/startos/install/versions/v_0_2_30.ts | 13 ++
deploy/startos/install/versions/v_0_2_31.ts | 13 ++
deploy/startos/install/versions/v_0_2_32.ts | 13 ++
frontend/index.html | 2 +-
frontend/public/manifest.webmanifest | 4 +-
frontend/public/sw.js | 2 +-
frontend/src/App.tsx | 2 +-
frontend/src/api.ts | 8 +-
frontend/src/components/CapitalChart.tsx | 11 +-
.../src/components/ChangePasswordModal.tsx | 8 +-
frontend/src/components/EntityHeader.tsx | 11 +-
frontend/src/components/Layout.tsx | 2 +-
frontend/src/components/PasswordInput.tsx | 2 +-
frontend/src/format.ts | 6 +
frontend/src/index.css | 21 ++
frontend/src/pages/AccessGrid.tsx | 2 +-
frontend/src/pages/CapitalAccounts.tsx | 4 +-
frontend/src/pages/Documents.tsx | 8 +-
frontend/src/pages/EntitiesList.tsx | 18 +-
frontend/src/pages/EntityAssets.tsx | 4 +-
frontend/src/pages/EntityDocuments.tsx | 2 +-
frontend/src/pages/EntityOverview.tsx | 61 +++++-
frontend/src/pages/EntityPartners.tsx | 58 +++++-
frontend/src/pages/Import.tsx | 13 +-
frontend/src/pages/Login.tsx | 10 +-
frontend/src/pages/Users.tsx | 10 +-
frontend/src/pages/ValuationWorkflow.tsx | 8 +-
frontend/src/portal/FundAdminHome.tsx | 6 +-
frontend/src/portal/InvestorPortalView.tsx | 188 +++++++++++++++---
frontend/src/version.ts | 2 +-
47 files changed, 935 insertions(+), 111 deletions(-)
create mode 100644 backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py
create mode 100644 backend/tests/test_clear_partners.py
create mode 100644 backend/tests/test_lp_polish.py
create mode 100644 deploy/startos/install/versions/v_0_2_27.ts
create mode 100644 deploy/startos/install/versions/v_0_2_28.ts
create mode 100644 deploy/startos/install/versions/v_0_2_29.ts
create mode 100644 deploy/startos/install/versions/v_0_2_30.ts
create mode 100644 deploy/startos/install/versions/v_0_2_31.ts
create mode 100644 deploy/startos/install/versions/v_0_2_32.ts
diff --git a/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py b/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py
new file mode 100644
index 0000000..ed57688
--- /dev/null
+++ b/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py
@@ -0,0 +1,27 @@
+"""add users.docs_seen_at (portal "New" document badge watermark)
+
+Revision ID: d0e1f2a3b4c5
+Revises: c9d0e1f2a3b4
+Create Date: 2026-07-03 09:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'd0e1f2a3b4c5'
+down_revision: Union[str, None] = 'c9d0e1f2a3b4'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('users', schema=None) as batch_op:
+ batch_op.drop_column('docs_seen_at')
diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py
index 98087f6..9bb8187 100644
--- a/backend/ten31portal/cli.py
+++ b/backend/ten31portal/cli.py
@@ -87,6 +87,42 @@ def reset_password(args: argparse.Namespace) -> None:
print(f"Password reset for {user.name} ({user.username}).")
+def enable_investor_logins(args: argparse.Namespace) -> None:
+ """Give every no-login investor account the shared default password and enable sign-in.
+
+ Targets investor-role accounts with login_enabled=False that log in on their own
+ (linked secondary names are skipped — they sign in under their primary). Accounts that
+ already have a working login are never touched.
+ """
+ run_migrations()
+
+ from ten31portal.models import UserRole
+
+ with Session(engine) as session:
+ users = session.exec(
+ select(User).where(
+ User.role == UserRole.investor,
+ User.login_enabled == False, # noqa: E712 — SQL expression
+ User.primary_account_id == None, # noqa: E711
+ ).order_by(User.name) # type: ignore[arg-type]
+ ).all()
+ if not users:
+ print("Nothing to do — every investor account already has a login.")
+ return
+ for u in users:
+ u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
+ u.login_enabled = True
+ session.add(u)
+ session.commit()
+ for u in users:
+ print(f"Enabled login for {u.name} ({u.username})")
+ print(
+ f"\n{len(users)} investor account(s) set to the default password "
+ f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the "
+ "portal (Change password)."
+ )
+
+
def show_admin_password(args: argparse.Namespace) -> None:
"""Print the randomly-generated initial admin password recorded on first boot."""
path = config.ADMIN_PASSWORD_FILE
@@ -234,6 +270,29 @@ def reset_holdings(args: argparse.Namespace) -> None:
f"Re-import the fund's NAV to repopulate it.")
+def reset_partners(args: argparse.Namespace) -> None:
+ """Remove all partners (capital-account statements + access grants) from one fund."""
+ run_migrations()
+ from ten31portal.models import Entity
+ from ten31portal.routers.capital_import_router import reset_entity_partners
+
+ with Session(engine) as session:
+ entity = None
+ if args.entity_id:
+ entity = session.get(Entity, args.entity_id)
+ elif args.name:
+ entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
+ if entity is None:
+ print(f"Error: no fund found for '{args.name or args.entity_id}'. "
+ f"Check the exact name with list-funds.", file=sys.stderr)
+ sys.exit(1)
+ res = reset_entity_partners(entity.id, session)
+ session.commit()
+ print(f"Cleared partners from {entity.name}: removed {res['statements']} capital "
+ f"statement(s) and {res['access_grants']} access grant(s). "
+ f"Investor accounts were kept. Re-import the correct roster to repopulate.")
+
+
def list_funds(args: argparse.Namespace) -> None:
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
run_migrations()
@@ -268,6 +327,11 @@ def main() -> None:
sub.add_parser("list-users", help="List all user accounts")
+ sub.add_parser(
+ "enable-investor-logins",
+ help="Set every no-login investor account to the default password and enable sign-in",
+ )
+
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
@@ -284,6 +348,10 @@ def main() -> None:
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
reset.add_argument("--entity-id", type=int, required=False, default=None)
+ rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund")
+ rparts.add_argument("--name", required=False, default=None, help="Exact fund name")
+ rparts.add_argument("--entity-id", type=int, required=False, default=None)
+
args = parser.parse_args()
if args.command == "create-user":
create_user(args)
@@ -291,6 +359,8 @@ def main() -> None:
reset_password(args)
elif args.command == "list-users":
list_users(args)
+ elif args.command == "enable-investor-logins":
+ enable_investor_logins(args)
elif args.command == "show-admin-password":
show_admin_password(args)
elif args.command == "delete-user":
@@ -301,6 +371,8 @@ def main() -> None:
list_funds(args)
elif args.command == "reset-holdings":
reset_holdings(args)
+ elif args.command == "reset-partners":
+ reset_partners(args)
else:
parser.print_help()
diff --git a/backend/ten31portal/config.py b/backend/ten31portal/config.py
index 9643d4e..837edcb 100644
--- a/backend/ten31portal/config.py
+++ b/backend/ten31portal/config.py
@@ -11,6 +11,11 @@ DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents")
# also bounds spreadsheet imports (which must be read fully into memory to parse).
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
+# Investor accounts created by the eNAV import (and existing no-login accounts converted via
+# the enable-investor-logins CLI/action) start with this password so the admin can hand out
+# credentials easily; each investor changes it via the portal's own Change password.
+DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal")
+
# Where start.sh records the randomly-generated initial admin password on first boot, so the
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py
index 7c57bbe..de014d0 100644
--- a/backend/ten31portal/models.py
+++ b/backend/ten31portal/models.py
@@ -39,6 +39,7 @@ class EntityType(str, enum.Enum):
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
+ carry = "carry"
class EntityStatus(str, enum.Enum):
@@ -75,6 +76,9 @@ class User(SQLModel, table=True):
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
+ # When this investor last loaded their documents list — docs newer than this get a "New"
+ # badge in the portal. Null until their first visit (nothing badged for brand-new logins).
+ docs_seen_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py
index 084b9dd..2854de1 100644
--- a/backend/ten31portal/routers/auth_router.py
+++ b/backend/ten31portal/routers/auth_router.py
@@ -87,8 +87,8 @@ def change_password(
"""Let the signed-in user set their own password (after confirming the current one)."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect.")
- if len(body.new_password) < 4:
- raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
+ if len(body.new_password) < 8:
+ raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
user.password_hash = hash_password(body.new_password)
session.add(user)
session.commit()
diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py
index 5bb37fd..1b5f6ea 100644
--- a/backend/ten31portal/routers/capital_import_router.py
+++ b/backend/ten31portal/routers/capital_import_router.py
@@ -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)
diff --git a/backend/ten31portal/routers/document_router.py b/backend/ten31portal/routers/document_router.py
index 13881eb..9241556 100644
--- a/backend/ten31portal/routers/document_router.py
+++ b/backend/ten31portal/routers/document_router.py
@@ -1,5 +1,7 @@
"""Document upload, listing, download, and deletion with per-account access control."""
+from datetime import datetime, timedelta
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
@@ -58,7 +60,25 @@ def list_documents(
query = query.where(Document.investor_user_id == investor_user_id)
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
visible = [d for d in rows if _can_view(user, d, session)]
- return [DocumentResponse.model_validate(d, from_attributes=True) for d in visible]
+
+ # Badge docs that arrived since the investor's previous visit. Badges show on the first
+ # page-load after new documents arrive; the watermark advances at most once per 30 minutes
+ # so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing —
+ # everything would be "new".
+ seen_before = user.docs_seen_at if user.role == UserRole.investor else None
+ if user.role == UserRole.investor:
+ now = datetime.utcnow()
+ if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30):
+ user.docs_seen_at = now
+ session.add(user)
+ session.commit()
+
+ return [
+ DocumentResponse.model_validate(d, from_attributes=True).model_copy(
+ update={"is_new": seen_before is not None and d.created_at > seen_before}
+ )
+ for d in visible
+ ]
@router.post("", status_code=201)
diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py
index b7976af..d632dea 100644
--- a/backend/ten31portal/routers/entity_router.py
+++ b/backend/ten31portal/routers/entity_router.py
@@ -148,6 +148,26 @@ def list_partners(
return result
+@router.delete("/{entity_id}/partners")
+def clear_partners(
+ entity_id: int,
+ user: User = Depends(require_writer),
+ session: Session = Depends(get_session),
+) -> dict[str, int]:
+ """Remove all partners from this fund — deletes its capital-account statements and the
+ investors' access grants, but keeps the investor accounts (they belong to other funds).
+ For undoing a wrong members import. Holdings/NAV are not affected."""
+ # Local import avoids a module-load cycle (capital_import_router imports import_router).
+ from ten31portal.routers.capital_import_router import reset_entity_partners
+
+ if session.get(Entity, entity_id) is None:
+ raise HTTPException(status_code=404, detail="Entity not found")
+ res = reset_entity_partners(entity_id, session)
+ record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
+ session.commit()
+ return res
+
+
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py
index 86f5195..9bb79eb 100644
--- a/backend/ten31portal/schemas.py
+++ b/backend/ten31portal/schemas.py
@@ -212,6 +212,8 @@ class DocumentResponse(BaseModel):
size_bytes: int
uploaded_by: int | None
created_at: datetime
+ # True for an investor when the doc arrived since their previous portal visit.
+ is_new: bool = False
# --- Capital account ---
@@ -310,7 +312,7 @@ class ImportCommitInvestor(BaseModel):
name: str | None = None # for action=create
username: str | None = None # for action=create
email: str | None = None
- password: str | None = None # for action=create; omit to create without a login
+ password: str | None = None # for action=create; omit for the shared default password
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
diff --git a/backend/tests/test_clear_partners.py b/backend/tests/test_clear_partners.py
new file mode 100644
index 0000000..5e7205b
--- /dev/null
+++ b/backend/tests/test_clear_partners.py
@@ -0,0 +1,60 @@
+"""Clearing a fund's partners removes only that fund's statements + access grants,
+never the investor accounts (which may belong to other funds)."""
+
+from datetime import date
+
+from ten31portal.models import (
+ CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
+)
+
+
+def _make_fund(session, name):
+ e = Entity(name=name, type=EntityType.fund)
+ session.add(e)
+ session.commit()
+ session.refresh(e)
+ return e
+
+
+def _add_partner(session, entity_id, user_id, ending):
+ session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
+ session.add(CapitalAccountStatement(
+ entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
+ commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
+ distributions_cents=0, ending_balance_cents=ending,
+ ))
+ session.commit()
+
+
+def test_clear_partners_scoped_to_one_fund(auth_client, session):
+ from tests.conftest import make_user
+
+ fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
+ fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
+ lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
+ # Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
+ _add_partner(session, fund2.id, lp.id, 1_000_00)
+ _add_partner(session, fund3.id, lp.id, 2_000_00)
+
+ resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
+ assert resp.status_code == 200, resp.text
+ assert resp.json() == {"statements": 1, "access_grants": 1}
+
+ # Fund III is wiped of partners...
+ assert session.exec(
+ CapitalAccountStatement.__table__.select().where(
+ CapitalAccountStatement.entity_id == fund3.id
+ )
+ ).first() is None
+ assert session.exec(
+ EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
+ ).first() is None
+ assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
+
+ # ...but Fund II keeps its partner, and the investor account still exists.
+ assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
+ assert session.get(User, lp.id) is not None
+
+
+def test_clear_partners_missing_entity_404(auth_client):
+ assert auth_client.delete("/api/entities/99999/partners").status_code == 404
diff --git a/backend/tests/test_lp_polish.py b/backend/tests/test_lp_polish.py
new file mode 100644
index 0000000..b9d3c51
--- /dev/null
+++ b/backend/tests/test_lp_polish.py
@@ -0,0 +1,134 @@
+"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
+enable-investor-logins conversion, and the documents "New" badge watermark."""
+
+from datetime import datetime, timedelta
+
+from sqlmodel import select
+
+from ten31portal import config
+from ten31portal.auth import hash_password, verify_password
+from ten31portal.models import (
+ Document, Entity, EntityAccess, EntityType, User, UserRole,
+)
+from tests.conftest import make_user
+
+
+def _login(client, username, password):
+ return client.post("/api/auth/login", json={"login": username, "password": password})
+
+
+def test_change_password_requires_eight_chars(auth_client):
+ resp = auth_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "password123", "new_password": "short7c"},
+ )
+ assert resp.status_code == 400
+ assert "8 characters" in resp.json()["detail"]
+
+ resp = auth_client.post(
+ "/api/auth/change-password",
+ json={"current_password": "password123", "new_password": "longenough8"},
+ )
+ assert resp.status_code == 200
+
+
+def test_import_created_member_gets_default_password(auth_client, session):
+ entity = Entity(name="Test Fund", type=EntityType.fund)
+ session.add(entity)
+ session.commit()
+
+ resp = auth_client.post(
+ "/api/import/capital-accounts/commit",
+ json={
+ "entity_id": entity.id,
+ "as_of_date": "2026-03-31",
+ "investors": [{
+ "action": "create",
+ "name": "New LP",
+ "username": "newlp",
+ "value_dollars": 100_000,
+ }],
+ },
+ )
+ assert resp.status_code == 200, resp.text
+
+ user = session.exec(select(User).where(User.username == "newlp")).one()
+ assert user.login_enabled is True
+ assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
+
+ # And the account can actually sign in with it.
+ resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
+ assert resp.status_code == 200, resp.text
+
+
+def test_enable_investor_logins_converts_only_no_login_accounts(session):
+ from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
+
+ no_login = make_user(session, username="dormant", role=UserRole.investor,
+ login_enabled=False, name="Dormant LP")
+ has_login = make_user(session, username="active-lp", role=UserRole.investor,
+ password="theirownpw", name="Active LP")
+ linked = make_user(session, username="linked", role=UserRole.investor,
+ login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
+ old_active_hash = has_login.password_hash
+
+ # Run the conversion against the test engine (CLI normally uses the real one).
+ import ten31portal.cli as cli_mod
+ orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
+ cli_mod.engine = session.get_bind()
+ cli_mod.run_migrations = lambda: None
+ try:
+ enable_investor_logins(None)
+ finally:
+ cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
+
+ session.refresh(no_login)
+ session.refresh(has_login)
+ session.refresh(linked)
+ assert no_login.login_enabled is True
+ assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
+ # Working logins and linked secondary names are untouched.
+ assert has_login.password_hash == old_active_hash
+ assert linked.login_enabled is False
+
+
+def test_documents_new_badge(client, session):
+ entity = Entity(name="Badge Fund", type=EntityType.fund)
+ session.add(entity)
+ session.commit()
+ lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
+ session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
+
+ old_doc = Document(
+ entity_id=entity.id, category="statement", title="Old statement",
+ original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
+ storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
+ )
+ session.add(old_doc)
+ session.commit()
+
+ assert _login(client, "lp", "password123").status_code == 200
+
+ # First ever visit: nothing badged (no watermark yet), watermark gets set.
+ docs = client.get("/api/documents").json()
+ assert [d["is_new"] for d in docs] == [False]
+
+ # A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
+ session.refresh(lp)
+ lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
+ session.add(lp)
+ new_doc = Document(
+ entity_id=entity.id, category="k1", title="Fresh K-1",
+ original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
+ storage_path="x-new",
+ )
+ session.add(new_doc)
+ session.commit()
+
+ docs = client.get("/api/documents").json()
+ flags = {d["title"]: d["is_new"] for d in docs}
+ assert flags == {"Fresh K-1": True, "Old statement": False}
+
+ # Within the same visit (watermark just advanced) the badge computation stays stable.
+ docs = client.get("/api/documents").json()
+ assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs
diff --git a/deploy/package.json b/deploy/package.json
index e30174e..e295c52 100644
--- a/deploy/package.json
+++ b/deploy/package.json
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
- "version": "0.2.26",
+ "version": "0.2.32",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
diff --git a/deploy/startos/actions/index.ts b/deploy/startos/actions/index.ts
index b2cbb58..bedc525 100644
--- a/deploy/startos/actions/index.ts
+++ b/deploy/startos/actions/index.ts
@@ -414,12 +414,97 @@ const resetHoldingsAction = Action.withInput(
},
)
+// ============================================
+// Action: Reset Fund Partners
+// ============================================
+const resetPartnersInputSpec = InputSpec.of({
+ name: Value.text({
+ name: 'Fund Name',
+ description: 'Exact name of the fund whose partners to clear (see List Funds)',
+ default: '',
+ required: true,
+ placeholder: 'Low Time Preference Fund III, LP',
+ }),
+})
+
+const resetPartnersAction = Action.withInput(
+ 'reset-partners',
+ {
+ name: 'Reset Fund Partners',
+ description:
+ "Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).",
+ warning:
+ "This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.",
+ allowedStatuses: 'only-running',
+ group: null,
+ visibility: 'enabled',
+ },
+ resetPartnersInputSpec,
+ async () => ({ name: '' }),
+ async ({ input, effects }) => {
+ try {
+ const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task')
+ if (result.exitCode !== 0) {
+ return errorResult(result.stderr?.toString() || 'Failed to clear partners')
+ }
+ return {
+ version: '1' as const,
+ title: 'Partners Cleared',
+ message: result.stdout?.toString() || `Cleared partners from ${input.name}.`,
+ result: null,
+ }
+ } catch (e: any) {
+ return errorResult(`Failed to clear partners: ${e.message || e}`)
+ }
+ },
+)
+
+// ============================================
+// Action: Enable Investor Logins
+// ============================================
+const enableInvestorLoginsAction = Action.withoutInput(
+ 'enable-investor-logins',
+ {
+ name: 'Enable Investor Logins',
+ description:
+ 'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.',
+ warning: 'Every converted account gets the same well-known default password until the investor changes it.',
+ allowedStatuses: 'only-running',
+ group: null,
+ visibility: 'enabled',
+ },
+ async ({ effects }) => {
+ try {
+ const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task')
+ if (result.exitCode !== 0) {
+ return errorResult(result.stderr?.toString() || 'Failed to enable investor logins')
+ }
+ return {
+ version: '1' as const,
+ title: 'Investor Logins Enabled',
+ message: 'Send each investor their username; they sign in with the default password and change it.',
+ result: {
+ type: 'single' as const,
+ value: result.stdout?.toString() || 'Nothing to do.',
+ copyable: true,
+ qr: false,
+ masked: false,
+ },
+ }
+ } catch (e: any) {
+ return errorResult(`Failed to enable investor logins: ${e.message || e}`)
+ }
+ },
+)
+
export const actions = sdk.Actions.of()
.addAction(createUserAction)
.addAction(resetPasswordAction)
.addAction(showAdminPasswordAction)
.addAction(listUsersAction)
+ .addAction(enableInvestorLoginsAction)
.addAction(deleteUserAction)
.addAction(dedupeAction)
.addAction(listFundsAction)
.addAction(resetHoldingsAction)
+ .addAction(resetPartnersAction)
diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts
index ea7f38f..0ad6a8b 100644
--- a/deploy/startos/install/versions/index.ts
+++ b/deploy/startos/install/versions/index.ts
@@ -1,4 +1,4 @@
-export { v_0_2_26 as current } from './v_0_2_26'
+export { v_0_2_32 as current } from './v_0_2_32'
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'
@@ -25,4 +25,10 @@ import { v_0_2_22 } from './v_0_2_22'
import { v_0_2_23 } from './v_0_2_23'
import { v_0_2_24 } from './v_0_2_24'
import { v_0_2_25 } from './v_0_2_25'
-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]
+import { v_0_2_26 } from './v_0_2_26'
+import { v_0_2_27 } from './v_0_2_27'
+import { v_0_2_28 } from './v_0_2_28'
+import { v_0_2_29 } from './v_0_2_29'
+import { v_0_2_30 } from './v_0_2_30'
+import { v_0_2_31 } from './v_0_2_31'
+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]
diff --git a/deploy/startos/install/versions/v_0_2_27.ts b/deploy/startos/install/versions/v_0_2_27.ts
new file mode 100644
index 0000000..a36b44c
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_27.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_27 = VersionInfo.of({
+ version: '0.2.27:0',
+ releaseNotes: {
+ en_US:
+ 'Entity overview now shows a full Valuation history — every quarter on record with its NAV, status, and signed date — instead of just the latest quarter. Investor portal now shows, per fund/SPV, gain/loss (amount and % vs paid-in) on the capital account and the % of commitment distributed.',
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/deploy/startos/install/versions/v_0_2_28.ts b/deploy/startos/install/versions/v_0_2_28.ts
new file mode 100644
index 0000000..b370450
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_28.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_28 = VersionInfo.of({
+ version: '0.2.28:0',
+ releaseNotes: {
+ en_US:
+ "Investor gain/loss now measures total value (current NAV + distributions received) against paid-in capital, so an LP who has taken distributions no longer shows a false loss.",
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/deploy/startos/install/versions/v_0_2_29.ts b/deploy/startos/install/versions/v_0_2_29.ts
new file mode 100644
index 0000000..be93273
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_29.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_29 = VersionInfo.of({
+ version: '0.2.29:0',
+ releaseNotes: {
+ en_US:
+ "New 'Reset Fund Partners' service action (and a 'Clear all partners' button on a fund's Partners tab) removes all of a fund's capital-account statements and investor access grants — for undoing a wrong members import, e.g. one fund's roster loaded into another. Investor accounts and holdings/NAV are left intact. Reset Fund Holdings still only clears holdings; this covers the partner side.",
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/deploy/startos/install/versions/v_0_2_30.ts b/deploy/startos/install/versions/v_0_2_30.ts
new file mode 100644
index 0000000..1b4aceb
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_30.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_30 = VersionInfo.of({
+ version: '0.2.30:0',
+ releaseNotes: {
+ en_US:
+ "Investor portal: the capital block now reads 'Current Capital Balance' and shows only the percentage gain (green) or loss (red) beneath it — the redundant dollar 'gain/loss vs paid-in' line was removed.",
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/deploy/startos/install/versions/v_0_2_31.ts b/deploy/startos/install/versions/v_0_2_31.ts
new file mode 100644
index 0000000..31b4886
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_31.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_31 = VersionInfo.of({
+ version: '0.2.31:0',
+ releaseNotes: {
+ en_US:
+ "Entities view: the 'GP Entities and Management Companies' section is now 'Management Entities' and gains a new 'Carry Vehicle' entity type (with both Partners and Assets tabs) for carry vehicles like Ten31 EP LLC. Investor capital chart: the Distributions line only appears once distributions have actually been made.",
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/deploy/startos/install/versions/v_0_2_32.ts b/deploy/startos/install/versions/v_0_2_32.ts
new file mode 100644
index 0000000..dca3467
--- /dev/null
+++ b/deploy/startos/install/versions/v_0_2_32.ts
@@ -0,0 +1,13 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+export const v_0_2_32 = VersionInfo.of({
+ version: '0.2.32:0',
+ releaseNotes: {
+ en_US:
+ "Investor portal polish: a portfolio summary card totals commitment, paid-in, distributions and current balance across funds; gain/loss is labeled 'net of paid-in'; headline figures show whole dollars; documents group by year with a 'New' badge since the investor's last visit. Ten31 brand palette (navy/mint from the logo) replaces the orange accents. New members from the eNAV import now start with the default password 'Ten31Portal' (login enabled), and a new 'Enable Investor Logins' action converts existing no-login accounts. Password changes now require at least 8 characters. Login page shows a Portal@ten31.xyz contact line.",
+ },
+ migrations: {
+ up: async ({ effects }) => {},
+ down: async ({ effects }) => {},
+ },
+})
diff --git a/frontend/index.html b/frontend/index.html
index afaf458..f4a4404 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -6,7 +6,7 @@
-
+
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
index c489c3d..ce5b12c 100644
--- a/frontend/public/manifest.webmanifest
+++ b/frontend/public/manifest.webmanifest
@@ -6,8 +6,8 @@
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
- "background_color": "#16243A",
- "theme_color": "#16243A",
+ "background_color": "#0C1F33",
+ "theme_color": "#0C1F33",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
index 81e9bc0..9310366 100644
--- a/frontend/public/sw.js
+++ b/frontend/public/sw.js
@@ -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.26'
+const CACHE = 'ten31-portal-0.2.32'
self.addEventListener('install', () => self.skipWaiting())
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 39a8034..f9bb810 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -62,7 +62,7 @@ function OfflineNotice({ onRetry }: { onRetry: () => void }) {
Retry
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 5d2913e..6c48849 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -12,7 +12,7 @@ export type UserRole =
| "viewer"
| "investor"
| "fund_administrator";
-export type EntityType = "fund" | "spv" | "gp" | "mgmt_co";
+export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
export type EntityStatus = "active" | "closed";
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
export type DocumentCategory =
@@ -70,6 +70,7 @@ export interface PortalDocument {
size_bytes: number;
uploaded_by: number | null;
created_at: string;
+ is_new?: boolean;
}
export interface Partner {
@@ -307,6 +308,11 @@ export const api = {
}[]>("/api/entities/rollup"),
getEntity: (id: number) => request(`/api/entities/${id}`),
listPartners: (entityId: number) => request(`/api/entities/${entityId}/partners`),
+ clearPartners: (entityId: number) =>
+ request<{ statements: number; access_grants: number }>(
+ `/api/entities/${entityId}/partners`,
+ { method: "DELETE" },
+ ),
createEntity: (data: Partial) =>
request("/api/entities", { method: "POST", body: JSON.stringify(data) }),
updateEntity: (id: number, data: Partial) =>
diff --git a/frontend/src/components/CapitalChart.tsx b/frontend/src/components/CapitalChart.tsx
index 7579b2f..8f3099e 100644
--- a/frontend/src/components/CapitalChart.tsx
+++ b/frontend/src/components/CapitalChart.tsx
@@ -23,6 +23,11 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
if (data.length < 2) return null;
+ // Only plot the Distributions series once the LP has actually received a distribution;
+ // otherwise it's a flat line pinned at zero that just clutters the chart.
+ const hasDistributions = data.some((d) => d.distributions > 0);
+ const series = hasDistributions ? SERIES : SERIES.filter((s) => s.key !== "distributions");
+
const W = 640;
const H = 240;
const padL = 64;
@@ -51,7 +56,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
))}
- {SERIES.map((s) => (
+ {series.map((s) => (
))}
- {SERIES.map((s) =>
+ {series.map((s) =>
data.map((d, i) => (
{`${formatQuarter(d.date)} · ${s.label}: ${formatMoney(d[s.key])}`}
@@ -77,7 +82,7 @@ export default function CapitalChart({ points }: { points: CapitalPoint[] }) {
- {SERIES.map((s) => (
+ {series.map((s) => (
{s.label}
diff --git a/frontend/src/components/ChangePasswordModal.tsx b/frontend/src/components/ChangePasswordModal.tsx
index 7ce3e0e..d6f94a3 100644
--- a/frontend/src/components/ChangePasswordModal.tsx
+++ b/frontend/src/components/ChangePasswordModal.tsx
@@ -13,7 +13,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
const save = async () => {
setError("");
- if (next.length < 4) return setError("New password must be at least 4 characters.");
+ 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 {
@@ -34,7 +34,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
Your password has been updated.
-
+
Done
@@ -47,7 +47,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
Confirm new password
@@ -61,7 +61,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
{busy ? "Saving…" : "Update password"}
diff --git a/frontend/src/components/EntityHeader.tsx b/frontend/src/components/EntityHeader.tsx
index 1342ef9..23a78da 100644
--- a/frontend/src/components/EntityHeader.tsx
+++ b/frontend/src/components/EntityHeader.tsx
@@ -6,6 +6,7 @@ const TYPE_LABELS: Record
= {
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
+ carry: "Carry Vehicle",
};
const TABS: { key: string; label: string; path: (id: number) => string }[] = [
@@ -15,8 +16,8 @@ const TABS: { key: string; label: string; path: (id: number) => string }[] = [
{ key: "documents", label: "Documents", path: (id) => `/entities/${id}/documents` },
];
-// GP entities and management companies hold interests in the funds they manage; that's their
-// "Assets" tab. Funds/SPVs use the Investments tab instead.
+// GP entities, management companies, and carry vehicles hold interests in the funds they manage;
+// that's their "Assets" tab. Funds/SPVs use the Investments tab instead.
const ASSETS_TAB = {
key: "assets",
label: "Assets",
@@ -25,7 +26,9 @@ const ASSETS_TAB = {
export default function EntityHeader({ entity, active }: { entity: Entity; active: string }) {
const tabs =
- entity.type === "gp" || entity.type === "mgmt_co" ? [...TABS, ASSETS_TAB] : TABS;
+ entity.type === "gp" || entity.type === "mgmt_co" || entity.type === "carry"
+ ? [...TABS, ASSETS_TAB]
+ : TABS;
return (
@@ -39,7 +42,7 @@ export default function EntityHeader({ entity, active }: { entity: Entity; activ
active === t.key ? (
{t.label}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 90658e4..2340ca3 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -66,7 +66,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
onClick={() => setNavOpen(false)}
className={`block px-4 py-2 text-sm ${
active
- ? "bg-orange-50 text-orange-600 border-r-2 border-orange-500 font-medium"
+ ? "bg-accent-50 text-accent-600 border-r-2 border-accent-500 font-medium"
: "text-gray-700 hover:bg-gray-50"
}`}
>
diff --git a/frontend/src/components/PasswordInput.tsx b/frontend/src/components/PasswordInput.tsx
index bffd27a..624b902 100644
--- a/frontend/src/components/PasswordInput.tsx
+++ b/frontend/src/components/PasswordInput.tsx
@@ -16,7 +16,7 @@ export default function PasswordInput({
}) {
const [show, setShow] = useState(false);
const base =
- "w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
+ "w-full px-3 py-2 pr-10 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
return (
{busy ? "Saving…" : "Save statement"}
@@ -219,4 +219,4 @@ function DollarField({
}
const inputCls =
- "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
+ "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx
index a8026df..55f384f 100644
--- a/frontend/src/pages/Documents.tsx
+++ b/frontend/src/pages/Documents.tsx
@@ -87,7 +87,7 @@ export default function Documents() {
Download
@@ -242,7 +242,7 @@ function UploadForm({
{targetLabel ? (
Uploading to {entityName} ·{" "}
-
+
{targetLabel}
@@ -252,7 +252,7 @@ function UploadForm({
{busy ? "Uploading…" : "Upload"}
@@ -262,4 +262,4 @@ function UploadForm({
}
const inputCls =
- "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
+ "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
diff --git a/frontend/src/pages/EntitiesList.tsx b/frontend/src/pages/EntitiesList.tsx
index c06bb83..65cd3af 100644
--- a/frontend/src/pages/EntitiesList.tsx
+++ b/frontend/src/pages/EntitiesList.tsx
@@ -21,6 +21,7 @@ const TYPE_LABELS: Record = {
spv: "SPV",
gp: "GP",
mgmt_co: "Mgmt Co",
+ carry: "Carry Vehicle",
};
export default function EntitiesList() {
@@ -62,7 +63,9 @@ export default function EntitiesList() {
}
const funds = entities.filter((e) => e.type === "fund" || e.type === "spv");
- const gps = entities.filter((e) => e.type === "gp" || e.type === "mgmt_co");
+ const gps = entities.filter(
+ (e) => e.type === "gp" || e.type === "mgmt_co" || e.type === "carry",
+ );
return (
@@ -71,7 +74,7 @@ export default function EntitiesList() {
{isWriter && (
setShowForm(true)}
- className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
+ className="px-4 py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
>
Add Entity
@@ -89,7 +92,7 @@ export default function EntitiesList() {
)}
-
+
);
}
@@ -155,7 +158,7 @@ function AddEntityForm({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
- className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
+ className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
placeholder="Low Time Preference Fund I, LLC"
required
/>
@@ -171,6 +174,7 @@ function AddEntityForm({
SPV
GP
Mgmt Co
+ Carry Vehicle
@@ -202,7 +206,7 @@ function AddEntityForm({
{saving ? "Creating..." : "Create"}
@@ -261,7 +265,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
return (
sortBy(k)} className={`w-full hover:text-gray-900 ${a}`}>
- {label}{arrow(k)}
+ {label}{arrow(k)}
);
@@ -345,7 +349,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) {
{row.name}
diff --git a/frontend/src/pages/EntityAssets.tsx b/frontend/src/pages/EntityAssets.tsx
index e2a3e6c..895056a 100644
--- a/frontend/src/pages/EntityAssets.tsx
+++ b/frontend/src/pages/EntityAssets.tsx
@@ -77,7 +77,7 @@ export default function EntityAssets() {
This entity isn't linked to an investor account yet, so there are no balances to show.
Link it on the{" "}
-
+
Overview tab → Edit entity
{" "}
(choose its investor account). Its capital-account balance in each fund will then
@@ -110,7 +110,7 @@ export default function EntityAssets() {
{rows.map((r) => (
-
+
{fundName(r.fundId)}
diff --git a/frontend/src/pages/EntityDocuments.tsx b/frontend/src/pages/EntityDocuments.tsx
index 184c8fb..a50f048 100644
--- a/frontend/src/pages/EntityDocuments.tsx
+++ b/frontend/src/pages/EntityDocuments.tsx
@@ -61,7 +61,7 @@ export default function EntityDocuments() {
{formatDate(d.created_at)}
-
+
Download
(null);
+ const [rounds, setRounds] = useState([]);
const [assetBalances, setAssetBalances] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
@@ -49,7 +50,10 @@ export default function EntityOverview() {
const ent = await api.getEntity(entityId);
setEntity(ent);
- if ((ent.type === "gp" || ent.type === "mgmt_co") && ent.linked_user_id != null) {
+ if (
+ (ent.type === "gp" || ent.type === "mgmt_co" || ent.type === "carry") &&
+ ent.linked_user_id != null
+ ) {
api.entityAssetBalances(entityId).then(setAssetBalances).catch(() => {});
}
@@ -66,6 +70,9 @@ export default function EntityOverview() {
setTotalInvested(invested);
const rounds = await api.listRounds(entityId);
+ // Newest quarter first, so the history table reads top-down like a statement run.
+ const sorted = [...rounds].sort((a, b) => b.quarter_end.localeCompare(a.quarter_end));
+ setRounds(sorted);
const approved = rounds.filter((r) => r.status === "approved");
if (approved.length > 0) {
setLatestRound(approved[0]);
@@ -141,7 +148,7 @@ export default function EntityOverview() {
{assetBalances.linked_name ? ` · ${assetBalances.linked_name}` : ""}
-
+
View by fund →
@@ -165,6 +172,47 @@ export default function EntityOverview() {
)}
+
+ {/* Valuation history — every quarter uploaded/signed for this entity */}
+ {rounds.length > 0 && (
+
+
+
Valuation history
+
+ {rounds.length} quarter{rounds.length === 1 ? "" : "s"} on record
+
+
+
+
+
+ Quarter
+ NAV
+ Status
+ Signed
+
+
+
+ {rounds.map((r) => {
+ const nav = r.valuations.reduce((s, v) => s + v.value_cents, 0);
+ return (
+
+ {formatQuarter(r.quarter_end)}
+ {formatMoney(nav)}
+
+
+ {r.status}
+
+
+
+ {r.status === "approved" && r.approved_at ? formatDate(r.approved_at) : "—"}
+
+
+ );
+ })}
+
+
+
+ )}
);
}
@@ -191,9 +239,9 @@ function EditEntityForm({
const [saving, setSaving] = useState(false);
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
- // A GP / management company can be linked to its own investor account, so its Assets view
- // shows its real per-fund balances.
- const isGp = type === "gp" || type === "mgmt_co";
+ // A GP / management company / carry vehicle can be linked to its own investor account, so its
+ // Assets view shows its real per-fund balances.
+ const isGp = type === "gp" || type === "mgmt_co" || type === "carry";
useEffect(() => {
if (!isGp) return;
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
@@ -243,6 +291,7 @@ function EditEntityForm({
SPV
GP
Mgmt Co
+ Carry Vehicle
@@ -282,7 +331,7 @@ function EditEntityForm({
)}
{error &&
{error}
}
-
+
{saving ? "Saving…" : "Save"}
diff --git a/frontend/src/pages/EntityPartners.tsx b/frontend/src/pages/EntityPartners.tsx
index 84895c0..3da6068 100644
--- a/frontend/src/pages/EntityPartners.tsx
+++ b/frontend/src/pages/EntityPartners.tsx
@@ -1,16 +1,22 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
-import { api, type Entity, type Partner } from "../api";
+import { api, canEditRound, type Entity, type Partner } from "../api";
+import { useAuth } from "../context/AuthContext";
import { formatDate, formatMoneyExact } from "../format";
import EntityHeader from "../components/EntityHeader";
export default function EntityPartners() {
const { id } = useParams<{ id: string }>();
+ const { user } = useAuth();
const [entity, setEntity] = useState(null);
const [partners, setPartners] = useState([]);
const [error, setError] = useState("");
+ const [notice, setNotice] = useState("");
+ const [clearing, setClearing] = useState(false);
const [loading, setLoading] = useState(true);
+ const isWriter = !!user && canEditRound(user.role);
+
useEffect(() => {
if (!id) return;
const eid = parseInt(id);
@@ -23,6 +29,34 @@ export default function EntityPartners() {
.finally(() => setLoading(false));
}, [id]);
+ async function clearAllPartners() {
+ if (!entity) return;
+ if (
+ !confirm(
+ `Remove ALL ${partners.length} partner(s) from ${entity.name}?\n\n` +
+ "This deletes this fund's capital-account statements and the investors' access to " +
+ "it. The investor accounts themselves are kept (they may belong to other funds), " +
+ "and holdings/NAV are not affected. Re-import the correct roster afterward.",
+ )
+ )
+ return;
+ setClearing(true);
+ setError("");
+ setNotice("");
+ try {
+ const res = await api.clearPartners(entity.id);
+ const fresh = await api.listPartners(entity.id);
+ setPartners(fresh);
+ setNotice(
+ `Cleared: removed ${res.statements} capital statement(s) and ${res.access_grants} access grant(s).`,
+ );
+ } catch (err: any) {
+ setError(err.message || "Failed to clear partners");
+ } finally {
+ setClearing(false);
+ }
+ }
+
if (loading || !entity) return Loading…
;
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
@@ -33,16 +67,28 @@ export default function EntityPartners() {
{error && {error}
}
+ {notice && {notice}
}
{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
-
- Total committed: {formatMoneyExact(totalCommitted)}
- ·
- Total capital: {formatMoneyExact(totalCapital)}
-
+
+
+ Total committed: {formatMoneyExact(totalCommitted)}
+ ·
+ Total capital: {formatMoneyExact(totalCapital)}
+
+ {isWriter && partners.length > 0 && (
+
+ {clearing ? "Clearing…" : "Clear all partners"}
+
+ )}
+
diff --git a/frontend/src/pages/Import.tsx b/frontend/src/pages/Import.tsx
index 8510d72..21b22b9 100644
--- a/frontend/src/pages/Import.tsx
+++ b/frontend/src/pages/Import.tsx
@@ -230,7 +230,7 @@ export default function Import() {
{result.members} members ({result.created} newly created)
{result.note && {result.note} }
- setResult(null)} className="mt-3 px-3 py-1.5 bg-gray-900 text-white rounded hover:bg-gray-800">
+ setResult(null)} className="mt-3 px-3 py-1.5 bg-brand-900 text-white rounded hover:bg-brand-800">
Import another
@@ -240,11 +240,11 @@ export default function Import() {
Fund
- { setEntityMode("existing"); reset(); }} className="text-orange-500" />
+ { setEntityMode("existing"); reset(); }} className="text-accent-500" />
Use existing fund
- { setEntityMode("from_file"); reset(); }} className="text-orange-500" />
+ { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
Create from file
@@ -260,6 +260,7 @@ export default function Import() {
SPV
GP
Mgmt Co
+ Carry Vehicle
setVintageYear(e.target.value)} />
@@ -278,7 +279,7 @@ export default function Import() {
-
+
{busy ? "Reading…" : "Read eNAV"}
@@ -373,7 +374,7 @@ export default function Import() {
{decisions.filter((d) => d.action === "match").length} matched ·{" "}
{decisions.filter((d) => d.action === "skip").length} skipped
-
+
{busy ? (step || "Importing…") : "Confirm import"}
@@ -491,7 +492,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
{busy ? "Loading history…" : "Load history"}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index f85e8e7..72c36de 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -37,7 +37,7 @@ export default function Login() {
autoComplete="username"
value={handle}
onChange={(e) => setHandle(e.target.value)}
- className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
+ className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
required
/>
@@ -49,11 +49,17 @@ export default function Login() {
{loading ? "Signing in..." : "Sign in"}
+
+ Trouble signing in?{" "}
+
+ Contact Portal@ten31.xyz
+
+
);
diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx
index 13a4309..7e471d3 100644
--- a/frontend/src/pages/Users.tsx
+++ b/frontend/src/pages/Users.tsx
@@ -40,7 +40,7 @@ export default function Users() {
Users
setCreating(true)}
- className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
+ className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
>
New user
@@ -94,7 +94,7 @@ export default function Users() {
onClick={() =>
api.getUser(u.id).then(setEditing).catch((e) => setError(e.message))
}
- className="text-orange-600 hover:text-orange-700"
+ className="text-accent-600 hover:text-accent-700"
>
Manage
@@ -254,7 +254,7 @@ function CreateUserModal({
Create
@@ -387,7 +387,7 @@ function EditUserModal({
Save
@@ -398,7 +398,7 @@ function EditUserModal({
}
const inputCls =
- "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
+ "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
diff --git a/frontend/src/pages/ValuationWorkflow.tsx b/frontend/src/pages/ValuationWorkflow.tsx
index 92c32b6..d0629a5 100644
--- a/frontend/src/pages/ValuationWorkflow.tsx
+++ b/frontend/src/pages/ValuationWorkflow.tsx
@@ -187,7 +187,7 @@ export default function ValuationWorkflow() {
Investments
-
+
Valuation
@@ -214,7 +214,7 @@ export default function ValuationWorkflow() {
/>
Create
@@ -228,7 +228,7 @@ export default function ValuationWorkflow() {
selectRound(r)}
className={`w-full text-left px-3 py-2 rounded text-sm ${
- selectedRound?.id === r.id ? "bg-orange-50 text-orange-700" : "hover:bg-gray-50 text-gray-700"
+ selectedRound?.id === r.id ? "bg-accent-50 text-accent-700" : "hover:bg-gray-50 text-gray-700"
}`}
>
{formatQuarter(r.quarter_end)}
@@ -271,7 +271,7 @@ export default function ValuationWorkflow() {
Submit for Review
diff --git a/frontend/src/portal/FundAdminHome.tsx b/frontend/src/portal/FundAdminHome.tsx
index b446733..5b65d81 100644
--- a/frontend/src/portal/FundAdminHome.tsx
+++ b/frontend/src/portal/FundAdminHome.tsx
@@ -74,7 +74,7 @@ function EntityDocs({ entity }: { entity: Entity }) {
{categoryLabel(d.category)}
· {investorName(d.investor_user_id)}
{formatDate(d.created_at)}
-
+
Download
@@ -167,7 +167,7 @@ function UploadForm({
{busy ? "Uploading…" : "Upload"}
@@ -177,4 +177,4 @@ function UploadForm({
}
const inputCls =
- "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent";
+ "w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
diff --git a/frontend/src/portal/InvestorPortalView.tsx b/frontend/src/portal/InvestorPortalView.tsx
index 886e5a6..8252f4c 100644
--- a/frontend/src/portal/InvestorPortalView.tsx
+++ b/frontend/src/portal/InvestorPortalView.tsx
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { api, type CapitalAccount, type Entity, type PortalDocument } from "../api";
-import { categoryLabel, formatDate, formatMoneyExact } from "../format";
+import { categoryLabel, formatDate, formatMoneyExact, formatMoneyWhole } from "../format";
import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
+const pct = (p: number) => `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(1)}%`;
+
// 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({
@@ -27,6 +29,7 @@ export default function InvestorPortalView({
return (
+ {entities.length > 1 &&
}
{entities.map((e) => (
{
+ const latest = new Map();
+ for (const a of accounts) {
+ const key = `${a.entity_id}:${a.investor_user_id}`;
+ const cur = latest.get(key);
+ if (!cur || a.as_of_date > cur.as_of_date) latest.set(key, a);
+ }
+ let commitment = 0, paidIn = 0, distributions = 0, balance = 0;
+ for (const a of latest.values()) {
+ commitment += a.commitment_cents;
+ paidIn += a.contributions_cents;
+ distributions += a.distributions_cents;
+ balance += a.ending_balance_cents;
+ }
+ return { commitment, paidIn, distributions, balance };
+ }, [accounts]);
+
+ if (totals.commitment === 0 && totals.balance === 0) return null;
+
+ const gainLossPct =
+ totals.paidIn > 0
+ ? ((totals.balance + totals.distributions - totals.paidIn) / totals.paidIn) * 100
+ : null;
+
+ return (
+
+
+ Across your {count} funds
+
+
+
+
Commitment
+
{formatMoneyWhole(totals.commitment)}
+
+
+
Paid-in
+
{formatMoneyWhole(totals.paidIn)}
+
+ {totals.distributions > 0 && (
+
+
Distributions
+
{formatMoneyWhole(totals.distributions)}
+
+ )}
+
+
Current Capital Balance
+
{formatMoneyWhole(totals.balance)}
+ {gainLossPct != null && (
+
= 0 ? "text-accent-300" : "text-red-300"}`}>
+ {pct(gainLossPct)} net of paid-in
+
+ )}
+
+
+
+ );
+}
+
function FundSection({
entity,
accounts,
@@ -81,29 +145,64 @@ function FundSection({
))
)}
-
-
Documents
- {docs.length === 0 ? (
-
No documents available.
- ) : (
-
- {docs.map((d) => (
-
- {d.title}
- {categoryLabel(d.category)}
- {formatDate(d.created_at)}
-
- Download
-
-
- ))}
-
- )}
-
+
);
}
+function DocumentList({ docs }: { docs: PortalDocument[] }) {
+ // A handful of docs stays a flat list; once tax seasons pile up, group them by year.
+ const groups = useMemo(() => {
+ if (docs.length <= 6) return [{ year: null as number | null, docs }];
+ const byYear = new Map();
+ for (const d of docs) {
+ const y = new Date(d.created_at).getFullYear();
+ const g = byYear.get(y) ?? [];
+ g.push(d);
+ byYear.set(y, g);
+ }
+ return [...byYear.entries()]
+ .sort((a, b) => b[0] - a[0])
+ .map(([year, ds]) => ({ year: year as number | null, docs: ds }));
+ }, [docs]);
+
+ return (
+
+
Documents
+ {docs.length === 0 ? (
+
No documents available.
+ ) : (
+
+ {groups.map((g) => (
+
+ {g.year != null && (
+
{g.year}
+ )}
+
+ {g.docs.map((d) => (
+
+ {d.title}
+ {d.is_new && (
+
+ New
+
+ )}
+ {categoryLabel(d.category)}
+ {formatDate(d.created_at)}
+
+ Download
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
function CapitalBlock({
accounts,
label,
@@ -115,6 +214,17 @@ function CapitalBlock({
}) {
// accounts arrive newest-first; history is oldest-first for the chart/table.
const latest = accounts[0];
+ // Simple LP performance: gain/loss = total value (current NAV + distributions received)
+ // vs paid-in — so distributions taken don't read as a loss; and how much of the
+ // commitment has been distributed back.
+ const gainLoss =
+ latest.ending_balance_cents + latest.distributions_cents - latest.contributions_cents;
+ const gainLossPct =
+ latest.contributions_cents > 0 ? (gainLoss / latest.contributions_cents) * 100 : null;
+ const distributedPct =
+ latest.commitment_cents > 0
+ ? (latest.distributions_cents / latest.commitment_cents) * 100
+ : null;
const history = useMemo(
() => [...accounts].sort((a, b) => a.as_of_date.localeCompare(b.as_of_date)),
[accounts],
@@ -133,10 +243,14 @@ function CapitalBlock({
{label && {label}
}
As of {formatDate(latest.as_of_date)}
-
-
+
+
{latest.distributions_cents > 0 && (
-
+
)}
{latest.distributions_cents > 0 && latest.contributions_cents > 0 && (
)}
- Current capital value
+ Current Capital Balance
- {formatMoneyExact(latest.ending_balance_cents)}
+ {formatMoneyWhole(latest.ending_balance_cents)}
+ {gainLossPct != null && (
+ = 0 ? "text-accent-600" : "text-red-600"}`}>
+ {pct(gainLossPct)} net of paid-in
+
+ )}
{history.length > 1 && (
@@ -159,9 +278,9 @@ function CapitalBlock({
className="flex items-center gap-1.5 text-xs font-medium text-gray-500 uppercase hover:text-gray-700"
>
▸
- Capital over time
+ History
- ({history.length} quarters{showChart ? "" : " — show"})
+ · {history.length} quarters
{showChart && (
@@ -195,11 +314,22 @@ function CapitalBlock({
);
}
-function Metric({ label, value }: { label: string; value: string }) {
+function Metric({
+ label,
+ value,
+ sub,
+ color = "text-gray-900",
+}: {
+ label: string;
+ value: string;
+ sub?: string;
+ color?: string;
+}) {
return (
{label}
-
{value}
+
{value}
+ {sub &&
{sub}
}
);
}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index 85e3d1a..4b44ed4 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -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.26";
+export const APP_VERSION = "0.2.32";