The admin read-only Investor View built capital-account responses without exited_on, so an exited position showed the active card with $0s (the LP's own portal was correct). Extracted exit_dates() into capital_account_router and stamp it in investor_view too; regression test added. A fund card where every position is exited now renders greyed (bg + title) so it reads as closed at a glance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
5.1 KiB
Python
131 lines
5.1 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_investor_view_mirrors_exit(auth_client, session):
|
|
"""The admin's read-only Investor View must carry exited_on exactly like the LP's own
|
|
portal — it was missing there (the bug behind the 'active card despite exit' report)."""
|
|
entity, lp = _setup_fund(session)
|
|
assert auth_client.put(
|
|
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
|
|
json={"exited_on": "2026-05-15"},
|
|
).status_code == 200
|
|
|
|
view = auth_client.get(f"/api/users/{lp.id}/investor-view").json()
|
|
assert view["capital_accounts"], "expected the LP's statements in the view"
|
|
assert all(c["exited_on"] == "2026-05-15" for c in view["capital_accounts"])
|
|
|
|
|
|
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"
|