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:
co-authored by
Claude Fable 5
parent
0822eca887
commit
eac3262f29
@@ -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"
|
||||
Reference in New Issue
Block a user