/** * Typed API client for Ten31Portal backend. */ // --- Types --- export type UserRole = | "approver" | "operations" | "cfo" | "fund_admin" | "viewer" | "investor" | "fund_administrator"; export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry"; export type EntityStatus = "active" | "closed"; export type RoundStatus = "draft" | "submitted" | "approved" | "returned"; export type DocumentCategory = | "capital_account" | "k1" | "statement" | "tax" | "other"; export const INTERNAL_ROLES: UserRole[] = ["approver", "operations", "cfo", "fund_admin", "viewer"]; export const EXTERNAL_ROLES: UserRole[] = ["investor", "fund_administrator"]; // Roles that can administer users, documents, and capital accounts (everything except sign-off). export const ADMIN_ROLES: UserRole[] = ["approver", "operations", "cfo"]; // Roles that can create/edit entities and valuation rounds (staff, excluding read-only viewer). export const WRITER_ROLES: UserRole[] = ["fund_admin", "operations", "cfo", "approver"]; export const isInternal = (role: UserRole) => INTERNAL_ROLES.includes(role); export const isAdmin = (role: UserRole) => ADMIN_ROLES.includes(role); export const canEditRound = (role: UserRole) => WRITER_ROLES.includes(role); export const isApprover = (role: UserRole) => role === "approver"; // The external Administrator: full management, but only inside their granted entities. export const isAdministrator = (role: UserRole) => role === "fund_administrator"; // Can run the admin screens (users, documents, capital accounts, imports). export const isManager = (role: UserRole) => isAdmin(role) || isAdministrator(role); // Can edit entity records (partners, exits, fund details) — internal writers + Administrator. export const canManageEntity = (role: UserRole) => WRITER_ROLES.includes(role) || isAdministrator(role); export interface User { id: number; name: string; username: string; email: string | null; role: UserRole; is_active: boolean; is_service_admin: boolean; primary_account_id: number | null; totp_enabled: boolean; must_change_password: boolean; onboarded_at: string | null; created_at: string; } /** Password was accepted but the account needs its authenticator code to finish signing in. */ export interface LoginPending2FA { requires_2fa: true; } export interface LinkedAccount { id: number; name: string; username: string; } export interface UserDetail extends User { primary_account_name: string | null; linked_accounts: LinkedAccount[]; entity_ids: number[]; } export interface PortalDocument { id: number; entity_id: number; investor_user_id: number | null; category: DocumentCategory; title: string; original_filename: string; content_type: string; size_bytes: number; uploaded_by: number | null; created_at: string; is_new?: boolean; } export interface Partner { user_id: number; name: string; username: string; external_investor_id: string | null; is_active: boolean; login_enabled: boolean; latest_commitment_cents: number | null; latest_contributions_cents: number | null; latest_distributions_cents: number | null; latest_value_cents: number | null; latest_as_of: string | null; statements_count: number; exited_on: string | null; } export interface AccessGrant { user_id: number; entity_id: number; } export interface AccessMatrix { users: User[]; entities: Entity[]; grants: AccessGrant[]; } export interface ImportValueRow { row_index: number; label: string; } export interface ImportInvestorPreview { source_name: string; column_index: number; value_dollars: number; commitment_dollars: number; contributions_dollars: number; distributions_dollars: number; external_id: string | null; matched_user_id: number | null; matched_username: string | null; suggested_username: string | null; } export interface CapitalImportPreview { as_of_date: string | null; value_rows: ImportValueRow[]; chosen_row_index: number; investors: ImportInvestorPreview[]; } export interface CapitalImportResult { status: string; created_accounts: number; matched_accounts_updated: number; statements_written: number; } export interface BatchCapitalFileResult { filename: string; as_of_date: string | null; matched: number; statements_written: number; updated: number; skipped: string[]; error: string | null; nav_status: "added" | "updated" | "kept-signed" | "no-match" | "no-hld" | "error" | null; nav_matched: number; nav_unmatched: number; nav_cents: number; } export interface BatchCapitalImportResult { entity_id: number; files: BatchCapitalFileResult[]; total_statements: number; } export interface BtcPricesStatus { count: number; first_date: string | null; last_date: string | null; latest_price_cents: number | null; } export interface BtcPricesImportResult extends BtcPricesStatus { imported: number; skipped_rows: number; } export interface CapitalAccount { id: number; entity_id: number; investor_user_id: number; investor_name: string | null; as_of_date: string; commitment_cents: number; beginning_balance_cents: number; contributions_cents: number; distributions_cents: number; ending_balance_cents: number; document_id: number | null; created_at: string; exited_on: string | null; // BTC/USD marks for the bitcoin-denominated view (null = no price data / no close date) btc_price_cents: number | null; btc_close_price_cents: number | null; } export interface Entity { id: number; name: string; type: EntityType; vintage_year: number | null; fund_size_cents: number | null; status: EntityStatus; linked_user_id: number | null; close_date: string | null; created_at: string; } export interface Holding { id: number; entity_id: number; company_name: string; created_at: string; } export interface Position { id: number; holding_id: number; security_name: string; investment_date: string; shares: string | null; cost_cents: number; created_at: string; } export interface Valuation { id: number; round_id: number; position_id: number; value_cents: number; created_at: string; } export interface ValuationRound { id: number; entity_id: number; quarter_end: string; status: RoundStatus; submitted_by: number | null; submitted_at: string | null; approved_by: number | null; approved_at: string | null; return_note: string | null; is_seed: boolean; created_at: string; valuations: Valuation[]; } export interface AuditEntry { id: number; actor_user_id: number | null; action: string; object_type: string; object_id: number | null; detail: unknown; created_at: string; } export interface EntityStake { id: number; holder_entity_id: number; fund_entity_id: number; fund_name: string | null; fund_type: EntityType | null; ownership_pct: number | null; value_cents: number | null; note: string | null; created_at: string; } export interface InvestorView { user: User; entities: Entity[]; capital_accounts: CapitalAccount[]; documents: PortalDocument[]; } export interface AssetBalances { linked_user_id: number | null; linked_name: string | null; balances: CapitalAccount[]; } // --- API helpers --- export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } async function request(path: string, options?: RequestInit): Promise { const res = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...options?.headers, }, }); if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })); throw new ApiError(res.status, body.detail || res.statusText); } return res.json(); } // --- Auth --- export const api = { login: (login: string, password: string) => request("/api/auth/login", { method: "POST", body: JSON.stringify({ login, password }), }), verifyTotp: (code: string) => request("/api/auth/login/verify-totp", { method: "POST", body: JSON.stringify({ code }), }), logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }), me: () => request("/api/auth/me"), changePassword: (current_password: string, new_password: string) => request<{ status: string }>("/api/auth/change-password", { method: "POST", body: JSON.stringify({ current_password, new_password }), }), totpSetup: () => request<{ secret: string; otpauth_uri: string; qr_svg: string }>("/api/auth/totp/setup", { method: "POST", }), totpConfirm: (code: string) => request<{ recovery_codes: string[] }>("/api/auth/totp/confirm", { method: "POST", body: JSON.stringify({ code }), }), totpDisable: (password: string) => request<{ status: string }>("/api/auth/totp/disable", { method: "POST", body: JSON.stringify({ password }), }), // Entities listEntities: () => request("/api/entities"), listEntityRollup: () => request<{ id: number; name: string; type: EntityType; vintage_year: number | null; fund_size_cents: number | null; status: EntityStatus; committed_cents: number; invested_cents: number; last_signed_value_cents: number; }[]>("/api/entities/rollup"), getEntity: (id: number) => request(`/api/entities/${id}`), listPartners: (entityId: number) => request(`/api/entities/${entityId}/partners`), clearPartners: (entityId: number) => request<{ statements: number; access_grants: number }>( `/api/entities/${entityId}/partners`, { method: "DELETE" }, ), setPartnerExited: (entityId: number, userId: number, exitedOn: string | null) => request(`/api/entities/${entityId}/partners/${userId}/exited`, { method: "PUT", body: JSON.stringify({ exited_on: exitedOn }), }), createEntity: (data: Partial) => request("/api/entities", { method: "POST", body: JSON.stringify(data) }), updateEntity: (id: number, data: Partial) => request(`/api/entities/${id}`, { method: "PATCH", body: JSON.stringify(data) }), // Entity stakes (a GP/mgmt entity's interest in the funds it manages) listStakes: (entityId: number) => request(`/api/entities/${entityId}/stakes`), createStake: ( entityId: number, data: { fund_entity_id: number; ownership_pct?: number | null; value_dollars?: number | null; note?: string | null }, ) => request(`/api/entities/${entityId}/stakes`, { method: "POST", body: JSON.stringify(data) }), deleteStake: (entityId: number, stakeId: number) => request<{ status: string }>(`/api/entities/${entityId}/stakes/${stakeId}`, { method: "DELETE" }), // A GP/mgmt entity's assets: its linked account's capital balances across the funds. entityAssetBalances: (entityId: number) => request(`/api/entities/${entityId}/asset-balances`), // Investor View (admin read-only reconstruction of an investor's portal) investorView: (userId: number) => request(`/api/users/${userId}/investor-view`), // Holdings listHoldings: (entityId: number) => request(`/api/entities/${entityId}/holdings`), createHolding: (entityId: number, data: { company_name: string }) => request(`/api/entities/${entityId}/holdings`, { method: "POST", body: JSON.stringify(data), }), // Positions listPositions: (holdingId: number) => request(`/api/holdings/${holdingId}/positions`), // Rounds listRounds: (entityId: number) => request(`/api/entities/${entityId}/rounds`), getRound: (roundId: number) => request(`/api/rounds/${roundId}`), createRound: (entityId: number, quarterEnd: string) => request(`/api/entities/${entityId}/rounds`, { method: "POST", body: JSON.stringify({ quarter_end: quarterEnd }), }), updateValuations: (roundId: number, valuations: { position_id: number; value_cents: number }[]) => request(`/api/rounds/${roundId}/valuations`, { method: "PATCH", body: JSON.stringify({ valuations }), }), submitRound: (roundId: number) => request(`/api/rounds/${roundId}/submit`, { method: "POST" }), approveRound: (roundId: number) => request(`/api/rounds/${roundId}/approve`, { method: "POST" }), returnRound: (roundId: number, note: string) => request(`/api/rounds/${roundId}/return`, { method: "POST", body: JSON.stringify({ note }), }), // Audit listAudit: (params?: { object_type?: string; object_id?: number; page?: number }) => { const q = new URLSearchParams(); if (params?.object_type) q.set("object_type", params.object_type); if (params?.object_id) q.set("object_id", String(params.object_id)); if (params?.page) q.set("page", String(params.page)); return request(`/api/audit?${q}`); }, // Users (admin) listUsers: () => request("/api/users"), investorsForEntity: (entityId: number) => request(`/api/users/investors-for-entity/${entityId}`), accessMatrix: () => request("/api/users/access-matrix"), grantAccess: (userId: number, entityId: number) => request<{ status: string }>(`/api/users/${userId}/access/${entityId}`, { method: "PUT" }), revokeAccess: (userId: number, entityId: number) => request<{ status: string }>(`/api/users/${userId}/access/${entityId}`, { method: "DELETE" }), // Holdings / NAV import (eNAV HLD tab) scheduleImport: async ( file: File, opts: { commit: boolean; entityId?: number | null; password?: string; asOf?: string; createType?: EntityType; vintageYear?: string; replaceExisting?: boolean; }, ): Promise => { const form = new FormData(); form.set("file", file); if (opts.password) form.set("password", opts.password); const p = new URLSearchParams(); p.set("commit", String(opts.commit)); if (opts.replaceExisting) p.set("replace_existing", "true"); if (opts.asOf) p.set("as_of", opts.asOf); if (opts.entityId != null) { p.set("entity_id", String(opts.entityId)); } else { p.set("create_entity_type", opts.createType || "fund"); if (opts.vintageYear) p.set("create_vintage_year", opts.vintageYear); } const res = await fetch(`/api/import/schedule?${p}`, { method: "POST", body: form }); const data = await res.json().catch(() => ({ detail: res.statusText })); if (!res.ok) throw new ApiError(res.status, data.detail || "Import failed"); return data; }, // Capital-account import (fund-admin spreadsheet) capitalImportPreview: async ( file: File, entityId: number, rowIndex?: number, password?: string, ): Promise => { const form = new FormData(); form.set("file", file); form.set("entity_id", String(entityId)); if (rowIndex != null) form.set("row_index", String(rowIndex)); if (password) form.set("password", password); const res = await fetch("/api/import/capital-accounts/preview", { method: "POST", body: form }); if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })); throw new ApiError(res.status, body.detail || res.statusText); } return res.json(); }, capitalImportCommit: (data: { entity_id: number; as_of_date: string; investors: { action: "match" | "create" | "skip"; value_dollars: number; commitment_dollars?: number; contributions_dollars?: number; distributions_dollars?: number; user_id?: number | null; name?: string | null; username?: string | null; email?: string | null; password?: string | null; external_id?: string | null; }[]; }) => request("/api/import/capital-accounts/commit", { method: "POST", body: JSON.stringify(data), }), // Batch backfill: several historical eNAV files → capital statements per quarter, auto-matched. capitalImportBatch: async ( entityId: number, files: File[], password?: string, ): Promise => { const form = new FormData(); form.set("entity_id", String(entityId)); if (password) form.set("password", password); for (const f of files) form.append("files", f); const res = await fetch("/api/import/capital-accounts/batch", { method: "POST", body: form }); const data = await res.json().catch(() => ({ detail: res.statusText })); if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed"); return data; }, // BTC prices (bitcoin-denominated view) btcPricesStatus: () => request("/api/import/btc-prices"), importBtcPrices: async (file: File): Promise => { const form = new FormData(); form.set("file", file); const res = await fetch("/api/import/btc-prices", { method: "POST", body: form }); const data = await res.json().catch(() => ({ detail: res.statusText })); if (!res.ok) throw new ApiError(res.status, data.detail || "Price import failed"); return data; }, markOnboarded: () => request<{ status: string }>("/api/auth/onboarded", { method: "POST" }), getUser: (id: number) => request(`/api/users/${id}`), createUser: (data: { name: string; username: string; password: string; role: UserRole; email?: string | null; entity_ids?: number[]; }) => request("/api/users", { method: "POST", body: JSON.stringify(data) }), updateUser: ( id: number, data: Partial<{ name: string; username: string; email: string | null; role: UserRole; is_active: boolean; entity_ids: number[]; }>, ) => request(`/api/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }), resetPassword: (id: number, password: string) => request<{ status: string }>(`/api/users/${id}/reset-password`, { method: "POST", body: JSON.stringify({ password }), }), linkAccount: (id: number, primary_account_id: number | null) => request(`/api/users/${id}/primary-account`, { method: "PUT", body: JSON.stringify({ primary_account_id }), }), deleteUser: (id: number) => request<{ status: string }>(`/api/users/${id}`, { method: "DELETE" }), // Documents listDocuments: (params?: { entity_id?: number; investor_user_id?: number }) => { const q = new URLSearchParams(); if (params?.entity_id != null) q.set("entity_id", String(params.entity_id)); if (params?.investor_user_id != null) q.set("investor_user_id", String(params.investor_user_id)); const qs = q.toString(); return request(`/api/documents${qs ? `?${qs}` : ""}`); }, uploadDocument: async (form: FormData): Promise => { const res = await fetch("/api/documents", { method: "POST", body: form }); if (!res.ok) { const body = await res.json().catch(() => ({ detail: res.statusText })); throw new ApiError(res.status, body.detail || res.statusText); } return res.json(); }, downloadUrl: (id: number) => `/api/documents/${id}/download`, deleteDocument: (id: number) => request<{ status: string }>(`/api/documents/${id}`, { method: "DELETE" }), // Capital accounts listCapitalAccounts: (params?: { entity_id?: number; investor_user_id?: number }) => { const q = new URLSearchParams(); if (params?.entity_id != null) q.set("entity_id", String(params.entity_id)); if (params?.investor_user_id != null) q.set("investor_user_id", String(params.investor_user_id)); const qs = q.toString(); return request(`/api/capital-accounts${qs ? `?${qs}` : ""}`); }, createCapitalAccount: (data: { entity_id: number; investor_user_id: number; as_of_date: string; beginning_balance_dollars: number; contributions_dollars: number; distributions_dollars: number; ending_balance_dollars: number; document_id?: number | null; }) => request("/api/capital-accounts", { method: "POST", body: JSON.stringify(data) }), deleteCapitalAccount: (id: number) => request<{ status: string }>(`/api/capital-accounts/${id}`, { method: "DELETE" }), };