- 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>
451 lines
12 KiB
Python
451 lines
12 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
|
|
totp_enabled: bool = False
|
|
# First-login flow: force a password change while on the shared default, then show the
|
|
# welcome step (2FA offer) until onboarded_at is stamped.
|
|
must_change_password: bool = False
|
|
onboarded_at: datetime | None = None
|
|
created_at: datetime
|
|
|
|
|
|
class LoginPending2FA(BaseModel):
|
|
"""Password accepted, waiting on the second factor before the session is signed in."""
|
|
requires_2fa: bool = True
|
|
|
|
|
|
class TotpVerifyRequest(BaseModel):
|
|
code: str # 6-digit authenticator code, or a one-time recovery code
|
|
|
|
|
|
class TotpSetupResponse(BaseModel):
|
|
secret: str
|
|
otpauth_uri: str
|
|
qr_svg: str
|
|
|
|
|
|
class TotpConfirmRequest(BaseModel):
|
|
code: str
|
|
|
|
|
|
class TotpConfirmResponse(BaseModel):
|
|
recovery_codes: list[str] # shown exactly once
|
|
|
|
|
|
class TotpDisableRequest(BaseModel):
|
|
password: str
|
|
|
|
|
|
# --- 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
|
|
close_date: date | 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
|
|
close_date: date | 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
|
|
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
|
|
# "Exited" badge instead of a phantom -100% and drops the position from totals.
|
|
exited_on: date | None = None
|
|
# Bitcoin-denominated view: BTC/USD at this statement's as-of date (newest uploaded price
|
|
# on or before it) and at the fund's close date (the entry mark). Null when no price or
|
|
# no close date is set — the portal simply hides the BTC view then.
|
|
btc_price_cents: int | None = None
|
|
btc_close_price_cents: int | None = None
|
|
|
|
|
|
# --- BTC prices (bitcoin-denominated view) ---
|
|
|
|
class BtcPricesStatus(BaseModel):
|
|
count: int
|
|
first_date: date | None = None
|
|
last_date: date | None = None
|
|
latest_price_cents: int | None = None
|
|
|
|
|
|
class BtcPricesImportResult(BtcPricesStatus):
|
|
imported: int # rows upserted from this file (new + updated)
|
|
skipped_rows: int # unparseable lines ignored
|
|
|
|
|
|
# --- 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
|
|
exited_on: date | None = None
|
|
|
|
|
|
class PartnerExitUpdate(BaseModel):
|
|
exited_on: date | None # null clears the exit (marks the member active again)
|
|
|
|
|
|
# --- 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
|