Files
Ten31-Portal/frontend/src/api.ts
T
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

634 lines
20 KiB
TypeScript

/**
* 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<T>(path: string, options?: RequestInit): Promise<T> {
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<User | LoginPending2FA>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ login, password }),
}),
verifyTotp: (code: string) =>
request<User>("/api/auth/login/verify-totp", {
method: "POST",
body: JSON.stringify({ code }),
}),
logout: () => request<{ status: string }>("/api/auth/logout", { method: "POST" }),
me: () => request<User>("/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<Entity[]>("/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<Entity>(`/api/entities/${id}`),
listPartners: (entityId: number) => request<Partner[]>(`/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<Partner>(`/api/entities/${entityId}/partners/${userId}/exited`, {
method: "PUT",
body: JSON.stringify({ exited_on: exitedOn }),
}),
createEntity: (data: Partial<Entity>) =>
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
updateEntity: (id: number, data: Partial<Entity>) =>
request<Entity>(`/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<EntityStake[]>(`/api/entities/${entityId}/stakes`),
createStake: (
entityId: number,
data: { fund_entity_id: number; ownership_pct?: number | null; value_dollars?: number | null; note?: string | null },
) => request<EntityStake>(`/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<AssetBalances>(`/api/entities/${entityId}/asset-balances`),
// Investor View (admin read-only reconstruction of an investor's portal)
investorView: (userId: number) => request<InvestorView>(`/api/users/${userId}/investor-view`),
// Holdings
listHoldings: (entityId: number) =>
request<Holding[]>(`/api/entities/${entityId}/holdings`),
createHolding: (entityId: number, data: { company_name: string }) =>
request<Holding>(`/api/entities/${entityId}/holdings`, {
method: "POST",
body: JSON.stringify(data),
}),
// Positions
listPositions: (holdingId: number) =>
request<Position[]>(`/api/holdings/${holdingId}/positions`),
// Rounds
listRounds: (entityId: number) =>
request<ValuationRound[]>(`/api/entities/${entityId}/rounds`),
getRound: (roundId: number) => request<ValuationRound>(`/api/rounds/${roundId}`),
createRound: (entityId: number, quarterEnd: string) =>
request<ValuationRound>(`/api/entities/${entityId}/rounds`, {
method: "POST",
body: JSON.stringify({ quarter_end: quarterEnd }),
}),
updateValuations: (roundId: number, valuations: { position_id: number; value_cents: number }[]) =>
request<ValuationRound>(`/api/rounds/${roundId}/valuations`, {
method: "PATCH",
body: JSON.stringify({ valuations }),
}),
submitRound: (roundId: number) =>
request<ValuationRound>(`/api/rounds/${roundId}/submit`, { method: "POST" }),
approveRound: (roundId: number) =>
request<ValuationRound>(`/api/rounds/${roundId}/approve`, { method: "POST" }),
returnRound: (roundId: number, note: string) =>
request<ValuationRound>(`/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<AuditEntry[]>(`/api/audit?${q}`);
},
// Users (admin)
listUsers: () => request<User[]>("/api/users"),
investorsForEntity: (entityId: number) =>
request<User[]>(`/api/users/investors-for-entity/${entityId}`),
accessMatrix: () => request<AccessMatrix>("/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<any> => {
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<CapitalImportPreview> => {
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<CapitalImportResult>("/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<BatchCapitalImportResult> => {
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<BtcPricesStatus>("/api/import/btc-prices"),
importBtcPrices: async (file: File): Promise<BtcPricesImportResult> => {
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<UserDetail>(`/api/users/${id}`),
createUser: (data: {
name: string;
username: string;
password: string;
role: UserRole;
email?: string | null;
entity_ids?: number[];
}) => request<UserDetail>("/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<UserDetail>(`/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<UserDetail>(`/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<PortalDocument[]>(`/api/documents${qs ? `?${qs}` : ""}`);
},
uploadDocument: async (form: FormData): Promise<PortalDocument> => {
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<CapitalAccount[]>(`/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<CapitalAccount>("/api/capital-accounts", { method: "POST", body: JSON.stringify(data) }),
deleteCapitalAccount: (id: number) =>
request<{ status: string }>(`/api/capital-accounts/${id}`, { method: "DELETE" }),
};