0.2.43: historical NAV backfill without touching current holdings

The batch history import now also records each quarter's NAV in the
fund's valuation history: the old file's HLD rows are matched by issuer
and security name against the book as it exists today, matched rows
write that quarter's valuations, unmatched rows are counted and
reported, and nothing outside the round is created or modified. A
manually signed quarter is never overwritten.

The single-file wizard automatically takes the same history-only path
when the file is older than the fund's newest round. Previously that
import would regress position cost basis to the old file's values and
resurrect since-exited positions, corrupting the fund's Invested total.
This commit is contained in:
Jonathan Kirkwood
2026-08-11 12:32:57 -05:00
parent ae967494bd
commit ebafcf19d9
11 changed files with 376 additions and 10 deletions
+1 -1
View File
@@ -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.42'
const CACHE = 'ten31-portal-0.2.43'
self.addEventListener('install', () => self.skipWaiting())
+4
View File
@@ -155,6 +155,10 @@ export interface BatchCapitalFileResult {
updated: number;
skipped: string[];
error: string | null;
nav_status: "added" | "updated" | "kept-signed" | "no-match" | "no-hld" | "error" | null;
nav_matched: number;
nav_unmatched: number;
nav_cents: number;
}
export interface BatchCapitalImportResult {
+37 -3
View File
@@ -165,13 +165,22 @@ export default function Import() {
await api.capitalImportCommit({ entity_id: resolvedEntityId, ...memberPayload });
setStep(replaceExisting ? "Replacing holdings…" : "Loading holdings…");
try {
await api.scheduleImport(file, {
const sres = await api.scheduleImport(file, {
commit: true,
entityId: resolvedEntityId,
password: password || undefined,
asOf,
replaceExisting,
});
if (sres.history_only) {
const total = (sres.matched ?? 0) + (sres.unmatched ?? 0);
holdingsNote =
sres.status === "kept-signed"
? "This quarter already has a signed valuation round; it was left unchanged."
: sres.status === "no-match"
? "Older file: none of its holdings match the current book, so no NAV was recorded for that quarter."
: `Older file: recorded as a historical quarter in valuation history (${sres.matched} of ${total} holdings matched). Current holdings and cost basis untouched.`;
}
} catch (e: any) {
if (String(e.message).toLowerCase().includes("already exists")) {
holdingsNote = "A signed valuation round exists for this quarter — holdings left unchanged.";
@@ -458,8 +467,10 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
<p className="text-sm text-gray-500 mb-4 max-w-3xl">
Load several past quarters at once to build investors' trend-lines. Drop the eNAV
workbooks for one fund; each file's members are matched to existing accounts and their
capital statement is saved at that file's own as-of date. The latest figures are never
replaced, and members not already in the portal are skipped (not created).
capital statement is saved at that file's own as-of date, and the quarter's NAV is
recorded in the fund's valuation history. The latest figures are never replaced,
current holdings are never modified, and members not already in the portal are
skipped (not created).
</p>
{error && <div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700 mb-4">{error}</div>}
@@ -518,6 +529,7 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
<th className="px-4 py-2 font-medium">File</th>
<th className="px-4 py-2 font-medium">As-of</th>
<th className="px-4 py-2 font-medium text-right">Loaded</th>
<th className="px-4 py-2 font-medium text-right">NAV</th>
<th className="px-4 py-2 font-medium">Skipped (no account)</th>
</tr>
</thead>
@@ -529,6 +541,28 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
<td className="px-4 py-2 text-right text-gray-900">
{f.error ? "—" : f.statements_written}
</td>
<td className="px-4 py-2 text-right text-gray-600 whitespace-nowrap">
{f.error || !f.nav_status ? (
"—"
) : f.nav_status === "added" || f.nav_status === "updated" ? (
<>
{formatMoneyExact(f.nav_cents)}
{f.nav_unmatched > 0 && (
<span className="text-xs text-amber-600 block">
{f.nav_unmatched} row{f.nav_unmatched === 1 ? "" : "s"} not in current book
</span>
)}
</>
) : f.nav_status === "kept-signed" ? (
<span className="text-xs text-gray-400">signed, kept</span>
) : f.nav_status === "no-match" ? (
<span className="text-xs text-amber-600">no holdings matched</span>
) : f.nav_status === "no-hld" ? (
<span className="text-xs text-gray-400">no HLD tab</span>
) : (
<span className="text-xs text-red-600">NAV failed</span>
)}
</td>
<td className="px-4 py-2 text-gray-500">
{f.error ? (
<span className="text-red-600">{f.error}</span>
+1 -1
View File
@@ -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.42";
export const APP_VERSION = "0.2.43";