"""Phase 5.2 — Double-entry posting domain models.""" from __future__ import annotations import uuid from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base from app.models.base import TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin from app.models.types import ( GUID, JournalStatus, PostingStatus, VoucherStatus, ) class Voucher(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "vouchers" fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) voucher_number: Mapped[str] = mapped_column(String(50), nullable=False) voucher_date: Mapped[date] = mapped_column(Date, nullable=False) status: Mapped[VoucherStatus] = mapped_column( default=VoucherStatus.DRAFT, nullable=False ) description: Mapped[str | None] = mapped_column(Text, nullable=True) reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True) source_module: Mapped[str | None] = mapped_column(String(50), nullable=True) currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) total_debit: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) total_credit: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) reversed_voucher_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) lines: Mapped[list["VoucherLine"]] = relationship( back_populates="voucher", cascade="all, delete-orphan" ) journal_entries: Mapped[list["JournalEntry"]] = relationship(back_populates="voucher") __table_args__ = ( UniqueConstraint("tenant_id", "voucher_number", name="uq_voucher_tenant_number"), Index("ix_vouchers_tenant_status", "tenant_id", "status"), Index("ix_vouchers_fiscal_period", "tenant_id", "fiscal_period_id"), ) class VoucherLine(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "voucher_lines" voucher_id: Mapped[uuid.UUID] = mapped_column( GUID(), ForeignKey("vouchers.id", ondelete="CASCADE"), nullable=False ) line_number: Mapped[int] = mapped_column(Integer, nullable=False) account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False) credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) currency_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True) voucher: Mapped["Voucher"] = relationship(back_populates="lines") __table_args__ = ( Index("ix_voucher_lines_voucher_id", "voucher_id"), Index("ix_voucher_lines_account_id", "tenant_id", "account_id"), ) class Journal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "journals" code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint("tenant_id", "code", name="uq_journal_tenant_code"), ) class JournalEntry(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Immutable after posting — only created via Posting Engine.""" __tablename__ = "journal_entries" voucher_id: Mapped[uuid.UUID] = mapped_column( GUID(), ForeignKey("vouchers.id", ondelete="RESTRICT"), nullable=False ) journal_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) fiscal_period_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) entry_number: Mapped[str] = mapped_column(String(50), nullable=False) entry_date: Mapped[date] = mapped_column(Date, nullable=False) status: Mapped[JournalStatus] = mapped_column( default=JournalStatus.DRAFT, nullable=False ) account_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) debit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False) credit: Mapped[Decimal] = mapped_column(Numeric(18, 4), default=Decimal("0"), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) cost_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) project_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) posted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) voucher: Mapped["Voucher"] = relationship(back_populates="journal_entries") __table_args__ = ( Index("ix_journal_entries_tenant_account", "tenant_id", "account_id"), Index("ix_journal_entries_voucher", "voucher_id"), Index("ix_journal_entries_period", "tenant_id", "fiscal_period_id"), ) class PostingLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "posting_logs" voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) operation: Mapped[str] = mapped_column(String(50), nullable=False) status: Mapped[PostingStatus] = mapped_column(nullable=False) actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) source_module: Mapped[str | None] = mapped_column(String(50), nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True) correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True) __table_args__ = (Index("ix_posting_logs_voucher", "tenant_id", "voucher_id"),) class PostingError(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "posting_errors" voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) error_code: Mapped[str] = mapped_column(String(50), nullable=False) error_message: Mapped[str] = mapped_column(Text, nullable=False) field_name: Mapped[str | None] = mapped_column(String(100), nullable=True) __table_args__ = (Index("ix_posting_errors_voucher", "tenant_id", "voucher_id"),) class PostingReference(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "posting_references" voucher_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) source_module: Mapped[str] = mapped_column(String(50), nullable=False) source_document_type: Mapped[str] = mapped_column(String(50), nullable=False) source_document_id: Mapped[str] = mapped_column(String(100), nullable=False) is_posted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) __table_args__ = ( UniqueConstraint( "tenant_id", "source_module", "source_document_type", "source_document_id", name="uq_posting_ref_source", ), ) class AccountingAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "accounting_audit_logs" operation: Mapped[str] = mapped_column(String(50), nullable=False) actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False) source_module: Mapped[str | None] = mapped_column(String(50), nullable=True) voucher_number: Mapped[str | None] = mapped_column(String(50), nullable=True) reference_number: Mapped[str | None] = mapped_column(String(100), nullable=True) resource_type: Mapped[str] = mapped_column(String(50), nullable=False) resource_id: Mapped[str] = mapped_column(String(100), nullable=False) before_value: Mapped[str | None] = mapped_column(Text, nullable=True) after_value: Mapped[str | None] = mapped_column(Text, nullable=True) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True) __table_args__ = ( Index("ix_audit_logs_tenant_created", "tenant_id", "created_at"), )