Files
Jonathan Kirkwood ebafcf19d9 0.2.43: historical NAV backfill without touching current holdings
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.
2026-08-11 12:32:57 -05:00

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