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
+4
View File
@@ -175,6 +175,10 @@ class EntityAccess(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id")
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)
@@ -10,7 +10,7 @@ from ten31portal.auth import (
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, User, UserRole,
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
)
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})
)
).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] = []
for r in rows:
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
data.investor_name = names.get(r.investor_user_id)
data.exited_on = exits.get((r.investor_user_id, r.entity_id))
out.append(data)
return out
+51 -4
View File
@@ -17,7 +17,8 @@ from ten31portal.models import (
)
from ten31portal.schemas import (
AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse,
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerResponse,
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerExitUpdate,
PartnerResponse,
)
router = APIRouter(prefix="/api/entities", tags=["entities"])
@@ -75,13 +76,21 @@ def entity_rollup(
last_signed_value_cents = int(val_sum)
# 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(
select(CapitalAccountStatement)
.where(CapitalAccountStatement.entity_id == ent.id)
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
).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
seen_investors: set[int] = set()
seen_investors: set[int] = set(exited_ids)
for st in stmts:
if st.investor_user_id in seen_investors:
continue
@@ -114,14 +123,14 @@ def list_partners(
raise HTTPException(status_code=404, detail="Entity not found")
members = session.exec(
select(User)
select(User, EntityAccess)
.join(EntityAccess, EntityAccess.user_id == User.id)
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
.order_by(User.name) # type: ignore[arg-type]
).all()
result: list[PartnerResponse] = []
for m in members:
for m, access in members:
stmts = session.exec(
select(CapitalAccountStatement)
.where(
@@ -144,10 +153,48 @@ def list_partners(
latest_value_cents=latest.ending_balance_cents if latest else None,
latest_as_of=latest.as_of_date if latest else None,
statements_count=len(stmts),
exited_on=access.exited_on,
))
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")
def clear_partners(
entity_id: int,
+8
View File
@@ -243,6 +243,9 @@ class CapitalAccountResponse(BaseModel):
ending_balance_cents: int
document_id: int | None
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) ---
@@ -260,6 +263,11 @@ class PartnerResponse(BaseModel):
latest_value_cents: int | None
latest_as_of: date | None
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 ---