Compare commits
23
Commits
main
...
investor-access
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "frontend",
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
@@ -10,6 +10,9 @@ dist/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Built frontend served by backend (produced by the deploy build)
|
||||
backend/static/
|
||||
|
||||
# DB
|
||||
*.db
|
||||
|
||||
@@ -17,6 +20,19 @@ frontend/dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Packaged service artifacts
|
||||
*.s9pk
|
||||
|
||||
# Deploy
|
||||
deploy/node_modules/
|
||||
deploy/javascript/
|
||||
|
||||
# Confidential fund-admin spreadsheets (never commit)
|
||||
*.xlsx
|
||||
*.xls
|
||||
|
||||
# start-cli signing key + local config (never commit)
|
||||
deploy/.startos/
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
Internal system of record for Ten31 entities, holdings, positions, and quarterly valuation sign-off.
|
||||
|
||||
## Accounts and access
|
||||
|
||||
Two kinds of accounts:
|
||||
|
||||
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) — the full back-office app
|
||||
(entities, holdings, valuations, import, audit). `approver` and `cfo` also get the
|
||||
admin screens below.
|
||||
- **External accounts** (`investor`, `fund_administrator`) — a separate, entity-scoped
|
||||
portal. An external account only sees the entities granted to it.
|
||||
- **Investor** — sees, per fund, their latest capital-account value and history, plus
|
||||
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||
- **Fund administrator** — sees assigned entities and can upload documents for them
|
||||
(shared or addressed to a specific investor).
|
||||
|
||||
Admin screens (Users / Documents / Capital Accounts, visible to `approver` and `cfo`)
|
||||
let you create an account with a username and password, check off which entities it can
|
||||
view, upload documents, and enter each investor's capital-account figures.
|
||||
|
||||
Login accepts a username **or** an email. The first admin is created from the CLI:
|
||||
|
||||
```bash
|
||||
ten31portal-cli create-user --name "You" --username admin --role cfo --password '...'
|
||||
# --email is optional; external accounts are normally created from the Users screen.
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Ten31Portal — ROADMAP
|
||||
|
||||
Deferred / parked work, grounded in a read-only survey of the current tree on 2026-07-01 and
|
||||
adjudicated the same day (investigate → debate → judge over each item). 13 items were dropped
|
||||
as not-worth-it and removed; what remains is either scheduled work (DO) with a ready plan, or
|
||||
your decision (ESCALATE / HIGH). Priority key: **P2** = nice-to-have, **P3** = trivial.
|
||||
|
||||
## Current state
|
||||
|
||||
Working internal fund-admin portal (FastAPI + SQLModel + SQLite backend, React/Vite/Tailwind
|
||||
frontend, StartOS 0.4.0 packaging). Phase-1 (entities, holdings, positions, valuation rounds,
|
||||
sign-off, audit, CSV import, four roles) is built. Phase-2 (external investor / fund-admin
|
||||
accounts, capital accounts, documents) is built but largely **uncommitted** in the working
|
||||
tree. No test suite. Single git commit to date.
|
||||
|
||||
---
|
||||
|
||||
## HIGH — owner's call (not backlog; surfaced, never auto-judged)
|
||||
|
||||
- **H1. Session secret: backup coverage + `change-me` fallbacks.** `start.sh:5-11` persists a
|
||||
random secret to `/data/.session-secret`, but `backups.ts:4` backs up only the `main`
|
||||
volume — if `/data` is a separate volume, a restore regenerates the secret and silently logs
|
||||
everyone out. Separately, `main.ts:27` and `config.py:7` fall back to a hardcoded `change-me`
|
||||
string if the env var is unset. **Reinforced by D8 below:** adjudication confirmed the secret
|
||||
really is regenerated whenever `/data` is wiped, so the backup-coverage question is live.
|
||||
Needs verification of the volume mapping, then a fix. *(security + data continuity)*
|
||||
- **H2. Login hardening.** `auth_router.py:14` has no rate limiting / lockout on failed logins,
|
||||
and `auth_router.py:60` allows a 4-character password minimum. External-facing attack
|
||||
surface. *(security)*
|
||||
|
||||
---
|
||||
|
||||
## DO — adjudicated worth-doing + low-risk (your go-ahead to schedule)
|
||||
|
||||
### Backend
|
||||
- **B3. Backend test suite** (P2). ~3,500-line financial system of record with zero tests.
|
||||
*Plan:* add pytest/pytest-asyncio/httpx as dev deps in `pyproject.toml` (leave runtime deps);
|
||||
add `backend/tests/` with a `conftest.py` overriding `get_session` (database.py:10) to a fresh
|
||||
in-memory SQLite + a TestClient fixture and an authenticated-user fixture; first tests cover
|
||||
auth (login works; anon + cross-user access rejected), one CRUD router round-trip, and the
|
||||
rollup (entity_router.py:36) asserting correct aggregate numbers. Additive; no prod-code change.
|
||||
- **B4. Document upload size limit** (P2). Uploads stream to disk with no cap, on the same volume
|
||||
as the DB — a runaway upload can fill it and take the portal down. *Plan:* add
|
||||
`MAX_UPLOAD_SIZE` to `config.py` (env `TEN31_MAX_UPLOAD_SIZE`, default 50 MB); in
|
||||
`storage.save_upload` (19-30) check size in the stream loop, `unlink` the partial and raise
|
||||
413; optional early Content-Length check; add an over-limit test.
|
||||
- **B7. Fix audit `detail` type label** (P3). `models.py:154` types `detail` as `str | None` but
|
||||
callers store dicts. *Plan:* change annotation to `dict | list | str | None` to match
|
||||
`schemas.py`; add a one-line comment with examples; note it in `record_audit` (audit.py:17).
|
||||
Documentation/label only — no migration, no call-site changes.
|
||||
- **B10. FK indexes** (P3). ~a dozen linking columns have no indexes; zero indexes anywhere.
|
||||
*Plan:* one additive Alembic migration adding `create_index` on the FK columns
|
||||
(users.primary_account_id, holdings.entity_id, positions.holding_id, valuation_rounds.*,
|
||||
valuations.*, audit_logs.actor_user_id, entity_access.*, documents.*,
|
||||
capital_account_statements.*); mirror with `index=True` on the model Fields; `downgrade`
|
||||
drops them. No query-code change.
|
||||
- **B11. cli.py silent delete errors** (P3). `cli.py:123-126` `except Exception: pass` discards
|
||||
real disk/permission failures and orphans files. *Plan:* capture the exception, print a
|
||||
warning to stderr (matching existing style) with the failing `storage_path`, continue the
|
||||
loop; optionally narrow the catch to `OSError`. No DB/API change.
|
||||
|
||||
### Frontend
|
||||
- **F2. AuthContext: network vs auth failure** (P2, medium conf). A momentary connection blip
|
||||
wrongly shows a logged-out login screen. *Plan:* export `ApiError` from `api.ts`; add an
|
||||
`error`/`offline` flag to `AuthState`; change the line-18 bootstrap catch to inspect the error
|
||||
— 401/403 = logged out, network/5xx = offline flag; render a "couldn't reach the server,
|
||||
retrying" banner (with retry) vs. the login screen.
|
||||
- **F4. Import page: add a real confirmation** (P2). One checkbox + Confirm can permanently
|
||||
erase a fund's holdings, positions, and valuation history with no final "are you sure?".
|
||||
*Plan:* in `Import.tsx`, gate the destructive replace (before the commit at ~145-152) behind a
|
||||
confirmation dialog that spells out what's cleared; add per-step progress labels ("Saving
|
||||
members…", "Loading holdings…"). Inputs already persist, so the retry concern is minor.
|
||||
- **F6. AuditLog expandable detail** (P2). The "what changed" column is truncated with no way to
|
||||
read the rest — exactly when the log matters (a dispute). Data's already sent. *Plan:* add an
|
||||
expanded-row state; remove the always-truncate classes / make the row clickable; render a
|
||||
full-width row with pretty-printed JSON in a `<pre>`; add a `title` on the collapsed cell.
|
||||
- **F7. Investments empty-state** (P3, medium conf). An entity with no holdings shows a row of
|
||||
zeros + empty table that reads as "failed to load." *Plan:* in `Investments.tsx`, when
|
||||
`groups.length === 0` render an empty-state block ("No investments recorded yet" + one line of
|
||||
guidance) instead of the table. Display-only.
|
||||
- **F8. Centralize role checks** (P3). Four pages hand-copy role lists; the shared helpers
|
||||
already exist in `api.ts` and just aren't used. *Plan:* add `WRITER_ROLES` + `canEditRound` to
|
||||
`api.ts`; point AuditLog/Import at the existing `isAdmin`; import the shared list in
|
||||
EntitiesList; use `canEditRound` in ValuationWorkflow. Keep every list byte-identical; do NOT
|
||||
merge `fund_admin` (internal) and `fund_administrator` (external) — they are distinct roles.
|
||||
|
||||
### Deploy
|
||||
- **D2. Commit the version stub files** (P2). `index.ts` is tracked and imports 20 untracked
|
||||
`v_0_2_*.ts` files — committing as-is breaks a fresh checkout. *Plan:* stage the 20 files with
|
||||
the modified `index.ts` and commit together (they carry real release notes); verify a clean
|
||||
checkout builds. The missing `v_0_2_2` is intentional (versions are an unordered set).
|
||||
- **D5. Type-check in build** (P2). A `tsc --noEmit` `check` script exists but never runs; it
|
||||
passes clean today. *Plan:* in `s9pk.mk` (~117) change `npm run build` to
|
||||
`npm run check && npm run build`. One line; identical behavior today, catches future type errors.
|
||||
- **D6. `TEN31_LOG_LEVEL` env var** (P3). `start.sh:41` hardcodes `--log-level info`. *Plan:* add
|
||||
`LOG_LEVEL="${TEN31_LOG_LEVEL:-info}"` near the other env defaults and use `"$LOG_LEVEL"` on
|
||||
line 41 — same generate-or-default pattern already in the file. Default stays `info`.
|
||||
|
||||
---
|
||||
|
||||
## ESCALATE — your decision (touches something that matters)
|
||||
|
||||
- **B1. Pagination on list endpoints** (P2, blast radius HIGH). Every list except audit loads all
|
||||
rows, two with an extra query per row — fine now, will slow and time out as data grows.
|
||||
- *Judge's lean:* worth doing, but **split it first** — it's an epic.
|
||||
- *Why it's yours:* changes observable API behaviour across 5 investor-facing endpoints; two
|
||||
lists filter for permissions **in Python after the query**, so a naive page limit could
|
||||
silently return short/wrong lists of capital-account figures. Sequence the mechanical
|
||||
offset/limit pass separately from the two permission-filtered endpoints.
|
||||
- **F5. ValuationWorkflow input validation** (P2, blast radius HIGH). The Value box accepts a
|
||||
negative number and it reaches the DB uncorrected.
|
||||
- *Judge's lean:* fix it, but the robust fix is **not frontend-only**.
|
||||
- *Why it's yours:* `ValuationBulkItem.value_cents` (schemas.py:165) has no `ge=0` and
|
||||
`round_router.py:140` writes it directly — proper fix adds a backend constraint + the three
|
||||
frontend guards, crossing onto a money-write path beyond the item's stated scope.
|
||||
- **D7. Force override of default admin creds** (P3, blast radius HIGH). A normal install ships
|
||||
the top admin account with a password printed in the public setup guide (`admin` / `Ten31`) and
|
||||
nothing forces a change.
|
||||
- *Judge's lean:* worth fixing **now** — external investor/fund-admin accounts are being
|
||||
onboarded, so "I'm the only user" no longer holds.
|
||||
- *Why it's yours:* changes auth on the most powerful account and rewrites first-boot in ~3
|
||||
spots + 2 docs; a careless change could lock you (or a live deploy) out. Suggested direction:
|
||||
reuse the session-secret generate-and-persist pattern to mint a random admin password on
|
||||
first boot and print it once; add a StartOS config field; test an upgrade path.
|
||||
- **D8. Uninstall alert wording** (P3, blast radius LOW — escalated on a corrected fact). The
|
||||
adjudication caught that the original survey premise was wrong: the session secret **is**
|
||||
regenerated whenever `/data` is wiped (`start.sh:5-11`), so the item is valid, not moot. It
|
||||
sits next to the open **H1** backup question. Re-triage together with H1 using the corrected
|
||||
facts rather than as a standalone wording tweak.
|
||||
@@ -0,0 +1,101 @@
|
||||
"""investor access: username, entity access, documents, capital accounts
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 2792c4ff4612
|
||||
Create Date: 2026-06-26 14:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '2792c4ff4612'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# users: add username (login handle), make email optional.
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
batch_op.alter_column('email', existing_type=sa.String(), nullable=True)
|
||||
|
||||
# Backfill username for any existing internal accounts so the NOT NULL holds.
|
||||
op.execute("UPDATE users SET username = email WHERE username IS NULL")
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'username',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
)
|
||||
batch_op.create_unique_constraint('uq_users_username', ['username'])
|
||||
|
||||
op.create_table(
|
||||
'entity_access',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'documents',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('investor_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||
sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('uploaded_by', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'capital_account_statements',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('investor_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('as_of_date', sa.Date(), nullable=False),
|
||||
sa.Column('beginning_balance_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('contributions_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('distributions_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('ending_balance_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_table('capital_account_statements')
|
||||
op.drop_table('documents')
|
||||
op.drop_table('entity_access')
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_users_username', type_='unique')
|
||||
batch_op.alter_column('email', existing_type=sa.String(), nullable=False)
|
||||
batch_op.drop_column('username')
|
||||
@@ -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')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add indexes on foreign-key columns
|
||||
|
||||
Indexes the FK columns that are commonly filtered/joined and are not already the left-most
|
||||
column of an existing unique constraint (those are covered by the constraint's index). Names
|
||||
match SQLModel's index=True default (ix_<table>_<column>) so the ORM and DB stay in sync.
|
||||
|
||||
Revision ID: a7b8c9d0e1f2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-07-01 09:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = 'a7b8c9d0e1f2'
|
||||
down_revision: Union[str, None] = 'f6a7b8c9d0e1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# (index_name, table, column)
|
||||
_INDEXES = [
|
||||
('ix_users_primary_account_id', 'users', 'primary_account_id'),
|
||||
('ix_holdings_entity_id', 'holdings', 'entity_id'),
|
||||
('ix_positions_holding_id', 'positions', 'holding_id'),
|
||||
('ix_valuation_rounds_submitted_by', 'valuation_rounds', 'submitted_by'),
|
||||
('ix_valuation_rounds_approved_by', 'valuation_rounds', 'approved_by'),
|
||||
('ix_valuations_position_id', 'valuations', 'position_id'),
|
||||
('ix_audit_log_actor_user_id', 'audit_log', 'actor_user_id'),
|
||||
('ix_entity_access_entity_id', 'entity_access', 'entity_id'),
|
||||
('ix_documents_entity_id', 'documents', 'entity_id'),
|
||||
('ix_documents_investor_user_id', 'documents', 'investor_user_id'),
|
||||
('ix_documents_uploaded_by', 'documents', 'uploaded_by'),
|
||||
('ix_capital_account_statements_investor_user_id',
|
||||
'capital_account_statements', 'investor_user_id'),
|
||||
('ix_capital_account_statements_document_id',
|
||||
'capital_account_statements', 'document_id'),
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for name, table, column in _INDEXES:
|
||||
op.create_index(name, table, [column])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name, table, _column in reversed(_INDEXES):
|
||||
op.drop_index(name, table_name=table)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add users.external_investor_id
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-06-28 11:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('external_investor_id')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add entity_stakes (a holder entity's stake in the funds it manages)
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-07-01 10:15:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'b8c9d0e1f2a3'
|
||||
down_revision: Union[str, None] = 'a7b8c9d0e1f2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'entity_stakes',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column('holder_entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('fund_entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('ownership_pct', sa.Float(), nullable=True),
|
||||
sa.Column('value_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('note', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']),
|
||||
sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']),
|
||||
sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'),
|
||||
)
|
||||
op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id'])
|
||||
op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes')
|
||||
op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes')
|
||||
op.drop_table('entity_stakes')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add users.login_enabled
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-06-28 11:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('login_enabled')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add entities.linked_user_id (link a GP entity to its investor account)
|
||||
|
||||
Revision ID: c9d0e1f2a3b4
|
||||
Revises: b8c9d0e1f2a3
|
||||
Create Date: 2026-07-01 10:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'c9d0e1f2a3b4'
|
||||
down_revision: Union[str, None] = 'b8c9d0e1f2a3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('linked_user_id', sa.Integer(), nullable=True))
|
||||
op.create_index('ix_entities_linked_user_id', 'entities', ['linked_user_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_entities_linked_user_id', table_name='entities')
|
||||
with op.batch_alter_table('entities', schema=None) as batch_op:
|
||||
batch_op.drop_column('linked_user_id')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add users.docs_seen_at (portal "New" document badge watermark)
|
||||
|
||||
Revision ID: d0e1f2a3b4c5
|
||||
Revises: c9d0e1f2a3b4
|
||||
Create Date: 2026-07-03 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd0e1f2a3b4c5'
|
||||
down_revision: Union[str, None] = 'c9d0e1f2a3b4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('docs_seen_at')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add capital_account_statements.commitment_cents
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-06-28 16:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = 'c3d4e5f6a7b8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||
batch_op.drop_column('commitment_cents')
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add entity_access.exited_on (member sold/transferred their stake)
|
||||
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: d0e1f2a3b4c5
|
||||
Create Date: 2026-07-03 10:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'e1f2a3b4c5d6'
|
||||
down_revision: Union[str, None] = 'd0e1f2a3b4c5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('entity_access', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('exited_on', sa.Date(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('entity_access', schema=None) as batch_op:
|
||||
batch_op.drop_column('exited_on')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add users.primary_account_id (linked investor logins)
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-06-28 17:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'e5f6a7b8c9d0'
|
||||
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('primary_account_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey')
|
||||
batch_op.drop_column('primary_account_id')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add users TOTP two-factor columns (secret, enabled flag, recovery codes)
|
||||
|
||||
Revision ID: f2a3b4c5d6e7
|
||||
Revises: e1f2a3b4c5d6
|
||||
Create Date: 2026-07-11 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'f2a3b4c5d6e7'
|
||||
down_revision: Union[str, None] = 'e1f2a3b4c5d6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('totp_secret', sa.String(), nullable=True))
|
||||
batch_op.add_column(
|
||||
sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
batch_op.add_column(sa.Column('totp_recovery_codes', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('totp_recovery_codes')
|
||||
batch_op.drop_column('totp_enabled')
|
||||
batch_op.drop_column('totp_secret')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add users.is_service_admin and flag the bootstrap admin
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-06-29 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'f6a7b8c9d0e1'
|
||||
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
# The first account created (the first-boot bootstrap admin) is the Service Admin.
|
||||
op.execute(
|
||||
"UPDATE users SET is_service_admin = 1 "
|
||||
"WHERE id = (SELECT MIN(id) FROM users)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_service_admin')
|
||||
@@ -14,6 +14,15 @@ dependencies = [
|
||||
"aiosqlite==0.21.0",
|
||||
"starlette-session==0.4.3",
|
||||
"openpyxl==3.1.5",
|
||||
"msoffcrypto-tool==6.0.0",
|
||||
"pyotp==2.9.0",
|
||||
"qrcode==8.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==8.3.4",
|
||||
"httpx==0.28.1", # required by starlette's TestClient
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -23,3 +32,6 @@ ten31portal-cli = "ten31portal.cli:main"
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -16,7 +16,12 @@ def record_audit(
|
||||
object_id: int | None = None,
|
||||
detail: Any = None,
|
||||
) -> AuditLog:
|
||||
"""Write one audit log entry and flush it."""
|
||||
"""Write one audit log entry and flush it.
|
||||
|
||||
``detail`` is a small JSON-serializable payload describing the change (typically a dict of
|
||||
changed fields, sometimes an identifying value on delete, or None). It is persisted to a
|
||||
JSON column on AuditLog.
|
||||
"""
|
||||
entry = AuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
action=action,
|
||||
|
||||
@@ -5,10 +5,10 @@ from typing import Annotated
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, select, col
|
||||
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import User, UserRole
|
||||
from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES
|
||||
|
||||
ph = PasswordHasher()
|
||||
|
||||
@@ -44,8 +44,83 @@ def require_role(*roles: UserRole):
|
||||
return checker
|
||||
|
||||
|
||||
def require_internal(user: User = Depends(get_current_user)) -> User:
|
||||
"""Block external (entity-scoped) accounts from internal staff endpoints."""
|
||||
if user.role in EXTERNAL_ROLES:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
|
||||
|
||||
def household_user_ids(user: User, session: Session) -> list[int]:
|
||||
"""All account ids that share this user's login.
|
||||
|
||||
An investor who invests under several legal names has one "primary" account (the login)
|
||||
and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as
|
||||
the primary should surface every linked name's entities, statements, and documents. For a
|
||||
standalone account this is just ``[user.id]``.
|
||||
"""
|
||||
root_id = user.primary_account_id or user.id
|
||||
linked = session.exec(
|
||||
select(User.id).where(User.primary_account_id == root_id)
|
||||
).all()
|
||||
return list({root_id, user.id, *linked})
|
||||
|
||||
|
||||
def accessible_entity_ids(user: User, session: Session) -> set[int] | None:
|
||||
"""Entity ids an external account may view. None means unrestricted (internal staff)."""
|
||||
if user.role not in EXTERNAL_ROLES:
|
||||
return None
|
||||
rows = session.exec(
|
||||
select(EntityAccess.entity_id).where(
|
||||
col(EntityAccess.user_id).in_(household_user_ids(user, session))
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def can_access_entity(user: User, entity_id: int, session: Session) -> bool:
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
return allowed is None or entity_id in allowed
|
||||
|
||||
|
||||
# Convenience aliases
|
||||
require_user = get_current_user
|
||||
require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver)
|
||||
require_approver = require_role(UserRole.approver)
|
||||
require_audit_reader = require_role(UserRole.approver, UserRole.cfo)
|
||||
require_writer = require_role(
|
||||
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations
|
||||
)
|
||||
require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only
|
||||
require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||
# Account administration (create/manage users, documents, capital accounts)
|
||||
require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
|
||||
|
||||
# Internal admins plus the external Administrator (fund_administrator). Every endpoint
|
||||
# using this gate must also call check_administrator_scope for the entity it touches —
|
||||
# the role alone says nothing about WHICH entities an Administrator may manage.
|
||||
require_admin = require_role(
|
||||
UserRole.approver, UserRole.cfo, UserRole.operations, UserRole.fund_administrator
|
||||
)
|
||||
# Entity-record writers: internal writers plus the external Administrator (scope-checked).
|
||||
require_entity_writer = require_role(
|
||||
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations,
|
||||
UserRole.fund_administrator,
|
||||
)
|
||||
|
||||
|
||||
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
|
||||
"""Read gate for admin screens: any internal role, or an external Administrator
|
||||
(managing or view-only).
|
||||
|
||||
Investors are blocked; external calls must still be scope-checked per entity.
|
||||
"""
|
||||
if user.role == UserRole.investor:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return user
|
||||
|
||||
|
||||
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
|
||||
"""403 when an external account touches an entity outside their grants.
|
||||
|
||||
Internal roles pass through untouched — their reach is decided by the route's gate.
|
||||
"""
|
||||
if user.role in EXTERNAL_ROLES and not can_access_entity(user, entity_id, session):
|
||||
raise HTTPException(status_code=403, detail="No access to this entity")
|
||||
|
||||
+314
-6
@@ -1,23 +1,38 @@
|
||||
"""CLI commands for Ten31Portal."""
|
||||
"""CLI commands for Ten31Portal (also driven by the StartOS service Actions)."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal import config
|
||||
from ten31portal.auth import hash_password
|
||||
from ten31portal.database import engine
|
||||
from ten31portal.db_init import run_migrations
|
||||
from ten31portal.models import User, UserRole
|
||||
|
||||
|
||||
def _find_user(session: Session, username: str | None, email: str | None) -> User | None:
|
||||
"""Look a user up by username (preferred) or email."""
|
||||
if username:
|
||||
u = session.exec(select(User).where(User.username == username)).first()
|
||||
if u:
|
||||
return u
|
||||
if email:
|
||||
return session.exec(select(User).where(User.email == email)).first()
|
||||
return None
|
||||
|
||||
|
||||
def create_user(args: argparse.Namespace) -> None:
|
||||
"""Create a new user account."""
|
||||
run_migrations()
|
||||
|
||||
with Session(engine) as session:
|
||||
existing = session.exec(select(User).where(User.email == args.email)).first()
|
||||
if existing:
|
||||
if session.exec(select(User).where(User.username == args.username)).first():
|
||||
print(f"Error: user with username {args.username} already exists.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.email and session.exec(select(User).where(User.email == args.email)).first():
|
||||
print(f"Error: user with email {args.email} already exists.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -29,13 +44,247 @@ def create_user(args: argparse.Namespace) -> None:
|
||||
|
||||
user = User(
|
||||
name=args.name,
|
||||
email=args.email,
|
||||
username=args.username,
|
||||
email=args.email or None,
|
||||
password_hash=hash_password(args.password),
|
||||
role=role,
|
||||
is_service_admin=bool(getattr(args, "service_admin", False)),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
print(f"Created user: {user.name} ({user.email}) with role {user.role.value}")
|
||||
print(f"Created user: {user.name} ({user.username}) with role {user.role.value}")
|
||||
|
||||
|
||||
def _clear_admin_password_file() -> None:
|
||||
"""Remove the recorded initial admin password (it is no longer valid once changed)."""
|
||||
try:
|
||||
os.remove(config.ADMIN_PASSWORD_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_password(args: argparse.Namespace) -> None:
|
||||
"""Reset a user's password (and re-enable their login)."""
|
||||
run_migrations()
|
||||
|
||||
with Session(engine) as session:
|
||||
user = _find_user(session, args.username, args.email)
|
||||
if user is None:
|
||||
who = args.username or args.email
|
||||
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||
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()
|
||||
# Resetting the built-in admin invalidates the initial password recorded on first boot.
|
||||
if is_admin:
|
||||
_clear_admin_password_file()
|
||||
print(f"Password reset for {user.name} ({user.username}).")
|
||||
|
||||
|
||||
def reset_2fa(args: argparse.Namespace) -> None:
|
||||
"""Clear a user's two-factor enrollment so they can sign in with password alone."""
|
||||
run_migrations()
|
||||
|
||||
with Session(engine) as session:
|
||||
user = _find_user(session, args.username, args.email)
|
||||
if user is None:
|
||||
who = args.username or args.email
|
||||
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not user.totp_enabled and not user.totp_secret:
|
||||
print(f"{user.name} ({user.username}) does not have two-factor enabled.")
|
||||
return
|
||||
user.totp_secret = None
|
||||
user.totp_enabled = False
|
||||
user.totp_recovery_codes = None
|
||||
session.add(user)
|
||||
session.commit()
|
||||
print(f"Two-factor disabled for {user.name} ({user.username}). "
|
||||
"They can sign in with just their password and re-enroll from the app.")
|
||||
|
||||
|
||||
def enable_investor_logins(args: argparse.Namespace) -> None:
|
||||
"""Give every no-login investor account the shared default password and enable sign-in.
|
||||
|
||||
Targets investor-role accounts with login_enabled=False that log in on their own
|
||||
(linked secondary names are skipped — they sign in under their primary). Accounts that
|
||||
already have a working login are never touched.
|
||||
"""
|
||||
run_migrations()
|
||||
|
||||
from ten31portal.models import UserRole
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(
|
||||
select(User).where(
|
||||
User.role == UserRole.investor,
|
||||
User.login_enabled == False, # noqa: E712 — SQL expression
|
||||
User.primary_account_id == None, # noqa: E711
|
||||
).order_by(User.name) # type: ignore[arg-type]
|
||||
).all()
|
||||
if not users:
|
||||
print("Nothing to do — every investor account already has a login.")
|
||||
return
|
||||
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:
|
||||
print(f"Enabled login for {u.name} ({u.username})")
|
||||
print(
|
||||
f"\n{len(users)} investor account(s) set to the default password "
|
||||
f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the "
|
||||
"portal (Change password)."
|
||||
)
|
||||
|
||||
|
||||
def show_admin_password(args: argparse.Namespace) -> None:
|
||||
"""Print the randomly-generated initial admin password recorded on first boot."""
|
||||
path = config.ADMIN_PASSWORD_FILE
|
||||
if not os.path.exists(path):
|
||||
print(
|
||||
"No stored initial password. It was either set explicitly at install time, or the "
|
||||
"admin password has already been changed. Use Reset Password to set a new one."
|
||||
)
|
||||
return
|
||||
with open(path) as f:
|
||||
pw = f.read().strip()
|
||||
print(pw if pw else "(the recorded initial password is empty)")
|
||||
|
||||
|
||||
def list_users(args: argparse.Namespace) -> None:
|
||||
"""Print all user accounts."""
|
||||
run_migrations()
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(select(User).order_by(User.role, User.name)).all() # type: ignore[arg-type]
|
||||
if not users:
|
||||
print("No users.")
|
||||
return
|
||||
print(f"{'USERNAME':<20} {'NAME':<24} {'ROLE':<14} {'STATUS':<10} EMAIL")
|
||||
print("-" * 84)
|
||||
for u in users:
|
||||
status = "active" if u.is_active else "disabled"
|
||||
tag = " [SERVICE ADMIN]" if u.is_service_admin else ""
|
||||
print(f"{u.username:<20} {u.name:<24} {u.role.value:<14} {status:<10} {u.email or '-'}{tag}")
|
||||
print(f"\n{len(users)} user(s). The Service Admin cannot be deleted.")
|
||||
|
||||
|
||||
def delete_user(args: argparse.Namespace) -> None:
|
||||
"""Delete a user account and its dependent rows. The Service Admin is protected."""
|
||||
run_migrations()
|
||||
|
||||
from ten31portal.routers.user_router import delete_user_cascade
|
||||
|
||||
with Session(engine) as session:
|
||||
user = _find_user(session, args.username, args.email)
|
||||
if user is None:
|
||||
who = args.username or args.email
|
||||
print(f"Error: no user found for '{who}'.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if user.is_service_admin:
|
||||
print(f"Error: '{user.username}' is the Service Admin and cannot be deleted.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
name, username = user.name, user.username
|
||||
# Don't abort on a file that won't delete, but surface it — a swallowed disk or
|
||||
# permission error would silently orphan the file on the data volume.
|
||||
for warning in delete_user_cascade(session, user):
|
||||
print(f"Warning: {warning}", file=sys.stderr)
|
||||
session.commit()
|
||||
print(f"Deleted user {name} ({username}).")
|
||||
|
||||
|
||||
def dedupe_holdings(args: argparse.Namespace) -> None:
|
||||
"""Remove duplicate holdings/positions left by old double-imports (fixes inflated Invested)."""
|
||||
run_migrations()
|
||||
from ten31portal.models import Entity
|
||||
from ten31portal.routers.import_router import dedupe_entity
|
||||
|
||||
with Session(engine) as session:
|
||||
if args.entity_id:
|
||||
entities = [e for e in [session.get(Entity, args.entity_id)] if e]
|
||||
else:
|
||||
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||
total_h = total_p = 0
|
||||
for ent in entities:
|
||||
res = dedupe_entity(ent.id, session)
|
||||
if res["removed_holdings"] or res["removed_positions"]:
|
||||
print(f"{ent.name}: removed {res['removed_holdings']} holding(s), "
|
||||
f"{res['removed_positions']} position(s)")
|
||||
total_h += res["removed_holdings"]
|
||||
total_p += res["removed_positions"]
|
||||
session.commit()
|
||||
if total_h or total_p:
|
||||
print(f"\nDone. Removed {total_h} duplicate holding(s) and {total_p} position(s).")
|
||||
else:
|
||||
print("No duplicates found — nothing to clean up.")
|
||||
|
||||
|
||||
def reset_holdings(args: argparse.Namespace) -> None:
|
||||
"""Clear a fund's holdings/positions/rounds so it can be re-imported from scratch."""
|
||||
run_migrations()
|
||||
from ten31portal.models import Entity
|
||||
from ten31portal.routers.import_router import reset_entity_holdings
|
||||
|
||||
with Session(engine) as session:
|
||||
entity = None
|
||||
if args.entity_id:
|
||||
entity = session.get(Entity, args.entity_id)
|
||||
elif args.name:
|
||||
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
|
||||
if entity is None:
|
||||
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
|
||||
f"Check the exact name with list-funds.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
res = reset_entity_holdings(entity.id, session)
|
||||
session.commit()
|
||||
print(f"Cleared {entity.name}: removed {res['holdings']} holding(s), "
|
||||
f"{res['positions']} position(s), {res['rounds']} round(s). "
|
||||
f"Re-import the fund's NAV to repopulate it.")
|
||||
|
||||
|
||||
def reset_partners(args: argparse.Namespace) -> None:
|
||||
"""Remove all partners (capital-account statements + access grants) from one fund."""
|
||||
run_migrations()
|
||||
from ten31portal.models import Entity
|
||||
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||
|
||||
with Session(engine) as session:
|
||||
entity = None
|
||||
if args.entity_id:
|
||||
entity = session.get(Entity, args.entity_id)
|
||||
elif args.name:
|
||||
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
|
||||
if entity is None:
|
||||
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
|
||||
f"Check the exact name with list-funds.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
res = reset_entity_partners(entity.id, session)
|
||||
session.commit()
|
||||
print(f"Cleared partners from {entity.name}: removed {res['statements']} capital "
|
||||
f"statement(s) and {res['access_grants']} access grant(s). "
|
||||
f"Investor accounts were kept. Re-import the correct roster to repopulate.")
|
||||
|
||||
|
||||
def list_funds(args: argparse.Namespace) -> None:
|
||||
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
|
||||
run_migrations()
|
||||
from ten31portal.models import Entity
|
||||
|
||||
with Session(engine) as session:
|
||||
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||
if not entities:
|
||||
print("No funds yet.")
|
||||
return
|
||||
for e in entities:
|
||||
print(f"[{e.id}] {e.name} ({e.type.value})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -44,13 +293,72 @@ def main() -> None:
|
||||
|
||||
create = sub.add_parser("create-user", help="Provision a new user")
|
||||
create.add_argument("--name", required=True)
|
||||
create.add_argument("--email", required=True)
|
||||
create.add_argument("--username", required=True)
|
||||
create.add_argument("--email", required=False, default=None)
|
||||
create.add_argument("--role", required=True, choices=[r.value for r in UserRole])
|
||||
create.add_argument("--password", required=True)
|
||||
create.add_argument("--service-admin", action="store_true",
|
||||
help="Mark as the protected built-in Service Admin")
|
||||
|
||||
reset = sub.add_parser("reset-password", help="Reset a user's password")
|
||||
reset.add_argument("--username", required=False, default=None)
|
||||
reset.add_argument("--email", required=False, default=None)
|
||||
reset.add_argument("--password", required=True)
|
||||
|
||||
r2fa = sub.add_parser("reset-2fa", help="Clear a user's two-factor enrollment (lost phone)")
|
||||
r2fa.add_argument("--username", required=False, default=None)
|
||||
r2fa.add_argument("--email", required=False, default=None)
|
||||
|
||||
sub.add_parser("list-users", help="List all user accounts")
|
||||
|
||||
sub.add_parser(
|
||||
"enable-investor-logins",
|
||||
help="Set every no-login investor account to the default password and enable sign-in",
|
||||
)
|
||||
|
||||
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
|
||||
|
||||
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
|
||||
delete.add_argument("--username", required=False, default=None)
|
||||
delete.add_argument("--email", required=False, default=None)
|
||||
|
||||
dedupe = sub.add_parser("dedupe-holdings", help="Remove duplicate holdings/positions")
|
||||
dedupe.add_argument("--entity-id", type=int, required=False, default=None,
|
||||
help="Limit to one entity; omit to clean all")
|
||||
|
||||
sub.add_parser("list-funds", help="List entities (funds/SPVs) with their ids")
|
||||
|
||||
reset = sub.add_parser("reset-holdings", help="Clear a fund's holdings to re-import fresh")
|
||||
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||
reset.add_argument("--entity-id", type=int, required=False, default=None)
|
||||
|
||||
rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund")
|
||||
rparts.add_argument("--name", required=False, default=None, help="Exact fund name")
|
||||
rparts.add_argument("--entity-id", type=int, required=False, default=None)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "create-user":
|
||||
create_user(args)
|
||||
elif args.command == "reset-password":
|
||||
reset_password(args)
|
||||
elif args.command == "reset-2fa":
|
||||
reset_2fa(args)
|
||||
elif args.command == "list-users":
|
||||
list_users(args)
|
||||
elif args.command == "enable-investor-logins":
|
||||
enable_investor_logins(args)
|
||||
elif args.command == "show-admin-password":
|
||||
show_admin_password(args)
|
||||
elif args.command == "delete-user":
|
||||
delete_user(args)
|
||||
elif args.command == "dedupe-holdings":
|
||||
dedupe_holdings(args)
|
||||
elif args.command == "list-funds":
|
||||
list_funds(args)
|
||||
elif args.command == "reset-holdings":
|
||||
reset_holdings(args)
|
||||
elif args.command == "reset-partners":
|
||||
reset_partners(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
@@ -5,3 +5,18 @@ import os
|
||||
|
||||
DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db")
|
||||
SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production")
|
||||
DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents")
|
||||
# Cap on a single uploaded document. The docs dir shares the data volume with the DB, so an
|
||||
# unbounded upload could fill the disk and take the portal down. Default 50 MB. The same cap
|
||||
# also bounds spreadsheet imports (which must be read fully into memory to parse).
|
||||
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
|
||||
|
||||
# Investor accounts created by the eNAV import (and existing no-login accounts converted via
|
||||
# the enable-investor-logins CLI/action) start with this password so the admin can hand out
|
||||
# credentials easily; each investor changes it via the portal's own Change password.
|
||||
DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal")
|
||||
|
||||
# Where start.sh records the randomly-generated initial admin password on first boot, so the
|
||||
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
|
||||
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
|
||||
ADMIN_PASSWORD_FILE: str = os.path.join(os.path.dirname(DB_PATH) or ".", ".admin-password")
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
@@ -18,16 +19,26 @@ from ten31portal.routers.holding_router import router as holding_router
|
||||
from ten31portal.routers.position_router import router as position_router
|
||||
from ten31portal.routers.round_router import router as round_router
|
||||
from ten31portal.routers.import_router import router as import_router
|
||||
from ten31portal.routers.user_router import router as user_router
|
||||
from ten31portal.routers.document_router import router as document_router
|
||||
from ten31portal.routers.capital_account_router import router as capital_account_router
|
||||
from ten31portal.routers.capital_import_router import router as capital_import_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
run_migrations()
|
||||
from ten31portal.storage import ensure_docs_dir
|
||||
ensure_docs_dir()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
|
||||
# Compress HTML/JS/CSS/JSON over the wire (the 320KB JS bundle gzips to ~80KB). Added after
|
||||
# SessionMiddleware so it sits outermost and compresses the final response. minimum_size skips
|
||||
# tiny payloads (health checks, small JSON) where compression isn't worth it.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(entity_router)
|
||||
@@ -35,6 +46,10 @@ app.include_router(holding_router)
|
||||
app.include_router(position_router)
|
||||
app.include_router(round_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(user_router)
|
||||
app.include_router(document_router)
|
||||
app.include_router(capital_account_router)
|
||||
app.include_router(capital_import_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -42,15 +57,47 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _contained_static_path(static_root: Path, path: str) -> Path | None:
|
||||
"""Resolve `path` under `static_root`, returning the file only if it stays
|
||||
within the root. Percent-encoded traversal (..%2f) survives routing and
|
||||
would otherwise let an unauthenticated caller read files outside static/
|
||||
(e.g. the DB or session secret on the data volume). Returns None if the
|
||||
resolved path escapes the root."""
|
||||
root = static_root.resolve()
|
||||
candidate = (root / path).resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
# Serve built frontend in production (when static/ dir exists next to the app)
|
||||
_static_dir = Path(__file__).resolve().parent.parent / "static"
|
||||
if _static_dir.is_dir():
|
||||
@app.get("/{path:path}")
|
||||
# index.html must always be revalidated so a new build is picked up right after an
|
||||
# upgrade (its hashed asset references change). Hashed assets themselves are immutable.
|
||||
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
|
||||
# Vite content-hashes asset filenames (index-UPbvqVP1.js), so they can cache forever.
|
||||
_IMMUTABLE = {"Cache-Control": "public, max-age=31536000, immutable"}
|
||||
_ONE_DAY = {"Cache-Control": "public, max-age=86400"}
|
||||
|
||||
def _index() -> FileResponse:
|
||||
return FileResponse(_static_dir / "index.html", headers=_NO_CACHE)
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||
async def serve_spa(path: str):
|
||||
file = _static_dir / path
|
||||
file = _contained_static_path(_static_dir, path)
|
||||
if file is None:
|
||||
return _index()
|
||||
if file.is_file():
|
||||
# Don't let the HTML entrypoint get cached; fingerprinted assets can cache.
|
||||
if file.name == "index.html":
|
||||
return _index()
|
||||
if path.startswith("assets/"):
|
||||
return FileResponse(file, headers=_IMMUTABLE)
|
||||
if path in ("ten31-logo.png", "favicon.svg", "favicon.ico"):
|
||||
return FileResponse(file, headers=_ONE_DAY)
|
||||
return FileResponse(file)
|
||||
return FileResponse(_static_dir / "index.html")
|
||||
return _index()
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
|
||||
+152
-10
@@ -2,19 +2,38 @@
|
||||
|
||||
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 ---
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
approver = "approver"
|
||||
# Internal staff
|
||||
approver = "approver" # "Managing Partner" — full access incl. valuation sign-off
|
||||
operations = "operations" # full access except final sign-off
|
||||
cfo = "cfo"
|
||||
fund_admin = "fund_admin"
|
||||
viewer = "viewer"
|
||||
# External accounts (entity-scoped via EntityAccess)
|
||||
investor = "investor"
|
||||
fund_administrator = "fund_administrator" # "Administrator" — manages its funds
|
||||
administrator_viewer = "administrator_viewer" # "Administrator (view only)" — reads its funds
|
||||
|
||||
|
||||
# External roles see only the entities granted to them.
|
||||
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer)
|
||||
|
||||
|
||||
class DocumentCategory(str, enum.Enum):
|
||||
capital_account = "capital_account"
|
||||
k1 = "k1"
|
||||
statement = "statement"
|
||||
tax = "tax"
|
||||
other = "other"
|
||||
|
||||
|
||||
class EntityType(str, enum.Enum):
|
||||
@@ -22,6 +41,7 @@ class EntityType(str, enum.Enum):
|
||||
spv = "spv"
|
||||
gp = "gp"
|
||||
mgmt_co = "mgmt_co"
|
||||
carry = "carry"
|
||||
|
||||
|
||||
class EntityStatus(str, enum.Enum):
|
||||
@@ -43,10 +63,35 @@ class User(SQLModel, table=True):
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
email: str = Field(sa_column=Column(String, unique=True, nullable=False))
|
||||
username: str = Field(sa_column=Column(String, unique=True, nullable=False))
|
||||
email: str | None = Field(default=None, sa_column=Column(String, unique=True, nullable=True))
|
||||
password_hash: str
|
||||
role: UserRole
|
||||
is_active: bool = Field(default=True)
|
||||
# The built-in Service Admin (bootstrap account). Can be reset but never deleted.
|
||||
is_service_admin: bool = Field(default=False)
|
||||
# False for members imported without a password; set True when an admin sets one.
|
||||
login_enabled: bool = Field(default=True)
|
||||
# When an investor invests under several legal names (one per Partner/vehicle), each name
|
||||
# is its own account. Linking the secondary accounts to one "primary" lets that person sign
|
||||
# in once and see every name's investments. Null = this account logs in on its own.
|
||||
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
|
||||
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
|
||||
# When this investor last loaded their documents list — docs newer than this get a "New"
|
||||
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
|
||||
docs_seen_at: datetime | None = Field(default=None)
|
||||
# Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only
|
||||
# counts once totp_enabled is True (enrollment is confirmed with a first valid code).
|
||||
totp_secret: str | None = Field(default=None)
|
||||
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)
|
||||
|
||||
|
||||
@@ -59,14 +104,33 @@ class Entity(SQLModel, table=True):
|
||||
vintage_year: int | None = None
|
||||
fund_size_cents: int | None = None
|
||||
status: EntityStatus = Field(default=EntityStatus.active)
|
||||
# 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"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
entity_id: int = Field(foreign_key="entities.id")
|
||||
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||
company_name: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -75,7 +139,7 @@ class Position(SQLModel, table=True):
|
||||
__tablename__ = "positions"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
holding_id: int = Field(foreign_key="holdings.id")
|
||||
holding_id: int = Field(foreign_key="holdings.id", index=True)
|
||||
security_name: str
|
||||
investment_date: date
|
||||
shares: str | None = None # Decimal stored as string
|
||||
@@ -93,9 +157,9 @@ class ValuationRound(SQLModel, table=True):
|
||||
entity_id: int = Field(foreign_key="entities.id")
|
||||
quarter_end: date
|
||||
status: RoundStatus = Field(default=RoundStatus.draft)
|
||||
submitted_by: int | None = Field(default=None, foreign_key="users.id")
|
||||
submitted_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
submitted_at: datetime | None = None
|
||||
approved_by: int | None = Field(default=None, foreign_key="users.id")
|
||||
approved_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
approved_at: datetime | None = None
|
||||
return_note: str | None = None
|
||||
is_seed: bool = Field(default=False)
|
||||
@@ -110,7 +174,7 @@ class Valuation(SQLModel, table=True):
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
round_id: int = Field(foreign_key="valuation_rounds.id")
|
||||
position_id: int = Field(foreign_key="positions.id")
|
||||
position_id: int = Field(foreign_key="positions.id", index=True)
|
||||
value_cents: int
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -119,9 +183,87 @@ class AuditLog(SQLModel, table=True):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
actor_user_id: int | None = Field(default=None, foreign_key="users.id")
|
||||
actor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
action: str
|
||||
object_type: str
|
||||
object_id: int | None = None
|
||||
detail: str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
# An action-specific JSON payload describing the change (stored in a JSON column). Most
|
||||
# callers pass a dict of changed fields (e.g. an entity update); some pass an identifying
|
||||
# field on delete, and some pass None. Matches AuditLogResponse.detail in schemas.py.
|
||||
detail: dict | list | str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class EntityAccess(SQLModel, table=True):
|
||||
"""Which entities an external account may view."""
|
||||
__tablename__ = "entity_access"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "entity_id", name="uq_access_user_entity"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="users.id")
|
||||
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||
# Set when this member sold/transferred their stake (e.g. a secondary sale): the fund's
|
||||
# books show a $0 balance with no distribution, which is NOT a loss. An exited position
|
||||
# shows a badge instead of gain/loss, keeps its documents, and drops out of totals.
|
||||
exited_on: date | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class Document(SQLModel, table=True):
|
||||
"""An uploaded file. Shared to a fund (investor_user_id null) or private to one investor."""
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
entity_id: int = Field(foreign_key="entities.id", index=True)
|
||||
investor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
category: DocumentCategory = Field(default=DocumentCategory.other)
|
||||
title: str
|
||||
original_filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
storage_path: str # opaque filename on the data volume, relative to DOCS_DIR
|
||||
uploaded_by: int | None = Field(default=None, foreign_key="users.id", index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class CapitalAccountStatement(SQLModel, table=True):
|
||||
"""An investor's capital-account figures for one fund as of a date."""
|
||||
__tablename__ = "capital_account_statements"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entity_id", "investor_user_id", "as_of_date",
|
||||
name="uq_capacct_entity_investor_date"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
entity_id: int = Field(foreign_key="entities.id")
|
||||
investor_user_id: int = Field(foreign_key="users.id", index=True)
|
||||
as_of_date: date
|
||||
commitment_cents: int = 0 # initial capital commitment
|
||||
beginning_balance_cents: int = 0
|
||||
contributions_cents: int = 0 # paid-in capital
|
||||
distributions_cents: int = 0 # capital returned (for DPI)
|
||||
ending_balance_cents: int = 0 # current capital value
|
||||
document_id: int | None = Field(default=None, foreign_key="documents.id", index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class EntityStake(SQLModel, table=True):
|
||||
"""A holder entity's ownership stake in a fund/SPV it manages.
|
||||
|
||||
Models the assets of a GP or management company: its interest in each fund it manages,
|
||||
which are entities in their own right rather than portfolio-company holdings.
|
||||
"""
|
||||
__tablename__ = "entity_stakes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"),
|
||||
)
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co
|
||||
fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held
|
||||
ownership_pct: float | None = None # e.g. 20.0 for a 20% interest
|
||||
value_cents: int | None = None # optional current value of the stake
|
||||
note: str | None = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""A tiny in-process sliding-window rate limiter for the login endpoint.
|
||||
|
||||
The portal runs as a single uvicorn process, so an in-memory counter is enough to blunt
|
||||
online password guessing without adding a dependency or a shared store. It is keyed by client
|
||||
IP; only failed attempts are counted, and a successful login clears the key. This deliberately
|
||||
does NOT lock accounts (which would let anyone lock out a user by name) — it throttles the
|
||||
source of the guessing instead.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
def __init__(self, max_attempts: int, window_seconds: float):
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self._hits: dict[str, deque] = defaultdict(deque)
|
||||
self._lock = Lock()
|
||||
|
||||
def _prune(self, key: str, now: float) -> deque:
|
||||
dq = self._hits[key]
|
||||
cutoff = now - self.window
|
||||
while dq and dq[0] <= cutoff:
|
||||
dq.popleft()
|
||||
if not dq:
|
||||
self._hits.pop(key, None)
|
||||
return dq
|
||||
|
||||
def retry_after(self, key: str) -> float:
|
||||
"""Seconds until `key` may try again, or 0.0 if it is under the limit right now."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
dq = self._prune(key, now)
|
||||
if len(dq) < self.max_attempts:
|
||||
return 0.0
|
||||
return self.window - (now - dq[0])
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._hits[key].append(now)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._hits.pop(key, None)
|
||||
@@ -1,27 +1,142 @@
|
||||
"""Authentication endpoints."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal import config, totp
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, hash_password, verify_password
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import User
|
||||
from ten31portal.schemas import LoginRequest, UserResponse
|
||||
from ten31portal.ratelimit import SlidingWindowLimiter
|
||||
from ten31portal.schemas import (
|
||||
ChangePasswordRequest,
|
||||
LoginPending2FA,
|
||||
LoginRequest,
|
||||
TotpConfirmRequest,
|
||||
TotpConfirmResponse,
|
||||
TotpDisableRequest,
|
||||
TotpSetupResponse,
|
||||
TotpVerifyRequest,
|
||||
UserResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes.
|
||||
# Second-factor code guesses count against the same window.
|
||||
_login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300)
|
||||
|
||||
# How long a password-accepted session may wait for its second factor.
|
||||
_PENDING_2FA_MAX_AGE = 300 # seconds
|
||||
|
||||
# A throwaway hash verified when no user matches, so a missing account costs the same argon2
|
||||
# time as a real one — otherwise the timing difference reveals which usernames exist.
|
||||
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
user = session.exec(select(User).where(User.email == body.email)).first()
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
) -> UserResponse | LoginPending2FA:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
retry_after = _login_limiter.retry_after(client_ip)
|
||||
if retry_after > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many login attempts. Please wait a moment and try again.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
# Accept either a username or an email in the login field.
|
||||
handle = body.login.strip()
|
||||
user = session.exec(select(User).where(User.username == handle)).first()
|
||||
if user is None:
|
||||
user = session.exec(select(User).where(User.email == handle)).first()
|
||||
# Always run a verify (dummy hash when the user is unknown) so success/failure take the
|
||||
# same time, and give one generic message so neither branch leaks whether the user exists.
|
||||
if user is None:
|
||||
verify_password(body.password, _DUMMY_HASH)
|
||||
_login_limiter.record_failure(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
_login_limiter.record_failure(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
# Past this point the password was correct, so these messages don't aid guessing.
|
||||
if user.primary_account_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="This account signs in under another login. Use that account's credentials.",
|
||||
)
|
||||
if not user.login_enabled:
|
||||
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.
|
||||
request.session.clear()
|
||||
request.session["pending_2fa_user_id"] = user.id
|
||||
request.session["pending_2fa_at"] = time.time()
|
||||
return LoginPending2FA()
|
||||
_login_limiter.reset(client_ip)
|
||||
request.session["user_id"] = user.id
|
||||
return UserResponse.model_validate(user, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/login/verify-totp")
|
||||
def verify_totp_login(
|
||||
body: TotpVerifyRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
"""Second login step: accept an authenticator code or an unused recovery code."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
retry_after = _login_limiter.retry_after(client_ip)
|
||||
if retry_after > 0:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many attempts. Please wait a moment and try again.",
|
||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||
)
|
||||
|
||||
user_id = request.session.get("pending_2fa_user_id")
|
||||
started = request.session.get("pending_2fa_at", 0)
|
||||
if user_id is None or time.time() - started > _PENDING_2FA_MAX_AGE:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
|
||||
user = session.get(User, user_id)
|
||||
if user is None or not user.is_active or not user.totp_enabled or not user.totp_secret:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
|
||||
|
||||
if not totp.verify_code(user.totp_secret, body.code):
|
||||
# Not a current authenticator code — maybe a one-time recovery code.
|
||||
remaining = totp.consume_recovery_code(user.totp_recovery_codes, body.code)
|
||||
if remaining is None:
|
||||
_login_limiter.record_failure(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid code. Try again.")
|
||||
user.totp_recovery_codes = remaining
|
||||
session.add(user)
|
||||
record_audit(session, user.id, "totp_recovery_used", "user", user.id)
|
||||
session.commit()
|
||||
|
||||
_login_limiter.reset(client_ip)
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user.id
|
||||
return UserResponse.model_validate(user, from_attributes=True)
|
||||
|
||||
@@ -35,3 +150,102 @@ def logout(request: Request) -> dict[str, str]:
|
||||
@router.get("/me")
|
||||
def me(user: User = Depends(get_current_user)) -> UserResponse:
|
||||
return UserResponse.model_validate(user, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
"""Let the signed-in user set their own password (after confirming the current one)."""
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
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.
|
||||
if user.is_service_admin:
|
||||
try:
|
||||
os.remove(config.ADMIN_PASSWORD_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
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")
|
||||
def totp_setup(
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> TotpSetupResponse:
|
||||
"""Start enrollment: mint a secret and return the QR. Not active until confirmed."""
|
||||
if user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Two-factor is already enabled. Disable it first to re-enroll."
|
||||
)
|
||||
secret = totp.new_secret()
|
||||
user.totp_secret = secret
|
||||
session.add(user)
|
||||
session.commit()
|
||||
uri = totp.otpauth_uri(secret, user.username)
|
||||
return TotpSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=totp.qr_svg(uri))
|
||||
|
||||
|
||||
@router.post("/totp/confirm")
|
||||
def totp_confirm(
|
||||
body: TotpConfirmRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> TotpConfirmResponse:
|
||||
"""Finish enrollment: prove the authenticator works, then hand out recovery codes."""
|
||||
if user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="Two-factor is already enabled.")
|
||||
if not user.totp_secret:
|
||||
raise HTTPException(status_code=400, detail="Start two-factor setup first.")
|
||||
if not totp.verify_code(user.totp_secret, body.code):
|
||||
raise HTTPException(status_code=400, detail="That code didn't match. Try again.")
|
||||
codes, digests_json = totp.generate_recovery_codes()
|
||||
user.totp_enabled = True
|
||||
user.totp_recovery_codes = digests_json
|
||||
session.add(user)
|
||||
record_audit(session, user.id, "totp_enabled", "user", user.id)
|
||||
session.commit()
|
||||
return TotpConfirmResponse(recovery_codes=codes)
|
||||
|
||||
|
||||
@router.post("/totp/disable")
|
||||
def totp_disable(
|
||||
body: TotpDisableRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
"""Turn off two-factor (requires the account password, not just a live session)."""
|
||||
if not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Password is incorrect.")
|
||||
user.totp_secret = None
|
||||
user.totp_enabled = False
|
||||
user.totp_recovery_codes = None
|
||||
session.add(user)
|
||||
record_audit(session, user.id, "totp_disabled", "user", user.id)
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""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
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, check_administrator_scope, get_current_user,
|
||||
household_user_ids, require_admin,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
BtcPrice, CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||
)
|
||||
from ten31portal.schemas import CapitalAccountCreate, CapitalAccountResponse
|
||||
|
||||
router = APIRouter(prefix="/api/capital-accounts", tags=["capital-accounts"])
|
||||
|
||||
|
||||
def _dollars_to_cents(dollars: float) -> int:
|
||||
return round(dollars * 100)
|
||||
|
||||
|
||||
def exit_dates(session: Session, rows) -> dict:
|
||||
"""(investor_user_id, entity_id) -> exited_on for every exited pair in these statements,
|
||||
so a sold/transferred stake renders as "Exited" instead of a phantom -100% loss."""
|
||||
if not rows:
|
||||
return {}
|
||||
return {
|
||||
(a.user_id, a.entity_id): a.exited_on
|
||||
for a in session.exec(
|
||||
select(EntityAccess).where(
|
||||
col(EntityAccess.user_id).in_({r.investor_user_id for r in rows}),
|
||||
col(EntityAccess.exited_on).is_not(None),
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
investor_user_id: int | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[CapitalAccountResponse]:
|
||||
query = select(CapitalAccountStatement)
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
|
||||
if user.role in (UserRole.fund_administrator, UserRole.administrator_viewer):
|
||||
# An Administrator (managing or view-only) sees every investor's statements,
|
||||
# but only inside their funds.
|
||||
if not allowed:
|
||||
return []
|
||||
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||
if investor_user_id is not None:
|
||||
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||
elif allowed is not None:
|
||||
# Investors see statements for every legal name linked to their login.
|
||||
query = query.where(
|
||||
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
|
||||
)
|
||||
if allowed:
|
||||
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||
else:
|
||||
return []
|
||||
else:
|
||||
if investor_user_id is not None:
|
||||
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
|
||||
|
||||
if entity_id is not None:
|
||||
query = query.where(CapitalAccountStatement.entity_id == entity_id)
|
||||
|
||||
rows = session.exec(
|
||||
query.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||
).all()
|
||||
# Attach each statement's legal name so the portal can label/group accounts held under
|
||||
# different names without an admin-only user lookup.
|
||||
names = dict(session.exec(
|
||||
select(User.id, User.name).where(
|
||||
col(User.id).in_({r.investor_user_id for r in rows})
|
||||
)
|
||||
).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
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_statement(
|
||||
body: CapitalAccountCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CapitalAccountResponse:
|
||||
check_administrator_scope(admin, body.entity_id, session)
|
||||
if session.get(Entity, body.entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
investor = session.get(User, body.investor_user_id)
|
||||
if investor is None or investor.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="investor_user_id must be an investor account")
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == body.entity_id,
|
||||
CapitalAccountStatement.investor_user_id == body.investor_user_id,
|
||||
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A statement for this investor, fund, and date already exists.",
|
||||
)
|
||||
|
||||
stmt = CapitalAccountStatement(
|
||||
entity_id=body.entity_id,
|
||||
investor_user_id=body.investor_user_id,
|
||||
as_of_date=body.as_of_date,
|
||||
commitment_cents=_dollars_to_cents(body.commitment_dollars),
|
||||
beginning_balance_cents=_dollars_to_cents(body.beginning_balance_dollars),
|
||||
contributions_cents=_dollars_to_cents(body.contributions_dollars),
|
||||
distributions_cents=_dollars_to_cents(body.distributions_dollars),
|
||||
ending_balance_cents=_dollars_to_cents(body.ending_balance_dollars),
|
||||
document_id=body.document_id,
|
||||
)
|
||||
session.add(stmt)
|
||||
session.flush()
|
||||
record_audit(session, admin.id, "create", "capital_account", stmt.id, {
|
||||
"entity_id": body.entity_id,
|
||||
"investor_user_id": body.investor_user_id,
|
||||
"as_of_date": str(body.as_of_date),
|
||||
"ending_balance_cents": stmt.ending_balance_cents,
|
||||
})
|
||||
session.commit()
|
||||
session.refresh(stmt)
|
||||
return CapitalAccountResponse.model_validate(stmt, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/{statement_id}")
|
||||
def delete_statement(
|
||||
statement_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
stmt = session.get(CapitalAccountStatement, statement_id)
|
||||
if stmt is None:
|
||||
raise HTTPException(status_code=404, detail="Statement not found")
|
||||
check_administrator_scope(admin, stmt.entity_id, session)
|
||||
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
|
||||
session.delete(stmt)
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Import fund members and their capital figures from a fund-administrator workbook.
|
||||
|
||||
Primary path: an eNAV workbook's "ALLOC SI" tab (one row per investor with INVESTOR ID,
|
||||
INVESTOR NAME, COMMITTED CAPITAL, CONTRIBUTIONS, (DISTRIBUTIONS), ENDING BALANCE).
|
||||
Fallback: a subaccounts sheet (investor names across columns, per-vehicle value rows).
|
||||
|
||||
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
|
||||
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
|
||||
(on the shared default password unless one is given), grants entity access, and loads each
|
||||
member's capital-account statement (commitment, contributions, distributions, current value).
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
|
||||
import openpyxl
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal import config, storage
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import check_administrator_scope, hash_password, require_admin
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
|
||||
)
|
||||
from ten31portal.routers.import_router import (
|
||||
_open_workbook, _enav_as_of, _parse_schedule_xlsx, upsert_history_round,
|
||||
)
|
||||
from ten31portal.schemas import (
|
||||
BatchCapitalFileResult, BatchCapitalImportResult,
|
||||
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/import/capital-accounts", tags=["import"])
|
||||
|
||||
MAX_ROWS = 400
|
||||
MAX_COLS = 90
|
||||
|
||||
|
||||
def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]:
|
||||
"""Remove every partner from one fund: delete its capital-account statements and the
|
||||
investors' access grants to it. The investor *accounts* are kept — they usually also
|
||||
belong to other funds — only their membership of THIS entity is cleared. Use to undo a
|
||||
wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV
|
||||
are untouched (see reset_entity_holdings for those). Caller commits.
|
||||
"""
|
||||
statements = 0
|
||||
for s in session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == entity_id
|
||||
)
|
||||
).all():
|
||||
session.delete(s)
|
||||
statements += 1
|
||||
|
||||
# Only drop investor memberships; a fund_administrator's access is not a "partner".
|
||||
investor_ids = {
|
||||
u.id for u in session.exec(
|
||||
select(User).where(User.role == UserRole.investor)
|
||||
).all()
|
||||
}
|
||||
access = 0
|
||||
for a in session.exec(
|
||||
select(EntityAccess).where(EntityAccess.entity_id == entity_id)
|
||||
).all():
|
||||
if a.user_id in investor_ids:
|
||||
session.delete(a)
|
||||
access += 1
|
||||
|
||||
return {"statements": statements, "access_grants": access}
|
||||
|
||||
|
||||
def _slug_username(name: str) -> str:
|
||||
base = re.sub(r"[^a-z0-9]+", "", name.lower())
|
||||
return base or "investor"
|
||||
|
||||
|
||||
def _as_float(v) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).replace(",", "").replace("$", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# --- ALLOC SI (eNAV investor roster) ---
|
||||
|
||||
def _parse_alloc_si(wb):
|
||||
"""Return (as_of, investors) from an eNAV ALLOC SI sheet.
|
||||
|
||||
investors: [{name, external_id, commitment, contributions, distributions, ending}].
|
||||
"""
|
||||
ws = wb["ALLOC SI"]
|
||||
rows = list(ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||
|
||||
header_idx = None
|
||||
header: list[str] = []
|
||||
for i, row in enumerate(rows):
|
||||
cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row]
|
||||
if "INVESTOR ID" in cells and "INVESTOR NAME" in cells:
|
||||
header_idx, header = i, cells
|
||||
break
|
||||
if header_idx is None:
|
||||
raise HTTPException(status_code=422, detail="Could not find the ALLOC SI header row.")
|
||||
|
||||
def col(*names: str) -> int | None:
|
||||
for n in names:
|
||||
if n in header:
|
||||
return header.index(n)
|
||||
return None
|
||||
|
||||
c_id = col("INVESTOR ID")
|
||||
c_type = col("INVESTOR TYPE")
|
||||
c_name = col("INVESTOR NAME")
|
||||
c_commit = col("COMMITTED CAPITAL")
|
||||
c_contrib = col("CONTRIBUTIONS")
|
||||
c_distrib = col("(DISTRIBUTIONS)", "DISTRIBUTIONS")
|
||||
c_ending = col("ENDING BALANCE")
|
||||
if c_ending is None:
|
||||
raise HTTPException(status_code=422, detail="ALLOC SI is missing an ENDING BALANCE column.")
|
||||
|
||||
def amount(row, ci):
|
||||
if ci is None or ci >= len(row):
|
||||
return 0.0
|
||||
return _as_float(row[ci]) or 0.0
|
||||
|
||||
investors: list[dict] = []
|
||||
for r in range(header_idx + 1, len(rows)):
|
||||
row = rows[r]
|
||||
name_raw = row[c_name] if c_name is not None and c_name < len(row) else None
|
||||
if not (isinstance(name_raw, str) and name_raw.strip()):
|
||||
continue
|
||||
nm = name_raw.strip()
|
||||
if nm.upper() in ("GP", "LP", "TOTAL"):
|
||||
continue
|
||||
itype = row[c_type] if c_type is not None and c_type < len(row) else None
|
||||
if itype is not None and str(itype).strip().upper() not in ("LP", ""):
|
||||
continue # investors only (skip the GP entity)
|
||||
|
||||
display = None
|
||||
if c_name is not None and c_name + 1 < len(row) and isinstance(row[c_name + 1], str) and row[c_name + 1].strip():
|
||||
display = row[c_name + 1].strip()
|
||||
name = display or nm.title()
|
||||
|
||||
inv_id = row[c_id] if c_id is not None and c_id < len(row) else None
|
||||
external_id = str(inv_id).strip() if inv_id not in (None, "") else None
|
||||
|
||||
investors.append({
|
||||
"name": name,
|
||||
"external_id": external_id,
|
||||
"commitment": amount(row, c_commit),
|
||||
"contributions": amount(row, c_contrib),
|
||||
"distributions": abs(amount(row, c_distrib)), # sheet may show as a credit
|
||||
"ending": amount(row, c_ending),
|
||||
})
|
||||
|
||||
return _enav_as_of(wb), investors
|
||||
|
||||
|
||||
# --- Subaccounts (names-across-columns) ---
|
||||
|
||||
def _parse_grid(wb):
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
grid: list[list] = []
|
||||
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True):
|
||||
grid.append(list(row))
|
||||
|
||||
header_idx = None
|
||||
total_col = None
|
||||
for i, row in enumerate(grid):
|
||||
for j, cell in enumerate(row):
|
||||
if isinstance(cell, str) and cell.strip().lower() == "total":
|
||||
header_idx, total_col = i, j
|
||||
break
|
||||
if header_idx is not None:
|
||||
break
|
||||
if header_idx is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail='Could not find a header row with a "Total" column. Check the spreadsheet layout.',
|
||||
)
|
||||
|
||||
header = grid[header_idx]
|
||||
investor_cols = [
|
||||
j for j in range(len(header))
|
||||
if j < total_col
|
||||
and isinstance(header[j], str)
|
||||
and header[j].strip().lower() not in ("", "total")
|
||||
]
|
||||
if not investor_cols:
|
||||
raise HTTPException(status_code=422, detail="No investor name columns found left of the Total column.")
|
||||
|
||||
as_of: date | None = None
|
||||
for row in grid[: header_idx + 2]:
|
||||
for cell in row:
|
||||
if isinstance(cell, (datetime, date)):
|
||||
as_of = cell.date() if isinstance(cell, datetime) else cell
|
||||
break
|
||||
if as_of:
|
||||
break
|
||||
|
||||
value_rows: list[ImportValueRow] = []
|
||||
for i in range(header_idx + 1, len(grid)):
|
||||
row = grid[i]
|
||||
label = row[0]
|
||||
if not (isinstance(label, str) and label.strip()):
|
||||
continue
|
||||
if any(_as_float(row[j]) is not None for j in investor_cols if j < len(row)):
|
||||
value_rows.append(ImportValueRow(row_index=i, label=label.strip()))
|
||||
if not value_rows:
|
||||
raise HTTPException(status_code=422, detail="No value rows found under the header.")
|
||||
|
||||
return header_idx, total_col, investor_cols, as_of, value_rows, grid
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
def preview_import(
|
||||
file: UploadFile = File(...),
|
||||
entity_id: int | None = Form(None),
|
||||
row_index: int | None = Form(None),
|
||||
password: str | None = Form(None),
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> CapitalImportPreview:
|
||||
if entity_id is not None:
|
||||
check_administrator_scope(admin, entity_id, session)
|
||||
if entity_id is not None and session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
try:
|
||||
file_bytes = storage.read_capped(file)
|
||||
except storage.UploadTooLarge as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc))
|
||||
wb = _open_workbook(file_bytes, password)
|
||||
|
||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||
by_name = {u.name.strip().lower(): u for u in investors}
|
||||
by_username = {u.username.strip().lower(): u for u in investors}
|
||||
by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id}
|
||||
|
||||
def match_for(name: str, external_id: str | None):
|
||||
if external_id and external_id in by_extid:
|
||||
return by_extid[external_id]
|
||||
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||
|
||||
if "ALLOC SI" in wb.sheetnames:
|
||||
as_of, roster = _parse_alloc_si(wb)
|
||||
previews: list[ImportInvestorPreview] = []
|
||||
for inv in roster:
|
||||
m = match_for(inv["name"], inv["external_id"])
|
||||
previews.append(ImportInvestorPreview(
|
||||
source_name=inv["name"],
|
||||
column_index=0,
|
||||
value_dollars=inv["ending"],
|
||||
commitment_dollars=inv["commitment"],
|
||||
contributions_dollars=inv["contributions"],
|
||||
distributions_dollars=inv["distributions"],
|
||||
external_id=inv["external_id"],
|
||||
matched_user_id=m.id if m else None,
|
||||
matched_username=m.username if m else None,
|
||||
suggested_username=None if m else _slug_username(inv["name"]),
|
||||
))
|
||||
# Roster mode: no per-row value picker needed.
|
||||
return CapitalImportPreview(as_of_date=as_of, value_rows=[], chosen_row_index=0, investors=previews)
|
||||
|
||||
# Fallback: subaccounts layout (single value figure per investor).
|
||||
header_idx, total_col, investor_cols, as_of, value_rows, grid = _parse_grid(wb)
|
||||
valid_indexes = {vr.row_index for vr in value_rows}
|
||||
chosen = row_index if row_index in valid_indexes else value_rows[0].row_index
|
||||
chosen_row = grid[chosen]
|
||||
|
||||
previews = []
|
||||
for col_i in investor_cols:
|
||||
source_name = str(grid[header_idx][col_i]).strip()
|
||||
value = _as_float(chosen_row[col_i]) if col_i < len(chosen_row) else None
|
||||
m = match_for(source_name, None)
|
||||
previews.append(ImportInvestorPreview(
|
||||
source_name=source_name,
|
||||
column_index=col_i,
|
||||
value_dollars=value or 0.0,
|
||||
matched_user_id=m.id if m else None,
|
||||
matched_username=m.username if m else None,
|
||||
suggested_username=None if m else _slug_username(source_name),
|
||||
))
|
||||
return CapitalImportPreview(as_of_date=as_of, value_rows=value_rows, chosen_row_index=chosen, investors=previews)
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
def commit_import(
|
||||
body: CapitalImportCommit,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
check_administrator_scope(admin, body.entity_id, session)
|
||||
entity = session.get(Entity, body.entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
created_accounts = 0
|
||||
updated_accounts = 0
|
||||
statements = 0
|
||||
|
||||
for inv in body.investors:
|
||||
if inv.action == "skip":
|
||||
continue
|
||||
|
||||
if inv.action == "create":
|
||||
if not inv.username or not inv.name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"New member '{inv.name or inv.username}' needs a name and username.",
|
||||
)
|
||||
if session.exec(select(User).where(User.username == inv.username)).first():
|
||||
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
|
||||
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
|
||||
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
|
||||
# New members start on the shared default password (login enabled) so the admin
|
||||
# can send credentials right away; each investor rotates it in the portal.
|
||||
pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD
|
||||
user = User(
|
||||
name=inv.name,
|
||||
username=inv.username,
|
||||
email=inv.email or None,
|
||||
password_hash=hash_password(pw),
|
||||
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()
|
||||
created_accounts += 1
|
||||
elif inv.action == "match":
|
||||
user = session.get(User, inv.user_id) if inv.user_id else None
|
||||
if user is None or user.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="match requires a valid investor user_id.")
|
||||
if inv.external_id and not user.external_investor_id:
|
||||
user.external_investor_id = inv.external_id
|
||||
session.add(user)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action '{inv.action}'.")
|
||||
|
||||
has_access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user.id, EntityAccess.entity_id == body.entity_id
|
||||
)
|
||||
).first()
|
||||
if has_access is None:
|
||||
session.add(EntityAccess(user_id=user.id, entity_id=body.entity_id))
|
||||
|
||||
cents = lambda d: round(d * 100)
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == body.entity_id,
|
||||
CapitalAccountStatement.investor_user_id == user.id,
|
||||
CapitalAccountStatement.as_of_date == body.as_of_date,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
existing.commitment_cents = cents(inv.commitment_dollars)
|
||||
existing.contributions_cents = cents(inv.contributions_dollars)
|
||||
existing.distributions_cents = cents(inv.distributions_dollars)
|
||||
existing.ending_balance_cents = cents(inv.value_dollars)
|
||||
session.add(existing)
|
||||
updated_accounts += 1
|
||||
else:
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=body.entity_id,
|
||||
investor_user_id=user.id,
|
||||
as_of_date=body.as_of_date,
|
||||
commitment_cents=cents(inv.commitment_dollars),
|
||||
contributions_cents=cents(inv.contributions_dollars),
|
||||
distributions_cents=cents(inv.distributions_dollars),
|
||||
ending_balance_cents=cents(inv.value_dollars),
|
||||
))
|
||||
statements += 1
|
||||
|
||||
record_audit(session, admin.id, "import", "capital_account", body.entity_id, {
|
||||
"as_of_date": str(body.as_of_date),
|
||||
"created_accounts": created_accounts,
|
||||
"statements": statements,
|
||||
})
|
||||
session.commit()
|
||||
return {
|
||||
"status": "ok",
|
||||
"created_accounts": created_accounts,
|
||||
"matched_accounts_updated": updated_accounts,
|
||||
"statements_written": statements,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_import(
|
||||
files: list[UploadFile] = File(...),
|
||||
entity_id: int = Form(...),
|
||||
password: str | None = Form(None),
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BatchCapitalImportResult:
|
||||
"""Backfill several quarters of capital history for one fund from a batch of eNAV files.
|
||||
|
||||
Each file is a full eNAV workbook; its ALLOC SI roster is auto-matched (by fund-admin
|
||||
investor ID, else name/username) against existing members and each member's capital
|
||||
statement is upserted at the file's own as-of date — so older files add historical points
|
||||
without touching the latest. Members not already in the system are skipped and reported
|
||||
(no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is
|
||||
reported per-file and does not abort the rest.
|
||||
"""
|
||||
check_administrator_scope(admin, entity_id, session)
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||
by_name = {u.name.strip().lower(): u for u in investors}
|
||||
by_username = {u.username.strip().lower(): u for u in investors}
|
||||
by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id}
|
||||
|
||||
def match_for(name: str, external_id: str | None) -> User | None:
|
||||
if external_id and external_id in by_extid:
|
||||
return by_extid[external_id]
|
||||
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||
|
||||
cents = lambda d: round((d or 0.0) * 100)
|
||||
results: list[BatchCapitalFileResult] = []
|
||||
total_statements = 0
|
||||
|
||||
for upload in files:
|
||||
fname = upload.filename or "(unnamed)"
|
||||
res = BatchCapitalFileResult(filename=fname)
|
||||
try:
|
||||
file_bytes = storage.read_capped(upload)
|
||||
wb = _open_workbook(file_bytes, password)
|
||||
if "ALLOC SI" not in wb.sheetnames:
|
||||
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
|
||||
as_of, roster = _parse_alloc_si(wb)
|
||||
if as_of is None:
|
||||
raise HTTPException(status_code=422, detail="Could not determine the as-of date from the file.")
|
||||
res.as_of_date = as_of
|
||||
|
||||
for inv in roster:
|
||||
user = match_for(inv["name"], inv["external_id"])
|
||||
if user is None:
|
||||
res.skipped.append(inv["name"])
|
||||
continue
|
||||
|
||||
# Record the fund-admin ID on first sighting so later files match by ID too.
|
||||
if inv["external_id"] and not user.external_investor_id:
|
||||
user.external_investor_id = inv["external_id"]
|
||||
by_extid[inv["external_id"]] = user
|
||||
session.add(user)
|
||||
|
||||
has_access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user.id, EntityAccess.entity_id == entity_id
|
||||
)
|
||||
).first()
|
||||
if has_access is None:
|
||||
session.add(EntityAccess(user_id=user.id, entity_id=entity_id))
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == entity_id,
|
||||
CapitalAccountStatement.investor_user_id == user.id,
|
||||
CapitalAccountStatement.as_of_date == as_of,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
existing.commitment_cents = cents(inv["commitment"])
|
||||
existing.contributions_cents = cents(inv["contributions"])
|
||||
existing.distributions_cents = cents(inv["distributions"])
|
||||
existing.ending_balance_cents = cents(inv["ending"])
|
||||
session.add(existing)
|
||||
res.updated += 1
|
||||
else:
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity_id,
|
||||
investor_user_id=user.id,
|
||||
as_of_date=as_of,
|
||||
commitment_cents=cents(inv["commitment"]),
|
||||
contributions_cents=cents(inv["contributions"]),
|
||||
distributions_cents=cents(inv["distributions"]),
|
||||
ending_balance_cents=cents(inv["ending"]),
|
||||
))
|
||||
res.matched += 1
|
||||
res.statements_written += 1
|
||||
|
||||
# NAV history leg: record this quarter's valuation round from the file's HLD
|
||||
# sheet, matched against today's book only (holdings are never modified). An
|
||||
# HLD problem must not lose the member statements above, so it runs in a
|
||||
# savepoint and reports per-file.
|
||||
try:
|
||||
with session.begin_nested():
|
||||
_, _, _, positions_prev, _ = _parse_schedule_xlsx(file_bytes, password)
|
||||
if positions_prev:
|
||||
hist = upsert_history_round(entity_id, as_of, positions_prev, admin, session)
|
||||
res.nav_status = hist["status"]
|
||||
res.nav_matched = hist["matched"]
|
||||
res.nav_unmatched = hist["unmatched"]
|
||||
res.nav_cents = hist["nav_cents"]
|
||||
else:
|
||||
res.nav_status = "no-hld"
|
||||
except Exception: # noqa: BLE001 — the savepoint rolled back; members still land
|
||||
res.nav_status = "error"
|
||||
|
||||
record_audit(session, admin.id, "import_batch", "capital_account", entity_id, {
|
||||
"file": fname,
|
||||
"as_of_date": str(as_of),
|
||||
"matched": res.matched,
|
||||
"skipped": len(res.skipped),
|
||||
"nav_status": res.nav_status,
|
||||
"nav_cents": res.nav_cents,
|
||||
})
|
||||
session.commit()
|
||||
total_statements += res.statements_written
|
||||
except HTTPException as e:
|
||||
# Our own controlled messages (bad password, no ALLOC SI, no date) are safe to show.
|
||||
session.rollback()
|
||||
res.error = e.detail
|
||||
except storage.UploadTooLarge:
|
||||
session.rollback()
|
||||
res.error = "File is too large."
|
||||
except Exception: # noqa: BLE001 — keep going on any parse failure; don't leak internals
|
||||
session.rollback()
|
||||
res.error = "Could not process this file (unexpected format or error)."
|
||||
results.append(res)
|
||||
|
||||
return BatchCapitalImportResult(
|
||||
entity_id=entity_id, files=results, total_statements=total_statements
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Document upload, listing, download, and deletion with per-account access control."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, can_access_entity, get_current_user, household_user_ids,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
Document, DocumentCategory, Entity, User, UserRole,
|
||||
)
|
||||
from ten31portal.schemas import DocumentResponse
|
||||
from ten31portal import storage
|
||||
|
||||
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||
|
||||
|
||||
def _can_upload(user: User, entity_id: int, session: Session) -> bool:
|
||||
"""Internal admins upload anywhere; external fund admins upload for their entities."""
|
||||
if user.role in (UserRole.approver, UserRole.cfo, UserRole.operations):
|
||||
return True
|
||||
if user.role == UserRole.fund_administrator:
|
||||
return can_access_entity(user, entity_id, session)
|
||||
return False
|
||||
|
||||
|
||||
def _can_view(user: User, doc: Document, session: Session) -> bool:
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
if allowed is None: # internal staff
|
||||
return True
|
||||
if doc.entity_id not in allowed:
|
||||
return False
|
||||
# Investors only see shared docs or those addressed to any of their linked names;
|
||||
# fund admins see all docs for the entity.
|
||||
if user.role == UserRole.investor:
|
||||
return (
|
||||
doc.investor_user_id is None
|
||||
or doc.investor_user_id in household_user_ids(user, session)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_documents(
|
||||
entity_id: int | None = None,
|
||||
investor_user_id: int | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[DocumentResponse]:
|
||||
query = select(Document)
|
||||
if entity_id is not None:
|
||||
query = query.where(Document.entity_id == entity_id)
|
||||
if investor_user_id is not None:
|
||||
query = query.where(Document.investor_user_id == investor_user_id)
|
||||
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
|
||||
visible = [d for d in rows if _can_view(user, d, session)]
|
||||
|
||||
# Badge docs that arrived since the investor's previous visit. Badges show on the first
|
||||
# page-load after new documents arrive; the watermark advances at most once per 30 minutes
|
||||
# so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing —
|
||||
# everything would be "new".
|
||||
seen_before = user.docs_seen_at if user.role == UserRole.investor else None
|
||||
if user.role == UserRole.investor:
|
||||
now = datetime.utcnow()
|
||||
if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30):
|
||||
user.docs_seen_at = now
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
return [
|
||||
DocumentResponse.model_validate(d, from_attributes=True).model_copy(
|
||||
update={"is_new": seen_before is not None and d.created_at > seen_before}
|
||||
)
|
||||
for d in visible
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def upload_document(
|
||||
entity_id: int = Form(...),
|
||||
category: DocumentCategory = Form(DocumentCategory.other),
|
||||
title: str | None = Form(None),
|
||||
investor_user_id: int | None = Form(None),
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DocumentResponse:
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
if not _can_upload(user, entity_id, session):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
# If targeting a specific investor, they must have access to this entity.
|
||||
if investor_user_id is not None:
|
||||
target = session.get(User, investor_user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="Investor not found")
|
||||
if not can_access_entity(target, entity_id, session):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That investor does not have access to this entity.",
|
||||
)
|
||||
|
||||
try:
|
||||
storage_name, size = storage.save_upload(file)
|
||||
except storage.UploadTooLarge as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc))
|
||||
doc = Document(
|
||||
entity_id=entity_id,
|
||||
investor_user_id=investor_user_id,
|
||||
category=category,
|
||||
title=title or (file.filename or "Untitled"),
|
||||
original_filename=file.filename or storage_name,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=size,
|
||||
storage_path=storage_name,
|
||||
uploaded_by=user.id,
|
||||
)
|
||||
session.add(doc)
|
||||
session.flush()
|
||||
record_audit(session, user.id, "upload", "document", doc.id, {
|
||||
"entity_id": entity_id,
|
||||
"investor_user_id": investor_user_id,
|
||||
"category": category.value,
|
||||
"filename": doc.original_filename,
|
||||
})
|
||||
session.commit()
|
||||
session.refresh(doc)
|
||||
return DocumentResponse.model_validate(doc, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/{document_id}/download")
|
||||
def download_document(
|
||||
document_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FileResponse:
|
||||
doc = session.get(Document, document_id)
|
||||
if doc is None or not _can_view(user, doc, session):
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
try:
|
||||
path = storage.full_path(doc.storage_path)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="File missing on disk")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=doc.content_type,
|
||||
filename=doc.original_filename,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
def delete_document(
|
||||
document_id: int,
|
||||
admin: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
doc = session.get(Document, document_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
# Same reach as upload: internal admins anywhere, an Administrator on their entities.
|
||||
if not _can_upload(admin, doc.entity_id, session):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
storage.delete_file(doc.storage_path)
|
||||
record_audit(session, admin.id, "delete", "document", document_id, {
|
||||
"filename": doc.original_filename,
|
||||
})
|
||||
session.delete(doc)
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -3,16 +3,23 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, literal
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids,
|
||||
require_entity_writer, require_internal, require_internal_or_administrator, require_writer,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
Entity, EntityStatus, Holding, Position,
|
||||
Valuation, ValuationRound, RoundStatus, User,
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityStake, EntityStatus, Holding,
|
||||
Position, UserRole, Valuation, ValuationRound, RoundStatus, User,
|
||||
)
|
||||
from ten31portal.schemas import (
|
||||
AssetBalancesResponse, CapitalAccountResponse, EntityCreate, EntityResponse,
|
||||
EntityStakeCreate, EntityStakeResponse, EntityUpdate, PartnerExitUpdate,
|
||||
PartnerResponse,
|
||||
)
|
||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||
|
||||
@@ -24,6 +31,7 @@ class EntityRollupItem(BaseModel):
|
||||
vintage_year: int | None
|
||||
fund_size_cents: int | None
|
||||
status: str
|
||||
committed_cents: int # total LP commitments (latest per investor)
|
||||
invested_cents: int
|
||||
last_signed_value_cents: int
|
||||
|
||||
@@ -34,7 +42,10 @@ def entity_rollup(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[EntityRollupItem]:
|
||||
"""Per-entity invested and last-signed-value in a single pass."""
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
entities = session.exec(select(Entity)).all()
|
||||
if allowed is not None:
|
||||
entities = [e for e in entities if e.id in allowed]
|
||||
result: list[EntityRollupItem] = []
|
||||
|
||||
for ent in entities:
|
||||
@@ -64,6 +75,28 @@ def entity_rollup(
|
||||
).one()
|
||||
last_signed_value_cents = int(val_sum)
|
||||
|
||||
# Total committed capital = each investor's most recent commitment for this entity.
|
||||
# Exited members (stake sold/transferred) are skipped — their buyer's commitment now
|
||||
# appears on the roster, so counting both would double the fund's committed total.
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(CapitalAccountStatement.entity_id == ent.id)
|
||||
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||
).all()
|
||||
exited_ids = set(session.exec(
|
||||
select(EntityAccess.user_id).where(
|
||||
EntityAccess.entity_id == ent.id,
|
||||
col(EntityAccess.exited_on).is_not(None),
|
||||
)
|
||||
).all())
|
||||
committed_cents = 0
|
||||
seen_investors: set[int] = set(exited_ids)
|
||||
for st in stmts:
|
||||
if st.investor_user_id in seen_investors:
|
||||
continue
|
||||
seen_investors.add(st.investor_user_id)
|
||||
committed_cents += st.commitment_cents
|
||||
|
||||
result.append(EntityRollupItem(
|
||||
id=ent.id,
|
||||
name=ent.name,
|
||||
@@ -71,6 +104,7 @@ def entity_rollup(
|
||||
vintage_year=ent.vintage_year,
|
||||
fund_size_cents=ent.fund_size_cents,
|
||||
status=ent.status.value,
|
||||
committed_cents=committed_cents,
|
||||
invested_cents=invested_cents,
|
||||
last_signed_value_cents=last_signed_value_cents,
|
||||
))
|
||||
@@ -78,12 +112,131 @@ def entity_rollup(
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{entity_id}/partners")
|
||||
def list_partners(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[PartnerResponse]:
|
||||
"""Members (investors) granted access to this entity, with their latest capital value."""
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
members = session.exec(
|
||||
select(User, EntityAccess)
|
||||
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||
.order_by(User.name) # type: ignore[arg-type]
|
||||
).all()
|
||||
|
||||
result: list[PartnerResponse] = []
|
||||
for m, access in members:
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(
|
||||
CapitalAccountStatement.entity_id == entity_id,
|
||||
CapitalAccountStatement.investor_user_id == m.id,
|
||||
)
|
||||
.order_by(CapitalAccountStatement.as_of_date.desc()) # type: ignore[union-attr]
|
||||
).all()
|
||||
latest = stmts[0] if stmts else None
|
||||
result.append(PartnerResponse(
|
||||
user_id=m.id,
|
||||
name=m.name,
|
||||
username=m.username,
|
||||
external_investor_id=m.external_investor_id,
|
||||
is_active=m.is_active,
|
||||
login_enabled=m.login_enabled,
|
||||
latest_commitment_cents=latest.commitment_cents if latest else None,
|
||||
latest_contributions_cents=latest.contributions_cents if latest else None,
|
||||
latest_distributions_cents=latest.distributions_cents if latest else None,
|
||||
latest_value_cents=latest.ending_balance_cents if latest else None,
|
||||
latest_as_of=latest.as_of_date if latest else None,
|
||||
statements_count=len(stmts),
|
||||
exited_on=access.exited_on,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/{entity_id}/partners/{user_id}/exited")
|
||||
def set_partner_exited(
|
||||
entity_id: int,
|
||||
user_id: int,
|
||||
body: PartnerExitUpdate,
|
||||
admin: User = Depends(require_entity_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> PartnerResponse:
|
||||
"""Mark a member as exited from this fund (stake sold/transferred), or clear it.
|
||||
|
||||
Their statements and documents stay; the portal shows an Exited badge instead of a
|
||||
phantom -100% and drops the position from portfolio and fund committed totals.
|
||||
"""
|
||||
check_administrator_scope(admin, entity_id, session)
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
member = session.get(User, user_id)
|
||||
if member is None or member.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Only investor members can be marked exited")
|
||||
|
||||
access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.entity_id == entity_id, EntityAccess.user_id == user_id
|
||||
)
|
||||
).first()
|
||||
if access is None:
|
||||
# A manually-entered investor may have statements without an access grant yet
|
||||
# (e.g. from the Capital Accounts screen). Marking them exited creates the roster
|
||||
# row with the flag set; clearing an exit that doesn't exist stays a 404.
|
||||
if body.exited_on is None:
|
||||
raise HTTPException(status_code=404, detail="That member has no access to this fund")
|
||||
access = EntityAccess(user_id=user_id, entity_id=entity_id)
|
||||
session.add(access)
|
||||
session.flush()
|
||||
|
||||
access.exited_on = body.exited_on
|
||||
session.add(access)
|
||||
record_audit(session, admin.id, "set_exited", "entity_access", access.id, {
|
||||
"entity_id": entity_id,
|
||||
"user_id": user_id,
|
||||
"exited_on": str(body.exited_on) if body.exited_on else None,
|
||||
})
|
||||
session.commit()
|
||||
|
||||
# Return the member's refreshed partner row for easy UI updates.
|
||||
return next(p for p in list_partners(entity_id, admin, session) if p.user_id == user_id)
|
||||
|
||||
|
||||
@router.delete("/{entity_id}/partners")
|
||||
def clear_partners(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_entity_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, int]:
|
||||
"""Remove all partners from this fund — deletes its capital-account statements and the
|
||||
investors' access grants, but keeps the investor accounts (they belong to other funds).
|
||||
For undoing a wrong members import. Holdings/NAV are not affected."""
|
||||
# Local import avoids a module-load cycle (capital_import_router imports import_router).
|
||||
from ten31portal.routers.capital_import_router import reset_entity_partners
|
||||
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
res = reset_entity_partners(entity_id, session)
|
||||
record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
|
||||
session.commit()
|
||||
return res
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_entities(
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[EntityResponse]:
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
rows = session.exec(select(Entity)).all()
|
||||
if allowed is not None:
|
||||
rows = [r for r in rows if r.id in allowed]
|
||||
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@@ -93,18 +246,31 @@ def get_entity(
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EntityResponse:
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
if allowed is not None and entity_id not in allowed:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
return EntityResponse.model_validate(entity, from_attributes=True)
|
||||
|
||||
|
||||
def _validate_linked_user(linked_user_id: int | None, session: Session) -> None:
|
||||
"""A linked account (for a GP entity that is also an LP) must be an investor account."""
|
||||
if linked_user_id is None:
|
||||
return
|
||||
linked = session.get(User, linked_user_id)
|
||||
if linked is None or linked.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Linked account must be an investor account.")
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_entity(
|
||||
body: EntityCreate,
|
||||
user: User = Depends(require_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EntityResponse:
|
||||
_validate_linked_user(body.linked_user_id, session)
|
||||
entity = Entity(**body.model_dump())
|
||||
session.add(entity)
|
||||
session.flush()
|
||||
@@ -118,18 +284,161 @@ def create_entity(
|
||||
def update_entity(
|
||||
entity_id: int,
|
||||
body: EntityUpdate,
|
||||
user: User = Depends(require_writer),
|
||||
user: User = Depends(require_entity_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EntityResponse:
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if "linked_user_id" in changes:
|
||||
_validate_linked_user(changes["linked_user_id"], session)
|
||||
for key, val in changes.items():
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/{entity_id}/asset-balances")
|
||||
def asset_balances(
|
||||
entity_id: int,
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AssetBalancesResponse:
|
||||
"""A GP/mgmt entity's assets = the linked account's capital balances across the funds.
|
||||
|
||||
Household-aware: if the linked account has other legal names linked to it (as the eNAV
|
||||
often splits one LLC across names), their balances are included too.
|
||||
"""
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
if entity.linked_user_id is None:
|
||||
return AssetBalancesResponse()
|
||||
linked = session.get(User, entity.linked_user_id)
|
||||
if linked is None:
|
||||
return AssetBalancesResponse(linked_user_id=entity.linked_user_id)
|
||||
|
||||
household = household_user_ids(linked, session)
|
||||
rows = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(col(CapitalAccountStatement.investor_user_id).in_(household))
|
||||
.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||
).all()
|
||||
# An Administrator only sees the slice of the linked account's balances that sits
|
||||
# in funds granted to them — the linked account may also invest elsewhere.
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
if allowed is not None:
|
||||
rows = [r for r in rows if r.entity_id in allowed]
|
||||
names = dict(session.exec(
|
||||
select(User.id, User.name).where(col(User.id).in_({r.investor_user_id for r in rows}))
|
||||
).all()) if rows else {}
|
||||
balances: list[CapitalAccountResponse] = []
|
||||
for r in rows:
|
||||
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
||||
d.investor_name = names.get(r.investor_user_id)
|
||||
balances.append(d)
|
||||
return AssetBalancesResponse(
|
||||
linked_user_id=linked.id, linked_name=linked.name, balances=balances,
|
||||
)
|
||||
|
||||
|
||||
# --- Entity stakes: a GP/mgmt entity's interest in the funds it manages ---
|
||||
|
||||
def _stake_response(stake: EntityStake, funds: dict[int, Entity]) -> EntityStakeResponse:
|
||||
data = EntityStakeResponse.model_validate(stake, from_attributes=True)
|
||||
fund = funds.get(stake.fund_entity_id)
|
||||
if fund is not None:
|
||||
data.fund_name = fund.name
|
||||
data.fund_type = fund.type
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/{entity_id}/stakes")
|
||||
def list_stakes(
|
||||
entity_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[EntityStakeResponse]:
|
||||
"""The funds this entity holds a stake in (e.g. a GP's interest in its funds)."""
|
||||
allowed = accessible_entity_ids(user, session)
|
||||
if allowed is not None and entity_id not in allowed:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
rows = session.exec(
|
||||
select(EntityStake).where(EntityStake.holder_entity_id == entity_id)
|
||||
).all()
|
||||
funds = {
|
||||
f.id: f for f in session.exec(
|
||||
select(Entity).where(col(Entity.id).in_({r.fund_entity_id for r in rows}))
|
||||
).all()
|
||||
} if rows else {}
|
||||
return [_stake_response(r, funds) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{entity_id}/stakes", status_code=201)
|
||||
def create_stake(
|
||||
entity_id: int,
|
||||
body: EntityStakeCreate,
|
||||
user: User = Depends(require_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EntityStakeResponse:
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
if body.fund_entity_id == entity_id:
|
||||
raise HTTPException(status_code=400, detail="An entity cannot hold a stake in itself.")
|
||||
fund = session.get(Entity, body.fund_entity_id)
|
||||
if fund is None:
|
||||
raise HTTPException(status_code=404, detail="Fund not found")
|
||||
if session.exec(
|
||||
select(EntityStake).where(
|
||||
EntityStake.holder_entity_id == entity_id,
|
||||
EntityStake.fund_entity_id == body.fund_entity_id,
|
||||
)
|
||||
).first():
|
||||
raise HTTPException(status_code=409, detail="A stake in this fund already exists.")
|
||||
|
||||
stake = EntityStake(
|
||||
holder_entity_id=entity_id,
|
||||
fund_entity_id=body.fund_entity_id,
|
||||
ownership_pct=body.ownership_pct,
|
||||
value_cents=round(body.value_dollars * 100) if body.value_dollars is not None else None,
|
||||
note=body.note,
|
||||
)
|
||||
session.add(stake)
|
||||
session.flush()
|
||||
record_audit(session, user.id, "create", "entity_stake", stake.id, {
|
||||
"holder_entity_id": entity_id,
|
||||
"fund_entity_id": body.fund_entity_id,
|
||||
})
|
||||
session.commit()
|
||||
session.refresh(stake)
|
||||
return _stake_response(stake, {fund.id: fund})
|
||||
|
||||
|
||||
@router.delete("/{entity_id}/stakes/{stake_id}")
|
||||
def delete_stake(
|
||||
entity_id: int,
|
||||
stake_id: int,
|
||||
user: User = Depends(require_writer),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
stake = session.get(EntityStake, stake_id)
|
||||
if stake is None or stake.holder_entity_id != entity_id:
|
||||
raise HTTPException(status_code=404, detail="Stake not found")
|
||||
record_audit(session, user.id, "delete", "entity_stake", stake_id, {
|
||||
"holder_entity_id": entity_id,
|
||||
"fund_entity_id": stake.fund_entity_id,
|
||||
})
|
||||
session.delete(stake)
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer
|
||||
from ten31portal.auth import (
|
||||
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import Entity, Holding, Position, User
|
||||
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
|
||||
@@ -15,9 +17,10 @@ router = APIRouter(tags=["holdings"])
|
||||
@router.get("/api/entities/{entity_id}/holdings")
|
||||
def list_holdings(
|
||||
entity_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[HoldingResponse]:
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer
|
||||
from ten31portal.auth import (
|
||||
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
|
||||
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
|
||||
@@ -22,12 +24,13 @@ def _dollars_to_cents(dollars: float) -> int:
|
||||
@router.get("/api/holdings/{holding_id}/positions")
|
||||
def list_positions(
|
||||
holding_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[PositionResponse]:
|
||||
holding = session.get(Holding, holding_id)
|
||||
if holding is None:
|
||||
raise HTTPException(status_code=404, detail="Holding not found")
|
||||
check_administrator_scope(user, holding.entity_id, session)
|
||||
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
|
||||
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, col
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer, require_approver
|
||||
from ten31portal.auth import (
|
||||
check_administrator_scope, require_internal_or_administrator, require_writer,
|
||||
require_approver,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
Entity, Holding, Position, Valuation, ValuationRound,
|
||||
@@ -30,9 +33,10 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
|
||||
@router.get("/api/entities/{entity_id}/rounds")
|
||||
def list_rounds(
|
||||
entity_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[RoundResponse]:
|
||||
check_administrator_scope(user, entity_id, session)
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
@@ -47,12 +51,13 @@ def list_rounds(
|
||||
@router.get("/api/rounds/{round_id}")
|
||||
def get_round(
|
||||
round_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_internal_or_administrator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> RoundResponse:
|
||||
round = session.get(ValuationRound, round_id)
|
||||
if round is None:
|
||||
raise HTTPException(status_code=404, detail="Round not found")
|
||||
check_administrator_scope(user, round.entity_id, session)
|
||||
return _round_response(round, session)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
"""User administration: create and manage accounts and their entity access."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, col
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import (
|
||||
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
|
||||
hash_password, household_user_ids, require_admin, require_internal_admin, require_role,
|
||||
)
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import (
|
||||
AuditLog, CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES,
|
||||
User, UserRole, ValuationRound,
|
||||
)
|
||||
from ten31portal.schemas import (
|
||||
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
|
||||
DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset,
|
||||
UserCreate, UserDetailResponse, UserResponse, UserUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
# --- Administrator (external, entity-scoped) visibility helpers ---
|
||||
|
||||
def _visible_investor_ids(scope: set[int], session: Session) -> set[int]:
|
||||
"""Investors an Administrator may see and manage: anyone with an access grant or a
|
||||
capital-account statement in one of the Administrator's entities."""
|
||||
if not scope:
|
||||
return set()
|
||||
ids = set(session.exec(
|
||||
select(EntityAccess.user_id).where(col(EntityAccess.entity_id).in_(scope))
|
||||
).all())
|
||||
ids |= set(session.exec(
|
||||
select(CapitalAccountStatement.investor_user_id).where(
|
||||
col(CapitalAccountStatement.entity_id).in_(scope)
|
||||
)
|
||||
).all())
|
||||
return ids
|
||||
|
||||
|
||||
def _check_target_in_scope(admin: User, target: User, session: Session) -> None:
|
||||
"""403 unless the caller is internal, or the target is an investor tied to one of the
|
||||
Administrator's entities. Keeps an external Administrator away from staff accounts and
|
||||
from other funds' investors entirely."""
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is None:
|
||||
return
|
||||
if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session):
|
||||
raise HTTPException(status_code=403, detail="No access to this account")
|
||||
|
||||
|
||||
def delete_user_cascade(session: Session, user: User) -> list[str]:
|
||||
"""Delete a user and its dependent rows; returns warnings for files that would not delete.
|
||||
|
||||
Shared by the API endpoint and the CLI. The caller is responsible for the guards
|
||||
(Service Admin, self-deletion, scope) and for committing the session.
|
||||
"""
|
||||
from ten31portal import storage
|
||||
|
||||
warnings: list[str] = []
|
||||
uid = user.id
|
||||
|
||||
# Entity-access grants and capital-account statements are this user's own data.
|
||||
for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all():
|
||||
session.delete(acc)
|
||||
for stmt in session.exec(
|
||||
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid)
|
||||
).all():
|
||||
session.delete(stmt)
|
||||
|
||||
# Documents addressed privately to this investor are removed (file + row); documents
|
||||
# they uploaded stay, with the uploader cleared.
|
||||
for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all():
|
||||
try:
|
||||
storage.delete_file(doc.storage_path)
|
||||
except OSError as exc:
|
||||
warnings.append(f"could not delete file {doc.storage_path}: {exc}")
|
||||
session.delete(doc)
|
||||
for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all():
|
||||
doc.uploaded_by = None
|
||||
session.add(doc)
|
||||
|
||||
# Preserve history/rounds by clearing the references to this user.
|
||||
for rnd in session.exec(
|
||||
select(ValuationRound).where(
|
||||
(ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid)
|
||||
)
|
||||
).all():
|
||||
if rnd.submitted_by == uid:
|
||||
rnd.submitted_by = None
|
||||
if rnd.approved_by == uid:
|
||||
rnd.approved_by = None
|
||||
session.add(rnd)
|
||||
for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all():
|
||||
log.actor_user_id = None
|
||||
session.add(log)
|
||||
|
||||
# Detach any linked sub-accounts so they log in on their own again.
|
||||
for sub in session.exec(select(User).where(User.primary_account_id == uid)).all():
|
||||
sub.primary_account_id = None
|
||||
session.add(sub)
|
||||
|
||||
session.delete(user)
|
||||
return warnings
|
||||
|
||||
|
||||
@router.get("/access-matrix")
|
||||
def access_matrix(
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> AccessMatrixResponse:
|
||||
"""External accounts, all entities, and the grants linking them.
|
||||
|
||||
For an external Administrator the matrix is their slice of the world: only their
|
||||
granted entities, and only the investors tied to those entities.
|
||||
"""
|
||||
users = session.exec(
|
||||
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
|
||||
).all()
|
||||
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
|
||||
grants = session.exec(select(EntityAccess)).all()
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is not None:
|
||||
visible = _visible_investor_ids(scope, session)
|
||||
users = [u for u in users if u.id in visible]
|
||||
entities = [e for e in entities if e.id in scope]
|
||||
grants = [g for g in grants if g.entity_id in scope and g.user_id in visible]
|
||||
return AccessMatrixResponse(
|
||||
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
|
||||
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||
grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}/access/{entity_id}", status_code=200)
|
||||
def grant_access(
|
||||
user_id: int,
|
||||
entity_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
check_administrator_scope(admin, entity_id, session)
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if admin.role == UserRole.fund_administrator and user.role != UserRole.investor:
|
||||
raise HTTPException(status_code=403, detail="Administrators manage investor accounts only")
|
||||
if session.get(Entity, entity_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
existing = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||
)
|
||||
).first()
|
||||
if existing is None:
|
||||
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||
record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id})
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}/access/{entity_id}")
|
||||
def revoke_access(
|
||||
user_id: int,
|
||||
entity_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
check_administrator_scope(admin, entity_id, session)
|
||||
if admin.role == UserRole.fund_administrator:
|
||||
target = session.get(User, user_id)
|
||||
if target is not None and target.role != UserRole.investor:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Administrators manage investor accounts only"
|
||||
)
|
||||
existing = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
session.delete(existing)
|
||||
record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id})
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/investors-for-entity/{entity_id}")
|
||||
def investors_for_entity(
|
||||
entity_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[UserResponse]:
|
||||
"""Investor accounts with access to an entity. For internal staff and the entity's fund admins."""
|
||||
if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not (
|
||||
user.role == UserRole.fund_administrator
|
||||
and can_access_entity(user, entity_id, session)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
rows = session.exec(
|
||||
select(User)
|
||||
.join(EntityAccess, EntityAccess.user_id == User.id)
|
||||
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
|
||||
.order_by(User.name) # type: ignore[arg-type]
|
||||
).all()
|
||||
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{user_id}/investor-view")
|
||||
def investor_view(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_internal_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> InvestorViewResponse:
|
||||
"""Reconstruct exactly what an investor sees in their portal — read-only, for admins.
|
||||
|
||||
No session impersonation: this returns the same data the investor's own portal would load
|
||||
(their accessible entities, their capital statements, and the documents visible to them),
|
||||
scoped with the same access helpers.
|
||||
"""
|
||||
target = session.get(User, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if target.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Investor View is for investor accounts.")
|
||||
|
||||
allowed = accessible_entity_ids(target, session) or set()
|
||||
household = household_user_ids(target, session)
|
||||
|
||||
entities = session.exec(
|
||||
select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type]
|
||||
).all() if allowed else []
|
||||
|
||||
caps: list[CapitalAccountResponse] = []
|
||||
docs: list[DocumentResponse] = []
|
||||
if allowed:
|
||||
cap_rows = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(
|
||||
col(CapitalAccountStatement.investor_user_id).in_(household),
|
||||
col(CapitalAccountStatement.entity_id).in_(allowed),
|
||||
)
|
||||
.order_by(col(CapitalAccountStatement.as_of_date).desc())
|
||||
).all()
|
||||
names = dict(session.exec(
|
||||
select(User.id, User.name).where(
|
||||
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 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(
|
||||
select(Document)
|
||||
.where(col(Document.entity_id).in_(allowed))
|
||||
.order_by(col(Document.created_at).desc())
|
||||
).all()
|
||||
# Investor sees shared docs and those addressed to any of their linked names.
|
||||
docs = [
|
||||
DocumentResponse.model_validate(d, from_attributes=True)
|
||||
for d in doc_rows
|
||||
if d.investor_user_id is None or d.investor_user_id in household
|
||||
]
|
||||
|
||||
return InvestorViewResponse(
|
||||
user=UserResponse.model_validate(target, from_attributes=True),
|
||||
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
|
||||
capital_accounts=caps,
|
||||
documents=docs,
|
||||
)
|
||||
|
||||
|
||||
def _entity_ids_for(user_id: int, session: Session) -> list[int]:
|
||||
return list(session.exec(
|
||||
select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id)
|
||||
).all())
|
||||
|
||||
|
||||
def _user_detail(user: User, session: Session) -> UserDetailResponse:
|
||||
"""Build a full user detail, including the linked-account relationships."""
|
||||
data = UserResponse.model_validate(user, from_attributes=True).model_dump()
|
||||
primary_name = None
|
||||
if user.primary_account_id:
|
||||
primary = session.get(User, user.primary_account_id)
|
||||
primary_name = primary.name if primary else None
|
||||
linked = session.exec(
|
||||
select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type]
|
||||
).all()
|
||||
return UserDetailResponse(
|
||||
**data,
|
||||
primary_account_name=primary_name,
|
||||
linked_accounts=[
|
||||
LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked
|
||||
],
|
||||
entity_ids=_entity_ids_for(user.id, session),
|
||||
)
|
||||
|
||||
|
||||
def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None:
|
||||
"""Replace a user's entity grants with the given set, ignoring unknown ids."""
|
||||
valid = set(session.exec(
|
||||
select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr]
|
||||
).all()) if entity_ids else set()
|
||||
existing = session.exec(
|
||||
select(EntityAccess).where(EntityAccess.user_id == user_id)
|
||||
).all()
|
||||
current = {a.entity_id: a for a in existing}
|
||||
# Remove grants no longer wanted.
|
||||
for eid, access in current.items():
|
||||
if eid not in valid:
|
||||
session.delete(access)
|
||||
# Add new grants.
|
||||
for eid in valid:
|
||||
if eid not in current:
|
||||
session.add(EntityAccess(user_id=user_id, entity_id=eid))
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_users(
|
||||
# Read-widened: a view-only Administrator may list the investors of its funds (it needs
|
||||
# their names on the capital-accounts and documents screens); every mutating endpoint
|
||||
# below keeps the stricter require_admin gate.
|
||||
admin: User = Depends(require_role(
|
||||
UserRole.approver, UserRole.cfo, UserRole.operations,
|
||||
UserRole.fund_administrator, UserRole.administrator_viewer,
|
||||
)),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[UserResponse]:
|
||||
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is not None:
|
||||
visible = _visible_investor_ids(scope, session)
|
||||
rows = [r for r in rows if r.role == UserRole.investor and r.id in visible]
|
||||
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
def get_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserDetailResponse:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_check_target_in_scope(admin, user, session)
|
||||
return _user_detail(user, session)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_user(
|
||||
body: UserCreate,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserDetailResponse:
|
||||
entity_ids = body.entity_ids
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is not None:
|
||||
# An Administrator creates investor accounts only, and only on their own funds —
|
||||
# anything else would be privilege escalation or an account they can't see again.
|
||||
if body.role != UserRole.investor:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Administrators can only create investor accounts"
|
||||
)
|
||||
entity_ids = [e for e in entity_ids if e in scope]
|
||||
if not entity_ids:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Select at least one of your funds for this investor"
|
||||
)
|
||||
if session.exec(select(User).where(User.username == body.username)).first():
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
if body.email and session.exec(select(User).where(User.email == body.email)).first():
|
||||
raise HTTPException(status_code=409, detail="Email already in use")
|
||||
|
||||
user = User(
|
||||
name=body.name,
|
||||
username=body.username,
|
||||
email=body.email or None,
|
||||
password_hash=hash_password(body.password),
|
||||
role=body.role,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
_set_entity_access(user.id, entity_ids, session)
|
||||
record_audit(session, admin.id, "create", "user", user.id,
|
||||
{"username": body.username, "role": body.role.value,
|
||||
"entity_ids": entity_ids})
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_detail(user, session)
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserDetailResponse:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_check_target_in_scope(admin, user, session)
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
entity_ids = changes.pop("entity_ids", None)
|
||||
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is not None:
|
||||
if "role" in changes and changes["role"] != user.role:
|
||||
raise HTTPException(status_code=403, detail="Administrators cannot change roles")
|
||||
changes.pop("role", None)
|
||||
if entity_ids is not None:
|
||||
# Only this Administrator's funds are theirs to grant or revoke; the investor's
|
||||
# access to any other fund is preserved untouched.
|
||||
kept_elsewhere = set(_entity_ids_for(user_id, session)) - scope
|
||||
entity_ids = list((set(entity_ids) & scope) | kept_elsewhere)
|
||||
|
||||
if "username" in changes:
|
||||
new_username = (changes["username"] or "").strip()
|
||||
if not new_username:
|
||||
raise HTTPException(status_code=400, detail="Username cannot be blank")
|
||||
clash = session.exec(select(User).where(User.username == new_username)).first()
|
||||
if clash and clash.id != user_id:
|
||||
raise HTTPException(status_code=409, detail="Username already taken")
|
||||
changes["username"] = new_username
|
||||
|
||||
if "email" in changes and changes["email"]:
|
||||
clash = session.exec(select(User).where(User.email == changes["email"])).first()
|
||||
if clash and clash.id != user_id:
|
||||
raise HTTPException(status_code=409, detail="Email already in use")
|
||||
|
||||
for key, val in changes.items():
|
||||
setattr(user, key, val)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
if entity_ids is not None:
|
||||
_set_entity_access(user_id, entity_ids, session)
|
||||
|
||||
record_audit(session, admin.id, "update", "user", user_id,
|
||||
{**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})})
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_detail(user, session)
|
||||
|
||||
|
||||
@router.put("/{user_id}/primary-account")
|
||||
def link_account(
|
||||
user_id: int,
|
||||
body: AccountLink,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> UserDetailResponse:
|
||||
"""Link an investor account to a primary login (or detach it when null).
|
||||
|
||||
The primary becomes the single sign-on that sees every linked name's investments. The
|
||||
linked account's own login is disabled so there is one set of credentials per person.
|
||||
"""
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_check_target_in_scope(admin, user, session)
|
||||
if user.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
|
||||
|
||||
primary_id = body.primary_account_id
|
||||
if primary_id is not None:
|
||||
if primary_id == user_id:
|
||||
raise HTTPException(status_code=400, detail="An account cannot link to itself.")
|
||||
primary = session.get(User, primary_id)
|
||||
if primary is None or primary.role != UserRole.investor:
|
||||
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
|
||||
_check_target_in_scope(admin, primary, session)
|
||||
if primary.primary_account_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That account is itself linked to another login. Link to a primary instead.",
|
||||
)
|
||||
# Prevent chains: an account that other names log in under can't become a secondary.
|
||||
if session.exec(select(User).where(User.primary_account_id == user_id)).first():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This account is a primary for other names. Detach those first.",
|
||||
)
|
||||
# Login is blocked while primary_account_id is set (see auth_router.login), so we leave
|
||||
# login_enabled untouched — unlinking then restores the account's own sign-in cleanly.
|
||||
user.primary_account_id = primary_id
|
||||
else:
|
||||
user.primary_account_id = None
|
||||
|
||||
session.add(user)
|
||||
record_audit(session, admin.id, "link_account", "user", user_id,
|
||||
{"primary_account_id": primary_id})
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return _user_detail(user, session)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
def reset_password(
|
||||
user_id: int,
|
||||
body: PasswordReset,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
_check_target_in_scope(admin, user, session)
|
||||
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)
|
||||
session.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, str]:
|
||||
"""Delete an account and its dependent data (grants, statements, private documents).
|
||||
|
||||
The Service Admin can never be deleted, nor can you delete yourself. An external
|
||||
Administrator may only delete an investor who belongs solely to their own funds —
|
||||
an investor who also sits in another fund is another manager's problem too.
|
||||
"""
|
||||
user = session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.is_service_admin:
|
||||
raise HTTPException(status_code=400, detail="The Service Admin cannot be deleted")
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
_check_target_in_scope(admin, user, session)
|
||||
|
||||
scope = accessible_entity_ids(admin, session)
|
||||
if scope is not None:
|
||||
footprint = set(_entity_ids_for(user_id, session))
|
||||
footprint |= set(session.exec(
|
||||
select(CapitalAccountStatement.entity_id).where(
|
||||
CapitalAccountStatement.investor_user_id == user_id
|
||||
)
|
||||
).all())
|
||||
if not footprint <= scope:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This investor also belongs to funds outside your access; "
|
||||
"remove them from your funds instead of deleting the account.",
|
||||
)
|
||||
|
||||
name, username = user.name, user.username
|
||||
delete_user_cascade(session, user)
|
||||
record_audit(session, admin.id, "delete", "user", user_id,
|
||||
{"username": username, "name": name})
|
||||
session.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -5,25 +5,117 @@ from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus
|
||||
from ten31portal.models import (
|
||||
UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory,
|
||||
)
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
login: str # username or email
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
email: str
|
||||
username: str
|
||||
email: str | None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
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
|
||||
|
||||
|
||||
class LoginPending2FA(BaseModel):
|
||||
"""Password accepted, waiting on the second factor before the session is signed in."""
|
||||
requires_2fa: bool = True
|
||||
|
||||
|
||||
class TotpVerifyRequest(BaseModel):
|
||||
code: str # 6-digit authenticator code, or a one-time recovery code
|
||||
|
||||
|
||||
class TotpSetupResponse(BaseModel):
|
||||
secret: str
|
||||
otpauth_uri: str
|
||||
qr_svg: str
|
||||
|
||||
|
||||
class TotpConfirmRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class TotpConfirmResponse(BaseModel):
|
||||
recovery_codes: list[str] # shown exactly once
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
# --- User administration ---
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
name: str
|
||||
username: str
|
||||
password: str
|
||||
role: UserRole
|
||||
email: str | None = None
|
||||
entity_ids: list[int] = []
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
username: str | None = None
|
||||
email: str | None = None
|
||||
role: UserRole | None = None
|
||||
is_active: bool | None = None
|
||||
entity_ids: list[int] | None = None # full replacement of grants when provided
|
||||
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class LinkedAccount(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
username: str
|
||||
|
||||
|
||||
class UserDetailResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
username: str
|
||||
email: str | None
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
is_service_admin: bool = False
|
||||
primary_account_id: int | None = None
|
||||
primary_account_name: str | None = None # the login this account is linked under, if any
|
||||
linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account
|
||||
created_at: datetime
|
||||
entity_ids: list[int] = []
|
||||
|
||||
|
||||
class AccountLink(BaseModel):
|
||||
# null detaches the account so it logs in on its own again
|
||||
primary_account_id: int | None = None
|
||||
|
||||
|
||||
# --- Entity ---
|
||||
|
||||
class EntityCreate(BaseModel):
|
||||
@@ -31,6 +123,7 @@ class EntityCreate(BaseModel):
|
||||
type: EntityType
|
||||
vintage_year: int | None = None
|
||||
fund_size_cents: int | None = None
|
||||
linked_user_id: int | None = None
|
||||
|
||||
|
||||
class EntityUpdate(BaseModel):
|
||||
@@ -39,6 +132,8 @@ class EntityUpdate(BaseModel):
|
||||
vintage_year: int | None = None
|
||||
fund_size_cents: int | None = None
|
||||
status: EntityStatus | None = None
|
||||
linked_user_id: int | None = None
|
||||
close_date: date | None = None
|
||||
|
||||
|
||||
class EntityResponse(BaseModel):
|
||||
@@ -48,6 +143,8 @@ class EntityResponse(BaseModel):
|
||||
vintage_year: int | None
|
||||
fund_size_cents: int | None
|
||||
status: EntityStatus
|
||||
linked_user_id: int | None = None
|
||||
close_date: date | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -136,6 +233,216 @@ class RoundResponse(BaseModel):
|
||||
valuations: list[ValuationResponse] = []
|
||||
|
||||
|
||||
# --- Document ---
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: int
|
||||
entity_id: int
|
||||
investor_user_id: int | None
|
||||
category: DocumentCategory
|
||||
title: str
|
||||
original_filename: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
uploaded_by: int | None
|
||||
created_at: datetime
|
||||
# True for an investor when the doc arrived since their previous portal visit.
|
||||
is_new: bool = False
|
||||
|
||||
|
||||
# --- Capital account ---
|
||||
|
||||
class CapitalAccountCreate(BaseModel):
|
||||
entity_id: int
|
||||
investor_user_id: int
|
||||
as_of_date: date
|
||||
commitment_dollars: float = 0
|
||||
beginning_balance_dollars: float = 0
|
||||
contributions_dollars: float = 0
|
||||
distributions_dollars: float = 0
|
||||
ending_balance_dollars: float = 0
|
||||
document_id: int | None = None
|
||||
|
||||
|
||||
class CapitalAccountResponse(BaseModel):
|
||||
id: int
|
||||
entity_id: int
|
||||
investor_user_id: int
|
||||
investor_name: str | None = None # legal name of the account this statement belongs to
|
||||
as_of_date: date
|
||||
commitment_cents: int
|
||||
beginning_balance_cents: int
|
||||
contributions_cents: int
|
||||
distributions_cents: int
|
||||
ending_balance_cents: int
|
||||
document_id: int | None
|
||||
created_at: datetime
|
||||
# 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) ---
|
||||
|
||||
class PartnerResponse(BaseModel):
|
||||
user_id: int
|
||||
name: str
|
||||
username: str
|
||||
external_investor_id: str | None
|
||||
is_active: bool
|
||||
login_enabled: bool
|
||||
latest_commitment_cents: int | None = None
|
||||
latest_contributions_cents: int | None = None
|
||||
latest_distributions_cents: int | None = None
|
||||
latest_value_cents: int | None
|
||||
latest_as_of: date | None
|
||||
statements_count: int
|
||||
exited_on: date | None = None
|
||||
|
||||
|
||||
class PartnerExitUpdate(BaseModel):
|
||||
exited_on: date | None # null clears the exit (marks the member active again)
|
||||
|
||||
|
||||
# --- Access matrix ---
|
||||
|
||||
class AccessGrant(BaseModel):
|
||||
user_id: int
|
||||
entity_id: int
|
||||
|
||||
|
||||
class AccessMatrixResponse(BaseModel):
|
||||
users: list[UserResponse]
|
||||
entities: list[EntityResponse]
|
||||
grants: list[AccessGrant]
|
||||
|
||||
|
||||
# --- Capital account import (review-and-confirm) ---
|
||||
|
||||
class ImportInvestorPreview(BaseModel):
|
||||
source_name: str # name as it appears in the spreadsheet
|
||||
column_index: int # 0-based column it was read from
|
||||
value_dollars: float # current capital value (ending balance)
|
||||
commitment_dollars: float = 0
|
||||
contributions_dollars: float = 0
|
||||
distributions_dollars: float = 0
|
||||
external_id: str | None = None # fund-admin INVESTOR ID, when present
|
||||
matched_user_id: int | None = None
|
||||
matched_username: str | None = None
|
||||
suggested_username: str | None = None # for unmatched: a safe default
|
||||
|
||||
|
||||
class ImportValueRow(BaseModel):
|
||||
row_index: int # 0-based sheet row
|
||||
label: str # col A label (e.g. "LTPF1")
|
||||
|
||||
|
||||
class CapitalImportPreview(BaseModel):
|
||||
as_of_date: date | None
|
||||
value_rows: list[ImportValueRow] # candidate rows holding per-investor balances
|
||||
chosen_row_index: int # the row used for the values below
|
||||
investors: list[ImportInvestorPreview]
|
||||
|
||||
|
||||
class ImportCommitInvestor(BaseModel):
|
||||
action: str # "match" | "create" | "skip"
|
||||
value_dollars: float # current capital value (ending balance)
|
||||
commitment_dollars: float = 0
|
||||
contributions_dollars: float = 0
|
||||
distributions_dollars: float = 0
|
||||
user_id: int | None = None # for action=match
|
||||
name: str | None = None # for action=create
|
||||
username: str | None = None # for action=create
|
||||
email: str | None = None
|
||||
password: str | None = None # for action=create; omit for the shared default password
|
||||
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
|
||||
|
||||
|
||||
class CapitalImportCommit(BaseModel):
|
||||
entity_id: int
|
||||
as_of_date: date
|
||||
investors: list[ImportCommitInvestor]
|
||||
|
||||
|
||||
# --- Batch historical capital backfill (one eNAV file per quarter, auto-matched) ---
|
||||
|
||||
class BatchCapitalFileResult(BaseModel):
|
||||
filename: str
|
||||
as_of_date: date | None = None
|
||||
matched: int = 0 # existing members whose statement was written
|
||||
statements_written: int = 0 # created + updated
|
||||
updated: int = 0 # matched a statement already at this as-of date
|
||||
skipped: list[str] = [] # roster names with no existing member (not created)
|
||||
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
|
||||
# NAV history leg: the quarter's valuation round written from the file's HLD sheet.
|
||||
nav_status: str | None = None # added | updated | kept-signed | no-match | no-hld | error
|
||||
nav_matched: int = 0 # HLD rows matched to positions in today's book
|
||||
nav_unmatched: int = 0 # HLD rows with no current position (sold/renamed since)
|
||||
nav_cents: int = 0 # the quarter's NAV as recorded (matched rows only)
|
||||
|
||||
|
||||
class BatchCapitalImportResult(BaseModel):
|
||||
entity_id: int
|
||||
files: list[BatchCapitalFileResult]
|
||||
total_statements: int
|
||||
|
||||
|
||||
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
||||
|
||||
class EntityStakeCreate(BaseModel):
|
||||
fund_entity_id: int
|
||||
ownership_pct: float | None = None
|
||||
value_dollars: float | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class EntityStakeResponse(BaseModel):
|
||||
id: int
|
||||
holder_entity_id: int
|
||||
fund_entity_id: int
|
||||
fund_name: str | None = None
|
||||
fund_type: EntityType | None = None
|
||||
ownership_pct: float | None
|
||||
value_cents: int | None
|
||||
note: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# --- Investor View (admin reconstruction of what one investor sees) ---
|
||||
|
||||
class InvestorViewResponse(BaseModel):
|
||||
user: UserResponse
|
||||
entities: list[EntityResponse] = []
|
||||
capital_accounts: list[CapitalAccountResponse] = []
|
||||
documents: list[DocumentResponse] = []
|
||||
|
||||
|
||||
class AssetBalancesResponse(BaseModel):
|
||||
"""A GP/mgmt entity's assets: the linked account's capital balances across the funds."""
|
||||
linked_user_id: int | None = None
|
||||
linked_name: str | None = None
|
||||
balances: list[CapitalAccountResponse] = []
|
||||
|
||||
|
||||
# --- Audit ---
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""File storage on the data volume for uploaded documents."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ten31portal.config import DOCS_DIR, MAX_UPLOAD_SIZE
|
||||
|
||||
|
||||
class UploadTooLarge(Exception):
|
||||
"""Raised when an upload exceeds MAX_UPLOAD_SIZE. The partial file is removed first."""
|
||||
|
||||
|
||||
def read_capped(file: UploadFile, limit: int = MAX_UPLOAD_SIZE) -> bytes:
|
||||
"""Read an upload fully into memory, but abort past `limit` instead of reading unbounded.
|
||||
|
||||
The spreadsheet importers must parse the whole workbook in memory; without a cap an
|
||||
authenticated writer could POST a huge file and exhaust the process. Reads in 1 MB chunks
|
||||
and raises UploadTooLarge (→ 413) once the total would exceed the limit.
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
while chunk := file.file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > limit:
|
||||
raise UploadTooLarge(f"Upload exceeds the {limit}-byte limit.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def ensure_docs_dir() -> Path:
|
||||
path = Path(DOCS_DIR)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_upload(file: UploadFile) -> tuple[str, int]:
|
||||
"""Stream an upload to disk under an opaque name. Returns (storage_path, size_bytes).
|
||||
|
||||
Enforces MAX_UPLOAD_SIZE as it streams so a runaway upload can't fill the data volume;
|
||||
the partial file is deleted before UploadTooLarge propagates.
|
||||
"""
|
||||
docs = ensure_docs_dir()
|
||||
suffix = Path(file.filename or "").suffix
|
||||
storage_name = f"{uuid4().hex}{suffix}"
|
||||
dest = docs / storage_name
|
||||
size = 0
|
||||
try:
|
||||
with dest.open("wb") as out:
|
||||
while chunk := file.file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_UPLOAD_SIZE:
|
||||
raise UploadTooLarge(
|
||||
f"Upload exceeds the {MAX_UPLOAD_SIZE}-byte limit."
|
||||
)
|
||||
out.write(chunk)
|
||||
except UploadTooLarge:
|
||||
dest.unlink(missing_ok=True)
|
||||
raise
|
||||
return storage_name, size
|
||||
|
||||
|
||||
def full_path(storage_path: str) -> Path:
|
||||
"""Resolve a stored file, guarding against path traversal."""
|
||||
docs = ensure_docs_dir().resolve()
|
||||
candidate = (docs / storage_path).resolve()
|
||||
if not str(candidate).startswith(str(docs) + os.sep):
|
||||
raise ValueError("Invalid storage path")
|
||||
return candidate
|
||||
|
||||
|
||||
def delete_file(storage_path: str) -> None:
|
||||
try:
|
||||
full_path(storage_path).unlink(missing_ok=True)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,71 @@
|
||||
"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes.
|
||||
|
||||
Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest —
|
||||
unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful
|
||||
match removes its hash from the stored list.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import secrets
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
ISSUER = "Ten31 Portal"
|
||||
RECOVERY_CODE_COUNT = 8
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def otpauth_uri(secret: str, account_name: str) -> str:
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER)
|
||||
|
||||
|
||||
def qr_svg(uri: str) -> str:
|
||||
"""The enrollment QR as a standalone SVG document (no raster deps needed)."""
|
||||
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf)
|
||||
return buf.getvalue().decode()
|
||||
|
||||
|
||||
def verify_code(secret: str, code: str) -> bool:
|
||||
# valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift.
|
||||
return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1)
|
||||
|
||||
|
||||
def _normalize_recovery(code: str) -> str:
|
||||
return code.strip().replace("-", "").replace(" ", "").lower()
|
||||
|
||||
|
||||
def _digest(code: str) -> str:
|
||||
return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest()
|
||||
|
||||
|
||||
def generate_recovery_codes() -> tuple[list[str], str]:
|
||||
"""Return (plaintext codes to show once, JSON of their digests to store)."""
|
||||
codes = []
|
||||
for _ in range(RECOVERY_CODE_COUNT):
|
||||
raw = secrets.token_hex(10) # 20 hex chars, 80 bits
|
||||
codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}")
|
||||
return codes, json.dumps([_digest(c) for c in codes])
|
||||
|
||||
|
||||
def consume_recovery_code(stored_json: str | None, code: str) -> str | None:
|
||||
"""If ``code`` matches an unused recovery code, return the updated JSON without it.
|
||||
|
||||
Returns None when the code doesn't match (or none are stored).
|
||||
"""
|
||||
if not stored_json:
|
||||
return None
|
||||
digests: list[str] = json.loads(stored_json)
|
||||
d = _digest(code)
|
||||
if d not in digests:
|
||||
return None
|
||||
digests.remove(d)
|
||||
return json.dumps(digests)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Shared pytest fixtures: an in-memory DB and a FastAPI TestClient.
|
||||
|
||||
The client is created WITHOUT the context-manager form on purpose, so the app lifespan (which
|
||||
runs real Alembic migrations against the configured DB_PATH) never fires. Tables come from
|
||||
SQLModel.metadata.create_all against a throwaway in-memory database instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
import ten31portal.models # noqa: F401 — importing registers every table on SQLModel.metadata
|
||||
from ten31portal.auth import hash_password
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.main import app
|
||||
from ten31portal.models import User, UserRole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
eng = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool, # one shared in-memory connection across sessions
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
SQLModel.metadata.drop_all(eng)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(engine):
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(engine):
|
||||
def override_get_session():
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
app.dependency_overrides[get_session] = override_get_session
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def make_user(session, *, username="admin", password="password123",
|
||||
role=UserRole.approver, name="Test User", email=None, **kwargs):
|
||||
user = User(
|
||||
name=name,
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
**kwargs,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approver(session):
|
||||
return make_user(session, username="approver", role=UserRole.approver)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(client, approver):
|
||||
"""A TestClient already logged in as an approver (a writer)."""
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_login_limiter():
|
||||
"""The login rate-limiter is a process-global; clear the TestClient's key around each test
|
||||
so failed-login tests can't throttle unrelated ones."""
|
||||
from ten31portal.routers.auth_router import _login_limiter
|
||||
_login_limiter.reset("testclient")
|
||||
yield
|
||||
_login_limiter.reset("testclient")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""External Administrator role (0.2.42): full management, fenced to granted entities.
|
||||
|
||||
An Administrator (UserRole.fund_administrator) runs the same admin screens as internal
|
||||
staff — users, partners, documents, capital accounts, imports — but only for the funds
|
||||
granted to them via EntityAccess. These tests pin the fence.
|
||||
"""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Document, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _login(client, username, password="password123"):
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={"login": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def _setup(session):
|
||||
"""Two funds; the Administrator manages fund A only. One LP in each fund."""
|
||||
fund_a = Entity(name="Fund A", type=EntityType.fund)
|
||||
fund_b = Entity(name="Fund B", type=EntityType.fund)
|
||||
session.add(fund_a)
|
||||
session.add(fund_b)
|
||||
session.commit()
|
||||
|
||||
admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator,
|
||||
name="Outside Administrator")
|
||||
session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id))
|
||||
|
||||
lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id))
|
||||
lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta")
|
||||
session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id))
|
||||
session.commit()
|
||||
return fund_a, fund_b, admin, lp_a, lp_b
|
||||
|
||||
|
||||
def _stmt(entity_id, investor_id, as_of=date(2026, 3, 31), balance=1_000_000_00):
|
||||
return CapitalAccountStatement(
|
||||
entity_id=entity_id, investor_user_id=investor_id, as_of_date=as_of,
|
||||
commitment_cents=balance, beginning_balance_cents=0,
|
||||
contributions_cents=balance, distributions_cents=0,
|
||||
ending_balance_cents=balance,
|
||||
)
|
||||
|
||||
|
||||
def test_administrator_sees_only_their_funds_users(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
users = client.get("/api/users").json()
|
||||
assert {u["username"] for u in users} == {"lp-a"}
|
||||
|
||||
# Their fund's investor is reachable; the other fund's — and staff — are not.
|
||||
assert client.get(f"/api/users/{lp_a.id}").status_code == 200
|
||||
assert client.get(f"/api/users/{lp_b.id}").status_code == 403
|
||||
assert client.get(f"/api/users/{approver.id}").status_code == 403
|
||||
|
||||
|
||||
def test_administrator_creates_investors_only_on_their_funds(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
# Investor on fund A: allowed; out-of-scope fund ids are silently dropped.
|
||||
resp = client.post("/api/users", json={
|
||||
"name": "New LP", "username": "new-lp", "password": "secretpw",
|
||||
"role": "investor", "entity_ids": [fund_a.id, fund_b.id],
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["entity_ids"] == [fund_a.id]
|
||||
|
||||
# Staff roles are privilege escalation.
|
||||
resp = client.post("/api/users", json={
|
||||
"name": "Sneaky", "username": "sneaky", "password": "secretpw",
|
||||
"role": "operations", "entity_ids": [],
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# An investor with no in-scope fund would be invisible to its creator.
|
||||
resp = client.post("/api/users", json={
|
||||
"name": "Orphan", "username": "orphan", "password": "secretpw",
|
||||
"role": "investor", "entity_ids": [fund_b.id],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_administrator_partner_management_is_scoped(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403
|
||||
|
||||
resp = client.put(
|
||||
f"/api/entities/{fund_a.id}/partners/{lp_a.id}/exited",
|
||||
json={"exited_on": "2026-06-30"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.put(
|
||||
f"/api/entities/{fund_b.id}/partners/{lp_b.id}/exited",
|
||||
json={"exited_on": "2026-06-30"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_administrator_documents_upload_and_delete(client, session, approver, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr("ten31portal.storage.DOCS_DIR", str(tmp_path))
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
|
||||
doc_b = Document(
|
||||
entity_id=fund_b.id, category="statement", title="B statement",
|
||||
original_filename="b.pdf", content_type="application/pdf", size_bytes=1,
|
||||
storage_path="x-b",
|
||||
)
|
||||
session.add(doc_b)
|
||||
session.commit()
|
||||
|
||||
_login(client, "fundadmin")
|
||||
resp = client.post(
|
||||
"/api/documents",
|
||||
data={"entity_id": str(fund_a.id), "category": "statement", "title": "Q1 statement"},
|
||||
files={"file": ("q1.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
doc_a_id = resp.json()["id"]
|
||||
|
||||
assert client.delete(f"/api/documents/{doc_a_id}").json() == {"status": "deleted"}
|
||||
assert client.delete(f"/api/documents/{doc_b.id}").status_code == 403
|
||||
|
||||
|
||||
def test_administrator_capital_accounts_are_scoped(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
session.add(_stmt(fund_a.id, lp_a.id))
|
||||
stmt_b = _stmt(fund_b.id, lp_b.id)
|
||||
session.add(stmt_b)
|
||||
session.commit()
|
||||
|
||||
_login(client, "fundadmin")
|
||||
rows = client.get("/api/capital-accounts").json()
|
||||
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||
|
||||
resp = client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-06-30",
|
||||
"commitment_dollars": 100, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 100, "distributions_dollars": 0,
|
||||
"ending_balance_dollars": 110,
|
||||
})
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
resp = client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_b.id, "investor_user_id": lp_b.id, "as_of_date": "2026-06-30",
|
||||
"commitment_dollars": 100, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 100, "distributions_dollars": 0,
|
||||
"ending_balance_dollars": 110,
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
assert client.delete(f"/api/capital-accounts/{stmt_b.id}").status_code == 403
|
||||
|
||||
|
||||
def test_administrator_deletes_only_investors_solely_in_their_funds(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
# lp_a also joins fund B — deleting them would reach beyond the Administrator's fence.
|
||||
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
|
||||
solo = make_user(session, username="solo", role=UserRole.investor, name="Solo LP")
|
||||
session.add(EntityAccess(user_id=solo.id, entity_id=fund_a.id))
|
||||
session.commit()
|
||||
|
||||
solo_id = solo.id
|
||||
_login(client, "fundadmin")
|
||||
assert client.delete(f"/api/users/{lp_a.id}").status_code == 403
|
||||
assert client.delete(f"/api/users/{approver.id}").status_code == 403
|
||||
assert client.delete(f"/api/users/{solo_id}").json() == {"status": "deleted"}
|
||||
session.expire_all() # the API deleted through its own session; drop our cached copy
|
||||
assert session.exec(select(User).where(User.id == solo_id)).first() is None
|
||||
assert session.exec(
|
||||
select(EntityAccess).where(EntityAccess.user_id == solo_id)
|
||||
).first() is None
|
||||
|
||||
|
||||
def test_internal_admin_delete_guards(auth_client, session, approver):
|
||||
service = make_user(session, username="svc", role=UserRole.operations,
|
||||
is_service_admin=True)
|
||||
victim = make_user(session, username="victim", role=UserRole.investor)
|
||||
|
||||
assert auth_client.delete(f"/api/users/{service.id}").status_code == 400
|
||||
assert auth_client.delete(f"/api/users/{approver.id}").status_code == 400 # self
|
||||
assert auth_client.delete(f"/api/users/{victim.id}").json() == {"status": "deleted"}
|
||||
|
||||
|
||||
def test_administrator_update_preserves_other_funds_grants(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
|
||||
session.commit()
|
||||
|
||||
_login(client, "fundadmin")
|
||||
# Submitting only in-scope grants must not strip the investor's fund B access.
|
||||
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": [fund_a.id]})
|
||||
assert resp.status_code == 200
|
||||
assert set(resp.json()["entity_ids"]) == {fund_a.id, fund_b.id}
|
||||
|
||||
# Revoking their own fund keeps fund B untouched.
|
||||
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": []})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["entity_ids"] == [fund_b.id]
|
||||
|
||||
|
||||
def test_administrator_blocked_from_internal_surfaces(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
assert client.get("/api/audit").status_code == 403
|
||||
assert client.post("/api/entities", json={"name": "New Fund", "type": "fund"}).status_code == 403
|
||||
# eNAV import into a fund outside their grants (or with no fund chosen) is refused.
|
||||
resp = client.post(
|
||||
"/api/import/schedule",
|
||||
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
resp = client.post(
|
||||
f"/api/import/schedule?entity_id={fund_b.id}",
|
||||
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_administrator_reads_rounds_and_holdings_in_scope(client, session, approver):
|
||||
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
|
||||
_login(client, "fundadmin")
|
||||
|
||||
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_b.id}/rounds").status_code == 403
|
||||
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_b.id}/holdings").status_code == 403
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Administrator (view only) role (0.2.45): reads everything on its granted entities,
|
||||
changes nothing anywhere."""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _login(client, username, password="password123"):
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={"login": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def _setup(session):
|
||||
fund_a = Entity(name="Fund A", type=EntityType.fund)
|
||||
fund_b = Entity(name="Fund B", type=EntityType.fund)
|
||||
session.add(fund_a)
|
||||
session.add(fund_b)
|
||||
session.commit()
|
||||
|
||||
viewer = make_user(session, username="viewonly", role=UserRole.administrator_viewer,
|
||||
name="Read Only Admin")
|
||||
session.add(EntityAccess(user_id=viewer.id, entity_id=fund_a.id))
|
||||
|
||||
lp = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||
session.add(EntityAccess(user_id=lp.id, entity_id=fund_a.id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=fund_a.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||
commitment_cents=100_00, beginning_balance_cents=0, contributions_cents=100_00,
|
||||
distributions_cents=0, ending_balance_cents=110_00,
|
||||
))
|
||||
session.commit()
|
||||
return fund_a, fund_b, viewer, lp
|
||||
|
||||
|
||||
def test_viewer_reads_only_their_fund(client, session, approver):
|
||||
fund_a, fund_b, viewer, lp = _setup(session)
|
||||
_login(client, "viewonly")
|
||||
|
||||
entities = client.get("/api/entities").json()
|
||||
assert [e["name"] for e in entities] == ["Fund A"]
|
||||
assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403
|
||||
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||
|
||||
# Sees every investor's statements within the fund, plus their names.
|
||||
rows = client.get("/api/capital-accounts").json()
|
||||
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||
users = client.get("/api/users").json()
|
||||
assert {u["username"] for u in users} == {"lp-a"}
|
||||
|
||||
docs = client.get("/api/documents").json()
|
||||
assert isinstance(docs, list)
|
||||
|
||||
|
||||
def test_viewer_cannot_change_anything(client, session, approver):
|
||||
fund_a, fund_b, viewer, lp = _setup(session)
|
||||
_login(client, "viewonly")
|
||||
|
||||
# Entity records
|
||||
assert client.patch(f"/api/entities/{fund_a.id}", json={"name": "X"}).status_code == 403
|
||||
assert client.put(
|
||||
f"/api/entities/{fund_a.id}/partners/{lp.id}/exited", json={"exited_on": "2026-06-30"}
|
||||
).status_code == 403
|
||||
assert client.delete(f"/api/entities/{fund_a.id}/partners").status_code == 403
|
||||
|
||||
# Documents
|
||||
resp = client.post(
|
||||
"/api/documents",
|
||||
data={"entity_id": str(fund_a.id), "category": "statement"},
|
||||
files={"file": ("x.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# Capital accounts
|
||||
assert client.post("/api/capital-accounts", json={
|
||||
"entity_id": fund_a.id, "investor_user_id": lp.id, "as_of_date": "2026-06-30",
|
||||
"commitment_dollars": 1, "beginning_balance_dollars": 0,
|
||||
"contributions_dollars": 1, "distributions_dollars": 0, "ending_balance_dollars": 1,
|
||||
}).status_code == 403
|
||||
|
||||
# Imports
|
||||
assert client.post(
|
||||
f"/api/import/schedule?entity_id={fund_a.id}",
|
||||
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||
).status_code == 403
|
||||
assert client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": str(fund_a.id)},
|
||||
files=[("files", ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream"))],
|
||||
).status_code == 403
|
||||
|
||||
# User management
|
||||
assert client.post("/api/users", json={
|
||||
"name": "N", "username": "n", "password": "secretpw",
|
||||
"role": "investor", "entity_ids": [fund_a.id],
|
||||
}).status_code == 403
|
||||
assert client.patch(f"/api/users/{lp.id}", json={"is_active": False}).status_code == 403
|
||||
assert client.delete(f"/api/users/{lp.id}").status_code == 403
|
||||
assert client.get("/api/users/access-matrix").status_code == 403
|
||||
|
||||
|
||||
def test_internal_admin_toggles_administrator_level(auth_client, session):
|
||||
admin_acct = make_user(session, username="mgr", role=UserRole.fund_administrator,
|
||||
name="Managing Admin")
|
||||
|
||||
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "administrator_viewer"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["role"] == "administrator_viewer"
|
||||
|
||||
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "fund_administrator"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "fund_administrator"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Auth: login works, and protected/writer endpoints reject the wrong caller."""
|
||||
|
||||
from tests.conftest import make_user
|
||||
from ten31portal.models import UserRole
|
||||
|
||||
|
||||
def test_login_success(client, approver):
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["username"] == "approver"
|
||||
|
||||
|
||||
def test_login_wrong_password(client, approver):
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"login": "approver", "password": "nope"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_me_requires_authentication(client):
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_me_returns_current_user(auth_client):
|
||||
resp = auth_client.get("/api/auth/me")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["role"] == "approver"
|
||||
|
||||
|
||||
def test_writer_endpoint_rejects_viewer(client, session):
|
||||
"""A viewer is authenticated but not a writer — create-entity must 403, not 401."""
|
||||
make_user(session, username="viewer", role=UserRole.viewer)
|
||||
login = client.post(
|
||||
"/api/auth/login", json={"login": "viewer", "password": "password123"}
|
||||
)
|
||||
assert login.status_code == 200
|
||||
resp = client.post("/api/entities", json={"name": "X", "type": "fund"})
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Login hardening: generic failure message, timing-safe unknown-user path, rate limiting,
|
||||
and the import size cap."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from ten31portal import storage
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def test_login_failure_is_generic_for_unknown_and_wrong_password(client, session):
|
||||
make_user(session, username="alice", password="correct-horse")
|
||||
|
||||
unknown = client.post("/api/auth/login", json={"login": "nobody", "password": "x"})
|
||||
wrong = client.post("/api/auth/login", json={"login": "alice", "password": "nope"})
|
||||
|
||||
assert unknown.status_code == 401
|
||||
assert wrong.status_code == 401
|
||||
# Same message either way, so it doesn't reveal whether the username exists.
|
||||
assert unknown.json()["detail"] == wrong.json()["detail"] == "Invalid username or password"
|
||||
|
||||
|
||||
def test_login_is_rate_limited_after_repeated_failures(client, session):
|
||||
make_user(session, username="bob", password="s3cret-pass")
|
||||
|
||||
for _ in range(10):
|
||||
r = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"})
|
||||
assert r.status_code == 401
|
||||
|
||||
blocked = client.post("/api/auth/login", json={"login": "bob", "password": "wrong"})
|
||||
assert blocked.status_code == 429
|
||||
assert "Retry-After" in blocked.headers
|
||||
# Even the correct password is refused while the source IP is throttled.
|
||||
correct = client.post("/api/auth/login", json={"login": "bob", "password": "s3cret-pass"})
|
||||
assert correct.status_code == 429
|
||||
|
||||
|
||||
def test_successful_login_clears_the_failure_counter(client, session):
|
||||
make_user(session, username="carol", password="right-pass")
|
||||
|
||||
for _ in range(9): # one shy of the limit
|
||||
assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401
|
||||
assert client.post("/api/auth/login", json={"login": "carol", "password": "right-pass"}).status_code == 200
|
||||
|
||||
# Counter reset — a fresh run of failures doesn't immediately trip the limit.
|
||||
assert client.post("/api/auth/login", json={"login": "carol", "password": "no"}).status_code == 401
|
||||
|
||||
|
||||
class _StubUpload:
|
||||
"""Minimal stand-in for UploadFile: read_capped only touches `.file.read`."""
|
||||
def __init__(self, data: bytes):
|
||||
self.file = io.BytesIO(data)
|
||||
|
||||
|
||||
def test_read_capped_enforces_the_limit():
|
||||
assert storage.read_capped(_StubUpload(b"x" * 100), limit=1000) == b"x" * 100
|
||||
with pytest.raises(storage.UploadTooLarge):
|
||||
storage.read_capped(_StubUpload(b"x" * 2000), limit=1000)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Batch historical capital backfill: several eNAV files → per-quarter statements, auto-matched."""
|
||||
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
|
||||
import openpyxl
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, User, UserRole
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _alloc_si_file(report: datetime, rows: list[dict]) -> bytes:
|
||||
"""Build a minimal eNAV workbook with an ALLOC SI roster and a report date in A1."""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "ALLOC SI"
|
||||
ws["A1"] = report # _enav_as_of reads the report date from the top-left cells
|
||||
header = ["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
|
||||
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"]
|
||||
ws.append([None] * 7) # row 2 spacer
|
||||
ws.append(header) # row 3 header
|
||||
for r in rows:
|
||||
ws.append([
|
||||
r.get("id"), "LP", r["name"],
|
||||
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"],
|
||||
])
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_batch_backfill_builds_history(auth_client, session):
|
||||
fund = Entity(name="LTPF III", type=EntityType.fund)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(fund)
|
||||
|
||||
# One member matched by fund-admin ID, one by name; both already exist.
|
||||
alice = make_user(session, username="alice", name="Alice Trust",
|
||||
role=UserRole.investor, external_investor_id="INV-100")
|
||||
make_user(session, username="bob", name="Bob Llc", role=UserRole.investor)
|
||||
|
||||
q3 = _alloc_si_file(datetime(2025, 9, 30), [
|
||||
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 420_000},
|
||||
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 205_000},
|
||||
{"id": "INV-999", "name": "Ghost Capital", "commit": 999, "contrib": 999, "ending": 999}, # no account → skipped
|
||||
])
|
||||
q4 = _alloc_si_file(datetime(2025, 12, 31), [
|
||||
{"id": "INV-100", "name": "Alice Trust", "commit": 1_000_000, "contrib": 400_000, "ending": 455_000},
|
||||
{"id": "INV-200", "name": "Bob LLC", "commit": 500_000, "contrib": 200_000, "ending": 210_000},
|
||||
])
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": fund.id},
|
||||
files=[
|
||||
("files", ("LTPF_III_Q3.xlsx", q3, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||
("files", ("LTPF_III_Q4.xlsx", q4, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")),
|
||||
],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
assert body["total_statements"] == 4 # 2 members × 2 quarters
|
||||
q3res = next(f for f in body["files"] if "Q3" in f["filename"])
|
||||
assert q3res["as_of_date"] == "2025-09-30"
|
||||
assert q3res["matched"] == 2
|
||||
assert q3res["skipped"] == ["Ghost Capital"] # unknown member reported, not created
|
||||
|
||||
# No account was created for the unknown roster name.
|
||||
assert session.exec(select(User).where(User.name == "Ghost Capital")).first() is None
|
||||
|
||||
# Alice now has two statements — a real trend line — and the latest (Q4) is intact.
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement)
|
||||
.where(CapitalAccountStatement.investor_user_id == alice.id)
|
||||
.order_by(CapitalAccountStatement.as_of_date) # type: ignore[arg-type]
|
||||
).all()
|
||||
assert [s.as_of_date for s in stmts] == [date(2025, 9, 30), date(2025, 12, 31)]
|
||||
assert [s.ending_balance_cents for s in stmts] == [42_000_000, 45_500_000]
|
||||
|
||||
|
||||
def test_batch_reimport_updates_in_place(auth_client, session):
|
||||
fund = Entity(name="LTPF IV", type=EntityType.fund)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(fund)
|
||||
carol = make_user(session, username="carol", name="Carol Ira", role=UserRole.investor)
|
||||
|
||||
def one_file(ending: int) -> bytes:
|
||||
return _alloc_si_file(datetime(2025, 12, 31),
|
||||
[{"id": "INV-1", "name": "Carol Ira", "ending": ending}])
|
||||
|
||||
for ending in (300_000, 315_000): # corrected figure re-imported at the same quarter
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": fund.id},
|
||||
files=[("files", ("f.xlsx", one_file(ending), "application/octet-stream"))],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
stmts = session.exec(
|
||||
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == carol.id)
|
||||
).all()
|
||||
assert len(stmts) == 1 # upsert, not a duplicate
|
||||
assert stmts[0].ending_balance_cents == 31_500_000
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Clearing a fund's partners removes only that fund's statements + access grants,
|
||||
never the investor accounts (which may belong to other funds)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
|
||||
|
||||
def _make_fund(session, name):
|
||||
e = Entity(name=name, type=EntityType.fund)
|
||||
session.add(e)
|
||||
session.commit()
|
||||
session.refresh(e)
|
||||
return e
|
||||
|
||||
|
||||
def _add_partner(session, entity_id, user_id, ending):
|
||||
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
|
||||
commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
|
||||
distributions_cents=0, ending_balance_cents=ending,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_clear_partners_scoped_to_one_fund(auth_client, session):
|
||||
from tests.conftest import make_user
|
||||
|
||||
fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
|
||||
fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
|
||||
lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
|
||||
# Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
|
||||
_add_partner(session, fund2.id, lp.id, 1_000_00)
|
||||
_add_partner(session, fund3.id, lp.id, 2_000_00)
|
||||
|
||||
resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"statements": 1, "access_grants": 1}
|
||||
|
||||
# Fund III is wiped of partners...
|
||||
assert session.exec(
|
||||
CapitalAccountStatement.__table__.select().where(
|
||||
CapitalAccountStatement.entity_id == fund3.id
|
||||
)
|
||||
).first() is None
|
||||
assert session.exec(
|
||||
EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
|
||||
).first() is None
|
||||
assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
|
||||
|
||||
# ...but Fund II keeps its partner, and the investor account still exists.
|
||||
assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
|
||||
assert session.get(User, lp.id) is not None
|
||||
|
||||
|
||||
def test_clear_partners_missing_entity_404(auth_client):
|
||||
assert auth_client.delete("/api/entities/99999/partners").status_code == 404
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Entity CRUD round-trips through the API as a writer."""
|
||||
|
||||
|
||||
def test_entity_crud_roundtrip(auth_client):
|
||||
# Create
|
||||
created = auth_client.post(
|
||||
"/api/entities",
|
||||
json={"name": "Ten31 Fund I", "type": "fund", "vintage_year": 2025},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
entity_id = created.json()["id"]
|
||||
|
||||
# Read (single)
|
||||
got = auth_client.get(f"/api/entities/{entity_id}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["name"] == "Ten31 Fund I"
|
||||
|
||||
# List
|
||||
listed = auth_client.get("/api/entities")
|
||||
assert listed.status_code == 200
|
||||
assert any(e["id"] == entity_id for e in listed.json())
|
||||
|
||||
# Update
|
||||
patched = auth_client.patch(
|
||||
f"/api/entities/{entity_id}", json={"name": "Ten31 Fund I, LP"}
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["name"] == "Ten31 Fund I, LP"
|
||||
|
||||
|
||||
def test_get_missing_entity_404(auth_client):
|
||||
assert auth_client.get("/api/entities/99999").status_code == 404
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Linking a GP entity to its investor account (so Assets can pull real balances)."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from tests.conftest import make_user
|
||||
from ten31portal.models import CapitalAccountStatement, Entity, EntityType, UserRole
|
||||
|
||||
|
||||
def test_link_entity_to_investor(auth_client, session):
|
||||
inv = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
|
||||
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||
session.add(gp)
|
||||
session.commit()
|
||||
session.refresh(gp)
|
||||
|
||||
linked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": inv.id})
|
||||
assert linked.status_code == 200, linked.text
|
||||
assert linked.json()["linked_user_id"] == inv.id
|
||||
|
||||
# Unlink.
|
||||
unlinked = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": None})
|
||||
assert unlinked.status_code == 200
|
||||
assert unlinked.json()["linked_user_id"] is None
|
||||
|
||||
|
||||
def test_link_rejects_non_investor(auth_client, session):
|
||||
staff = make_user(session, username="ops2", role=UserRole.operations)
|
||||
gp = Entity(name="Mgmt Co", type=EntityType.mgmt_co)
|
||||
session.add(gp)
|
||||
session.commit()
|
||||
session.refresh(gp)
|
||||
resp = auth_client.patch(f"/api/entities/{gp.id}", json={"linked_user_id": staff.id})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_asset_balances_include_household(auth_client, session):
|
||||
"""Balances under linked (household) names of the linked account are included."""
|
||||
primary = make_user(session, username="ten31llc", role=UserRole.investor, name="Ten31 LLC")
|
||||
secondary = make_user(
|
||||
session, username="ten31llc_trust", role=UserRole.investor,
|
||||
name="Ten31 LLC Trust", primary_account_id=primary.id,
|
||||
)
|
||||
f1 = Entity(name="LTPF I", type=EntityType.fund)
|
||||
f2 = Entity(name="LTPF II", type=EntityType.fund)
|
||||
gp = Entity(name="Ten31 LLC", type=EntityType.gp, linked_user_id=primary.id)
|
||||
session.add_all([f1, f2, gp])
|
||||
session.commit()
|
||||
for x in (f1, f2, gp):
|
||||
session.refresh(x)
|
||||
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=f1.id, investor_user_id=primary.id, as_of_date=date(2026, 3, 31),
|
||||
ending_balance_cents=600_000,
|
||||
))
|
||||
# This balance sits under the linked secondary name, not the primary.
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=f2.id, investor_user_id=secondary.id, as_of_date=date(2026, 3, 31),
|
||||
ending_balance_cents=400_000,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["linked_name"] == "Ten31 LLC"
|
||||
assert {b["entity_id"] for b in body["balances"]} == {f1.id, f2.id}
|
||||
|
||||
|
||||
def test_asset_balances_unlinked_is_empty(auth_client, session):
|
||||
gp = Entity(name="Mgmt", type=EntityType.mgmt_co)
|
||||
session.add(gp)
|
||||
session.commit()
|
||||
session.refresh(gp)
|
||||
resp = auth_client.get(f"/api/entities/{gp.id}/asset-balances")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["balances"] == []
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Exited positions (0.2.33): a member who sold/transferred their stake shows as exited
|
||||
instead of a phantom -100% loss, and drops out of committed totals."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _setup_fund(session, *, commitment=4_000_000_00, balance=0):
|
||||
entity = Entity(name="Pawn Fund", type=EntityType.spv)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
lp = make_user(session, username="seller", role=UserRole.investor, name="Seller 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=commitment, beginning_balance_cents=0,
|
||||
contributions_cents=commitment, distributions_cents=0,
|
||||
ending_balance_cents=balance,
|
||||
))
|
||||
session.commit()
|
||||
return entity, lp
|
||||
|
||||
|
||||
def test_mark_exited_flows_to_partners_and_statements(auth_client, session):
|
||||
entity, lp = _setup_fund(session)
|
||||
|
||||
resp = auth_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
|
||||
json={"exited_on": "2026-05-15"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["exited_on"] == "2026-05-15"
|
||||
|
||||
partners = auth_client.get(f"/api/entities/{entity.id}/partners").json()
|
||||
assert partners[0]["exited_on"] == "2026-05-15"
|
||||
|
||||
# The LP's own capital-account view carries the exit date.
|
||||
lp_client = auth_client
|
||||
lp_client.post("/api/auth/logout")
|
||||
assert lp_client.post(
|
||||
"/api/auth/login", json={"login": "seller", "password": "password123"}
|
||||
).status_code == 200
|
||||
accounts = lp_client.get("/api/capital-accounts").json()
|
||||
assert accounts[0]["exited_on"] == "2026-05-15"
|
||||
|
||||
# Undo clears it.
|
||||
lp_client.post("/api/auth/logout")
|
||||
assert lp_client.post(
|
||||
"/api/auth/login", json={"login": "approver", "password": "password123"}
|
||||
).status_code == 200
|
||||
resp = lp_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{lp.id}/exited", json={"exited_on": None}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["exited_on"] is None
|
||||
|
||||
|
||||
def test_investor_view_mirrors_exit(auth_client, session):
|
||||
"""The admin's read-only Investor View must carry exited_on exactly like the LP's own
|
||||
portal — it was missing there (the bug behind the 'active card despite exit' report)."""
|
||||
entity, lp = _setup_fund(session)
|
||||
assert auth_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{lp.id}/exited",
|
||||
json={"exited_on": "2026-05-15"},
|
||||
).status_code == 200
|
||||
|
||||
view = auth_client.get(f"/api/users/{lp.id}/investor-view").json()
|
||||
assert view["capital_accounts"], "expected the LP's statements in the view"
|
||||
assert all(c["exited_on"] == "2026-05-15" for c in view["capital_accounts"])
|
||||
|
||||
|
||||
def test_exited_member_excluded_from_rollup_committed(auth_client, session):
|
||||
entity, seller = _setup_fund(session)
|
||||
# The buyer joins the roster with the same commitment (they bought the stake).
|
||||
buyer = make_user(session, username="buyer", role=UserRole.investor, name="Buyer LP")
|
||||
session.add(EntityAccess(user_id=buyer.id, entity_id=entity.id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity.id, investor_user_id=buyer.id, as_of_date=date(2026, 6, 30),
|
||||
commitment_cents=4_000_000_00, beginning_balance_cents=0,
|
||||
contributions_cents=4_000_000_00, distributions_cents=0,
|
||||
ending_balance_cents=4_200_000_00,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
# Before the exit both commitments count — the double-count problem.
|
||||
rollup = auth_client.get("/api/entities/rollup").json()
|
||||
row = next(r for r in rollup if r["id"] == entity.id)
|
||||
assert row["committed_cents"] == 8_000_000_00
|
||||
|
||||
assert auth_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{seller.id}/exited",
|
||||
json={"exited_on": "2026-05-15"},
|
||||
).status_code == 200
|
||||
|
||||
rollup = auth_client.get("/api/entities/rollup").json()
|
||||
row = next(r for r in rollup if r["id"] == entity.id)
|
||||
assert row["committed_cents"] == 4_000_000_00
|
||||
|
||||
|
||||
def test_exited_without_access_row(auth_client, session):
|
||||
"""A manually-entered investor may have statements but no access grant yet — marking
|
||||
them exited from the Capital Accounts screen creates the roster row with the flag set.
|
||||
Clearing an exit that was never set stays a 404."""
|
||||
entity, _ = _setup_fund(session)
|
||||
manual = make_user(session, username="manual-lp", role=UserRole.investor)
|
||||
|
||||
resp = auth_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{manual.id}/exited",
|
||||
json={"exited_on": None},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
resp = auth_client.put(
|
||||
f"/api/entities/{entity.id}/partners/{manual.id}/exited",
|
||||
json={"exited_on": "2026-05-15"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["exited_on"] == "2026-05-15"
|
||||
access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.entity_id == entity.id, EntityAccess.user_id == manual.id
|
||||
)
|
||||
).one()
|
||||
assert str(access.exited_on) == "2026-05-15"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""NAV history backfill (0.2.43): old eNAV files add past quarters to valuation history
|
||||
WITHOUT touching current holdings — no cost-basis regression, no resurrected positions."""
|
||||
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
|
||||
import openpyxl
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal.models import (
|
||||
Entity, EntityType, Holding, Position, RoundStatus, Valuation, ValuationRound,
|
||||
)
|
||||
|
||||
|
||||
def _enav_file(report: datetime, hld_rows: list[tuple], alloc_rows: list[dict] | None = None) -> bytes:
|
||||
"""Minimal eNAV workbook: an HLD sheet (and optionally an ALLOC SI roster).
|
||||
|
||||
hld_rows: (security_name, quantity, cost, value) tuples.
|
||||
"""
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "HLD"
|
||||
ws["A1"] = report # _enav_as_of scans the top-left cells for the report date
|
||||
ws.append([None])
|
||||
ws.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
|
||||
for name, qty, cost, value in hld_rows:
|
||||
ws.append([name, qty, cost, value])
|
||||
if alloc_rows is not None:
|
||||
alloc = wb.create_sheet("ALLOC SI")
|
||||
alloc.append(["INVESTOR ID", "INVESTOR TYPE", "INVESTOR NAME",
|
||||
"COMMITTED CAPITAL", "CONTRIBUTIONS", "(DISTRIBUTIONS)", "ENDING BALANCE"])
|
||||
for r in alloc_rows:
|
||||
alloc.append([r.get("id"), "LP", r["name"],
|
||||
r.get("commit", 0), r.get("contrib", 0), r.get("distrib", 0), r["ending"]])
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _fund_with_current_book(session):
|
||||
"""A fund holding one position (cost $100) with its NAV already signed for Q1 2026."""
|
||||
fund = Entity(name="LTPF I", type=EntityType.fund)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(fund)
|
||||
holding = Holding(entity_id=fund.id, company_name="Acme")
|
||||
session.add(holding)
|
||||
session.commit()
|
||||
pos = Position(holding_id=holding.id, security_name="Acme - Series A",
|
||||
investment_date=date(2023, 1, 1), cost_cents=100_00)
|
||||
session.add(pos)
|
||||
session.commit()
|
||||
session.refresh(pos)
|
||||
rnd = ValuationRound(entity_id=fund.id, quarter_end=date(2026, 3, 31),
|
||||
status=RoundStatus.approved, is_seed=True)
|
||||
session.add(rnd)
|
||||
session.commit()
|
||||
session.refresh(rnd)
|
||||
session.add(Valuation(round_id=rnd.id, position_id=pos.id, value_cents=900_00))
|
||||
session.commit()
|
||||
return fund, pos
|
||||
|
||||
|
||||
def test_old_file_becomes_history_round_without_touching_book(auth_client, session):
|
||||
fund, pos = _fund_with_current_book(session)
|
||||
old = _enav_file(datetime(2025, 9, 30), [
|
||||
("Acme - Series A", 10, 123.0, 555.0), # matches today's book
|
||||
("Ghost - SAFE", 5, 999.0, 999.0), # sold since; must NOT be created
|
||||
])
|
||||
|
||||
resp = auth_client.post(
|
||||
f"/api/import/schedule?entity_id={fund.id}&commit=true",
|
||||
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["history_only"] is True
|
||||
assert body["status"] == "added"
|
||||
assert body["matched"] == 1 and body["unmatched"] == 1
|
||||
|
||||
session.expire_all()
|
||||
# The quarter landed in valuation history with the matched row's value only.
|
||||
hist_round = session.exec(select(ValuationRound).where(
|
||||
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
|
||||
)).first()
|
||||
assert hist_round is not None
|
||||
vals = session.exec(select(Valuation).where(Valuation.round_id == hist_round.id)).all()
|
||||
assert [(v.position_id, v.value_cents) for v in vals] == [(pos.id, 555_00)]
|
||||
# Today's book is untouched: cost basis kept, no ghost position resurrected.
|
||||
assert session.get(Position, pos.id).cost_cents == 100_00
|
||||
assert session.exec(select(Holding).where(Holding.company_name == "Ghost")).first() is None
|
||||
|
||||
|
||||
def test_signed_round_is_kept(auth_client, session):
|
||||
fund, pos = _fund_with_current_book(session)
|
||||
signed = ValuationRound(entity_id=fund.id, quarter_end=date(2025, 9, 30),
|
||||
status=RoundStatus.approved, is_seed=False)
|
||||
session.add(signed)
|
||||
session.commit()
|
||||
old = _enav_file(datetime(2025, 9, 30), [("Acme - Series A", 10, 123.0, 555.0)])
|
||||
|
||||
resp = auth_client.post(
|
||||
f"/api/import/schedule?entity_id={fund.id}&commit=true",
|
||||
files={"file": ("old.xlsx", io.BytesIO(old), "application/octet-stream")},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "kept-signed"
|
||||
session.expire_all()
|
||||
assert session.exec(select(Valuation).join(
|
||||
ValuationRound, Valuation.round_id == ValuationRound.id
|
||||
).where(ValuationRound.quarter_end == date(2025, 9, 30))).all() == []
|
||||
|
||||
|
||||
def test_batch_backfill_also_records_nav(auth_client, session):
|
||||
from tests.conftest import make_user
|
||||
from ten31portal.models import CapitalAccountStatement, EntityAccess, UserRole
|
||||
|
||||
fund, pos = _fund_with_current_book(session)
|
||||
lp = make_user(session, username="lp", name="Alice Trust", role=UserRole.investor)
|
||||
session.add(EntityAccess(user_id=lp.id, entity_id=fund.id))
|
||||
session.commit()
|
||||
|
||||
old = _enav_file(
|
||||
datetime(2025, 9, 30),
|
||||
[("Acme - Series A", 10, 123.0, 555.0), ("Ghost - SAFE", 5, 999.0, 999.0)],
|
||||
alloc_rows=[{"id": "INV-1", "name": "Alice Trust", "commit": 1000, "contrib": 400, "ending": 420}],
|
||||
)
|
||||
|
||||
for expected_status in ("added", "updated"): # second pass proves idempotency
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/batch",
|
||||
data={"entity_id": str(fund.id)},
|
||||
files=[("files", ("old.xlsx", io.BytesIO(old), "application/octet-stream"))],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
f = resp.json()["files"][0]
|
||||
assert f["error"] is None and f["skipped"] == []
|
||||
assert f["statements_written"] == 1
|
||||
assert f["nav_status"] == expected_status
|
||||
assert f["nav_matched"] == 1 and f["nav_unmatched"] == 1
|
||||
assert f["nav_cents"] == 555_00
|
||||
|
||||
session.expire_all()
|
||||
stmts = session.exec(select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == fund.id
|
||||
)).all()
|
||||
assert len(stmts) == 1 # upserted, not duplicated
|
||||
rounds = session.exec(select(ValuationRound).where(
|
||||
ValuationRound.entity_id == fund.id, ValuationRound.quarter_end == date(2025, 9, 30)
|
||||
)).all()
|
||||
assert len(rounds) == 1
|
||||
assert session.get(Position, pos.id).cost_cents == 100_00
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Admin Investor View reconstructs what one investor sees, read-only."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from tests.conftest import make_user
|
||||
from ten31portal.models import (
|
||||
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||
)
|
||||
|
||||
|
||||
def test_investor_view_reconstructs(auth_client, session):
|
||||
inv = make_user(session, username="lp1", role=UserRole.investor)
|
||||
ent = Entity(name="LTPF I", type=EntityType.fund)
|
||||
other = Entity(name="Not Theirs", type=EntityType.fund)
|
||||
session.add(ent)
|
||||
session.add(other)
|
||||
session.commit()
|
||||
session.refresh(ent)
|
||||
session.refresh(other)
|
||||
|
||||
session.add(EntityAccess(user_id=inv.id, entity_id=ent.id))
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=ent.id, investor_user_id=inv.id, as_of_date=date(2026, 3, 31),
|
||||
commitment_cents=500_000, ending_balance_cents=600_000,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
resp = auth_client.get(f"/api/users/{inv.id}/investor-view")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["user"]["username"] == "lp1"
|
||||
# Only the granted entity is visible, not the other one.
|
||||
assert [e["id"] for e in body["entities"]] == [ent.id]
|
||||
assert len(body["capital_accounts"]) == 1
|
||||
assert body["capital_accounts"][0]["ending_balance_cents"] == 600_000
|
||||
|
||||
|
||||
def test_investor_view_rejects_non_investor(auth_client, session):
|
||||
staff = make_user(session, username="ops", role=UserRole.operations)
|
||||
assert auth_client.get(f"/api/users/{staff.id}/investor-view").status_code == 400
|
||||
@@ -0,0 +1,134 @@
|
||||
"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
|
||||
enable-investor-logins conversion, and the documents "New" badge watermark."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ten31portal import config
|
||||
from ten31portal.auth import hash_password, verify_password
|
||||
from ten31portal.models import (
|
||||
Document, Entity, EntityAccess, EntityType, User, UserRole,
|
||||
)
|
||||
from tests.conftest import make_user
|
||||
|
||||
|
||||
def _login(client, username, password):
|
||||
return client.post("/api/auth/login", json={"login": username, "password": password})
|
||||
|
||||
|
||||
def test_change_password_requires_eight_chars(auth_client):
|
||||
resp = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "short7c"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "8 characters" in resp.json()["detail"]
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "longenough8"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_import_created_member_gets_default_password(auth_client, session):
|
||||
entity = Entity(name="Test Fund", type=EntityType.fund)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
|
||||
resp = auth_client.post(
|
||||
"/api/import/capital-accounts/commit",
|
||||
json={
|
||||
"entity_id": entity.id,
|
||||
"as_of_date": "2026-03-31",
|
||||
"investors": [{
|
||||
"action": "create",
|
||||
"name": "New LP",
|
||||
"username": "newlp",
|
||||
"value_dollars": 100_000,
|
||||
}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
user = session.exec(select(User).where(User.username == "newlp")).one()
|
||||
assert user.login_enabled is True
|
||||
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
|
||||
|
||||
# And the account can actually sign in with it.
|
||||
resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def test_enable_investor_logins_converts_only_no_login_accounts(session):
|
||||
from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
|
||||
|
||||
no_login = make_user(session, username="dormant", role=UserRole.investor,
|
||||
login_enabled=False, name="Dormant LP")
|
||||
has_login = make_user(session, username="active-lp", role=UserRole.investor,
|
||||
password="theirownpw", name="Active LP")
|
||||
linked = make_user(session, username="linked", role=UserRole.investor,
|
||||
login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
|
||||
old_active_hash = has_login.password_hash
|
||||
|
||||
# Run the conversion against the test engine (CLI normally uses the real one).
|
||||
import ten31portal.cli as cli_mod
|
||||
orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
|
||||
cli_mod.engine = session.get_bind()
|
||||
cli_mod.run_migrations = lambda: None
|
||||
try:
|
||||
enable_investor_logins(None)
|
||||
finally:
|
||||
cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
|
||||
|
||||
session.refresh(no_login)
|
||||
session.refresh(has_login)
|
||||
session.refresh(linked)
|
||||
assert no_login.login_enabled is True
|
||||
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
|
||||
# Working logins and linked secondary names are untouched.
|
||||
assert has_login.password_hash == old_active_hash
|
||||
assert linked.login_enabled is False
|
||||
|
||||
|
||||
def test_documents_new_badge(client, session):
|
||||
entity = Entity(name="Badge Fund", type=EntityType.fund)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
|
||||
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
|
||||
|
||||
old_doc = Document(
|
||||
entity_id=entity.id, category="statement", title="Old statement",
|
||||
original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
|
||||
storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
|
||||
)
|
||||
session.add(old_doc)
|
||||
session.commit()
|
||||
|
||||
assert _login(client, "lp", "password123").status_code == 200
|
||||
|
||||
# First ever visit: nothing badged (no watermark yet), watermark gets set.
|
||||
docs = client.get("/api/documents").json()
|
||||
assert [d["is_new"] for d in docs] == [False]
|
||||
|
||||
# A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
|
||||
session.refresh(lp)
|
||||
lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
|
||||
session.add(lp)
|
||||
new_doc = Document(
|
||||
entity_id=entity.id, category="k1", title="Fresh K-1",
|
||||
original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
|
||||
storage_path="x-new",
|
||||
)
|
||||
session.add(new_doc)
|
||||
session.commit()
|
||||
|
||||
docs = client.get("/api/documents").json()
|
||||
flags = {d["title"]: d["is_new"] for d in docs}
|
||||
assert flags == {"Fresh K-1": True, "Old statement": False}
|
||||
|
||||
# Within the same visit (watermark just advanced) the badge computation stays stable.
|
||||
docs = client.get("/api/documents").json()
|
||||
assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs
|
||||
@@ -0,0 +1,48 @@
|
||||
"""The entity rollup aggregates invested cost and the latest signed valuation correctly."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from ten31portal.models import (
|
||||
Entity, EntityType, Holding, Position, RoundStatus, Valuation, ValuationRound,
|
||||
)
|
||||
|
||||
|
||||
def test_rollup_sums_cost_and_latest_signed_value(auth_client, session):
|
||||
entity = Entity(name="Rollup Fund", type=EntityType.fund)
|
||||
session.add(entity)
|
||||
session.commit()
|
||||
session.refresh(entity)
|
||||
|
||||
holding = Holding(entity_id=entity.id, company_name="Acme")
|
||||
session.add(holding)
|
||||
session.commit()
|
||||
session.refresh(holding)
|
||||
|
||||
position = Position(
|
||||
holding_id=holding.id,
|
||||
security_name="Acme Series A",
|
||||
investment_date=date(2025, 1, 15),
|
||||
cost_cents=100_000,
|
||||
)
|
||||
session.add(position)
|
||||
session.commit()
|
||||
session.refresh(position)
|
||||
|
||||
rnd = ValuationRound(
|
||||
entity_id=entity.id,
|
||||
quarter_end=date(2026, 3, 31),
|
||||
status=RoundStatus.approved,
|
||||
)
|
||||
session.add(rnd)
|
||||
session.commit()
|
||||
session.refresh(rnd)
|
||||
|
||||
session.add(Valuation(round_id=rnd.id, position_id=position.id, value_cents=150_000))
|
||||
session.commit()
|
||||
|
||||
resp = auth_client.get("/api/entities/rollup")
|
||||
assert resp.status_code == 200, resp.text
|
||||
row = next(r for r in resp.json() if r["id"] == entity.id)
|
||||
assert row["invested_cents"] == 100_000
|
||||
assert row["last_signed_value_cents"] == 150_000
|
||||
assert row["committed_cents"] == 0 # no capital-account statements added
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Entity stakes: a GP entity's interest in the funds it manages."""
|
||||
|
||||
from ten31portal.models import Entity, EntityType
|
||||
|
||||
|
||||
def test_stake_crud(auth_client, session):
|
||||
gp = Entity(name="Ten31 LLC", type=EntityType.gp)
|
||||
fund = Entity(name="LTPF I", type=EntityType.fund)
|
||||
session.add(gp)
|
||||
session.add(fund)
|
||||
session.commit()
|
||||
session.refresh(gp)
|
||||
session.refresh(fund)
|
||||
|
||||
created = auth_client.post(
|
||||
f"/api/entities/{gp.id}/stakes",
|
||||
json={"fund_entity_id": fund.id, "ownership_pct": 20, "value_dollars": 1000},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["fund_name"] == "LTPF I"
|
||||
assert body["fund_type"] == "fund"
|
||||
assert body["value_cents"] == 100_000 # dollars -> cents
|
||||
stake_id = body["id"]
|
||||
|
||||
listed = auth_client.get(f"/api/entities/{gp.id}/stakes")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
# An entity cannot hold a stake in itself.
|
||||
assert auth_client.post(
|
||||
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": gp.id}
|
||||
).status_code == 400
|
||||
|
||||
# Duplicate stake rejected.
|
||||
assert auth_client.post(
|
||||
f"/api/entities/{gp.id}/stakes", json={"fund_entity_id": fund.id}
|
||||
).status_code == 409
|
||||
|
||||
assert auth_client.delete(f"/api/entities/{gp.id}/stakes/{stake_id}").status_code == 200
|
||||
assert auth_client.get(f"/api/entities/{gp.id}/stakes").json() == []
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression test for the SPA static-file path-traversal fix.
|
||||
|
||||
Before the fix, the `/{path:path}` catch-all joined the request path onto the
|
||||
static dir with no containment check, so percent-encoded traversal
|
||||
(GET /..%2f..%2fdata%2fportal.db) read arbitrary files off disk — including the
|
||||
session secret, which allowed forging an admin session. See main.py.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ten31portal.main import _contained_static_path
|
||||
|
||||
|
||||
def test_contained_helper_blocks_traversal():
|
||||
root = Path(tempfile.mkdtemp())
|
||||
static = root / "static"
|
||||
static.mkdir()
|
||||
(static / "index.html").write_text("spa")
|
||||
(static / "app.js").write_text("ok")
|
||||
data = root / "data"
|
||||
data.mkdir()
|
||||
(data / "portal.db").write_text("secret-db")
|
||||
|
||||
# Legit assets resolve within the root.
|
||||
assert _contained_static_path(static, "app.js") == (static / "app.js").resolve()
|
||||
assert _contained_static_path(static, "index.html") == (static / "index.html").resolve()
|
||||
|
||||
# Traversal (already-decoded, i.e. what ..%2f becomes) escapes -> None.
|
||||
for evil in ("../data/portal.db", "../../data/portal.db", "../data/../data/portal.db"):
|
||||
assert _contained_static_path(static, evil) is None, evil
|
||||
|
||||
|
||||
def test_spa_route_does_not_leak_via_encoded_traversal():
|
||||
"""End-to-end: encoded traversal against the real route shape returns the
|
||||
SPA shell, never the out-of-root file."""
|
||||
root = Path(tempfile.mkdtemp())
|
||||
static = root / "static"
|
||||
static.mkdir()
|
||||
(static / "index.html").write_text("<html>SPA</html>")
|
||||
data = root / "data"
|
||||
data.mkdir()
|
||||
(data / ".session-secret").write_text("TOPSECRET")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
def _index():
|
||||
return FileResponse(static / "index.html")
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "HEAD"])
|
||||
async def serve_spa(path: str):
|
||||
file = _contained_static_path(static, path)
|
||||
if file is None or not file.is_file():
|
||||
return _index()
|
||||
return FileResponse(file)
|
||||
|
||||
client = TestClient(app)
|
||||
for attack in (
|
||||
"/..%2f..%2fdata%2f.session-secret",
|
||||
"/%2e%2e%2f%2e%2e%2fdata%2f.session-secret",
|
||||
"/../../data/.session-secret",
|
||||
):
|
||||
r = client.get(attack)
|
||||
assert "TOPSECRET" not in r.text, attack
|
||||
@@ -0,0 +1,123 @@
|
||||
"""TOTP two-factor: enrollment, two-step login, recovery codes, disable, CLI-style reset."""
|
||||
|
||||
import pyotp
|
||||
|
||||
from tests.conftest import make_user
|
||||
from ten31portal.models import User, UserRole
|
||||
|
||||
|
||||
def _enroll(client):
|
||||
"""Run the full setup+confirm flow for the signed-in user; return (secret, recovery_codes)."""
|
||||
setup = client.post("/api/auth/totp/setup")
|
||||
assert setup.status_code == 200, setup.text
|
||||
secret = setup.json()["secret"]
|
||||
assert setup.json()["qr_svg"].lstrip().startswith("<?xml") or "<svg" in setup.json()["qr_svg"]
|
||||
confirm = client.post(
|
||||
"/api/auth/totp/confirm", json={"code": pyotp.TOTP(secret).now()}
|
||||
)
|
||||
assert confirm.status_code == 200, confirm.text
|
||||
codes = confirm.json()["recovery_codes"]
|
||||
assert len(codes) == 8
|
||||
return secret, codes
|
||||
|
||||
|
||||
def test_enroll_then_login_requires_code(client, session):
|
||||
make_user(session, username="mp", role=UserRole.approver)
|
||||
assert client.post("/api/auth/login", json={"login": "mp", "password": "password123"}).status_code == 200
|
||||
secret, _ = _enroll(client)
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
# Password alone no longer signs in — it parks the session pending the code.
|
||||
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"requires_2fa": True}
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
# Wrong code is rejected; the right code completes sign-in.
|
||||
bad = client.post("/api/auth/login/verify-totp", json={"code": "000000"})
|
||||
assert bad.status_code == 401
|
||||
good = client.post(
|
||||
"/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()}
|
||||
)
|
||||
assert good.status_code == 200, good.text
|
||||
assert good.json()["username"] == "mp"
|
||||
assert good.json()["totp_enabled"] is True
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
|
||||
|
||||
def test_recovery_code_works_once(client, session):
|
||||
make_user(session, username="mp", role=UserRole.approver)
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
_, codes = _enroll(client)
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
first = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
|
||||
assert first.status_code == 200, first.text
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
# The same recovery code is spent and cannot be used again.
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
again = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
|
||||
assert again.status_code == 401
|
||||
other = client.post("/api/auth/login/verify-totp", json={"code": codes[1]})
|
||||
assert other.status_code == 200
|
||||
|
||||
|
||||
def test_verify_without_pending_login_fails(client, session):
|
||||
make_user(session, username="mp", role=UserRole.approver)
|
||||
resp = client.post("/api/auth/login/verify-totp", json={"code": "123456"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_confirm_requires_valid_first_code(client, session):
|
||||
make_user(session, username="mp", role=UserRole.approver)
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
setup = client.post("/api/auth/totp/setup")
|
||||
assert setup.status_code == 200
|
||||
bad = client.post("/api/auth/totp/confirm", json={"code": "000000"})
|
||||
assert bad.status_code == 400
|
||||
# Enrollment never completed, so login stays single-step.
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "mp"
|
||||
|
||||
|
||||
def test_disable_restores_single_step_login(client, session):
|
||||
make_user(session, username="mp", role=UserRole.approver)
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
secret, _ = _enroll(client)
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
client.post("/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()})
|
||||
|
||||
wrong = client.post("/api/auth/totp/disable", json={"password": "not-it"})
|
||||
assert wrong.status_code == 400
|
||||
ok = client.post("/api/auth/totp/disable", json={"password": "password123"})
|
||||
assert ok.status_code == 200
|
||||
client.post("/api/auth/logout")
|
||||
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "mp"
|
||||
|
||||
|
||||
def test_admin_style_reset_clears_enrollment(client, session):
|
||||
"""Clearing the totp fields (what the reset-2fa CLI does) restores password-only login."""
|
||||
user = make_user(session, username="mp", role=UserRole.approver)
|
||||
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
_enroll(client)
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
db_user = session.get(User, user.id)
|
||||
session.refresh(db_user)
|
||||
assert db_user.totp_enabled is True
|
||||
db_user.totp_secret = None
|
||||
db_user.totp_enabled = False
|
||||
db_user.totp_recovery_codes = None
|
||||
session.add(db_user)
|
||||
session.commit()
|
||||
|
||||
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["username"] == "mp"
|
||||
+11
-2
@@ -1,7 +1,10 @@
|
||||
# Build context is the repo root (docker build -f deploy/Dockerfile .)
|
||||
|
||||
# --- Frontend build stage ---
|
||||
FROM node:20-slim AS frontend-build
|
||||
# Build on the native builder arch ($BUILDPLATFORM) so the JS bundler runs without
|
||||
# cross-arch emulation. The output is static assets (HTML/CSS/JS), which are
|
||||
# architecture-independent and copied into the target-arch runtime stage below.
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS frontend-build
|
||||
|
||||
WORKDIR /build
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
@@ -31,8 +34,14 @@ RUN chmod +x ./start.sh
|
||||
# Data volume mount point
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Unprivileged account for the server process. The container still starts as root (see start.sh)
|
||||
# so it can chown the platform-mounted /data volume, then drops to this uid via setpriv.
|
||||
RUN groupadd --gid 10001 appuser \
|
||||
&& useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin appuser
|
||||
|
||||
ENV TEN31_DB_PATH=/data/portal.db
|
||||
ENV TEN31_SESSION_SECRET=***
|
||||
ENV TEN31_DOCS_DIR=/data/documents
|
||||
ENV TEN31_SESSION_SECRET=change-me
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ten31
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 856 B After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,39 @@
|
||||
# Ten31Portal
|
||||
|
||||
Internal system of record for Ten31 entities, holdings, positions, and quarterly
|
||||
valuation sign-off, plus an investor / fund-administrator portal.
|
||||
|
||||
## First login
|
||||
|
||||
On first boot a default administrator (approver) account is created. Unless you set
|
||||
the environment variables below, the defaults are:
|
||||
|
||||
- **Username:** `admin`
|
||||
- **Password:** `Ten31`
|
||||
|
||||
Open the web interface and sign in. Change this password immediately from the Users
|
||||
screen, or create a new admin and disable the default one.
|
||||
|
||||
The login field accepts a username **or** an email address.
|
||||
|
||||
## Accounts and access
|
||||
|
||||
- **Internal staff** (`approver`, `cfo`, `fund_admin`, `viewer`) use the back-office
|
||||
app: entities, holdings, positions, valuation rounds, import, and audit log.
|
||||
Approvers and the CFO also get the admin screens below.
|
||||
- **External accounts** (`investor`, `fund_administrator`) get an entity-scoped
|
||||
portal and only ever see the entities granted to them.
|
||||
- **Investors** see, per fund, their latest capital-account value and history, plus
|
||||
documents shared to the fund or addressed privately to them (e.g. their K-1).
|
||||
- **Fund administrators** see their assigned entities and can upload documents.
|
||||
|
||||
From the **Users** screen, create an account with a username and password and check
|
||||
off which entities it can view. Use **Documents** to upload statements and K-1s
|
||||
(shared to a fund or private to one investor), and **Capital Accounts** to enter each
|
||||
investor's figures.
|
||||
|
||||
## Data and backups
|
||||
|
||||
All data — the database, uploaded documents, and the generated session secret — lives
|
||||
on the service's data volume and is included in StartOS platform backups. Create a
|
||||
backup before uninstalling; uninstalling removes all fund data.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten31portal-startos",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten31portal-startos",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.21",
|
||||
"dependencies": {
|
||||
"@start9labs/start-sdk": "^0.4.0-beta.58"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "ten31portal-startos",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.45",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+45
-16
@@ -14,25 +14,54 @@ fi
|
||||
|
||||
# Create first approver on first boot if no users exist
|
||||
if [ ! -f /data/.initialized ]; then
|
||||
echo "First boot: running migrations and creating default approver..."
|
||||
echo "First boot: running migrations and creating the admin account..."
|
||||
|
||||
# Set default credentials (user can change via CLI later)
|
||||
ADMIN_NAME="${TEN31_ADMIN_NAME:-Jonathan}"
|
||||
ADMIN_USERNAME="${TEN31_ADMIN_USERNAME:-admin}"
|
||||
ADMIN_EMAIL="${TEN31_ADMIN_EMAIL:-jonathan@ten31.xyz}"
|
||||
ADMIN_PASSWORD="${TEN31_ADMIN_PASSWORD:-Ten31}"
|
||||
|
||||
python3 -m ten31portal.cli create-user \
|
||||
--name "$ADMIN_NAME" \
|
||||
--email "$ADMIN_EMAIL" \
|
||||
--role approver \
|
||||
--password "$ADMIN_PASSWORD" || true
|
||||
|
||||
touch /data/.initialized
|
||||
echo "Default approver created: $ADMIN_EMAIL"
|
||||
# No weak default: use an operator-supplied password if given, else generate a strong random
|
||||
# one and record it (0600) so it can be retrieved once via the "Show Initial Admin Password"
|
||||
# action. There is no fixed default credential to guess.
|
||||
ADMIN_PW_FILE="$(dirname "$TEN31_DB_PATH")/.admin-password"
|
||||
if [ -n "$TEN31_ADMIN_PASSWORD" ]; then
|
||||
ADMIN_PASSWORD="$TEN31_ADMIN_PASSWORD"
|
||||
GENERATED=""
|
||||
else
|
||||
ADMIN_PASSWORD="$(python3 -c "import secrets; print(secrets.token_urlsafe(18))")"
|
||||
GENERATED="yes"
|
||||
fi
|
||||
|
||||
# Serve frontend static files from the backend
|
||||
exec uvicorn ten31portal.main:app \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000 \
|
||||
--log-level info
|
||||
if python3 -m ten31portal.cli create-user \
|
||||
--name "$ADMIN_NAME" \
|
||||
--username "$ADMIN_USERNAME" \
|
||||
--email "$ADMIN_EMAIL" \
|
||||
--role approver \
|
||||
--password "$ADMIN_PASSWORD" \
|
||||
--service-admin; then
|
||||
if [ -n "$GENERATED" ]; then
|
||||
printf '%s' "$ADMIN_PASSWORD" > "$ADMIN_PW_FILE"
|
||||
chmod 600 "$ADMIN_PW_FILE"
|
||||
echo "Admin '$ADMIN_USERNAME' created with a generated password."
|
||||
echo " Retrieve it once via the 'Show Initial Admin Password' service action, then change it."
|
||||
else
|
||||
echo "Admin '$ADMIN_USERNAME' created with the operator-supplied password."
|
||||
fi
|
||||
fi
|
||||
|
||||
touch /data/.initialized
|
||||
fi
|
||||
|
||||
# Log level is overridable at runtime for debugging; defaults to info.
|
||||
LOG_LEVEL="${TEN31_LOG_LEVEL:-info}"
|
||||
|
||||
# Hand off to the server as an unprivileged user. The platform mounts /data owned by root, so
|
||||
# (while still root) we take ownership of the data volume first, then drop privileges with
|
||||
# setpriv — the long-running server process is never root, limiting what a compromise can reach.
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R 10001:10001 /data
|
||||
exec setpriv --reuid=10001 --regid=10001 --clear-groups \
|
||||
uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL"
|
||||
else
|
||||
exec uvicorn ten31portal.main:app --host 0.0.0.0 --port 8000 --log-level "$LOG_LEVEL"
|
||||
fi
|
||||
|
||||
+483
-53
@@ -2,6 +2,29 @@ import { sdk } from '../sdk'
|
||||
|
||||
const { InputSpec, Value, Action } = sdk
|
||||
|
||||
// Run a ten31portal CLI subcommand inside the service container against the data volume.
|
||||
async function runCli(effects: any, args: string[], taskName: string) {
|
||||
const sub = await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
taskName,
|
||||
)
|
||||
return sub.exec(
|
||||
['python3', '-m', 'ten31portal.cli', ...args],
|
||||
{ env: { TEN31_DB_PATH: '/data/portal.db' } },
|
||||
30000,
|
||||
)
|
||||
}
|
||||
|
||||
const errorResult = (message: string) =>
|
||||
({ version: '1' as const, title: 'Error', message, result: null })
|
||||
|
||||
// ============================================
|
||||
// Action: Create User
|
||||
// ============================================
|
||||
@@ -26,6 +49,13 @@ const createUserInputSpec = InputSpec.of({
|
||||
},
|
||||
],
|
||||
}),
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Login username',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'jsmith',
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'Password',
|
||||
description: 'Initial password (user should change after first login)',
|
||||
@@ -36,13 +66,12 @@ const createUserInputSpec = InputSpec.of({
|
||||
role: Value.select({
|
||||
name: 'Role',
|
||||
description:
|
||||
'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.',
|
||||
default: 'viewer',
|
||||
'Managing Partner: full access including valuation sign-off. Operations: full access except final sign-off. Fund Admin: edit holdings/NAV and submit, no user or document admin.',
|
||||
default: 'operations',
|
||||
values: {
|
||||
approver: 'Approver',
|
||||
cfo: 'CFO',
|
||||
approver: 'Managing Partner',
|
||||
operations: 'Operations',
|
||||
fund_admin: 'Fund Admin',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -51,7 +80,7 @@ const createUserAction = Action.withInput(
|
||||
'create-user',
|
||||
{
|
||||
name: 'Create User',
|
||||
description: 'Add a new user account with a role',
|
||||
description: 'Add a new staff user account with a role',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
@@ -61,69 +90,470 @@ const createUserAction = Action.withInput(
|
||||
async () => ({
|
||||
name: '',
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'viewer' as const,
|
||||
role: 'operations' as const,
|
||||
}),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const sub = await sdk.SubContainer.of(
|
||||
const result = await runCli(
|
||||
effects,
|
||||
{ imageId: 'main' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
[
|
||||
'create-user',
|
||||
'--name', input.name,
|
||||
'--username', input.username,
|
||||
'--email', input.email,
|
||||
'--role', input.role,
|
||||
'--password', input.password,
|
||||
],
|
||||
'create-user-task',
|
||||
)
|
||||
|
||||
const result = await sub.exec(
|
||||
[
|
||||
'python3',
|
||||
'-m',
|
||||
'ten31portal.cli',
|
||||
'create-user',
|
||||
'--name',
|
||||
input.name,
|
||||
'--email',
|
||||
input.email,
|
||||
'--role',
|
||||
input.role,
|
||||
'--password',
|
||||
input.password,
|
||||
],
|
||||
{
|
||||
env: { TEN31_DB_PATH: '/data/portal.db' },
|
||||
},
|
||||
30000,
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr?.toString() || 'Unknown error'
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${stderr}`,
|
||||
result: null,
|
||||
return errorResult(`Failed to create user: ${result.stderr?.toString() || 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'User Created',
|
||||
message: `Created user ${input.name} (${input.email}) with role: ${input.role}`,
|
||||
message: `Created ${input.name} (${input.username}) with role: ${input.role}`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Error',
|
||||
message: `Failed to create user: ${e.message || e}`,
|
||||
result: null,
|
||||
}
|
||||
return errorResult(`Failed to create user: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const actions = sdk.Actions.of().addAction(createUserAction)
|
||||
// ============================================
|
||||
// Action: Reset Password
|
||||
// ============================================
|
||||
const resetPasswordInputSpec = InputSpec.of({
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Username of the account to reset (e.g. the Admin / Service Admin)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'admin',
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'New Password',
|
||||
description: 'The new password for this account',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'minimum 4 characters',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetPasswordAction = Action.withInput(
|
||||
'reset-password',
|
||||
{
|
||||
name: 'Reset Password',
|
||||
description: "Reset any user's password (including the Admin / Service Admin)",
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetPasswordInputSpec,
|
||||
async () => ({ username: '', password: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(
|
||||
effects,
|
||||
['reset-password', '--username', input.username, '--password', input.password],
|
||||
'reset-password-task',
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to reset password')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Password Reset',
|
||||
message: `Password reset for ${input.username}. Their login is enabled.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to reset password: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Reset Two-Factor
|
||||
// ============================================
|
||||
const resetTwoFactorInputSpec = InputSpec.of({
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Username of the account whose two-factor should be cleared (lost phone)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'admin',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetTwoFactorAction = Action.withInput(
|
||||
'reset-2fa',
|
||||
{
|
||||
name: 'Reset Two-Factor',
|
||||
description:
|
||||
"Clear a user's two-factor enrollment so they can sign in with just their password (e.g. after losing their authenticator)",
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetTwoFactorInputSpec,
|
||||
async () => ({ username: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(
|
||||
effects,
|
||||
['reset-2fa', '--username', input.username],
|
||||
'reset-2fa-task',
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to reset two-factor')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Two-Factor Reset',
|
||||
message: `Two-factor cleared for ${input.username}. They can sign in with their password and re-enroll from the app.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to reset two-factor: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: List Users
|
||||
// ============================================
|
||||
const listUsersAction = Action.withoutInput(
|
||||
'list-users',
|
||||
{
|
||||
name: 'List Users',
|
||||
description: 'Show every user account and role',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['list-users'], 'list-users-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to list users')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Users',
|
||||
message: 'The Service Admin cannot be deleted.',
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: result.stdout?.toString() || 'No users.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to list users: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Show Initial Admin Password
|
||||
// ============================================
|
||||
const showAdminPasswordAction = Action.withoutInput(
|
||||
'show-admin-password',
|
||||
{
|
||||
name: 'Show Initial Admin Password',
|
||||
description:
|
||||
'Reveal the randomly-generated admin password created on first boot. Sign in with it, then change your password — after which this no longer shows it.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['show-admin-password'], 'show-admin-password-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to read the admin password')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Initial Admin Password',
|
||||
message: 'Sign in as "admin" with this password, then change it from the portal.',
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: (result.stdout?.toString() || '').trim() || 'No stored password.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: true,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to read the admin password: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Delete User
|
||||
// ============================================
|
||||
const deleteUserInputSpec = InputSpec.of({
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: 'Username of the account to delete (use List Users to see them)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'jsmith',
|
||||
}),
|
||||
})
|
||||
|
||||
const deleteUserAction = Action.withInput(
|
||||
'delete-user',
|
||||
{
|
||||
name: 'Delete User',
|
||||
description: 'Permanently delete a user account',
|
||||
warning:
|
||||
'This permanently deletes the user and their data (access grants, capital statements, private documents). The Service Admin cannot be deleted.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
deleteUserInputSpec,
|
||||
async () => ({ username: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(
|
||||
effects,
|
||||
['delete-user', '--username', input.username],
|
||||
'delete-user-task',
|
||||
)
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to delete user')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'User Deleted',
|
||||
message: result.stdout?.toString() || `Deleted ${input.username}.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to delete user: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Fix Duplicate Holdings
|
||||
// ============================================
|
||||
const dedupeAction = Action.withoutInput(
|
||||
'dedupe-holdings',
|
||||
{
|
||||
name: 'Fix Duplicate Holdings',
|
||||
description:
|
||||
'Remove duplicate holdings/positions left by repeated imports on older versions, which inflated the Invested totals on the Entities view. Safe to run anytime.',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['dedupe-holdings'], 'dedupe-holdings-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to clean up duplicates')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Duplicates Cleaned Up',
|
||||
message: result.stdout?.toString() || 'Done.',
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to clean up duplicates: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: List Funds
|
||||
// ============================================
|
||||
const listFundsAction = Action.withoutInput(
|
||||
'list-funds',
|
||||
{
|
||||
name: 'List Funds',
|
||||
description: 'Show every fund/SPV and its exact name (for Reset Fund Holdings)',
|
||||
warning: null,
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['list-funds'], 'list-funds-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to list funds')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Funds',
|
||||
message: null,
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: result.stdout?.toString() || 'No funds.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to list funds: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Reset Fund Holdings
|
||||
// ============================================
|
||||
const resetHoldingsInputSpec = InputSpec.of({
|
||||
name: Value.text({
|
||||
name: 'Fund Name',
|
||||
description: 'Exact name of the fund to clear (see List Funds)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'Low Time Preference Fund III, LP',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetHoldingsAction = Action.withInput(
|
||||
'reset-holdings',
|
||||
{
|
||||
name: 'Reset Fund Holdings',
|
||||
description:
|
||||
'Clear a fund\'s holdings, positions, and valuation rounds so it can be re-imported from scratch. Use after switching source workbooks (e.g. Carta → eNAV) renamed the positions. Investor capital accounts are NOT affected.',
|
||||
warning:
|
||||
'This permanently deletes the fund\'s holdings, positions, and valuation history. Re-import the fund\'s NAV afterward to repopulate it. Investor capital accounts are kept.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetHoldingsInputSpec,
|
||||
async () => ({ name: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['reset-holdings', '--name', input.name], 'reset-holdings-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to reset fund')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Fund Cleared',
|
||||
message: result.stdout?.toString() || `Cleared ${input.name}.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to reset fund: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Reset Fund Partners
|
||||
// ============================================
|
||||
const resetPartnersInputSpec = InputSpec.of({
|
||||
name: Value.text({
|
||||
name: 'Fund Name',
|
||||
description: 'Exact name of the fund whose partners to clear (see List Funds)',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'Low Time Preference Fund III, LP',
|
||||
}),
|
||||
})
|
||||
|
||||
const resetPartnersAction = Action.withInput(
|
||||
'reset-partners',
|
||||
{
|
||||
name: 'Reset Fund Partners',
|
||||
description:
|
||||
"Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).",
|
||||
warning:
|
||||
"This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.",
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
resetPartnersInputSpec,
|
||||
async () => ({ name: '' }),
|
||||
async ({ input, effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to clear partners')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Partners Cleared',
|
||||
message: result.stdout?.toString() || `Cleared partners from ${input.name}.`,
|
||||
result: null,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to clear partners: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Action: Enable Investor Logins
|
||||
// ============================================
|
||||
const enableInvestorLoginsAction = Action.withoutInput(
|
||||
'enable-investor-logins',
|
||||
{
|
||||
name: 'Enable Investor Logins',
|
||||
description:
|
||||
'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.',
|
||||
warning: 'Every converted account gets the same well-known default password until the investor changes it.',
|
||||
allowedStatuses: 'only-running',
|
||||
group: null,
|
||||
visibility: 'enabled',
|
||||
},
|
||||
async ({ effects }) => {
|
||||
try {
|
||||
const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task')
|
||||
if (result.exitCode !== 0) {
|
||||
return errorResult(result.stderr?.toString() || 'Failed to enable investor logins')
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Investor Logins Enabled',
|
||||
message: 'Send each investor their username; they sign in with the default password and change it.',
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
value: result.stdout?.toString() || 'Nothing to do.',
|
||||
copyable: true,
|
||||
qr: false,
|
||||
masked: false,
|
||||
},
|
||||
}
|
||||
} catch (e: any) {
|
||||
return errorResult(`Failed to enable investor logins: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(createUserAction)
|
||||
.addAction(resetPasswordAction)
|
||||
.addAction(resetTwoFactorAction)
|
||||
.addAction(showAdminPasswordAction)
|
||||
.addAction(listUsersAction)
|
||||
.addAction(enableInvestorLoginsAction)
|
||||
.addAction(deleteUserAction)
|
||||
.addAction(dedupeAction)
|
||||
.addAction(listFundsAction)
|
||||
.addAction(resetHoldingsAction)
|
||||
.addAction(resetPartnersAction)
|
||||
|
||||
@@ -1,2 +1,47 @@
|
||||
export { v_0_1_0 as current } from './v_0_1_0'
|
||||
export const other = []
|
||||
export { v_0_2_45 as current } from './v_0_2_45'
|
||||
import { v_0_1_0 } from './v_0_1_0'
|
||||
import { v_0_2_40 } from './v_0_2_40'
|
||||
import { v_0_2_41 } from './v_0_2_41'
|
||||
import { v_0_2_42 } from './v_0_2_42'
|
||||
import { v_0_2_43 } from './v_0_2_43'
|
||||
import { v_0_2_44 } from './v_0_2_44'
|
||||
import { v_0_2_0 } from './v_0_2_0'
|
||||
import { v_0_2_1 } from './v_0_2_1'
|
||||
import { v_0_2_3 } from './v_0_2_3'
|
||||
import { v_0_2_4 } from './v_0_2_4'
|
||||
import { v_0_2_5 } from './v_0_2_5'
|
||||
import { v_0_2_6 } from './v_0_2_6'
|
||||
import { v_0_2_7 } from './v_0_2_7'
|
||||
import { v_0_2_8 } from './v_0_2_8'
|
||||
import { v_0_2_9 } from './v_0_2_9'
|
||||
import { v_0_2_10 } from './v_0_2_10'
|
||||
import { v_0_2_11 } from './v_0_2_11'
|
||||
import { v_0_2_12 } from './v_0_2_12'
|
||||
import { v_0_2_13 } from './v_0_2_13'
|
||||
import { v_0_2_14 } from './v_0_2_14'
|
||||
import { v_0_2_15 } from './v_0_2_15'
|
||||
import { v_0_2_16 } from './v_0_2_16'
|
||||
import { v_0_2_17 } from './v_0_2_17'
|
||||
import { v_0_2_18 } from './v_0_2_18'
|
||||
import { v_0_2_19 } from './v_0_2_19'
|
||||
import { v_0_2_20 } from './v_0_2_20'
|
||||
import { v_0_2_21 } from './v_0_2_21'
|
||||
import { v_0_2_22 } from './v_0_2_22'
|
||||
import { v_0_2_23 } from './v_0_2_23'
|
||||
import { v_0_2_24 } from './v_0_2_24'
|
||||
import { v_0_2_25 } from './v_0_2_25'
|
||||
import { v_0_2_26 } from './v_0_2_26'
|
||||
import { v_0_2_27 } from './v_0_2_27'
|
||||
import { v_0_2_28 } from './v_0_2_28'
|
||||
import { v_0_2_29 } from './v_0_2_29'
|
||||
import { v_0_2_30 } from './v_0_2_30'
|
||||
import { v_0_2_31 } from './v_0_2_31'
|
||||
import { v_0_2_32 } from './v_0_2_32'
|
||||
import { v_0_2_33 } from './v_0_2_33'
|
||||
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'
|
||||
import { v_0_2_38 } from './v_0_2_38'
|
||||
import { v_0_2_39 } from './v_0_2_39'
|
||||
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, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_0 = VersionInfo.of({
|
||||
version: '0.2.0:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Investor and fund-administrator accounts. Per-entity access control, built-in document storage (statements, K-1s), and per-investor capital-account statements. Admin screens to create accounts, grant entity access, upload documents, and enter capital-account figures.',
|
||||
},
|
||||
migrations: {
|
||||
// New tables (entity_access, documents, capital_account_statements) and the
|
||||
// users.username column are applied by Alembic on app startup. No StartOS-level
|
||||
// data migration is required.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_1 = VersionInfo.of({
|
||||
version: '0.2.1:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Owner access grid (accounts by fund/SPV, click to grant or revoke access) and a capital-account importer that reads a fund-administrator spreadsheet, matches or creates investor accounts on review, grants entity access, and loads each investor\'s capital-account balance.',
|
||||
},
|
||||
migrations: {
|
||||
// No schema changes beyond 0.2.0; new features reuse existing tables. Alembic
|
||||
// remains the source of truth for the database schema on app startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_10 = VersionInfo.of({
|
||||
version: '0.2.10:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'One unified Import: a single eNAV upload loads fund holdings/NAV and members + capital (commitment, paid-in, distributions, current value). Investor view now shows commitment and DPI alongside current value. Menu cleanup: one Import item, Audit Log moved to the bottom.',
|
||||
},
|
||||
migrations: {
|
||||
// Adds capital_account_statements.commitment_cents; applied by Alembic on startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_11 = VersionInfo.of({
|
||||
version: '0.2.11:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Linked investor logins: an investor who invests under several names (e.g. an IRA and a trust, or through different Partners) can be given one sign-on that shows every name\'s investments together, labeled by name. Set it in Users → Manage → Login. Also: sortable columns on the Entities view.',
|
||||
},
|
||||
migrations: {
|
||||
// Adds users.primary_account_id; applied by Alembic on startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_12 = VersionInfo.of({
|
||||
version: '0.2.12:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Clearnet performance: gzip compression on all responses (the ~320KB app bundle drops to ~90KB over the wire) and long-lived browser caching of content-hashed assets, so repeat visits are near-instant. No data changes.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_13 = VersionInfo.of({
|
||||
version: '0.2.13:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Re-importing a NAV now updates that quarter in place instead of adding a second round (no more doubled totals on the Entities view). New Ten31 Portal branding and logo.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_14 = VersionInfo.of({
|
||||
version: '0.2.14:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'User administration from StartOS Actions: Create User, Reset Password, List Users, and Delete User. New role layout — Managing Partner (full access incl. sign-off), Operations (full access except sign-off), Fund Admin. The built-in Service Admin can be reset but never deleted.',
|
||||
},
|
||||
migrations: {
|
||||
// Adds users.is_service_admin and flags the bootstrap admin; applied by Alembic on startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_15 = VersionInfo.of({
|
||||
version: '0.2.15:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Fixes doubled "Invested" totals by removing duplicate holdings/positions left by older imports (runs automatically on the next import of a fund, or all at once via the new "Fix Duplicate Holdings" action). Usernames are now editable in Users → Manage. Mobile/PWA: installable to a phone home screen with an app icon, plus a responsive layout with a collapsible menu.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_16 = VersionInfo.of({
|
||||
version: '0.2.16:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Clear and rebuild a fund: re-import with the new "Replace existing holdings" option to wipe a fund\'s old positions and rebuild from the file — for when a source change (e.g. Carta → eNAV) renamed every position. Also available as the "Reset Fund Holdings" and "List Funds" actions. Investor capital accounts are never affected.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_17 = VersionInfo.of({
|
||||
version: '0.2.17:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'The Entities view now shows a Committed column (total LP commitments to each fund) alongside Invested (capital deployed into companies) and Last Signed Value.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_18 = VersionInfo.of({
|
||||
version: '0.2.18:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'The fund Partners tab now shows each member\'s Committed, Paid-in, and Distributions alongside their capital value, with a total committed for the fund — matching what investors see in their own portal.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_19 = VersionInfo.of({
|
||||
version: '0.2.19:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Mobile/PWA fixes: the top bar (including Sign out) no longer hides behind the phone status bar, and the Entities view now shows each fund as a card that fits the screen — no more zooming out or sideways scrolling.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_20 = VersionInfo.of({
|
||||
version: '0.2.20:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Passwords now persist when you edit an investor (the password only changes if you type a new one). Investors can change their own password from the portal, and admins keep the ability to reset it. Every password field has a show/hide eye toggle.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_21 = VersionInfo.of({
|
||||
version: '0.2.21:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Importing with replace-existing-holdings now asks for confirmation before clearing a fund, and shows step-by-step progress. You can expand an audit log entry to see its full detail. Entities with no investments show a clear empty state. A brief server hiccup shows a retry prompt instead of logging you out. Backend adds document upload size limits, database indexes, and an automated test suite.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_22 = VersionInfo.of({
|
||||
version: '0.2.22:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Investors see a graph of their capital over time (value, paid-in, and distributions per quarter). A new admin Investor View shows exactly what an investor sees, read-only. Document uploads are scoped to the chosen fund\'s own investors with a clear target, so a file cannot go to the wrong person. GP and management-company entities get an Assets tab that lists their interests in the funds they manage. You can now edit an existing entity, including its type.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_23 = VersionInfo.of({
|
||||
version: '0.2.23:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'You can link a GP or management-company entity to its investor account. Its Assets tab then shows its real capital-account balance in each fund, pulled from the eNAV, with no double entry. Set this on the entity\'s Edit form.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_24 = VersionInfo.of({
|
||||
version: '0.2.24:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'A GP or management entity\'s linked capital balance now shows on its Overview page too, not only the Assets tab. The Assets view also includes balances held under the linked account\'s other legal names (as the eNAV often splits one LLC across names), and shows a clear message when a linked account has no balances on file.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_25 = VersionInfo.of({
|
||||
version: '0.2.25:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Load several past quarters of investor capital at once: the Import page now has a "Backfill historical capital" batch — drop a fund\'s eNAV workbooks and each file\'s members are matched to existing accounts and saved at that file\'s own as-of date, building trend-lines without replacing the latest figures (members not already in the portal are skipped, never created). Investors\' "Capital over time" chart is now collapsed by default and expands per fund.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_26 = VersionInfo.of({
|
||||
version: '0.2.26:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Security hardening (from a full audit): no more fixed default admin password — first boot generates a strong random one, retrievable once via the new "Show Initial Admin Password" action and cleared after you change it. Login now rate-limits repeated failures and returns a single generic message (no username enumeration). The server process runs unprivileged (non-root). Spreadsheet imports are size-capped and batch import errors no longer leak internal details.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_27 = VersionInfo.of({
|
||||
version: '0.2.27:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Entity overview now shows a full Valuation history — every quarter on record with its NAV, status, and signed date — instead of just the latest quarter. Investor portal now shows, per fund/SPV, gain/loss (amount and % vs paid-in) on the capital account and the % of commitment distributed.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_28 = VersionInfo.of({
|
||||
version: '0.2.28:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Investor gain/loss now measures total value (current NAV + distributions received) against paid-in capital, so an LP who has taken distributions no longer shows a false loss.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_29 = VersionInfo.of({
|
||||
version: '0.2.29:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"New 'Reset Fund Partners' service action (and a 'Clear all partners' button on a fund's Partners tab) removes all of a fund's capital-account statements and investor access grants — for undoing a wrong members import, e.g. one fund's roster loaded into another. Investor accounts and holdings/NAV are left intact. Reset Fund Holdings still only clears holdings; this covers the partner side.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_3 = VersionInfo.of({
|
||||
version: '0.2.3:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Replace the Carta importer with a fund-administrator eNAV importer. Reads the holdings (HLD) sheet into holdings, positions, and a quarter valuation; decrypts password-protected workbooks (password entered on upload); reads the report date from the sheet; accepts XLSX or CSV.',
|
||||
},
|
||||
migrations: {
|
||||
// No schema changes; import is parsing-only. Alembic owns the DB schema on startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_30 = VersionInfo.of({
|
||||
version: '0.2.30:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Investor portal: the capital block now reads 'Current Capital Balance' and shows only the percentage gain (green) or loss (red) beneath it — the redundant dollar 'gain/loss vs paid-in' line was removed.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_31 = VersionInfo.of({
|
||||
version: '0.2.31:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Entities view: the 'GP Entities and Management Companies' section is now 'Management Entities' and gains a new 'Carry Vehicle' entity type (with both Partners and Assets tabs) for carry vehicles like Ten31 EP LLC. Investor capital chart: the Distributions line only appears once distributions have actually been made.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_32 = VersionInfo.of({
|
||||
version: '0.2.32:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Investor portal polish: a portfolio summary card totals commitment, paid-in, distributions and current balance across funds; gain/loss is labeled 'net of paid-in'; headline figures show whole dollars; documents group by year with a 'New' badge since the investor's last visit. Ten31 brand palette (navy/mint from the logo) replaces the orange accents. New members from the eNAV import now start with the default password 'Ten31Portal' (login enabled), and a new 'Enable Investor Logins' action converts existing no-login accounts. Password changes now require at least 8 characters. Login page shows a Portal@ten31.xyz contact line.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_33 = VersionInfo.of({
|
||||
version: '0.2.33:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Exited positions: a member who sold or transferred their stake (secondary sale) can be marked 'Exited' on the fund's Partners tab. Their portal card shows a quiet Exited badge instead of a phantom -100% loss, documents stay available, and the position drops out of the investor's portfolio totals and the fund's committed totals (avoiding seller+buyer double-counting).",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_34 = VersionInfo.of({
|
||||
version: '0.2.34:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Exited positions can now also be managed from the admin Capital Accounts view: each statement row shows the member's Exited status with the same mark/undo control as the Partners tab, so re-imported eNAV rows carry the badge forward without affecting paid-in totals. Marking a manually-entered investor exited creates their roster row automatically.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_35 = VersionInfo.of({
|
||||
version: '0.2.35:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Exited positions now show the member's capital history up to the exit date: the collapsible History chart/table returns on the exited card, clipped at the exit — statements the eNAV keeps producing after the exit are recorded but never plotted, so the line ends at the exit instead of crashing to zero. History toggle now counts 'statements' (SPVs have event-driven statements, funds quarterly).",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_36 = VersionInfo.of({
|
||||
version: '0.2.36:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Fix: the admin's read-only Investor View now carries exit status, so an exited position shows its Exited badge there exactly as the LP sees it (it previously showed the active card with $0s). A fund card where every position is exited is now greyed out so it reads as closed at a glance; documents inside stay fully usable.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_37 = VersionInfo.of({
|
||||
version: '0.2.37:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
"Investor portal ordering: live funds and SPVs now always appear above exited ones — a fully-exited position sinks to the bottom of the stack instead of sitting between active funds.",
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_38 = VersionInfo.of({
|
||||
version: '0.2.38:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Two-factor authentication (optional, per user): enroll an authenticator app from the "Two-factor" option next to Change password; sign-in then asks for a 6-digit code. One-time recovery codes are issued at enrollment, and a new "Reset Two-Factor" action clears a lost enrollment so the user can sign in with just their password.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
@@ -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 }) => {},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
export const v_0_2_4 = VersionInfo.of({
|
||||
version: '0.2.4:0',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Import fund members from the eNAV ALLOC SI tab. The capital-account import now reads the investor roster, matches existing members by fund-administrator investor ID (idempotent re-imports), offers to create new members (without a login until you set a password), grants entity access, and loads each member\'s capital balance.',
|
||||
},
|
||||
migrations: {
|
||||
// Adds users.external_investor_id; applied by Alembic on startup.
|
||||
up: async ({ effects }) => {},
|
||||
down: async ({ effects }) => {},
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user