23 Commits
Author SHA1 Message Date
Jonathan Kirkwood 858bbe10da 0.2.45: Administrator (view only) role
New external role administrator_viewer: signs into the admin interface
and reads everything for its granted funds and SPVs (overview, partners,
capital accounts with every investor's statements, documents, valuation
history) but every write is refused: no imports, uploads, deletions,
entity edits, exit marking, or account management. No migration needed;
roles are stored as strings.

Internal admins can flip an Administrator between full management and
view only via a new Access level dropdown in Users > Manage. The
user-list endpoint is read-widened for the viewer role so investor names
resolve on its screens; all mutating endpoints keep the stricter gate.
2026-08-11 15:37:44 -05:00
Jonathan Kirkwood 1dabbc3073 0.2.44: bitcoin terms value paid-in capital call by call
The LP portal's 'In bitcoin terms' box converted ALL paid-in capital at
the fund close date's BTC price, mispricing every mid-life capital call.
It now walks the statement history: the first statement's contributions
at the close-date entry mark, each later quarter's new contributions at
that statement date's price, and distributions at the price when
received. A dollar called later is no longer credited with bitcoin it
could never have bought. Funds with a single statement (and files
predating the uploaded price range) fall back to the close-date price.
Frontend only; shared by the LP portal and the admin Investor View.
2026-08-11 12:52:01 -05:00
Jonathan Kirkwood ebafcf19d9 0.2.43: historical NAV backfill without touching current holdings
The batch history import now also records each quarter's NAV in the
fund's valuation history: the old file's HLD rows are matched by issuer
and security name against the book as it exists today, matched rows
write that quarter's valuations, unmatched rows are counted and
reported, and nothing outside the round is created or modified. A
manually signed quarter is never overwritten.

The single-file wizard automatically takes the same history-only path
when the file is older than the fund's newest round. Previously that
import would regress position cost basis to the old file's values and
resurrect since-exited positions, corrupting the fund's Invested total.
2026-08-11 12:32:57 -05:00
Jonathan Kirkwood ae967494bd 0.2.42: external Administrator role with entity-scoped management
The external fund_administrator role (relabeled Administrator) now signs
into the full admin interface, fenced to the funds and SPVs granted to
it via EntityAccess:

- Partners, capital accounts, documents (upload and delete), entity
  edits, and eNAV imports for its own funds only; no fund creation,
  valuation sign-off, audit log, or investor view.
- Scoped user management: sees and manages only investors tied to its
  funds; creates investor accounts only; updates preserve grants on
  funds outside its scope.
- New DELETE /api/users/{id} (in-app Delete user button) with the
  cascade cleanup factored out of the CLI; Service Admin and self are
  protected, and an Administrator can only delete an investor who
  belongs solely to its funds.
- Internal fund_admin relabeled 'Staff (all funds)' and dropped from
  the create picker to end the two-similar-names confusion.
- Version badge removed from the UI (sidebar and portal header); the
  build version now logs to the browser console instead.
- deploy/.startos (signing key) added to .gitignore.
2026-08-10 15:38:39 -05:00
Jonathan Kirkwood 3c7094241c 0.2.41: contain SPA static serving to the web root
Percent-encoded traversal (..%2f) survived routing and let an
unauthenticated request read files outside static/, including the
database and session secret on the data volume. Paths are now resolved
and contained to the frontend build directory; anything that escapes
falls back to index.html.
2026-08-10 15:38:21 -05:00
Jonathan KirkwoodandClaude Fable 5 5d4e87e69b 0.2.40: bitcoin-denominated line on the LP capital chart
The capital-over-time chart gains a dashed orange series on its own
right-hand ₿ axis: total value (ending balance + distributions) divided
by the BTC price at each statement date — the time-series of the
"In bitcoin terms" box. The history table gains a matching In-bitcoin
column. Frontend-only; prices were already stamped per statement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:10:14 +02:00
Jonathan KirkwoodandClaude Fable 5 eac3262f29 0.2.39: bitcoin-denominated view, first-login flow, unfunded + tax center
- BTC prices: btc_prices table, CSV upload on Import page (auto-detected
  date/close columns, upsert by date), entities.close_date as the BTC entry
  mark; statements carry btc_price_cents (as-of) + btc_close_price_cents.
  LP capital blocks show paid-in vs current value in bitcoin terms.
- First login: accounts on the shared default password are flagged
  (must_change_password) and blocked behind a full-screen password change;
  external accounts then get a one-time welcome tour with a 2FA offer
  (users.onboarded_at).
- LP portal: Unfunded (callable commitment) metric; Tax documents center
  aggregating K-1/tax docs across funds, grouped by year.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:01:50 +02:00
Jonathan KirkwoodandClaude Fable 5 0822eca887 0.2.38: optional two-factor authentication (authenticator-app TOTP)
Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 22:08:04 +02:00
Jonathan KirkwoodandClaude Opus 4.8 053bfeab23 0.2.37: live funds above exited ones in the investor portal
A fully-exited fund/SPV card sinks to the bottom of the LP's stack
instead of sitting between active funds (stable sort, shared by the
LP portal and the admin Investor View).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:07:05 -05:00
Jonathan KirkwoodandClaude Opus 4.8 1deb6586b0 0.2.36: Investor View carries exit status + grey out fully-exited fund cards
The admin read-only Investor View built capital-account responses
without exited_on, so an exited position showed the active card with
$0s (the LP's own portal was correct). Extracted exit_dates() into
capital_account_router and stamp it in investor_view too; regression
test added. A fund card where every position is exited now renders
greyed (bg + title) so it reads as closed at a glance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:35:03 -05:00
Jonathan KirkwoodandClaude Opus 4.8 2c87ab499a 0.2.35: exited positions show history clipped at the exit date
The eNAV keeps producing statements after an exit (quarterly for funds,
event-driven for SPVs), so an unfiltered graph would crash to zero.
Now the exited card gets its collapsible History back — chart + table
showing the capital journey up to exited_on only. Post-exit statements
stay recorded (audit trail; Undo restores full history) but are never
plotted. History section extracted into a shared HistorySection
component; toggle counts "statements" not "quarters" (SPV statements
are event-driven).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:23:47 -05:00
Jonathan KirkwoodandClaude Opus 4.8 6313d781b4 0.2.34: manage exited status from the Capital Accounts view
The eNAV keeps listing exited members each quarter, so the admin needs
the exit control where the statements live:
- Capital Accounts table gains a Status column with the same
  Exited-badge / mark / undo flow as the Partners tab (keyed per
  investor+fund pair — marking any statement row marks them all)
- set_partner_exited now creates the access row (flag set) when a
  manually-entered investor has statements but no roster entry yet;
  clearing a never-set exit stays 404

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:03:49 -05:00
Jonathan KirkwoodandClaude Opus 4.8 a639ba14cf 0.2.33: exited positions (secondary-sale edge case)
A member who sold/transferred their stake showed a phantom -100% loss
($0 ending balance, no distribution through the fund). Now:
- entity_access.exited_on (migration e1f2a3b4c5d6), set/cleared from the
  Partners tab (writer-only, inline date picker, audited)
- LP portal card shows a quiet "Exited <date>" badge, keeps documents,
  hides balance/gain
- portfolio summary excludes exited positions ("Excludes N exited")
- fund committed totals (rollup + Partners tab) skip exited members so
  seller + buyer are not double-counted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:54:19 -05:00
Jonathan KirkwoodandClaude Opus 4.8 4215d4478f 0.2.27-0.2.32: LP portal polish, brand palette, default investor logins
Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
  distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
  distributions-line gate
- 0.2.32: LP-facing polish pass
  * Ten31 brand palette from the logo (navy/mint); orange retired
  * portfolio summary card across funds; gain labeled "net of paid-in"
  * whole-dollar headline figures; "History · N quarters" toggle
  * documents grouped by year with a "New" badge (users.docs_seen_at)
  * eNAV-created members start on default password with login enabled;
    enable-investor-logins CLI + StartOS action for existing accounts
  * password minimum raised to 8 chars; login help line (Portal@ten31.xyz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:40:40 -05:00
Jonathan KirkwoodandClaude Opus 4.8 69f12b0519 0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4):

Default admin credentials (P2): remove the fixed `Ten31` default. First
boot now generates a strong random admin password (secrets.token_urlsafe),
records it 0600 at /data/.admin-password, and surfaces it once via a new
"Show Initial Admin Password" StartOS action (CLI `show-admin-password`).
The stored password is cleared when the admin is reset (CLI reset-password)
or self-changes it (change-password endpoint).

Login hardening (P2): add a per-IP in-memory sliding-window rate limiter
(10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy
argon2 verify when the user is unknown so timing can't enumerate usernames;
keep a single generic 401 for unknown-user and wrong-password.

Hardening (P3): server process now runs unprivileged -- Dockerfile adds
uid 10001 appuser; start.sh (still root) chowns the mounted /data then
drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are
size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the
schedule, capital preview, and batch paths. batch_import no longer returns
raw exception text (generic per-file messages).

Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001,
/data owned 10001 with 0600 secrets; generated admin password retrievable
via CLI and logs in (200); 11th bad login -> 429; admin reset clears the
stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed;
frontend tsc + StartOS bundle clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:09:27 -05:00
Jonathan KirkwoodandClaude Opus 4.8 025aff4fac 0.2.25: bump deploy package version
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:50:09 -05:00
Jonathan KirkwoodandClaude Opus 4.8 099459b2f3 0.2.25: batch historical eNAV backfill + collapsible investor chart
Add POST /api/import/capital-accounts/batch: upload several of a fund's
eNAV workbooks at once; each file's ALLOC SI roster is auto-matched to
existing members (by fund-admin investor ID, else name/username) and their
capital statement is 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 and reported per file (never created). Capital
statements only -- holdings/NAV are untouched. One bad file (wrong
password, no ALLOC SI, unreadable date) is reported per-file and does not
abort the rest.

Import page gains a "Backfill historical capital" batch section (fund
picker, multi-file .xlsx input, shared password, per-file results table).

Investor portal "Capital over time" chart is now collapsed by default and
expands per fund (first login opens clean); applies to InvestorHome and
the admin Investor View via the shared component.

Tests: backend/tests/test_capital_batch.py (2). Full suite 17 passed;
frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:47:01 -05:00
Jonathan Kirkwood 33776d42f4 0.2.24: household-aware GP asset balances + show on Overview
Fixes a linked GP entity showing an empty Assets tab when its capital is
held under the linked account's other legal names (the eNAV often splits
one LLC across names).

- New GET /api/entities/{id}/asset-balances: household-aware, returns the
  linked account's (and its linked names') capital balances per fund, plus
  the linked account name for a clear empty state.
- Assets tab uses it; shows "linked to X but no balances on file" instead
  of a blank table when the wrong account is linked.
- GP/mgmt Overview now surfaces the total linked balance across funds with
  a "View by fund" link, so assets are visible without opening the tab.

Verified: 15/15 backend tests (incl. household case); frontend tsc + vite
build clean.
2026-07-01 16:14:58 -05:00
Jonathan Kirkwood e7501a14b0 0.2.23: link GP entities to their investor account for auto asset balances
Ten31 LLC (and any GP/mgmt entity) can be linked to its investor account,
so its Assets tab shows its real capital-account balance in each fund,
pulled live from the eNAV capital accounts instead of manual entry.

- entities.linked_user_id (migration c9d0e1f2a3b4) + EntityCreate/Update/
  Response fields; validated to be an investor account.
- Edit-entity form gains a "Linked investor account" picker for GP/mgmt.
- Assets tab now auto-lists the linked account's balance per fund (the
  earlier manual-stakes API remains but is no longer used by the UI).

Verified: 13/13 backend tests pass; alembic head c9d0e1f2a3b4; frontend
tsc + vite build clean.
2026-07-01 15:21:28 -05:00
Jonathan Kirkwood f0f8fd15c6 Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.

New features:
- Investor capital-over-time chart (value, paid-in, distributions per
  quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
  (GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
  explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
  they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
  categorized correctly.

Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
2026-07-01 14:25:50 -05:00
Jonathan Kirkwood 7fc78d7058 Release 0.2.21; track the StartOS version files
Cut version 0.2.21 (current = v_0_2_21) with the adjudicated DO-item fixes.
Also commits the previously-untracked v_0_2_* version files that
versions/index.ts imports (ROADMAP item D2), so a fresh checkout builds
without dangling imports. The missing v_0_2_2 is intentional (versions are
an unordered set).

Built and verified: ten31portal_x86_64.s9pk packs cleanly at 0.2.21:0,
arch x86_64, SDK 0.4.0.
2026-07-01 13:39:56 -05:00
Jonathan Kirkwood 8247c28243 Implement adjudicated DO items across backend, frontend, deploy
From the ROADMAP adjudication (12 of 13 DO items; D2 is a commit action).

Backend:
- B3: pytest suite (auth, entity CRUD, rollup) + dev deps + pytest config
- B4: cap document uploads at TEN31_MAX_UPLOAD_SIZE (default 50MB), stream-
  checked with partial-file cleanup, 413 on overflow
- B7: type AuditLog.detail as dict|list|str|None to match the JSON column
- B10: index foreign-key columns (migration a7b8c9d0e1f2 + index=True)
- B11: cli delete-user logs file-removal errors instead of swallowing them

Frontend:
- F2: distinguish "server unreachable" from "logged out"; retry prompt
- F4: confirm before destructive holdings-replace on import; step progress
- F6: expandable audit-log detail with full JSON
- F7: empty-state on the Investments page
- F8: shared role helpers (WRITER_ROLES/canEditRound/isApprover), used by
  EntitiesList, AuditLog, Import, ValuationWorkflow

Deploy:
- D5: run tsc --noEmit before packaging (build script)
- D6: TEN31_LOG_LEVEL env var (defaults to info)

Verified: 8/8 backend tests pass; alembic upgrades to head with 13 FK
indexes; upload limit rejects oversized + cleans up; frontend tsc + vite
build clean; dev server serves and proxies to the API.
2026-07-01 13:33:40 -05:00
Jonathan Kirkwood 77eeb3bd7f Add adjudicated ROADMAP: 13 drop, 13 do, 4 escalate
Grounded survey of the tree (backend/frontend/deploy), then
adjudicated each P2/P3 item investigate -> debate -> judge.

Dropped as not-worth-it (removed): B2 rollup N+1 (fine at scale),
F1 money formatters (2 trivial lines), F3 SW cache (stale-shell
can't happen), D1 health check (no real race), D3 aarch64 (x86
internal tool), D4 pin SDK (lockfile already deterministic),
B5 string caps (SQLite ignores), B6 created_at (already present),
B8 VelvetSweatshop (harmless), B9 empty-strip (already handled),
F9 loading text / F10 spinner / F11 confirm() (cosmetic).

Kept as DO with plans: B3 tests, B4 upload cap, B7 audit type,
B10 FK indexes, B11 cli errors, F2 auth-vs-network, F4 import
confirm, F6 audit detail, F7 empty-state, F8 role helpers,
D2 commit version files, D5 type-check, D6 log-level env.

Escalated (your call): B1 pagination (epic, perm-filtered lists),
F5 valuation negatives (money write path), D7 default admin creds
(auth, onboarding externals), D8 uninstall wording (corrected
fact; ties to H1 backup question).
2026-07-01 09:21:59 -05:00
153 changed files with 12763 additions and 1241 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "frontend",
"port": 5173
}
]
}
+16
View File
@@ -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/
+25
View File
@@ -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
View File
@@ -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')
+12
View File
@@ -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"]
+6 -1
View File
@@ -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,
+80 -5
View File
@@ -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
View File
@@ -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()
+15
View File
@@ -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")
+50 -3
View File
@@ -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
View File
@@ -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)
+47
View File
@@ -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)
+219 -5
View File
@@ -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"}
+316 -7
View File
@@ -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]
+8 -3
View File
@@ -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)
+574
View File
@@ -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"}
+310 -3
View File
@@ -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):
+79
View File
@@ -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
+71
View File
@@ -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)
+87
View File
@@ -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")
+241
View File
@@ -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
+120
View File
@@ -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"
+40
View File
@@ -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
+59
View File
@@ -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)
+120
View File
@@ -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"
+107
View File
@@ -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
+60
View File
@@ -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
+32
View File
@@ -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
+76
View File
@@ -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"] == []
+130
View File
@@ -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"
+152
View File
@@ -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
+40
View File
@@ -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
+134
View File
@@ -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
+48
View File
@@ -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
+41
View File
@@ -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() == []
+68
View File
@@ -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
+123
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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.
View File
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 23 KiB

+39
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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": {
+40 -11
View File
@@ -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 \
# 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
if python3 -m ten31portal.cli create-user \
--name "$ADMIN_NAME" \
--username "$ADMIN_USERNAME" \
--email "$ADMIN_EMAIL" \
--role approver \
--password "$ADMIN_PASSWORD" || true
--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
echo "Default approver created: $ADMIN_EMAIL"
fi
# Serve frontend static files from the backend
exec uvicorn ten31portal.main:app \
--host 0.0.0.0 \
--port 8000 \
--log-level info
# 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
View File
@@ -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)
+47 -2
View File
@@ -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