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
@@ -0,0 +1,27 @@
"""add entity_access.exited_on (member sold/transferred their stake)
Revision ID: e1f2a3b4c5d6
Revises: d0e1f2a3b4c5
Create Date: 2026-07-03 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'e1f2a3b4c5d6'
down_revision: Union[str, None] = 'd0e1f2a3b4c5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('entity_access', schema=None) as batch_op:
batch_op.add_column(sa.Column('exited_on', sa.Date(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('entity_access', schema=None) as batch_op:
batch_op.drop_column('exited_on')
+4
View File
@@ -175,6 +175,10 @@ class EntityAccess(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id") user_id: int = Field(foreign_key="users.id")
entity_id: int = Field(foreign_key="entities.id", index=True) entity_id: int = Field(foreign_key="entities.id", index=True)
# Set when this member sold/transferred their stake (e.g. a secondary sale): the fund's
# books show a $0 balance with no distribution, which is NOT a loss. An exited position
# shows a badge instead of gain/loss, keeps its documents, and drops out of totals.
exited_on: date | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -10,7 +10,7 @@ from ten31portal.auth import (
) )
from ten31portal.database import get_session from ten31portal.database import get_session
from ten31portal.models import ( from ten31portal.models import (
CapitalAccountStatement, Entity, User, UserRole, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
) )
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
@@ -57,10 +57,22 @@ def list_statements(
col(User.id).in_({r.investor_user_id for r in rows}) col(User.id).in_({r.investor_user_id for r in rows})
) )
).all()) if rows else {} ).all()) if rows else {}
# And each (investor, entity)'s exit date, so a sold/transferred stake renders as
# "Exited" instead of a phantom -100% loss.
exits = {
(a.user_id, a.entity_id): a.exited_on
for a in session.exec(
select(EntityAccess).where(
col(EntityAccess.user_id).in_({r.investor_user_id for r in rows}),
col(EntityAccess.exited_on).is_not(None),
)
).all()
} if rows else {}
out: list[CapitalAccountResponse] = [] out: list[CapitalAccountResponse] = []
for r in rows: for r in rows:
data = CapitalAccountResponse.model_validate(r, from_attributes=True) data = CapitalAccountResponse.model_validate(r, from_attributes=True)
data.investor_name = names.get(r.investor_user_id) data.investor_name = names.get(r.investor_user_id)
data.exited_on = exits.get((r.investor_user_id, r.entity_id))
out.append(data) out.append(data)
return out return out
+51 -4
View File
@@ -17,7 +17,8 @@ from ten31portal.models import (
) )
from ten31portal.schemas import ( from ten31portal.schemas import (
AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse, AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse,
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerResponse, EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerExitUpdate,
PartnerResponse,
) )
router = APIRouter(prefix="/api/entities", tags=["entities"]) router = APIRouter(prefix="/api/entities", tags=["entities"])
@@ -75,13 +76,21 @@ def entity_rollup(
last_signed_value_cents = int(val_sum) last_signed_value_cents = int(val_sum)
# Total committed capital = each investor's most recent commitment for this entity. # Total committed capital = each investor's most recent commitment for this entity.
# Exited members (stake sold/transferred) are skipped — their buyer's commitment now
# appears on the roster, so counting both would double the fund's committed total.
stmts = session.exec( stmts = session.exec(
select(CapitalAccountStatement) select(CapitalAccountStatement)
.where(CapitalAccountStatement.entity_id == ent.id) .where(CapitalAccountStatement.entity_id == ent.id)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr] .order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).all() ).all()
exited_ids = set(session.exec(
select(EntityAccess.user_id).where(
EntityAccess.entity_id == ent.id,
col(EntityAccess.exited_on).is_not(None),
)
).all())
committed_cents = 0 committed_cents = 0
seen_investors: set[int] = set() seen_investors: set[int] = set(exited_ids)
for st in stmts: for st in stmts:
if st.investor_user_id in seen_investors: if st.investor_user_id in seen_investors:
continue continue
@@ -114,14 +123,14 @@ def list_partners(
raise HTTPException(status_code=404, detail="Entity not found") raise HTTPException(status_code=404, detail="Entity not found")
members = session.exec( members = session.exec(
select(User) select(User, EntityAccess)
.join(EntityAccess, EntityAccess.user_id == User.id) .join(EntityAccess, EntityAccess.user_id == User.id)
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor) .where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
.order_by(User.name) # type: ignore[arg-type] .order_by(User.name) # type: ignore[arg-type]
).all() ).all()
result: list[PartnerResponse] = [] result: list[PartnerResponse] = []
for m in members: for m, access in members:
stmts = session.exec( stmts = session.exec(
select(CapitalAccountStatement) select(CapitalAccountStatement)
.where( .where(
@@ -144,10 +153,48 @@ def list_partners(
latest_value_cents=latest.ending_balance_cents if latest else None, latest_value_cents=latest.ending_balance_cents if latest else None,
latest_as_of=latest.as_of_date if latest else None, latest_as_of=latest.as_of_date if latest else None,
statements_count=len(stmts), statements_count=len(stmts),
exited_on=access.exited_on,
)) ))
return result return result
@router.put("/{entity_id}/partners/{user_id}/exited")
def set_partner_exited(
entity_id: int,
user_id: int,
body: PartnerExitUpdate,
admin: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> PartnerResponse:
"""Mark a member as exited from this fund (stake sold/transferred), or clear it.
Their statements and documents stay; the portal shows an Exited badge instead of a
phantom -100% and drops the position from portfolio and fund committed totals.
"""
access = session.exec(
select(EntityAccess).where(
EntityAccess.entity_id == entity_id, EntityAccess.user_id == user_id
)
).first()
if access is None:
raise HTTPException(status_code=404, detail="That member has no access to this fund")
member = session.get(User, user_id)
if member is None or member.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor members can be marked exited")
access.exited_on = body.exited_on
session.add(access)
record_audit(session, admin.id, "set_exited", "entity_access", access.id, {
"entity_id": entity_id,
"user_id": user_id,
"exited_on": str(body.exited_on) if body.exited_on else None,
})
session.commit()
# Return the member's refreshed partner row for easy UI updates.
return next(p for p in list_partners(entity_id, admin, session) if p.user_id == user_id)
@router.delete("/{entity_id}/partners") @router.delete("/{entity_id}/partners")
def clear_partners( def clear_partners(
entity_id: int, entity_id: int,
+8
View File
@@ -243,6 +243,9 @@ class CapitalAccountResponse(BaseModel):
ending_balance_cents: int ending_balance_cents: int
document_id: int | None document_id: int | None
created_at: datetime created_at: datetime
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
# "Exited" badge instead of a phantom -100% and drops the position from totals.
exited_on: date | None = None
# --- Partners (members of an entity) --- # --- Partners (members of an entity) ---
@@ -260,6 +263,11 @@ class PartnerResponse(BaseModel):
latest_value_cents: int | None latest_value_cents: int | None
latest_as_of: date | None latest_as_of: date | None
statements_count: int statements_count: int
exited_on: date | None = None
class PartnerExitUpdate(BaseModel):
exited_on: date | None # null clears the exit (marks the member active again)
# --- Access matrix --- # --- Access matrix ---
+99
View File
@@ -0,0 +1,99 @@
"""Exited positions (0.2.33): a member who sold/transferred their stake shows as exited
instead of a phantom -100% loss, and drops out of committed totals."""
from datetime import date
from sqlmodel import select
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
)
from tests.conftest import make_user
def _setup_fund(session, *, commitment=4_000_000_00, balance=0):
entity = Entity(name="Pawn Fund", type=EntityType.spv)
session.add(entity)
session.commit()
lp = make_user(session, username="seller", role=UserRole.investor, name="Seller LP")
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
session.add(CapitalAccountStatement(
entity_id=entity.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
commitment_cents=commitment, beginning_balance_cents=0,
contributions_cents=commitment, distributions_cents=0,
ending_balance_cents=balance,
))
session.commit()
return entity, lp
def test_mark_exited_flows_to_partners_and_statements(auth_client, session):
entity, lp = _setup_fund(session)
resp = auth_client.put(
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
json={"exited_on": "2026-05-15"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["exited_on"] == "2026-05-15"
partners = auth_client.get(f"/api/entities/{entity.id}/partners").json()
assert partners[0]["exited_on"] == "2026-05-15"
# The LP's own capital-account view carries the exit date.
lp_client = auth_client
lp_client.post("/api/auth/logout")
assert lp_client.post(
"/api/auth/login", json={"login": "seller", "password": "password123"}
).status_code == 200
accounts = lp_client.get("/api/capital-accounts").json()
assert accounts[0]["exited_on"] == "2026-05-15"
# Undo clears it.
lp_client.post("/api/auth/logout")
assert lp_client.post(
"/api/auth/login", json={"login": "approver", "password": "password123"}
).status_code == 200
resp = lp_client.put(
f"/api/entities/{entity.id}/partners/{lp.id}/exited", json={"exited_on": None}
)
assert resp.status_code == 200
assert resp.json()["exited_on"] is None
def test_exited_member_excluded_from_rollup_committed(auth_client, session):
entity, seller = _setup_fund(session)
# The buyer joins the roster with the same commitment (they bought the stake).
buyer = make_user(session, username="buyer", role=UserRole.investor, name="Buyer LP")
session.add(EntityAccess(user_id=buyer.id, entity_id=entity.id))
session.add(CapitalAccountStatement(
entity_id=entity.id, investor_user_id=buyer.id, as_of_date=date(2026, 6, 30),
commitment_cents=4_000_000_00, beginning_balance_cents=0,
contributions_cents=4_000_000_00, distributions_cents=0,
ending_balance_cents=4_200_000_00,
))
session.commit()
# Before the exit both commitments count — the double-count problem.
rollup = auth_client.get("/api/entities/rollup").json()
row = next(r for r in rollup if r["id"] == entity.id)
assert row["committed_cents"] == 8_000_000_00
assert auth_client.put(
f"/api/entities/{entity.id}/partners/{seller.id}/exited",
json={"exited_on": "2026-05-15"},
).status_code == 200
rollup = auth_client.get("/api/entities/rollup").json()
row = next(r for r in rollup if r["id"] == entity.id)
assert row["committed_cents"] == 4_000_000_00
def test_exited_requires_membership(auth_client, session):
entity, _ = _setup_fund(session)
stranger = make_user(session, username="stranger", role=UserRole.investor)
resp = auth_client.put(
f"/api/entities/{entity.id}/partners/{stranger.id}/exited",
json={"exited_on": "2026-05-15"},
)
assert resp.status_code == 404
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ten31portal-startos", "name": "ten31portal-startos",
"version": "0.2.32", "version": "0.2.33",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", "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_32 as current } from './v_0_2_32' export { v_0_2_33 as current } from './v_0_2_33'
import { v_0_1_0 } from './v_0_1_0' import { v_0_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0' import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1' import { v_0_2_1 } from './v_0_2_1'
@@ -31,4 +31,5 @@ import { v_0_2_28 } from './v_0_2_28'
import { v_0_2_29 } from './v_0_2_29' import { v_0_2_29 } from './v_0_2_29'
import { v_0_2_30 } from './v_0_2_30' import { v_0_2_30 } from './v_0_2_30'
import { v_0_2_31 } from './v_0_2_31' import { v_0_2_31 } from './v_0_2_31'
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, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31] import { v_0_2_32 } from './v_0_2_32'
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, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32]
@@ -0,0 +1,13 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_33 = VersionInfo.of({
version: '0.2.33:0',
releaseNotes: {
en_US:
"Exited positions: a member who sold or transferred their stake (secondary sale) can be marked 'Exited' on the fund's Partners tab. Their portal card shows a quiet Exited badge instead of a phantom -100% loss, documents stay available, and the position drops out of the investor's portfolio totals and the fund's committed totals (avoiding seller+buyer double-counting).",
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever) // - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached // - /api/* is never cached
// Bump CACHE on each release so old entries are purged. // Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.32' const CACHE = 'ten31-portal-0.2.33'
self.addEventListener('install', () => self.skipWaiting()) self.addEventListener('install', () => self.skipWaiting())
+7
View File
@@ -86,6 +86,7 @@ export interface Partner {
latest_value_cents: number | null; latest_value_cents: number | null;
latest_as_of: string | null; latest_as_of: string | null;
statements_count: number; statements_count: number;
exited_on: string | null;
} }
export interface AccessGrant { export interface AccessGrant {
@@ -160,6 +161,7 @@ export interface CapitalAccount {
ending_balance_cents: number; ending_balance_cents: number;
document_id: number | null; document_id: number | null;
created_at: string; created_at: string;
exited_on: string | null;
} }
export interface Entity { export interface Entity {
@@ -313,6 +315,11 @@ export const api = {
`/api/entities/${entityId}/partners`, `/api/entities/${entityId}/partners`,
{ method: "DELETE" }, { 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>) => createEntity: (data: Partial<Entity>) =>
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }), request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
updateEntity: (id: number, data: Partial<Entity>) => updateEntity: (id: number, data: Partial<Entity>) =>
+80 -5
View File
@@ -14,6 +14,9 @@ export default function EntityPartners() {
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [loading, setLoading] = useState(true); 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); 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>; 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); // Exited members (stake sold/transferred) stay on the roster but out of the totals —
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0); // 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 ( return (
<div> <div>
@@ -78,6 +97,11 @@ export default function EntityPartners() {
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span> Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
<span className="mx-2 text-gray-300">·</span> <span className="mx-2 text-gray-300">·</span>
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</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> </p>
{isWriter && partners.length > 0 && ( {isWriter && partners.length > 0 && (
<button <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">Member</th>
<th className="px-4 py-2 font-medium">Investor ID</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">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">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">Committed</th>
<th className="px-4 py-2 font-medium text-right">Paid-in</th> <th className="px-4 py-2 font-medium text-right">Paid-in</th>
@@ -107,9 +132,9 @@ export default function EntityPartners() {
</thead> </thead>
<tbody> <tbody>
{partners.map((p) => ( {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"> <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> <div className="text-xs text-gray-400">{p.username}</div>
</td> </td>
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</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> <span className="text-amber-600">No login yet</span>
)} )}
</td> </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"> <td className="px-4 py-2 text-gray-500">
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"} {p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
</td> </td>
@@ -141,7 +216,7 @@ export default function EntityPartners() {
))} ))}
{partners.length === 0 && ( {partners.length === 0 && (
<tr> <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 No members yet. Import the eNAV ALLOC SI tab from Import Statements, or grant
access on the Access Grid. access on the Access Grid.
</td> </td>
+47 -2
View File
@@ -27,9 +27,23 @@ export default function InvestorPortalView({
// When this login covers several legal names (e.g. an IRA and a trust), label each block. // When this login covers several legal names (e.g. an IRA and a trust), label each block.
const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1; const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1;
// Exited positions (stake sold/transferred) keep their card + documents but stay out of
// the portfolio totals — a $0 balance from a sale is not a loss.
const activeAccounts = accounts.filter((a) => !a.exited_on);
const activeEntityCount = new Set(activeAccounts.map((a) => a.entity_id)).size;
const exitedPositions = new Set(
accounts.filter((a) => a.exited_on).map((a) => `${a.entity_id}:${a.investor_user_id}`),
).size;
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{entities.length > 1 && <PortfolioSummary accounts={accounts} count={entities.length} />} {activeEntityCount > 1 && (
<PortfolioSummary
accounts={activeAccounts}
count={activeEntityCount}
exitedCount={exitedPositions}
/>
)}
{entities.map((e) => ( {entities.map((e) => (
<FundSection <FundSection
key={e.id} key={e.id}
@@ -44,7 +58,15 @@ export default function InvestorPortalView({
} }
// One quiet navy card answering the multi-fund LP's first question: the total across Ten31. // One quiet navy card answering the multi-fund LP's first question: the total across Ten31.
function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; count: number }) { function PortfolioSummary({
accounts,
count,
exitedCount,
}: {
accounts: CapitalAccount[];
count: number;
exitedCount: number;
}) {
// Latest statement per (fund, legal name); summed across all of them. // Latest statement per (fund, legal name); summed across all of them.
const totals = useMemo(() => { const totals = useMemo(() => {
const latest = new Map<string, CapitalAccount>(); const latest = new Map<string, CapitalAccount>();
@@ -100,6 +122,11 @@ function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; cou
)} )}
</div> </div>
</div> </div>
{exitedCount > 0 && (
<p className="mt-3 text-xs text-brand-300">
Excludes {exitedCount} exited position{exitedCount === 1 ? "" : "s"}.
</p>
)}
</section> </section>
); );
} }
@@ -238,6 +265,24 @@ function CapitalBlock({
// Collapsed by default so the portal opens clean; the investor expands the trend per fund. // Collapsed by default so the portal opens clean; the investor expands the trend per fund.
const [showChart, setShowChart] = useState(false); const [showChart, setShowChart] = useState(false);
// A sold/transferred stake: the fund's books show $0 with no distribution, which is not a
// loss. Show a quiet Exited badge and keep the documents; no balance, no gain/loss.
if (latest.exited_on) {
return (
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
<p className="mt-2">
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-2 py-1 rounded">
Exited {formatDate(latest.exited_on)}
</span>
</p>
<p className="text-sm text-gray-400 mt-2">
This position was sold or transferred. Documents remain available below.
</p>
</div>
);
}
return ( return (
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}> <div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
{label && <p className="text-sm font-medium text-gray-700">{label}</p>} {label && <p className="text-sm font-medium text-gray-700">{label}</p>}
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI. // Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version, // If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served. // the new frontend isn't actually being served.
export const APP_VERSION = "0.2.32"; export const APP_VERSION = "0.2.33";