- 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>
121 lines
5.0 KiB
Python
121 lines
5.0 KiB
Python
"""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"
|