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,43 @@
|
||||
"""btc_prices table + entities.close_date + first-login flow columns on users
|
||||
|
||||
Revision ID: a3b4c5d6e7f8
|
||||
Revises: f2a3b4c5d6e7
|
||||
Create Date: 2026-07-12 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'a3b4c5d6e7f8'
|
||||
down_revision: Union[str, None] = 'f2a3b4c5d6e7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'btc_prices',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('date', sa.Date(), nullable=False, unique=True),
|
||||
sa.Column('price_cents', sa.Integer(), nullable=False),
|
||||
)
|
||||
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('close_date', sa.Date(), nullable=True))
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('must_change_password', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false())
|
||||
)
|
||||
batch_op.add_column(sa.Column('onboarded_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('onboarded_at')
|
||||
batch_op.drop_column('must_change_password')
|
||||
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||
batch_op.drop_column('close_date')
|
||||
op.drop_table('btc_prices')
|
||||
@@ -78,6 +78,7 @@ def reset_password(args: argparse.Namespace) -> None:
|
||||
sys.exit(1)
|
||||
user.password_hash = hash_password(args.password)
|
||||
user.login_enabled = True
|
||||
user.must_change_password = False
|
||||
is_admin = user.is_service_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
@@ -134,6 +135,7 @@ def enable_investor_logins(args: argparse.Namespace) -> None:
|
||||
for u in users:
|
||||
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
|
||||
u.login_enabled = True
|
||||
u.must_change_password = True
|
||||
session.add(u)
|
||||
session.commit()
|
||||
for u in users:
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from datetime import date as _date # for fields literally named "date"
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
|
||||
from sqlmodel import Field, SQLModel, Column, Date, String, JSON, UniqueConstraint
|
||||
|
||||
|
||||
# --- Enums ---
|
||||
@@ -85,6 +86,11 @@ class User(SQLModel, table=True):
|
||||
totp_enabled: bool = Field(default=False)
|
||||
# JSON list of sha256 hex digests of unused one-time recovery codes.
|
||||
totp_recovery_codes: str | None = Field(default=None)
|
||||
# True while the account is on the shared default password — the portal forces a
|
||||
# password change before anything else. Cleared by change-password / admin reset.
|
||||
must_change_password: bool = Field(default=False)
|
||||
# When the investor finished (or skipped) the first-login welcome flow. Null = show it.
|
||||
onboarded_at: datetime | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -100,9 +106,25 @@ class Entity(SQLModel, table=True):
|
||||
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
|
||||
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
|
||||
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
# Final close of the fund/SPV — the BTC entry mark: paid-in capital is valued at the BTC
|
||||
# price on this date for the bitcoin-denominated view. Null = no BTC view for this fund.
|
||||
close_date: date | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class BtcPrice(SQLModel, table=True):
|
||||
"""Daily (or as-uploaded) BTC/USD closing prices from the admin's CSV.
|
||||
|
||||
Statements are valued at the newest price on or before their as-of date, so the CSV
|
||||
doesn't need every calendar day — quarter-end rows are enough."""
|
||||
|
||||
__tablename__ = "btc_prices"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
date: _date = Field(sa_column=Column(Date, unique=True, nullable=False))
|
||||
price_cents: int
|
||||
|
||||
|
||||
class Holding(SQLModel, table=True):
|
||||
__tablename__ = "holdings"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
@@ -78,6 +79,13 @@ def login(
|
||||
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Account disabled")
|
||||
# Anyone still signing in with the shared default password gets flagged so the portal
|
||||
# forces them to set their own before doing anything else (covers accounts created
|
||||
# before the flag existed).
|
||||
if body.password == config.DEFAULT_INVESTOR_PASSWORD and not user.must_change_password:
|
||||
user.must_change_password = True
|
||||
session.add(user)
|
||||
session.commit()
|
||||
if user.totp_enabled:
|
||||
# Password accepted, but don't sign the session in yet — park the login until
|
||||
# /login/verify-totp confirms the second factor.
|
||||
@@ -155,7 +163,10 @@ def change_password(
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
||||
if body.new_password == config.DEFAULT_INVESTOR_PASSWORD:
|
||||
raise HTTPException(status_code=400, detail="Please choose a password of your own.")
|
||||
user.password_hash = hash_password(body.new_password)
|
||||
user.must_change_password = False
|
||||
session.add(user)
|
||||
session.commit()
|
||||
# Once the built-in admin sets their own password, the initial one from first boot is stale.
|
||||
@@ -167,6 +178,19 @@ def change_password(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/onboarded")
|
||||
def mark_onboarded(
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
"""Stamp the first-login welcome flow as finished (or skipped) so it stops showing."""
|
||||
if user.onboarded_at is None:
|
||||
user.onboarded_at = datetime.utcnow()
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# --- Two-factor enrollment (per-user opt-in) ---
|
||||
|
||||
@router.post("/totp/setup")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Capital account statements: admin entry, investor read of their own figures."""
|
||||
|
||||
from bisect import bisect_right
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, col
|
||||
|
||||
@@ -10,7 +12,7 @@ from ten31portal.auth import (
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||
BtcPrice, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||
)
|
||||
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
||||
|
||||
@@ -37,6 +39,33 @@ def exit_dates(session: Session, rows) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def btc_marks(session: Session, rows) -> tuple[dict, dict]:
|
||||
"""BTC/USD marks for the bitcoin-denominated view.
|
||||
|
||||
Returns ({statement_id: price at its as-of date}, {entity_id: price at the fund's
|
||||
close date}) — "price at" meaning the newest uploaded price on or before that date,
|
||||
so a quarter-end-only CSV is enough. Empty when no prices are uploaded; a fund without
|
||||
a close_date has no entry and the portal hides its BTC view.
|
||||
"""
|
||||
if not rows:
|
||||
return {}, {}
|
||||
prices = session.exec(select(BtcPrice).order_by(col(BtcPrice.date))).all()
|
||||
if not prices:
|
||||
return {}, {}
|
||||
dates = [p.date for p in prices]
|
||||
|
||||
def price_at(d):
|
||||
i = bisect_right(dates, d)
|
||||
return prices[i - 1].price_cents if i else None
|
||||
|
||||
asof = {r.id: price_at(r.as_of_date) for r in rows}
|
||||
entities = session.exec(
|
||||
select(Entity).where(col(Entity.id).in_({r.entity_id for r in rows}))
|
||||
).all()
|
||||
close = {e.id: price_at(e.close_date) for e in entities if e.close_date is not None}
|
||||
return asof, close
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_statements(
|
||||
entity_id: int | None = None,
|
||||
@@ -74,11 +103,14 @@ def list_statements(
|
||||
)
|
||||
).all()) if rows else {}
|
||||
exits = exit_dates(session, rows)
|
||||
btc_asof, btc_close = btc_marks(session, rows)
|
||||
out: list[CapitalAccountResponse] = []
|
||||
for r in rows:
|
||||
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||
data.investor_name = names.get(r.investor_user_id)
|
||||
data.exited_on = exits.get((r.investor_user_id, r.entity_id))
|
||||
data.btc_price_cents = btc_asof.get(r.id)
|
||||
data.btc_close_price_cents = btc_close.get(r.entity_id)
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
@@ -325,6 +325,8 @@ def commit_import(
|
||||
role=UserRole.investor,
|
||||
login_enabled=True,
|
||||
external_investor_id=inv.external_id,
|
||||
# First login forces a change while they're on the shared default.
|
||||
must_change_password=(pw == config.DEFAULT_INVESTOR_PASSWORD),
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
@@ -294,7 +294,9 @@ def update_entity(
|
||||
setattr(entity, key, val)
|
||||
session.add(entity)
|
||||
session.flush()
|
||||
record_audit(session, user.id, "update", "entity", entity.id, changes)
|
||||
# mode="json" so date fields (close_date) serialize into the audit JSON column.
|
||||
record_audit(session, user.id, "update", "entity", entity.id,
|
||||
body.model_dump(exclude_unset=True, mode="json"))
|
||||
session.commit()
|
||||
session.refresh(entity)
|
||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||
|
||||
@@ -21,9 +21,10 @@ from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import require_role
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
Entity, EntityType, Holding, Position,
|
||||
BtcPrice, Entity, EntityType, Holding, Position,
|
||||
User, UserRole, Valuation, ValuationRound, RoundStatus,
|
||||
)
|
||||
from ten31portal.schemas import BtcPricesImportResult, BtcPricesStatus
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
|
||||
@@ -716,3 +717,131 @@ def _parse_schedule_csv(file_bytes: bytes) -> tuple[list[dict], list[dict], list
|
||||
})
|
||||
|
||||
return holdings_preview, positions_preview, errors
|
||||
|
||||
|
||||
# --- BTC prices (bitcoin-denominated view) ---
|
||||
|
||||
_BTC_DATE_HEADERS = ("date", "time", "day", "snapped_at")
|
||||
_BTC_PRICE_HEADERS = ("close", "price", "usd", "last", "rate")
|
||||
|
||||
_BTC_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%Y/%m/%d", "%d-%b-%Y", "%b %d, %Y")
|
||||
|
||||
|
||||
def _parse_btc_date(raw: str) -> date | None:
|
||||
s = raw.strip().strip('"')
|
||||
if not s:
|
||||
return None
|
||||
# Timestamps like "2026-01-01 00:00:00 UTC" — the date part is enough.
|
||||
for sep in (" ", "T"):
|
||||
if sep in s and len(s.split(sep)[0]) >= 8:
|
||||
s = s.split(sep)[0]
|
||||
break
|
||||
for fmt in _BTC_DATE_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_btc_price(raw: str) -> int | None:
|
||||
s = raw.strip().strip('"').replace("$", "").replace(",", "").replace(" ", "")
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
value = float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return round(value * 100)
|
||||
|
||||
|
||||
def _btc_status(session: Session) -> dict[str, Any]:
|
||||
rows = session.exec(select(BtcPrice).order_by(col(BtcPrice.date))).all()
|
||||
latest = rows[-1] if rows else None
|
||||
return {
|
||||
"count": len(rows),
|
||||
"first_date": rows[0].date if rows else None,
|
||||
"last_date": latest.date if latest else None,
|
||||
"latest_price_cents": latest.price_cents if latest else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/btc-prices")
|
||||
def btc_prices_status(
|
||||
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BtcPricesStatus:
|
||||
return BtcPricesStatus(**_btc_status(session))
|
||||
|
||||
|
||||
@router.post("/btc-prices")
|
||||
def import_btc_prices(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BtcPricesImportResult:
|
||||
"""Upsert BTC/USD prices from a CSV (a date column and a close/price column).
|
||||
|
||||
Column headers are auto-detected; a headerless two-column file works too. Re-uploading
|
||||
overwrites prices for dates already stored, so corrections are just another upload.
|
||||
"""
|
||||
try:
|
||||
file_bytes = storage.read_capped(file)
|
||||
except storage.UploadTooLarge as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc))
|
||||
try:
|
||||
text = file_bytes.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
text = file_bytes.decode("latin-1")
|
||||
|
||||
rows = [r for r in csv.reader(io.StringIO(text)) if any(c.strip() for c in r)]
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="The file is empty.")
|
||||
|
||||
# Column detection: match headers when present, else assume date,price.
|
||||
date_idx, price_idx, start = 0, 1, 0
|
||||
header = [c.strip().lower() for c in rows[0]]
|
||||
if not (_parse_btc_date(rows[0][0]) and len(rows[0]) > 1):
|
||||
for i, name in enumerate(header):
|
||||
if any(k in name for k in _BTC_DATE_HEADERS):
|
||||
date_idx = i
|
||||
break
|
||||
for key in _BTC_PRICE_HEADERS: # 'close' preferred over generic 'price'
|
||||
hit = next((i for i, name in enumerate(header) if key in name and i != date_idx), None)
|
||||
if hit is not None:
|
||||
price_idx = hit
|
||||
break
|
||||
start = 1
|
||||
|
||||
existing = {p.date: p for p in session.exec(select(BtcPrice)).all()}
|
||||
imported = 0
|
||||
skipped = 0
|
||||
for r in rows[start:]:
|
||||
if len(r) <= max(date_idx, price_idx):
|
||||
skipped += 1
|
||||
continue
|
||||
d = _parse_btc_date(r[date_idx])
|
||||
price = _parse_btc_price(r[price_idx])
|
||||
if d is None or price is None:
|
||||
skipped += 1
|
||||
continue
|
||||
row = existing.get(d)
|
||||
if row is None:
|
||||
row = BtcPrice(date=d, price_cents=price)
|
||||
existing[d] = row
|
||||
else:
|
||||
row.price_cents = price
|
||||
session.add(row)
|
||||
imported += 1
|
||||
|
||||
if imported == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No prices found — expected a CSV with a date column and a price column.",
|
||||
)
|
||||
record_audit(session, user.id, "import_btc_prices", "btc_prices", None,
|
||||
detail={"imported": imported, "skipped": skipped, "filename": file.filename})
|
||||
session.commit()
|
||||
return BtcPricesImportResult(imported=imported, skipped_rows=skipped, **_btc_status(session))
|
||||
|
||||
@@ -144,14 +144,17 @@ def investor_view(
|
||||
col(User.id).in_({r.investor_user_id for r in cap_rows})
|
||||
)
|
||||
).all()) if cap_rows else {}
|
||||
# Mirror the investor's own portal exactly — including exit status, so an exited
|
||||
# position shows its badge here too instead of a phantom -100%.
|
||||
from ten31portal.routers.capital_account_router import exit_dates
|
||||
# Mirror the investor's own portal exactly — including exit status and BTC marks,
|
||||
# so this view never drifts from what the LP actually sees.
|
||||
from ten31portal.routers.capital_account_router import btc_marks, exit_dates
|
||||
exits = exit_dates(session, cap_rows)
|
||||
btc_asof, btc_close = btc_marks(session, cap_rows)
|
||||
for r in cap_rows:
|
||||
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||
d.investor_name = names.get(r.investor_user_id)
|
||||
d.exited_on = exits.get((r.investor_user_id, r.entity_id))
|
||||
d.btc_price_cents = btc_asof.get(r.id)
|
||||
d.btc_close_price_cents = btc_close.get(r.entity_id)
|
||||
caps.append(d)
|
||||
|
||||
doc_rows = session.exec(
|
||||
@@ -373,6 +376,8 @@ def reset_password(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
user.password_hash = hash_password(body.password)
|
||||
# Admin handed them a real password — no forced change on next login.
|
||||
user.must_change_password = False
|
||||
user.login_enabled = True # setting a password enables login
|
||||
session.add(user)
|
||||
record_audit(session, admin.id, "reset_password", "user", user_id, None)
|
||||
|
||||
@@ -27,6 +27,10 @@ class UserResponse(BaseModel):
|
||||
is_service_admin: bool = False
|
||||
primary_account_id: int | None = None # set when this account logs in under another
|
||||
totp_enabled: bool = False
|
||||
# First-login flow: force a password change while on the shared default, then show the
|
||||
# welcome step (2FA offer) until onboarded_at is stamped.
|
||||
must_change_password: bool = False
|
||||
onboarded_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -129,6 +133,7 @@ class EntityUpdate(BaseModel):
|
||||
fund_size_cents: int | None = None
|
||||
status: EntityStatus | None = None
|
||||
linked_user_id: int | None = None
|
||||
close_date: date | None = None
|
||||
|
||||
|
||||
class EntityResponse(BaseModel):
|
||||
@@ -139,6 +144,7 @@ class EntityResponse(BaseModel):
|
||||
fund_size_cents: int | None
|
||||
status: EntityStatus
|
||||
linked_user_id: int | None = None
|
||||
close_date: date | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -274,6 +280,25 @@ class CapitalAccountResponse(BaseModel):
|
||||
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
|
||||
# "Exited" badge instead of a phantom -100% and drops the position from totals.
|
||||
exited_on: date | None = None
|
||||
# Bitcoin-denominated view: BTC/USD at this statement's as-of date (newest uploaded price
|
||||
# on or before it) and at the fund's close date (the entry mark). Null when no price or
|
||||
# no close date is set — the portal simply hides the BTC view then.
|
||||
btc_price_cents: int | None = None
|
||||
btc_close_price_cents: int | None = None
|
||||
|
||||
|
||||
# --- BTC prices (bitcoin-denominated view) ---
|
||||
|
||||
class BtcPricesStatus(BaseModel):
|
||||
count: int
|
||||
first_date: date | None = None
|
||||
last_date: date | None = None
|
||||
latest_price_cents: int | None = None
|
||||
|
||||
|
||||
class BtcPricesImportResult(BtcPricesStatus):
|
||||
imported: int # rows upserted from this file (new + updated)
|
||||
skipped_rows: int # unparseable lines ignored
|
||||
|
||||
|
||||
# --- Partners (members of an entity) ---
|
||||
|
||||
@@ -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