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:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

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