| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085 |
- 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_MODIFY),
- ):
- """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_MODIFY),
- ):
- """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_MODIFY),
- ):
- """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"}
|