test_finance_api.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  1. """Integration tests for the finance/billing API."""
  2. import pytest
  3. from httpx import AsyncClient
  4. from sqlalchemy import select
  5. from backend.app.core.auth import get_password_hash
  6. from backend.app.models.archive import PrintArchive
  7. from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
  8. from backend.app.models.group import Group
  9. from backend.app.models.print_queue import PrintQueueItem
  10. from backend.app.models.settings import Settings
  11. from backend.app.models.user import User
  12. from backend.app.services.finance_billing import apply_print_charge_for_archive
  13. class TestFinanceAPI:
  14. @pytest.fixture
  15. async def admin_user(self, db_session):
  16. user = User(
  17. username="finance-admin",
  18. email="finance-admin@example.com",
  19. password_hash=get_password_hash("AdminPass1!"),
  20. role="admin",
  21. is_active=True,
  22. )
  23. db_session.add(user)
  24. await db_session.commit()
  25. await db_session.refresh(user)
  26. return user
  27. @pytest.fixture
  28. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  29. db_session.add(Settings(key="auth_enabled", value="true"))
  30. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  31. # Ensure billing is enabled for finance integration tests
  32. existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  33. if existing is None:
  34. db_session.add(Settings(key="billing_enabled", value="true"))
  35. else:
  36. existing.value = "true"
  37. await db_session.commit()
  38. response = await async_client.post(
  39. "/api/v1/auth/login",
  40. json={"username": admin_user.username, "password": "AdminPass1!"},
  41. )
  42. assert response.status_code == 200
  43. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  44. async def _enable_basic_user_creation(self, db_session):
  45. return None
  46. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  47. response = await async_client.post(
  48. "/api/v1/users",
  49. json={
  50. "username": username,
  51. "password": "Regularpass1!",
  52. "email": f"{username}@example.com",
  53. "role": "user",
  54. },
  55. headers=auth_headers,
  56. )
  57. assert response.status_code == 201
  58. return response.json()
  59. async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
  60. response = await async_client.post(
  61. "/api/v1/auth/login",
  62. json={"username": username, "password": "Regularpass1!"},
  63. )
  64. assert response.status_code == 200
  65. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_create_cost_center_assign_member_and_list_mine(
  69. self,
  70. async_client: AsyncClient,
  71. auth_headers: dict[str, str],
  72. db_session,
  73. ):
  74. await self._enable_basic_user_creation(db_session)
  75. created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
  76. user_headers = await self._login_user(async_client, "carol")
  77. create_response = await async_client.post(
  78. "/api/v1/finance/cost-centers",
  79. json={
  80. "name": "Shared Lab",
  81. "monthly_budget": 120.0,
  82. "total_budget": 999.0,
  83. "is_active": True,
  84. },
  85. headers=auth_headers,
  86. )
  87. assert create_response.status_code == 200
  88. shared_center = create_response.json()
  89. assert shared_center["name"] == "Shared Lab"
  90. assert shared_center["monthly_budget"] == 120.0
  91. assert shared_center["total_budget"] is None
  92. assert shared_center["budget_mode"] == "monthly"
  93. member_response = await async_client.post(
  94. f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
  95. json={"user_id": created_user["id"], "can_print": False},
  96. headers=auth_headers,
  97. )
  98. assert member_response.status_code == 200
  99. assert member_response.json()["user_id"] == created_user["id"]
  100. assert member_response.json()["can_print"] is False
  101. mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
  102. assert mine_response.status_code == 200
  103. mine_names = {center["name"] for center in mine_response.json()}
  104. assert "carol" in mine_names
  105. assert "Shared Lab" in mine_names
  106. detail_response = await async_client.get(
  107. f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
  108. )
  109. assert detail_response.status_code == 200
  110. detail = detail_response.json()
  111. assert len(detail["members"]) == 1
  112. assert detail["members"][0]["user_id"] == created_user["id"]
  113. remove_response = await async_client.delete(
  114. f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
  115. headers=auth_headers,
  116. )
  117. assert remove_response.status_code == 200
  118. mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
  119. assert mine_after_remove.status_code == 200
  120. assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_private_cost_center_cannot_be_deactivated_but_can_have_zero_budget(
  124. self,
  125. async_client: AsyncClient,
  126. auth_headers: dict[str, str],
  127. db_session,
  128. ):
  129. created_user = await self._create_user_via_api(async_client, auth_headers, "private-budget-user")
  130. private_center = await db_session.scalar(
  131. select(CostCenter).where(
  132. CostCenter.owner_user_id == created_user["id"],
  133. CostCenter.is_private.is_(True),
  134. )
  135. )
  136. assert private_center is not None
  137. deactivate_response = await async_client.patch(
  138. f"/api/v1/finance/cost-centers/{private_center.id}",
  139. json={"is_active": False},
  140. headers=auth_headers,
  141. )
  142. assert deactivate_response.status_code == 400
  143. assert "cannot be deactivated" in deactivate_response.json()["detail"]
  144. await db_session.refresh(private_center)
  145. assert private_center.is_active is True
  146. budget_response = await async_client.patch(
  147. f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
  148. json={"total_budget": 0},
  149. headers=auth_headers,
  150. )
  151. assert budget_response.status_code == 200
  152. assert budget_response.json()["total_budget"] == 0
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_cost_center_available_budget_does_not_double_count_queue_reservation(
  156. self,
  157. async_client: AsyncClient,
  158. auth_headers: dict[str, str],
  159. db_session,
  160. ):
  161. center_response = await async_client.post(
  162. "/api/v1/finance/cost-centers",
  163. json={"name": "Reserved Once", "total_budget": 10.0},
  164. headers=auth_headers,
  165. )
  166. assert center_response.status_code == 200
  167. center_id = center_response.json()["id"]
  168. reserved_item = PrintQueueItem(
  169. cost_center_id=center_id,
  170. estimated_cost=3.0,
  171. status="pending",
  172. position=1,
  173. )
  174. legacy_unreserved_item = PrintQueueItem(
  175. cost_center_id=center_id,
  176. estimated_cost=2.0,
  177. status="pending",
  178. position=2,
  179. )
  180. db_session.add_all([reserved_item, legacy_unreserved_item])
  181. await db_session.flush()
  182. db_session.add(
  183. BudgetReservation(
  184. cost_center_id=center_id,
  185. amount=3.0,
  186. status="active",
  187. source_type="print_queue",
  188. source_id=reserved_item.id,
  189. )
  190. )
  191. await db_session.commit()
  192. response = await async_client.get("/api/v1/finance/cost-centers", headers=auth_headers)
  193. assert response.status_code == 200
  194. center = next(item for item in response.json() if item["id"] == center_id)
  195. # 3.00 active reservation + 2.00 legacy queue estimate, not 3 + 3 + 2.
  196. assert center["budget_available"] == 5.0
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_cost_center_with_balanced_transactions_cannot_be_deleted(
  200. self,
  201. async_client: AsyncClient,
  202. auth_headers: dict[str, str],
  203. admin_user,
  204. db_session,
  205. ):
  206. center_response = await async_client.post(
  207. "/api/v1/finance/cost-centers",
  208. json={"name": "Balanced History"},
  209. headers=auth_headers,
  210. )
  211. center_id = center_response.json()["id"]
  212. db_session.add_all(
  213. [
  214. WalletTransaction(
  215. user_id=admin_user.id,
  216. cost_center_id=center_id,
  217. transaction_type="deposit",
  218. amount=50.0,
  219. balance_after=50.0,
  220. ),
  221. WalletTransaction(
  222. user_id=admin_user.id,
  223. cost_center_id=center_id,
  224. transaction_type="withdraw",
  225. amount=-50.0,
  226. balance_after=0.0,
  227. ),
  228. ]
  229. )
  230. await db_session.commit()
  231. response = await async_client.delete(
  232. f"/api/v1/finance/cost-centers/{center_id}",
  233. headers=auth_headers,
  234. )
  235. assert response.status_code == 400
  236. assert "transactions reference it" in response.json()["detail"]
  237. transactions = (
  238. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.cost_center_id == center_id)))
  239. .scalars()
  240. .all()
  241. )
  242. assert len(transactions) == 2
  243. @pytest.mark.asyncio
  244. @pytest.mark.integration
  245. async def test_cost_center_with_active_reservation_cannot_be_deleted(
  246. self,
  247. async_client: AsyncClient,
  248. auth_headers: dict[str, str],
  249. db_session,
  250. ):
  251. center_response = await async_client.post(
  252. "/api/v1/finance/cost-centers",
  253. json={"name": "Active Hold"},
  254. headers=auth_headers,
  255. )
  256. center_id = center_response.json()["id"]
  257. db_session.add(
  258. BudgetReservation(
  259. cost_center_id=center_id,
  260. amount=3.0,
  261. status="active",
  262. source_type="direct_print",
  263. source_id=123,
  264. )
  265. )
  266. await db_session.commit()
  267. response = await async_client.delete(
  268. f"/api/v1/finance/cost-centers/{center_id}",
  269. headers=auth_headers,
  270. )
  271. assert response.status_code == 400
  272. assert "active budget reservations" in response.json()["detail"]
  273. assert await db_session.get(CostCenter, center_id) is not None
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_wallet_adjustments_and_transaction_ledger_rebuild(
  277. self,
  278. async_client: AsyncClient,
  279. auth_headers: dict[str, str],
  280. admin_user,
  281. db_session,
  282. ):
  283. """A user's private cost center and unassigned entries share one wallet."""
  284. await self._enable_basic_user_creation(db_session)
  285. created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
  286. private_center = await db_session.scalar(
  287. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  288. )
  289. assert private_center is not None
  290. # The owner's private cost center affects both its own ledger and the wallet.
  291. deposit = await async_client.post(
  292. f"/api/v1/finance/users/{created_user['id']}/deposit",
  293. json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
  294. headers=auth_headers,
  295. )
  296. assert deposit.status_code == 200
  297. assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
  298. assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
  299. assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
  300. # A withdrawal updates both views by the same amount.
  301. withdraw = await async_client.post(
  302. f"/api/v1/finance/users/{created_user['id']}/withdraw",
  303. json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
  304. headers=auth_headers,
  305. )
  306. assert withdraw.status_code == 200
  307. assert withdraw.json()["transaction"]["amount"] == -5.0
  308. assert withdraw.json()["transaction"]["balance_after"] == 20.0 # CC balance after withdraw
  309. assert withdraw.json()["balance"]["balance"] == 20.0 # Response shows CC balance
  310. # Personal deposit: affects user wallet
  311. personal_deposit = await async_client.post(
  312. f"/api/v1/finance/users/{created_user['id']}/deposit",
  313. json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
  314. headers=auth_headers,
  315. )
  316. assert personal_deposit.status_code == 200
  317. assert personal_deposit.json()["transaction"]["cost_center_id"] is None
  318. assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
  319. assert personal_deposit.json()["balance"]["balance"] == 50.0
  320. transactions_response = await async_client.get(
  321. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  322. )
  323. assert transactions_response.status_code == 200
  324. transactions = transactions_response.json()
  325. assert len(transactions) == 3
  326. cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
  327. personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
  328. assert len(cc_txs) == 2
  329. assert len(personal_txs) == 1
  330. balance_response = await async_client.get(
  331. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  332. )
  333. assert balance_response.status_code == 200
  334. assert balance_response.json()["balance"] == 50.0
  335. # Delete personal transaction, user wallet should decrease
  336. personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
  337. delete_response = await async_client.delete(
  338. f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
  339. )
  340. assert delete_response.status_code == 200
  341. balance_after_delete = await async_client.get(
  342. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  343. )
  344. assert balance_after_delete.status_code == 200
  345. assert balance_after_delete.json()["balance"] == 20.0
  346. remaining = await async_client.get(
  347. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  348. )
  349. assert remaining.status_code == 200
  350. assert len(remaining.json()) == 2 # 2 CC transactions remain
  351. @pytest.mark.asyncio
  352. @pytest.mark.integration
  353. async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
  354. self,
  355. async_client: AsyncClient,
  356. auth_headers: dict[str, str],
  357. db_session,
  358. ):
  359. created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
  360. user = await db_session.get(User, created_user["id"])
  361. operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
  362. assert operators is not None
  363. user.groups.append(operators)
  364. await db_session.commit()
  365. user_headers = await self._login_user(async_client, "balance-lifecycle")
  366. private_center = await db_session.scalar(
  367. select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
  368. )
  369. assert private_center is not None
  370. deposit = await async_client.post(
  371. f"/api/v1/finance/users/{user.id}/deposit",
  372. json={"amount": 20.0, "cost_center_id": private_center.id},
  373. headers=auth_headers,
  374. )
  375. assert deposit.status_code == 200
  376. archive = PrintArchive(
  377. filename="charged.gcode.3mf",
  378. file_path="archives/test/charged.gcode.3mf",
  379. file_size=10,
  380. status="completed",
  381. cost=4.0,
  382. created_by_id=user.id,
  383. cost_center_id=private_center.id,
  384. )
  385. db_session.add(archive)
  386. await db_session.commit()
  387. assert await apply_print_charge_for_archive(db_session, archive.id) is True
  388. await db_session.commit()
  389. adjustment = await async_client.post(
  390. f"/api/v1/finance/users/{user.id}/deposit",
  391. json={"amount": 5.0, "description": "temporary adjustment"},
  392. headers=auth_headers,
  393. )
  394. assert adjustment.status_code == 200
  395. async def assert_views_agree(expected: float):
  396. admin_view = await async_client.get(
  397. f"/api/v1/finance/users/{user.id}/balance",
  398. headers=auth_headers,
  399. )
  400. user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
  401. assert admin_view.status_code == 200
  402. assert user_view.status_code == 200
  403. assert admin_view.json()["balance"] == expected
  404. assert user_view.json()["balance"] == expected
  405. await assert_views_agree(21.0)
  406. delete_response = await async_client.delete(
  407. f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
  408. headers=auth_headers,
  409. )
  410. assert delete_response.status_code == 200
  411. await assert_views_agree(16.0)
  412. @pytest.mark.asyncio
  413. @pytest.mark.integration
  414. async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
  415. self,
  416. async_client: AsyncClient,
  417. auth_headers: dict[str, str],
  418. db_session,
  419. ):
  420. await self._enable_basic_user_creation(db_session)
  421. created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
  422. shared_center = await db_session.scalar(
  423. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  424. )
  425. assert shared_center is not None
  426. first_deposit = await async_client.post(
  427. f"/api/v1/finance/users/{created_user['id']}/deposit",
  428. json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
  429. headers=auth_headers,
  430. )
  431. assert first_deposit.status_code == 200
  432. cc_withdraw = await async_client.post(
  433. f"/api/v1/finance/users/{created_user['id']}/withdraw",
  434. json={"amount": 5.0, "description": "CC usage", "cost_center_id": shared_center.id},
  435. headers=auth_headers,
  436. )
  437. assert cc_withdraw.status_code == 200
  438. personal_deposit = await async_client.post(
  439. f"/api/v1/finance/users/{created_user['id']}/deposit",
  440. json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
  441. headers=auth_headers,
  442. )
  443. assert personal_deposit.status_code == 200
  444. delete_response = await async_client.delete(
  445. f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
  446. headers=auth_headers,
  447. )
  448. assert delete_response.status_code == 200
  449. transactions_response = await async_client.get(
  450. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  451. )
  452. assert transactions_response.status_code == 200
  453. transactions = transactions_response.json()
  454. assert len(transactions) == 2
  455. cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
  456. assert cc_transaction["amount"] == -5.0
  457. assert cc_transaction["balance_after"] == -5.0
  458. balance_response = await async_client.get(
  459. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  460. )
  461. assert balance_response.status_code == 200
  462. assert balance_response.json()["balance"] == 7.0
  463. @pytest.mark.asyncio
  464. @pytest.mark.integration
  465. async def test_delete_print_charge_stays_deleted_after_recalculate(
  466. self,
  467. async_client: AsyncClient,
  468. auth_headers: dict[str, str],
  469. db_session,
  470. ):
  471. await self._enable_basic_user_creation(db_session)
  472. created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
  473. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  474. assert user is not None
  475. user_id = user.id
  476. archive = PrintArchive(
  477. printer_id=None,
  478. filename="print.gcode",
  479. file_path="archives/test/print.gcode",
  480. file_size=10,
  481. content_hash="hash-print",
  482. status="completed",
  483. cost=4.0,
  484. created_by_id=user.id,
  485. )
  486. db_session.add(archive)
  487. await db_session.flush()
  488. tx = WalletTransaction(
  489. user_id=user.id,
  490. transaction_type="print_charge",
  491. amount=-4.0,
  492. balance_after=-4.0,
  493. description="Print charge: print.gcode",
  494. created_by_user_id=None,
  495. print_run_id="deleted-print-run",
  496. print_archive_id=archive.id,
  497. )
  498. db_session.add(tx)
  499. await db_session.commit()
  500. archive_id = archive.id
  501. tx_rows_before = (
  502. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
  503. .scalars()
  504. .all()
  505. )
  506. assert len(tx_rows_before) == 1
  507. delete_response = await async_client.delete(
  508. f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
  509. )
  510. assert delete_response.status_code == 200
  511. db_session.expire_all()
  512. tx_rows_after = (
  513. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
  514. .scalars()
  515. .all()
  516. )
  517. assert len(tx_rows_after) == 1
  518. assert tx_rows_after[0].is_voided is True
  519. # The voided run remains an idempotency tombstone and cannot be
  520. # recreated by a delayed duplicate completion callback.
  521. assert (
  522. await apply_print_charge_for_archive(
  523. db_session,
  524. archive_id,
  525. print_run_id="deleted-print-run",
  526. )
  527. ) is False
  528. # A later reprint of the same archive has a distinct run identity and
  529. # must still be charged normally.
  530. assert (
  531. await apply_print_charge_for_archive(
  532. db_session,
  533. archive_id,
  534. charged_user_id=user_id,
  535. print_run_id="later-reprint-run",
  536. )
  537. ) is True
  538. await db_session.commit()
  539. visible = await async_client.get(
  540. f"/api/v1/finance/users/{user_id}/transactions",
  541. headers=auth_headers,
  542. )
  543. assert visible.status_code == 200
  544. assert [row["print_run_id"] for row in visible.json()] == ["later-reprint-run"]
  545. async def test_edit_transaction_updates_ledger(
  546. self,
  547. async_client: AsyncClient,
  548. auth_headers: dict[str, str],
  549. db_session,
  550. ):
  551. """Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
  552. await self._enable_basic_user_creation(db_session)
  553. user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
  554. user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
  555. # Create a cost center
  556. cc_response = await async_client.post(
  557. "/api/v1/finance/cost-centers",
  558. json={"name": "Test Center", "is_active": True},
  559. headers=auth_headers,
  560. )
  561. assert cc_response.status_code == 200
  562. cost_center = cc_response.json()
  563. # Get user records from DB
  564. user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
  565. user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
  566. # Create a personal transaction for user1
  567. tx_response = await async_client.post(
  568. f"/api/v1/finance/users/{user1_db.id}/deposit",
  569. json={"amount": 50.0, "description": "Initial deposit"},
  570. headers=auth_headers,
  571. )
  572. assert tx_response.status_code == 200
  573. tx_data = tx_response.json()
  574. tx_id = tx_data["transaction"]["id"]
  575. # Get the original transaction
  576. original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
  577. assert original_tx.user_id == user1_db.id
  578. assert original_tx.cost_center_id is None
  579. assert original_tx.amount == 50.0
  580. assert original_tx.balance_after == 50.0
  581. # Edit the transaction: change user, add cost center, change amount
  582. edit_response = await async_client.patch(
  583. f"/api/v1/finance/transactions/{tx_id}",
  584. json={
  585. "user_id": user2_db.id,
  586. "cost_center_id": cost_center["id"],
  587. "amount": 75.0,
  588. "description": "Updated deposit (Admin edit)",
  589. },
  590. headers=auth_headers,
  591. )
  592. assert edit_response.status_code == 200
  593. edited_tx_data = edit_response.json()
  594. # Verify transaction was updated
  595. assert edited_tx_data["user_id"] == user2_db.id
  596. assert edited_tx_data["cost_center_id"] == cost_center["id"]
  597. assert edited_tx_data["amount"] == 75.0
  598. # Description should have "(Admin edit)" appended
  599. assert "(Admin edit)" in edited_tx_data["description"]
  600. # An explicit null moves the transaction back to the personal ledger.
  601. clear_response = await async_client.patch(
  602. f"/api/v1/finance/transactions/{tx_id}",
  603. json={"cost_center_id": None},
  604. headers=auth_headers,
  605. )
  606. assert clear_response.status_code == 200
  607. assert clear_response.json()["cost_center_id"] is None
  608. invalid_user_response = await async_client.patch(
  609. f"/api/v1/finance/transactions/{tx_id}",
  610. json={"user_id": 2147483647},
  611. headers=auth_headers,
  612. )
  613. assert invalid_user_response.status_code == 404
  614. assert invalid_user_response.json()["detail"] == "User not found"
  615. invalid_center_response = await async_client.patch(
  616. f"/api/v1/finance/transactions/{tx_id}",
  617. json={"cost_center_id": 2147483647},
  618. headers=auth_headers,
  619. )
  620. assert invalid_center_response.status_code == 404
  621. assert invalid_center_response.json()["detail"] == "Cost center not found"
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_create_manual_print_and_recalculates_ledger(
  625. self,
  626. async_client: AsyncClient,
  627. auth_headers: dict[str, str],
  628. db_session,
  629. ):
  630. """Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
  631. await self._enable_basic_user_creation(db_session)
  632. created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
  633. # Get user DB record
  634. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  635. assert user is not None
  636. # Private cost center for user
  637. private_cc = await db_session.scalar(
  638. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  639. )
  640. assert private_cc is not None
  641. # Post manual print affecting the cost center
  642. payload = {
  643. "user_id": user.id,
  644. "cost_center_id": private_cc.id,
  645. "amount": -4.0,
  646. "description": "Manual adjustment for a print",
  647. "created_at": "2026-05-12T12:00:00Z",
  648. }
  649. response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
  650. assert response.status_code == 200
  651. resp_json = response.json()
  652. assert "transaction" in resp_json or "id" in resp_json
  653. # Response contains the created transaction details
  654. assert resp_json["transaction_type"] == "manual_adjustment"
  655. assert resp_json["amount"] == -4.0
  656. assert resp_json["cost_center_id"] == private_cc.id
  657. # The response includes the computed running balance for the transaction
  658. assert resp_json.get("balance_after") == -4.0
  659. invalid_user_response = await async_client.post(
  660. "/api/v1/finance/transactions/manual",
  661. json={**payload, "user_id": 2147483647},
  662. headers=auth_headers,
  663. )
  664. assert invalid_user_response.status_code == 404
  665. assert invalid_user_response.json()["detail"] == "User not found"
  666. invalid_center_response = await async_client.post(
  667. "/api/v1/finance/transactions/manual",
  668. json={**payload, "cost_center_id": 2147483647},
  669. headers=auth_headers,
  670. )
  671. assert invalid_center_response.status_code == 404
  672. assert invalid_center_response.json()["detail"] == "Cost center not found"
  673. class TestPartialPrintChargesIntegration:
  674. """Integration tests for partial print charge calculation."""
  675. @pytest.fixture
  676. async def admin_user(self, db_session):
  677. user = User(
  678. username="partial-admin",
  679. email="partial-admin@example.com",
  680. password_hash=get_password_hash("AdminPass1!"),
  681. role="admin",
  682. is_active=True,
  683. )
  684. db_session.add(user)
  685. await db_session.commit()
  686. await db_session.refresh(user)
  687. return user
  688. @pytest.fixture
  689. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  690. db_session.add(Settings(key="auth_enabled", value="true"))
  691. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  692. # Ensure billing is enabled for these partial-charge integration tests
  693. existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  694. if existing is None:
  695. db_session.add(Settings(key="billing_enabled", value="true"))
  696. else:
  697. existing.value = "true"
  698. await db_session.commit()
  699. response = await async_client.post(
  700. "/api/v1/auth/login",
  701. json={"username": admin_user.username, "password": "AdminPass1!"},
  702. )
  703. assert response.status_code == 200
  704. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  705. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  706. response = await async_client.post(
  707. "/api/v1/users",
  708. json={
  709. "username": username,
  710. "password": "Regularpass1!",
  711. "email": f"{username}@example.com",
  712. "role": "user",
  713. },
  714. headers=auth_headers,
  715. )
  716. assert response.status_code == 201
  717. return response.json()
  718. @pytest.mark.asyncio
  719. @pytest.mark.integration
  720. async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
  721. self,
  722. async_client: AsyncClient,
  723. auth_headers: dict[str, str],
  724. db_session,
  725. ):
  726. """Verify aborted prints are included in recalculate and charged proportionally."""
  727. created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
  728. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  729. assert user is not None
  730. # Wallet is already created by ensure_user_finance_defaults during user creation
  731. # Archive: completed print (100% charge)
  732. completed = PrintArchive(
  733. printer_id=None,
  734. filename="completed.3mf",
  735. file_path="archives/test/completed.3mf",
  736. file_size=100,
  737. content_hash="partial-complete",
  738. status="completed",
  739. cost=10.0,
  740. created_by_id=user.id,
  741. )
  742. # Archive: aborted print (50% filament used = 50% charge)
  743. aborted = PrintArchive(
  744. printer_id=None,
  745. filename="aborted.3mf",
  746. file_path="archives/test/aborted.3mf",
  747. file_size=100,
  748. content_hash="partial-aborted",
  749. status="aborted",
  750. cost=8.0,
  751. filament_used_grams=50.0,
  752. extra_data={"filament_grams_total": 100.0},
  753. created_by_id=user.id,
  754. )
  755. # Archive: failed print (0% filament used = no charge)
  756. failed = PrintArchive(
  757. printer_id=None,
  758. filename="failed.3mf",
  759. file_path="archives/test/failed.3mf",
  760. file_size=100,
  761. content_hash="partial-failed",
  762. status="failed",
  763. cost=5.0,
  764. filament_used_grams=0.0,
  765. created_by_id=user.id,
  766. )
  767. db_session.add_all([completed, aborted, failed])
  768. await db_session.commit()
  769. @pytest.mark.asyncio
  770. @pytest.mark.integration
  771. async def test_partial_charges_appear_in_transaction_ledger(
  772. self,
  773. async_client: AsyncClient,
  774. auth_headers: dict[str, str],
  775. db_session,
  776. ):
  777. """Verify transaction descriptions indicate partial charges."""
  778. created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
  779. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  780. assert user is not None
  781. # Wallet is already created by ensure_user_finance_defaults during user creation
  782. cancelled = PrintArchive(
  783. printer_id=None,
  784. filename="cancelled.3mf",
  785. file_path="archives/test/cancelled.3mf",
  786. file_size=100,
  787. content_hash="partial-cancel",
  788. status="cancelled",
  789. cost=12.0,
  790. filament_used_grams=25.0,
  791. extra_data={"filament_grams_total": 100.0},
  792. print_name="Partially Cancelled Print",
  793. created_by_id=user.id,
  794. )
  795. db_session.add(cancelled)
  796. await db_session.commit()
  797. from backend.app.services.finance_billing import apply_print_charge_for_archive
  798. changed = await apply_print_charge_for_archive(db_session, cancelled.id)
  799. assert changed is True
  800. await db_session.commit()
  801. tx_response = await async_client.get(
  802. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  803. )
  804. assert tx_response.status_code == 200
  805. transactions = tx_response.json()
  806. assert len(transactions) == 1
  807. tx = transactions[0]
  808. assert tx["transaction_type"] == "print_charge"
  809. assert tx["amount"] == -3.0 # 25% of 12.0
  810. assert "cancelled" in tx["description"].lower()
  811. assert "25.0g/100.0" in tx["description"] # filament amounts in description
  812. @pytest.mark.asyncio
  813. @pytest.mark.integration
  814. async def test_partial_charges_with_cost_center_override(
  815. self,
  816. async_client: AsyncClient,
  817. auth_headers: dict[str, str],
  818. db_session,
  819. ):
  820. """Verify partial charges respect cost_center_id when present."""
  821. created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
  822. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  823. assert user is not None
  824. # Create cost centers
  825. default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
  826. lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
  827. db_session.add_all([default_cc, lab_cc])
  828. await db_session.flush()
  829. # Wallet is already created by ensure_user_finance_defaults during user creation
  830. # Archive assigned to default_cc
  831. aborted = PrintArchive(
  832. printer_id=None,
  833. filename="aborted_cc.3mf",
  834. file_path="archives/test/aborted_cc.3mf",
  835. file_size=100,
  836. content_hash="partial-cc",
  837. status="aborted",
  838. cost=6.0,
  839. filament_used_grams=30.0,
  840. extra_data={"filament_grams_total": 100.0},
  841. cost_center_id=default_cc.id,
  842. created_by_id=user.id,
  843. )
  844. db_session.add(aborted)
  845. await db_session.commit()
  846. # Manually apply charge with override
  847. from backend.app.services.finance_billing import apply_print_charge_for_archive
  848. changed = await apply_print_charge_for_archive(
  849. db_session,
  850. aborted.id,
  851. cost_center_id=lab_cc.id,
  852. )
  853. await db_session.commit()
  854. assert changed is True
  855. tx_response = await async_client.get(
  856. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  857. )
  858. assert tx_response.status_code == 200
  859. transactions = tx_response.json()
  860. assert len(transactions) == 1
  861. tx = transactions[0]
  862. assert tx["cost_center_id"] == lab_cc.id # Overridden to lab_cc
  863. assert tx["amount"] == pytest.approx(-1.8, abs=0.01) # 30% of 6.0
  864. class TestFinanceUserDefaults:
  865. """Tests for user creation and finance defaults initialization."""
  866. @pytest.fixture
  867. async def admin_user(self, db_session):
  868. user = User(
  869. username="billing-admin",
  870. email="billing-admin@example.com",
  871. password_hash=get_password_hash("AdminPass1!"),
  872. role="admin",
  873. is_active=True,
  874. )
  875. db_session.add(user)
  876. await db_session.commit()
  877. await db_session.refresh(user)
  878. return user
  879. @pytest.fixture
  880. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  881. db_session.add(Settings(key="auth_enabled", value="true"))
  882. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  883. await db_session.commit()
  884. response = await async_client.post(
  885. "/api/v1/auth/login",
  886. json={"username": admin_user.username, "password": "AdminPass1!"},
  887. )
  888. assert response.status_code == 200
  889. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  890. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  891. response = await async_client.post(
  892. "/api/v1/users",
  893. json={
  894. "username": username,
  895. "password": "Regularpass1!",
  896. "email": f"{username}@example.com",
  897. "role": "user",
  898. },
  899. headers=auth_headers,
  900. )
  901. assert response.status_code == 201
  902. return response.json()
  903. @pytest.mark.asyncio
  904. @pytest.mark.integration
  905. async def test_create_user_initializes_wallet_and_private_cost_center(
  906. self,
  907. async_client: AsyncClient,
  908. auth_headers: dict[str, str],
  909. db_session,
  910. ):
  911. """Verify user creation initializes wallet, private cost center, and membership."""
  912. result = await async_client.post(
  913. "/api/v1/users",
  914. json={
  915. "username": "alice",
  916. "password": "Regularpass1!",
  917. "email": "alice@example.com",
  918. "role": "user",
  919. },
  920. headers=auth_headers,
  921. )
  922. assert result.status_code == 201
  923. created = result.json()
  924. assert created["username"] == "alice"
  925. user = await db_session.scalar(select(User).where(User.username == "alice"))
  926. assert user is not None
  927. from backend.app.models.finance import CostCenterMember
  928. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  929. assert wallet is not None
  930. assert wallet.balance == 0.0
  931. private_center = await db_session.scalar(
  932. select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
  933. )
  934. assert private_center is not None
  935. assert private_center.name == "alice"
  936. membership = await db_session.scalar(
  937. select(CostCenterMember).where(
  938. CostCenterMember.cost_center_id == private_center.id,
  939. CostCenterMember.user_id == user.id,
  940. )
  941. )
  942. assert membership is not None
  943. assert membership.can_print is True
  944. @pytest.mark.asyncio
  945. @pytest.mark.integration
  946. async def test_update_user_keeps_private_cost_center_in_sync(
  947. self,
  948. async_client: AsyncClient,
  949. auth_headers: dict[str, str],
  950. db_session,
  951. ):
  952. """Verify user updates keep private cost center name in sync."""
  953. created = await self._create_user_via_api(async_client, auth_headers, "bob")
  954. response = await async_client.patch(
  955. f"/api/v1/users/{created['id']}",
  956. json={"username": "bobby"},
  957. headers=auth_headers,
  958. )
  959. assert response.status_code == 200
  960. assert response.json()["username"] == "bobby"
  961. user = await db_session.scalar(select(User).where(User.id == created["id"]))
  962. assert user is not None
  963. private_centers = (
  964. (
  965. await db_session.execute(
  966. select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
  967. )
  968. )
  969. .scalars()
  970. .all()
  971. )
  972. assert len(private_centers) == 1
  973. assert private_centers[0].name == "bobby"
  974. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  975. assert wallet is not None