finance.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085
  1. import calendar
  2. from datetime import datetime, timezone
  3. from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
  4. from fastapi import APIRouter, Depends, HTTPException, Query
  5. from sqlalchemy import and_, case, func, or_, select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from sqlalchemy.orm import selectinload
  8. from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
  9. from backend.app.core.database import get_db
  10. from backend.app.core.permissions import Permission
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.finance import (
  13. BudgetReservation,
  14. CostCenter,
  15. CostCenterMember,
  16. TransactionType,
  17. UserWallet,
  18. WalletTransaction,
  19. normalize_transaction_type,
  20. )
  21. from backend.app.models.print_queue import PrintQueueItem
  22. from backend.app.models.settings import Settings
  23. from backend.app.models.user import User
  24. from backend.app.schemas.finance import (
  25. CostCenterBudgetUpdateRequest,
  26. CostCenterCreateRequest,
  27. CostCenterDetailResponse,
  28. CostCenterMemberRequest,
  29. CostCenterMemberResponse,
  30. CostCenterSummaryResponse,
  31. CostCenterUpdateRequest,
  32. ManualPrintRequest,
  33. TransactionEditRequest,
  34. WalletAdjustmentRequest,
  35. WalletAdjustmentResponse,
  36. WalletBalanceResponse,
  37. WalletTransactionListResponse,
  38. WalletTransactionResponse,
  39. )
  40. router = APIRouter(prefix="/finance", tags=["finance"])
  41. def _serialize_wallet_transaction(tx: WalletTransaction) -> WalletTransactionResponse:
  42. transaction_type = (
  43. tx.transaction_type.value if isinstance(tx.transaction_type, TransactionType) else tx.transaction_type
  44. )
  45. return WalletTransactionResponse.model_construct(
  46. id=tx.id,
  47. user_id=tx.user_id,
  48. cost_center_id=tx.cost_center_id,
  49. transaction_type=transaction_type,
  50. amount=tx.amount,
  51. balance_after=tx.balance_after,
  52. description=tx.description,
  53. created_by_user_id=tx.created_by_user_id,
  54. print_run_id=tx.print_run_id,
  55. print_archive_id=tx.print_archive_id,
  56. print_queue_id=tx.print_queue_id,
  57. created_at=tx.created_at,
  58. )
  59. def _clamp_day(year: int, month: int, desired_day: int) -> int:
  60. return min(max(1, desired_day), calendar.monthrange(year, month)[1])
  61. async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
  62. """Resolve monthly budget window start in UTC using configurable reset day/timezone.
  63. Defaults preserve current behavior: day=1, timezone=UTC.
  64. """
  65. desired_day = 1
  66. tz_name = "UTC"
  67. result = await db.execute(
  68. select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
  69. )
  70. for setting in result.scalars().all():
  71. if setting.key == "finance_budget_reset_day":
  72. try:
  73. parsed = int(setting.value)
  74. if 1 <= parsed <= 31:
  75. desired_day = parsed
  76. except (TypeError, ValueError):
  77. pass
  78. elif setting.key == "finance_budget_reset_timezone":
  79. value = (setting.value or "").strip()
  80. if value:
  81. tz_name = value
  82. try:
  83. tz = ZoneInfo(tz_name)
  84. except ZoneInfoNotFoundError:
  85. tz = timezone.utc
  86. now_local = datetime.now(tz)
  87. current_month_reset_day = _clamp_day(now_local.year, now_local.month, desired_day)
  88. if now_local.day >= current_month_reset_day:
  89. start_local = datetime(now_local.year, now_local.month, current_month_reset_day, tzinfo=tz)
  90. else:
  91. prev_year = now_local.year
  92. prev_month = now_local.month - 1
  93. if prev_month == 0:
  94. prev_month = 12
  95. prev_year -= 1
  96. prev_month_reset_day = _clamp_day(prev_year, prev_month, desired_day)
  97. start_local = datetime(prev_year, prev_month, prev_month_reset_day, tzinfo=tz)
  98. return start_local.astimezone(timezone.utc)
  99. async def _get_cost_center_usage_maps(
  100. db: AsyncSession,
  101. cost_center_ids: list[int],
  102. ) -> tuple[dict[int, float], dict[int, float]]:
  103. if not cost_center_ids:
  104. return {}, {}
  105. spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
  106. total_rows = await db.execute(
  107. select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
  108. .where(
  109. WalletTransaction.cost_center_id.in_(cost_center_ids),
  110. WalletTransaction.cost_center_id.is_not(None),
  111. )
  112. .group_by(WalletTransaction.cost_center_id)
  113. )
  114. budget_window_start_utc = await _get_budget_window_start_utc(db)
  115. month_rows = await db.execute(
  116. select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
  117. .where(
  118. WalletTransaction.cost_center_id.in_(cost_center_ids),
  119. WalletTransaction.cost_center_id.is_not(None),
  120. WalletTransaction.created_at >= budget_window_start_utc,
  121. )
  122. .group_by(WalletTransaction.cost_center_id)
  123. )
  124. total_map = {int(center_id): float(value) for center_id, value in total_rows.all() if center_id is not None}
  125. month_map = {int(center_id): float(value) for center_id, value in month_rows.all() if center_id is not None}
  126. return total_map, month_map
  127. async def _get_cost_center_balance_map(
  128. db: AsyncSession,
  129. cost_center_ids: list[int],
  130. ) -> dict[int, float]:
  131. if not cost_center_ids:
  132. return {}
  133. rows = await db.execute(
  134. select(WalletTransaction.cost_center_id, func.coalesce(func.sum(WalletTransaction.amount), 0.0))
  135. .where(
  136. WalletTransaction.cost_center_id.in_(cost_center_ids),
  137. WalletTransaction.cost_center_id.is_not(None),
  138. )
  139. .group_by(WalletTransaction.cost_center_id)
  140. )
  141. return {int(center_id): float(value) for center_id, value in rows.all() if center_id is not None}
  142. async def _get_cost_center_reserved_map(
  143. db: AsyncSession,
  144. cost_center_ids: list[int],
  145. ) -> dict[int, float]:
  146. if not cost_center_ids:
  147. return {}
  148. budget_rows = await db.execute(
  149. select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
  150. .where(
  151. BudgetReservation.cost_center_id.in_(cost_center_ids),
  152. BudgetReservation.status == "active",
  153. )
  154. .group_by(BudgetReservation.cost_center_id)
  155. )
  156. reserved_map = {int(center_id): float(value) for center_id, value in budget_rows.all() if center_id is not None}
  157. queue_rows = await db.execute(
  158. select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
  159. .where(
  160. PrintQueueItem.cost_center_id.in_(cost_center_ids),
  161. PrintQueueItem.status.in_(("pending", "printing")),
  162. )
  163. .group_by(PrintQueueItem.cost_center_id)
  164. )
  165. for center_id, value in queue_rows.all():
  166. if center_id is not None:
  167. reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
  168. return reserved_map
  169. def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
  170. # Monthly takes precedence if legacy data still has both set.
  171. if center.monthly_budget is not None:
  172. return "monthly", float(center.monthly_budget)
  173. if center.total_budget is not None:
  174. return "total", float(center.total_budget)
  175. return "none", None
  176. def _to_cost_center_summary(
  177. center: CostCenter,
  178. *,
  179. can_print: bool,
  180. total_usage: float,
  181. month_usage: float,
  182. total_balance: float,
  183. reserved: float = 0.0,
  184. ) -> CostCenterSummaryResponse:
  185. budget_mode, budget_limit = _budget_mode_and_limit(center)
  186. budget_used = month_usage if budget_mode == "monthly" else total_usage if budget_mode == "total" else None
  187. budget_available = (
  188. max(0.0, budget_limit - budget_used - reserved)
  189. if budget_limit is not None and budget_used is not None
  190. else None
  191. )
  192. return CostCenterSummaryResponse(
  193. id=center.id,
  194. name=center.name,
  195. is_private=center.is_private,
  196. owner_user_id=center.owner_user_id,
  197. is_active=center.is_active,
  198. total_balance=total_balance,
  199. total_budget=center.total_budget,
  200. monthly_budget=center.monthly_budget,
  201. budget_mode=budget_mode,
  202. budget_limit=budget_limit,
  203. budget_used=budget_used,
  204. budget_available=budget_available,
  205. can_print=can_print,
  206. )
  207. async def _require_authenticated_user(current_user: User | None) -> User:
  208. if current_user is None:
  209. raise HTTPException(status_code=401, detail="Authentication required")
  210. return current_user
  211. def _has_cost_center_admin_access(user: User) -> bool:
  212. return user.has_any_permission(
  213. Permission.COST_CENTERS_READ_ALL.value,
  214. Permission.COST_CENTERS_MODIFY.value,
  215. Permission.COST_CENTERS_CREATE.value,
  216. )
  217. async def _require_cost_center_admin_access(current_user: User | None) -> User:
  218. user = await _require_authenticated_user(current_user)
  219. if not _has_cost_center_admin_access(user):
  220. raise HTTPException(status_code=403, detail="Missing required permissions for cost center administration")
  221. return user
  222. async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
  223. result = await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))
  224. wallet = result.scalar_one_or_none()
  225. if wallet:
  226. return wallet
  227. wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
  228. db.add(wallet)
  229. await db.flush()
  230. await db.refresh(wallet)
  231. return wallet
  232. async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
  233. result = await db.execute(select(User).where(User.id == user_id))
  234. user = result.scalar_one_or_none()
  235. if user is None:
  236. raise HTTPException(status_code=404, detail="User not found")
  237. return user
  238. async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> CostCenter:
  239. result = await db.execute(
  240. select(CostCenter).options(selectinload(CostCenter.members)).where(CostCenter.id == cost_center_id)
  241. )
  242. center = result.scalar_one_or_none()
  243. if center is None:
  244. raise HTTPException(status_code=404, detail="Cost center not found")
  245. return center
  246. def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
  247. return WalletBalanceResponse(
  248. user_id=wallet.user_id,
  249. balance=wallet.balance,
  250. currency=wallet.currency,
  251. updated_at=wallet.updated_at,
  252. )
  253. async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[int, float]:
  254. result = await db.execute(
  255. select(
  256. WalletTransaction.id,
  257. WalletTransaction.amount,
  258. WalletTransaction.cost_center_id,
  259. CostCenter.is_private,
  260. CostCenter.owner_user_id,
  261. )
  262. .where(
  263. WalletTransaction.user_id == user_id,
  264. )
  265. .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
  266. .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  267. )
  268. running_balance = 0.0
  269. balance_map: dict[int, float] = {}
  270. for transaction_id, amount, cost_center_id, is_private, owner_user_id in result.all():
  271. is_personal_cost_center = bool(cost_center_id is not None and is_private and owner_user_id == user_id)
  272. if cost_center_id is None or is_personal_cost_center:
  273. running_balance += float(amount)
  274. balance_map[int(transaction_id)] = running_balance
  275. return balance_map
  276. def _personal_balance_condition(user_id: int):
  277. return or_(
  278. WalletTransaction.cost_center_id.is_(None),
  279. and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
  280. )
  281. async def _create_wallet_adjustment(
  282. db: AsyncSession,
  283. *,
  284. target_user_id: int,
  285. actor_user_id: int,
  286. amount: float,
  287. transaction_type: str,
  288. description: str | None,
  289. cost_center_id: int | None,
  290. ) -> WalletAdjustmentResponse:
  291. transaction_type = normalize_transaction_type(transaction_type)
  292. if cost_center_id is not None:
  293. await _get_cost_center_or_404(db, cost_center_id)
  294. wallet = await _get_or_create_wallet(db, target_user_id)
  295. # Calculate balance_after for this specific transaction context
  296. if cost_center_id is None:
  297. # Personal transaction: validate and update user wallet
  298. new_balance = wallet.balance + amount
  299. if new_balance < 0:
  300. raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
  301. wallet.balance = new_balance
  302. balance_after = new_balance
  303. else:
  304. # Cost-center transaction: validate against cost center balance only (global, not per-user)
  305. result = await db.execute(
  306. select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
  307. WalletTransaction.cost_center_id == cost_center_id,
  308. )
  309. )
  310. current_cc_balance = float(result.scalar() or 0.0)
  311. new_cc_balance = current_cc_balance + amount
  312. if new_cc_balance < 0:
  313. raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
  314. balance_after = new_cc_balance
  315. # Do NOT update wallet.balance for cost-center transactions
  316. tx = WalletTransaction(
  317. user_id=target_user_id,
  318. cost_center_id=cost_center_id,
  319. transaction_type=transaction_type,
  320. amount=amount,
  321. balance_after=balance_after,
  322. description=description,
  323. created_by_user_id=actor_user_id,
  324. )
  325. db.add(tx)
  326. await db.flush()
  327. await db.commit()
  328. await db.refresh(wallet)
  329. await db.refresh(tx)
  330. # Return appropriate balance based on transaction type
  331. if cost_center_id is None:
  332. # Personal transaction: return user wallet balance
  333. response_balance = _to_balance_response(wallet)
  334. else:
  335. # Cost-center transaction: return cost-center balance as if it were a wallet
  336. response_balance = WalletBalanceResponse(
  337. user_id=target_user_id,
  338. balance=balance_after,
  339. currency=wallet.currency,
  340. updated_at=tx.created_at,
  341. )
  342. return WalletAdjustmentResponse(
  343. transaction=_serialize_wallet_transaction(tx),
  344. balance=response_balance,
  345. )
  346. @router.get("/me/balance", response_model=WalletBalanceResponse)
  347. async def get_my_balance(
  348. db: AsyncSession = Depends(get_db),
  349. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
  350. ):
  351. """Return the current user's wallet balance."""
  352. user = await _require_authenticated_user(current_user)
  353. wallet = await _get_or_create_wallet(db, user.id)
  354. personal_balance_result = await db.execute(
  355. select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
  356. .select_from(WalletTransaction)
  357. .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
  358. .where(WalletTransaction.user_id == user.id, _personal_balance_condition(user.id))
  359. )
  360. personal_balance = float(personal_balance_result.scalar_one() or 0.0)
  361. return WalletBalanceResponse(
  362. user_id=user.id,
  363. balance=personal_balance,
  364. currency=wallet.currency,
  365. updated_at=wallet.updated_at,
  366. )
  367. @router.get("/me/transactions", response_model=WalletTransactionListResponse)
  368. async def get_my_transactions(
  369. limit: int = Query(50, ge=1, le=500),
  370. offset: int = Query(0, ge=0),
  371. db: AsyncSession = Depends(get_db),
  372. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
  373. ):
  374. """Return wallet ledger entries for the current user."""
  375. user = await _require_authenticated_user(current_user)
  376. total_result = await db.execute(
  377. select(func.count(WalletTransaction.id)).where(WalletTransaction.user_id == user.id)
  378. )
  379. total = int(total_result.scalar_one() or 0)
  380. result = await db.execute(
  381. select(WalletTransaction)
  382. .where(WalletTransaction.user_id == user.id)
  383. .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
  384. .limit(limit)
  385. .offset(offset)
  386. )
  387. transactions = result.scalars().all()
  388. personal_balance_map = await _build_personal_balance_map(db, user.id)
  389. return WalletTransactionListResponse(
  390. items=[
  391. _serialize_wallet_transaction(tx).model_copy(
  392. update={"balance_after": personal_balance_map.get(tx.id, tx.balance_after)}
  393. )
  394. for tx in transactions
  395. ],
  396. total=total,
  397. limit=limit,
  398. offset=offset,
  399. )
  400. @router.get("/transactions", response_model=WalletTransactionListResponse)
  401. async def get_all_transactions(
  402. limit: int = Query(50, ge=1, le=500),
  403. offset: int = Query(0, ge=0),
  404. user_id: int | None = Query(None, description="Optional filter by user id"),
  405. db: AsyncSession = Depends(get_db),
  406. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
  407. ):
  408. """Return wallet ledger entries across users for admin finance view."""
  409. await _require_authenticated_user(current_user)
  410. conditions = []
  411. if user_id is not None:
  412. await _get_user_or_404(db, user_id)
  413. conditions.append(WalletTransaction.user_id == user_id)
  414. total_result = await db.execute(select(func.count(WalletTransaction.id)).where(*conditions))
  415. total = int(total_result.scalar_one() or 0)
  416. result = await db.execute(
  417. select(WalletTransaction)
  418. .where(*conditions)
  419. .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
  420. .limit(limit)
  421. .offset(offset)
  422. )
  423. transactions = result.scalars().all()
  424. return WalletTransactionListResponse(
  425. items=[_serialize_wallet_transaction(tx) for tx in transactions],
  426. total=total,
  427. limit=limit,
  428. offset=offset,
  429. )
  430. async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
  431. """Recompute `balance_after` for all wallet transactions of a user.
  432. - Personal transactions (cost_center_id=None): running balance per user
  433. - Cost-center transactions: running balance GLOBAL for entire cost center (not per-user)
  434. - Also updates the user's wallet balance (sum of personal transactions only)
  435. """
  436. result = await db.execute(
  437. select(WalletTransaction)
  438. .where(WalletTransaction.user_id == user_id)
  439. .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  440. )
  441. user_transactions = result.scalars().all()
  442. # Handle personal transactions (cost_center_id=None)
  443. personal_balance = 0.0
  444. for tx in user_transactions:
  445. if tx.cost_center_id is None:
  446. personal_balance += float(tx.amount)
  447. tx.balance_after = personal_balance
  448. db.add(tx)
  449. for cc_id in {tx.cost_center_id for tx in user_transactions if tx.cost_center_id is not None}:
  450. # Get all transactions for this cost center (all users, all time)
  451. result_all_cc = await db.execute(
  452. select(WalletTransaction)
  453. .where(WalletTransaction.cost_center_id == cc_id)
  454. .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  455. )
  456. all_cc_transactions = result_all_cc.scalars().all()
  457. running = 0.0
  458. for tx in all_cc_transactions:
  459. running += float(tx.amount)
  460. tx.balance_after = running
  461. db.add(tx)
  462. # Update user wallet balance (sum of all personal transactions only)
  463. wallet = await _get_or_create_wallet(db, user_id)
  464. wallet.balance = personal_balance
  465. await db.flush()
  466. await db.commit()
  467. @router.delete("/transactions/{transaction_id}")
  468. async def delete_transaction(
  469. transaction_id: int,
  470. db: AsyncSession = Depends(get_db),
  471. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  472. ):
  473. """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
  474. await _require_authenticated_user(current_user)
  475. result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
  476. tx = result.scalar_one_or_none()
  477. if tx is None:
  478. raise HTTPException(status_code=404, detail="Transaction not found")
  479. user_id = tx.user_id
  480. if tx.transaction_type == "print_charge" and tx.print_archive_id is not None:
  481. archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == tx.print_archive_id))
  482. archive = archive_result.scalar_one_or_none()
  483. if archive is not None:
  484. archive.wallet_charge_skipped = True
  485. db.add(archive)
  486. await db.delete(tx)
  487. await db.flush()
  488. await _rebuild_wallet_ledger_for_user(db, user_id)
  489. return {"status": "success"}
  490. @router.patch("/transactions/{transaction_id}", response_model=WalletTransactionResponse)
  491. async def edit_transaction(
  492. transaction_id: int,
  493. request: TransactionEditRequest,
  494. db: AsyncSession = Depends(get_db),
  495. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  496. ):
  497. """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
  498. await _require_authenticated_user(current_user)
  499. result = await db.execute(select(WalletTransaction).where(WalletTransaction.id == transaction_id))
  500. tx = result.scalar_one_or_none()
  501. if tx is None:
  502. raise HTTPException(status_code=404, detail="Transaction not found")
  503. # Apply edits
  504. if request.user_id is not None:
  505. tx.user_id = request.user_id
  506. if request.cost_center_id is not None:
  507. tx.cost_center_id = request.cost_center_id
  508. if request.amount is not None:
  509. tx.amount = request.amount
  510. if request.description is not None:
  511. # Append "(Admin edit)" marker if not already present
  512. new_desc = request.description
  513. if not new_desc.endswith("(Admin edit)"):
  514. new_desc = f"{new_desc} (Admin edit)"
  515. tx.description = new_desc
  516. db.add(tx)
  517. await db.flush()
  518. # Rebuild full ledger using the current session
  519. from backend.app.core.database import repair_wallet_ledger_internal
  520. await repair_wallet_ledger_internal(db)
  521. return tx
  522. @router.post("/transactions/manual", response_model=WalletTransactionResponse)
  523. async def create_manual_print(
  524. request: ManualPrintRequest,
  525. db: AsyncSession = Depends(get_db),
  526. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  527. ):
  528. """Create a manual print charge transaction (for admin purposes)."""
  529. await _require_authenticated_user(current_user)
  530. from datetime import timezone
  531. # Use provided created_at or current time
  532. created_at = request.created_at or datetime.now(timezone.utc)
  533. # Ensure manual print charges are negative amounts (charges reduce wallet)
  534. amount = request.amount
  535. if amount > 0:
  536. amount = -abs(amount)
  537. # Create transaction
  538. tx = WalletTransaction(
  539. user_id=request.user_id,
  540. cost_center_id=request.cost_center_id,
  541. transaction_type=TransactionType.MANUAL_ADJUSTMENT.value,
  542. amount=amount,
  543. balance_after=None, # Will be set by repair_wallet_ledger_internal
  544. description=request.description or "Manual print charge",
  545. created_by_user_id=current_user.id if current_user else None,
  546. created_at=created_at,
  547. )
  548. db.add(tx)
  549. await db.flush()
  550. # Rebuild full ledger using the current session
  551. from backend.app.core.database import repair_wallet_ledger_internal
  552. await repair_wallet_ledger_internal(db)
  553. return tx
  554. @router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
  555. async def get_my_cost_centers(
  556. db: AsyncSession = Depends(get_db),
  557. current_user: User | None = Depends(require_auth_if_enabled),
  558. ):
  559. """Return private and assigned cost centers for the current user."""
  560. user = await _require_authenticated_user(current_user)
  561. result = await db.execute(
  562. select(CostCenter, CostCenterMember.can_print)
  563. .outerjoin(
  564. CostCenterMember,
  565. (CostCenterMember.cost_center_id == CostCenter.id) & (CostCenterMember.user_id == user.id),
  566. )
  567. .where(
  568. CostCenter.is_active.is_(True),
  569. or_(
  570. (CostCenter.is_private.is_(True) & (CostCenter.owner_user_id == user.id)),
  571. (CostCenterMember.user_id == user.id),
  572. ),
  573. )
  574. .order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
  575. )
  576. rows = result.all()
  577. centers_only = [center for center, _ in rows]
  578. center_ids = [center.id for center in centers_only]
  579. total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
  580. total_balance_map = await _get_cost_center_balance_map(db, center_ids)
  581. reserved_map = await _get_cost_center_reserved_map(db, center_ids)
  582. centers: list[CostCenterSummaryResponse] = []
  583. for center, can_print in rows:
  584. centers.append(
  585. _to_cost_center_summary(
  586. center,
  587. can_print=True if center.is_private and center.owner_user_id == user.id else bool(can_print),
  588. total_usage=total_usage_map.get(center.id, 0.0),
  589. month_usage=month_usage_map.get(center.id, 0.0),
  590. total_balance=total_balance_map.get(center.id, 0.0),
  591. reserved=reserved_map.get(center.id, 0.0),
  592. )
  593. )
  594. return centers
  595. @router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
  596. async def get_user_balance(
  597. user_id: int,
  598. db: AsyncSession = Depends(get_db),
  599. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
  600. ):
  601. """Return a specific user's wallet balance."""
  602. await _require_authenticated_user(current_user)
  603. user = await _get_user_or_404(db, user_id)
  604. wallet = await _get_or_create_wallet(db, user.id)
  605. return _to_balance_response(wallet)
  606. @router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
  607. async def get_user_transactions(
  608. user_id: int,
  609. limit: int = Query(50, ge=1, le=500),
  610. offset: int = Query(0, ge=0),
  611. db: AsyncSession = Depends(get_db),
  612. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
  613. ):
  614. """Return wallet ledger entries for a specific user."""
  615. await _require_authenticated_user(current_user)
  616. await _get_user_or_404(db, user_id)
  617. result = await db.execute(
  618. select(WalletTransaction)
  619. .where(WalletTransaction.user_id == user_id)
  620. .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
  621. .limit(limit)
  622. .offset(offset)
  623. )
  624. return [_serialize_wallet_transaction(tx) for tx in result.scalars().all()]
  625. @router.post("/users/{user_id}/deposit", response_model=WalletAdjustmentResponse)
  626. async def deposit_user_balance(
  627. user_id: int,
  628. body: WalletAdjustmentRequest,
  629. db: AsyncSession = Depends(get_db),
  630. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  631. ):
  632. """Add funds to a user's wallet."""
  633. actor = await _require_authenticated_user(current_user)
  634. await _get_user_or_404(db, user_id)
  635. return await _create_wallet_adjustment(
  636. db,
  637. target_user_id=user_id,
  638. actor_user_id=actor.id,
  639. amount=body.amount,
  640. transaction_type=TransactionType.DEPOSIT.value,
  641. description=body.description,
  642. cost_center_id=body.cost_center_id,
  643. )
  644. @router.post("/users/{user_id}/withdraw", response_model=WalletAdjustmentResponse)
  645. async def withdraw_user_balance(
  646. user_id: int,
  647. body: WalletAdjustmentRequest,
  648. db: AsyncSession = Depends(get_db),
  649. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  650. ):
  651. """Withdraw funds from a user's wallet."""
  652. actor = await _require_authenticated_user(current_user)
  653. await _get_user_or_404(db, user_id)
  654. return await _create_wallet_adjustment(
  655. db,
  656. target_user_id=user_id,
  657. actor_user_id=actor.id,
  658. amount=-body.amount,
  659. transaction_type=TransactionType.WITHDRAW.value,
  660. description=body.description,
  661. cost_center_id=body.cost_center_id,
  662. )
  663. @router.post("/rebuild-balance-ledger")
  664. async def rebuild_balance_ledger(
  665. db: AsyncSession = Depends(get_db),
  666. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  667. ):
  668. """Recompute balance_after for all wallet transactions.
  669. This rebuilds the running balance for all users and cost centers.
  670. - Personal transactions: per-user running balance
  671. - Cost-center transactions: global running balance for the entire cost center
  672. """
  673. await _require_authenticated_user(current_user)
  674. # Get ALL transactions sorted by timestamp
  675. result = await db.execute(
  676. select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  677. )
  678. all_transactions = result.scalars().all()
  679. # Build running balances per (user, cost_center_id) pair
  680. # For each cost center, track its global running balance
  681. # For each user's personal balance, track that separately
  682. cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
  683. user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
  684. tx_updates: list[tuple[WalletTransaction, float]] = []
  685. for tx in all_transactions:
  686. if tx.cost_center_id is None:
  687. # Personal transaction: per-user running balance
  688. current = user_personal_balances.get(tx.user_id, 0.0)
  689. new_balance = current + float(tx.amount)
  690. user_personal_balances[tx.user_id] = new_balance
  691. tx_updates.append((tx, new_balance))
  692. else:
  693. # Cost-center transaction: global running balance for this cost center
  694. current = cc_running_balances.get(tx.cost_center_id, 0.0)
  695. new_balance = current + float(tx.amount)
  696. cc_running_balances[tx.cost_center_id] = new_balance
  697. tx_updates.append((tx, new_balance))
  698. # Update all transactions with the new balance_after values
  699. for tx, new_balance in tx_updates:
  700. tx.balance_after = new_balance
  701. db.add(tx)
  702. await db.flush()
  703. await db.commit()
  704. return {
  705. "status": "success",
  706. "transactions_rebuilt": len(all_transactions),
  707. "message": f"Rebuilt balance_after for {len(all_transactions)} transactions",
  708. }
  709. @router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
  710. async def list_cost_centers(
  711. include_inactive: bool = Query(False),
  712. db: AsyncSession = Depends(get_db),
  713. current_user: User | None = Depends(require_auth_if_enabled),
  714. ):
  715. """List all cost centers.
  716. Requires admin-level finance permissions.
  717. """
  718. await _require_cost_center_admin_access(current_user)
  719. query = select(CostCenter).order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
  720. if not include_inactive:
  721. query = query.where(CostCenter.is_active.is_(True))
  722. result = await db.execute(query)
  723. centers = result.scalars().all()
  724. center_ids = [center.id for center in centers]
  725. total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
  726. total_balance_map = await _get_cost_center_balance_map(db, center_ids)
  727. reserved_map = await _get_cost_center_reserved_map(db, center_ids)
  728. return [
  729. _to_cost_center_summary(
  730. center,
  731. can_print=True,
  732. total_usage=total_usage_map.get(center.id, 0.0),
  733. month_usage=month_usage_map.get(center.id, 0.0),
  734. total_balance=total_balance_map.get(center.id, 0.0),
  735. reserved=reserved_map.get(center.id, 0.0),
  736. )
  737. for center in centers
  738. ]
  739. @router.post("/cost-centers", response_model=CostCenterSummaryResponse)
  740. async def create_cost_center(
  741. body: CostCenterCreateRequest,
  742. db: AsyncSession = Depends(get_db),
  743. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
  744. ):
  745. """Create a shared cost center."""
  746. await _require_authenticated_user(current_user)
  747. total_budget = body.total_budget
  748. monthly_budget = body.monthly_budget
  749. if monthly_budget is not None:
  750. total_budget = None
  751. elif total_budget is not None:
  752. monthly_budget = None
  753. center = CostCenter(
  754. name=body.name.strip(),
  755. is_active=body.is_active,
  756. is_private=False,
  757. owner_user_id=None,
  758. total_budget=total_budget,
  759. monthly_budget=monthly_budget,
  760. )
  761. db.add(center)
  762. await db.flush()
  763. await db.commit()
  764. await db.refresh(center)
  765. return _to_cost_center_summary(center, can_print=True, total_usage=0.0, month_usage=0.0, total_balance=0.0)
  766. @router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
  767. async def get_cost_center(
  768. cost_center_id: int,
  769. db: AsyncSession = Depends(get_db),
  770. current_user: User | None = Depends(require_auth_if_enabled),
  771. ):
  772. """Get one cost center with its memberships."""
  773. await _require_cost_center_admin_access(current_user)
  774. center = await _get_cost_center_or_404(db, cost_center_id)
  775. total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
  776. total_balance_map = await _get_cost_center_balance_map(db, [center.id])
  777. reserved_map = await _get_cost_center_reserved_map(db, [center.id])
  778. summary = _to_cost_center_summary(
  779. center,
  780. can_print=True,
  781. total_usage=total_usage_map.get(center.id, 0.0),
  782. month_usage=month_usage_map.get(center.id, 0.0),
  783. total_balance=total_balance_map.get(center.id, 0.0),
  784. reserved=reserved_map.get(center.id, 0.0),
  785. )
  786. return CostCenterDetailResponse(
  787. **summary.model_dump(),
  788. members=[CostCenterMemberResponse.model_validate(m) for m in center.members],
  789. )
  790. @router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterSummaryResponse)
  791. async def update_cost_center(
  792. cost_center_id: int,
  793. body: CostCenterUpdateRequest,
  794. db: AsyncSession = Depends(get_db),
  795. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  796. ):
  797. """Update name or active-state of a cost center."""
  798. await _require_authenticated_user(current_user)
  799. center = await _get_cost_center_or_404(db, cost_center_id)
  800. if body.name is not None:
  801. center.name = body.name.strip()
  802. if body.is_active is not None:
  803. center.is_active = body.is_active
  804. await db.flush()
  805. total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
  806. total_balance_map = await _get_cost_center_balance_map(db, [center.id])
  807. reserved_map = await _get_cost_center_reserved_map(db, [center.id])
  808. return _to_cost_center_summary(
  809. center,
  810. can_print=True,
  811. total_usage=total_usage_map.get(center.id, 0.0),
  812. month_usage=month_usage_map.get(center.id, 0.0),
  813. total_balance=total_balance_map.get(center.id, 0.0),
  814. reserved=reserved_map.get(center.id, 0.0),
  815. )
  816. @router.patch("/cost-centers/{cost_center_id}/budgets", response_model=CostCenterSummaryResponse)
  817. async def update_cost_center_budgets(
  818. cost_center_id: int,
  819. body: CostCenterBudgetUpdateRequest,
  820. db: AsyncSession = Depends(get_db),
  821. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  822. ):
  823. """Update budget values of a cost center."""
  824. await _require_authenticated_user(current_user)
  825. center = await _get_cost_center_or_404(db, cost_center_id)
  826. if body.monthly_budget is not None:
  827. center.monthly_budget = body.monthly_budget
  828. center.total_budget = None
  829. elif body.total_budget is not None:
  830. center.total_budget = body.total_budget
  831. center.monthly_budget = None
  832. else:
  833. center.total_budget = None
  834. center.monthly_budget = None
  835. await db.flush()
  836. total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
  837. total_balance_map = await _get_cost_center_balance_map(db, [center.id])
  838. reserved_map = await _get_cost_center_reserved_map(db, [center.id])
  839. return _to_cost_center_summary(
  840. center,
  841. can_print=True,
  842. total_usage=total_usage_map.get(center.id, 0.0),
  843. month_usage=month_usage_map.get(center.id, 0.0),
  844. total_balance=total_balance_map.get(center.id, 0.0),
  845. reserved=reserved_map.get(center.id, 0.0),
  846. )
  847. @router.post("/cost-centers/{cost_center_id}/members", response_model=CostCenterMemberResponse)
  848. async def upsert_cost_center_member(
  849. cost_center_id: int,
  850. body: CostCenterMemberRequest,
  851. db: AsyncSession = Depends(get_db),
  852. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  853. ):
  854. """Assign or update a user's membership on a cost center."""
  855. await _require_authenticated_user(current_user)
  856. center = await _get_cost_center_or_404(db, cost_center_id)
  857. if center.is_private:
  858. raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
  859. await _get_user_or_404(db, body.user_id)
  860. existing = await db.execute(
  861. select(CostCenterMember).where(
  862. CostCenterMember.cost_center_id == cost_center_id,
  863. CostCenterMember.user_id == body.user_id,
  864. )
  865. )
  866. member = existing.scalar_one_or_none()
  867. if member is None:
  868. member = CostCenterMember(cost_center_id=cost_center_id, user_id=body.user_id, can_print=body.can_print)
  869. db.add(member)
  870. else:
  871. member.can_print = body.can_print
  872. await db.flush()
  873. await db.commit()
  874. return CostCenterMemberResponse.model_validate(member)
  875. @router.delete("/cost-centers/{cost_center_id}")
  876. async def delete_cost_center(
  877. cost_center_id: int,
  878. db: AsyncSession = Depends(get_db),
  879. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  880. ):
  881. """Delete a shared cost center."""
  882. await _require_authenticated_user(current_user)
  883. center = await _get_cost_center_or_404(db, cost_center_id)
  884. if center.is_private:
  885. raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
  886. balance_map = await _get_cost_center_balance_map(db, [center.id])
  887. total_balance = balance_map.get(center.id, 0.0)
  888. if abs(total_balance) > 1e-9:
  889. raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
  890. await db.delete(center)
  891. await db.commit()
  892. return {"status": "success"}
  893. @router.delete("/cost-centers/{cost_center_id}/members/{user_id}")
  894. async def remove_cost_center_member(
  895. cost_center_id: int,
  896. user_id: int,
  897. db: AsyncSession = Depends(get_db),
  898. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
  899. ):
  900. """Remove a user from a shared cost center."""
  901. await _require_authenticated_user(current_user)
  902. center = await _get_cost_center_or_404(db, cost_center_id)
  903. if center.is_private:
  904. raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
  905. result = await db.execute(
  906. select(CostCenterMember).where(
  907. CostCenterMember.cost_center_id == cost_center_id,
  908. CostCenterMember.user_id == user_id,
  909. )
  910. )
  911. member = result.scalar_one_or_none()
  912. if member is None:
  913. raise HTTPException(status_code=404, detail="Membership not found")
  914. await db.delete(member)
  915. await db.commit()
  916. return {"status": "success"}