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
@@ -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,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 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,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,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')