Files
Ten31-Portal/backend/ten31portal/schemas.py
T
Jonathan Kirkwood 33776d42f4 0.2.24: household-aware GP asset balances + show on Overview
Fixes a linked GP entity showing an empty Assets tab when its capital is
held under the linked account's other legal names (the eNAV often splits
one LLC across names).

- New GET /api/entities/{id}/asset-balances: household-aware, returns the
  linked account's (and its linked names') capital balances per fund, plus
  the linked account name for a clear empty state.
- Assets tab uses it; shows "linked to X but no balances on file" instead
  of a blank table when the wrong account is linked.
- GP/mgmt Overview now surfaces the total linked balance across funds with
  a "View by fund" link, so assets are visible without opening the tab.

Verified: 15/15 backend tests (incl. household case); frontend tsc + vite
build clean.
2026-07-01 16:14:58 -05:00

370 lines
8.9 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] = []
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