"""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_without_access_row(auth_client, session): """A manually-entered investor may have statements but no access grant yet — marking them exited from the Capital Accounts screen creates the roster row with the flag set. Clearing an exit that was never set stays a 404.""" entity, _ = _setup_fund(session) manual = make_user(session, username="manual-lp", role=UserRole.investor) resp = auth_client.put( f"/api/entities/{entity.id}/partners/{manual.id}/exited", json={"exited_on": None}, ) assert resp.status_code == 404 resp = auth_client.put( f"/api/entities/{entity.id}/partners/{manual.id}/exited", json={"exited_on": "2026-05-15"}, ) assert resp.status_code == 200, resp.text assert resp.json()["exited_on"] == "2026-05-15" access = session.exec( select(EntityAccess).where( EntityAccess.entity_id == entity.id, EntityAccess.user_id == manual.id ) ).one() assert str(access.exited_on) == "2026-05-15"