Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
distributions-line gate
- 0.2.32: LP-facing polish pass
* Ten31 brand palette from the logo (navy/mint); orange retired
* portfolio summary card across funds; gain labeled "net of paid-in"
* whole-dollar headline figures; "History · N quarters" toggle
* documents grouped by year with a "New" badge (users.docs_seen_at)
* eNAV-created members start on default password with login enabled;
enable-investor-logins CLI + StartOS action for existing accounts
* password minimum raised to 8 chars; login help line (Portal@ten31.xyz)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
390 lines
9.7 KiB
Python
390 lines
9.7 KiB
Python
"""Pydantic schemas for API request/response shapes."""
|
|
|
|
from datetime import date, datetime
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from ten31portal.models import (
|
|
UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory,
|
|
)
|
|
|
|
|
|
# --- Auth ---
|
|
|
|
class LoginRequest(BaseModel):
|
|
login: str # username or email
|
|
password: str
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
username: str
|
|
email: str | None
|
|
role: UserRole
|
|
is_active: bool
|
|
is_service_admin: bool = False
|
|
primary_account_id: int | None = None # set when this account logs in under another
|
|
created_at: datetime
|
|
|
|
|
|
# --- User administration ---
|
|
|
|
class UserCreate(BaseModel):
|
|
name: str
|
|
username: str
|
|
password: str
|
|
role: UserRole
|
|
email: str | None = None
|
|
entity_ids: list[int] = []
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
name: str | None = None
|
|
username: str | None = None
|
|
email: str | None = None
|
|
role: UserRole | None = None
|
|
is_active: bool | None = None
|
|
entity_ids: list[int] | None = None # full replacement of grants when provided
|
|
|
|
|
|
class PasswordReset(BaseModel):
|
|
password: str
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class LinkedAccount(BaseModel):
|
|
id: int
|
|
name: str
|
|
username: str
|
|
|
|
|
|
class UserDetailResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
username: str
|
|
email: str | None
|
|
role: UserRole
|
|
is_active: bool
|
|
is_service_admin: bool = False
|
|
primary_account_id: int | None = None
|
|
primary_account_name: str | None = None # the login this account is linked under, if any
|
|
linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account
|
|
created_at: datetime
|
|
entity_ids: list[int] = []
|
|
|
|
|
|
class AccountLink(BaseModel):
|
|
# null detaches the account so it logs in on its own again
|
|
primary_account_id: int | None = None
|
|
|
|
|
|
# --- Entity ---
|
|
|
|
class EntityCreate(BaseModel):
|
|
name: str
|
|
type: EntityType
|
|
vintage_year: int | None = None
|
|
fund_size_cents: int | None = None
|
|
linked_user_id: int | None = None
|
|
|
|
|
|
class EntityUpdate(BaseModel):
|
|
name: str | None = None
|
|
type: EntityType | None = None
|
|
vintage_year: int | None = None
|
|
fund_size_cents: int | None = None
|
|
status: EntityStatus | None = None
|
|
linked_user_id: int | None = None
|
|
|
|
|
|
class EntityResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
type: EntityType
|
|
vintage_year: int | None
|
|
fund_size_cents: int | None
|
|
status: EntityStatus
|
|
linked_user_id: int | None = None
|
|
created_at: datetime
|
|
|
|
|
|
# --- Holding ---
|
|
|
|
class HoldingCreate(BaseModel):
|
|
company_name: str
|
|
|
|
|
|
class HoldingUpdate(BaseModel):
|
|
company_name: str | None = None
|
|
|
|
|
|
class HoldingResponse(BaseModel):
|
|
id: int
|
|
entity_id: int
|
|
company_name: str
|
|
created_at: datetime
|
|
|
|
|
|
# --- Position ---
|
|
|
|
class PositionCreate(BaseModel):
|
|
security_name: str
|
|
investment_date: date
|
|
shares: str | None = None
|
|
cost_dollars: float # Accept dollars at API boundary, store as cents
|
|
|
|
|
|
class PositionUpdate(BaseModel):
|
|
security_name: str | None = None
|
|
investment_date: date | None = None
|
|
shares: str | None = None
|
|
cost_dollars: float | None = None
|
|
|
|
|
|
class PositionResponse(BaseModel):
|
|
id: int
|
|
holding_id: int
|
|
security_name: str
|
|
investment_date: date
|
|
shares: str | None
|
|
cost_cents: int
|
|
created_at: datetime
|
|
|
|
|
|
# --- Valuation Round ---
|
|
|
|
class RoundCreate(BaseModel):
|
|
quarter_end: date
|
|
|
|
|
|
class ValuationBulkItem(BaseModel):
|
|
position_id: int
|
|
value_cents: int
|
|
|
|
|
|
class ValuationBulkUpdate(BaseModel):
|
|
valuations: list[ValuationBulkItem]
|
|
|
|
|
|
class ReturnNote(BaseModel):
|
|
note: str
|
|
|
|
|
|
class ValuationResponse(BaseModel):
|
|
id: int
|
|
round_id: int
|
|
position_id: int
|
|
value_cents: int
|
|
created_at: datetime
|
|
|
|
|
|
class RoundResponse(BaseModel):
|
|
id: int
|
|
entity_id: int
|
|
quarter_end: date
|
|
status: RoundStatus
|
|
submitted_by: int | None
|
|
submitted_at: datetime | None
|
|
approved_by: int | None
|
|
approved_at: datetime | None
|
|
return_note: str | None
|
|
is_seed: bool
|
|
created_at: datetime
|
|
valuations: list[ValuationResponse] = []
|
|
|
|
|
|
# --- Document ---
|
|
|
|
class DocumentResponse(BaseModel):
|
|
id: int
|
|
entity_id: int
|
|
investor_user_id: int | None
|
|
category: DocumentCategory
|
|
title: str
|
|
original_filename: str
|
|
content_type: str
|
|
size_bytes: int
|
|
uploaded_by: int | None
|
|
created_at: datetime
|
|
# True for an investor when the doc arrived since their previous portal visit.
|
|
is_new: bool = False
|
|
|
|
|
|
# --- Capital account ---
|
|
|
|
class CapitalAccountCreate(BaseModel):
|
|
entity_id: int
|
|
investor_user_id: int
|
|
as_of_date: date
|
|
commitment_dollars: float = 0
|
|
beginning_balance_dollars: float = 0
|
|
contributions_dollars: float = 0
|
|
distributions_dollars: float = 0
|
|
ending_balance_dollars: float = 0
|
|
document_id: int | None = None
|
|
|
|
|
|
class CapitalAccountResponse(BaseModel):
|
|
id: int
|
|
entity_id: int
|
|
investor_user_id: int
|
|
investor_name: str | None = None # legal name of the account this statement belongs to
|
|
as_of_date: date
|
|
commitment_cents: int
|
|
beginning_balance_cents: int
|
|
contributions_cents: int
|
|
distributions_cents: int
|
|
ending_balance_cents: int
|
|
document_id: int | None
|
|
created_at: datetime
|
|
|
|
|
|
# --- Partners (members of an entity) ---
|
|
|
|
class PartnerResponse(BaseModel):
|
|
user_id: int
|
|
name: str
|
|
username: str
|
|
external_investor_id: str | None
|
|
is_active: bool
|
|
login_enabled: bool
|
|
latest_commitment_cents: int | None = None
|
|
latest_contributions_cents: int | None = None
|
|
latest_distributions_cents: int | None = None
|
|
latest_value_cents: int | None
|
|
latest_as_of: date | None
|
|
statements_count: int
|
|
|
|
|
|
# --- Access matrix ---
|
|
|
|
class AccessGrant(BaseModel):
|
|
user_id: int
|
|
entity_id: int
|
|
|
|
|
|
class AccessMatrixResponse(BaseModel):
|
|
users: list[UserResponse]
|
|
entities: list[EntityResponse]
|
|
grants: list[AccessGrant]
|
|
|
|
|
|
# --- Capital account import (review-and-confirm) ---
|
|
|
|
class ImportInvestorPreview(BaseModel):
|
|
source_name: str # name as it appears in the spreadsheet
|
|
column_index: int # 0-based column it was read from
|
|
value_dollars: float # current capital value (ending balance)
|
|
commitment_dollars: float = 0
|
|
contributions_dollars: float = 0
|
|
distributions_dollars: float = 0
|
|
external_id: str | None = None # fund-admin INVESTOR ID, when present
|
|
matched_user_id: int | None = None
|
|
matched_username: str | None = None
|
|
suggested_username: str | None = None # for unmatched: a safe default
|
|
|
|
|
|
class ImportValueRow(BaseModel):
|
|
row_index: int # 0-based sheet row
|
|
label: str # col A label (e.g. "LTPF1")
|
|
|
|
|
|
class CapitalImportPreview(BaseModel):
|
|
as_of_date: date | None
|
|
value_rows: list[ImportValueRow] # candidate rows holding per-investor balances
|
|
chosen_row_index: int # the row used for the values below
|
|
investors: list[ImportInvestorPreview]
|
|
|
|
|
|
class ImportCommitInvestor(BaseModel):
|
|
action: str # "match" | "create" | "skip"
|
|
value_dollars: float # current capital value (ending balance)
|
|
commitment_dollars: float = 0
|
|
contributions_dollars: float = 0
|
|
distributions_dollars: float = 0
|
|
user_id: int | None = None # for action=match
|
|
name: str | None = None # for action=create
|
|
username: str | None = None # for action=create
|
|
email: str | None = None
|
|
password: str | None = None # for action=create; omit for the shared default password
|
|
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
|
|
|
|
|
class CapitalImportCommit(BaseModel):
|
|
entity_id: int
|
|
as_of_date: date
|
|
investors: list[ImportCommitInvestor]
|
|
|
|
|
|
# --- Batch historical capital backfill (one eNAV file per quarter, auto-matched) ---
|
|
|
|
class BatchCapitalFileResult(BaseModel):
|
|
filename: str
|
|
as_of_date: date | None = None
|
|
matched: int = 0 # existing members whose statement was written
|
|
statements_written: int = 0 # created + updated
|
|
updated: int = 0 # matched a statement already at this as-of date
|
|
skipped: list[str] = [] # roster names with no existing member (not created)
|
|
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
|
|
|
|
|
|
class BatchCapitalImportResult(BaseModel):
|
|
entity_id: int
|
|
files: list[BatchCapitalFileResult]
|
|
total_statements: int
|
|
|
|
|
|
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
|
|
|
class EntityStakeCreate(BaseModel):
|
|
fund_entity_id: int
|
|
ownership_pct: float | None = None
|
|
value_dollars: float | None = None
|
|
note: str | None = None
|
|
|
|
|
|
class EntityStakeResponse(BaseModel):
|
|
id: int
|
|
holder_entity_id: int
|
|
fund_entity_id: int
|
|
fund_name: str | None = None
|
|
fund_type: EntityType | None = None
|
|
ownership_pct: float | None
|
|
value_cents: int | None
|
|
note: str | None
|
|
created_at: datetime
|
|
|
|
|
|
# --- Investor View (admin reconstruction of what one investor sees) ---
|
|
|
|
class InvestorViewResponse(BaseModel):
|
|
user: UserResponse
|
|
entities: list[EntityResponse] = []
|
|
capital_accounts: list[CapitalAccountResponse] = []
|
|
documents: list[DocumentResponse] = []
|
|
|
|
|
|
class AssetBalancesResponse(BaseModel):
|
|
"""A GP/mgmt entity's assets: the linked account's capital balances across the funds."""
|
|
linked_user_id: int | None = None
|
|
linked_name: str | None = None
|
|
balances: list[CapitalAccountResponse] = []
|
|
|
|
|
|
# --- Audit ---
|
|
|
|
class AuditLogResponse(BaseModel):
|
|
id: int
|
|
actor_user_id: int | None
|
|
action: str
|
|
object_type: str
|
|
object_id: int | None
|
|
detail: dict | list | str | None
|
|
created_at: datetime
|