From 6313d781b48e58b47c6a9cd59752fd45eed25b39 Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Fri, 3 Jul 2026 09:03:49 -0500 Subject: [PATCH] 0.2.34: manage exited status from the Capital Accounts view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eNAV keeps listing exited members each quarter, so the admin needs the exit control where the statements live: - Capital Accounts table gains a Status column with the same Exited-badge / mark / undo flow as the Partners tab (keyed per investor+fund pair — marking any statement row marks them all) - set_partner_exited now creates the access row (flag set) when a manually-entered investor has statements but no roster entry yet; clearing a never-set exit stays 404 Co-Authored-By: Claude Opus 4.8 --- backend/ten31portal/routers/entity_router.py | 18 ++++-- backend/tests/test_exited.py | 25 ++++++-- deploy/package.json | 2 +- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_34.ts | 13 ++++ frontend/public/sw.js | 2 +- frontend/src/pages/CapitalAccounts.tsx | 63 +++++++++++++++++++- frontend/src/version.ts | 2 +- 8 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 deploy/startos/install/versions/v_0_2_34.ts diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 439385d..4813610 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -171,16 +171,26 @@ def set_partner_exited( 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. """ + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + 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 = 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") + # A manually-entered investor may have statements without an access grant yet + # (e.g. from the Capital Accounts screen). Marking them exited creates the roster + # row with the flag set; clearing an exit that doesn't exist stays a 404. + if body.exited_on is None: + raise HTTPException(status_code=404, detail="That member has no access to this fund") + access = EntityAccess(user_id=user_id, entity_id=entity_id) + session.add(access) + session.flush() access.exited_on = body.exited_on session.add(access) diff --git a/backend/tests/test_exited.py b/backend/tests/test_exited.py index f8b4a94..f681615 100644 --- a/backend/tests/test_exited.py +++ b/backend/tests/test_exited.py @@ -89,11 +89,28 @@ def test_exited_member_excluded_from_rollup_committed(auth_client, session): assert row["committed_cents"] == 4_000_000_00 -def test_exited_requires_membership(auth_client, session): +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) - stranger = make_user(session, username="stranger", role=UserRole.investor) + manual = make_user(session, username="manual-lp", role=UserRole.investor) + resp = auth_client.put( - f"/api/entities/{entity.id}/partners/{stranger.id}/exited", - json={"exited_on": "2026-05-15"}, + 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" diff --git a/deploy/package.json b/deploy/package.json index ff6f5bc..a937ea2 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.33", + "version": "0.2.34", "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 fbc3bfc..c96d86b 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_33 as current } from './v_0_2_33' +export { v_0_2_34 as current } from './v_0_2_34' 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' @@ -32,4 +32,5 @@ 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' 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] +import { v_0_2_33 } from './v_0_2_33' +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, v_0_2_33] diff --git a/deploy/startos/install/versions/v_0_2_34.ts b/deploy/startos/install/versions/v_0_2_34.ts new file mode 100644 index 0000000..8a6d2ba --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_34.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_34 = VersionInfo.of({ + version: '0.2.34:0', + releaseNotes: { + en_US: + "Exited positions can now also be managed from the admin Capital Accounts view: each statement row shows the member's Exited status with the same mark/undo control as the Partners tab, so re-imported eNAV rows carry the badge forward without affecting paid-in totals. Marking a manually-entered investor exited creates their roster row automatically.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index e91668c..5426b74 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.33' +const CACHE = 'ten31-portal-0.2.34' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/pages/CapitalAccounts.tsx b/frontend/src/pages/CapitalAccounts.tsx index e546c47..6da7a67 100644 --- a/frontend/src/pages/CapitalAccounts.tsx +++ b/frontend/src/pages/CapitalAccounts.tsx @@ -7,6 +7,10 @@ export default function CapitalAccounts() { const [users, setUsers] = useState([]); const [rows, setRows] = useState([]); const [error, setError] = useState(""); + // Exit status is per investor+fund (from the eNAV the admin keeps re-importing), so the + // inline picker is keyed on that pair — marking any statement row marks them all. + const [exitingKey, setExitingKey] = useState(null); + const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10)); const investors = useMemo(() => users.filter((u) => u.role === "investor"), [users]); const userById = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]); @@ -15,6 +19,17 @@ export default function CapitalAccounts() { const load = () => { api.listCapitalAccounts().then(setRows).catch((e) => setError(e.message)); }; + + const saveExit = async (r: CapitalAccount, exitedOn: string | null) => { + setError(""); + try { + await api.setPartnerExited(r.entity_id, r.investor_user_id, exitedOn); + setExitingKey(null); + load(); + } catch (e: any) { + setError(e.message || "Failed to update exit status"); + } + }; useEffect(() => { api.listEntities().then(setEntities).catch(() => {}); api.listUsers().then(setUsers).catch(() => {}); @@ -48,13 +63,14 @@ export default function CapitalAccounts() { Contributions Distributions Ending balance + Status {rows.map((r) => ( - + {userById.get(r.investor_user_id)?.name ?? r.investor_user_id} {entityById.get(r.entity_id)?.name ?? r.entity_id} @@ -64,6 +80,49 @@ export default function CapitalAccounts() { {formatMoneyExact(r.ending_balance_cents)} + + {r.exited_on ? ( + + + Exited {formatDate(r.exited_on)} + + + + ) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? ( + + setExitDate(e.target.value)} + className="px-1.5 py-0.5 border border-gray-300 rounded text-xs" + /> + + + + ) : ( + + )} +