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

28
backend/app/db/base.py Normal file
View file

@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
pass
def create_engine():
settings = get_settings()
return create_async_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=settings.is_development,
)
def create_session_factory(engine):
return async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)

View file

@ -0,0 +1,19 @@
from app.db.models.user import User
from app.db.models.session import Session
from app.db.models.account import Account
from app.db.models.category import Category
from app.db.models.transaction import Transaction
from app.db.models.budget import Budget
from app.db.models.asset import Asset
from app.db.models.asset_price import AssetPrice
from app.db.models.investment_holding import InvestmentHolding
from app.db.models.investment_transaction import InvestmentTransaction
from app.db.models.currency import Currency, ExchangeRate
from app.db.models.net_worth_snapshot import NetWorthSnapshot
from app.db.models.audit_log import AuditLog
__all__ = [
"User", "Session", "Account", "Category", "Transaction", "Budget",
"Asset", "AssetPrice", "InvestmentHolding", "InvestmentTransaction",
"Currency", "ExchangeRate", "NetWorthSnapshot", "AuditLog",
]

View file

@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, ForeignKey, LargeBinary, Numeric, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Account(Base):
__tablename__ = "accounts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name_enc: Mapped[bytes] = mapped_column("name", LargeBinary, nullable=False)
institution_enc: Mapped[bytes | None] = mapped_column("institution", LargeBinary, nullable=True)
type: Mapped[str] = mapped_column(String(30), nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False)
current_balance: Mapped[Decimal] = mapped_column(Numeric(20, 8), default=0, nullable=False)
credit_limit: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
interest_rate: Mapped[Decimal | None] = mapped_column(Numeric(8, 4), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
include_in_net_worth: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
color: Mapped[str] = mapped_column(String(7), default="#6366f1", nullable=False)
icon: Mapped[str | None] = mapped_column(Text, nullable=True)
notes_enc: Mapped[bytes | None] = mapped_column("notes", LargeBinary, nullable=True)
meta: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
user: Mapped["User"] = relationship(back_populates="accounts", lazy="noload") # type: ignore[name-defined]
transactions: Mapped[list["Transaction"]] = relationship(foreign_keys="Transaction.account_id", back_populates="account", lazy="noload") # type: ignore[name-defined]
holdings: Mapped[list["InvestmentHolding"]] = relationship(back_populates="account", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, Numeric, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Asset(Base):
__tablename__ = "assets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
symbol: Mapped[str] = mapped_column(Text, nullable=False, index=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
type: Mapped[str] = mapped_column(String(30), nullable=False) # stock|etf|mutual_fund|bond|crypto|commodity|other
currency: Mapped[str] = mapped_column(String(10), nullable=False)
exchange: Mapped[str | None] = mapped_column(Text, nullable=True)
isin: Mapped[str | None] = mapped_column(String(12), nullable=True)
data_source: Mapped[str] = mapped_column(String(30), default="yahoo_finance", nullable=False)
data_source_id: Mapped[str | None] = mapped_column(Text, nullable=True)
last_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
last_price_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
price_change_24h: Mapped[Decimal | None] = mapped_column(Numeric(10, 4), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prices: Mapped[list["AssetPrice"]] = relationship(back_populates="asset", lazy="noload") # type: ignore[name-defined]
holdings: Mapped[list["InvestmentHolding"]] = relationship(back_populates="asset", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,25 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class AssetPrice(Base):
__tablename__ = "asset_prices"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
open: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
high: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
low: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
close: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
volume: Mapped[Decimal | None] = mapped_column(Numeric(30, 8), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
asset: Mapped["Asset"] = relationship(back_populates="prices", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
resource_type: Mapped[str | None] = mapped_column(Text, nullable=True)
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
meta: Mapped[dict] = mapped_column("metadata", JSONB, default=dict, nullable=False)
success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View file

@ -0,0 +1,30 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Budget(Base):
__tablename__ = "budgets"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
category_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False)
period: Mapped[str] = mapped_column(String(20), nullable=False) # weekly|monthly|quarterly|yearly
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
rollover: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
alert_threshold: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=80, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
category: Mapped["Category"] = relationship(back_populates="budgets", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
type: Mapped[str] = mapped_column(String(20), nullable=False) # income | expense | transfer
icon: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
children: Mapped[list["Category"]] = relationship(lazy="noload")
budgets: Mapped[list["Budget"]] = relationship(back_populates="category", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, Integer, Numeric, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Currency(Base):
__tablename__ = "currencies"
code: Mapped[str] = mapped_column(String(10), primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
symbol: Mapped[str] = mapped_column(String(5), nullable=False)
is_crypto: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
decimal_places: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class ExchangeRate(Base):
__tablename__ = "exchange_rates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
base_currency: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
quote_currency: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
rate: Mapped[Decimal] = mapped_column(Numeric(20, 10), nullable=False)
source: Mapped[str] = mapped_column(String(50), nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)

View file

@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class InvestmentHolding(Base):
__tablename__ = "investment_holdings"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False)
asset_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("assets.id"), nullable=False)
quantity: Mapped[Decimal] = mapped_column(Numeric(30, 10), default=0, nullable=False)
avg_cost_basis: Mapped[Decimal] = mapped_column(Numeric(20, 8), default=0, nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
account: Mapped["Account"] = relationship(back_populates="holdings", lazy="noload") # type: ignore[name-defined]
asset: Mapped["Asset"] = relationship(back_populates="holdings", lazy="noload") # type: ignore[name-defined]
investment_transactions: Mapped[list["InvestmentTransaction"]] = relationship(back_populates="holding", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,29 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, LargeBinary, Numeric, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class InvestmentTransaction(Base):
__tablename__ = "investment_transactions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
holding_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("investment_holdings.id"), nullable=False, index=True)
transaction_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("transactions.id"), nullable=True)
type: Mapped[str] = mapped_column(String(20), nullable=False) # buy|sell|dividend|split|merger|transfer_in|transfer_out|fee
quantity: Mapped[Decimal] = mapped_column(Numeric(30, 10), nullable=False)
price: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
fees: Mapped[Decimal] = mapped_column(Numeric(20, 8), default=0, nullable=False)
total_amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
notes_enc: Mapped[bytes | None] = mapped_column("notes", LargeBinary, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
holding: Mapped["InvestmentHolding"] = relationship(back_populates="investment_transactions", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,23 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class NetWorthSnapshot(Base):
__tablename__ = "net_worth_snapshots"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
total_assets: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
total_liabilities: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
net_worth: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
base_currency: Mapped[str] = mapped_column(String(10), nullable=False)
breakdown: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View file

@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True)
ip_address: Mapped[str | None] = mapped_column(INET, nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
last_active_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
user: Mapped["User"] = relationship(back_populates="sessions", lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,42 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, LargeBinary, Numeric, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class Transaction(Base):
__tablename__ = "transactions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=False, index=True)
transfer_account_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("accounts.id"), nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True, index=True)
type: Mapped[str] = mapped_column(String(20), nullable=False) # income|expense|transfer|investment
status: Mapped[str] = mapped_column(String(20), default="cleared", nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 8), nullable=False)
amount_base: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True)
currency: Mapped[str] = mapped_column(String(10), nullable=False)
base_currency: Mapped[str] = mapped_column(String(10), nullable=False)
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(20, 10), nullable=True)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
description_enc: Mapped[bytes] = mapped_column("description", LargeBinary, nullable=False)
merchant_enc: Mapped[bytes | None] = mapped_column("merchant", LargeBinary, nullable=True)
notes_enc: Mapped[bytes | None] = mapped_column("notes", LargeBinary, nullable=True)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False)
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
recurring_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
attachment_refs: Mapped[list] = mapped_column(JSONB, default=list, nullable=False)
import_hash: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
meta: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
account: Mapped["Account"] = relationship(foreign_keys=[account_id], back_populates="transactions", lazy="noload") # type: ignore[name-defined]
category: Mapped["Category | None"] = relationship(lazy="noload") # type: ignore[name-defined]

View file

@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import INET, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
totp_secret_enc: Mapped[bytes | None] = mapped_column("totp_secret", type_=String, nullable=True)
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
totp_backup_codes_enc: Mapped[str | None] = mapped_column("totp_backup_codes", Text, nullable=True)
display_name: Mapped[str] = mapped_column(Text, nullable=False)
base_currency: Mapped[str] = mapped_column(String(10), default="GBP", nullable=False)
theme: Mapped[str] = mapped_column(String(20), default="dark", nullable=False)
locale: Mapped[str] = mapped_column(String(20), default="en-GB", nullable=False)
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_ip: Mapped[str | None] = mapped_column(INET, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accounts: Mapped[list["Account"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]
sessions: Mapped[list["Session"]] = relationship(back_populates="user", lazy="noload") # type: ignore[name-defined]