The batch history import now also records each quarter's NAV in the fund's valuation history: the old file's HLD rows are matched by issuer and security name against the book as it exists today, matched rows write that quarter's valuations, unmatched rows are counted and reported, and nothing outside the round is created or modified. A manually signed quarter is never overwritten. The single-file wizard automatically takes the same history-only path when the file is older than the fund's newest round. Previously that import would regress position cost basis to the old file's values and resurrect since-exited positions, corrupting the fund's Invested total.
456 lines
12 KiB
Python
456 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.)
|
|
# NAV history leg: the quarter's valuation round written from the file's HLD sheet.
|
|
nav_status: str | None = None # added | updated | kept-signed | no-match | no-hld | error
|
|
nav_matched: int = 0 # HLD rows matched to positions in today's book
|
|
nav_unmatched: int = 0 # HLD rows with no current position (sold/renamed since)
|
|
nav_cents: int = 0 # the quarter's NAV as recorded (matched rows only)
|
|
|
|
|
|
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
|