0.2.27-0.2.32: LP portal polish, brand palette, default investor logins
Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
distributions-line gate
- 0.2.32: LP-facing polish pass
* Ten31 brand palette from the logo (navy/mint); orange retired
* portfolio summary card across funds; gain labeled "net of paid-in"
* whole-dollar headline figures; "History · N quarters" toggle
* documents grouped by year with a "New" badge (users.docs_seen_at)
* eNAV-created members start on default password with login enabled;
enable-investor-logins CLI + StartOS action for existing accounts
* password minimum raised to 8 chars; login help line (Portal@ten31.xyz)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69f12b0519
commit
4215d4478f
@@ -0,0 +1,60 @@
|
||||
"""Clearing a fund's partners removes only that fund's statements + access grants,
|
||||
never the investor accounts (which may belong to other funds)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
|
||||
|
||||
def _make_fund(session, name):
|
||||
e = Entity(name=name, type=EntityType.fund)
|
||||
session.add(e)
|
||||
session.commit()
|
||||
session.refresh(e)
|
||||
return e
|
||||
|
||||
|
||||
def _add_partner(session, entity_id, user_id, ending):
|
||||
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
|
||||
commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
|
||||
distributions_cents=0, ending_balance_cents=ending,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_clear_partners_scoped_to_one_fund(auth_client, session):
|
||||
from tests.conftest import make_user
|
||||
|
||||
fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
|
||||
fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
|
||||
lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
|
||||
# Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
|
||||
_add_partner(session, fund2.id, lp.id, 1_000_00)
|
||||
_add_partner(session, fund3.id, lp.id, 2_000_00)
|
||||
|
||||
resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"statements": 1, "access_grants": 1}
|
||||
|
||||
# Fund III is wiped of partners...
|
||||
assert session.exec(
|
||||
CapitalAccountStatement.__table__.select().where(
|
||||
CapitalAccountStatement.entity_id == fund3.id
|
||||
)
|
||||
).first() is None
|
||||
assert session.exec(
|
||||
EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
|
||||
).first() is None
|
||||
assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
|
||||
|
||||
# ...but Fund II keeps its partner, and the investor account still exists.
|
||||
assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
|
||||
assert session.get(User, lp.id) is not None
|
||||
|
||||
|
||||
def test_clear_partners_missing_entity_404(auth_client):
|
||||
assert auth_client.delete("/api/entities/99999/partners").status_code == 404
|
||||
@@ -0,0 +1,134 @@
|
||||
"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
|
||||
enable-investor-logins conversion, and the documents "New" badge watermark."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal import config
|
||||
from ten31portal.auth import hash_password, verify_password
|
||||
from ten31portal.models import (
|
||||
Document, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
return client.post("/api/auth/login", json={"login": username, "password": password})
|
||||
|
||||
|
||||
def test_change_password_requires_eight_chars(auth_client):
|
||||
resp = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "short7c"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "8 characters" in resp.json()["detail"]
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "longenough8"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_import_created_member_gets_default_password(auth_client, session):
|
||||
entity = Entity(name="Test Fund", type=EntityType.fund)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/commit",
|
||||
json={
|
||||
"entity_id": entity.id,
|
||||
"as_of_date": "2026-03-31",
|
||||
"investors": [{
|
||||
"action": "create",
|
||||
"name": "New LP",
|
||||
"username": "newlp",
|
||||
"value_dollars": 100_000,
|
||||
}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
user = session.exec(select(User).where(User.username == "newlp")).one()
|
||||
assert user.login_enabled is True
|
||||
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
|
||||
|
||||
# And the account can actually sign in with it.
|
||||
resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def test_enable_investor_logins_converts_only_no_login_accounts(session):
|
||||
from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
|
||||
|
||||
no_login = make_user(session, username="dormant", role=UserRole.investor,
|
||||
login_enabled=False, name="Dormant LP")
|
||||
has_login = make_user(session, username="active-lp", role=UserRole.investor,
|
||||
password="theirownpw", name="Active LP")
|
||||
linked = make_user(session, username="linked", role=UserRole.investor,
|
||||
login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
|
||||
old_active_hash = has_login.password_hash
|
||||
|
||||
# Run the conversion against the test engine (CLI normally uses the real one).
|
||||
import ten31portal.cli as cli_mod
|
||||
orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
|
||||
cli_mod.engine = session.get_bind()
|
||||
cli_mod.run_migrations = lambda: None
|
||||
try:
|
||||
enable_investor_logins(None)
|
||||
finally:
|
||||
cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
|
||||
|
||||
session.refresh(no_login)
|
||||
session.refresh(has_login)
|
||||
session.refresh(linked)
|
||||
assert no_login.login_enabled is True
|
||||
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
|
||||
# Working logins and linked secondary names are untouched.
|
||||
assert has_login.password_hash == old_active_hash
|
||||
assert linked.login_enabled is False
|
||||
|
||||
|
||||
def test_documents_new_badge(client, session):
|
||||
entity = Entity(name="Badge Fund", type=EntityType.fund)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
|
||||
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||
|
||||
old_doc = Document(
|
||||
entity_id=entity.id, category="statement", title="Old statement",
|
||||
original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
|
||||
storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
|
||||
)
|
||||
session.add(old_doc)
|
||||
session.commit()
|
||||
|
||||
assert _login(client, "lp", "password123").status_code == 200
|
||||
|
||||
# First ever visit: nothing badged (no watermark yet), watermark gets set.
|
||||
docs = client.get("/api/documents").json()
|
||||
assert [d["is_new"] for d in docs] == [False]
|
||||
|
||||
# A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
|
||||
session.refresh(lp)
|
||||
lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
|
||||
session.add(lp)
|
||||
new_doc = Document(
|
||||
entity_id=entity.id, category="k1", title="Fresh K-1",
|
||||
original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
|
||||
storage_path="x-new",
|
||||
)
|
||||
session.add(new_doc)
|
||||
session.commit()
|
||||
|
||||
docs = client.get("/api/documents").json()
|
||||
flags = {d["title"]: d["is_new"] for d in docs}
|
||||
assert flags == {"Fresh K-1": True, "Old statement": False}
|
||||
|
||||
# Within the same visit (watermark just advanced) the badge computation stays stable.
|
||||
docs = client.get("/api/documents").json()
|
||||
assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs
|
||||
Reference in New Issue
Block a user