Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
308
backend/alembic/versions/0001_initial_schema.py
Normal file
308
backend/alembic/versions/0001_initial_schema.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue