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:
co-authored by
Claude Opus 4.8
parent
33776d42f4
commit
099459b2f3
@@ -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
|
||||
Reference in New Issue
Block a user