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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user