Files
Ten31-Portal/backend/ten31portal/models.py
T
Jonathan Kirkwood f0f8fd15c6 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.
2026-07-01 14:25:50 -05:00

230 lines
8.6 KiB
Python

"""SQLModel table definitions for Ten31Portal."""
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
# --- Enums ---
class UserRole(str, enum.Enum):
# Internal staff
approver = "approver" # "Managing Partner" — full access incl. valuation sign-off
operations = "operations" # full access except final sign-off
cfo = "cfo"
fund_admin = "fund_admin"
viewer = "viewer"
# External accounts (entity-scoped via EntityAccess)
investor = "investor"
fund_administrator = "fund_administrator"
# External roles see only the entities granted to them.
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator)
class DocumentCategory(str, enum.Enum):
capital_account = "capital_account"
k1 = "k1"
statement = "statement"
tax = "tax"
other = "other"
class EntityType(str, enum.Enum):
fund = "fund"
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
class EntityStatus(str, enum.Enum):
active = "active"
closed = "closed"
class RoundStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
approved = "approved"
returned = "returned"
# --- Tables ---
class User(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
username: str = Field(sa_column=Column(String, unique=True, nullable=False))
email: str | None = Field(default=None, sa_column=Column(String, unique=True, nullable=True))
password_hash: str
role: UserRole
is_active: bool = Field(default=True)
# The built-in Service Admin (bootstrap account). Can be reset but never deleted.
is_service_admin: bool = Field(default=False)
# False for members imported without a password; set True when an admin sets one.
login_enabled: bool = Field(default=True)
# When an investor invests under several legal names (one per Partner/vehicle), each name
# is its own account. Linking the secondary accounts to one "primary" lets that person sign
# in once and see every name's investments. Null = this account logs in on its own.
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
created_at: datetime = Field(default_factory=datetime.utcnow)
class Entity(SQLModel, table=True):
__tablename__ = "entities"
id: int | None = Field(default=None, primary_key=True)
name: str
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus = Field(default=EntityStatus.active)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Holding(SQLModel, table=True):
__tablename__ = "holdings"
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id", index=True)
company_name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class Position(SQLModel, table=True):
__tablename__ = "positions"
id: int | None = Field(default=None, primary_key=True)
holding_id: int = Field(foreign_key="holdings.id", index=True)
security_name: str
investment_date: date
shares: str | None = None # Decimal stored as string
cost_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class ValuationRound(SQLModel, table=True):
__tablename__ = "valuation_rounds"
__table_args__ = (
UniqueConstraint("entity_id", "quarter_end", name="uq_round_entity_quarter"),
)
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
quarter_end: date
status: RoundStatus = Field(default=RoundStatus.draft)
submitted_by: int | None = Field(default=None, foreign_key="users.id", index=True)
submitted_at: datetime | None = None
approved_by: int | None = Field(default=None, foreign_key="users.id", index=True)
approved_at: datetime | None = None
return_note: str | None = None
is_seed: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Valuation(SQLModel, table=True):
__tablename__ = "valuations"
__table_args__ = (
UniqueConstraint("round_id", "position_id", name="uq_valuation_round_position"),
)
id: int | None = Field(default=None, primary_key=True)
round_id: int = Field(foreign_key="valuation_rounds.id")
position_id: int = Field(foreign_key="positions.id", index=True)
value_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class AuditLog(SQLModel, table=True):
__tablename__ = "audit_log"
id: int | None = Field(default=None, primary_key=True)
actor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
action: str
object_type: str
object_id: int | None = None
# An action-specific JSON payload describing the change (stored in a JSON column). Most
# callers pass a dict of changed fields (e.g. an entity update); some pass an identifying
# field on delete, and some pass None. Matches AuditLogResponse.detail in schemas.py.
detail: dict | list | str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
created_at: datetime = Field(default_factory=datetime.utcnow)
class EntityAccess(SQLModel, table=True):
"""Which entities an external account may view."""
__tablename__ = "entity_access"
__table_args__ = (
UniqueConstraint("user_id", "entity_id", name="uq_access_user_entity"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id")
entity_id: int = Field(foreign_key="entities.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Document(SQLModel, table=True):
"""An uploaded file. Shared to a fund (investor_user_id null) or private to one investor."""
__tablename__ = "documents"
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id", index=True)
investor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
category: DocumentCategory = Field(default=DocumentCategory.other)
title: str
original_filename: str
content_type: str
size_bytes: int
storage_path: str # opaque filename on the data volume, relative to DOCS_DIR
uploaded_by: int | None = Field(default=None, foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class CapitalAccountStatement(SQLModel, table=True):
"""An investor's capital-account figures for one fund as of a date."""
__tablename__ = "capital_account_statements"
__table_args__ = (
UniqueConstraint("entity_id", "investor_user_id", "as_of_date",
name="uq_capacct_entity_investor_date"),
)
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
investor_user_id: int = Field(foreign_key="users.id", index=True)
as_of_date: date
commitment_cents: int = 0 # initial capital commitment
beginning_balance_cents: int = 0
contributions_cents: int = 0 # paid-in capital
distributions_cents: int = 0 # capital returned (for DPI)
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)