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