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>
100 lines
3.8 KiB
Python
100 lines
3.8 KiB
Python
"""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
|