0.2.25: batch historical eNAV backfill + collapsible investor chart

Add POST /api/import/capital-accounts/batch: upload several of a fund's
eNAV workbooks at once; each file's ALLOC SI roster is auto-matched to
existing members (by fund-admin investor ID, else name/username) and their
capital statement is saved at that file's own as-of date, building
trend-lines without replacing the latest figures. Members not already in
the portal are skipped and reported per file (never created). Capital
statements only -- holdings/NAV are untouched. One bad file (wrong
password, no ALLOC SI, unreadable date) is reported per-file and does not
abort the rest.

Import page gains a "Backfill historical capital" batch section (fund
picker, multi-file .xlsx input, shared password, per-file results table).

Investor portal "Capital over time" chart is now collapsed by default and
expands per fund (first login opens clean); applies to InvestorHome and
the admin Investor View via the shared component.

Tests: backend/tests/test_capital_batch.py (2). Full suite 17 passed;
frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-01 18:47:01 -05:00
co-authored by Claude Opus 4.8
parent 33776d42f4
commit 099459b2f3
10 changed files with 447 additions and 8 deletions
+107
View File
@@ -0,0 +1,107 @@
"""Batch historical capital backfill: several eNAV files → per-quarter statements, auto-matched."""
import io
from datetime import date, datetime
import openpyxl
from sqlmodel import select
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, User, UserRole
from tests.conftest import make_user
def _alloc_si_file(report: datetime, rows: list[dict]) -> bytes:
"""Build a minimal eNAV workbook with an ALLOC SI roster and a report date in A1."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "ALLOC SI"
ws["A1"] = report # _enav_as_of reads the report date from the top-left cells
header = ["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"]
ws.append([None] * 7) # row 2 spacer
ws.append(header) # row 3 header
for r in rows:
ws.append([
r.get("id"), "LP", r["name"],
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"],
])
out = io.BytesIO()
wb.save(out)
return out.getvalue()
def test_batch_backfill_builds_history(auth_client, session):
fund = Entity(name="LTPF III", type=EntityType.fund)
session.add(fund)
session.commit()
session.refresh(fund)
# One member matched by fund-admin ID, one by name; both already exist.
alice = make_user(session, username="alice", name="Alice Trust",
role=UserRole.investor, external_investor_id="INV-100")
make_user(session, username="bob", name="Bob Llc", role=UserRole.investor)
q3 = _alloc_si_file(datetime(2025, 9, 30), [
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 420_000},
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 205_000},
{"id": "INV-999", "name": "Ghost Capital", "commit": 999, "contrib": 999, "ending": 999}, # no account → skipped
])
q4 = _alloc_si_file(datetime(2025, 12, 31), [
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 455_000},
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 210_000},
])
resp = auth_client.post(
"/api/import/capital-accounts/batch",
data={"entity_id": fund.id},
files=[
("files", ("LTPF_III_Q3.xlsx", q3, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
("files", ("LTPF_III_Q4.xlsx", q4, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
],
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total_statements"] == 4 # 2 members × 2 quarters
q3res = next(f for f in body["files"] if "Q3" in f["filename"])
assert q3res["as_of_date"] == "2025-09-30"
assert q3res["matched"] == 2
assert q3res["skipped"] == ["Ghost Capital"] # unknown member reported, not created
# No account was created for the unknown roster name.
assert session.exec(select(User).where(User.name == "Ghost Capital")).first() is None
# Alice now has two statements — a real trend line — and the latest (Q4) is intact.
stmts = session.exec(
select(CapitalAccountStatement)
.where(CapitalAccountStatement.investor_user_id == alice.id)
.order_by(CapitalAccountStatement.as_of_date) # type: ignore[arg-type]
).all()
assert [s.as_of_date for s in stmts] == [date(2025, 9, 30), date(2025, 12, 31)]
assert [s.ending_balance_cents for s in stmts] == [42_000_000, 45_500_000]
def test_batch_reimport_updates_in_place(auth_client, session):
fund = Entity(name="LTPF IV", type=EntityType.fund)
session.add(fund)
session.commit()
session.refresh(fund)
carol = make_user(session, username="carol", name="Carol Ira", role=UserRole.investor)
def one_file(ending: int) -> bytes:
return _alloc_si_file(datetime(2025, 12, 31),
[{"id": "INV-1", "name": "Carol Ira", "ending": ending}])
for ending in (300_000, 315_000): # corrected figure re-imported at the same quarter
resp = auth_client.post(
"/api/import/capital-accounts/batch",
data={"entity_id": fund.id},
files=[("files", ("f.xlsx", one_file(ending), "application/octet-stream"))],
)
assert resp.status_code == 200, resp.text
stmts = session.exec(
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == carol.id)
).all()
assert len(stmts) == 1 # upsert, not a duplicate
assert stmts[0].ending_balance_cents == 31_500_000