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:
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user