From a639ba14cfd6458b020b19b233cf2daa4b2915df Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Fri, 3 Jul 2026 08:54:19 -0500 Subject: [PATCH] 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 " 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 --- .../versions/e1f2a3b4c5d6_access_exited_on.py | 27 +++++ backend/ten31portal/models.py | 4 + .../routers/capital_account_router.py | 14 ++- backend/ten31portal/routers/entity_router.py | 55 ++++++++++- backend/ten31portal/schemas.py | 8 ++ backend/tests/test_exited.py | 99 +++++++++++++++++++ deploy/package.json | 2 +- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_33.ts | 13 +++ frontend/public/sw.js | 2 +- frontend/src/api.ts | 7 ++ frontend/src/pages/EntityPartners.tsx | 85 +++++++++++++++- frontend/src/portal/InvestorPortalView.tsx | 49 ++++++++- frontend/src/version.ts | 2 +- 14 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 backend/alembic/versions/e1f2a3b4c5d6_access_exited_on.py create mode 100644 backend/tests/test_exited.py create mode 100644 deploy/startos/install/versions/v_0_2_33.ts diff --git a/backend/alembic/versions/e1f2a3b4c5d6_access_exited_on.py b/backend/alembic/versions/e1f2a3b4c5d6_access_exited_on.py new file mode 100644 index 0000000..0d30f6d --- /dev/null +++ b/backend/alembic/versions/e1f2a3b4c5d6_access_exited_on.py @@ -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') diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index de014d0..61767d4 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -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) diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index f37472a..acc41a1 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -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 diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index d632dea..439385d 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -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, diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index 9bb79eb..aaa2c62 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -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 --- diff --git a/backend/tests/test_exited.py b/backend/tests/test_exited.py new file mode 100644 index 0000000..f8b4a94 --- /dev/null +++ b/backend/tests/test_exited.py @@ -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 diff --git a/deploy/package.json b/deploy/package.json index e295c52..ff6f5bc 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.32", + "version": "0.2.33", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 0ad6a8b..fbc3bfc 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_32 as current } from './v_0_2_32' +export { v_0_2_33 as current } from './v_0_2_33' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' @@ -31,4 +31,5 @@ import { v_0_2_28 } from './v_0_2_28' import { v_0_2_29 } from './v_0_2_29' import { v_0_2_30 } from './v_0_2_30' import { v_0_2_31 } from './v_0_2_31' -export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31] +import { v_0_2_32 } from './v_0_2_32' +export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32] diff --git a/deploy/startos/install/versions/v_0_2_33.ts b/deploy/startos/install/versions/v_0_2_33.ts new file mode 100644 index 0000000..9d28a14 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_33.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_33 = VersionInfo.of({ + version: '0.2.33:0', + releaseNotes: { + en_US: + "Exited positions: a member who sold or transferred their stake (secondary sale) can be marked 'Exited' on the fund's Partners tab. Their portal card shows a quiet Exited badge instead of a phantom -100% loss, documents stay available, and the position drops out of the investor's portfolio totals and the fund's committed totals (avoiding seller+buyer double-counting).", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 9310366..e91668c 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.32' +const CACHE = 'ten31-portal-0.2.33' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6c48849..9baa08e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -86,6 +86,7 @@ export interface Partner { latest_value_cents: number | null; latest_as_of: string | null; statements_count: number; + exited_on: string | null; } export interface AccessGrant { @@ -160,6 +161,7 @@ export interface CapitalAccount { ending_balance_cents: number; document_id: number | null; created_at: string; + exited_on: string | null; } export interface Entity { @@ -313,6 +315,11 @@ export const api = { `/api/entities/${entityId}/partners`, { method: "DELETE" }, ), + setPartnerExited: (entityId: number, userId: number, exitedOn: string | null) => + request(`/api/entities/${entityId}/partners/${userId}/exited`, { + method: "PUT", + body: JSON.stringify({ exited_on: exitedOn }), + }), createEntity: (data: Partial) => request("/api/entities", { method: "POST", body: JSON.stringify(data) }), updateEntity: (id: number, data: Partial) => diff --git a/frontend/src/pages/EntityPartners.tsx b/frontend/src/pages/EntityPartners.tsx index 3da6068..17ce913 100644 --- a/frontend/src/pages/EntityPartners.tsx +++ b/frontend/src/pages/EntityPartners.tsx @@ -14,6 +14,9 @@ export default function EntityPartners() { const [notice, setNotice] = useState(""); const [clearing, setClearing] = useState(false); const [loading, setLoading] = useState(true); + // Row currently getting an exit date (inline date picker), and the chosen date. + const [exitingId, setExitingId] = useState(null); + const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10)); const isWriter = !!user && canEditRound(user.role); @@ -57,10 +60,26 @@ export default function EntityPartners() { } } + async function saveExit(userId: number, exitedOn: string | null) { + if (!entity) return; + setError(""); + try { + const updated = await api.setPartnerExited(entity.id, userId, exitedOn); + setPartners((prev) => prev.map((p) => (p.user_id === userId ? updated : p))); + setExitingId(null); + } catch (err: any) { + setError(err.message || "Failed to update exit status"); + } + } + if (loading || !entity) return
Loading…
; - const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0); - const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0); + // Exited members (stake sold/transferred) stay on the roster but out of the totals — + // otherwise a seller and their buyer would both be counted. + const active = partners.filter((p) => !p.exited_on); + const exitedCount = partners.length - active.length; + const totalCommitted = active.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0); + const totalCapital = active.reduce((s, p) => s + (p.latest_value_cents || 0), 0); return (
@@ -78,6 +97,11 @@ export default function EntityPartners() { Total committed: {formatMoneyExact(totalCommitted)} · Total capital: {formatMoneyExact(totalCapital)} + {exitedCount > 0 && ( + + (excludes {exitedCount} exited) + + )}

{isWriter && partners.length > 0 && ( + )} + + ) : exitingId === p.user_id ? ( + + setExitDate(e.target.value)} + className="px-1.5 py-0.5 border border-gray-300 rounded text-xs" + /> + + + + ) : ( + + Member + {isWriter && ( + + )} + + )} + {p.latest_as_of ? formatDate(p.latest_as_of) : "—"} @@ -141,7 +216,7 @@ export default function EntityPartners() { ))} {partners.length === 0 && ( - + No members yet. Import the eNAV ALLOC SI tab from “Import Statements,” or grant access on the Access Grid. diff --git a/frontend/src/portal/InvestorPortalView.tsx b/frontend/src/portal/InvestorPortalView.tsx index 8252f4c..8f1d94f 100644 --- a/frontend/src/portal/InvestorPortalView.tsx +++ b/frontend/src/portal/InvestorPortalView.tsx @@ -27,9 +27,23 @@ export default function InvestorPortalView({ // When this login covers several legal names (e.g. an IRA and a trust), label each block. const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1; + // Exited positions (stake sold/transferred) keep their card + documents but stay out of + // the portfolio totals — a $0 balance from a sale is not a loss. + const activeAccounts = accounts.filter((a) => !a.exited_on); + const activeEntityCount = new Set(activeAccounts.map((a) => a.entity_id)).size; + const exitedPositions = new Set( + accounts.filter((a) => a.exited_on).map((a) => `${a.entity_id}:${a.investor_user_id}`), + ).size; + return (
- {entities.length > 1 && } + {activeEntityCount > 1 && ( + + )} {entities.map((e) => ( { const latest = new Map(); @@ -100,6 +122,11 @@ function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; cou )}
+ {exitedCount > 0 && ( +

+ Excludes {exitedCount} exited position{exitedCount === 1 ? "" : "s"}. +

+ )} ); } @@ -238,6 +265,24 @@ function CapitalBlock({ // Collapsed by default so the portal opens clean; the investor expands the trend per fund. const [showChart, setShowChart] = useState(false); + // A sold/transferred stake: the fund's books show $0 with no distribution, which is not a + // loss. Show a quiet Exited badge and keep the documents; no balance, no gain/loss. + if (latest.exited_on) { + return ( +
+ {label &&

{label}

} +

+ + Exited {formatDate(latest.exited_on)} + +

+

+ This position was sold or transferred. Documents remain available below. +

+
+ ); + } + return (
{label &&

{label}

} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 4b44ed4..8ba0afb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ // Bumped each release so the running build is visible in the UI. // If the number shown in the app doesn't match the installed s9pk version, // the new frontend isn't actually being served. -export const APP_VERSION = "0.2.32"; +export const APP_VERSION = "0.2.33";