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:
co-authored by
Claude Opus 4.8
parent
4215d4478f
commit
a639ba14cf
@@ -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')
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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>) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user