Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account, so its Assets tab shows its real capital-account balance in each fund, pulled live from the eNAV capital accounts instead of manual entry. - entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/ Response fields; validated to be an investor account. - Edit-entity form gains a "Linked investor account" picker for GP/mgmt. - Assets tab now auto-lists the linked account's balance per fund (the earlier manual-stakes API remains but is no longer used by the UI). Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend tsc + vite build clean.
363 lines
8.6 KiB
Python
363 lines
8.6 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
|
|
|
|
|
|
# --- 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 to create without a login
|
|
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]
|
|
|
|
|
|
# --- 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] = []
|
|
|
|
|
|
# --- 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
|