0.2.33: exited positions (secondary-sale edge case)
A member who sold/transferred their stake showed a phantom -100% loss
($0 ending balance, no distribution through the fund). Now:
- entity_access.exited_on (migration e1f2a3b4c5d6), set/cleared from the
Partners tab (writer-only, inline date picker, audited)
- LP portal card shows a quiet "Exited <date>" badge, keeps documents,
hides balance/gain
- portfolio summary excludes exited positions ("Excludes N exited")
- fund committed totals (rollup + Partners tab) skip exited members so
seller + buyer are not double-counted
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4215d4478f
commit
a639ba14cf
@@ -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.32'
|
||||
const CACHE = 'ten31-portal-0.2.33'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface Partner {
|
||||
latest_value_cents: number | null;
|
||||
latest_as_of: string | null;
|
||||
statements_count: number;
|
||||
exited_on: string | null;
|
||||
}
|
||||
|
||||
export interface AccessGrant {
|
||||
@@ -160,6 +161,7 @@ export interface CapitalAccount {
|
||||
ending_balance_cents: number;
|
||||
document_id: number | null;
|
||||
created_at: string;
|
||||
exited_on: string | null;
|
||||
}
|
||||
|
||||
export interface Entity {
|
||||
@@ -313,6 +315,11 @@ export const api = {
|
||||
`/api/entities/${entityId}/partners`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
setPartnerExited: (entityId: number, userId: number, exitedOn: string | null) =>
|
||||
request<Partner>(`/api/entities/${entityId}/partners/${userId}/exited`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ exited_on: exitedOn }),
|
||||
}),
|
||||
createEntity: (data: Partial<Entity>) =>
|
||||
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateEntity: (id: number, data: Partial<Entity>) =>
|
||||
|
||||
@@ -14,6 +14,9 @@ export default function EntityPartners() {
|
||||
const [notice, setNotice] = useState("");
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Row currently getting an exit date (inline date picker), and the chosen date.
|
||||
const [exitingId, setExitingId] = useState<number | null>(null);
|
||||
const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
|
||||
@@ -57,10 +60,26 @@ export default function EntityPartners() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExit(userId: number, exitedOn: string | null) {
|
||||
if (!entity) return;
|
||||
setError("");
|
||||
try {
|
||||
const updated = await api.setPartnerExited(entity.id, userId, exitedOn);
|
||||
setPartners((prev) => prev.map((p) => (p.user_id === userId ? updated : p)));
|
||||
setExitingId(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update exit status");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !entity) return <div className="text-gray-500 text-sm">Loading…</div>;
|
||||
|
||||
const totalCommitted = partners.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||
const totalCapital = partners.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
|
||||
// Exited members (stake sold/transferred) stay on the roster but out of the totals —
|
||||
// otherwise a seller and their buyer would both be counted.
|
||||
const active = partners.filter((p) => !p.exited_on);
|
||||
const exitedCount = partners.length - active.length;
|
||||
const totalCommitted = active.reduce((s, p) => s + (p.latest_commitment_cents || 0), 0);
|
||||
const totalCapital = active.reduce((s, p) => s + (p.latest_value_cents || 0), 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -78,6 +97,11 @@ export default function EntityPartners() {
|
||||
Total committed: <span className="font-medium">{formatMoneyExact(totalCommitted)}</span>
|
||||
<span className="mx-2 text-gray-300">·</span>
|
||||
Total capital: <span className="font-medium">{formatMoneyExact(totalCapital)}</span>
|
||||
{exitedCount > 0 && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
(excludes {exitedCount} exited)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{isWriter && partners.length > 0 && (
|
||||
<button
|
||||
@@ -98,6 +122,7 @@ export default function EntityPartners() {
|
||||
<th className="px-4 py-2 font-medium">Member</th>
|
||||
<th className="px-4 py-2 font-medium">Investor ID</th>
|
||||
<th className="px-4 py-2 font-medium">Login</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">As of</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Committed</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Paid-in</th>
|
||||
@@ -107,9 +132,9 @@ export default function EntityPartners() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{partners.map((p) => (
|
||||
<tr key={p.user_id} className="border-t border-gray-100">
|
||||
<tr key={p.user_id} className={`border-t border-gray-100 ${p.exited_on ? "text-gray-400" : ""}`}>
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-gray-900">{p.name}</div>
|
||||
<div className={p.exited_on ? "text-gray-500" : "text-gray-900"}>{p.name}</div>
|
||||
<div className="text-xs text-gray-400">{p.username}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600">{p.external_investor_id ?? "—"}</td>
|
||||
@@ -122,6 +147,56 @@ export default function EntityPartners() {
|
||||
<span className="text-amber-600">No login yet</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{p.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(p.exited_on)}
|
||||
</span>
|
||||
{isWriter && (
|
||||
<button
|
||||
onClick={() => saveExit(p.user_id, null)}
|
||||
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : exitingId === p.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(p.user_id, exitDate)}
|
||||
className="text-xs text-accent-600 hover:text-accent-700 font-medium"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExitingId(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-gray-600">Member</span>
|
||||
{isWriter && (
|
||||
<button
|
||||
onClick={() => setExitingId(p.user_id)}
|
||||
className="ml-2 text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
Mark exited
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">
|
||||
{p.latest_as_of ? formatDate(p.latest_as_of) : "—"}
|
||||
</td>
|
||||
@@ -141,7 +216,7 @@ export default function EntityPartners() {
|
||||
))}
|
||||
{partners.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-6 text-center text-gray-400">
|
||||
<td colSpan={9} className="px-4 py-6 text-center text-gray-400">
|
||||
No members yet. Import the eNAV ALLOC SI tab from “Import Statements,” or grant
|
||||
access on the Access Grid.
|
||||
</td>
|
||||
|
||||
@@ -27,9 +27,23 @@ export default function InvestorPortalView({
|
||||
// When this login covers several legal names (e.g. an IRA and a trust), label each block.
|
||||
const showNames = new Set(accounts.map((a) => a.investor_user_id)).size > 1;
|
||||
|
||||
// Exited positions (stake sold/transferred) keep their card + documents but stay out of
|
||||
// the portfolio totals — a $0 balance from a sale is not a loss.
|
||||
const activeAccounts = accounts.filter((a) => !a.exited_on);
|
||||
const activeEntityCount = new Set(activeAccounts.map((a) => a.entity_id)).size;
|
||||
const exitedPositions = new Set(
|
||||
accounts.filter((a) => a.exited_on).map((a) => `${a.entity_id}:${a.investor_user_id}`),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{entities.length > 1 && <PortfolioSummary accounts={accounts} count={entities.length} />}
|
||||
{activeEntityCount > 1 && (
|
||||
<PortfolioSummary
|
||||
accounts={activeAccounts}
|
||||
count={activeEntityCount}
|
||||
exitedCount={exitedPositions}
|
||||
/>
|
||||
)}
|
||||
{entities.map((e) => (
|
||||
<FundSection
|
||||
key={e.id}
|
||||
@@ -44,7 +58,15 @@ export default function InvestorPortalView({
|
||||
}
|
||||
|
||||
// One quiet navy card answering the multi-fund LP's first question: the total across Ten31.
|
||||
function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; count: number }) {
|
||||
function PortfolioSummary({
|
||||
accounts,
|
||||
count,
|
||||
exitedCount,
|
||||
}: {
|
||||
accounts: CapitalAccount[];
|
||||
count: number;
|
||||
exitedCount: number;
|
||||
}) {
|
||||
// Latest statement per (fund, legal name); summed across all of them.
|
||||
const totals = useMemo(() => {
|
||||
const latest = new Map<string, CapitalAccount>();
|
||||
@@ -100,6 +122,11 @@ function PortfolioSummary({ accounts, count }: { accounts: CapitalAccount[]; cou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{exitedCount > 0 && (
|
||||
<p className="mt-3 text-xs text-brand-300">
|
||||
Excludes {exitedCount} exited position{exitedCount === 1 ? "" : "s"}.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -238,6 +265,24 @@ function CapitalBlock({
|
||||
// Collapsed by default so the portal opens clean; the investor expands the trend per fund.
|
||||
const [showChart, setShowChart] = useState(false);
|
||||
|
||||
// A sold/transferred stake: the fund's books show $0 with no distribution, which is not a
|
||||
// loss. Show a quiet Exited badge and keep the documents; no balance, no gain/loss.
|
||||
if (latest.exited_on) {
|
||||
return (
|
||||
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
|
||||
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||
<p className="mt-2">
|
||||
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
Exited {formatDate(latest.exited_on)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
This position was sold or transferred. Documents remain available below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={divider ? "mt-6 pt-5 border-t border-gray-100" : "mt-3"}>
|
||||
{label && <p className="text-sm font-medium text-gray-700">{label}</p>}
|
||||
|
||||
@@ -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.32";
|
||||
export const APP_VERSION = "0.2.33";
|
||||
|
||||
Reference in New Issue
Block a user