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.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
+6 -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 ./
@@ -32,7 +35,8 @@ RUN chmod +x ./start.sh
RUN mkdir -p /data
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"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
"version": "0.2.21",
"version": "0.2.22",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+310 -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,297 @@ 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: 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: 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}`)
}
},
)
export const actions = sdk.Actions.of()
.addAction(createUserAction)
.addAction(resetPasswordAction)
.addAction(listUsersAction)
.addAction(deleteUserAction)
.addAction(dedupeAction)
.addAction(listFundsAction)
.addAction(resetHoldingsAction)
+3 -2
View File
@@ -1,4 +1,4 @@
export { v_0_2_21 as current } from './v_0_2_21'
export { v_0_2_22 as current } from './v_0_2_22'
import { v_0_1_0 } from './v_0_1_0'
import { v_0_2_0 } from './v_0_2_0'
import { v_0_2_1 } from './v_0_2_1'
@@ -20,4 +20,5 @@ 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'
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]
import { v_0_2_21 } from './v_0_2_21'
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]
@@ -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 }) => {},
},
})
+1
View File
@@ -23,6 +23,7 @@ export const main = sdk.setupMain(async ({ effects }) => {
command: ['sh', '-c', '/app/start.sh'],
env: {
TEN31_DB_PATH: '/data/portal.db',
TEN31_DOCS_DIR: '/data/documents',
TEN31_SESSION_SECRET: process.env.TEN31_SESSION_SECRET || 'change-me',
},
},