Files
Ten31-Portal/backend/ten31portal/models.py
T
Jonathan KirkwoodandClaude Fable 5 0822eca887 0.2.38: optional two-factor authentication (authenticator-app TOTP)
Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:08:04 +02:00

247 lines
9.9 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"
carry = "carry"
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))
# When this investor last loaded their documents list — docs newer than this get a "New"
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
docs_seen_at: datetime | None = Field(default=None)
# Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only
# counts once totp_enabled is True (enrollment is confirmed with a first valid code).
totp_secret: str | None = Field(default=None)
totp_enabled: bool = Field(default=False)
# JSON list of sha256 hex digests of unused one-time recovery codes.
totp_recovery_codes: str | None = Field(default=None)
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)
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
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)
# Set when this member sold/transferred their stake (e.g. a secondary sale): the fund's
# books show a $0 balance with no distribution, which is NOT a loss. An exited position
# shows a badge instead of gain/loss, keeps its documents, and drops out of totals.
exited_on: date | None = Field(default=None)
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)