Files
Ten31-Portal/backend/ten31portal/models.py
T
Jonathan KirkwoodandClaude Fable 5 eac3262f29 0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center
- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:01:50 +02:00

269 lines
11 KiB
Python

"""SQLModel table definitions for Ten31Portal."""
import enum
from datetime import date, datetime
from datetime import date as _date # for fields literally named "date"
from decimal import Decimal
from typing import Optional
from sqlmodel import Field, SQLModel, Column, Date, 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)
# True while the account is on the shared default password — the portal forces a
# password change before anything else. Cleared by change-password / admin reset.
must_change_password: bool = Field(default=False)
# When the investor finished (or skipped) the first-login welcome flow. Null = show it.
onboarded_at: datetime | 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)
# Final close of the fund/SPV — the BTC entry mark: paid-in capital is valued at the BTC
# price on this date for the bitcoin-denominated view. Null = no BTC view for this fund.
close_date: date | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
class BtcPrice(SQLModel, table=True):
"""Daily (or as-uploaded) BTC/USD closing prices from the admin's CSV.
Statements are valued at the newest price on or before their as-of date, so the CSV
doesn't need every calendar day — quarter-end rows are enough."""
__tablename__ = "btc_prices"
id: int | None = Field(default=None, primary_key=True)
date: _date = Field(sa_column=Column(Date, unique=True, nullable=False))
price_cents: int
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)