Files
bambuddy/backend/app/models/finance.py
T

167 lines
7.1 KiB
Python

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)