| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- from __future__ import annotations
- import uuid
- from datetime import datetime
- from enum import Enum as PyEnum
- from typing import TYPE_CHECKING
- from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
- from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
- from backend.app.core.database import Base
- if TYPE_CHECKING:
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.user import User
- class TransactionType(str, PyEnum):
- PRINT_CHARGE = "print_charge"
- DEPOSIT = "deposit"
- WITHDRAW = "withdraw"
- MANUAL_ADJUSTMENT = "manual_adjustment"
- VALID_TRANSACTION_TYPES = {item.value for item in TransactionType}
- def normalize_transaction_type(value: str | TransactionType) -> str:
- if isinstance(value, TransactionType):
- return value.value
- if value not in VALID_TRANSACTION_TYPES:
- raise ValueError(f"Invalid transaction type: {value}")
- return value
- class UserWallet(Base):
- """Per-user wallet balance.
- Balance updates are driven by wallet transactions.
- """
- __tablename__ = "user_wallets"
- id: Mapped[int] = mapped_column(primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
- balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
- currency: Mapped[str] = mapped_column(String(3), default="EUR")
- updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
- user: Mapped[User] = relationship()
- class CostCenter(Base):
- """Cost center for assigning print costs and budgets."""
- __tablename__ = "cost_centers"
- id: Mapped[int] = mapped_column(primary_key=True)
- code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
- name: Mapped[str] = mapped_column(String(150), index=True)
- is_active: Mapped[bool] = mapped_column(Boolean, default=True)
- is_private: Mapped[bool] = mapped_column(Boolean, default=False)
- owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
- total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
- monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
- owner: Mapped[User | None] = relationship()
- members: Mapped[list[CostCenterMember]] = relationship(
- "CostCenterMember",
- back_populates="cost_center",
- cascade="all, delete-orphan",
- lazy="selectin",
- )
- class CostCenterMember(Base):
- """User-to-cost-center assignment with print permission."""
- __tablename__ = "cost_center_members"
- __table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
- id: Mapped[int] = mapped_column(primary_key=True)
- cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- can_print: Mapped[bool] = mapped_column(Boolean, default=True)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
- user: Mapped[User] = relationship()
- class BudgetReservation(Base):
- """Persisted budget hold for accepted print work that has not been charged yet."""
- __tablename__ = "budget_reservations"
- id: Mapped[int] = mapped_column(primary_key=True)
- cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
- amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
- status: Mapped[str] = mapped_column(String(20), default="active", index=True)
- source_type: Mapped[str] = mapped_column(String(50), index=True)
- source_id: Mapped[int | None] = mapped_column(index=True)
- print_archive_id: Mapped[int | None] = mapped_column(
- ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
- )
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
- cost_center: Mapped[CostCenter] = relationship()
- print_archive: Mapped[PrintArchive | None] = relationship()
- class WalletTransaction(Base):
- """Immutable wallet ledger entry."""
- __tablename__ = "wallet_transactions"
- __table_args__ = (
- CheckConstraint(
- "transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
- name="ck_wallet_transactions_transaction_type",
- ),
- )
- id: Mapped[int] = mapped_column(primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- cost_center_id: Mapped[int | None] = mapped_column(
- ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
- )
- transaction_type: Mapped[str] = mapped_column(String(40), index=True)
- amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
- balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
- description: Mapped[str | None] = mapped_column(Text, nullable=True)
- created_by_user_id: Mapped[int | None] = mapped_column(
- ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
- )
- print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
- print_archive_id: Mapped[int | None] = mapped_column(
- ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
- )
- print_queue_id: Mapped[int | None] = mapped_column(
- ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
- )
- # Voided ledger rows stay persisted as run-scoped idempotency tombstones.
- # They are excluded from balances and API listings, but their print_run_id
- # prevents a delayed duplicate completion callback from recreating a charge
- # that an administrator deliberately removed.
- is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
- user: Mapped[User] = relationship(foreign_keys=[user_id])
- cost_center: Mapped[CostCenter | None] = relationship()
- created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
- print_archive: Mapped[PrintArchive | None] = relationship()
- print_queue: Mapped[PrintQueueItem | None] = relationship()
- @validates("transaction_type")
- def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
- return normalize_transaction_type(value)
|