Files
Ten31-Portal/backend/ten31portal/schemas.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

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