diff --git a/backend/alembic/versions/a3b4c5d6e7f8_btc_prices_onboarding.py b/backend/alembic/versions/a3b4c5d6e7f8_btc_prices_onboarding.py new file mode 100644 index 0000000..ee2078c --- /dev/null +++ b/backend/alembic/versions/a3b4c5d6e7f8_btc_prices_onboarding.py @@ -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') diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py index 3c77b65..a164729 100644 --- a/backend/ten31portal/cli.py +++ b/backend/ten31portal/cli.py @@ -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: diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 7e3b2a1..265d670 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -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" diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py index ffe278c..39707aa 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -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") diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index 80a1cb6..82cd3d0 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -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 diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py index 1b5f6ea..023da8e 100644 --- a/backend/ten31portal/routers/capital_import_router.py +++ b/backend/ten31portal/routers/capital_import_router.py @@ -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() diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index 4813610..4c86a75 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -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) diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index ce1b267..525ff5b 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -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)) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py index 9dbed05..5c6cd00 100644 --- a/backend/ten31portal/routers/user_router.py +++ b/backend/ten31portal/routers/user_router.py @@ -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) diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index 6fe0ab7..337333e 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -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) --- diff --git a/backend/tests/test_btc_and_onboarding.py b/backend/tests/test_btc_and_onboarding.py new file mode 100644 index 0000000..edf2a33 --- /dev/null +++ b/backend/tests/test_btc_and_onboarding.py @@ -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" diff --git a/deploy/package.json b/deploy/package.json index 5e4863b..8ff9ce2 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.38", + "version": "0.2.39", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index 5cd5a51..afd4191 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -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_2_0 } from './v_0_2_0' 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_36 } from './v_0_2_36' 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] diff --git a/deploy/startos/install/versions/v_0_2_39.ts b/deploy/startos/install/versions/v_0_2_39.ts new file mode 100644 index 0000000..1727a1a --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_39.ts @@ -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 }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 13e14bd..aac4661 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // 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()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9bb810..3f10066 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { AuthProvider, useAuth } from "./context/AuthContext"; import { isInternal } from "./api"; +import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin"; import Layout from "./components/Layout"; import Login from "./pages/Login"; import EntitiesList from "./pages/EntitiesList"; @@ -50,6 +51,7 @@ function ExternalApp() { return ( {user?.role === "fund_administrator" ? : } + {user && !user.onboarded_at && } ); } @@ -87,6 +89,9 @@ function ProtectedRoutes() { return offline ? : ; } + // Still on the shared default password → nothing else until they set their own. + if (user.must_change_password) return ; + return isInternal(user.role) ? : ; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5a35678..3786497 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -45,6 +45,8 @@ export interface User { is_service_admin: boolean; primary_account_id: number | null; totp_enabled: boolean; + must_change_password: boolean; + onboarded_at: string | null; created_at: string; } @@ -154,6 +156,18 @@ export interface BatchCapitalImportResult { 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 { id: number; entity_id: number; @@ -168,6 +182,9 @@ export interface CapitalAccount { document_id: number | null; created_at: string; 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 { @@ -178,6 +195,7 @@ export interface Entity { fund_size_cents: number | null; status: EntityStatus; linked_user_id: number | null; + close_date: string | null; created_at: string; } @@ -514,6 +532,20 @@ export const api = { if (!res.ok) throw new ApiError(res.status, data.detail || "Batch import failed"); return data; }, + // BTC prices (bitcoin-denominated view) + btcPricesStatus: () => request("/api/import/btc-prices"), + importBtcPrices: async (file: File): Promise => { + 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(`/api/users/${id}`), createUser: (data: { name: string; diff --git a/frontend/src/components/FirstLogin.tsx b/frontend/src/components/FirstLogin.tsx new file mode 100644 index 0000000..c78ec34 --- /dev/null +++ b/frontend/src/components/FirstLogin.tsx @@ -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 ( +
+
+
+ +

Welcome{user ? `, ${user.name}` : ""}

+
+

+ Your account is using a temporary password. Choose your own to continue — only you + will know it. +

+
+
+ + +
+
+ + +
+
+ + +
+ {error &&

{error}

} + + +
+
+
+ ); +} + +/** 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 { setEnrolling(false); finish(); }} />; + } + + return ( +
+
+
+ +

Welcome to Ten31 Portal

+
+
    +
  • + Your capital accounts — commitment, + paid-in, and current balance for each fund, updated as statements arrive. +
  • +
  • + Documents — K-1s and fund documents + live here permanently; new ones are badged. +
  • +
  • + Self-hosted by Ten31 — your data + stays on our own infrastructure, never a third-party service. +
  • +
+

+ One more thing worth doing: add two-factor authentication, so your account stays + safe even if your password is ever guessed. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/pages/EntityOverview.tsx b/frontend/src/pages/EntityOverview.tsx index e86030e..7824fb9 100644 --- a/frontend/src/pages/EntityOverview.tsx +++ b/frontend/src/pages/EntityOverview.tsx @@ -234,6 +234,7 @@ function EditEntityForm({ entity.fund_size_cents != null ? (entity.fund_size_cents / 100).toString() : "", ); const [linkedUserId, setLinkedUserId] = useState(entity.linked_user_id ?? ""); + const [closeDate, setCloseDate] = useState(entity.close_date ?? ""); const [investors, setInvestors] = useState([]); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); @@ -265,6 +266,7 @@ function EditEntityForm({ ? Math.round(parseFloat(fundSizeDollars.replace(/[,$]/g, "")) * 100) : null, linked_user_id: isGp ? (linkedUserId === "" ? null : Number(linkedUserId)) : null, + close_date: closeDate || null, }; const updated = await api.updateEntity(entity.id, data); onSaved(updated); @@ -312,6 +314,17 @@ function EditEntityForm({ setFundSizeDollars(e.target.value)} placeholder="3,300,000" /> +
+ + setCloseDate(e.target.value)} + /> +
{isGp && (