diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..68f75ac --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "frontend", + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore index 399e9e1..ba5e8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ dist/ node_modules/ frontend/dist/ +# Built frontend served by backend (produced by the deploy build) +backend/static/ + # DB *.db @@ -17,6 +20,16 @@ frontend/dist/ .vscode/ .idea/ +# macOS +.DS_Store + +# Packaged service artifacts +*.s9pk + # Deploy deploy/node_modules/ deploy/javascript/ + +# Confidential fund-admin spreadsheets (never commit) +*.xlsx +*.xls diff --git a/README.md b/README.md index 3c8d5f6..9a98312 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,31 @@ Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off. +## Accounts and access + +Two kinds of accounts: + +- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) — the full back-office app + (entities, holdings, valuations, import, audit). `approver` and `cfo` also get the + admin screens below. +- **External accounts** (`investor`, `fund_administrator`) — a separate, entity-scoped + portal. An external account only sees the entities granted to it. + - **Investor** — sees, per fund, their latest capital-account value and history, plus + documents shared to the fund or addressed privately to them (e.g. their K-1). + - **Fund administrator** — sees assigned entities and can upload documents for them + (shared or addressed to a specific investor). + +Admin screens (Users / Documents / Capital Accounts, visible to `approver` and `cfo`) +let you create an account with a username and password, check off which entities it can +view, upload documents, and enter each investor's capital-account figures. + +Login accepts a username **or** an email. The first admin is created from the CLI: + +```bash +ten31portal-cli create-user --name "You" --username admin --role cfo --password '...' +# --email is optional; external accounts are normally created from the Users screen. +``` + ## Prerequisites - Python 3.11+ diff --git a/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py b/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py new file mode 100644 index 0000000..41d2ea7 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_investor_access.py @@ -0,0 +1,101 @@ +"""investor access: username, entity access, documents, capital accounts + +Revision ID: a1b2c3d4e5f6 +Revises: 2792c4ff4612 +Create Date: 2026-06-26 14:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '2792c4ff4612' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # users: add username (login handle), make email optional. + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + batch_op.alter_column('email', existing_type=sa.String(), nullable=True) + + # Backfill username for any existing internal accounts so the NOT NULL holds. + op.execute("UPDATE users SET username = email WHERE username IS NULL") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column( + 'username', + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.create_unique_constraint('uq_users_username', ['username']) + + op.create_table( + 'entity_access', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'), + ) + + op.create_table( + 'documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('investor_user_id', sa.Integer(), nullable=True), + sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + + op.create_table( + 'capital_account_statements', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=False), + sa.Column('investor_user_id', sa.Integer(), nullable=False), + sa.Column('as_of_date', sa.Date(), nullable=False), + sa.Column('beginning_balance_cents', sa.Integer(), nullable=False), + sa.Column('contributions_cents', sa.Integer(), nullable=False), + sa.Column('distributions_cents', sa.Integer(), nullable=False), + sa.Column('ending_balance_cents', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ), + sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ), + sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table('capital_account_statements') + op.drop_table('documents') + op.drop_table('entity_access') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('uq_users_username', type_='unique') + batch_op.alter_column('email', existing_type=sa.String(), nullable=False) + batch_op.drop_column('username') diff --git a/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py b/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py new file mode 100644 index 0000000..9f9e69d --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_external_investor_id.py @@ -0,0 +1,30 @@ +"""add users.external_investor_id + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-28 11:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'b2c3d4e5f6a7' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +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('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('external_investor_id') diff --git a/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py b/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py new file mode 100644 index 0000000..df7d087 --- /dev/null +++ b/backend/alembic/versions/b8c9d0e1f2a3_entity_stakes.py @@ -0,0 +1,41 @@ +"""add entity_stakes (a holder entity's stake in the funds it manages) + +Revision ID: b8c9d0e1f2a3 +Revises: a7b8c9d0e1f2 +Create Date: 2026-07-01 10:15:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b8c9d0e1f2a3' +down_revision: Union[str, None] = 'a7b8c9d0e1f2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'entity_stakes', + sa.Column('id', sa.Integer(), primary_key=True, nullable=False), + sa.Column('holder_entity_id', sa.Integer(), nullable=False), + sa.Column('fund_entity_id', sa.Integer(), nullable=False), + sa.Column('ownership_pct', sa.Float(), nullable=True), + sa.Column('value_cents', sa.Integer(), nullable=True), + sa.Column('note', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']), + sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']), + sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'), + ) + op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id']) + op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id']) + + +def downgrade() -> None: + op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes') + op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes') + op.drop_table('entity_stakes') diff --git a/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py b/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py new file mode 100644 index 0000000..a4911d8 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_login_enabled.py @@ -0,0 +1,29 @@ +"""add users.login_enabled + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-06-28 11:45:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c3d4e5f6a7b8' +down_revision: Union[str, None] = 'b2c3d4e5f6a7' +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('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true()) + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('login_enabled') diff --git a/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py b/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py new file mode 100644 index 0000000..9add644 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_commitment_cents.py @@ -0,0 +1,29 @@ +"""add capital_account_statements.commitment_cents + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-06-28 16:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd4e5f6a7b8c9' +down_revision: Union[str, None] = 'c3d4e5f6a7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('capital_account_statements', schema=None) as batch_op: + batch_op.add_column( + sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0') + ) + + +def downgrade() -> None: + with op.batch_alter_table('capital_account_statements', schema=None) as batch_op: + batch_op.drop_column('commitment_cents') diff --git a/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py b/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py new file mode 100644 index 0000000..50a414a --- /dev/null +++ b/backend/alembic/versions/e5f6a7b8c9d0_primary_account_id.py @@ -0,0 +1,33 @@ +"""add users.primary_account_id (linked investor logins) + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-06-28 17:10:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'e5f6a7b8c9d0' +down_revision: Union[str, None] = 'd4e5f6a7b8c9' +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('primary_account_id', sa.Integer(), nullable=True) + ) + batch_op.create_foreign_key( + 'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id'] + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey') + batch_op.drop_column('primary_account_id') diff --git a/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py b/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py new file mode 100644 index 0000000..05d30d7 --- /dev/null +++ b/backend/alembic/versions/f6a7b8c9d0e1_service_admin.py @@ -0,0 +1,34 @@ +"""add users.is_service_admin and flag the bootstrap admin + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-06-29 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'f6a7b8c9d0e1' +down_revision: Union[str, None] = 'e5f6a7b8c9d0' +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('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false()) + ) + # The first account created (the first-boot bootstrap admin) is the Service Admin. + op.execute( + "UPDATE users SET is_service_admin = 1 " + "WHERE id = (SELECT MIN(id) FROM users)" + ) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('is_service_admin') diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py index 59c5598..0f365b0 100644 --- a/backend/ten31portal/auth.py +++ b/backend/ten31portal/auth.py @@ -5,10 +5,10 @@ from typing import Annotated from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError from fastapi import Depends, HTTPException, Request -from sqlmodel import Session, select +from sqlmodel import Session, select, col from ten31portal.database import get_session -from ten31portal.models import User, UserRole +from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES ph = PasswordHasher() @@ -44,8 +44,51 @@ def require_role(*roles: UserRole): return checker +def require_internal(user: User = Depends(get_current_user)) -> User: + """Block external (entity-scoped) accounts from internal staff endpoints.""" + if user.role in EXTERNAL_ROLES: + raise HTTPException(status_code=403, detail="Insufficient permissions") + return user + + +def household_user_ids(user: User, session: Session) -> list[int]: + """All account ids that share this user's login. + + An investor who invests under several legal names has one "primary" account (the login) + and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as + the primary should surface every linked name's entities, statements, and documents. For a + standalone account this is just ``[user.id]``. + """ + root_id = user.primary_account_id or user.id + linked = session.exec( + select(User.id).where(User.primary_account_id == root_id) + ).all() + return list({root_id, user.id, *linked}) + + +def accessible_entity_ids(user: User, session: Session) -> set[int] | None: + """Entity ids an external account may view. None means unrestricted (internal staff).""" + if user.role not in EXTERNAL_ROLES: + return None + rows = session.exec( + select(EntityAccess.entity_id).where( + col(EntityAccess.user_id).in_(household_user_ids(user, session)) + ) + ).all() + return set(rows) + + +def can_access_entity(user: User, entity_id: int, session: Session) -> bool: + allowed = accessible_entity_ids(user, session) + return allowed is None or entity_id in allowed + + # Convenience aliases require_user = get_current_user -require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver) -require_approver = require_role(UserRole.approver) -require_audit_reader = require_role(UserRole.approver, UserRole.cfo) +require_writer = require_role( + UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations +) +require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only +require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) +# Account administration (create/manage users, documents, capital accounts) +require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations) diff --git a/backend/ten31portal/main.py b/backend/ten31portal/main.py index 70f59cf..7d82653 100644 --- a/backend/ten31portal/main.py +++ b/backend/ten31portal/main.py @@ -6,6 +6,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import FileResponse @@ -18,16 +19,26 @@ from ten31portal.routers.holding_router import router as holding_router from ten31portal.routers.position_router import router as position_router from ten31portal.routers.round_router import router as round_router from ten31portal.routers.import_router import router as import_router +from ten31portal.routers.user_router import router as user_router +from ten31portal.routers.document_router import router as document_router +from ten31portal.routers.capital_account_router import router as capital_account_router +from ten31portal.routers.capital_import_router import router as capital_import_router @asynccontextmanager async def lifespan(app: FastAPI): run_migrations() + from ten31portal.storage import ensure_docs_dir + ensure_docs_dir() yield app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan) app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET) +# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after +# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips +# tiny payloads (health checks, small JSON) where compression isn't worth it. +app.add_middleware(GZipMiddleware, minimum_size=500) app.include_router(auth_router) app.include_router(audit_router) app.include_router(entity_router) @@ -35,6 +46,10 @@ app.include_router(holding_router) app.include_router(position_router) app.include_router(round_router) app.include_router(import_router) +app.include_router(user_router) +app.include_router(document_router) +app.include_router(capital_account_router) +app.include_router(capital_import_router) @app.get("/api/health") @@ -45,12 +60,29 @@ def health() -> dict[str, str]: # Serve built frontend in production (when static/ dir exists next to the app) _static_dir = Path(__file__).resolve().parent.parent / "static" if _static_dir.is_dir(): - @app.get("/{path:path}") + # index.html must always be revalidated so a new build is picked up right after an + # upgrade (its hashed asset references change). Hashed assets themselves are immutable. + _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} + # Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever. + _IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"} + _ONE_DAY = {"Cache-Control": "public, max-age=86400"} + + def _index() -> FileResponse: + return FileResponse(_static_dir / "index.html", headers=_NO_CACHE) + + @app.api_route("/{path:path}", methods=["GET", "HEAD"]) async def serve_spa(path: str): file = _static_dir / path if file.is_file(): + # Don't let the HTML entrypoint get cached; fingerprinted assets can cache. + if file.name == "index.html": + return _index() + if path.startswith("assets/"): + return FileResponse(file, headers=_IMMUTABLE) + if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"): + return FileResponse(file, headers=_ONE_DAY) return FileResponse(file) - return FileResponse(_static_dir / "index.html") + return _index() def cli() -> None: diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 6177f68..7ca2732 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -207,3 +207,23 @@ class CapitalAccountStatement(SQLModel, table=True): ending_balance_cents: int = 0 # current capital value document_id: int | None = Field(default=None, foreign_key="documents.id", index=True) created_at: datetime = Field(default_factory=datetime.utcnow) + + +class EntityStake(SQLModel, table=True): + """A holder entity's ownership stake in a fund/SPV it manages. + + Models the assets of a GP or management company: its interest in each fund it manages, + which are entities in their own right rather than portfolio-company holdings. + """ + __tablename__ = "entity_stakes" + __table_args__ = ( + UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"), + ) + + id: int | None = Field(default=None, primary_key=True) + holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co + fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held + ownership_pct: float | None = None # e.g. 20.0 for a 20% interest + value_cents: int | None = None # optional current value of the stake + note: str | None = 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 161d02d..f6c08a5 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -6,7 +6,7 @@ from sqlmodel import Session, select from ten31portal.auth import get_current_user, hash_password, verify_password from ten31portal.database import get_session from ten31portal.models import User -from ten31portal.schemas import LoginRequest, UserResponse +from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -17,9 +17,20 @@ def login( request: Request, session: Session = Depends(get_session), ) -> UserResponse: - user = session.exec(select(User).where(User.email == body.email)).first() + # Accept either a username or an email in the login field. + handle = body.login.strip() + user = session.exec(select(User).where(User.username == handle)).first() + if user is None: + user = session.exec(select(User).where(User.email == handle)).first() if user is None or not verify_password(body.password, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid email or password") + raise HTTPException(status_code=401, detail="Invalid username or password") + if user.primary_account_id is not None: + raise HTTPException( + status_code=401, + detail="This account signs in under another login. Use that account's credentials.", + ) + if not user.login_enabled: + raise HTTPException(status_code=401, detail="This account does not have a login yet.") if not user.is_active: raise HTTPException(status_code=401, detail="Account disabled") request.session["user_id"] = user.id @@ -35,3 +46,20 @@ def logout(request: Request) -> dict[str, str]: @router.get("/me") def me(user: User = Depends(get_current_user)) -> UserResponse: return UserResponse.model_validate(user, from_attributes=True) + + +@router.post("/change-password") +def change_password( + body: ChangePasswordRequest, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> dict[str, str]: + """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.") + user.password_hash = hash_password(body.new_password) + session.add(user) + session.commit() + return {"status": "ok"} diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py new file mode 100644 index 0000000..f37472a --- /dev/null +++ b/backend/ten31portal/routers/capital_account_router.py @@ -0,0 +1,129 @@ +"""Capital account statements: admin entry, investor read of their own figures.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, col + +from ten31portal.audit import record_audit +from ten31portal.auth import ( + accessible_entity_ids, get_current_user, household_user_ids, + require_internal_admin, +) +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Entity, User, UserRole, +) +from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse + +router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"]) + + +def _dollars_to_cents(dollars: float) -> int: + return round(dollars * 100) + + +@router.get("") +def list_statements( + entity_id: int | None = None, + investor_user_id: int | None = None, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[CapitalAccountResponse]: + query = select(CapitalAccountStatement) + allowed = accessible_entity_ids(user, session) + + if allowed is not None: + # External accounts see statements for every legal name linked to their login. + query = query.where( + col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session)) + ) + if allowed: + query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed)) + else: + return [] + else: + if investor_user_id is not None: + query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id) + + if entity_id is not None: + query = query.where(CapitalAccountStatement.entity_id == entity_id) + + rows = session.exec( + query.order_by(col(CapitalAccountStatement.as_of_date).desc()) + ).all() + # Attach each statement's legal name so the portal can label/group accounts held under + # different names without an admin-only user lookup. + names = dict(session.exec( + select(User.id, User.name).where( + col(User.id).in_({r.investor_user_id for r in rows}) + ) + ).all()) if rows else {} + out: list[CapitalAccountResponse] = [] + for r in rows: + data = CapitalAccountResponse.model_validate(r, from_attributes=True) + data.investor_name = names.get(r.investor_user_id) + out.append(data) + return out + + +@router.post("", status_code=201) +def create_statement( + body: CapitalAccountCreate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> CapitalAccountResponse: + if session.get(Entity, body.entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + investor = session.get(User, body.investor_user_id) + if investor is None or investor.role != UserRole.investor: + raise HTTPException(status_code=400, detail="investor_user_id must be an investor account") + + existing = session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.entity_id == body.entity_id, + CapitalAccountStatement.investor_user_id == body.investor_user_id, + CapitalAccountStatement.as_of_date == body.as_of_date, + ) + ).first() + if existing: + raise HTTPException( + status_code=409, + detail="A statement for this investor, fund, and date already exists.", + ) + + stmt = CapitalAccountStatement( + entity_id=body.entity_id, + investor_user_id=body.investor_user_id, + as_of_date=body.as_of_date, + commitment_cents=_dollars_to_cents(body.commitment_dollars), + beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars), + contributions_cents=_dollars_to_cents(body.contributions_dollars), + distributions_cents=_dollars_to_cents(body.distributions_dollars), + ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars), + document_id=body.document_id, + ) + session.add(stmt) + session.flush() + record_audit(session, admin.id, "create", "capital_account", stmt.id, { + "entity_id": body.entity_id, + "investor_user_id": body.investor_user_id, + "as_of_date": str(body.as_of_date), + "ending_balance_cents": stmt.ending_balance_cents, + }) + session.commit() + session.refresh(stmt) + return CapitalAccountResponse.model_validate(stmt, from_attributes=True) + + +@router.delete("/{statement_id}") +def delete_statement( + statement_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + stmt = session.get(CapitalAccountStatement, statement_id) + if stmt is None: + raise HTTPException(status_code=404, detail="Statement not found") + record_audit(session, admin.id, "delete", "capital_account", statement_id, None) + session.delete(stmt) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py new file mode 100644 index 0000000..25e48a5 --- /dev/null +++ b/backend/ten31portal/routers/capital_import_router.py @@ -0,0 +1,348 @@ +"""Import fund members and their capital figures from a fund-administrator workbook. + +Primary path: an eNAV workbook's "ALLOC SI" tab (one row per investor with INVESTOR ID, +INVESTOR NAME, COMMITTED CAPITAL, CONTRIBUTIONS, (DISTRIBUTIONS), ENDING BALANCE). +Fallback: a subaccounts sheet (investor names across columns, per-vehicle value rows). + +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). +""" + +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.audit import record_audit +from ten31portal.auth import hash_password, require_internal_admin +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, User, UserRole, +) +from ten31portal.routers.import_router import _open_workbook, _enav_as_of +from ten31portal.schemas import ( + CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow, +) + +router = APIRouter(prefix="/api/import/capital-accounts", tags=["import"]) + +MAX_ROWS = 400 +MAX_COLS = 90 + + +def _slug_username(name: str) -> str: + base = re.sub(r"[^a-z0-9]+", "", name.lower()) + return base or "investor" + + +def _as_float(v) -> float | None: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).replace(",", "").replace("$", "").strip()) + except ValueError: + return None + + +# --- ALLOC SI (eNAV investor roster) --- + +def _parse_alloc_si(wb): + """Return (as_of, investors) from an eNAV ALLOC SI sheet. + + investors: [{name, external_id, commitment, contributions, distributions, ending}]. + """ + ws = wb["ALLOC SI"] + rows = list(ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True)) + + header_idx = None + header: list[str] = [] + for i, row in enumerate(rows): + cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row] + if "INVESTOR ID" in cells and "INVESTOR NAME" in cells: + header_idx, header = i, cells + break + if header_idx is None: + raise HTTPException(status_code=422, detail="Could not find the ALLOC SI header row.") + + def col(*names: str) -> int | None: + for n in names: + if n in header: + return header.index(n) + return None + + c_id = col("INVESTOR ID") + c_type = col("INVESTOR TYPE") + c_name = col("INVESTOR NAME") + c_commit = col("COMMITTED CAPITAL") + c_contrib = col("CONTRIBUTIONS") + c_distrib = col("(DISTRIBUTIONS)", "DISTRIBUTIONS") + c_ending = col("ENDING BALANCE") + if c_ending is None: + raise HTTPException(status_code=422, detail="ALLOC SI is missing an ENDING BALANCE column.") + + def amount(row, ci): + if ci is None or ci >= len(row): + return 0.0 + return _as_float(row[ci]) or 0.0 + + investors: list[dict] = [] + for r in range(header_idx + 1, len(rows)): + row = rows[r] + name_raw = row[c_name] if c_name is not None and c_name < len(row) else None + if not (isinstance(name_raw, str) and name_raw.strip()): + continue + nm = name_raw.strip() + if nm.upper() in ("GP", "LP", "TOTAL"): + continue + itype = row[c_type] if c_type is not None and c_type < len(row) else None + if itype is not None and str(itype).strip().upper() not in ("LP", ""): + continue # investors only (skip the GP entity) + + display = None + if c_name is not None and c_name + 1 < len(row) and isinstance(row[c_name + 1], str) and row[c_name + 1].strip(): + display = row[c_name + 1].strip() + name = display or nm.title() + + inv_id = row[c_id] if c_id is not None and c_id < len(row) else None + external_id = str(inv_id).strip() if inv_id not in (None, "") else None + + investors.append({ + "name": name, + "external_id": external_id, + "commitment": amount(row, c_commit), + "contributions": amount(row, c_contrib), + "distributions": abs(amount(row, c_distrib)), # sheet may show as a credit + "ending": amount(row, c_ending), + }) + + return _enav_as_of(wb), investors + + +# --- Subaccounts (names-across-columns) --- + +def _parse_grid(wb): + ws = wb[wb.sheetnames[0]] + grid: list[list] = [] + for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True): + grid.append(list(row)) + + header_idx = None + total_col = None + for i, row in enumerate(grid): + for j, cell in enumerate(row): + if isinstance(cell, str) and cell.strip().lower() == "total": + header_idx, total_col = i, j + break + if header_idx is not None: + break + if header_idx is None: + raise HTTPException( + status_code=422, + detail='Could not find a header row with a "Total" column. Check the spreadsheet layout.', + ) + + header = grid[header_idx] + investor_cols = [ + j for j in range(len(header)) + if j < total_col + and isinstance(header[j], str) + and header[j].strip().lower() not in ("", "total") + ] + if not investor_cols: + raise HTTPException(status_code=422, detail="No investor name columns found left of the Total column.") + + as_of: date | None = None + for row in grid[: header_idx + 2]: + for cell in row: + if isinstance(cell, (datetime, date)): + as_of = cell.date() if isinstance(cell, datetime) else cell + break + if as_of: + break + + value_rows: list[ImportValueRow] = [] + for i in range(header_idx + 1, len(grid)): + row = grid[i] + label = row[0] + if not (isinstance(label, str) and label.strip()): + continue + if any(_as_float(row[j]) is not None for j in investor_cols if j < len(row)): + value_rows.append(ImportValueRow(row_index=i, label=label.strip())) + if not value_rows: + raise HTTPException(status_code=422, detail="No value rows found under the header.") + + return header_idx, total_col, investor_cols, as_of, value_rows, grid + + +@router.post("/preview") +def preview_import( + file: UploadFile = File(...), + entity_id: int | None = Form(None), + row_index: int | None = Form(None), + password: str | None = Form(None), + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> CapitalImportPreview: + if entity_id is not None and session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + + wb = _open_workbook(file.file.read(), password) + + investors = session.exec(select(User).where(User.role == UserRole.investor)).all() + by_name = {u.name.strip().lower(): u for u in investors} + by_username = {u.username.strip().lower(): u for u in investors} + by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id} + + def match_for(name: str, external_id: str | None): + if external_id and external_id in by_extid: + return by_extid[external_id] + return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower()) + + if "ALLOC SI" in wb.sheetnames: + as_of, roster = _parse_alloc_si(wb) + previews: list[ImportInvestorPreview] = [] + for inv in roster: + m = match_for(inv["name"], inv["external_id"]) + previews.append(ImportInvestorPreview( + source_name=inv["name"], + column_index=0, + value_dollars=inv["ending"], + commitment_dollars=inv["commitment"], + contributions_dollars=inv["contributions"], + distributions_dollars=inv["distributions"], + external_id=inv["external_id"], + matched_user_id=m.id if m else None, + matched_username=m.username if m else None, + suggested_username=None if m else _slug_username(inv["name"]), + )) + # Roster mode: no per-row value picker needed. + return CapitalImportPreview(as_of_date=as_of, value_rows=[], chosen_row_index=0, investors=previews) + + # Fallback: subaccounts layout (single value figure per investor). + header_idx, total_col, investor_cols, as_of, value_rows, grid = _parse_grid(wb) + valid_indexes = {vr.row_index for vr in value_rows} + chosen = row_index if row_index in valid_indexes else value_rows[0].row_index + chosen_row = grid[chosen] + + previews = [] + for col_i in investor_cols: + source_name = str(grid[header_idx][col_i]).strip() + value = _as_float(chosen_row[col_i]) if col_i < len(chosen_row) else None + m = match_for(source_name, None) + previews.append(ImportInvestorPreview( + source_name=source_name, + column_index=col_i, + value_dollars=value or 0.0, + matched_user_id=m.id if m else None, + matched_username=m.username if m else None, + suggested_username=None if m else _slug_username(source_name), + )) + return CapitalImportPreview(as_of_date=as_of, value_rows=value_rows, chosen_row_index=chosen, investors=previews) + + +@router.post("/commit") +def commit_import( + body: CapitalImportCommit, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict: + entity = session.get(Entity, body.entity_id) + if entity is None: + raise HTTPException(status_code=404, detail="Entity not found") + + created_accounts = 0 + updated_accounts = 0 + statements = 0 + + for inv in body.investors: + if inv.action == "skip": + continue + + if inv.action == "create": + if not inv.username or not inv.name: + raise HTTPException( + status_code=400, + detail=f"New member '{inv.name or inv.username}' needs a name and username.", + ) + if session.exec(select(User).where(User.username == inv.username)).first(): + 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) + 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), + external_investor_id=inv.external_id, + ) + session.add(user) + session.flush() + created_accounts += 1 + elif inv.action == "match": + user = session.get(User, inv.user_id) if inv.user_id else None + if user is None or user.role != UserRole.investor: + raise HTTPException(status_code=400, detail="match requires a valid investor user_id.") + if inv.external_id and not user.external_investor_id: + user.external_investor_id = inv.external_id + session.add(user) + else: + raise HTTPException(status_code=400, detail=f"Unknown action '{inv.action}'.") + + has_access = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user.id, EntityAccess.entity_id == body.entity_id + ) + ).first() + if has_access is None: + session.add(EntityAccess(user_id=user.id, entity_id=body.entity_id)) + + cents = lambda d: round(d * 100) + existing = session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.entity_id == body.entity_id, + CapitalAccountStatement.investor_user_id == user.id, + CapitalAccountStatement.as_of_date == body.as_of_date, + ) + ).first() + if existing: + existing.commitment_cents = cents(inv.commitment_dollars) + existing.contributions_cents = cents(inv.contributions_dollars) + existing.distributions_cents = cents(inv.distributions_dollars) + existing.ending_balance_cents = cents(inv.value_dollars) + session.add(existing) + updated_accounts += 1 + else: + session.add(CapitalAccountStatement( + entity_id=body.entity_id, + investor_user_id=user.id, + as_of_date=body.as_of_date, + commitment_cents=cents(inv.commitment_dollars), + contributions_cents=cents(inv.contributions_dollars), + distributions_cents=cents(inv.distributions_dollars), + ending_balance_cents=cents(inv.value_dollars), + )) + statements += 1 + + record_audit(session, admin.id, "import", "capital_account", body.entity_id, { + "as_of_date": str(body.as_of_date), + "created_accounts": created_accounts, + "statements": statements, + }) + session.commit() + return { + "status": "ok", + "created_accounts": created_accounts, + "matched_accounts_updated": updated_accounts, + "statements_written": statements, + } diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 0270ce0..e82480c 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -3,16 +3,21 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func, literal -from sqlmodel import Session, select +from sqlmodel import Session, col, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import ( + accessible_entity_ids, get_current_user, require_internal, require_writer, +) from ten31portal.database import get_session from ten31portal.models import ( - Entity, EntityStatus, Holding, Position, - Valuation, ValuationRound, RoundStatus, User, + CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding, + Position, UserRole, Valuation, ValuationRound, RoundStatus, User, +) +from ten31portal.schemas import ( + EntityCreate, EntityResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate, + PartnerResponse, ) -from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate router = APIRouter(prefix="/api/entities", tags=["entities"]) @@ -24,6 +29,7 @@ class EntityRollupItem(BaseModel): vintage_year: int | None fund_size_cents: int | None status: str + committed_cents: int # total LP commitments (latest per investor) invested_cents: int last_signed_value_cents: int @@ -34,7 +40,10 @@ def entity_rollup( session: Session = Depends(get_session), ) -> list[EntityRollupItem]: """Per-entity invested and last-signed-value in a single pass.""" + allowed = accessible_entity_ids(user, session) entities = session.exec(select(Entity)).all() + if allowed is not None: + entities = [e for e in entities if e.id in allowed] result: list[EntityRollupItem] = [] for ent in entities: @@ -64,6 +73,20 @@ def entity_rollup( ).one() last_signed_value_cents = int(val_sum) + # Total committed capital = each investor's most recent commitment for this entity. + stmts = session.exec( + select(CapitalAccountStatement) + .where(CapitalAccountStatement.entity_id == ent.id) + .order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr] + ).all() + committed_cents = 0 + seen_investors: set[int] = set() + for st in stmts: + if st.investor_user_id in seen_investors: + continue + seen_investors.add(st.investor_user_id) + committed_cents += st.commitment_cents + result.append(EntityRollupItem( id=ent.id, name=ent.name, @@ -71,6 +94,7 @@ def entity_rollup( vintage_year=ent.vintage_year, fund_size_cents=ent.fund_size_cents, status=ent.status.value, + committed_cents=committed_cents, invested_cents=invested_cents, last_signed_value_cents=last_signed_value_cents, )) @@ -78,12 +102,60 @@ def entity_rollup( return result +@router.get("/{entity_id}/partners") +def list_partners( + entity_id: int, + user: User = Depends(require_internal), + session: Session = Depends(get_session), +) -> list[PartnerResponse]: + """Members (investors) granted access to this entity, with their latest capital value.""" + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + + members = session.exec( + select(User) + .join(EntityAccess, EntityAccess.user_id == User.id) + .where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor) + .order_by(User.name) # type: ignore[arg-type] + ).all() + + result: list[PartnerResponse] = [] + for m in members: + stmts = session.exec( + select(CapitalAccountStatement) + .where( + CapitalAccountStatement.entity_id == entity_id, + CapitalAccountStatement.investor_user_id == m.id, + ) + .order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr] + ).all() + latest = stmts[0] if stmts else None + result.append(PartnerResponse( + user_id=m.id, + name=m.name, + username=m.username, + external_investor_id=m.external_investor_id, + is_active=m.is_active, + login_enabled=m.login_enabled, + latest_commitment_cents=latest.commitment_cents if latest else None, + latest_contributions_cents=latest.contributions_cents if latest else None, + latest_distributions_cents=latest.distributions_cents if latest else None, + latest_value_cents=latest.ending_balance_cents if latest else None, + latest_as_of=latest.as_of_date if latest else None, + statements_count=len(stmts), + )) + return result + + @router.get("") def list_entities( user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> list[EntityResponse]: + allowed = accessible_entity_ids(user, session) rows = session.exec(select(Entity)).all() + if allowed is not None: + rows = [r for r in rows if r.id in allowed] return [EntityResponse.model_validate(r, from_attributes=True) for r in rows] @@ -93,6 +165,9 @@ def get_entity( user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> EntityResponse: + allowed = accessible_entity_ids(user, session) + if allowed is not None and entity_id not in allowed: + raise HTTPException(status_code=404, detail="Entity not found") entity = session.get(Entity, entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") @@ -133,3 +208,96 @@ def update_entity( session.commit() session.refresh(entity) return EntityResponse.model_validate(entity, from_attributes=True) + + +# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages --- + +def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse: + data = EntityStakeResponse.model_validate(stake, from_attributes=True) + fund = funds.get(stake.fund_entity_id) + if fund is not None: + data.fund_name = fund.name + data.fund_type = fund.type + return data + + +@router.get("/{entity_id}/stakes") +def list_stakes( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[EntityStakeResponse]: + """The funds this entity holds a stake in (e.g. a GP's interest in its funds).""" + allowed = accessible_entity_ids(user, session) + if allowed is not None and entity_id not in allowed: + raise HTTPException(status_code=404, detail="Entity not found") + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + rows = session.exec( + select(EntityStake).where(EntityStake.holder_entity_id == entity_id) + ).all() + funds = { + f.id: f for f in session.exec( + select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows})) + ).all() + } if rows else {} + return [_stake_response(r, funds) for r in rows] + + +@router.post("/{entity_id}/stakes", status_code=201) +def create_stake( + entity_id: int, + body: EntityStakeCreate, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> EntityStakeResponse: + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + if body.fund_entity_id == entity_id: + raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.") + fund = session.get(Entity, body.fund_entity_id) + if fund is None: + raise HTTPException(status_code=404, detail="Fund not found") + if session.exec( + select(EntityStake).where( + EntityStake.holder_entity_id == entity_id, + EntityStake.fund_entity_id == body.fund_entity_id, + ) + ).first(): + raise HTTPException(status_code=409, detail="A stake in this fund already exists.") + + stake = EntityStake( + holder_entity_id=entity_id, + fund_entity_id=body.fund_entity_id, + ownership_pct=body.ownership_pct, + value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None, + note=body.note, + ) + session.add(stake) + session.flush() + record_audit(session, user.id, "create", "entity_stake", stake.id, { + "holder_entity_id": entity_id, + "fund_entity_id": body.fund_entity_id, + }) + session.commit() + session.refresh(stake) + return _stake_response(stake, {fund.id: fund}) + + +@router.delete("/{entity_id}/stakes/{stake_id}") +def delete_stake( + entity_id: int, + stake_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> dict[str, str]: + stake = session.get(EntityStake, stake_id) + if stake is None or stake.holder_entity_id != entity_id: + raise HTTPException(status_code=404, detail="Stake not found") + record_audit(session, user.id, "delete", "entity_stake", stake_id, { + "holder_entity_id": entity_id, + "fund_entity_id": stake.fund_entity_id, + }) + session.delete(stake) + session.commit() + return {"status": "deleted"} diff --git a/backend/ten31portal/routers/holding_router.py b/backend/ten31portal/routers/holding_router.py index c86aee7..9e313b8 100644 --- a/backend/ten31portal/routers/holding_router.py +++ b/backend/ten31portal/routers/holding_router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import require_internal, require_writer from ten31portal.database import get_session from ten31portal.models import Entity, Holding, Position, User from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate @@ -15,7 +15,7 @@ router = APIRouter(tags=["holdings"]) @router.get("/api/entities/{entity_id}/holdings") def list_holdings( entity_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[HoldingResponse]: entity = session.get(Entity, entity_id) diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index bf23c4b..9f6326a 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -1,88 +1,32 @@ -"""CSV/XLSX import endpoints for entities and schedule of investments.""" +"""XLSX/CSV import of a fund's holdings and NAV from a fund-administrator eNAV pack. + +Reads the "HLD" (Holdings Report) sheet of an administrator eNAV workbook: each +security row becomes a holding + position, and its market value (book) becomes the +valuation for the quarter. Encrypted workbooks are decrypted with the open password. +A plain holdings CSV is also accepted. +""" import csv import io -import re from datetime import date, datetime from typing import Any +import msoffcrypto import openpyxl -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile -from sqlmodel import Session, select +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import require_role from ten31portal.database import get_session from ten31portal.models import ( - Entity, EntityStatus, EntityType, Holding, Position, + Entity, EntityType, Holding, Position, User, UserRole, Valuation, ValuationRound, RoundStatus, ) router = APIRouter(prefix="/api/import", tags=["import"]) -# --- Column maps --- -# Confirmed against real Carta exports 2026-06-07. - -# Entity CSV import (Issue 9) — still needs a real entity-level export to confirm. -ENTITY_COLUMN_MAP: dict[str, str] = { - # UNCONFIRMED: update after inspecting a real Carta entities export - "Entity Name": "name", - "Entity Type": "type", - "Vintage Year": "vintage_year", - "Fund Size": "fund_size_cents", -} - -ENTITY_TYPE_MAP: dict[str, EntityType] = { - "Fund": EntityType.fund, - "fund": EntityType.fund, - "SPV": EntityType.spv, - "spv": EntityType.spv, - "GP": EntityType.gp, - "gp": EntityType.gp, - "Mgmt Co": EntityType.mgmt_co, - "mgmt_co": EntityType.mgmt_co, - "Management Company": EntityType.mgmt_co, -} - -# Schedule of Investments XLSX import (Issue 10) -# CONFIRMED against Carta export: "Low Time Preference Fund I, LLC" 2026-06-07 -# -# Carta XLSX layout: -# Row 1: Entity name (e.g. "Low Time Preference Fund I, LLC") -# Row 2: Metadata line ("As of MM/DD/YYYY • Generated by ...") -# Row 3: Empty -# Row 4: Headers -# Row 5: Empty -# Rows 6+: Data (company rows alternate with position rows, separated by empty rows) -# Last data row: "Total" summary -# -# Column mapping (0-indexed from Row 4 headers): -# A (0): "Investment" — company name on company/subtotal rows -# B (1): "Asset" — security name on position rows -# C (2): "Investment date" — datetime on position rows -# D (3): "Shares" — number (0 for SAFEs/membership interests) -# E (4): "Cost" — dollar amount (float) -# F (5): "Value" — dollar amount (float) -# G (6): "Last Valuation date" — datetime on position rows -# H (7): "Gain/Loss" — derived, skip -# I (8): "Cost per share" — derived, skip -# J (9): "FMV per share" — derived, skip -# K (10): "Percent of partners' capital" — skip (phase 2) -# -# Company rows: A has name, B is empty, D/E/F have subtotals -# Position rows: A is empty, B has security name, all columns populated -# SAFEs: shares = 0, cost per share = 0, FMV per share = 0 - -SCHEDULE_COLUMNS = { - "investment": 0, # A — company name - "asset": 1, # B — security name - "inv_date": 2, # C — investment date - "shares": 3, # D — share count - "cost": 4, # E — cost in dollars - "value": 5, # F — value in dollars - "val_date": 6, # G — last valuation date -} def _parse_money(raw: str) -> int | None: @@ -121,268 +65,304 @@ def _dollars_to_cents(val: float | int) -> int: return round(float(val) * 100) -# --- Entity import (CSV) --- +# --- eNAV holdings import (XLSX) --- -@router.post("/entities") -def import_entities( - file: UploadFile = File(...), - commit: bool = Query(default=True), - user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), - session: Session = Depends(get_session), -) -> dict[str, Any]: - content = file.file.read().decode("utf-8-sig") - reader = csv.DictReader(io.StringIO(content)) - - results: list[dict] = [] - errors: list[dict] = [] - created = 0 - updated = 0 - - for i, row in enumerate(reader, start=2): - mapped: dict[str, Any] = {} - - for csv_col, model_field in ENTITY_COLUMN_MAP.items(): - val = row.get(csv_col) - if val is None: - for k, v in row.items(): - if k.strip().lower() == csv_col.lower(): - val = v - break - if val is not None: - mapped[model_field] = val.strip() - - parsed: dict[str, Any] = {} - row_errors: list[str] = [] - - name = mapped.get("name") - if not name: - row_errors.append("Missing entity name") - else: - parsed["name"] = name - - type_raw = mapped.get("type", "") - entity_type = ENTITY_TYPE_MAP.get(type_raw) - if entity_type is None and type_raw: - row_errors.append(f"Unknown entity type: {type_raw}") - elif entity_type: - parsed["type"] = entity_type - - vy = mapped.get("vintage_year") - if vy: - try: - parsed["vintage_year"] = int(vy) - except ValueError: - row_errors.append(f"Invalid vintage year: {vy}") - - fs = mapped.get("fund_size_cents") - if fs: - cents = _parse_money(fs) - if cents is None: - row_errors.append(f"Cannot parse fund size: {fs}") - else: - parsed["fund_size_cents"] = cents - - if row_errors: - errors.append({"row": i, "errors": row_errors, "raw": dict(row)}) - continue - - if not parsed.get("name"): - continue - - existing = session.exec(select(Entity).where(Entity.name == parsed["name"])).first() - action = "update" if existing else "create" - - results.append({ - "row": i, - "action": action, - "name": parsed["name"], - "type": parsed.get("type", EntityType.fund).value if parsed.get("type") else None, - "vintage_year": parsed.get("vintage_year"), - "fund_size_cents": parsed.get("fund_size_cents"), - }) - - if commit: - if existing: - if "type" in parsed: - existing.type = parsed["type"] - if "vintage_year" in parsed: - existing.vintage_year = parsed["vintage_year"] - if "fund_size_cents" in parsed: - existing.fund_size_cents = parsed["fund_size_cents"] - session.add(existing) - updated += 1 - else: - entity = Entity( - name=parsed["name"], - type=parsed.get("type", EntityType.fund), - vintage_year=parsed.get("vintage_year"), - fund_size_cents=parsed.get("fund_size_cents"), - ) - session.add(entity) - created += 1 - - if commit: - record_audit(session, user.id, "import_entities", "entity", None, { - "created": created, "updated": updated, "errors": len(errors), - }) - session.commit() - - return { - "committed": commit, - "preview": results, - "errors": errors, - "summary": {"created": created, "updated": updated, "error_rows": len(errors)}, - } +def _open_workbook(file_bytes: bytes, password: str | None): + """Load an xlsx workbook, decrypting an encrypted (password-protected) file if needed.""" + # A normal .xlsx is a zip ("PK"); an encrypted Office file is an OLE2 container. + if file_bytes[:2] == b"PK": + return openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + try: + office = msoffcrypto.OfficeFile(io.BytesIO(file_bytes)) + office.load_key(password=password or "VelvetSweatshop") + out = io.BytesIO() + office.decrypt(out) + out.seek(0) + return openpyxl.load_workbook(out, data_only=True) + except Exception: + if not password: + raise HTTPException( + status_code=400, + detail="This spreadsheet is password-protected. Enter the open password and try again.", + ) + raise HTTPException( + status_code=400, + detail="Could not open the spreadsheet. The password may be incorrect.", + ) -# --- Schedule of investments import (XLSX) --- +def _enav_as_of(wb) -> date | None: + """Find the report date, e.g. a 'DECEMBER 31, 2025' / 'AS OF ...' cell near the top.""" + for sheet in (["MENU", "HLD"] if "MENU" in wb.sheetnames else wb.sheetnames): + ws = wb[sheet] + for row in ws.iter_rows(min_row=1, max_row=6, max_col=2, values_only=True): + for cell in row: + if isinstance(cell, datetime): + return cell.date() + if isinstance(cell, date): + return cell + if isinstance(cell, str): + text = cell.replace("AS OF", "").strip() + for fmt in ("%B %d, %Y", "%b %d, %Y", "%m/%d/%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None -def _parse_schedule_xlsx(file_bytes: bytes) -> tuple[str | None, list[dict], list[dict], list[dict]]: + +def _parse_schedule_xlsx(file_bytes: bytes, password: str | None = None) -> tuple[str | None, date | None, list[dict], list[dict], list[dict]]: """ - Parse a Carta Schedule of Investments XLSX export. + Parse the HLD (Holdings Report) sheet of a fund-administrator eNAV workbook. - Returns: (entity_name, holdings_preview, positions_preview, errors) + Each security row becomes a holding (issuer) + position (security), with cost basis + and market value (book). Returns: (entity_name, as_of, holdings, positions, errors). """ - wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) - ws = wb.active + wb = _open_workbook(file_bytes, password) entity_name: str | None = None holdings_preview: list[dict] = [] positions_preview: list[dict] = [] errors: list[dict] = [] - # Row 1: entity name - row1_val = ws.cell(row=1, column=1).value - if row1_val: - entity_name = str(row1_val).strip() + # Fund name from the MENU cover sheet (row 2) when present. + if "MENU" in wb.sheetnames: + v = wb["MENU"].cell(row=2, column=1).value + if v: + entity_name = str(v).strip() - # Row 4: headers — validate - expected_headers = {0: "Investment", 1: "Asset", 4: "Cost", 5: "Value"} - for col_idx, expected in expected_headers.items(): - actual = ws.cell(row=4, column=col_idx + 1).value - if actual and str(actual).strip() != expected: - errors.append({ - "row": 4, - "errors": [f"Expected header '{expected}' in column {chr(65 + col_idx)}, got '{actual}'"], - }) + as_of = _enav_as_of(wb) + + if "HLD" not in wb.sheetnames: + errors.append({"row": 0, "errors": [ + "No 'HLD' (Holdings Report) sheet found. This does not look like an eNAV workbook." + ]}) + return entity_name, as_of, holdings_preview, positions_preview, errors + + ws = wb["HLD"] + rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, max_col=20, values_only=True)) + + # Locate the header row (the one whose first cell is "SECURITY NAME"). + header_idx = None + for i, row in enumerate(rows): + if row and isinstance(row[0], str) and row[0].strip().upper() == "SECURITY NAME": + header_idx = i + break + if header_idx is None: + errors.append({"row": 0, "errors": ["Could not find the holdings header row in the HLD sheet."]}) + return entity_name, as_of, holdings_preview, positions_preview, errors + + header = [str(c).strip().upper() if isinstance(c, str) else "" for c in rows[header_idx]] + + def col(*names: str) -> int | None: + for n in names: + if n in header: + return header.index(n) + return None + + c_name = col("SECURITY NAME") + c_qty = col("QUANTITY") + c_cost = col("COST BASIS - BOOK", "COST BASIS - LOCAL") + c_value = col("MARKET VALUE (BOOK)", "MARKET VALUE (LOCAL)", "MARKET VALUE - BOOK") + + if c_value is None or c_name is None: + errors.append({"row": header_idx + 1, "errors": [ + "HLD sheet is missing a SECURITY NAME or MARKET VALUE column." + ]}) + return entity_name, as_of, holdings_preview, positions_preview, errors - # Parse data rows (starting at row 6) - current_company: str | None = None seen_companies: set[str] = set() - # Track (company, security) occurrences to disambiguate duplicate tranches - security_counts: dict[tuple[str, str], int] = {} - - for row_idx in range(6, ws.max_row + 1): - col_a = ws.cell(row=row_idx, column=1).value # Investment (company) - col_b = ws.cell(row=row_idx, column=2).value # Asset (security) - col_c = ws.cell(row=row_idx, column=3).value # Investment date - col_d = ws.cell(row=row_idx, column=4).value # Shares - col_e = ws.cell(row=row_idx, column=5).value # Cost - col_f = ws.cell(row=row_idx, column=6).value # Value - col_g = ws.cell(row=row_idx, column=7).value # Last valuation date - - # Skip empty rows - if col_a is None and col_b is None: + for r in range(header_idx + 1, len(rows)): + row = rows[r] + raw_name = row[c_name] if c_name < len(row) else None + if raw_name is None or not str(raw_name).strip(): + continue + name = str(raw_name).strip() + # Stop at total / report-total summary rows. + if name.upper().startswith("TOTAL") or name.upper().startswith("REPORT TOTAL"): continue - # Skip total row - if col_a and str(col_a).strip().lower() == "total": + # Group tranches of the same issuer: holding = issuer (before " - "); position = full name. + company = name.split(" - ")[0].strip() or name + security = name + + if company not in seen_companies: + holdings_preview.append({"row": r + 1, "company_name": company}) + seen_companies.add(company) + + # Quantity -> shares (skip non-numeric like "N/A"). + shares: str | None = None + if c_qty is not None and c_qty < len(row) and row[c_qty] is not None: + qv = row[c_qty] + if isinstance(qv, (int, float)) and qv != 0: + shares = f"{qv:g}" + + cost_cents = None + if c_cost is not None and c_cost < len(row) and isinstance(row[c_cost], (int, float)): + cost_cents = _dollars_to_cents(row[c_cost]) + + value_cents = None + if isinstance(row[c_value], (int, float)): + value_cents = _dollars_to_cents(row[c_value]) + + positions_preview.append({ + "row": r + 1, + "company_name": company, + "security_name": security, + "investment_date": None, # eNAV holdings report has no acquisition date + "shares": shares, + "cost_cents": cost_cents, + "value_cents": value_cents, + "valuation_date": str(as_of) if as_of else None, + }) + + return entity_name, as_of, holdings_preview, positions_preview, errors + + + + +def reset_entity_holdings(entity_id: int, session: Session) -> dict[str, int]: + """Delete all holdings, positions, valuations, and rounds for one entity. + + For a clean restart — e.g. after switching the source workbook (Carta → eNAV) renamed every + position, so the old and new rows can't be matched and both get counted. Capital-account + statements (investor data) are left untouched. The caller should re-import afterwards. + """ + rounds = session.exec( + select(ValuationRound).where(ValuationRound.entity_id == entity_id) + ).all() + round_ids = [r.id for r in rounds] + valuations = 0 + if round_ids: + for v in session.exec( + select(Valuation).where(col(Valuation.round_id).in_(round_ids)) + ).all(): + session.delete(v) + valuations += 1 + + holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all() + holding_ids = [h.id for h in holdings] + positions = 0 + if holding_ids: + for p in session.exec( + select(Position).where(col(Position.holding_id).in_(holding_ids)) + ).all(): + session.delete(p) + positions += 1 + + for r in rounds: + session.delete(r) + for h in holdings: + session.delete(h) + session.flush() + return { + "rounds": len(rounds), + "holdings": len(holdings), + "positions": positions, + "valuations": valuations, + } + + +def dedupe_entity(entity_id: int, session: Session) -> dict[str, int]: + """Collapse exact-duplicate holdings and positions for one entity. + + Repeated imports on older builds created a second copy of each holding/position, which + inflated the entity's "Invested" total (the rollup sums cost across every position). This + keeps the lowest-id copy, re-points its positions/valuations, removes duplicate valuations + in the same round, and deletes the leftovers. Safe to run repeatedly (a no-op once clean). + """ + removed_holdings = 0 + removed_positions = 0 + + # 1) Merge holdings with the same name (case-insensitive) into the lowest-id one. + holdings = session.exec( + select(Holding).where(Holding.entity_id == entity_id).order_by(Holding.id) # type: ignore[arg-type] + ).all() + keep_by_name: dict[str, Holding] = {} + for h in holdings: + key = h.company_name.strip().lower() + keeper = keep_by_name.get(key) + if keeper is None: + keep_by_name[key] = h continue + for p in session.exec(select(Position).where(Position.holding_id == h.id)).all(): + p.holding_id = keeper.id + session.add(p) + session.flush() + session.delete(h) + removed_holdings += 1 + session.flush() - # Company subtotal row: col A has name, col B is empty - if col_a and not col_b: - company_name = str(col_a).strip() - current_company = company_name - if company_name not in seen_companies: - holdings_preview.append({ - "row": row_idx, - "company_name": company_name, - }) - seen_companies.add(company_name) - continue + # 2) Within each surviving holding, merge positions with the same security name. + for keeper_holding in keep_by_name.values(): + positions = session.exec( + select(Position) + .where(Position.holding_id == keeper_holding.id) + .order_by(Position.id) # type: ignore[arg-type] + ).all() + keep_by_sec: dict[str, Position] = {} + for p in positions: + key = p.security_name.strip().lower() + keeper = keep_by_sec.get(key) + if keeper is None: + keep_by_sec[key] = p + continue + # Move this duplicate's valuations onto the keeper, dropping any that would collide + # with an existing valuation for the same round (that collision IS the double-count). + for v in session.exec(select(Valuation).where(Valuation.position_id == p.id)).all(): + clash = session.exec( + select(Valuation).where( + Valuation.round_id == v.round_id, + Valuation.position_id == keeper.id, + ) + ).first() + if clash is not None: + session.delete(v) + else: + v.position_id = keeper.id + session.add(v) + session.flush() + session.delete(p) + removed_positions += 1 + session.flush() - # Position row: col B has security name - if col_b: - raw_security_name = str(col_b).strip() - company = current_company or "(unknown)" - - # Disambiguate duplicate (company, security) pairs - # e.g. two "Warrants" under BIP21 become "Warrants" and "Warrants (2)" - pair_key = (company, raw_security_name) - security_counts[pair_key] = security_counts.get(pair_key, 0) + 1 - if security_counts[pair_key] == 1: - security_name = raw_security_name - else: - security_name = f"{raw_security_name} ({security_counts[pair_key]})" - - # Parse investment date - inv_date: date | None = None - if isinstance(col_c, datetime): - inv_date = col_c.date() - elif col_c: - inv_date = _parse_date(str(col_c)) - - # Parse shares (could be 0 for SAFEs) - shares: str | None = None - if col_d is not None: - shares_val = float(col_d) - if shares_val != 0: - # Preserve precision: use string repr - shares = str(col_d) if not isinstance(col_d, float) else f"{col_d:g}" - # shares stays None for zero (SAFEs) - - # Parse cost (dollars) - cost_cents: int | None = None - if col_e is not None: - cost_cents = _dollars_to_cents(col_e) - - # Parse value (dollars) - value_cents: int | None = None - if col_f is not None: - value_cents = _dollars_to_cents(col_f) - - # Parse valuation date - val_date: date | None = None - if isinstance(col_g, datetime): - val_date = col_g.date() - elif col_g: - val_date = _parse_date(str(col_g)) - - positions_preview.append({ - "row": row_idx, - "company_name": company, - "security_name": security_name, - "investment_date": str(inv_date) if inv_date else None, - "shares": shares, - "cost_cents": cost_cents, - "value_cents": value_cents, - "valuation_date": str(val_date) if val_date else None, - }) - - return entity_name, holdings_preview, positions_preview, errors + return {"removed_holdings": removed_holdings, "removed_positions": removed_positions} @router.post("/schedule") def import_schedule( file: UploadFile = File(...), - as_of: date = Query(...), + as_of: date | None = Query(default=None), entity_id: int | None = Query(default=None), create_entity_type: EntityType = Query(default=EntityType.fund), create_vintage_year: int | None = Query(default=None), commit: bool = Query(default=True), - user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), + replace_existing: bool = Query(default=False), + password: str | None = Form(default=None), + user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)), session: Session = Depends(get_session), ) -> dict[str, Any]: file_bytes = file.file.read() filename = file.filename or "" - # Parse file first to get source_entity_name - if filename.endswith(".xlsx") or filename.endswith(".xls"): - source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes) + # Parse the file into holdings/positions previews. + if filename.lower().endswith((".xlsx", ".xls")): + source_entity_name, parsed_as_of, holdings_preview, positions_preview, errors = ( + _parse_schedule_xlsx(file_bytes, password) + ) else: source_entity_name = None + parsed_as_of = None holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes) + # As-of date: use the explicit value, else the date read from the sheet. + as_of = as_of or parsed_as_of + if as_of is None: + raise HTTPException( + status_code=400, + detail="Could not determine the quarter-end date. Set the as-of date and try again.", + ) + # Resolve entity entity: Entity | None = None entity_resolution: str = "existing" # "existing" | "matched" | "will_create" @@ -405,7 +385,7 @@ def import_schedule( else: raise HTTPException( status_code=400, - detail="No entity_id provided and the file has no entity name in row 1. Provide entity_id or upload a Carta XLSX with the fund name.", + detail="No entity selected and no fund name found in the file. Choose the fund to import into.", ) # Dry-run: report resolution without writing @@ -447,19 +427,36 @@ def import_schedule( assert entity is not None resolved_entity_id = entity.id - # Check for any existing round at this quarter + # Replace mode: wipe the fund's existing holdings/positions/rounds first so the file becomes + # the single source of truth. Use this after a source change (e.g. Carta → eNAV) renamed the + # positions, leaving un-matchable rows that inflate the totals. + if replace_existing: + reset_entity_holdings(resolved_entity_id, session) + + # Self-heal any duplicate holdings/positions left by imports on older builds, so this + # import's upsert lands on a single clean copy and "Invested" stops double-counting. + dedupe_entity(resolved_entity_id, session) + + # An existing round at this quarter: a NAV re-import should UPDATE it in place (refresh + # to the latest file) instead of stacking a second round and doubling the totals. Only + # import-created seed rounds are refreshable; a manually-signed valuation round is left + # protected. existing_round = session.exec( select(ValuationRound).where( ValuationRound.entity_id == resolved_entity_id, ValuationRound.quarter_end == as_of, ) ).first() - if existing_round: - kind = "seed" if existing_round.is_seed else "valuation" - raise HTTPException( - status_code=409, - detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.", - ) + reused_round = False + seed_round: ValuationRound | None = None + if existing_round is not None: + if not existing_round.is_seed: + raise HTTPException( + status_code=409, + detail=f"A signed valuation round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.", + ) + seed_round = existing_round + reused_round = True # Commit: create holdings, positions, seed round holding_map: dict[str, Holding] = {} @@ -480,20 +477,22 @@ def import_schedule( session.flush() holding_map[name] = h - # Create seed round - seed_round = ValuationRound( - entity_id=resolved_entity_id, - quarter_end=as_of, - status=RoundStatus.approved, - is_seed=True, - approved_by=user.id, - approved_at=datetime.utcnow(), - ) - session.add(seed_round) - session.flush() + # Create the seed round, or reuse the existing one for this quarter (re-import). + if seed_round is None: + seed_round = ValuationRound( + entity_id=resolved_entity_id, + quarter_end=as_of, + status=RoundStatus.approved, + is_seed=True, + approved_by=user.id, + approved_at=datetime.utcnow(), + ) + session.add(seed_round) + session.flush() positions_created = 0 positions_updated = 0 + seen_position_ids: set[int] = set() for pp in positions_preview: company = pp["company_name"] holding = holding_map.get(company) @@ -541,17 +540,39 @@ def import_schedule( session.flush() positions_updated += 1 - # Attach valuation to seed round - val = Valuation( - round_id=seed_round.id, - position_id=pos.id, - value_cents=pp["value_cents"] or 0, - ) - session.add(val) + # Upsert this position's valuation in the round, so a re-import refreshes the value + # in place instead of adding a second one (which would double the quarter's NAV). + val = session.exec( + select(Valuation).where( + Valuation.round_id == seed_round.id, + Valuation.position_id == pos.id, + ) + ).first() + if val is None: + session.add(Valuation( + round_id=seed_round.id, + position_id=pos.id, + value_cents=pp["value_cents"] or 0, + )) + else: + val.value_cents = pp["value_cents"] or 0 + session.add(val) + seen_position_ids.add(pos.id) + + # On re-import, drop valuations for holdings no longer in the file so the quarter's NAV + # equals the new file's total (no leftovers from the prior import). + if reused_round: + stale_q = select(Valuation).where(Valuation.round_id == seed_round.id) + if seen_position_ids: + stale_q = stale_q.where(col(Valuation.position_id).not_in(seen_position_ids)) + for stale_val in session.exec(stale_q).all(): + session.delete(stale_val) record_audit(session, user.id, "import_schedule", "entity", resolved_entity_id, { "source_entity_name": source_entity_name, "entity_resolution": entity_resolution, + "replaced_existing": replace_existing, + "round_updated": reused_round, "holdings": len(holding_map), "positions_created": positions_created, "positions_updated": positions_updated, @@ -573,25 +594,35 @@ def import_schedule( "positions_created": positions_created, "positions_updated": positions_updated, "seed_round_id": seed_round.id, + "round_updated": reused_round, + "replaced_existing": replace_existing, "errors": errors, } def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list[dict]]: - """Legacy CSV parser fallback.""" + """Parse a plain holdings CSV (generic columns or an eNAV HLD export).""" content = file_bytes.decode("utf-8-sig") reader = csv.DictReader(io.StringIO(content)) CSV_COLUMN_MAP = { "Company": "company_name", "Investment": "company_name", + "Issuer": "company_name", "Security": "security_name", "Asset": "security_name", + "Security Name": "security_name", "Investment Date": "investment_date", "Investment date": "investment_date", "Shares": "shares", + "Quantity": "shares", "Cost": "cost_cents", + "Cost Basis - Book": "cost_cents", + "Cost Basis - Local": "cost_cents", "Value": "value_cents", + "Market Value (Book)": "value_cents", + "Market Value (Local)": "value_cents", + "Market Value - Book": "value_cents", } holdings_preview: list[dict] = [] @@ -608,11 +639,18 @@ def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list if k.strip().lower() == csv_col.lower(): val = v break - if val is not None: - mapped[model_field] = val.strip() + if val is not None and model_field not in mapped: + mapped[model_field] = (val or "").strip() company = mapped.get("company_name", "").strip() security = mapped.get("security_name", "").strip() + # Skip total / summary rows. + if security.upper().startswith("TOTAL") or company.upper().startswith("TOTAL"): + continue + # eNAV-style rows have only a security name; derive the issuer from its prefix. + if security and not company: + company = security.split(" - ")[0].strip() + mapped["company_name"] = company if company and not security: current_company = company diff --git a/backend/ten31portal/routers/position_router.py b/backend/ten31portal/routers/position_router.py index 66513e8..208cb60 100644 --- a/backend/ten31portal/routers/position_router.py +++ b/backend/ten31portal/routers/position_router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer +from ten31portal.auth import require_internal, require_writer from ten31portal.database import get_session from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate @@ -22,7 +22,7 @@ def _dollars_to_cents(dollars: float) -> int: @router.get("/api/holdings/{holding_id}/positions") def list_positions( holding_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[PositionResponse]: holding = session.get(Holding, holding_id) diff --git a/backend/ten31portal/routers/round_router.py b/backend/ten31portal/routers/round_router.py index 2d51350..b0fa25d 100644 --- a/backend/ten31portal/routers/round_router.py +++ b/backend/ten31portal/routers/round_router.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select, col from ten31portal.audit import record_audit -from ten31portal.auth import get_current_user, require_writer, require_approver +from ten31portal.auth import require_internal, require_writer, require_approver from ten31portal.database import get_session from ten31portal.models import ( Entity, Holding, Position, Valuation, ValuationRound, @@ -30,7 +30,7 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse: @router.get("/api/entities/{entity_id}/rounds") def list_rounds( entity_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> list[RoundResponse]: entity = session.get(Entity, entity_id) @@ -47,7 +47,7 @@ def list_rounds( @router.get("/api/rounds/{round_id}") def get_round( round_id: int, - user: User = Depends(get_current_user), + user: User = Depends(require_internal), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py new file mode 100644 index 0000000..9c9c89a --- /dev/null +++ b/backend/ten31portal/routers/user_router.py @@ -0,0 +1,375 @@ +"""User administration: create and manage accounts and their entity access.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select, col + +from ten31portal.audit import record_audit +from ten31portal.auth import ( + accessible_entity_ids, can_access_entity, get_current_user, hash_password, + household_user_ids, require_internal_admin, +) +from ten31portal.database import get_session +from ten31portal.models import ( + CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole, +) +from ten31portal.schemas import ( + AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse, + DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset, + UserCreate, UserDetailResponse, UserResponse, UserUpdate, +) + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +@router.get("/access-matrix") +def access_matrix( + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> AccessMatrixResponse: + """External accounts, all entities, and the grants linking them.""" + users = session.exec( + select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type] + ).all() + entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type] + grants = session.exec(select(EntityAccess)).all() + return AccessMatrixResponse( + users=[UserResponse.model_validate(u, from_attributes=True) for u in users], + entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities], + grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants], + ) + + +@router.put("/{user_id}/access/{entity_id}", status_code=200) +def grant_access( + user_id: int, + entity_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + existing = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id + ) + ).first() + if existing is None: + session.add(EntityAccess(user_id=user_id, entity_id=entity_id)) + record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id}) + session.commit() + return {"status": "ok"} + + +@router.delete("/{user_id}/access/{entity_id}") +def revoke_access( + user_id: int, + entity_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + existing = session.exec( + select(EntityAccess).where( + EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id + ) + ).first() + if existing is not None: + session.delete(existing) + record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id}) + session.commit() + return {"status": "ok"} + + +@router.get("/investors-for-entity/{entity_id}") +def investors_for_entity( + entity_id: int, + user: User = Depends(get_current_user), + session: Session = Depends(get_session), +) -> list[UserResponse]: + """Investor accounts with access to an entity. For internal staff and the entity's fund admins.""" + if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not ( + user.role == UserRole.fund_administrator + and can_access_entity(user, entity_id, session) + ): + raise HTTPException(status_code=403, detail="Insufficient permissions") + rows = session.exec( + select(User) + .join(EntityAccess, EntityAccess.user_id == User.id) + .where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor) + .order_by(User.name) # type: ignore[arg-type] + ).all() + return [UserResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.get("/{user_id}/investor-view") +def investor_view( + user_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> InvestorViewResponse: + """Reconstruct exactly what an investor sees in their portal — read-only, for admins. + + No session impersonation: this returns the same data the investor's own portal would load + (their accessible entities, their capital statements, and the documents visible to them), + scoped with the same access helpers. + """ + target = session.get(User, user_id) + if target is None: + raise HTTPException(status_code=404, detail="User not found") + if target.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Investor View is for investor accounts.") + + allowed = accessible_entity_ids(target, session) or set() + household = household_user_ids(target, session) + + entities = session.exec( + select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type] + ).all() if allowed else [] + + caps: list[CapitalAccountResponse] = [] + docs: list[DocumentResponse] = [] + if allowed: + cap_rows = session.exec( + select(CapitalAccountStatement) + .where( + col(CapitalAccountStatement.investor_user_id).in_(household), + col(CapitalAccountStatement.entity_id).in_(allowed), + ) + .order_by(col(CapitalAccountStatement.as_of_date).desc()) + ).all() + names = dict(session.exec( + select(User.id, User.name).where( + col(User.id).in_({r.investor_user_id for r in cap_rows}) + ) + ).all()) if cap_rows else {} + for r in cap_rows: + d = CapitalAccountResponse.model_validate(r, from_attributes=True) + d.investor_name = names.get(r.investor_user_id) + caps.append(d) + + doc_rows = session.exec( + select(Document) + .where(col(Document.entity_id).in_(allowed)) + .order_by(col(Document.created_at).desc()) + ).all() + # Investor sees shared docs and those addressed to any of their linked names. + docs = [ + DocumentResponse.model_validate(d, from_attributes=True) + for d in doc_rows + if d.investor_user_id is None or d.investor_user_id in household + ] + + return InvestorViewResponse( + user=UserResponse.model_validate(target, from_attributes=True), + entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities], + capital_accounts=caps, + documents=docs, + ) + + +def _entity_ids_for(user_id: int, session: Session) -> list[int]: + return list(session.exec( + select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id) + ).all()) + + +def _user_detail(user: User, session: Session) -> UserDetailResponse: + """Build a full user detail, including the linked-account relationships.""" + data = UserResponse.model_validate(user, from_attributes=True).model_dump() + primary_name = None + if user.primary_account_id: + primary = session.get(User, user.primary_account_id) + primary_name = primary.name if primary else None + linked = session.exec( + select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type] + ).all() + return UserDetailResponse( + **data, + primary_account_name=primary_name, + linked_accounts=[ + LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked + ], + entity_ids=_entity_ids_for(user.id, session), + ) + + +def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None: + """Replace a user's entity grants with the given set, ignoring unknown ids.""" + valid = set(session.exec( + select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr] + ).all()) if entity_ids else set() + existing = session.exec( + select(EntityAccess).where(EntityAccess.user_id == user_id) + ).all() + current = {a.entity_id: a for a in existing} + # Remove grants no longer wanted. + for eid, access in current.items(): + if eid not in valid: + session.delete(access) + # Add new grants. + for eid in valid: + if eid not in current: + session.add(EntityAccess(user_id=user_id, entity_id=eid)) + + +@router.get("") +def list_users( + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> list[UserResponse]: + rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type] + return [UserResponse.model_validate(r, from_attributes=True) for r in rows] + + +@router.get("/{user_id}") +def get_user( + user_id: int, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return _user_detail(user, session) + + +@router.post("", status_code=201) +def create_user( + body: UserCreate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + if session.exec(select(User).where(User.username == body.username)).first(): + raise HTTPException(status_code=409, detail="Username already taken") + if body.email and session.exec(select(User).where(User.email == body.email)).first(): + raise HTTPException(status_code=409, detail="Email already in use") + + user = User( + name=body.name, + username=body.username, + email=body.email or None, + password_hash=hash_password(body.password), + role=body.role, + ) + session.add(user) + session.flush() + _set_entity_access(user.id, body.entity_ids, session) + record_audit(session, admin.id, "create", "user", user.id, + {"username": body.username, "role": body.role.value, + "entity_ids": body.entity_ids}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.patch("/{user_id}") +def update_user( + user_id: int, + body: UserUpdate, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + changes = body.model_dump(exclude_unset=True) + entity_ids = changes.pop("entity_ids", None) + + if "username" in changes: + new_username = (changes["username"] or "").strip() + if not new_username: + raise HTTPException(status_code=400, detail="Username cannot be blank") + clash = session.exec(select(User).where(User.username == new_username)).first() + if clash and clash.id != user_id: + raise HTTPException(status_code=409, detail="Username already taken") + changes["username"] = new_username + + if "email" in changes and changes["email"]: + clash = session.exec(select(User).where(User.email == changes["email"])).first() + if clash and clash.id != user_id: + raise HTTPException(status_code=409, detail="Email already in use") + + for key, val in changes.items(): + setattr(user, key, val) + session.add(user) + session.flush() + + if entity_ids is not None: + _set_entity_access(user_id, entity_ids, session) + + record_audit(session, admin.id, "update", "user", user_id, + {**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.put("/{user_id}/primary-account") +def link_account( + user_id: int, + body: AccountLink, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> UserDetailResponse: + """Link an investor account to a primary login (or detach it when null). + + The primary becomes the single sign-on that sees every linked name's investments. The + linked account's own login is disabled so there is one set of credentials per person. + """ + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + if user.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Only investor accounts can be linked.") + + primary_id = body.primary_account_id + if primary_id is not None: + if primary_id == user_id: + raise HTTPException(status_code=400, detail="An account cannot link to itself.") + primary = session.get(User, primary_id) + if primary is None or primary.role != UserRole.investor: + raise HTTPException(status_code=400, detail="Primary must be an investor account.") + if primary.primary_account_id is not None: + raise HTTPException( + status_code=400, + detail="That account is itself linked to another login. Link to a primary instead.", + ) + # Prevent chains: an account that other names log in under can't become a secondary. + if session.exec(select(User).where(User.primary_account_id == user_id)).first(): + raise HTTPException( + status_code=400, + detail="This account is a primary for other names. Detach those first.", + ) + # Login is blocked while primary_account_id is set (see auth_router.login), so we leave + # login_enabled untouched — unlinking then restores the account's own sign-in cleanly. + user.primary_account_id = primary_id + else: + user.primary_account_id = None + + session.add(user) + record_audit(session, admin.id, "link_account", "user", user_id, + {"primary_account_id": primary_id}) + session.commit() + session.refresh(user) + return _user_detail(user, session) + + +@router.post("/{user_id}/reset-password") +def reset_password( + user_id: int, + body: PasswordReset, + admin: User = Depends(require_internal_admin), + session: Session = Depends(get_session), +) -> dict[str, str]: + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + user.password_hash = hash_password(body.password) + user.login_enabled = True # setting a password enables login + session.add(user) + record_audit(session, admin.id, "reset_password", "user", user_id, None) + session.commit() + return {"status": "ok"} diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index a5a58c9..1c2de97 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -5,25 +5,85 @@ from typing import Optional from pydantic import BaseModel -from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus +from ten31portal.models import ( + UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory, +) # --- Auth --- class LoginRequest(BaseModel): - email: str + login: str # username or email password: str class UserResponse(BaseModel): id: int name: str - email: str + username: str + email: str | None role: UserRole is_active: bool + is_service_admin: bool = False + primary_account_id: int | None = None # set when this account logs in under another created_at: datetime +# --- User administration --- + +class UserCreate(BaseModel): + name: str + username: str + password: str + role: UserRole + email: str | None = None + entity_ids: list[int] = [] + + +class UserUpdate(BaseModel): + name: str | None = None + username: str | None = None + email: str | None = None + role: UserRole | None = None + is_active: bool | None = None + entity_ids: list[int] | None = None # full replacement of grants when provided + + +class PasswordReset(BaseModel): + password: str + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + +class LinkedAccount(BaseModel): + id: int + name: str + username: str + + +class UserDetailResponse(BaseModel): + id: int + name: str + username: str + email: str | None + role: UserRole + is_active: bool + is_service_admin: bool = False + primary_account_id: int | None = None + primary_account_name: str | None = None # the login this account is linked under, if any + linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account + created_at: datetime + entity_ids: list[int] = [] + + +class AccountLink(BaseModel): + # null detaches the account so it logs in on its own again + primary_account_id: int | None = None + + # --- Entity --- class EntityCreate(BaseModel): @@ -136,6 +196,157 @@ class RoundResponse(BaseModel): valuations: list[ValuationResponse] = [] +# --- Document --- + +class DocumentResponse(BaseModel): + id: int + entity_id: int + investor_user_id: int | None + category: DocumentCategory + title: str + original_filename: str + content_type: str + size_bytes: int + uploaded_by: int | None + created_at: datetime + + +# --- Capital account --- + +class CapitalAccountCreate(BaseModel): + entity_id: int + investor_user_id: int + as_of_date: date + commitment_dollars: float = 0 + beginning_balance_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + ending_balance_dollars: float = 0 + document_id: int | None = None + + +class CapitalAccountResponse(BaseModel): + id: int + entity_id: int + investor_user_id: int + investor_name: str | None = None # legal name of the account this statement belongs to + as_of_date: date + commitment_cents: int + beginning_balance_cents: int + contributions_cents: int + distributions_cents: int + ending_balance_cents: int + document_id: int | None + created_at: datetime + + +# --- Partners (members of an entity) --- + +class PartnerResponse(BaseModel): + user_id: int + name: str + username: str + external_investor_id: str | None + is_active: bool + login_enabled: bool + latest_commitment_cents: int | None = None + latest_contributions_cents: int | None = None + latest_distributions_cents: int | None = None + latest_value_cents: int | None + latest_as_of: date | None + statements_count: int + + +# --- Access matrix --- + +class AccessGrant(BaseModel): + user_id: int + entity_id: int + + +class AccessMatrixResponse(BaseModel): + users: list[UserResponse] + entities: list[EntityResponse] + grants: list[AccessGrant] + + +# --- Capital account import (review-and-confirm) --- + +class ImportInvestorPreview(BaseModel): + source_name: str # name as it appears in the spreadsheet + column_index: int # 0-based column it was read from + value_dollars: float # current capital value (ending balance) + commitment_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + external_id: str | None = None # fund-admin INVESTOR ID, when present + matched_user_id: int | None = None + matched_username: str | None = None + suggested_username: str | None = None # for unmatched: a safe default + + +class ImportValueRow(BaseModel): + row_index: int # 0-based sheet row + label: str # col A label (e.g. "LTPF1") + + +class CapitalImportPreview(BaseModel): + as_of_date: date | None + value_rows: list[ImportValueRow] # candidate rows holding per-investor balances + chosen_row_index: int # the row used for the values below + investors: list[ImportInvestorPreview] + + +class ImportCommitInvestor(BaseModel): + action: str # "match" | "create" | "skip" + value_dollars: float # current capital value (ending balance) + commitment_dollars: float = 0 + contributions_dollars: float = 0 + distributions_dollars: float = 0 + user_id: int | None = None # for action=match + 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 + external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching + + +class CapitalImportCommit(BaseModel): + entity_id: int + as_of_date: date + investors: list[ImportCommitInvestor] + + +# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) --- + +class EntityStakeCreate(BaseModel): + fund_entity_id: int + ownership_pct: float | None = None + value_dollars: float | None = None + note: str | None = None + + +class EntityStakeResponse(BaseModel): + id: int + holder_entity_id: int + fund_entity_id: int + fund_name: str | None = None + fund_type: EntityType | None = None + ownership_pct: float | None + value_cents: int | None + note: str | None + created_at: datetime + + +# --- Investor View (admin reconstruction of what one investor sees) --- + +class InvestorViewResponse(BaseModel): + user: UserResponse + entities: list[EntityResponse] = [] + capital_accounts: list[CapitalAccountResponse] = [] + documents: list[DocumentResponse] = [] + + # --- Audit --- class AuditLogResponse(BaseModel): diff --git a/backend/tests/test_investor_view.py b/backend/tests/test_investor_view.py new file mode 100644 index 0000000..a4dc9c5 --- /dev/null +++ b/backend/tests/test_investor_view.py @@ -0,0 +1,40 @@ +"""Admin Investor View reconstructs what one investor sees, read-only.""" + +from datetime import date + +from tests.conftest import make_user +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole, +) + + +def test_investor_view_reconstructs(auth_client, session): + inv = make_user(session, username="lp1", role=UserRole.investor) + ent = Entity(name="LTPF I", type=EntityType.fund) + other = Entity(name="Not Theirs", type=EntityType.fund) + session.add(ent) + session.add(other) + session.commit() + session.refresh(ent) + session.refresh(other) + + session.add(EntityAccess(user_id=inv.id, entity_id=ent.id)) + session.add(CapitalAccountStatement( + entity_id=ent.id, investor_user_id=inv.id, as_of_date=date(2026, 3, 31), + commitment_cents=500_000, ending_balance_cents=600_000, + )) + session.commit() + + resp = auth_client.get(f"/api/users/{inv.id}/investor-view") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["user"]["username"] == "lp1" + # Only the granted entity is visible, not the other one. + assert [e["id"] for e in body["entities"]] == [ent.id] + assert len(body["capital_accounts"]) == 1 + assert body["capital_accounts"][0]["ending_balance_cents"] == 600_000 + + +def test_investor_view_rejects_non_investor(auth_client, session): + staff = make_user(session, username="ops", role=UserRole.operations) + assert auth_client.get(f"/api/users/{staff.id}/investor-view").status_code == 400 diff --git a/backend/tests/test_stakes.py b/backend/tests/test_stakes.py new file mode 100644 index 0000000..9ccfa24 --- /dev/null +++ b/backend/tests/test_stakes.py @@ -0,0 +1,41 @@ +"""Entity stakes: a GP entity's interest in the funds it manages.""" + +from ten31portal.models import Entity, EntityType + + +def test_stake_crud(auth_client, session): + gp = Entity(name="Ten31 LLC", type=EntityType.gp) + fund = Entity(name="LTPF I", type=EntityType.fund) + session.add(gp) + session.add(fund) + session.commit() + session.refresh(gp) + session.refresh(fund) + + created = auth_client.post( + f"/api/entities/{gp.id}/stakes", + json={"fund_entity_id": fund.id, "ownership_pct": 20, "value_dollars": 1000}, + ) + assert created.status_code == 201, created.text + body = created.json() + assert body["fund_name"] == "LTPF I" + assert body["fund_type"] == "fund" + assert body["value_cents"] == 100_000 # dollars -> cents + stake_id = body["id"] + + listed = auth_client.get(f"/api/entities/{gp.id}/stakes") + assert listed.status_code == 200 + assert len(listed.json()) == 1 + + # An entity cannot hold a stake in itself. + assert auth_client.post( + f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": gp.id} + ).status_code == 400 + + # Duplicate stake rejected. + assert auth_client.post( + f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": fund.id} + ).status_code == 409 + + assert auth_client.delete(f"/api/entities/{gp.id}/stakes/{stake_id}").status_code == 200 + assert auth_client.get(f"/api/entities/{gp.id}/stakes").json() == [] diff --git a/deploy/Dockerfile b/deploy/Dockerfile index d8f475b..f99ae05 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,10 @@ # Build context is the repo root (docker build -f deploy/Dockerfile .) # --- Frontend build stage --- -FROM node:20-slim AS frontend-build +# Build on the native builder arch ($BUILDPLATFORM) so the JS bundler runs without +# cross-arch emulation. The output is static assets (HTML/CSS/JS), which are +# architecture-independent and copied into the target-arch runtime stage below. +FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build WORKDIR /build COPY frontend/package.json frontend/package-lock.json ./ @@ -32,7 +35,8 @@ RUN chmod +x ./start.sh RUN mkdir -p /data ENV TEN31_DB_PATH=/data/portal.db -ENV TEN31_SESSION_SECRET=*** +ENV TEN31_DOCS_DIR=/data/documents +ENV TEN31_SESSION_SECRET=change-me EXPOSE 8000 diff --git a/deploy/LICENSE b/deploy/LICENSE new file mode 100644 index 0000000..5fbef5c --- /dev/null +++ b/deploy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ten31 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/deploy/assets/.gitkeep b/deploy/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/icon.png b/deploy/icon.png index 396d542..3f7d2f2 100644 Binary files a/deploy/icon.png and b/deploy/icon.png differ diff --git a/deploy/instructions.md b/deploy/instructions.md new file mode 100644 index 0000000..d0e9dd8 --- /dev/null +++ b/deploy/instructions.md @@ -0,0 +1,39 @@ +# Ten31Portal + +Internal system of record for Ten31 entities, holdings, positions, and quarterly +valuation sign-off, plus an investor / fund-administrator portal. + +## First login + +On first boot a default administrator (approver) account is created. Unless you set +the environment variables below, the defaults are: + +- **Username:** `admin` +- **Password:** `Ten31` + +Open the web interface and sign in. Change this password immediately from the Users +screen, or create a new admin and disable the default one. + +The login field accepts a username **or** an email address. + +## Accounts and access + +- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) use the back-office + app: entities, holdings, positions, valuation rounds, import, and audit log. + Approvers and the CFO also get the admin screens below. +- **External accounts** (`investor`, `fund_administrator`) get an entity-scoped + portal and only ever see the entities granted to them. + - **Investors** see, per fund, their latest capital-account value and history, plus + documents shared to the fund or addressed privately to them (e.g. their K-1). + - **Fund administrators** see their assigned entities and can upload documents. + +From the **Users** screen, create an account with a username and password and check +off which entities it can view. Use **Documents** to upload statements and K-1s +(shared to a fund or private to one investor), and **Capital Accounts** to enter each +investor's figures. + +## Data and backups + +All data — the database, uploaded documents, and the generated session secret — lives +on the service's data volume and is included in StartOS platform backups. Create a +backup before uninstalling; uninstalling removes all fund data. diff --git a/deploy/package-lock.json b/deploy/package-lock.json index 66005fd..4b6589d 100644 --- a/deploy/package-lock.json +++ b/deploy/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten31portal-startos", - "version": "0.1.0", + "version": "0.2.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten31portal-startos", - "version": "0.1.0", + "version": "0.2.21", "dependencies": { "@start9labs/start-sdk": "^0.4.0-beta.58" }, diff --git a/deploy/package.json b/deploy/package.json index 886dbfe..ed68705 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.21", + "version": "0.2.22", "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 9057760..2d80130 100644 --- a/deploy/startos/actions/index.ts +++ b/deploy/startos/actions/index.ts @@ -2,6 +2,29 @@ import { sdk } from '../sdk' const { InputSpec, Value, Action } = sdk +// Run a ten31portal CLI subcommand inside the service container against the data volume. +async function runCli(effects: any, args: string[], taskName: string) { + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + taskName, + ) + return sub.exec( + ['python3', '-m', 'ten31portal.cli', ...args], + { env: { TEN31_DB_PATH: '/data/portal.db' } }, + 30000, + ) +} + +const errorResult = (message: string) => + ({ version: '1' as const, title: 'Error', message, result: null }) + // ============================================ // Action: Create User // ============================================ @@ -26,6 +49,13 @@ const createUserInputSpec = InputSpec.of({ }, ], }), + username: Value.text({ + name: 'Username', + description: 'Login username', + default: '', + required: true, + placeholder: 'jsmith', + }), password: Value.text({ name: 'Password', description: 'Initial password (user should change after first login)', @@ -36,13 +66,12 @@ const createUserInputSpec = InputSpec.of({ role: Value.select({ name: 'Role', description: - 'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.', - default: 'viewer', + 'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.', + default: 'operations', values: { - approver: 'Approver', - cfo: 'CFO', + approver: 'Managing Partner', + operations: 'Operations', fund_admin: 'Fund Admin', - viewer: 'Viewer', }, }), }) @@ -51,7 +80,7 @@ const createUserAction = Action.withInput( 'create-user', { name: 'Create User', - description: 'Add a new user account with a role', + description: 'Add a new staff user account with a role', warning: null, allowedStatuses: 'only-running', group: null, @@ -61,69 +90,297 @@ const createUserAction = Action.withInput( async () => ({ name: '', email: '', + username: '', password: '', - role: 'viewer' as const, + role: 'operations' as const, }), async ({ input, effects }) => { try { - const sub = await sdk.SubContainer.of( + const result = await runCli( effects, - { imageId: 'main' }, - sdk.Mounts.of().mountVolume({ - volumeId: 'main', - subpath: null, - mountpoint: '/data', - readonly: false, - }), + [ + 'create-user', + '--name', input.name, + '--username', input.username, + '--email', input.email, + '--role', input.role, + '--password', input.password, + ], 'create-user-task', ) - - const result = await sub.exec( - [ - 'python3', - '-m', - 'ten31portal.cli', - 'create-user', - '--name', - input.name, - '--email', - input.email, - '--role', - input.role, - '--password', - input.password, - ], - { - env: { TEN31_DB_PATH: '/data/portal.db' }, - }, - 30000, - ) - if (result.exitCode !== 0) { - const stderr = result.stderr?.toString() || 'Unknown error' - return { - version: '1' as const, - title: 'Error', - message: `Failed to create user: ${stderr}`, - result: null, - } + return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`) } - return { version: '1' as const, title: 'User Created', - message: `Created user ${input.name} (${input.email}) with role: ${input.role}`, + message: `Created ${input.name} (${input.username}) with role: ${input.role}`, result: null, } } catch (e: any) { - return { - version: '1' as const, - title: 'Error', - message: `Failed to create user: ${e.message || e}`, - result: null, - } + return errorResult(`Failed to create user: ${e.message || e}`) } }, ) -export const actions = sdk.Actions.of().addAction(createUserAction) +// ============================================ +// Action: Reset Password +// ============================================ +const resetPasswordInputSpec = InputSpec.of({ + username: Value.text({ + name: 'Username', + description: 'Username of the account to reset (e.g. the Admin / Service Admin)', + default: '', + required: true, + placeholder: 'admin', + }), + password: Value.text({ + name: 'New Password', + description: 'The new password for this account', + default: '', + required: true, + placeholder: 'minimum 4 characters', + }), +}) + +const resetPasswordAction = Action.withInput( + 'reset-password', + { + name: 'Reset Password', + description: "Reset any user's password (including the Admin / Service Admin)", + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + resetPasswordInputSpec, + async () => ({ username: '', password: '' }), + async ({ input, effects }) => { + try { + const result = await runCli( + effects, + ['reset-password', '--username', input.username, '--password', input.password], + 'reset-password-task', + ) + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to reset password') + } + return { + version: '1' as const, + title: 'Password Reset', + message: `Password reset for ${input.username}. Their login is enabled.`, + result: null, + } + } catch (e: any) { + return errorResult(`Failed to reset password: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: List Users +// ============================================ +const listUsersAction = Action.withoutInput( + 'list-users', + { + name: 'List Users', + description: 'Show every user account and role', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + async ({ effects }) => { + try { + const result = await runCli(effects, ['list-users'], 'list-users-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to list users') + } + return { + version: '1' as const, + title: 'Users', + message: 'The Service Admin cannot be deleted.', + result: { + type: 'single' as const, + value: result.stdout?.toString() || 'No users.', + copyable: true, + qr: false, + masked: false, + }, + } + } catch (e: any) { + return errorResult(`Failed to list users: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: Delete User +// ============================================ +const deleteUserInputSpec = InputSpec.of({ + username: Value.text({ + name: 'Username', + description: 'Username of the account to delete (use List Users to see them)', + default: '', + required: true, + placeholder: 'jsmith', + }), +}) + +const deleteUserAction = Action.withInput( + 'delete-user', + { + name: 'Delete User', + description: 'Permanently delete a user account', + warning: + 'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.', + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + deleteUserInputSpec, + async () => ({ username: '' }), + async ({ input, effects }) => { + try { + const result = await runCli( + effects, + ['delete-user', '--username', input.username], + 'delete-user-task', + ) + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to delete user') + } + return { + version: '1' as const, + title: 'User Deleted', + message: result.stdout?.toString() || `Deleted ${input.username}.`, + result: null, + } + } catch (e: any) { + return errorResult(`Failed to delete user: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: Fix Duplicate Holdings +// ============================================ +const dedupeAction = Action.withoutInput( + 'dedupe-holdings', + { + name: 'Fix Duplicate Holdings', + description: + 'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + async ({ effects }) => { + try { + const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates') + } + return { + version: '1' as const, + title: 'Duplicates Cleaned Up', + message: result.stdout?.toString() || 'Done.', + result: null, + } + } catch (e: any) { + return errorResult(`Failed to clean up duplicates: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: List Funds +// ============================================ +const listFundsAction = Action.withoutInput( + 'list-funds', + { + name: 'List Funds', + description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + async ({ effects }) => { + try { + const result = await runCli(effects, ['list-funds'], 'list-funds-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to list funds') + } + return { + version: '1' as const, + title: 'Funds', + message: null, + result: { + type: 'single' as const, + value: result.stdout?.toString() || 'No funds.', + copyable: true, + qr: false, + masked: false, + }, + } + } catch (e: any) { + return errorResult(`Failed to list funds: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: Reset Fund Holdings +// ============================================ +const resetHoldingsInputSpec = InputSpec.of({ + name: Value.text({ + name: 'Fund Name', + description: 'Exact name of the fund to clear (see List Funds)', + default: '', + required: true, + placeholder: 'Low Time Preference Fund III, LP', + }), +}) + +const resetHoldingsAction = Action.withInput( + 'reset-holdings', + { + name: 'Reset Fund Holdings', + description: + 'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.', + warning: + 'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.', + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + resetHoldingsInputSpec, + async () => ({ name: '' }), + async ({ input, effects }) => { + try { + const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to reset fund') + } + return { + version: '1' as const, + title: 'Fund Cleared', + message: result.stdout?.toString() || `Cleared ${input.name}.`, + result: null, + } + } catch (e: any) { + return errorResult(`Failed to reset fund: ${e.message || e}`) + } + }, +) + +export const actions = sdk.Actions.of() + .addAction(createUserAction) + .addAction(resetPasswordAction) + .addAction(listUsersAction) + .addAction(deleteUserAction) + .addAction(dedupeAction) + .addAction(listFundsAction) + .addAction(resetHoldingsAction) diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 4059bcf..4ec7624 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_21 as current } from './v_0_2_21' +export { v_0_2_22 as current } from './v_0_2_22' 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' @@ -20,4 +20,5 @@ import { v_0_2_17 } from './v_0_2_17' import { v_0_2_18 } from './v_0_2_18' import { v_0_2_19 } from './v_0_2_19' import { v_0_2_20 } from './v_0_2_20' -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] +import { v_0_2_21 } from './v_0_2_21' +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] diff --git a/deploy/startos/install/versions/v_0_2_22.ts b/deploy/startos/install/versions/v_0_2_22.ts new file mode 100644 index 0000000..33ed407 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_22.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_22 = VersionInfo.of({ + version: '0.2.22:0', + releaseNotes: { + en_US: + 'Investors see a graph of their capital over time (value, paid-in, and distributions per quarter). A new admin Investor View shows exactly what an investor sees, read-only. Document uploads are scoped to the chosen fund\'s own investors with a clear target, so a file cannot go to the wrong person. GP and management-company entities get an Assets tab that lists their interests in the funds they manage. You can now edit an existing entity, including its type.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/main.ts b/deploy/startos/main.ts index 9957a44..aa1a0b2 100644 --- a/deploy/startos/main.ts +++ b/deploy/startos/main.ts @@ -23,6 +23,7 @@ export const main = sdk.setupMain(async ({ effects }) => { command: ['sh', '-c', '/app/start.sh'], env: { TEN31_DB_PATH: '/data/portal.db', + TEN31_DOCS_DIR: '/data/documents', TEN31_SESSION_SECRET: process.env.TEN31_SESSION_SECRET || 'change-me', }, }, diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..afaf458 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,16 @@
- + + + -Your password has been updated.
+{error}
} +