Files
Ten31-Portal/backend/tests/test_capital_batch.py
T
Jonathan KirkwoodandClaude Opus 4.8 099459b2f3 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>
2026-07-01 18:47:01 -05:00

108 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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