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
+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">