0.2.23: link GP entities to their investor account for auto asset balances

Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account,
so its Assets tab shows its real capital-account balance in each fund,
pulled live from the eNAV capital accounts instead of manual entry.

- entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/
  Response fields; validated to be an investor account.
- Edit-entity form gains a "Linked investor account" picker for GP/mgmt.
- Assets tab now auto-lists the linked account's balance per fund (the
  earlier manual-stakes API remains but is no longer used by the UI).

Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend
tsc + vite build clean.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 15:21:28 -05:00
parent f0f8fd15c6
commit e7501a14b0
12 changed files with 223 additions and 199 deletions
@@ -0,0 +1,29 @@
"""add entities.linked_user_id (link a GP entity to its investor account)
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
Create Date: 2026-07-01 10:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c9d0e1f2a3b4'
down_revision: Union[str, None] = 'b8c9d0e1f2a3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('entities', schema=None) as batch_op:
batch_op.add_column(sa.Column('linked_user_id', sa.Integer(), nullable=True))
op.create_index('ix_entities_linked_user_id', 'entities', ['linked_user_id'])
def downgrade() -> None:
op.drop_index('ix_entities_linked_user_id', table_name='entities')
with op.batch_alter_table('entities', schema=None) as batch_op:
batch_op.drop_column('linked_user_id')
+3
View File
@@ -87,6 +87,9 @@ class Entity(SQLModel, table=True):
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus = Field(default=EntityStatus.active)
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -174,12 +174,22 @@ def get_entity(
return EntityResponse.model_validate(entity, from_attributes=True)
def _validate_linked_user(linked_user_id: int | None, session: Session) -> None:
"""A linked account (for a GP entity that is also an LP) must be an investor account."""
if linked_user_id is None:
return
linked = session.get(User, linked_user_id)
if linked is None or linked.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Linked account must be an investor account.")
@router.post("", status_code=201)
def create_entity(
body: EntityCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
_validate_linked_user(body.linked_user_id, session)
entity = Entity(**body.model_dump())
session.add(entity)
session.flush()
@@ -200,6 +210,8 @@ def update_entity(
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
changes = body.model_dump(exclude_unset=True)
if "linked_user_id" in changes:
_validate_linked_user(changes["linked_user_id"], session)
for key, val in changes.items():
setattr(entity, key, val)
session.add(entity)
+3
View File
@@ -91,6 +91,7 @@ class EntityCreate(BaseModel):
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
linked_user_id: int | None = None
class EntityUpdate(BaseModel):
@@ -99,6 +100,7 @@ class EntityUpdate(BaseModel):
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus | None = None
linked_user_id: int | None = None
class EntityResponse(BaseModel):
@@ -108,6 +110,7 @@ class EntityResponse(BaseModel):
vintage_year: int | None
fund_size_cents: int | None
status: EntityStatus
linked_user_id: int | None = None
created_at: datetime
+31
View File
@@ -0,0 +1,31 @@
"""Linking a GP entity to its investor account (so Assets can pull real balances)."""
from tests.conftest import make_user
from ten31portal.models import Entity, EntityType, UserRole
def test_link_entity_to_investor(auth_client, session):
inv = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
session.add(gp)
session.commit()
session.refresh(gp)
linked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": inv.id})
assert linked.status_code == 200, linked.text
assert linked.json()["linked_user_id"] == inv.id
# Unlink.
unlinked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": None})
assert unlinked.status_code == 200
assert unlinked.json()["linked_user_id"] is None
def test_link_rejects_non_investor(auth_client, session):
staff = make_user(session, username="ops2", role=UserRole.operations)
gp = Entity(name="Mgmt Co", type=EntityType.mgmt_co)
session.add(gp)
session.commit()
session.refresh(gp)
resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id})
assert resp.status_code == 400
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
"version": "0.2.22",
"version": "0.2.23",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+3 -2
View File
@@ -1,4 +1,4 @@
export { v_0_2_22 as current } from './v_0_2_22'
export { v_0_2_23 as current } from './v_0_2_23'
import { v_0_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1'
@@ -21,4 +21,5 @@ import { v_0_2_18 } from './v_0_2_18'
import { v_0_2_19 } from './v_0_2_19'
import { v_0_2_20 } from './v_0_2_20'
import { v_0_2_21 } from './v_0_2_21'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21]
import { v_0_2_22 } from './v_0_2_22'
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22]
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_23 = VersionInfo.of({
version: '0.2.23:0',
releaseNotes: {
en_US:
'You can link a GP or management-company entity to its investor account. Its Assets tab then shows its real capital-account balance in each fund, pulled from the eNAV, with no double entry. Set this on the entity\'s Edit form.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached
// Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.22'
const CACHE = 'ten31-portal-0.2.23'
self.addEventListener('install', () => self.skipWaiting())
+1
View File
@@ -152,6 +152,7 @@ export interface Entity {
vintage_year: number | null;
fund_size_cents: number | null;
status: EntityStatus;
linked_user_id: number | null;
created_at: string;
}
+97 -194
View File
@@ -1,40 +1,42 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { api, canEditRound, type Entity, type EntityStake } from "../api";
import { useAuth } from "../context/AuthContext";
import { formatMoney } from "../format";
import { Link, useParams } from "react-router-dom";
import { api, type CapitalAccount, type Entity } from "../api";
import { formatDate, formatMoney } from "../format";
import EntityHeader from "../components/EntityHeader";
// A GP / management company's assets: its ownership interest in the funds it manages.
// A GP / management company's assets: its capital-account balance in each fund it holds,
// pulled live from the linked investor account (the same LLC on the eNAV / LP side).
export default function EntityAssets() {
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const [entity, setEntity] = useState<Entity | null>(null);
const [stakes, setStakes] = useState<EntityStake[]>([]);
const [allEntities, setAllEntities] = useState<Entity[]>([]);
const [accounts, setAccounts] = useState<CapitalAccount[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [error, setError] = useState("");
const entityId = id ? parseInt(id) : 0;
const isWriter = !!user && canEditRound(user.role);
useEffect(() => {
if (!entityId) return;
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entityId]);
async function load() {
setLoading(true);
try {
const [ent, stk, all] = await Promise.all([
api.getEntity(entityId),
api.listStakes(entityId),
api.listEntities(),
]);
const ent = await api.getEntity(entityId);
setEntity(ent);
setStakes(stk);
setAllEntities(all);
if (ent.linked_user_id != null) {
const [accts, ents] = await Promise.all([
api.listCapitalAccounts({ investor_user_id: ent.linked_user_id }),
api.listEntities(),
]);
setAccounts(accts);
setEntities(ents);
} else {
setAccounts([]);
}
} catch (e: any) {
setError(e.message || "Failed to load");
} finally {
@@ -42,196 +44,97 @@ export default function EntityAssets() {
}
}
// Funds/SPVs available to add (exclude self and ones already staked).
const candidates = useMemo(() => {
const staked = new Set(stakes.map((s) => s.fund_entity_id));
return allEntities.filter(
(e) => e.id !== entityId && !staked.has(e.id) && (e.type === "fund" || e.type === "spv"),
);
}, [allEntities, stakes, entityId]);
const fundName = useMemo(() => {
const m = new Map(entities.map((e) => [e.id, e.name]));
return (id: number) => m.get(id) ?? `Fund ${id}`;
}, [entities]);
const totalValue = stakes.reduce((s, k) => s + (k.value_cents ?? 0), 0);
// Latest statement per fund for the linked account.
const rows = useMemo(() => {
const byFund = new Map<number, CapitalAccount[]>();
for (const a of accounts) {
const g = byFund.get(a.entity_id) ?? [];
g.push(a);
byFund.set(a.entity_id, g);
}
return [...byFund.entries()]
.map(([fundId, accts]) => {
const latest = [...accts].sort((a, b) => b.as_of_date.localeCompare(a.as_of_date))[0];
return { fundId, latest };
})
.sort((a, b) => b.latest.ending_balance_cents - a.latest.ending_balance_cents);
}, [accounts]);
const total = rows.reduce((s, r) => s + r.latest.ending_balance_cents, 0);
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading...</div>;
async function remove(stakeId: number) {
if (!confirm("Remove this stake?")) return;
try {
await api.deleteStake(entityId, stakeId);
load();
} catch (e: any) {
setError(e.message || "Failed to remove");
}
}
return (
<div>
<EntityHeader entity={entity} active="assets" />
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Fund interests</h2>
{isWriter && candidates.length > 0 && (
<button
onClick={() => setShowForm(true)}
className="px-3 py-1.5 bg-gray-900 text-white text-sm rounded hover:bg-gray-800"
>
Add stake
</button>
)}
</div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Fund balances</h2>
{showForm && (
<AddStakeForm
entityId={entityId}
candidates={candidates}
onClose={() => setShowForm(false)}
onSaved={() => {
setShowForm(false);
load();
}}
/>
)}
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<th className="px-4 py-2 font-medium">Fund / SPV</th>
<th className="px-4 py-2 font-medium">Type</th>
<th className="px-4 py-2 font-medium text-right">Ownership</th>
<th className="px-4 py-2 font-medium text-right">Value</th>
<th className="px-4 py-2 font-medium">Note</th>
{isWriter && <th className="px-4 py-2" />}
</tr>
</thead>
<tbody>
{stakes.map((s) => (
<tr key={s.id} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-900">{s.fund_name ?? s.fund_entity_id}</td>
<td className="px-4 py-2 text-gray-600 uppercase text-xs">{s.fund_type}</td>
<td className="px-4 py-2 text-right text-gray-700">
{s.ownership_pct != null ? `${s.ownership_pct}%` : "—"}
</td>
<td className="px-4 py-2 text-right text-gray-900">
{s.value_cents != null ? formatMoney(s.value_cents) : "—"}
</td>
<td className="px-4 py-2 text-gray-500">{s.note || "—"}</td>
{isWriter && (
<td className="px-4 py-2 text-right">
<button onClick={() => remove(s.id)} className="text-gray-400 hover:text-red-600">
Remove
</button>
</td>
)}
</tr>
))}
{stakes.length > 0 && (
<tr className="bg-gray-50 font-medium">
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total</td>
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(totalValue)}</td>
<td className="px-4 py-2" colSpan={isWriter ? 2 : 1} />
</tr>
)}
{stakes.length === 0 && (
{entity.linked_user_id == null ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-sm text-gray-600">
<p>
This entity isn't linked to an investor account yet, so there are no balances to show.
</p>
<p className="mt-2 text-gray-500">
Link it on the{" "}
<Link to={`/entities/${entityId}`} className="text-orange-600 hover:text-orange-700">
Overview tab Edit entity
</Link>{" "}
(choose its investor account). Its capital-account balance in each fund will then
appear here automatically.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-left">
<tr>
<td colSpan={isWriter ? 6 : 5} className="px-4 py-6 text-center text-gray-400">
No fund interests recorded yet.
</td>
<th className="px-4 py-2 font-medium">Fund / SPV</th>
<th className="px-4 py-2 font-medium">As of</th>
<th className="px-4 py-2 font-medium text-right">Commitment</th>
<th className="px-4 py-2 font-medium text-right">Balance</th>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function AddStakeForm({
entityId,
candidates,
onClose,
onSaved,
}: {
entityId: number;
candidates: Entity[];
onClose: () => void;
onSaved: () => void;
}) {
const [fundId, setFundId] = useState<number | "">("");
const [pct, setPct] = useState("");
const [valueDollars, setValueDollars] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (fundId === "") {
setError("Choose a fund.");
return;
}
setSaving(true);
setError("");
try {
await api.createStake(entityId, {
fund_entity_id: Number(fundId),
ownership_pct: pct ? parseFloat(pct) : null,
value_dollars: valueDollars ? parseFloat(valueDollars.replace(/[,$]/g, "")) : null,
note: note.trim() || null,
});
onSaved();
} catch (err: any) {
setError(err.message || "Failed to add stake");
} finally {
setSaving(false);
}
}
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-4 max-w-lg">
<h3 className="text-sm font-medium text-gray-900 mb-3">Add a fund interest</h3>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Fund / SPV</label>
<select
className={inputCls}
value={fundId}
onChange={(e) => setFundId(e.target.value === "" ? "" : Number(e.target.value))}
>
<option value="">Select</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.fundId} className="border-t border-gray-100">
<td className="px-4 py-2 text-gray-900">
<Link to={`/entities/${r.fundId}`} className="hover:text-orange-600">
{fundName(r.fundId)}
</Link>
</td>
<td className="px-4 py-2 text-gray-500">{formatDate(r.latest.as_of_date)}</td>
<td className="px-4 py-2 text-right text-gray-600">
{formatMoney(r.latest.commitment_cents)}
</td>
<td className="px-4 py-2 text-right text-gray-900">
{formatMoney(r.latest.ending_balance_cents)}
</td>
</tr>
))}
{rows.length > 0 && (
<tr className="bg-gray-50 font-medium">
<td className="px-4 py-2 text-gray-900" colSpan={3}>Total balance</td>
<td className="px-4 py-2 text-right text-gray-900">{formatMoney(total)}</td>
</tr>
)}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-gray-400">
The linked account has no capital-account balances yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Ownership % (optional)</label>
<input className={inputCls} value={pct} onChange={(e) => setPct(e.target.value)} placeholder="20" />
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Value $ (optional)</label>
<input className={inputCls} value={valueDollars} onChange={(e) => setValueDollars(e.target.value)} placeholder="1,000,000" />
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Note (optional)</label>
<input className={inputCls} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">
{saving ? "Adding…" : "Add"}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
Cancel
</button>
</div>
</form>
)}
</div>
);
}
+29 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { api, canEditRound, type Entity, type EntityType, type Holding, type ValuationRound } from "../api";
import { api, canEditRound, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
import { useAuth } from "../context/AuthContext";
import { formatMoney, formatDate, formatQuarter } from "../format";
import EntityHeader from "../components/EntityHeader";
@@ -149,10 +149,20 @@ function EditEntityForm({
const [fundSizeDollars, setFundSizeDollars] = useState(
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
);
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
const [investors, setInvestors] = useState<User[]>([]);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
// A GP / management company can be linked to its own investor account, so its Assets view
// shows its real per-fund balances.
const isGp = type === "gp" || type === "mgmt_co";
useEffect(() => {
if (!isGp) return;
api.listUsers().then((us) => setInvestors(us.filter((u) => u.role === "investor"))).catch(() => {});
}, [isGp]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
@@ -170,6 +180,7 @@ function EditEntityForm({
fund_size_cents: fundSizeDollars
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
: null,
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
};
const updated = await api.updateEntity(entity.id, data);
onSaved(updated);
@@ -216,6 +227,23 @@ function EditEntityForm({
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
</div>
</div>
{isGp && (
<div>
<label className="block text-sm text-gray-700 mb-1">
Linked investor account (its Assets tab shows this account's fund balances)
</label>
<select
className={inputCls}
value={linkedUserId}
onChange={(e) => setLinkedUserId(e.target.value === "" ? "" : Number(e.target.value))}
>
<option value="">None</option>
{investors.map((u) => (
<option key={u.id} value={u.id}>{u.name} ({u.username})</option>
))}
</select>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<button type="submit" disabled={saving} className="px-4 py-2 bg-gray-900 text-white text-sm rounded hover:bg-gray-800 disabled:opacity-50">