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 }) {

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.

-
@@ -47,7 +47,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void }
- +
@@ -61,7 +61,7 @@ export default function ChangePasswordModal({ onClose }: { onClose: () => void } 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({ @@ -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 && ( @@ -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({ +
@@ -202,7 +206,7 @@ function AddEntityForm({ @@ -261,7 +265,7 @@ function EntityTable({ title, rows }: { title: string; rows: EntityRow[] }) { return ( ); @@ -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

- + 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 +

+
+ + + + + + + + + + + {rounds.map((r) => { + const nav = r.valuations.reduce((s, v) => s + v.value_cents, 0); + return ( + + + + + + + ); + })} + +
QuarterNAVStatusSigned
{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({ +
@@ -282,7 +331,7 @@ function EditEntityForm({ )} {error &&

{error}

}
- + )} +
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}
  • } -
    @@ -240,11 +240,11 @@ export default function Import() {
    @@ -260,6 +260,7 @@ export default function Import() { + setVintageYear(e.target.value)} /> @@ -278,7 +279,7 @@ export default function Import() {
    -
    @@ -373,7 +374,7 @@ export default function Import() { {decisions.filter((d) => d.action === "match").length} matched ·{" "} {decisions.filter((d) => d.action === "skip").length} skipped - @@ -491,7 +492,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) { 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() { +

    + 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

    @@ -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({ @@ -387,7 +387,7 @@ function EditUserModal({ @@ -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() { /> @@ -228,7 +228,7 @@ export default function ValuationWorkflow() { 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({ @@ -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";