"""initial schema Revision ID: 0001 Revises: Create Date: 2026-04-20 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql revision = "0001" down_revision = None branch_labels = None depends_on = None def upgrade() -> None: # users op.create_table( "users", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("email", sa.Text, nullable=False, unique=True), sa.Column("password_hash", sa.Text, nullable=False), sa.Column("totp_secret", sa.Text, nullable=True), sa.Column("totp_enabled", sa.Boolean, nullable=False, server_default="false"), sa.Column("totp_backup_codes", sa.Text, nullable=True), sa.Column("display_name", sa.Text, nullable=False), sa.Column("base_currency", sa.String(10), nullable=False, server_default="GBP"), sa.Column("theme", sa.String(20), nullable=False, server_default="dark"), sa.Column("locale", sa.String(20), nullable=False, server_default="en-GB"), sa.Column("failed_login_attempts", sa.Integer, nullable=False, server_default="0"), sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_login_ip", postgresql.INET, nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_users_email", "users", ["email"]) # sessions op.create_table( "sessions", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("token_hash", sa.Text, nullable=False, unique=True), sa.Column("ip_address", postgresql.INET, nullable=True), sa.Column("user_agent", sa.Text, nullable=True), sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) op.create_index("ix_sessions_token_hash", "sessions", ["token_hash"]) # accounts op.create_table( "accounts", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("name", sa.LargeBinary, nullable=False), sa.Column("institution", sa.LargeBinary, nullable=True), sa.Column("type", sa.String(30), nullable=False), sa.Column("currency", sa.String(10), nullable=False), sa.Column("current_balance", sa.Numeric(20, 8), nullable=False, server_default="0"), sa.Column("credit_limit", sa.Numeric(20, 8), nullable=True), sa.Column("interest_rate", sa.Numeric(8, 4), nullable=True), sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), sa.Column("include_in_net_worth", sa.Boolean, nullable=False, server_default="true"), sa.Column("color", sa.String(7), nullable=False, server_default="#6366f1"), sa.Column("icon", sa.Text, nullable=True), sa.Column("notes", sa.LargeBinary, nullable=True), sa.Column("meta", postgresql.JSONB, nullable=False, server_default="{}"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_accounts_user_id", "accounts", ["user_id"]) # categories op.create_table( "categories", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True), sa.Column("name", sa.Text, nullable=False), sa.Column("parent_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id"), nullable=True), sa.Column("type", sa.String(20), nullable=False), sa.Column("icon", sa.Text, nullable=True), sa.Column("color", sa.String(7), nullable=True), sa.Column("is_system", sa.Boolean, nullable=False, server_default="false"), sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) # transactions op.create_table( "transactions", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False), sa.Column("transfer_account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=True), sa.Column("category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id"), nullable=True), sa.Column("type", sa.String(20), nullable=False), sa.Column("status", sa.String(20), nullable=False, server_default="cleared"), sa.Column("amount", sa.Numeric(20, 8), nullable=False), sa.Column("amount_base", sa.Numeric(20, 8), nullable=True), sa.Column("currency", sa.String(10), nullable=False), sa.Column("base_currency", sa.String(10), nullable=False), sa.Column("exchange_rate", sa.Numeric(20, 10), nullable=True), sa.Column("date", sa.Date, nullable=False), sa.Column("description", sa.LargeBinary, nullable=False), sa.Column("merchant", sa.LargeBinary, nullable=True), sa.Column("notes", sa.LargeBinary, nullable=True), sa.Column("tags", postgresql.ARRAY(sa.Text), nullable=False, server_default="{}"), sa.Column("is_recurring", sa.Boolean, nullable=False, server_default="false"), sa.Column("recurring_rule", postgresql.JSONB, nullable=True), sa.Column("attachment_refs", postgresql.JSONB, nullable=False, server_default="[]"), sa.Column("import_hash", sa.Text, nullable=True), sa.Column("meta", postgresql.JSONB, nullable=False, server_default="{}"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_transactions_user_id_date", "transactions", ["user_id", "date"]) op.create_index("ix_transactions_account_id", "transactions", ["account_id"]) op.create_index("ix_transactions_import_hash", "transactions", ["import_hash"]) # budgets op.create_table( "budgets", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id"), nullable=False), sa.Column("name", sa.Text, nullable=False), sa.Column("amount", sa.Numeric(20, 8), nullable=False), sa.Column("currency", sa.String(10), nullable=False), sa.Column("period", sa.String(20), nullable=False), sa.Column("start_date", sa.Date, nullable=False), sa.Column("end_date", sa.Date, nullable=True), sa.Column("rollover", sa.Boolean, nullable=False, server_default="false"), sa.Column("alert_threshold", sa.Numeric(5, 2), nullable=False, server_default="80"), sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), ) # assets op.create_table( "assets", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("symbol", sa.Text, nullable=False), sa.Column("name", sa.Text, nullable=False), sa.Column("type", sa.String(30), nullable=False), sa.Column("currency", sa.String(10), nullable=False), sa.Column("exchange", sa.Text, nullable=True), sa.Column("isin", sa.String(12), nullable=True), sa.Column("data_source", sa.String(30), nullable=False, server_default="yahoo_finance"), sa.Column("data_source_id", sa.Text, nullable=True), sa.Column("last_price", sa.Numeric(20, 8), nullable=True), sa.Column("last_price_at", sa.DateTime(timezone=True), nullable=True), sa.Column("price_change_24h", sa.Numeric(10, 4), nullable=True), sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), ) op.create_unique_constraint("uq_assets_symbol_exchange", "assets", ["symbol", "exchange"]) # asset_prices op.create_table( "asset_prices", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False), sa.Column("date", sa.Date, nullable=False), sa.Column("open", sa.Numeric(20, 8), nullable=True), sa.Column("high", sa.Numeric(20, 8), nullable=True), sa.Column("low", sa.Numeric(20, 8), nullable=True), sa.Column("close", sa.Numeric(20, 8), nullable=False), sa.Column("volume", sa.Numeric(30, 8), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_unique_constraint("uq_asset_prices_asset_date", "asset_prices", ["asset_id", "date"]) # investment_holdings op.create_table( "investment_holdings", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("account_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("accounts.id"), nullable=False), sa.Column("asset_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("assets.id"), nullable=False), sa.Column("quantity", sa.Numeric(30, 10), nullable=False, server_default="0"), sa.Column("avg_cost_basis", sa.Numeric(20, 8), nullable=False, server_default="0"), sa.Column("currency", sa.String(10), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), ) op.create_unique_constraint("uq_holdings_account_asset", "investment_holdings", ["account_id", "asset_id"]) # investment_transactions op.create_table( "investment_transactions", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("holding_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("investment_holdings.id"), nullable=False), sa.Column("transaction_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("transactions.id"), nullable=True), sa.Column("type", sa.String(20), nullable=False), sa.Column("quantity", sa.Numeric(30, 10), nullable=False), sa.Column("price", sa.Numeric(20, 8), nullable=False), sa.Column("fees", sa.Numeric(20, 8), nullable=False, server_default="0"), sa.Column("total_amount", sa.Numeric(20, 8), nullable=False), sa.Column("currency", sa.String(10), nullable=False), sa.Column("date", sa.Date, nullable=False), sa.Column("notes", sa.LargeBinary, nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) # currencies op.create_table( "currencies", sa.Column("code", sa.String(10), primary_key=True), sa.Column("name", sa.Text, nullable=False), sa.Column("symbol", sa.String(5), nullable=False), sa.Column("is_crypto", sa.Boolean, nullable=False, server_default="false"), sa.Column("decimal_places", sa.Integer, nullable=False, server_default="2"), sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"), ) # exchange_rates op.create_table( "exchange_rates", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("base_currency", sa.String(10), nullable=False), sa.Column("quote_currency", sa.String(10), nullable=False), sa.Column("rate", sa.Numeric(20, 10), nullable=False), sa.Column("source", sa.String(50), nullable=False), sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), ) op.create_index("ix_exchange_rates_pair", "exchange_rates", ["base_currency", "quote_currency", "fetched_at"]) # net_worth_snapshots op.create_table( "net_worth_snapshots", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), sa.Column("date", sa.Date, nullable=False), sa.Column("total_assets", sa.Numeric(20, 8), nullable=False), sa.Column("total_liabilities", sa.Numeric(20, 8), nullable=False), sa.Column("net_worth", sa.Numeric(20, 8), nullable=False), sa.Column("base_currency", sa.String(10), nullable=False), sa.Column("breakdown", postgresql.JSONB, nullable=False, server_default="{}"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_unique_constraint("uq_nw_snapshots_user_date", "net_worth_snapshots", ["user_id", "date"]) # audit_logs op.create_table( "audit_logs", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), sa.Column("action", sa.String(50), nullable=False), sa.Column("resource_type", sa.Text, nullable=True), sa.Column("resource_id", postgresql.UUID(as_uuid=True), nullable=True), sa.Column("ip_address", postgresql.INET, nullable=True), sa.Column("user_agent", sa.Text, nullable=True), sa.Column("metadata", postgresql.JSONB, nullable=False, server_default="{}"), sa.Column("success", sa.Boolean, nullable=False, server_default="true"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), ) op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"]) op.create_index("ix_audit_logs_action", "audit_logs", ["action"]) # Enable RLS on user-owned tables for table in ["accounts", "transactions", "budgets", "investment_holdings", "investment_transactions", "net_worth_snapshots"]: op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") op.execute(f""" CREATE POLICY {table}_user_isolation ON {table} USING (user_id = current_app_user_id()) """) # Seed default currencies op.execute(""" INSERT INTO currencies (code, name, symbol, is_crypto, decimal_places) VALUES ('GBP', 'British Pound', '£', false, 2), ('USD', 'US Dollar', '$', false, 2), ('EUR', 'Euro', '€', false, 2), ('JPY', 'Japanese Yen', '¥', false, 0), ('CAD', 'Canadian Dollar', 'CA$', false, 2), ('AUD', 'Australian Dollar', 'A$', false, 2), ('CHF', 'Swiss Franc', 'Fr', false, 2), ('BTC', 'Bitcoin', '₿', true, 8), ('ETH', 'Ethereum', 'Ξ', true, 8) ON CONFLICT (code) DO NOTHING """) def downgrade() -> None: for table in ["accounts", "transactions", "budgets", "investment_holdings", "investment_transactions", "net_worth_snapshots"]: op.execute(f"DROP POLICY IF EXISTS {table}_user_isolation ON {table}") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") for table in [ "audit_logs", "net_worth_snapshots", "exchange_rates", "currencies", "investment_transactions", "investment_holdings", "asset_prices", "assets", "budgets", "transactions", "categories", "accounts", "sessions", "users", ]: op.drop_table(table)