finance.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. from __future__ import annotations
  2. import uuid
  3. from datetime import datetime
  4. from enum import Enum as PyEnum
  5. from typing import TYPE_CHECKING
  6. from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
  7. from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
  8. from backend.app.core.database import Base
  9. if TYPE_CHECKING:
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.print_queue import PrintQueueItem
  12. from backend.app.models.user import User
  13. class TransactionType(str, PyEnum):
  14. PRINT_CHARGE = "print_charge"
  15. DEPOSIT = "deposit"
  16. WITHDRAW = "withdraw"
  17. MANUAL_ADJUSTMENT = "manual_adjustment"
  18. VALID_TRANSACTION_TYPES = {item.value for item in TransactionType}
  19. def normalize_transaction_type(value: str | TransactionType) -> str:
  20. if isinstance(value, TransactionType):
  21. return value.value
  22. if value not in VALID_TRANSACTION_TYPES:
  23. raise ValueError(f"Invalid transaction type: {value}")
  24. return value
  25. class UserWallet(Base):
  26. """Per-user wallet balance.
  27. Balance updates are driven by wallet transactions.
  28. """
  29. __tablename__ = "user_wallets"
  30. id: Mapped[int] = mapped_column(primary_key=True)
  31. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
  32. balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
  33. currency: Mapped[str] = mapped_column(String(3), default="EUR")
  34. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  35. user: Mapped[User] = relationship()
  36. class CostCenter(Base):
  37. """Cost center for assigning print costs and budgets."""
  38. __tablename__ = "cost_centers"
  39. id: Mapped[int] = mapped_column(primary_key=True)
  40. code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
  41. name: Mapped[str] = mapped_column(String(150), index=True)
  42. is_active: Mapped[bool] = mapped_column(Boolean, default=True)
  43. is_private: Mapped[bool] = mapped_column(Boolean, default=False)
  44. owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  45. total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  46. monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  47. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  48. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  49. owner: Mapped[User | None] = relationship()
  50. members: Mapped[list[CostCenterMember]] = relationship(
  51. "CostCenterMember",
  52. back_populates="cost_center",
  53. cascade="all, delete-orphan",
  54. lazy="selectin",
  55. )
  56. class CostCenterMember(Base):
  57. """User-to-cost-center assignment with print permission."""
  58. __tablename__ = "cost_center_members"
  59. __table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
  60. id: Mapped[int] = mapped_column(primary_key=True)
  61. cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
  62. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
  63. can_print: Mapped[bool] = mapped_column(Boolean, default=True)
  64. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  65. cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
  66. user: Mapped[User] = relationship()
  67. class BudgetReservation(Base):
  68. """Persisted budget hold for accepted print work that has not been charged yet."""
  69. __tablename__ = "budget_reservations"
  70. id: Mapped[int] = mapped_column(primary_key=True)
  71. cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
  72. amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
  73. status: Mapped[str] = mapped_column(String(20), default="active", index=True)
  74. source_type: Mapped[str] = mapped_column(String(50), index=True)
  75. source_id: Mapped[int | None] = mapped_column(index=True)
  76. print_archive_id: Mapped[int | None] = mapped_column(
  77. ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
  78. )
  79. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  80. released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  81. cost_center: Mapped[CostCenter] = relationship()
  82. print_archive: Mapped[PrintArchive | None] = relationship()
  83. class WalletTransaction(Base):
  84. """Immutable wallet ledger entry."""
  85. __tablename__ = "wallet_transactions"
  86. __table_args__ = (
  87. CheckConstraint(
  88. "transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
  89. name="ck_wallet_transactions_transaction_type",
  90. ),
  91. )
  92. id: Mapped[int] = mapped_column(primary_key=True)
  93. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
  94. cost_center_id: Mapped[int | None] = mapped_column(
  95. ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
  96. )
  97. transaction_type: Mapped[str] = mapped_column(String(40), index=True)
  98. amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
  99. balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  100. description: Mapped[str | None] = mapped_column(Text, nullable=True)
  101. created_by_user_id: Mapped[int | None] = mapped_column(
  102. ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
  103. )
  104. print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
  105. print_archive_id: Mapped[int | None] = mapped_column(
  106. ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
  107. )
  108. print_queue_id: Mapped[int | None] = mapped_column(
  109. ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
  110. )
  111. # Voided ledger rows stay persisted as run-scoped idempotency tombstones.
  112. # They are excluded from balances and API listings, but their print_run_id
  113. # prevents a delayed duplicate completion callback from recreating a charge
  114. # that an administrator deliberately removed.
  115. is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
  116. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
  117. user: Mapped[User] = relationship(foreign_keys=[user_id])
  118. cost_center: Mapped[CostCenter | None] = relationship()
  119. created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
  120. print_archive: Mapped[PrintArchive | None] = relationship()
  121. print_queue: Mapped[PrintQueueItem | None] = relationship()
  122. @validates("transaction_type")
  123. def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
  124. return normalize_transaction_type(value)