| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- """Budget validation helpers for finance-aware print dispatch."""
- import calendar
- from datetime import datetime, timezone
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
- from fastapi import HTTPException
- from sqlalchemy import case, func, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.models.finance import BudgetReservation, CostCenter, CostCenterMember, WalletTransaction
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- async def is_billing_enabled(db: AsyncSession) -> bool:
- # Consider any 'billing_enabled' setting with a true-ish value as enabling billing.
- result = await db.execute(
- select(func.count())
- .select_from(Settings)
- .where(Settings.key == "billing_enabled", func.lower(func.coalesce(Settings.value, "")) == "true")
- )
- count = int(result.scalar_one() or 0)
- return count > 0
- async def is_printer_kill_switch_enabled(db: AsyncSession) -> bool:
- """Return True when billing and the printer kill-switch are both enabled."""
- result = await db.execute(
- select(Settings.key, Settings.value).where(Settings.key.in_(("billing_enabled", "printer_kill_switch_enabled")))
- )
- values = {key: (value or "").strip().lower() for key, value in result.all()}
- return values.get("billing_enabled") == "true" and values.get("printer_kill_switch_enabled") == "true"
- async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
- result = await db.execute(
- select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
- )
- values = {setting.key: setting.value for setting in result.scalars().all()}
- desired_day = 1
- try:
- parsed = int(values.get("finance_budget_reset_day") or 1)
- if 1 <= parsed <= 31:
- desired_day = parsed
- except (TypeError, ValueError):
- pass
- timezone_name = values.get("finance_budget_reset_timezone") or "UTC"
- try:
- tz = ZoneInfo(timezone_name)
- except ZoneInfoNotFoundError:
- tz = ZoneInfo("UTC")
- now = datetime.now(tz)
- current_month_reset_day = min(desired_day, calendar.monthrange(now.year, now.month)[1])
- if now.day < current_month_reset_day:
- month = now.month - 1
- year = now.year
- if month == 0:
- month = 12
- year -= 1
- else:
- month = now.month
- year = now.year
- reset_day = min(desired_day, calendar.monthrange(year, month)[1])
- return datetime(year, month, reset_day, tzinfo=tz).astimezone(timezone.utc)
- async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly: bool) -> float:
- spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
- conditions = [
- WalletTransaction.cost_center_id == cost_center_id,
- WalletTransaction.cost_center_id.is_not(None),
- WalletTransaction.is_voided.is_(False),
- ]
- if monthly:
- conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
- result = await db.execute(select(func.coalesce(func.sum(spend_expr), 0.0)).where(*conditions))
- return float(result.scalar() or 0.0)
- async def get_cost_center_reserved_map(
- db: AsyncSession,
- cost_center_ids: list[int],
- *,
- exclude_queue_item_id: int | None = None,
- exclude_reservation_source_type: str | None = None,
- exclude_reservation_source_id: int | None = None,
- ) -> dict[int, float]:
- """Return active holds plus unreserved open queue estimates per cost center.
- Queue items that already have an active ``print_queue`` reservation are
- excluded from the queue sum because the reservation is their replacement,
- not an additional hold.
- """
- if not cost_center_ids:
- return {}
- active_queue_reservation = (
- select(BudgetReservation.id)
- .where(
- BudgetReservation.status == "active",
- BudgetReservation.source_type == "print_queue",
- BudgetReservation.source_id == PrintQueueItem.id,
- )
- .exists()
- )
- queue_conditions = [
- PrintQueueItem.cost_center_id.in_(cost_center_ids),
- PrintQueueItem.status.in_(("pending", "printing")),
- ~active_queue_reservation,
- ]
- if exclude_queue_item_id is not None:
- queue_conditions.append(PrintQueueItem.id != exclude_queue_item_id)
- queue_rows = await db.execute(
- select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
- .where(*queue_conditions)
- .group_by(PrintQueueItem.cost_center_id)
- )
- reserved_map = {int(center_id): float(value) for center_id, value in queue_rows.all() if center_id is not None}
- reservation_conditions = [
- BudgetReservation.cost_center_id.in_(cost_center_ids),
- BudgetReservation.status == "active",
- ]
- if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
- reservation_conditions.append(
- ~(
- (BudgetReservation.source_type == exclude_reservation_source_type)
- & (BudgetReservation.source_id == exclude_reservation_source_id)
- )
- )
- reservation_rows = await db.execute(
- select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
- .where(*reservation_conditions)
- .group_by(BudgetReservation.cost_center_id)
- )
- for center_id, value in reservation_rows.all():
- if center_id is not None:
- reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
- return reserved_map
- async def validate_print_budget(
- db: AsyncSession,
- *,
- cost_center_id: int | None,
- estimated_cost: float | None,
- current_user: User | None,
- quantity: int = 1,
- exclude_queue_item_id: int | None = None,
- exclude_reservation_source_type: str | None = None,
- exclude_reservation_source_id: int | None = None,
- ) -> None:
- """Validate that a print can be assigned to a cost center budget."""
- if not await is_billing_enabled(db):
- return
- if cost_center_id is None:
- raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
- if estimated_cost is None or estimated_cost <= 0:
- raise HTTPException(status_code=400, detail="Estimated cost is required for cost center prints")
- center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id).with_for_update())
- if not center:
- raise HTTPException(status_code=404, detail="Cost center not found")
- if not center.is_active:
- raise HTTPException(status_code=400, detail="Cost center is inactive")
- if current_user is not None and not current_user.is_admin:
- if center.is_private:
- if center.owner_user_id != current_user.id:
- raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
- else:
- member = await db.scalar(
- select(CostCenterMember).where(
- CostCenterMember.cost_center_id == cost_center_id,
- CostCenterMember.user_id == current_user.id,
- )
- )
- if not member or not member.can_print:
- raise HTTPException(status_code=403, detail="You cannot print with this cost center")
- budget_limit = center.monthly_budget if center.monthly_budget is not None else center.total_budget
- if budget_limit is None:
- return
- used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
- reserved_map = await get_cost_center_reserved_map(
- db,
- [cost_center_id],
- exclude_queue_item_id=exclude_queue_item_id,
- exclude_reservation_source_type=exclude_reservation_source_type,
- exclude_reservation_source_id=exclude_reservation_source_id,
- )
- reserved = reserved_map.get(cost_center_id, 0.0)
- requested = estimated_cost * max(1, quantity)
- available = float(budget_limit) - used - reserved
- if requested > available:
- raise HTTPException(
- status_code=400,
- detail=f"Estimated print cost exceeds available cost center budget ({requested:.2f} > {available:.2f})",
- )
- async def create_budget_reservation(
- db: AsyncSession,
- *,
- cost_center_id: int | None,
- estimated_cost: float | None,
- current_user: User | None,
- source_type: str,
- source_id: int | None,
- print_archive_id: int | None = None,
- exclude_queue_item_id: int | None = None,
- ) -> BudgetReservation | None:
- if not await is_billing_enabled(db):
- return None
- if cost_center_id is None:
- raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
- await validate_print_budget(
- db,
- cost_center_id=cost_center_id,
- estimated_cost=estimated_cost,
- current_user=current_user,
- exclude_queue_item_id=exclude_queue_item_id,
- exclude_reservation_source_type=source_type,
- exclude_reservation_source_id=source_id,
- )
- existing = None
- if source_id is not None:
- existing = await db.scalar(
- select(BudgetReservation).where(
- BudgetReservation.status == "active",
- BudgetReservation.source_type == source_type,
- BudgetReservation.source_id == source_id,
- )
- )
- if existing is not None:
- existing.cost_center_id = cost_center_id
- existing.amount = float(estimated_cost or 0.0)
- if print_archive_id is not None:
- existing.print_archive_id = print_archive_id
- await db.flush()
- return existing
- reservation = BudgetReservation(
- cost_center_id=cost_center_id,
- amount=float(estimated_cost or 0.0),
- status="active",
- source_type=source_type,
- source_id=source_id,
- print_archive_id=print_archive_id,
- )
- db.add(reservation)
- await db.flush()
- return reservation
- async def release_budget_reservation(
- db: AsyncSession,
- *,
- source_type: str | None = None,
- source_id: int | None = None,
- print_archive_id: int | None = None,
- status: str = "released",
- ) -> int:
- conditions = [BudgetReservation.status == "active"]
- if print_archive_id is not None:
- conditions.append(BudgetReservation.print_archive_id == print_archive_id)
- else:
- conditions.extend(
- [
- BudgetReservation.source_type == source_type,
- BudgetReservation.source_id == source_id,
- ]
- )
- result = await db.execute(select(BudgetReservation).where(*conditions))
- reservations = result.scalars().all()
- for reservation in reservations:
- reservation.status = status
- reservation.released_at = datetime.now(timezone.utc)
- if reservations:
- await db.flush()
- return len(reservations)
|