0.2.34: manage exited status from the Capital Accounts view
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a639ba14cf
commit
6313d781b4
@@ -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:
|
||||
# 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")
|
||||
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 = EntityAccess(user_id=user_id, entity_id=entity_id)
|
||||
session.add(access)
|
||||
session.flush()
|
||||
|
||||
access.exited_on = body.exited_on
|
||||
session.add(access)
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }) => {},
|
||||
},
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ export default function CapitalAccounts() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [rows, setRows] = useState<CapitalAccount[]>([]);
|
||||
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<string | null>(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() {
|
||||
<th className="px-4 py-2 font-medium text-right">Contributions</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Distributions</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Ending balance</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-t border-gray-100">
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
<td className={`px-4 py-2 ${r.exited_on ? "text-gray-500" : "text-gray-900"}`}>
|
||||
{userById.get(r.investor_user_id)?.name ?? r.investor_user_id}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{entityById.get(r.entity_id)?.name ?? r.entity_id}</td>
|
||||
@@ -64,6 +80,49 @@ export default function CapitalAccounts() {
|
||||
<td className="px-4 py-2 text-right text-gray-900 font-medium">
|
||||
{formatMoneyExact(r.ending_balance_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{r.exited_on ? (
|
||||
<span>
|
||||
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
Exited {formatDate(r.exited_on)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => saveExit(r, null)}
|
||||
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</span>
|
||||
) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={exitDate}
|
||||
onChange={(e) => setExitDate(e.target.value)}
|
||||
className="px-1.5 py-0.5 border border-gray-300 rounded text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => exitDate && saveExit(r, exitDate)}
|
||||
className="text-xs text-accent-600 hover:text-accent-700 font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExitingKey(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setExitingKey(`${r.entity_id}:${r.investor_user_id}`)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Mark exited
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -79,7 +138,7 @@ export default function CapitalAccounts() {
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-6 text-center text-gray-400">
|
||||
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
|
||||
No statements yet.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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.33";
|
||||
export const APP_VERSION = "0.2.34";
|
||||
|
||||
Reference in New Issue
Block a user