0.2.33: exited positions (secondary-sale edge case)

A member who sold/transferred their stake showed a phantom -100% loss
($0 ending balance, no distribution through the fund). Now:
- entity_access.exited_on (migration e1f2a3b4c5d6), set/cleared from the
  Partners tab (writer-only, inline date picker, audited)
- LP portal card shows a quiet "Exited <date>" badge, keeps documents,
  hides balance/gain
- portfolio summary excludes exited positions ("Excludes N exited")
- fund committed totals (rollup + Partners tab) skip exited members so
  seller + buyer are not double-counted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-03 08:54:19 -05:00
co-authored by Claude Opus 4.8
parent 4215d4478f
commit a639ba14cf
14 changed files with 355 additions and 17 deletions
+80 -5
View File
@@ -14,6 +14,9 @@ export default function EntityPartners() {
const [notice, setNotice] = useState("");
const [clearing, setClearing] = useState(false);
const [loading, setLoading] = useState(true);
// Row currently getting an exit date (inline date picker), and the chosen date.
const [exitingId, setExitingId] = useState<number | null>(null);
const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10));
const isWriter = !!user && canEditRound(user.role);
@@ -57,10 +60,26 @@ export default function EntityPartners() {
}
}
async function saveExit(userId: number, exitedOn: string | null) {
if (!entity) return;
setError("");
try {
const updated = await api.setPartnerExited(entity.id, userId, exitedOn);
setPartners((prev) => prev.map((p) => (p.user_id === userId ? updated : p)));
setExitingId(null);
} catch (err: any) {
setError(err.message || "Failed to update exit status");
}
}
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading</div>;
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
// Exited members (stake sold/transferred) stay on the roster but out of the totals —
// otherwise a seller and their buyer would both be counted.
const active = partners.filter((p) => !p.exited_on);
const exitedCount = partners.length - active.length;
const totalCommitted = active.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
const totalCapital = active.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
return (
<div>
@@ -78,6 +97,11 @@ export default function EntityPartners() {
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
<span className="mx-2 text-gray-300">·</span>
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
{exitedCount > 0 && (
<span className="ml-2 text-xs text-gray-400">
(excludes {exitedCount} exited)
</span>
)}
</p>
{isWriter && partners.length > 0 && (
<button
@@ -98,6 +122,7 @@ export default function EntityPartners() {
<th className="px-4 py-2 font-medium">Member</th>
<th className="px-4 py-2 font-medium">Investor ID</th>
<th className="px-4 py-2 font-medium">Login</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">As of</th>
<th className="px-4 py-2 font-medium text-right">Committed</th>
<th className="px-4 py-2 font-medium text-right">Paid-in</th>
@@ -107,9 +132,9 @@ export default function EntityPartners() {
</thead>
<tbody>
{partners.map((p) => (
<tr key={p.user_id} className="border-t border-gray-100">
<tr key={p.user_id} className={`border-t border-gray-100 ${p.exited_on ? "text-gray-400" : ""}`}>
<td className="px-4 py-2">
<div className="text-gray-900">{p.name}</div>
<div className={p.exited_on ? "text-gray-500" : "text-gray-900"}>{p.name}</div>
<div className="text-xs text-gray-400">{p.username}</div>
</td>
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</td>
@@ -122,6 +147,56 @@ export default function EntityPartners() {
<span className="text-amber-600">No login yet</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{p.exited_on ? (
<span>
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
Exited {formatDate(p.exited_on)}
</span>
{isWriter && (
<button
onClick={() => saveExit(p.user_id, null)}
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
>
Undo
</button>
)}
</span>
) : exitingId === p.user_id ? (
<span className="flex items-center gap-1.5">
<input
type="date"
value={exitDate}
onChange={(e) => setExitDate(e.target.value)}
className="px-1.5 py-0.5 border border-gray-300 rounded text-xs"
/>
<button
onClick={() => exitDate && saveExit(p.user_id, exitDate)}
className="text-xs text-accent-600 hover:text-accent-700 font-medium"
>
Save
</button>
<button
onClick={() => setExitingId(null)}
className="text-xs text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</span>
) : (
<span>
<span className="text-gray-600">Member</span>
{isWriter && (
<button
onClick={() => setExitingId(p.user_id)}
className="ml-2 text-xs text-gray-400 hover:text-gray-600"
>
Mark exited
</button>
)}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-500">
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
</td>
@@ -141,7 +216,7 @@ export default function EntityPartners() {
))}
{partners.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
<td colSpan={9} className="px-4 py-6 text-center text-gray-400">
No members yet. Import the eNAV ALLOC SI tab from Import Statements, or grant
access on the Access Grid.
</td>