finance_budget.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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, 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. conditions = [BudgetReservation.status == "active"]
  244. if print_archive_id is not None:
  245. conditions.append(BudgetReservation.print_archive_id == print_archive_id)
  246. else:
  247. conditions.extend(
  248. [
  249. BudgetReservation.source_type == source_type,
  250. BudgetReservation.source_id == source_id,
  251. ]
  252. )
  253. result = await db.execute(select(BudgetReservation).where(*conditions))
  254. reservations = result.scalars().all()
  255. for reservation in reservations:
  256. reservation.status = status
  257. reservation.released_at = datetime.now(timezone.utc)
  258. if reservations:
  259. await db.flush()
  260. return len(reservations)