|
@@ -0,0 +1,1085 @@
|
|
|
|
|
+import calendar
|
|
|
|
|
+from datetime import datetime, timezone
|
|
|
|
|
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
|
+
|
|
|
|
|
+from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
|
|
+from sqlalchemy import and_, case, func, or_, select
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
+from sqlalchemy.orm import selectinload
|
|
|
|
|
+
|
|
|
|
|
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
|
|
|
|
|
+from backend.app.core.database import get_db
|
|
|
|
|
+from backend.app.core.permissions import Permission
|
|
|
|
|
+from backend.app.models.archive import PrintArchive
|
|
|
|
|
+from backend.app.models.finance import (
|
|
|
|
|
+ BudgetReservation,
|
|
|
|
|
+ CostCenter,
|
|
|
|
|
+ CostCenterMember,
|
|
|
|
|
+ TransactionType,
|
|
|
|
|
+ UserWallet,
|
|
|
|
|
+ WalletTransaction,
|
|
|
|
|
+ normalize_transaction_type,
|
|
|
|
|
+)
|
|
|
|
|
+from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
+from backend.app.models.settings import Settings
|
|
|
|
|
+from backend.app.models.user import User
|
|
|
|
|
+from backend.app.schemas.finance import (
|
|
|
|
|
+ CostCenterBudgetUpdateRequest,
|
|
|
|
|
+ CostCenterCreateRequest,
|
|
|
|
|
+ CostCenterDetailResponse,
|
|
|
|
|
+ CostCenterMemberRequest,
|
|
|
|
|
+ CostCenterMemberResponse,
|
|
|
|
|
+ CostCenterSummaryResponse,
|
|
|
|
|
+ CostCenterUpdateRequest,
|
|
|
|
|
+ ManualPrintRequest,
|
|
|
|
|
+ TransactionEditRequest,
|
|
|
|
|
+ WalletAdjustmentRequest,
|
|
|
|
|
+ WalletAdjustmentResponse,
|
|
|
|
|
+ WalletBalanceResponse,
|
|
|
|
|
+ WalletTransactionListResponse,
|
|
|
|
|
+ WalletTransactionResponse,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+router = APIRouter(prefix="/finance", tags=["finance"])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _serialize_wallet_transaction(tx: WalletTransaction) -> WalletTransactionResponse:
|
|
|
|
|
+ transaction_type = (
|
|
|
|
|
+ tx.transaction_type.value if isinstance(tx.transaction_type, TransactionType) else tx.transaction_type
|
|
|
|
|
+ )
|
|
|
|
|
+ return WalletTransactionResponse.model_construct(
|
|
|
|
|
+ id=tx.id,
|
|
|
|
|
+ user_id=tx.user_id,
|
|
|
|
|
+ cost_center_id=tx.cost_center_id,
|
|
|
|
|
+ transaction_type=transaction_type,
|
|
|
|
|
+ amount=tx.amount,
|
|
|
|
|
+ balance_after=tx.balance_after,
|
|
|
|
|
+ description=tx.description,
|
|
|
|
|
+ created_by_user_id=tx.created_by_user_id,
|
|
|
|
|
+ print_run_id=tx.print_run_id,
|
|
|
|
|
+ print_archive_id=tx.print_archive_id,
|
|
|
|
|
+ print_queue_id=tx.print_queue_id,
|
|
|
|
|
+ created_at=tx.created_at,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _clamp_day(year: int, month: int, desired_day: int) -> int:
|
|
|
|
|
+ return min(max(1, desired_day), calendar.monthrange(year, month)[1])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
|
|
|
|
|
+ """Resolve monthly budget window start in UTC using configurable reset day/timezone.
|
|
|
|
|
+
|
|
|
|
|
+ Defaults preserve current behavior: day=1, timezone=UTC.
|
|
|
|
|
+ """
|
|
|
|
|
+ desired_day = 1
|
|
|
|
|
+ tz_name = "UTC"
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
|
|
|
|
|
+ )
|
|
|
|
|
+ for setting in result.scalars().all():
|
|
|
|
|
+ if setting.key == "finance_budget_reset_day":
|
|
|
|
|
+ try:
|
|
|
|
|
+ parsed = int(setting.value)
|
|
|
|
|
+ if 1 <= parsed <= 31:
|
|
|
|
|
+ desired_day = parsed
|
|
|
|
|
+ except (TypeError, ValueError):
|
|
|
|
|
+ pass
|
|
|
|
|
+ elif setting.key == "finance_budget_reset_timezone":
|
|
|
|
|
+ value = (setting.value or "").strip()
|
|
|
|
|
+ if value:
|
|
|
|
|
+ tz_name = value
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ tz = ZoneInfo(tz_name)
|
|
|
|
|
+ except ZoneInfoNotFoundError:
|
|
|
|
|
+ tz = timezone.utc
|
|
|
|
|
+
|
|
|
|
|
+ now_local = datetime.now(tz)
|
|
|
|
|
+ current_month_reset_day = _clamp_day(now_local.year, now_local.month, desired_day)
|
|
|
|
|
+
|
|
|
|
|
+ if now_local.day >= current_month_reset_day:
|
|
|
|
|
+ start_local = datetime(now_local.year, now_local.month, current_month_reset_day, tzinfo=tz)
|
|
|
|
|
+ else:
|
|
|
|
|
+ prev_year = now_local.year
|
|
|
|
|
+ prev_month = now_local.month - 1
|
|
|
|
|
+ if prev_month == 0:
|
|
|
|
|
+ prev_month = 12
|
|
|
|
|
+ prev_year -= 1
|
|
|
|
|
+ prev_month_reset_day = _clamp_day(prev_year, prev_month, desired_day)
|
|
|
|
|
+ start_local = datetime(prev_year, prev_month, prev_month_reset_day, tzinfo=tz)
|
|
|
|
|
+
|
|
|
|
|
+ return start_local.astimezone(timezone.utc)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_cost_center_usage_maps(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ cost_center_ids: list[int],
|
|
|
|
|
+) -> tuple[dict[int, float], dict[int, float]]:
|
|
|
|
|
+ if not cost_center_ids:
|
|
|
|
|
+ return {}, {}
|
|
|
|
|
+
|
|
|
|
|
+ spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
|
|
|
|
|
+
|
|
|
|
|
+ total_rows = await db.execute(
|
|
|
|
|
+ select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
|
|
|
|
|
+ .where(
|
|
|
|
|
+ WalletTransaction.cost_center_id.in_(cost_center_ids),
|
|
|
|
|
+ WalletTransaction.cost_center_id.is_not(None),
|
|
|
|
|
+ )
|
|
|
|
|
+ .group_by(WalletTransaction.cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ budget_window_start_utc = await _get_budget_window_start_utc(db)
|
|
|
|
|
+
|
|
|
|
|
+ month_rows = await db.execute(
|
|
|
|
|
+ select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
|
|
|
|
|
+ .where(
|
|
|
|
|
+ WalletTransaction.cost_center_id.in_(cost_center_ids),
|
|
|
|
|
+ WalletTransaction.cost_center_id.is_not(None),
|
|
|
|
|
+ WalletTransaction.created_at >= budget_window_start_utc,
|
|
|
|
|
+ )
|
|
|
|
|
+ .group_by(WalletTransaction.cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ total_map = {int(center_id): float(value) for center_id, value in total_rows.all() if center_id is not None}
|
|
|
|
|
+ month_map = {int(center_id): float(value) for center_id, value in month_rows.all() if center_id is not None}
|
|
|
|
|
+ return total_map, month_map
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_cost_center_balance_map(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ cost_center_ids: list[int],
|
|
|
|
|
+) -> dict[int, float]:
|
|
|
|
|
+ if not cost_center_ids:
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+ rows = await db.execute(
|
|
|
|
|
+ select(WalletTransaction.cost_center_id, func.coalesce(func.sum(WalletTransaction.amount), 0.0))
|
|
|
|
|
+ .where(
|
|
|
|
|
+ WalletTransaction.cost_center_id.in_(cost_center_ids),
|
|
|
|
|
+ WalletTransaction.cost_center_id.is_not(None),
|
|
|
|
|
+ )
|
|
|
|
|
+ .group_by(WalletTransaction.cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ return {int(center_id): float(value) for center_id, value in rows.all() if center_id is not None}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_cost_center_reserved_map(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ cost_center_ids: list[int],
|
|
|
|
|
+) -> dict[int, float]:
|
|
|
|
|
+ if not cost_center_ids:
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+ budget_rows = await db.execute(
|
|
|
|
|
+ select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
|
|
|
|
|
+ .where(
|
|
|
|
|
+ BudgetReservation.cost_center_id.in_(cost_center_ids),
|
|
|
|
|
+ BudgetReservation.status == "active",
|
|
|
|
|
+ )
|
|
|
|
|
+ .group_by(BudgetReservation.cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ reserved_map = {int(center_id): float(value) for center_id, value in budget_rows.all() if center_id is not None}
|
|
|
|
|
+
|
|
|
|
|
+ queue_rows = await db.execute(
|
|
|
|
|
+ select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
|
|
|
|
|
+ .where(
|
|
|
|
|
+ PrintQueueItem.cost_center_id.in_(cost_center_ids),
|
|
|
|
|
+ PrintQueueItem.status.in_(("pending", "printing")),
|
|
|
|
|
+ )
|
|
|
|
|
+ .group_by(PrintQueueItem.cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ for center_id, value in queue_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
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
|
|
|
|
|
+ # Monthly takes precedence if legacy data still has both set.
|
|
|
|
|
+ if center.monthly_budget is not None:
|
|
|
|
|
+ return "monthly", float(center.monthly_budget)
|
|
|
|
|
+ if center.total_budget is not None:
|
|
|
|
|
+ return "total", float(center.total_budget)
|
|
|
|
|
+ return "none", None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _to_cost_center_summary(
|
|
|
|
|
+ center: CostCenter,
|
|
|
|
|
+ *,
|
|
|
|
|
+ can_print: bool,
|
|
|
|
|
+ total_usage: float,
|
|
|
|
|
+ month_usage: float,
|
|
|
|
|
+ total_balance: float,
|
|
|
|
|
+ reserved: float = 0.0,
|
|
|
|
|
+) -> CostCenterSummaryResponse:
|
|
|
|
|
+ budget_mode, budget_limit = _budget_mode_and_limit(center)
|
|
|
|
|
+ budget_used = month_usage if budget_mode == "monthly" else total_usage if budget_mode == "total" else None
|
|
|
|
|
+ budget_available = (
|
|
|
|
|
+ max(0.0, budget_limit - budget_used - reserved)
|
|
|
|
|
+ if budget_limit is not None and budget_used is not None
|
|
|
|
|
+ else None
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return CostCenterSummaryResponse(
|
|
|
|
|
+ id=center.id,
|
|
|
|
|
+ name=center.name,
|
|
|
|
|
+ is_private=center.is_private,
|
|
|
|
|
+ owner_user_id=center.owner_user_id,
|
|
|
|
|
+ is_active=center.is_active,
|
|
|
|
|
+ total_balance=total_balance,
|
|
|
|
|
+ total_budget=center.total_budget,
|
|
|
|
|
+ monthly_budget=center.monthly_budget,
|
|
|
|
|
+ budget_mode=budget_mode,
|
|
|
|
|
+ budget_limit=budget_limit,
|
|
|
|
|
+ budget_used=budget_used,
|
|
|
|
|
+ budget_available=budget_available,
|
|
|
|
|
+ can_print=can_print,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _require_authenticated_user(current_user: User | None) -> User:
|
|
|
|
|
+ if current_user is None:
|
|
|
|
|
+ raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
|
+ return current_user
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _has_cost_center_admin_access(user: User) -> bool:
|
|
|
|
|
+ return user.has_any_permission(
|
|
|
|
|
+ Permission.COST_CENTERS_READ_ALL.value,
|
|
|
|
|
+ Permission.COST_CENTERS_MODIFY.value,
|
|
|
|
|
+ Permission.COST_CENTERS_CREATE.value,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _require_cost_center_admin_access(current_user: User | None) -> User:
|
|
|
|
|
+ user = await _require_authenticated_user(current_user)
|
|
|
|
|
+ if not _has_cost_center_admin_access(user):
|
|
|
|
|
+ raise HTTPException(status_code=403, detail="Missing required permissions for cost center administration")
|
|
|
|
|
+ return user
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
|
|
|
|
|
+ result = await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))
|
|
|
|
|
+ wallet = result.scalar_one_or_none()
|
|
|
|
|
+ if wallet:
|
|
|
|
|
+ return wallet
|
|
|
|
|
+
|
|
|
|
|
+ wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
|
|
|
|
|
+ db.add(wallet)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.refresh(wallet)
|
|
|
|
|
+ return wallet
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
|
|
|
|
|
+ result = await db.execute(select(User).where(User.id == user_id))
|
|
|
|
|
+ user = result.scalar_one_or_none()
|
|
|
|
|
+ if user is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
|
+ return user
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> CostCenter:
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(CostCenter).options(selectinload(CostCenter.members)).where(CostCenter.id == cost_center_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ center = result.scalar_one_or_none()
|
|
|
|
|
+ if center is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Cost center not found")
|
|
|
|
|
+ return center
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
|
|
|
|
|
+ return WalletBalanceResponse(
|
|
|
|
|
+ user_id=wallet.user_id,
|
|
|
|
|
+ balance=wallet.balance,
|
|
|
|
|
+ currency=wallet.currency,
|
|
|
|
|
+ updated_at=wallet.updated_at,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[int, float]:
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(
|
|
|
|
|
+ WalletTransaction.id,
|
|
|
|
|
+ WalletTransaction.amount,
|
|
|
|
|
+ WalletTransaction.cost_center_id,
|
|
|
|
|
+ CostCenter.is_private,
|
|
|
|
|
+ CostCenter.owner_user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ .where(
|
|
|
|
|
+ WalletTransaction.user_id == user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ running_balance = 0.0
|
|
|
|
|
+ balance_map: dict[int, float] = {}
|
|
|
|
|
+ for transaction_id, amount, cost_center_id, is_private, owner_user_id in result.all():
|
|
|
|
|
+ is_personal_cost_center = bool(cost_center_id is not None and is_private and owner_user_id == user_id)
|
|
|
|
|
+ if cost_center_id is None or is_personal_cost_center:
|
|
|
|
|
+ running_balance += float(amount)
|
|
|
|
|
+ balance_map[int(transaction_id)] = running_balance
|
|
|
|
|
+
|
|
|
|
|
+ return balance_map
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _personal_balance_condition(user_id: int):
|
|
|
|
|
+ return or_(
|
|
|
|
|
+ WalletTransaction.cost_center_id.is_(None),
|
|
|
|
|
+ and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _create_wallet_adjustment(
|
|
|
|
|
+ db: AsyncSession,
|
|
|
|
|
+ *,
|
|
|
|
|
+ target_user_id: int,
|
|
|
|
|
+ actor_user_id: int,
|
|
|
|
|
+ amount: float,
|
|
|
|
|
+ transaction_type: str,
|
|
|
|
|
+ description: str | None,
|
|
|
|
|
+ cost_center_id: int | None,
|
|
|
|
|
+) -> WalletAdjustmentResponse:
|
|
|
|
|
+ transaction_type = normalize_transaction_type(transaction_type)
|
|
|
|
|
+
|
|
|
|
|
+ if cost_center_id is not None:
|
|
|
|
|
+ await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+
|
|
|
|
|
+ wallet = await _get_or_create_wallet(db, target_user_id)
|
|
|
|
|
+
|
|
|
|
|
+ # Calculate balance_after for this specific transaction context
|
|
|
|
|
+ if cost_center_id is None:
|
|
|
|
|
+ # Personal transaction: validate and update user wallet
|
|
|
|
|
+ new_balance = wallet.balance + amount
|
|
|
|
|
+ if new_balance < 0:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
|
|
|
|
|
+ wallet.balance = new_balance
|
|
|
|
|
+ balance_after = new_balance
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Cost-center transaction: validate against cost center balance only (global, not per-user)
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
|
|
|
|
|
+ WalletTransaction.cost_center_id == cost_center_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ current_cc_balance = float(result.scalar() or 0.0)
|
|
|
|
|
+ new_cc_balance = current_cc_balance + amount
|
|
|
|
|
+ if new_cc_balance < 0:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
|
|
|
|
|
+ balance_after = new_cc_balance
|
|
|
|
|
+ # Do NOT update wallet.balance for cost-center transactions
|
|
|
|
|
+
|
|
|
|
|
+ tx = WalletTransaction(
|
|
|
|
|
+ user_id=target_user_id,
|
|
|
|
|
+ cost_center_id=cost_center_id,
|
|
|
|
|
+ transaction_type=transaction_type,
|
|
|
|
|
+ amount=amount,
|
|
|
|
|
+ balance_after=balance_after,
|
|
|
|
|
+ description=description,
|
|
|
|
|
+ created_by_user_id=actor_user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ await db.refresh(wallet)
|
|
|
|
|
+ await db.refresh(tx)
|
|
|
|
|
+
|
|
|
|
|
+ # Return appropriate balance based on transaction type
|
|
|
|
|
+ if cost_center_id is None:
|
|
|
|
|
+ # Personal transaction: return user wallet balance
|
|
|
|
|
+ response_balance = _to_balance_response(wallet)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Cost-center transaction: return cost-center balance as if it were a wallet
|
|
|
|
|
+ response_balance = WalletBalanceResponse(
|
|
|
|
|
+ user_id=target_user_id,
|
|
|
|
|
+ balance=balance_after,
|
|
|
|
|
+ currency=wallet.currency,
|
|
|
|
|
+ updated_at=tx.created_at,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return WalletAdjustmentResponse(
|
|
|
|
|
+ transaction=_serialize_wallet_transaction(tx),
|
|
|
|
|
+ balance=response_balance,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/me/balance", response_model=WalletBalanceResponse)
|
|
|
|
|
+async def get_my_balance(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return the current user's wallet balance."""
|
|
|
|
|
+ user = await _require_authenticated_user(current_user)
|
|
|
|
|
+ wallet = await _get_or_create_wallet(db, user.id)
|
|
|
|
|
+ personal_balance_result = await db.execute(
|
|
|
|
|
+ select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
|
|
|
|
|
+ .select_from(WalletTransaction)
|
|
|
|
|
+ .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
|
|
|
|
+ .where(WalletTransaction.user_id == user.id, _personal_balance_condition(user.id))
|
|
|
|
|
+ )
|
|
|
|
|
+ personal_balance = float(personal_balance_result.scalar_one() or 0.0)
|
|
|
|
|
+ return WalletBalanceResponse(
|
|
|
|
|
+ user_id=user.id,
|
|
|
|
|
+ balance=personal_balance,
|
|
|
|
|
+ currency=wallet.currency,
|
|
|
|
|
+ updated_at=wallet.updated_at,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/me/transactions", response_model=WalletTransactionListResponse)
|
|
|
|
|
+async def get_my_transactions(
|
|
|
|
|
+ limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
+ offset: int = Query(0, ge=0),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return wallet ledger entries for the current user."""
|
|
|
|
|
+ user = await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ total_result = await db.execute(
|
|
|
|
|
+ select(func.count(WalletTransaction.id)).where(WalletTransaction.user_id == user.id)
|
|
|
|
|
+ )
|
|
|
|
|
+ total = int(total_result.scalar_one() or 0)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(WalletTransaction)
|
|
|
|
|
+ .where(WalletTransaction.user_id == user.id)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
|
|
|
|
|
+ .limit(limit)
|
|
|
|
|
+ .offset(offset)
|
|
|
|
|
+ )
|
|
|
|
|
+ transactions = result.scalars().all()
|
|
|
|
|
+ personal_balance_map = await _build_personal_balance_map(db, user.id)
|
|
|
|
|
+ return WalletTransactionListResponse(
|
|
|
|
|
+ items=[
|
|
|
|
|
+ _serialize_wallet_transaction(tx).model_copy(
|
|
|
|
|
+ update={"balance_after": personal_balance_map.get(tx.id, tx.balance_after)}
|
|
|
|
|
+ )
|
|
|
|
|
+ for tx in transactions
|
|
|
|
|
+ ],
|
|
|
|
|
+ total=total,
|
|
|
|
|
+ limit=limit,
|
|
|
|
|
+ offset=offset,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/transactions", response_model=WalletTransactionListResponse)
|
|
|
|
|
+async def get_all_transactions(
|
|
|
|
|
+ limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
+ offset: int = Query(0, ge=0),
|
|
|
|
|
+ user_id: int | None = Query(None, description="Optional filter by user id"),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return wallet ledger entries across users for admin finance view."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ conditions = []
|
|
|
|
|
+ if user_id is not None:
|
|
|
|
|
+ await _get_user_or_404(db, user_id)
|
|
|
|
|
+ conditions.append(WalletTransaction.user_id == user_id)
|
|
|
|
|
+
|
|
|
|
|
+ total_result = await db.execute(select(func.count(WalletTransaction.id)).where(*conditions))
|
|
|
|
|
+ total = int(total_result.scalar_one() or 0)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(WalletTransaction)
|
|
|
|
|
+ .where(*conditions)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
|
|
|
|
|
+ .limit(limit)
|
|
|
|
|
+ .offset(offset)
|
|
|
|
|
+ )
|
|
|
|
|
+ transactions = result.scalars().all()
|
|
|
|
|
+ return WalletTransactionListResponse(
|
|
|
|
|
+ items=[_serialize_wallet_transaction(tx) for tx in transactions],
|
|
|
|
|
+ total=total,
|
|
|
|
|
+ limit=limit,
|
|
|
|
|
+ offset=offset,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
|
|
|
|
|
+ """Recompute `balance_after` for all wallet transactions of a user.
|
|
|
|
|
+
|
|
|
|
|
+ - Personal transactions (cost_center_id=None): running balance per user
|
|
|
|
|
+ - Cost-center transactions: running balance GLOBAL for entire cost center (not per-user)
|
|
|
|
|
+ - Also updates the user's wallet balance (sum of personal transactions only)
|
|
|
|
|
+ """
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(WalletTransaction)
|
|
|
|
|
+ .where(WalletTransaction.user_id == user_id)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
|
|
|
|
+ )
|
|
|
|
|
+ user_transactions = result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ # Handle personal transactions (cost_center_id=None)
|
|
|
|
|
+ personal_balance = 0.0
|
|
|
|
|
+ for tx in user_transactions:
|
|
|
|
|
+ if tx.cost_center_id is None:
|
|
|
|
|
+ personal_balance += float(tx.amount)
|
|
|
|
|
+ tx.balance_after = personal_balance
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+
|
|
|
|
|
+ for cc_id in {tx.cost_center_id for tx in user_transactions if tx.cost_center_id is not None}:
|
|
|
|
|
+ # Get all transactions for this cost center (all users, all time)
|
|
|
|
|
+ result_all_cc = await db.execute(
|
|
|
|
|
+ select(WalletTransaction)
|
|
|
|
|
+ .where(WalletTransaction.cost_center_id == cc_id)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
|
|
|
|
+ )
|
|
|
|
|
+ all_cc_transactions = result_all_cc.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ running = 0.0
|
|
|
|
|
+ for tx in all_cc_transactions:
|
|
|
|
|
+ running += float(tx.amount)
|
|
|
|
|
+ tx.balance_after = running
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+
|
|
|
|
|
+ # Update user wallet balance (sum of all personal transactions only)
|
|
|
|
|
+ wallet = await _get_or_create_wallet(db, user_id)
|
|
|
|
|
+ wallet.balance = personal_balance
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.delete("/transactions/{transaction_id}")
|
|
|
|
|
+async def delete_transaction(
|
|
|
|
|
+ transaction_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
|
|
|
|
|
+ tx = result.scalar_one_or_none()
|
|
|
|
|
+ if tx is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Transaction not found")
|
|
|
|
|
+
|
|
|
|
|
+ user_id = tx.user_id
|
|
|
|
|
+
|
|
|
|
|
+ if tx.transaction_type == "print_charge" and tx.print_archive_id is not None:
|
|
|
|
|
+ archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == tx.print_archive_id))
|
|
|
|
|
+ archive = archive_result.scalar_one_or_none()
|
|
|
|
|
+ if archive is not None:
|
|
|
|
|
+ archive.wallet_charge_skipped = True
|
|
|
|
|
+ db.add(archive)
|
|
|
|
|
+
|
|
|
|
|
+ await db.delete(tx)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+
|
|
|
|
|
+ await _rebuild_wallet_ledger_for_user(db, user_id)
|
|
|
|
|
+
|
|
|
|
|
+ return {"status": "success"}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.patch("/transactions/{transaction_id}", response_model=WalletTransactionResponse)
|
|
|
|
|
+async def edit_transaction(
|
|
|
|
|
+ transaction_id: int,
|
|
|
|
|
+ request: TransactionEditRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
|
|
|
|
|
+ tx = result.scalar_one_or_none()
|
|
|
|
|
+ if tx is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Transaction not found")
|
|
|
|
|
+
|
|
|
|
|
+ # Apply edits
|
|
|
|
|
+ if request.user_id is not None:
|
|
|
|
|
+ tx.user_id = request.user_id
|
|
|
|
|
+
|
|
|
|
|
+ if request.cost_center_id is not None:
|
|
|
|
|
+ tx.cost_center_id = request.cost_center_id
|
|
|
|
|
+
|
|
|
|
|
+ if request.amount is not None:
|
|
|
|
|
+ tx.amount = request.amount
|
|
|
|
|
+
|
|
|
|
|
+ if request.description is not None:
|
|
|
|
|
+ # Append "(Admin edit)" marker if not already present
|
|
|
|
|
+ new_desc = request.description
|
|
|
|
|
+ if not new_desc.endswith("(Admin edit)"):
|
|
|
|
|
+ new_desc = f"{new_desc} (Admin edit)"
|
|
|
|
|
+ tx.description = new_desc
|
|
|
|
|
+
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+
|
|
|
|
|
+ # Rebuild full ledger using the current session
|
|
|
|
|
+ from backend.app.core.database import repair_wallet_ledger_internal
|
|
|
|
|
+
|
|
|
|
|
+ await repair_wallet_ledger_internal(db)
|
|
|
|
|
+
|
|
|
|
|
+ return tx
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/transactions/manual", response_model=WalletTransactionResponse)
|
|
|
|
|
+async def create_manual_print(
|
|
|
|
|
+ request: ManualPrintRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Create a manual print charge transaction (for admin purposes)."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ from datetime import timezone
|
|
|
|
|
+
|
|
|
|
|
+ # Use provided created_at or current time
|
|
|
|
|
+ created_at = request.created_at or datetime.now(timezone.utc)
|
|
|
|
|
+
|
|
|
|
|
+ # Ensure manual print charges are negative amounts (charges reduce wallet)
|
|
|
|
|
+ amount = request.amount
|
|
|
|
|
+ if amount > 0:
|
|
|
|
|
+ amount = -abs(amount)
|
|
|
|
|
+
|
|
|
|
|
+ # Create transaction
|
|
|
|
|
+ tx = WalletTransaction(
|
|
|
|
|
+ user_id=request.user_id,
|
|
|
|
|
+ cost_center_id=request.cost_center_id,
|
|
|
|
|
+ transaction_type=TransactionType.MANUAL_ADJUSTMENT.value,
|
|
|
|
|
+ amount=amount,
|
|
|
|
|
+ balance_after=None, # Will be set by repair_wallet_ledger_internal
|
|
|
|
|
+ description=request.description or "Manual print charge",
|
|
|
|
|
+ created_by_user_id=current_user.id if current_user else None,
|
|
|
|
|
+ created_at=created_at,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+
|
|
|
|
|
+ # Rebuild full ledger using the current session
|
|
|
|
|
+ from backend.app.core.database import repair_wallet_ledger_internal
|
|
|
|
|
+
|
|
|
|
|
+ await repair_wallet_ledger_internal(db)
|
|
|
|
|
+
|
|
|
|
|
+ return tx
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
|
|
|
|
|
+async def get_my_cost_centers(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = Depends(require_auth_if_enabled),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return private and assigned cost centers for the current user."""
|
|
|
|
|
+ user = await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(CostCenter, CostCenterMember.can_print)
|
|
|
|
|
+ .outerjoin(
|
|
|
|
|
+ CostCenterMember,
|
|
|
|
|
+ (CostCenterMember.cost_center_id == CostCenter.id) & (CostCenterMember.user_id == user.id),
|
|
|
|
|
+ )
|
|
|
|
|
+ .where(
|
|
|
|
|
+ CostCenter.is_active.is_(True),
|
|
|
|
|
+ or_(
|
|
|
|
|
+ (CostCenter.is_private.is_(True) & (CostCenter.owner_user_id == user.id)),
|
|
|
|
|
+ (CostCenterMember.user_id == user.id),
|
|
|
|
|
+ ),
|
|
|
|
|
+ )
|
|
|
|
|
+ .order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ rows = result.all()
|
|
|
|
|
+ centers_only = [center for center, _ in rows]
|
|
|
|
|
+ center_ids = [center.id for center in centers_only]
|
|
|
|
|
+ total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
|
|
|
|
|
+ total_balance_map = await _get_cost_center_balance_map(db, center_ids)
|
|
|
|
|
+ reserved_map = await _get_cost_center_reserved_map(db, center_ids)
|
|
|
|
|
+
|
|
|
|
|
+ centers: list[CostCenterSummaryResponse] = []
|
|
|
|
|
+ for center, can_print in rows:
|
|
|
|
|
+ centers.append(
|
|
|
|
|
+ _to_cost_center_summary(
|
|
|
|
|
+ center,
|
|
|
|
|
+ can_print=True if center.is_private and center.owner_user_id == user.id else bool(can_print),
|
|
|
|
|
+ total_usage=total_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ month_usage=month_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ total_balance=total_balance_map.get(center.id, 0.0),
|
|
|
|
|
+ reserved=reserved_map.get(center.id, 0.0),
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return centers
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
|
|
|
|
|
+async def get_user_balance(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return a specific user's wallet balance."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ user = await _get_user_or_404(db, user_id)
|
|
|
|
|
+ wallet = await _get_or_create_wallet(db, user.id)
|
|
|
|
|
+ return _to_balance_response(wallet)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
|
|
|
|
|
+async def get_user_transactions(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ limit: int = Query(50, ge=1, le=500),
|
|
|
|
|
+ offset: int = Query(0, ge=0),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
|
|
|
|
|
+):
|
|
|
|
|
+ """Return wallet ledger entries for a specific user."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ await _get_user_or_404(db, user_id)
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(WalletTransaction)
|
|
|
|
|
+ .where(WalletTransaction.user_id == user_id)
|
|
|
|
|
+ .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
|
|
|
|
|
+ .limit(limit)
|
|
|
|
|
+ .offset(offset)
|
|
|
|
|
+ )
|
|
|
|
|
+ return [_serialize_wallet_transaction(tx) for tx in result.scalars().all()]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/users/{user_id}/deposit", response_model=WalletAdjustmentResponse)
|
|
|
|
|
+async def deposit_user_balance(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ body: WalletAdjustmentRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Add funds to a user's wallet."""
|
|
|
|
|
+ actor = await _require_authenticated_user(current_user)
|
|
|
|
|
+ await _get_user_or_404(db, user_id)
|
|
|
|
|
+ return await _create_wallet_adjustment(
|
|
|
|
|
+ db,
|
|
|
|
|
+ target_user_id=user_id,
|
|
|
|
|
+ actor_user_id=actor.id,
|
|
|
|
|
+ amount=body.amount,
|
|
|
|
|
+ transaction_type=TransactionType.DEPOSIT.value,
|
|
|
|
|
+ description=body.description,
|
|
|
|
|
+ cost_center_id=body.cost_center_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/users/{user_id}/withdraw", response_model=WalletAdjustmentResponse)
|
|
|
|
|
+async def withdraw_user_balance(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ body: WalletAdjustmentRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Withdraw funds from a user's wallet."""
|
|
|
|
|
+ actor = await _require_authenticated_user(current_user)
|
|
|
|
|
+ await _get_user_or_404(db, user_id)
|
|
|
|
|
+ return await _create_wallet_adjustment(
|
|
|
|
|
+ db,
|
|
|
|
|
+ target_user_id=user_id,
|
|
|
|
|
+ actor_user_id=actor.id,
|
|
|
|
|
+ amount=-body.amount,
|
|
|
|
|
+ transaction_type=TransactionType.WITHDRAW.value,
|
|
|
|
|
+ description=body.description,
|
|
|
|
|
+ cost_center_id=body.cost_center_id,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/rebuild-balance-ledger")
|
|
|
|
|
+async def rebuild_balance_ledger(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Recompute balance_after for all wallet transactions.
|
|
|
|
|
+
|
|
|
|
|
+ This rebuilds the running balance for all users and cost centers.
|
|
|
|
|
+ - Personal transactions: per-user running balance
|
|
|
|
|
+ - Cost-center transactions: global running balance for the entire cost center
|
|
|
|
|
+ """
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+
|
|
|
|
|
+ # Get ALL transactions sorted by timestamp
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
|
|
|
|
+ )
|
|
|
|
|
+ all_transactions = result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ # Build running balances per (user, cost_center_id) pair
|
|
|
|
|
+ # For each cost center, track its global running balance
|
|
|
|
|
+ # For each user's personal balance, track that separately
|
|
|
|
|
+ cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
|
|
|
|
|
+ user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
|
|
|
|
|
+
|
|
|
|
|
+ tx_updates: list[tuple[WalletTransaction, float]] = []
|
|
|
|
|
+
|
|
|
|
|
+ for tx in all_transactions:
|
|
|
|
|
+ if tx.cost_center_id is None:
|
|
|
|
|
+ # Personal transaction: per-user running balance
|
|
|
|
|
+ current = user_personal_balances.get(tx.user_id, 0.0)
|
|
|
|
|
+ new_balance = current + float(tx.amount)
|
|
|
|
|
+ user_personal_balances[tx.user_id] = new_balance
|
|
|
|
|
+ tx_updates.append((tx, new_balance))
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Cost-center transaction: global running balance for this cost center
|
|
|
|
|
+ current = cc_running_balances.get(tx.cost_center_id, 0.0)
|
|
|
|
|
+ new_balance = current + float(tx.amount)
|
|
|
|
|
+ cc_running_balances[tx.cost_center_id] = new_balance
|
|
|
|
|
+ tx_updates.append((tx, new_balance))
|
|
|
|
|
+
|
|
|
|
|
+ # Update all transactions with the new balance_after values
|
|
|
|
|
+ for tx, new_balance in tx_updates:
|
|
|
|
|
+ tx.balance_after = new_balance
|
|
|
|
|
+ db.add(tx)
|
|
|
|
|
+
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "status": "success",
|
|
|
|
|
+ "transactions_rebuilt": len(all_transactions),
|
|
|
|
|
+ "message": f"Rebuilt balance_after for {len(all_transactions)} transactions",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
|
|
|
|
|
+async def list_cost_centers(
|
|
|
|
|
+ include_inactive: bool = Query(False),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = Depends(require_auth_if_enabled),
|
|
|
|
|
+):
|
|
|
|
|
+ """List all cost centers.
|
|
|
|
|
+
|
|
|
|
|
+ Requires admin-level finance permissions.
|
|
|
|
|
+ """
|
|
|
|
|
+ await _require_cost_center_admin_access(current_user)
|
|
|
|
|
+ query = select(CostCenter).order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
|
|
|
|
|
+ if not include_inactive:
|
|
|
|
|
+ query = query.where(CostCenter.is_active.is_(True))
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(query)
|
|
|
|
|
+ centers = result.scalars().all()
|
|
|
|
|
+ center_ids = [center.id for center in centers]
|
|
|
|
|
+ total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
|
|
|
|
|
+ total_balance_map = await _get_cost_center_balance_map(db, center_ids)
|
|
|
|
|
+ reserved_map = await _get_cost_center_reserved_map(db, center_ids)
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ _to_cost_center_summary(
|
|
|
|
|
+ center,
|
|
|
|
|
+ can_print=True,
|
|
|
|
|
+ total_usage=total_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ month_usage=month_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ total_balance=total_balance_map.get(center.id, 0.0),
|
|
|
|
|
+ reserved=reserved_map.get(center.id, 0.0),
|
|
|
|
|
+ )
|
|
|
|
|
+ for center in centers
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/cost-centers", response_model=CostCenterSummaryResponse)
|
|
|
|
|
+async def create_cost_center(
|
|
|
|
|
+ body: CostCenterCreateRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
|
|
|
|
|
+):
|
|
|
|
|
+ """Create a shared cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ total_budget = body.total_budget
|
|
|
|
|
+ monthly_budget = body.monthly_budget
|
|
|
|
|
+ if monthly_budget is not None:
|
|
|
|
|
+ total_budget = None
|
|
|
|
|
+ elif total_budget is not None:
|
|
|
|
|
+ monthly_budget = None
|
|
|
|
|
+
|
|
|
|
|
+ center = CostCenter(
|
|
|
|
|
+ name=body.name.strip(),
|
|
|
|
|
+ is_active=body.is_active,
|
|
|
|
|
+ is_private=False,
|
|
|
|
|
+ owner_user_id=None,
|
|
|
|
|
+ total_budget=total_budget,
|
|
|
|
|
+ monthly_budget=monthly_budget,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(center)
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ await db.refresh(center)
|
|
|
|
|
+
|
|
|
|
|
+ return _to_cost_center_summary(center, can_print=True, total_usage=0.0, month_usage=0.0, total_balance=0.0)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
|
|
|
|
|
+async def get_cost_center(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = Depends(require_auth_if_enabled),
|
|
|
|
|
+):
|
|
|
|
|
+ """Get one cost center with its memberships."""
|
|
|
|
|
+ await _require_cost_center_admin_access(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+ total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
|
|
|
|
|
+ total_balance_map = await _get_cost_center_balance_map(db, [center.id])
|
|
|
|
|
+ reserved_map = await _get_cost_center_reserved_map(db, [center.id])
|
|
|
|
|
+ summary = _to_cost_center_summary(
|
|
|
|
|
+ center,
|
|
|
|
|
+ can_print=True,
|
|
|
|
|
+ total_usage=total_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ month_usage=month_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ total_balance=total_balance_map.get(center.id, 0.0),
|
|
|
|
|
+ reserved=reserved_map.get(center.id, 0.0),
|
|
|
|
|
+ )
|
|
|
|
|
+ return CostCenterDetailResponse(
|
|
|
|
|
+ **summary.model_dump(),
|
|
|
|
|
+ members=[CostCenterMemberResponse.model_validate(m) for m in center.members],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterSummaryResponse)
|
|
|
|
|
+async def update_cost_center(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ body: CostCenterUpdateRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Update name or active-state of a cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+
|
|
|
|
|
+ if body.name is not None:
|
|
|
|
|
+ center.name = body.name.strip()
|
|
|
|
|
+ if body.is_active is not None:
|
|
|
|
|
+ center.is_active = body.is_active
|
|
|
|
|
+
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+
|
|
|
|
|
+ total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
|
|
|
|
|
+ total_balance_map = await _get_cost_center_balance_map(db, [center.id])
|
|
|
|
|
+ reserved_map = await _get_cost_center_reserved_map(db, [center.id])
|
|
|
|
|
+ return _to_cost_center_summary(
|
|
|
|
|
+ center,
|
|
|
|
|
+ can_print=True,
|
|
|
|
|
+ total_usage=total_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ month_usage=month_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ total_balance=total_balance_map.get(center.id, 0.0),
|
|
|
|
|
+ reserved=reserved_map.get(center.id, 0.0),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.patch("/cost-centers/{cost_center_id}/budgets", response_model=CostCenterSummaryResponse)
|
|
|
|
|
+async def update_cost_center_budgets(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ body: CostCenterBudgetUpdateRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Update budget values of a cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+
|
|
|
|
|
+ if body.monthly_budget is not None:
|
|
|
|
|
+ center.monthly_budget = body.monthly_budget
|
|
|
|
|
+ center.total_budget = None
|
|
|
|
|
+ elif body.total_budget is not None:
|
|
|
|
|
+ center.total_budget = body.total_budget
|
|
|
|
|
+ center.monthly_budget = None
|
|
|
|
|
+ else:
|
|
|
|
|
+ center.total_budget = None
|
|
|
|
|
+ center.monthly_budget = None
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+
|
|
|
|
|
+ total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
|
|
|
|
|
+ total_balance_map = await _get_cost_center_balance_map(db, [center.id])
|
|
|
|
|
+ reserved_map = await _get_cost_center_reserved_map(db, [center.id])
|
|
|
|
|
+ return _to_cost_center_summary(
|
|
|
|
|
+ center,
|
|
|
|
|
+ can_print=True,
|
|
|
|
|
+ total_usage=total_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ month_usage=month_usage_map.get(center.id, 0.0),
|
|
|
|
|
+ total_balance=total_balance_map.get(center.id, 0.0),
|
|
|
|
|
+ reserved=reserved_map.get(center.id, 0.0),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/cost-centers/{cost_center_id}/members", response_model=CostCenterMemberResponse)
|
|
|
|
|
+async def upsert_cost_center_member(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ body: CostCenterMemberRequest,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Assign or update a user's membership on a cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+
|
|
|
|
|
+ if center.is_private:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
|
|
|
|
|
+
|
|
|
|
|
+ await _get_user_or_404(db, body.user_id)
|
|
|
|
|
+
|
|
|
|
|
+ existing = await db.execute(
|
|
|
|
|
+ select(CostCenterMember).where(
|
|
|
|
|
+ CostCenterMember.cost_center_id == cost_center_id,
|
|
|
|
|
+ CostCenterMember.user_id == body.user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ member = existing.scalar_one_or_none()
|
|
|
|
|
+ if member is None:
|
|
|
|
|
+ member = CostCenterMember(cost_center_id=cost_center_id, user_id=body.user_id, can_print=body.can_print)
|
|
|
|
|
+ db.add(member)
|
|
|
|
|
+ else:
|
|
|
|
|
+ member.can_print = body.can_print
|
|
|
|
|
+
|
|
|
|
|
+ await db.flush()
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return CostCenterMemberResponse.model_validate(member)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.delete("/cost-centers/{cost_center_id}")
|
|
|
|
|
+async def delete_cost_center(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Delete a shared cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+
|
|
|
|
|
+ if center.is_private:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
|
|
|
|
|
+
|
|
|
|
|
+ balance_map = await _get_cost_center_balance_map(db, [center.id])
|
|
|
|
|
+ total_balance = balance_map.get(center.id, 0.0)
|
|
|
|
|
+ if abs(total_balance) > 1e-9:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
|
|
|
|
|
+
|
|
|
|
|
+ await db.delete(center)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return {"status": "success"}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.delete("/cost-centers/{cost_center_id}/members/{user_id}")
|
|
|
|
|
+async def remove_cost_center_member(
|
|
|
|
|
+ cost_center_id: int,
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
|
|
|
|
|
+):
|
|
|
|
|
+ """Remove a user from a shared cost center."""
|
|
|
|
|
+ await _require_authenticated_user(current_user)
|
|
|
|
|
+ center = await _get_cost_center_or_404(db, cost_center_id)
|
|
|
|
|
+ if center.is_private:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(CostCenterMember).where(
|
|
|
|
|
+ CostCenterMember.cost_center_id == cost_center_id,
|
|
|
|
|
+ CostCenterMember.user_id == user_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ member = result.scalar_one_or_none()
|
|
|
|
|
+ if member is None:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Membership not found")
|
|
|
|
|
+
|
|
|
|
|
+ await db.delete(member)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+ return {"status": "success"}
|