finance_budget.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. """Budget validation helpers for finance-aware print dispatch."""
  2. import calendar
  3. from datetime import datetime, timezone
  4. from typing import TYPE_CHECKING
  5. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  6. from fastapi import HTTPException
  7. from sqlalchemy import case, func, or_, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.models.finance import BudgetReservation, CostCenter, CostCenterMember, WalletTransaction
  10. from backend.app.models.print_queue import PrintQueueItem
  11. from backend.app.models.settings import Settings
  12. from backend.app.models.user import User
  13. if TYPE_CHECKING:
  14. from backend.app.core.auth import ApiKeyActor
  15. async def is_billing_enabled(db: AsyncSession) -> bool:
  16. # Consider any 'billing_enabled' setting with a true-ish value as enabling billing.
  17. result = await db.execute(
  18. select(func.count())
  19. .select_from(Settings)
  20. .where(Settings.key == "billing_enabled", func.lower(func.coalesce(Settings.value, "")) == "true")
  21. )
  22. count = int(result.scalar_one() or 0)
  23. return count > 0
  24. async def is_printer_kill_switch_enabled(db: AsyncSession) -> bool:
  25. """Return True when billing and the printer kill-switch are both enabled."""
  26. result = await db.execute(
  27. select(Settings.key, Settings.value).where(Settings.key.in_(("billing_enabled", "printer_kill_switch_enabled")))
  28. )
  29. values = {key: (value or "").strip().lower() for key, value in result.all()}
  30. return values.get("billing_enabled") == "true" and values.get("printer_kill_switch_enabled") == "true"
  31. async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
  32. result = await db.execute(
  33. select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
  34. )
  35. values = {setting.key: setting.value for setting in result.scalars().all()}
  36. desired_day = 1
  37. try:
  38. parsed = int(values.get("finance_budget_reset_day") or 1)
  39. if 1 <= parsed <= 31:
  40. desired_day = parsed
  41. except (TypeError, ValueError):
  42. pass
  43. timezone_name = values.get("finance_budget_reset_timezone") or "UTC"
  44. try:
  45. tz = ZoneInfo(timezone_name)
  46. except ZoneInfoNotFoundError:
  47. tz = ZoneInfo("UTC")
  48. now = datetime.now(tz)
  49. current_month_reset_day = min(desired_day, calendar.monthrange(now.year, now.month)[1])
  50. if now.day < current_month_reset_day:
  51. month = now.month - 1
  52. year = now.year
  53. if month == 0:
  54. month = 12
  55. year -= 1
  56. else:
  57. month = now.month
  58. year = now.year
  59. reset_day = min(desired_day, calendar.monthrange(year, month)[1])
  60. return datetime(year, month, reset_day, tzinfo=tz).astimezone(timezone.utc)
  61. async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly: bool) -> float:
  62. spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
  63. conditions = [
  64. WalletTransaction.cost_center_id == cost_center_id,
  65. WalletTransaction.cost_center_id.is_not(None),
  66. WalletTransaction.is_voided.is_(False),
  67. ]
  68. if monthly:
  69. conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
  70. result = await db.execute(select(func.coalesce(func.sum(spend_expr), 0.0)).where(*conditions))
  71. return float(result.scalar() or 0.0)
  72. async def get_cost_center_reserved_map(
  73. db: AsyncSession,
  74. cost_center_ids: list[int],
  75. *,
  76. exclude_queue_item_id: int | None = None,
  77. exclude_reservation_source_type: str | None = None,
  78. exclude_reservation_source_id: int | None = None,
  79. ) -> dict[int, float]:
  80. """Return active holds plus unreserved open queue estimates per cost center.
  81. Queue items that already have an active ``print_queue`` reservation are
  82. excluded from the queue sum because the reservation is their replacement,
  83. not an additional hold.
  84. """
  85. if not cost_center_ids:
  86. return {}
  87. active_queue_reservation = (
  88. select(BudgetReservation.id)
  89. .where(
  90. BudgetReservation.status == "active",
  91. BudgetReservation.source_type == "print_queue",
  92. BudgetReservation.source_id == PrintQueueItem.id,
  93. )
  94. .exists()
  95. )
  96. queue_conditions = [
  97. PrintQueueItem.cost_center_id.in_(cost_center_ids),
  98. PrintQueueItem.status.in_(("pending", "printing")),
  99. ~active_queue_reservation,
  100. ]
  101. if exclude_queue_item_id is not None:
  102. queue_conditions.append(PrintQueueItem.id != exclude_queue_item_id)
  103. queue_rows = await db.execute(
  104. select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
  105. .where(*queue_conditions)
  106. .group_by(PrintQueueItem.cost_center_id)
  107. )
  108. reserved_map = {int(center_id): float(value) for center_id, value in queue_rows.all() if center_id is not None}
  109. reservation_conditions = [
  110. BudgetReservation.cost_center_id.in_(cost_center_ids),
  111. BudgetReservation.status == "active",
  112. ]
  113. if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
  114. reservation_conditions.append(
  115. ~(
  116. (BudgetReservation.source_type == exclude_reservation_source_type)
  117. & (BudgetReservation.source_id == exclude_reservation_source_id)
  118. )
  119. )
  120. reservation_rows = await db.execute(
  121. select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
  122. .where(*reservation_conditions)
  123. .group_by(BudgetReservation.cost_center_id)
  124. )
  125. for center_id, value in reservation_rows.all():
  126. if center_id is not None:
  127. reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
  128. return reserved_map
  129. async def validate_print_budget(
  130. db: AsyncSession,
  131. *,
  132. cost_center_id: int | None,
  133. estimated_cost: float | None,
  134. current_user: "User | ApiKeyActor | None",
  135. quantity: int = 1,
  136. exclude_queue_item_id: int | None = None,
  137. exclude_reservation_source_type: str | None = None,
  138. exclude_reservation_source_id: int | None = None,
  139. ) -> None:
  140. """Validate that a print can be assigned to a cost center budget."""
  141. if not await is_billing_enabled(db):
  142. return
  143. if cost_center_id is None:
  144. raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
  145. if estimated_cost is None or estimated_cost <= 0:
  146. raise HTTPException(status_code=400, detail="Estimated cost is required for cost center prints")
  147. center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id).with_for_update())
  148. if not center:
  149. raise HTTPException(status_code=404, detail="Cost center not found")
  150. if not center.is_active:
  151. raise HTTPException(status_code=400, detail="Cost center is inactive")
  152. if current_user is not None and not current_user.is_admin:
  153. if center.is_private:
  154. if center.owner_user_id != current_user.id:
  155. raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
  156. else:
  157. member = await db.scalar(
  158. select(CostCenterMember).where(
  159. CostCenterMember.cost_center_id == cost_center_id,
  160. CostCenterMember.user_id == current_user.id,
  161. )
  162. )
  163. if not member or not member.can_print:
  164. raise HTTPException(status_code=403, detail="You cannot print with this cost center")
  165. budget_limit = center.monthly_budget if center.monthly_budget is not None else center.total_budget
  166. if budget_limit is None:
  167. return
  168. used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
  169. reserved_map = await get_cost_center_reserved_map(
  170. db,
  171. [cost_center_id],
  172. exclude_queue_item_id=exclude_queue_item_id,
  173. exclude_reservation_source_type=exclude_reservation_source_type,
  174. exclude_reservation_source_id=exclude_reservation_source_id,
  175. )
  176. reserved = reserved_map.get(cost_center_id, 0.0)
  177. requested = estimated_cost * max(1, quantity)
  178. available = float(budget_limit) - used - reserved
  179. if requested > available:
  180. raise HTTPException(
  181. status_code=400,
  182. detail=f"Estimated print cost exceeds available cost center budget ({requested:.2f} > {available:.2f})",
  183. )
  184. async def create_budget_reservation(
  185. db: AsyncSession,
  186. *,
  187. cost_center_id: int | None,
  188. estimated_cost: float | None,
  189. current_user: User | None,
  190. source_type: str,
  191. source_id: int | None,
  192. print_archive_id: int | None = None,
  193. exclude_queue_item_id: int | None = None,
  194. ) -> BudgetReservation | None:
  195. if not await is_billing_enabled(db):
  196. return None
  197. if cost_center_id is None:
  198. raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
  199. await validate_print_budget(
  200. db,
  201. cost_center_id=cost_center_id,
  202. estimated_cost=estimated_cost,
  203. current_user=current_user,
  204. exclude_queue_item_id=exclude_queue_item_id,
  205. exclude_reservation_source_type=source_type,
  206. exclude_reservation_source_id=source_id,
  207. )
  208. existing = None
  209. if source_id is not None:
  210. existing = await db.scalar(
  211. select(BudgetReservation).where(
  212. BudgetReservation.status == "active",
  213. BudgetReservation.source_type == source_type,
  214. BudgetReservation.source_id == source_id,
  215. )
  216. )
  217. if existing is not None:
  218. existing.cost_center_id = cost_center_id
  219. existing.amount = float(estimated_cost or 0.0)
  220. if print_archive_id is not None:
  221. existing.print_archive_id = print_archive_id
  222. await db.flush()
  223. return existing
  224. reservation = BudgetReservation(
  225. cost_center_id=cost_center_id,
  226. amount=float(estimated_cost or 0.0),
  227. status="active",
  228. source_type=source_type,
  229. source_id=source_id,
  230. print_archive_id=print_archive_id,
  231. )
  232. db.add(reservation)
  233. await db.flush()
  234. return reservation
  235. async def release_budget_reservation(
  236. db: AsyncSession,
  237. *,
  238. source_type: str | None = None,
  239. source_id: int | None = None,
  240. print_archive_id: int | None = None,
  241. status: str = "released",
  242. ) -> int:
  243. """By the archive, the reservations of its queue items that are over: one
  244. still pending or printing is another print's (another plate of it)."""
  245. conditions = [BudgetReservation.status == "active"]
  246. if print_archive_id is not None:
  247. conditions.append(BudgetReservation.print_archive_id == print_archive_id)
  248. conditions.append(
  249. or_(
  250. BudgetReservation.source_type != "print_queue",
  251. BudgetReservation.source_id.not_in(
  252. select(PrintQueueItem.id).where(PrintQueueItem.status.in_(("pending", "printing")))
  253. ),
  254. )
  255. )
  256. else:
  257. conditions.extend(
  258. [
  259. BudgetReservation.source_type == source_type,
  260. BudgetReservation.source_id == source_id,
  261. ]
  262. )
  263. result = await db.execute(select(BudgetReservation).where(*conditions))
  264. reservations = result.scalars().all()
  265. for reservation in reservations:
  266. reservation.status = status
  267. reservation.released_at = datetime.now(timezone.utc)
  268. if reservations:
  269. await db.flush()
  270. return len(reservations)