Release 0.2.22: capital chart, Investor View, GP stakes, doc folders

Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.

New features:
- Investor capital-over-time chart (value, paid-in, distributions per
  quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
  (GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
  explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
  they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
  categorized correctly.

Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
@@ -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')
+48 -5
View File
@@ -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)
+34 -2
View File
@@ -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:
+20
View File
@@ -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)
+31 -3
View File
@@ -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"}
@@ -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"}
@@ -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,
}
+173 -5
View File
@@ -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"}
@@ -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)
+357 -319
View File
@@ -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
@@ -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)
+3 -3
View File
@@ -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)
+375
View File
@@ -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"}
+214 -3
View File
@@ -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):
+40
View File
@@ -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
+41
View File
@@ -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() == []