diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index acc41a1..80a1cb6 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -21,6 +21,22 @@ def _dollars_to_cents(dollars: float) -> int: return round(dollars * 100) +def exit_dates(session: Session, rows) -> dict: + """(investor_user_id, entity_id) -> exited_on for every exited pair in these statements, + so a sold/transferred stake renders as "Exited" instead of a phantom -100% loss.""" + if not rows: + return {} + return { + (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() + } + + @router.get("") def list_statements( entity_id: int | None = None, @@ -57,17 +73,7 @@ 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 {} + exits = exit_dates(session, rows) out: list[CapitalAccountResponse] = [] for r in rows: data = CapitalAccountResponse.model_validate(r, from_attributes=True) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py index 9c9c89a..9dbed05 100644 --- a/backend/ten31portal/routers/user_router.py +++ b/backend/ten31portal/routers/user_router.py @@ -144,9 +144,14 @@ def investor_view( col(User.id).in_({r.investor_user_id for r in cap_rows}) ) ).all()) if cap_rows else {} + # Mirror the investor's own portal exactly — including exit status, so an exited + # position shows its badge here too instead of a phantom -100%. + from ten31portal.routers.capital_account_router import exit_dates + exits = exit_dates(session, cap_rows) for r in cap_rows: d = CapitalAccountResponse.model_validate(r, from_attributes=True) d.investor_name = names.get(r.investor_user_id) + d.exited_on = exits.get((r.investor_user_id, r.entity_id)) caps.append(d) doc_rows = session.exec( diff --git a/backend/tests/test_exited.py b/backend/tests/test_exited.py index f681615..248aaa9 100644 --- a/backend/tests/test_exited.py +++ b/backend/tests/test_exited.py @@ -61,6 +61,20 @@ def test_mark_exited_flows_to_partners_and_statements(auth_client, session): 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). diff --git a/deploy/package.json b/deploy/package.json index 6f9084d..6aaf228 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.35", + "version": "0.2.36", "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 a55a7e8..8748dc9 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_35 as current } from './v_0_2_35' +export { v_0_2_36 as current } from './v_0_2_36' 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' @@ -34,4 +34,5 @@ import { v_0_2_31 } from './v_0_2_31' import { v_0_2_32 } from './v_0_2_32' import { v_0_2_33 } from './v_0_2_33' import { v_0_2_34 } from './v_0_2_34' -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, v_0_2_34] +import { v_0_2_35 } from './v_0_2_35' +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, v_0_2_34, v_0_2_35] diff --git a/deploy/startos/install/versions/v_0_2_36.ts b/deploy/startos/install/versions/v_0_2_36.ts new file mode 100644 index 0000000..296d839 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_36.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_36 = VersionInfo.of({ + version: '0.2.36:0', + releaseNotes: { + en_US: + "Fix: the admin's read-only Investor View now carries exit status, so an exited position shows its Exited badge there exactly as the LP sees it (it previously showed the active card with $0s). A fund card where every position is exited is now greyed out so it reads as closed at a glance; documents inside stay fully usable.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 51d5e5f..2039b3e 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.35' +const CACHE = 'ten31-portal-0.2.36' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/portal/InvestorPortalView.tsx b/frontend/src/portal/InvestorPortalView.tsx index c56e8b5..d778ff1 100644 --- a/frontend/src/portal/InvestorPortalView.tsx +++ b/frontend/src/portal/InvestorPortalView.tsx @@ -152,10 +152,18 @@ function FundSection({ return [...groups.values()]; }, [accounts]); + // When every position in this fund has been sold/transferred, grey the whole card so it + // reads as closed at a glance — documents inside stay fully usable. + const allExited = byName.length > 0 && byName.every((g) => g[0].exited_on); + return ( -
+
-

{entity.name}

+

+ {entity.name} +

{entity.type}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts index e37b1fe..de21b28 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.35"; +export const APP_VERSION = "0.2.36";