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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user