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)
|
sys.exit(1)
|
||||||
user.password_hash = hash_password(args.password)
|
user.password_hash = hash_password(args.password)
|
||||||
user.login_enabled = True
|
user.login_enabled = True
|
||||||
|
user.must_change_password = False
|
||||||
is_admin = user.is_service_admin
|
is_admin = user.is_service_admin
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -134,6 +135,7 @@ def enable_investor_logins(args: argparse.Namespace) -> None:
|
|||||||
for u in users:
|
for u in users:
|
||||||
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
|
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
|
||||||
u.login_enabled = True
|
u.login_enabled = True
|
||||||
|
u.must_change_password = True
|
||||||
session.add(u)
|
session.add(u)
|
||||||
session.commit()
|
session.commit()
|
||||||
for u in users:
|
for u in users:
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
from datetime import date as _date # for fields literally named "date"
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
|
from sqlmodel import Field, SQLModel, Column, Date, String, JSON, UniqueConstraint
|
||||||
|
|
||||||
|
|
||||||
# --- Enums ---
|
# --- Enums ---
|
||||||
@@ -85,6 +86,11 @@ class User(SQLModel, table=True):
|
|||||||
totp_enabled: bool = Field(default=False)
|
totp_enabled: bool = Field(default=False)
|
||||||
# JSON list of sha256 hex digests of unused one-time recovery codes.
|
# JSON list of sha256 hex digests of unused one-time recovery codes.
|
||||||
totp_recovery_codes: str | None = Field(default=None)
|
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)
|
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
|
# 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.
|
# 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)
|
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)
|
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):
|
class Holding(SQLModel, table=True):
|
||||||
__tablename__ = "holdings"
|
__tablename__ = "holdings"
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
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.")
|
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(status_code=401, detail="Account disabled")
|
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:
|
if user.totp_enabled:
|
||||||
# Password accepted, but don't sign the session in yet — park the login until
|
# Password accepted, but don't sign the session in yet — park the login until
|
||||||
# /login/verify-totp confirms the second factor.
|
# /login/verify-totp confirms the second factor.
|
||||||
@@ -155,7 +163,10 @@ def change_password(
|
|||||||
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
raise HTTPException(status_code=400, detail="Current password is incorrect.")
|
||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
|
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.password_hash = hash_password(body.new_password)
|
||||||
|
user.must_change_password = False
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
# Once the built-in admin sets their own password, the initial one from first boot is stale.
|
# 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"}
|
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) ---
|
# --- Two-factor enrollment (per-user opt-in) ---
|
||||||
|
|
||||||
@router.post("/totp/setup")
|
@router.post("/totp/setup")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Capital account statements: admin entry, investor read of their own figures."""
|
"""Capital account statements: admin entry, investor read of their own figures."""
|
||||||
|
|
||||||
|
from bisect import bisect_right
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlmodel import Session, select, col
|
from sqlmodel import Session, select, col
|
||||||
|
|
||||||
@@ -10,7 +12,7 @@ from ten31portal.auth import (
|
|||||||
)
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
BtcPrice, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||||
)
|
)
|
||||||
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
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("")
|
@router.get("")
|
||||||
def list_statements(
|
def list_statements(
|
||||||
entity_id: int | None = None,
|
entity_id: int | None = None,
|
||||||
@@ -74,11 +103,14 @@ def list_statements(
|
|||||||
)
|
)
|
||||||
).all()) if rows else {}
|
).all()) if rows else {}
|
||||||
exits = exit_dates(session, rows)
|
exits = exit_dates(session, rows)
|
||||||
|
btc_asof, btc_close = btc_marks(session, rows)
|
||||||
out: list[CapitalAccountResponse] = []
|
out: list[CapitalAccountResponse] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
data = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
data.investor_name = names.get(r.investor_user_id)
|
data.investor_name = names.get(r.investor_user_id)
|
||||||
data.exited_on = exits.get((r.investor_user_id, r.entity_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)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,8 @@ def commit_import(
|
|||||||
role=UserRole.investor,
|
role=UserRole.investor,
|
||||||
login_enabled=True,
|
login_enabled=True,
|
||||||
external_investor_id=inv.external_id,
|
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.add(user)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -294,7 +294,9 @@ def update_entity(
|
|||||||
setattr(entity, key, val)
|
setattr(entity, key, val)
|
||||||
session.add(entity)
|
session.add(entity)
|
||||||
session.flush()
|
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.commit()
|
||||||
session.refresh(entity)
|
session.refresh(entity)
|
||||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
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.auth import require_role
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
Entity, EntityType, Holding, Position,
|
BtcPrice, Entity, EntityType, Holding, Position,
|
||||||
User, UserRole, Valuation, ValuationRound, RoundStatus,
|
User, UserRole, Valuation, ValuationRound, RoundStatus,
|
||||||
)
|
)
|
||||||
|
from ten31portal.schemas import BtcPricesImportResult, BtcPricesStatus
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
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
|
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})
|
col(User.id).in_({r.investor_user_id for r in cap_rows})
|
||||||
)
|
)
|
||||||
).all()) if cap_rows else {}
|
).all()) if cap_rows else {}
|
||||||
# Mirror the investor's own portal exactly — including exit status, so an exited
|
# Mirror the investor's own portal exactly — including exit status and BTC marks,
|
||||||
# position shows its badge here too instead of a phantom -100%.
|
# so this view never drifts from what the LP actually sees.
|
||||||
from ten31portal.routers.capital_account_router import exit_dates
|
from ten31portal.routers.capital_account_router import btc_marks, exit_dates
|
||||||
exits = exit_dates(session, cap_rows)
|
exits = exit_dates(session, cap_rows)
|
||||||
|
btc_asof, btc_close = btc_marks(session, cap_rows)
|
||||||
for r in cap_rows:
|
for r in cap_rows:
|
||||||
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||||
d.investor_name = names.get(r.investor_user_id)
|
d.investor_name = names.get(r.investor_user_id)
|
||||||
d.exited_on = exits.get((r.investor_user_id, r.entity_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)
|
caps.append(d)
|
||||||
|
|
||||||
doc_rows = session.exec(
|
doc_rows = session.exec(
|
||||||
@@ -373,6 +376,8 @@ def reset_password(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
user.password_hash = hash_password(body.password)
|
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
|
user.login_enabled = True # setting a password enables login
|
||||||
session.add(user)
|
session.add(user)
|
||||||
record_audit(session, admin.id, "reset_password", "user", user_id, None)
|
record_audit(session, admin.id, "reset_password", "user", user_id, None)
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class UserResponse(BaseModel):
|
|||||||
is_service_admin: bool = False
|
is_service_admin: bool = False
|
||||||
primary_account_id: int | None = None # set when this account logs in under another
|
primary_account_id: int | None = None # set when this account logs in under another
|
||||||
totp_enabled: bool = False
|
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
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +133,7 @@ class EntityUpdate(BaseModel):
|
|||||||
fund_size_cents: int | None = None
|
fund_size_cents: int | None = None
|
||||||
status: EntityStatus | None = None
|
status: EntityStatus | None = None
|
||||||
linked_user_id: int | None = None
|
linked_user_id: int | None = None
|
||||||
|
close_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class EntityResponse(BaseModel):
|
class EntityResponse(BaseModel):
|
||||||
@@ -139,6 +144,7 @@ class EntityResponse(BaseModel):
|
|||||||
fund_size_cents: int | None
|
fund_size_cents: int | None
|
||||||
status: EntityStatus
|
status: EntityStatus
|
||||||
linked_user_id: int | None = None
|
linked_user_id: int | None = None
|
||||||
|
close_date: date | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -274,6 +280,25 @@ class CapitalAccountResponse(BaseModel):
|
|||||||
# Date the member sold/transferred this stake (from EntityAccess); the portal shows an
|
# 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" badge instead of a phantom -100% and drops the position from totals.
|
||||||
exited_on: date | None = None
|
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) ---
|
# --- 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"
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.38",
|
"version": "0.2.39",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { v_0_2_38 as current } from './v_0_2_38'
|
export { v_0_2_39 as current } from './v_0_2_39'
|
||||||
import { v_0_1_0 } from './v_0_1_0'
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
import { v_0_2_0 } from './v_0_2_0'
|
import { v_0_2_0 } from './v_0_2_0'
|
||||||
import { v_0_2_1 } from './v_0_2_1'
|
import { v_0_2_1 } from './v_0_2_1'
|
||||||
@@ -37,4 +37,5 @@ import { v_0_2_34 } from './v_0_2_34'
|
|||||||
import { v_0_2_35 } from './v_0_2_35'
|
import { v_0_2_35 } from './v_0_2_35'
|
||||||
import { v_0_2_36 } from './v_0_2_36'
|
import { v_0_2_36 } from './v_0_2_36'
|
||||||
import { v_0_2_37 } from './v_0_2_37'
|
import { v_0_2_37 } from './v_0_2_37'
|
||||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37]
|
import { v_0_2_38 } from './v_0_2_38'
|
||||||
|
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_39 = VersionInfo.of({
|
||||||
|
version: '0.2.39:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Investor experience release: (1) Bitcoin-denominated view — upload a BTC price CSV on the Import page, set each fund\'s close date, and LPs see paid-in vs current value in bitcoin terms. (2) First-login flow — accounts on the shared default password must set their own, then get a welcome tour with a two-factor offer. (3) Unfunded commitment metric and a Tax documents center in the LP portal. Also carries 0.2.38: optional two-factor authentication (authenticator app + recovery codes) and the Reset Two-Factor action.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||||
// - /api/* is never cached
|
// - /api/* is never cached
|
||||||
// Bump CACHE on each release so old entries are purged.
|
// Bump CACHE on each release so old entries are purged.
|
||||||
const CACHE = 'ten31-portal-0.2.38'
|
const CACHE = 'ten31-portal-0.2.39'
|
||||||
|
|
||||||
self.addEventListener('install', () => self.skipWaiting())
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { AuthProvider, useAuth } from "./context/AuthContext";
|
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||||
import { isInternal } from "./api";
|
import { isInternal } from "./api";
|
||||||
|
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import EntitiesList from "./pages/EntitiesList";
|
import EntitiesList from "./pages/EntitiesList";
|
||||||
@@ -50,6 +51,7 @@ function ExternalApp() {
|
|||||||
return (
|
return (
|
||||||
<PortalLayout>
|
<PortalLayout>
|
||||||
{user?.role === "fund_administrator" ? <FundAdminHome /> : <InvestorHome />}
|
{user?.role === "fund_administrator" ? <FundAdminHome /> : <InvestorHome />}
|
||||||
|
{user && !user.onboarded_at && <WelcomeFlow />}
|
||||||
</PortalLayout>
|
</PortalLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,9 @@ function ProtectedRoutes() {
|
|||||||
return offline ? <OfflineNotice onRetry={retry} /> : <Login />;
|
return offline ? <OfflineNotice onRetry={retry} /> : <Login />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Still on the shared default password → nothing else until they set their own.
|
||||||
|
if (user.must_change_password) return <ForcePasswordChange />;
|
||||||
|
|
||||||
return isInternal(user.role) ? <InternalApp /> : <ExternalApp />;
|
return isInternal(user.role) ? <InternalApp /> : <ExternalApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface User {
|
|||||||
is_service_admin: boolean;
|
is_service_admin: boolean;
|
||||||
primary_account_id: number | null;
|
primary_account_id: number | null;
|
||||||
totp_enabled: boolean;
|
totp_enabled: boolean;
|
||||||
|
must_change_password: boolean;
|
||||||
|
onboarded_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +156,18 @@ export interface BatchCapitalImportResult {
|
|||||||
total_statements: number;
|
total_statements: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BtcPricesStatus {
|
||||||
|
count: number;
|
||||||
|
first_date: string | null;
|
||||||
|
last_date: string | null;
|
||||||
|
latest_price_cents: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BtcPricesImportResult extends BtcPricesStatus {
|
||||||
|
imported: number;
|
||||||
|
skipped_rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CapitalAccount {
|
export interface CapitalAccount {
|
||||||
id: number;
|
id: number;
|
||||||
entity_id: number;
|
entity_id: number;
|
||||||
@@ -168,6 +182,9 @@ export interface CapitalAccount {
|
|||||||
document_id: number | null;
|
document_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
exited_on: string | null;
|
exited_on: string | null;
|
||||||
|
// BTC/USD marks for the bitcoin-denominated view (null = no price data / no close date)
|
||||||
|
btc_price_cents: number | null;
|
||||||
|
btc_close_price_cents: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
@@ -178,6 +195,7 @@ export interface Entity {
|
|||||||
fund_size_cents: number | null;
|
fund_size_cents: number | null;
|
||||||
status: EntityStatus;
|
status: EntityStatus;
|
||||||
linked_user_id: number | null;
|
linked_user_id: number | null;
|
||||||
|
close_date: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +532,20 @@ export const api = {
|
|||||||
if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed");
|
if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed");
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
// BTC prices (bitcoin-denominated view)
|
||||||
|
btcPricesStatus: () => request<BtcPricesStatus>("/api/import/btc-prices"),
|
||||||
|
importBtcPrices: async (file: File): Promise<BtcPricesImportResult> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const res = await fetch("/api/import/btc-prices", { method: "POST", body: form });
|
||||||
|
const data = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
if (!res.ok) throw new ApiError(res.status, data.detail || "Price import failed");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
markOnboarded: () =>
|
||||||
|
request<{ status: string }>("/api/auth/onboarded", { method: "POST" }),
|
||||||
|
|
||||||
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
|
getUser: (id: number) => request<UserDetail>(`/api/users/${id}`),
|
||||||
createUser: (data: {
|
createUser: (data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import PasswordInput from "./PasswordInput";
|
||||||
|
import TwoFactorModal from "./TwoFactorModal";
|
||||||
|
|
||||||
|
/** Full-screen gate shown while the account is still on the shared default password.
|
||||||
|
* Nothing else is reachable until a personal password is set. */
|
||||||
|
export function ForcePasswordChange() {
|
||||||
|
const { user, retry, logout } = useAuth();
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
if (next.length < 8) return setError("New password must be at least 8 characters.");
|
||||||
|
if (next !== confirm) return setError("New passwords don't match.");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.changePassword(current, next);
|
||||||
|
retry(); // refreshes the user; must_change_password is now false
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Could not change password");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-full max-w-md">
|
||||||
|
<div className="flex items-center gap-2.5 mb-4">
|
||||||
|
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mb-5">
|
||||||
|
Your account is using a temporary password. Choose your own to continue — only you
|
||||||
|
will know it.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Temporary password</label>
|
||||||
|
<PasswordInput value={current} onChange={setCurrent} autoComplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">New password</label>
|
||||||
|
<PasswordInput value={next} onChange={setNext} placeholder="minimum 8 characters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||||
|
<PasswordInput value={confirm} onChange={setConfirm} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy || !current || !next}
|
||||||
|
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Saving…" : "Set password and continue"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time welcome overlay for external accounts: a 20-second orientation plus the 2FA
|
||||||
|
* offer. Dismissing it (either way) stamps onboarded_at so it never shows again. */
|
||||||
|
export function WelcomeFlow() {
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
setDone(true);
|
||||||
|
api.markOnboarded().catch(() => {}); // best-effort; shows again next visit if it failed
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) return null;
|
||||||
|
if (enrolling) {
|
||||||
|
return <TwoFactorModal onClose={() => { setEnrolling(false); finish(); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg w-full max-w-md p-6">
|
||||||
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
|
<img src="/ten31-logo.png" alt="" className="w-8 h-8 rounded-lg" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Welcome to Ten31 Portal</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-2 mb-4">
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-gray-800">Your capital accounts</span> — commitment,
|
||||||
|
paid-in, and current balance for each fund, updated as statements arrive.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-gray-800">Documents</span> — K-1s and fund documents
|
||||||
|
live here permanently; new ones are badged.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="font-medium text-gray-800">Self-hosted by Ten31</span> — your data
|
||||||
|
stays on our own infrastructure, never a third-party service.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm text-gray-600 mb-5">
|
||||||
|
One more thing worth doing: add two-factor authentication, so your account stays
|
||||||
|
safe even if your password is ever guessed.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={finish} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Maybe later
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEnrolling(true)}
|
||||||
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800"
|
||||||
|
>
|
||||||
|
Set up two-factor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -234,6 +234,7 @@ function EditEntityForm({
|
|||||||
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "",
|
||||||
);
|
);
|
||||||
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
|
const [linkedUserId, setLinkedUserId] = useState<number | "">(entity.linked_user_id ?? "");
|
||||||
|
const [closeDate, setCloseDate] = useState(entity.close_date ?? "");
|
||||||
const [investors, setInvestors] = useState<User[]>([]);
|
const [investors, setInvestors] = useState<User[]>([]);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -265,6 +266,7 @@ function EditEntityForm({
|
|||||||
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100)
|
||||||
: null,
|
: null,
|
||||||
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
|
linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null,
|
||||||
|
close_date: closeDate || null,
|
||||||
};
|
};
|
||||||
const updated = await api.updateEntity(entity.id, data);
|
const updated = await api.updateEntity(entity.id, data);
|
||||||
onSaved(updated);
|
onSaved(updated);
|
||||||
@@ -312,6 +314,17 @@ function EditEntityForm({
|
|||||||
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
<input className={inputCls} value={fundSizeDollars} onChange={(e) => setFundSizeDollars(e.target.value)} placeholder="3,300,000" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
|
Close date (BTC entry mark — paid-in capital is valued at the BTC price on this day)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={inputCls}
|
||||||
|
value={closeDate}
|
||||||
|
onChange={(e) => setCloseDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{isGp && (
|
{isGp && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-700 mb-1">
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
api, isAdmin,
|
api, isAdmin,
|
||||||
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
|
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
|
||||||
|
type BtcPricesStatus,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { formatDate, formatMoneyExact } from "../format";
|
import { formatDate, formatMoneyExact } from "../format";
|
||||||
@@ -383,6 +384,8 @@ export default function Import() {
|
|||||||
|
|
||||||
{!memberPv && !result && <BatchBackfill entities={entities} />}
|
{!memberPv && !result && <BatchBackfill entities={entities} />}
|
||||||
|
|
||||||
|
{!memberPv && !result && <BtcPrices />}
|
||||||
|
|
||||||
{confirmReplace && (
|
{confirmReplace && (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||||
@@ -542,3 +545,78 @@ function BatchBackfill({ entities }: { entities: Entity[] }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BTC/USD price history for the bitcoin-denominated LP view: upload a CSV (date + close
|
||||||
|
// price); statements are valued at the newest price on or before their date, and paid-in
|
||||||
|
// at the price on each fund's close date (set on the fund's overview page).
|
||||||
|
function BtcPrices() {
|
||||||
|
const [status, setStatus] = useState<BtcPricesStatus | null>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.btcPricesStatus().then(setStatus).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!file) return;
|
||||||
|
setError("");
|
||||||
|
setMsg("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await api.importBtcPrices(file);
|
||||||
|
setStatus(r);
|
||||||
|
setMsg(
|
||||||
|
`Loaded ${r.imported} price${r.imported === 1 ? "" : "s"}` +
|
||||||
|
(r.skipped_rows ? ` (${r.skipped_rows} unreadable row${r.skipped_rows === 1 ? "" : "s"} skipped)` : "") +
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
setFile(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Price import failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10 border-t border-gray-200 pt-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-1">Bitcoin prices</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Upload a CSV of BTC/USD prices (a date column and a close/price column). Investors
|
||||||
|
then see their capital in bitcoin terms; paid-in is valued at the price on each
|
||||||
|
fund's close date — set that on the fund's overview page.
|
||||||
|
</p>
|
||||||
|
{status && status.count > 0 && (
|
||||||
|
<p className="text-xs text-gray-400 mb-3">
|
||||||
|
{status.count} prices on file
|
||||||
|
{status.first_date && status.last_date && (
|
||||||
|
<> · {formatDate(status.first_date)} → {formatDate(status.last_date)}</>
|
||||||
|
)}
|
||||||
|
{status.latest_price_cents != null && (
|
||||||
|
<> · latest ${(status.latest_price_cents / 100).toLocaleString()}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={run}
|
||||||
|
disabled={busy || !file}
|
||||||
|
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Loading…" : "Load prices"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
|
||||||
|
{msg && <p className="text-sm text-green-700 mt-2">{msg}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import CapitalChart, { type CapitalPoint } from "../components/CapitalChart";
|
|||||||
|
|
||||||
const pct = (p: number) => `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(1)}%`;
|
const pct = (p: number) => `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(1)}%`;
|
||||||
|
|
||||||
|
// Dollars-at-cents ÷ BTC-price-at-cents, formatted to a sensible precision for the size.
|
||||||
|
const formatBtc = (usdCents: number, priceCents: number) => {
|
||||||
|
const btc = usdCents / priceCents;
|
||||||
|
const dp = btc >= 10 ? 2 : btc >= 1 ? 3 : 4;
|
||||||
|
return `₿${btc.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp })}`;
|
||||||
|
};
|
||||||
|
|
||||||
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
// The investor-facing portal, rendered purely from data. Used by the investor's own home
|
||||||
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
// (InvestorHome) and by the admin read-only Investor View, so both show exactly the same thing.
|
||||||
export default function InvestorPortalView({
|
export default function InvestorPortalView({
|
||||||
@@ -52,6 +59,7 @@ export default function InvestorPortalView({
|
|||||||
exitedCount={exitedPositions}
|
exitedCount={exitedPositions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<TaxCenter docs={docs} entities={entities} />
|
||||||
{ordered.map((e) => (
|
{ordered.map((e) => (
|
||||||
<FundSection
|
<FundSection
|
||||||
key={e.id}
|
key={e.id}
|
||||||
@@ -139,6 +147,83 @@ function PortfolioSummary({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every K-1 and tax document across the LP's funds, gathered in one place and grouped by
|
||||||
|
// year — so tax season is one stop instead of a hunt through fund cards.
|
||||||
|
function TaxCenter({ docs, entities }: { docs: PortalDocument[]; entities: Entity[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const taxDocs = useMemo(
|
||||||
|
() => docs.filter((d) => d.category === "k1" || d.category === "tax"),
|
||||||
|
[docs],
|
||||||
|
);
|
||||||
|
const entityName = useMemo(
|
||||||
|
() => new Map(entities.map((e) => [e.id, e.name])),
|
||||||
|
[entities],
|
||||||
|
);
|
||||||
|
const byYear = useMemo(() => {
|
||||||
|
const groups = new Map<number, PortalDocument[]>();
|
||||||
|
for (const d of taxDocs) {
|
||||||
|
const y = new Date(d.created_at).getFullYear();
|
||||||
|
const g = groups.get(y) ?? [];
|
||||||
|
g.push(d);
|
||||||
|
groups.set(y, g);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].sort((a, b) => b[0] - a[0]);
|
||||||
|
}, [taxDocs]);
|
||||||
|
|
||||||
|
if (taxDocs.length === 0) return null;
|
||||||
|
const hasNew = taxDocs.some((d) => d.is_new);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-gray-200 rounded-lg p-5 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 w-full text-left"
|
||||||
|
>
|
||||||
|
<span className={`text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}>▸</span>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Tax documents</h2>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{taxDocs.length} document{taxDocs.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{hasNew && (
|
||||||
|
<span className="text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{byYear.map(([year, ds]) => (
|
||||||
|
<div key={year}>
|
||||||
|
<p className="text-xs font-medium text-gray-400 mb-1">{year}</p>
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
|
||||||
|
{ds.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
|
||||||
|
<span className="text-gray-900">{d.title}</span>
|
||||||
|
{d.is_new && (
|
||||||
|
<span className="ml-2 text-[10px] font-medium uppercase text-accent-600 bg-accent-50 px-1.5 py-0.5 rounded">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">
|
||||||
|
{entityName.get(d.entity_id) ?? ""}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
|
||||||
|
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FundSection({
|
function FundSection({
|
||||||
entity,
|
entity,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -302,6 +387,13 @@ function CapitalBlock({
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-2">
|
||||||
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
|
<Metric label="Commitment" value={formatMoneyWhole(latest.commitment_cents)} />
|
||||||
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
|
<Metric label="Paid-in" value={formatMoneyWhole(latest.contributions_cents)} />
|
||||||
|
{latest.commitment_cents > latest.contributions_cents && (
|
||||||
|
<Metric
|
||||||
|
label="Unfunded"
|
||||||
|
value={formatMoneyWhole(latest.commitment_cents - latest.contributions_cents)}
|
||||||
|
sub="callable commitment"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{latest.distributions_cents > 0 && (
|
{latest.distributions_cents > 0 && (
|
||||||
<Metric
|
<Metric
|
||||||
label="Distributions"
|
label="Distributions"
|
||||||
@@ -326,11 +418,52 @@ function CapitalBlock({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<BtcTerms latest={latest} />
|
||||||
|
|
||||||
<HistorySection history={history} />
|
<HistorySection history={history} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bitcoin-denominated view: paid-in capital valued at the BTC price on the fund's close
|
||||||
|
// (the entry mark set by the admin), current value at the newest statement's price. Hidden
|
||||||
|
// entirely until the admin has uploaded prices and set the fund's close date.
|
||||||
|
function BtcTerms({ latest }: { latest: CapitalAccount }) {
|
||||||
|
const closePrice = latest.btc_close_price_cents;
|
||||||
|
const asofPrice = latest.btc_price_cents;
|
||||||
|
if (!closePrice || !asofPrice || latest.contributions_cents <= 0) return null;
|
||||||
|
|
||||||
|
const paidInBtc = latest.contributions_cents / closePrice;
|
||||||
|
const totalNowBtc = (latest.ending_balance_cents + latest.distributions_cents) / asofPrice;
|
||||||
|
const btcPct = paidInBtc > 0 ? (totalNowBtc / paidInBtc - 1) * 100 : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 border border-accent-100 bg-accent-50/50 rounded p-3">
|
||||||
|
<p className="text-xs font-medium uppercase text-gray-500">In bitcoin terms</p>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 mt-1.5">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Paid-in{" "}
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatBtc(latest.contributions_cents, closePrice)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-xs text-gray-400">at close</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Now{" "}
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{formatBtc(latest.ending_balance_cents + latest.distributions_cents, asofPrice)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{btcPct != null && (
|
||||||
|
<span className={`text-sm font-medium ${btcPct >= 0 ? "text-accent-600" : "text-red-600"}`}>
|
||||||
|
{pct(btcPct)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Collapsible capital-over-time chart + statement table. Collapsed by default so the portal
|
// Collapsible capital-over-time chart + statement table. Collapsed by default so the portal
|
||||||
// opens clean; the investor expands the trend per fund. SPVs get sparse event-driven points,
|
// opens clean; the investor expands the trend per fund. SPVs get sparse event-driven points,
|
||||||
// funds quarterly ones — the chart plots whatever statements exist.
|
// funds quarterly ones — the chart plots whatever statements exist.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Bumped each release so the running build is visible in the UI.
|
// Bumped each release so the running build is visible in the UI.
|
||||||
// If the number shown in the app doesn't match the installed s9pk version,
|
// If the number shown in the app doesn't match the installed s9pk version,
|
||||||
// the new frontend isn't actually being served.
|
// the new frontend isn't actually being served.
|
||||||
export const APP_VERSION = "0.2.38";
|
export const APP_VERSION = "0.2.39";
|
||||||
|
|||||||
Reference in New Issue
Block a user