test_finance_api.py 45 KB

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