finance.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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. No currency column: an install has exactly one currency, held in the
  29. ``currency`` app setting, and nothing here converts between currencies. The
  30. column that used to sit on this table recorded whatever was configured when
  31. the row happened to be created, three of the four writers hardcoded "EUR"
  32. into it, and the Finance page rendered what it found -- so an install set
  33. to AUD reported euros (#3123).
  34. """
  35. __tablename__ = "user_wallets"
  36. id: Mapped[int] = mapped_column(primary_key=True)
  37. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
  38. balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
  39. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  40. user: Mapped[User] = relationship()
  41. class CostCenter(Base):
  42. """Cost center for assigning print costs and budgets."""
  43. __tablename__ = "cost_centers"
  44. id: Mapped[int] = mapped_column(primary_key=True)
  45. code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
  46. name: Mapped[str] = mapped_column(String(150), index=True)
  47. is_active: Mapped[bool] = mapped_column(Boolean, default=True)
  48. is_private: Mapped[bool] = mapped_column(Boolean, default=False)
  49. owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  50. total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  51. monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  52. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  53. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  54. owner: Mapped[User | None] = relationship()
  55. members: Mapped[list[CostCenterMember]] = relationship(
  56. "CostCenterMember",
  57. back_populates="cost_center",
  58. cascade="all, delete-orphan",
  59. lazy="selectin",
  60. )
  61. class CostCenterMember(Base):
  62. """User-to-cost-center assignment with print permission."""
  63. __tablename__ = "cost_center_members"
  64. __table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
  65. id: Mapped[int] = mapped_column(primary_key=True)
  66. cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
  67. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
  68. can_print: Mapped[bool] = mapped_column(Boolean, default=True)
  69. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  70. cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
  71. user: Mapped[User] = relationship()
  72. class BudgetReservation(Base):
  73. """Persisted budget hold for accepted print work that has not been charged yet."""
  74. __tablename__ = "budget_reservations"
  75. id: Mapped[int] = mapped_column(primary_key=True)
  76. cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
  77. amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
  78. status: Mapped[str] = mapped_column(String(20), default="active", index=True)
  79. source_type: Mapped[str] = mapped_column(String(50), index=True)
  80. source_id: Mapped[int | None] = mapped_column(index=True)
  81. print_archive_id: Mapped[int | None] = mapped_column(
  82. ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
  83. )
  84. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  85. released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  86. cost_center: Mapped[CostCenter] = relationship()
  87. print_archive: Mapped[PrintArchive | None] = relationship()
  88. class WalletTransaction(Base):
  89. """Immutable wallet ledger entry."""
  90. __tablename__ = "wallet_transactions"
  91. __table_args__ = (
  92. CheckConstraint(
  93. "transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
  94. name="ck_wallet_transactions_transaction_type",
  95. ),
  96. )
  97. id: Mapped[int] = mapped_column(primary_key=True)
  98. user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
  99. cost_center_id: Mapped[int | None] = mapped_column(
  100. ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
  101. )
  102. transaction_type: Mapped[str] = mapped_column(String(40), index=True)
  103. amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
  104. balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
  105. description: Mapped[str | None] = mapped_column(Text, nullable=True)
  106. created_by_user_id: Mapped[int | None] = mapped_column(
  107. ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
  108. )
  109. print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
  110. print_archive_id: Mapped[int | None] = mapped_column(
  111. ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
  112. )
  113. print_queue_id: Mapped[int | None] = mapped_column(
  114. ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
  115. )
  116. # Voided ledger rows stay persisted as run-scoped idempotency tombstones.
  117. # They are excluded from balances and API listings, but their print_run_id
  118. # prevents a delayed duplicate completion callback from recreating a charge
  119. # that an administrator deliberately removed.
  120. is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
  121. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
  122. user: Mapped[User] = relationship(foreign_keys=[user_id])
  123. cost_center: Mapped[CostCenter | None] = relationship()
  124. created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
  125. print_archive: Mapped[PrintArchive | None] = relationship()
  126. print_queue: Mapped[PrintQueueItem | None] = relationship()
  127. @validates("transaction_type")
  128. def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
  129. return normalize_transaction_type(value)