finance.py 37 KB

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