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:
co-authored by
Claude Opus 4.8
parent
2c87ab499a
commit
1deb6586b0
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user