0.2.36: Investor View carries exit status + grey out fully-exited fund cards

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>
This commit is contained in:
Jonathan Kirkwood
2026-07-03 09:35:03 -05:00
co-authored by Claude Opus 4.8
parent 2c87ab499a
commit 1deb6586b0
9 changed files with 65 additions and 18 deletions
@@ -21,6 +21,22 @@ def _dollars_to_cents(dollars: float) -> int:
return round(dollars * 100) 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("") @router.get("")
def list_statements( def list_statements(
entity_id: int | None = None, entity_id: int | None = None,
@@ -57,17 +73,7 @@ def list_statements(
col(User.id).in_({r.investor_user_id for r in rows}) col(User.id).in_({r.investor_user_id for r in rows})
) )
).all()) if rows else {} ).all()) if rows else {}
# And each (investor, entity)'s exit date, so a sold/transferred stake renders as exits = exit_dates(session, rows)
# "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] = [] out: list[CapitalAccountResponse] = []
for r in rows: for r in rows:
data = CapitalAccountResponse.model_validate(r, from_attributes=True) data = CapitalAccountResponse.model_validate(r, from_attributes=True)
@@ -144,9 +144,14 @@ def investor_view(
col(User.id).in_({r.investor_user_id for r in cap_rows}) col(User.id).in_({r.investor_user_id for r in cap_rows})
) )
).all()) if cap_rows else {} ).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: for r in cap_rows:
d = CapitalAccountResponse.model_validate(r, from_attributes=True) d = CapitalAccountResponse.model_validate(r, from_attributes=True)
d.investor_name = names.get(r.investor_user_id) d.investor_name = names.get(r.investor_user_id)
d.exited_on = exits.get((r.investor_user_id, r.entity_id))
caps.append(d) caps.append(d)
doc_rows = session.exec( doc_rows = session.exec(
+14
View File
@@ -61,6 +61,20 @@ def test_mark_exited_flows_to_partners_and_statements(auth_client, session):
assert resp.json()["exited_on"] is None 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): def test_exited_member_excluded_from_rollup_committed(auth_client, session):
entity, seller = _setup_fund(session) entity, seller = _setup_fund(session)
# The buyer joins the roster with the same commitment (they bought the stake). # The buyer joins the roster with the same commitment (they bought the stake).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ten31portal-startos", "name": "ten31portal-startos",
"version": "0.2.35", "version": "0.2.36",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+3 -2
View File
@@ -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_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0' import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1' 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_32 } from './v_0_2_32'
import { v_0_2_33 } from './v_0_2_33' import { v_0_2_33 } from './v_0_2_33'
import { v_0_2_34 } from './v_0_2_34' 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]
@@ -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 }) => {},
},
})
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever) // - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached // - /api/* is never cached
// Bump CACHE on each release so old entries are purged. // 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()) self.addEventListener('install', () => self.skipWaiting())
+10 -2
View File
@@ -152,10 +152,18 @@ function FundSection({
return [...groups.values()]; return [...groups.values()];
}, [accounts]); }, [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 ( return (
<section className="bg-white border border-gray-200 rounded-lg p-5"> <section
className={`border border-gray-200 rounded-lg p-5 ${allExited ? "bg-gray-50" : "bg-white"}`}
>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2> <h2 className={`text-lg font-semibold ${allExited ? "text-gray-500" : "text-gray-900"}`}>
{entity.name}
</h2>
<span className="text-xs text-gray-400 uppercase">{entity.type}</span> <span className="text-xs text-gray-400 uppercase">{entity.type}</span>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI. // 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, // If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served. // the new frontend isn't actually being served.
export const APP_VERSION = "0.2.35"; export const APP_VERSION = "0.2.36";