import uuid from datetime import date from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, Date, ForeignKey, ForeignKeyConstraint, Integer, String, UniqueConstraint, Uuid, ) from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, PkMixin, TimestampMixin ACCOUNT_KINDS = ("cash", "debit", "credit") ACCOUNT_STATUSES = ("active", "archived") OPERATION_TYPES = ("expense", "income", "transfer", "refund") OPERATION_STATUSES = ("confirmed", "voided") class Account(Base, PkMixin, TimestampMixin): """Financial account. Balance is always derived: opening + confirmed postings.""" __tablename__ = "accounts" __table_args__ = ( CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"), CheckConstraint("status IN ('active', 'archived')", name="status_valid"), CheckConstraint("currency = 'RUB'", name="currency_rub_only"), UniqueConstraint("space_id", "id", name="uq_accounts_space_id"), ) space_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True ) name: Mapped[str] = mapped_column(String(200), nullable=False) kind: Mapped[str] = mapped_column(String(16), nullable=False) currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB") opening_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) opened_on: Mapped[date] = mapped_column(Date, nullable=False) bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True) masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True) fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) __mapper_args__ = {"version_id_col": version} class Operation(Base, PkMixin, TimestampMixin): """User financial fact. amount_kopecks is always positive; direction lives in postings.""" __tablename__ = "operations" __table_args__ = ( CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"), CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"), CheckConstraint("amount_kopecks > 0", name="amount_positive"), CheckConstraint( "NOT (type = 'transfer' AND counter_account_id IS NULL)", name="transfer_requires_counter", ), ForeignKeyConstraint( ["space_id", "account_id"], ["accounts.space_id", "accounts.id"], name="fk_operations_account_tenant", ), ForeignKeyConstraint( ["space_id", "counter_account_id"], ["accounts.space_id", "accounts.id"], name="fk_operations_counter_account_tenant", ), ForeignKeyConstraint( ["space_id", "category_id"], ["categories.space_id", "categories.id"], name="fk_operations_category_tenant", ), ForeignKeyConstraint( ["space_id", "income_source_id"], ["income_sources.space_id", "income_sources.id"], name="fk_operations_income_source_tenant", ), ForeignKeyConstraint( ["space_id", "payee_id"], ["payees.space_id", "payees.id"], name="fk_operations_payee_tenant", ), ForeignKeyConstraint( ["space_id", "parent_operation_id"], ["operations.space_id", "operations.id"], name="fk_operations_parent_tenant", ), UniqueConstraint("space_id", "request_key", name="uq_operations_space_request_key"), UniqueConstraint("space_id", "id", name="uq_operations_space_id"), ) space_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True ) type: Mapped[str] = mapped_column(String(16), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default="confirmed") amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) counter_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True) income_source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) payee_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) parent_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) occurred_on: Mapped[date] = mapped_column(Date, nullable=False, index=True) posted_on: Mapped[date | None] = mapped_column(Date, nullable=True) description: Mapped[str | None] = mapped_column(String(500), nullable=True) void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) request_key: Mapped[str | None] = mapped_column(String(120), nullable=True) created_by_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) __mapper_args__ = {"version_id_col": version} class Posting(Base, PkMixin, TimestampMixin): """Signed balance change of one account within one operation. Sign convention (uniform for asset and credit accounts, from the account's perspective): income and refunds post positive; expenses post negative; transfers post negative on the source and positive on the destination. Credit accounts normally carry a non-positive balance; debt = -balance. """ __tablename__ = "postings" __table_args__ = ( CheckConstraint("amount_kopecks <> 0", name="amount_nonzero"), ForeignKeyConstraint( ["space_id", "operation_id"], ["operations.space_id", "operations.id"], ondelete="CASCADE", name="fk_postings_operation_tenant", ), ForeignKeyConstraint( ["space_id", "account_id"], ["accounts.space_id", "accounts.id"], name="fk_postings_account_tenant", ), UniqueConstraint("space_id", "id", name="uq_postings_space_id"), ) space_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True ) operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False) class Category(Base, PkMixin, TimestampMixin): """Budget category. Kept in ledger module to avoid circular imports.""" __tablename__ = "categories" __table_args__ = ( CheckConstraint("kind IN ('monthly', 'savings_envelope')", name="kind_valid"), ForeignKeyConstraint( ["space_id", "linked_account_id"], ["accounts.space_id", "accounts.id"], name="fk_categories_linked_account_tenant", ), UniqueConstraint("space_id", "id", name="uq_categories_space_id"), ) space_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True ) kind: Mapped[str] = mapped_column(String(16), nullable=False) system_key: Mapped[str | None] = mapped_column(String(64), nullable=True) name_override: Mapped[str | None] = mapped_column(String(200), nullable=True) custom_name: Mapped[str | None] = mapped_column(String(200), nullable=True) linked_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)