0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center

- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-12 13:01:50 +02:00
co-authored by Claude Fable 5
parent 0822eca887
commit eac3262f29
22 changed files with 823 additions and 12 deletions
+120
View File
@@ -0,0 +1,120 @@
"""0.2.39: BTC price CSV + bitcoin-denominated marks, forced default-password change,
and the first-login onboarded watermark."""
import io
from datetime import date
from ten31portal import config
from ten31portal.models import CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole
from tests.conftest import make_user
def _fund_with_lp(session, *, close_date=None):
entity = Entity(name="LTPF X", type=EntityType.fund, close_date=close_date)
session.add(entity)
session.commit()
lp = make_user(session, username="lp", role=UserRole.investor, name="An LP")
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
session.add(CapitalAccountStatement(
entity_id=entity.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
commitment_cents=1_000_000_00, beginning_balance_cents=0,
contributions_cents=500_000_00, distributions_cents=0,
ending_balance_cents=600_000_00,
))
session.commit()
return entity, lp
def _upload_prices(client, csv_text):
return client.post(
"/api/import/btc-prices",
files={"file": ("prices.csv", io.BytesIO(csv_text.encode()), "text/csv")},
)
def test_btc_csv_import_and_marks(auth_client, session):
_fund_with_lp(session, close_date=date(2025, 6, 30))
resp = _upload_prices(
auth_client,
"Date,Close\n2025-06-30,60000\n2025-12-31,80000\n2026-03-31,100000.50\n",
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["imported"] == 3
assert body["latest_price_cents"] == 10_000_050
# The LP's statements carry both marks: as-of price and close-date price.
auth_client.post("/api/auth/logout")
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
acct = auth_client.get("/api/capital-accounts").json()[0]
assert acct["btc_price_cents"] == 10_000_050 # exact as-of match
assert acct["btc_close_price_cents"] == 6_000_000 # fund close 2025-06-30
def test_btc_price_nearest_on_or_before(auth_client, session):
# Prices only exist BEFORE the statement date → the newest one on-or-before is used.
_fund_with_lp(session, close_date=date(2025, 6, 30))
resp = _upload_prices(auth_client, "date,price\n2025-06-28,55000\n2026-03-01,90000\n")
assert resp.status_code == 200, resp.text
auth_client.post("/api/auth/logout")
auth_client.post("/api/auth/login", json={"login": "lp", "password": "password123"})
acct = auth_client.get("/api/capital-accounts").json()[0]
assert acct["btc_price_cents"] == 9_000_000 # 2026-03-01 covers 2026-03-31
assert acct["btc_close_price_cents"] == 5_500_000 # 2025-06-28 covers the 06-30 close
def test_btc_reupload_overwrites_and_bad_file_rejected(auth_client, session):
assert _upload_prices(auth_client, "Date,Close\n2026-01-01,90000\n").status_code == 200
r = _upload_prices(auth_client, "Date,Close\n2026-01-01,95000\n")
assert r.status_code == 200
assert r.json()["count"] == 1 # upsert, not a duplicate row
assert r.json()["latest_price_cents"] == 9_500_000
assert _upload_prices(auth_client, "just some text\nwith,no,dates\n").status_code == 400
def test_default_password_forces_change(client, session):
make_user(session, username="fresh", role=UserRole.investor,
password=config.DEFAULT_INVESTOR_PASSWORD)
resp = client.post(
"/api/auth/login",
json={"login": "fresh", "password": config.DEFAULT_INVESTOR_PASSWORD},
)
assert resp.status_code == 200
assert resp.json()["must_change_password"] is True
# Choosing the shared default again is rejected; a real password clears the flag.
r = client.post("/api/auth/change-password", json={
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
"new_password": config.DEFAULT_INVESTOR_PASSWORD,
})
assert r.status_code == 400
r = client.post("/api/auth/change-password", json={
"current_password": config.DEFAULT_INVESTOR_PASSWORD,
"new_password": "my-own-secret-1",
})
assert r.status_code == 200
assert client.get("/api/auth/me").json()["must_change_password"] is False
def test_onboarded_stamp(client, session):
make_user(session, username="lp2", role=UserRole.investor)
client.post("/api/auth/login", json={"login": "lp2", "password": "password123"})
assert client.get("/api/auth/me").json()["onboarded_at"] is None
assert client.post("/api/auth/onboarded").status_code == 200
stamped = client.get("/api/auth/me").json()["onboarded_at"]
assert stamped is not None
# Idempotent — the first stamp wins.
client.post("/api/auth/onboarded")
assert client.get("/api/auth/me").json()["onboarded_at"] == stamped
def test_entity_close_date_update(auth_client, session):
entity = Entity(name="SPV Y", type=EntityType.spv)
session.add(entity)
session.commit()
r = auth_client.patch(f"/api/entities/{entity.id}", json={"close_date": "2025-11-15"})
assert r.status_code == 200, r.text
assert r.json()["close_date"] == "2025-11-15"