finance.py 37 KB

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