test_finance_api.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  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 CostCenter, UserWallet, WalletTransaction
  8. from backend.app.models.settings import Settings
  9. from backend.app.models.user import User
  10. class TestFinanceAPI:
  11. @pytest.fixture
  12. async def admin_user(self, db_session):
  13. user = User(
  14. username="finance-admin",
  15. email="finance-admin@example.com",
  16. password_hash=get_password_hash("AdminPass1!"),
  17. role="admin",
  18. is_active=True,
  19. )
  20. db_session.add(user)
  21. await db_session.commit()
  22. await db_session.refresh(user)
  23. return user
  24. @pytest.fixture
  25. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  26. db_session.add(Settings(key="auth_enabled", value="true"))
  27. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  28. # Ensure billing is enabled for finance integration tests
  29. existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  30. if existing is None:
  31. db_session.add(Settings(key="billing_enabled", value="true"))
  32. else:
  33. existing.value = "true"
  34. await db_session.commit()
  35. response = await async_client.post(
  36. "/api/v1/auth/login",
  37. json={"username": admin_user.username, "password": "AdminPass1!"},
  38. )
  39. assert response.status_code == 200
  40. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  41. async def _enable_basic_user_creation(self, db_session):
  42. return None
  43. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  44. response = await async_client.post(
  45. "/api/v1/users",
  46. json={
  47. "username": username,
  48. "password": "Regularpass1!",
  49. "email": f"{username}@example.com",
  50. "role": "user",
  51. },
  52. headers=auth_headers,
  53. )
  54. assert response.status_code == 201
  55. return response.json()
  56. async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
  57. response = await async_client.post(
  58. "/api/v1/auth/login",
  59. json={"username": username, "password": "Regularpass1!"},
  60. )
  61. assert response.status_code == 200
  62. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  63. @pytest.mark.asyncio
  64. @pytest.mark.integration
  65. async def test_create_cost_center_assign_member_and_list_mine(
  66. self,
  67. async_client: AsyncClient,
  68. auth_headers: dict[str, str],
  69. db_session,
  70. ):
  71. await self._enable_basic_user_creation(db_session)
  72. created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
  73. user_headers = await self._login_user(async_client, "carol")
  74. create_response = await async_client.post(
  75. "/api/v1/finance/cost-centers",
  76. json={
  77. "name": "Shared Lab",
  78. "monthly_budget": 120.0,
  79. "total_budget": 999.0,
  80. "is_active": True,
  81. },
  82. headers=auth_headers,
  83. )
  84. assert create_response.status_code == 200
  85. shared_center = create_response.json()
  86. assert shared_center["name"] == "Shared Lab"
  87. assert shared_center["monthly_budget"] == 120.0
  88. assert shared_center["total_budget"] is None
  89. assert shared_center["budget_mode"] == "monthly"
  90. member_response = await async_client.post(
  91. f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
  92. json={"user_id": created_user["id"], "can_print": False},
  93. headers=auth_headers,
  94. )
  95. assert member_response.status_code == 200
  96. assert member_response.json()["user_id"] == created_user["id"]
  97. assert member_response.json()["can_print"] is False
  98. mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
  99. assert mine_response.status_code == 200
  100. mine_names = {center["name"] for center in mine_response.json()}
  101. assert "carol" in mine_names
  102. assert "Shared Lab" in mine_names
  103. detail_response = await async_client.get(
  104. f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
  105. )
  106. assert detail_response.status_code == 200
  107. detail = detail_response.json()
  108. assert len(detail["members"]) == 1
  109. assert detail["members"][0]["user_id"] == created_user["id"]
  110. remove_response = await async_client.delete(
  111. f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
  112. headers=auth_headers,
  113. )
  114. assert remove_response.status_code == 200
  115. mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
  116. assert mine_after_remove.status_code == 200
  117. assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_wallet_adjustments_and_transaction_ledger_rebuild(
  121. self,
  122. async_client: AsyncClient,
  123. auth_headers: dict[str, str],
  124. admin_user,
  125. db_session,
  126. ):
  127. """Test cost-center and personal transaction handling.
  128. Cost-center transactions affect cost-center balance only.
  129. Personal transactions (no cost_center_id) affect user wallet.
  130. """
  131. await self._enable_basic_user_creation(db_session)
  132. created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
  133. shared_center = await db_session.scalar(
  134. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  135. )
  136. assert shared_center is not None
  137. # Deposit to cost center: affects cost-center balance, not user wallet
  138. deposit = await async_client.post(
  139. f"/api/v1/finance/users/{created_user['id']}/deposit",
  140. json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": shared_center.id},
  141. headers=auth_headers,
  142. )
  143. assert deposit.status_code == 200
  144. assert deposit.json()["transaction"]["cost_center_id"] == shared_center.id
  145. assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
  146. assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
  147. # Withdraw from cost center: affects cost-center balance only
  148. withdraw = await async_client.post(
  149. f"/api/v1/finance/users/{created_user['id']}/withdraw",
  150. json={"amount": 5.0, "description": "CC Usage", "cost_center_id": shared_center.id},
  151. headers=auth_headers,
  152. )
  153. assert withdraw.status_code == 200
  154. assert withdraw.json()["transaction"]["amount"] == -5.0
  155. assert withdraw.json()["transaction"]["balance_after"] == 20.0 # CC balance after withdraw
  156. assert withdraw.json()["balance"]["balance"] == 20.0 # Response shows CC balance
  157. # Personal deposit: affects user wallet
  158. personal_deposit = await async_client.post(
  159. f"/api/v1/finance/users/{created_user['id']}/deposit",
  160. json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
  161. headers=auth_headers,
  162. )
  163. assert personal_deposit.status_code == 200
  164. assert personal_deposit.json()["transaction"]["cost_center_id"] is None
  165. assert personal_deposit.json()["transaction"]["balance_after"] == 30.0 # Personal balance
  166. assert personal_deposit.json()["balance"]["balance"] == 30.0 # User wallet updated
  167. transactions_response = await async_client.get(
  168. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  169. )
  170. assert transactions_response.status_code == 200
  171. transactions = transactions_response.json()
  172. assert len(transactions) == 3
  173. cc_txs = [tx for tx in transactions if tx["cost_center_id"] == shared_center.id]
  174. personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
  175. assert len(cc_txs) == 2
  176. assert len(personal_txs) == 1
  177. balance_response = await async_client.get(
  178. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  179. )
  180. assert balance_response.status_code == 200
  181. assert balance_response.json()["balance"] == 30.0 # Only personal balance
  182. # Delete personal transaction, user wallet should decrease
  183. personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
  184. delete_response = await async_client.delete(
  185. f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
  186. )
  187. assert delete_response.status_code == 200
  188. balance_after_delete = await async_client.get(
  189. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  190. )
  191. assert balance_after_delete.status_code == 200
  192. assert balance_after_delete.json()["balance"] == 0.0 # Personal balance back to 0
  193. remaining = await async_client.get(
  194. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  195. )
  196. assert remaining.status_code == 200
  197. assert len(remaining.json()) == 2 # 2 CC transactions remain
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
  201. self,
  202. async_client: AsyncClient,
  203. auth_headers: dict[str, str],
  204. db_session,
  205. ):
  206. await self._enable_basic_user_creation(db_session)
  207. created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
  208. shared_center = await db_session.scalar(
  209. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  210. )
  211. assert shared_center is not None
  212. first_deposit = await async_client.post(
  213. f"/api/v1/finance/users/{created_user['id']}/deposit",
  214. json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
  215. headers=auth_headers,
  216. )
  217. assert first_deposit.status_code == 200
  218. cc_withdraw = await async_client.post(
  219. f"/api/v1/finance/users/{created_user['id']}/withdraw",
  220. json={"amount": 5.0, "description": "CC usage", "cost_center_id": shared_center.id},
  221. headers=auth_headers,
  222. )
  223. assert cc_withdraw.status_code == 200
  224. personal_deposit = await async_client.post(
  225. f"/api/v1/finance/users/{created_user['id']}/deposit",
  226. json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
  227. headers=auth_headers,
  228. )
  229. assert personal_deposit.status_code == 200
  230. delete_response = await async_client.delete(
  231. f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
  232. headers=auth_headers,
  233. )
  234. assert delete_response.status_code == 200
  235. transactions_response = await async_client.get(
  236. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  237. )
  238. assert transactions_response.status_code == 200
  239. transactions = transactions_response.json()
  240. assert len(transactions) == 2
  241. cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
  242. assert cc_transaction["amount"] == -5.0
  243. assert cc_transaction["balance_after"] == -5.0
  244. balance_response = await async_client.get(
  245. f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
  246. )
  247. assert balance_response.status_code == 200
  248. assert balance_response.json()["balance"] == 12.0
  249. @pytest.mark.asyncio
  250. @pytest.mark.integration
  251. async def test_delete_print_charge_stays_deleted_after_recalculate(
  252. self,
  253. async_client: AsyncClient,
  254. auth_headers: dict[str, str],
  255. db_session,
  256. ):
  257. await self._enable_basic_user_creation(db_session)
  258. created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
  259. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  260. assert user is not None
  261. archive = PrintArchive(
  262. printer_id=None,
  263. filename="print.gcode",
  264. file_path="archives/test/print.gcode",
  265. file_size=10,
  266. content_hash="hash-print",
  267. status="completed",
  268. cost=4.0,
  269. created_by_id=user.id,
  270. )
  271. db_session.add(archive)
  272. await db_session.flush()
  273. tx = WalletTransaction(
  274. user_id=user.id,
  275. transaction_type="print_charge",
  276. amount=-4.0,
  277. balance_after=-4.0,
  278. description="Print charge: print.gcode",
  279. created_by_user_id=None,
  280. print_archive_id=archive.id,
  281. )
  282. db_session.add(tx)
  283. await db_session.commit()
  284. tx_rows_before = (
  285. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
  286. .scalars()
  287. .all()
  288. )
  289. assert len(tx_rows_before) == 1
  290. delete_response = await async_client.delete(
  291. f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
  292. )
  293. assert delete_response.status_code == 200
  294. tx_rows_after = (
  295. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
  296. .scalars()
  297. .all()
  298. )
  299. assert tx_rows_after == []
  300. async def test_edit_transaction_updates_ledger(
  301. self,
  302. async_client: AsyncClient,
  303. auth_headers: dict[str, str],
  304. db_session,
  305. ):
  306. """Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
  307. await self._enable_basic_user_creation(db_session)
  308. user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
  309. user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
  310. # Create a cost center
  311. cc_response = await async_client.post(
  312. "/api/v1/finance/cost-centers",
  313. json={"name": "Test Center", "is_active": True},
  314. headers=auth_headers,
  315. )
  316. assert cc_response.status_code == 200
  317. cost_center = cc_response.json()
  318. # Get user records from DB
  319. user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
  320. user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
  321. # Create a personal transaction for user1
  322. tx_response = await async_client.post(
  323. f"/api/v1/finance/users/{user1_db.id}/deposit",
  324. json={"amount": 50.0, "description": "Initial deposit"},
  325. headers=auth_headers,
  326. )
  327. assert tx_response.status_code == 200
  328. tx_data = tx_response.json()
  329. tx_id = tx_data["transaction"]["id"]
  330. # Get the original transaction
  331. original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
  332. assert original_tx.user_id == user1_db.id
  333. assert original_tx.cost_center_id is None
  334. assert original_tx.amount == 50.0
  335. assert original_tx.balance_after == 50.0
  336. # Edit the transaction: change user, add cost center, change amount
  337. edit_response = await async_client.patch(
  338. f"/api/v1/finance/transactions/{tx_id}",
  339. json={
  340. "user_id": user2_db.id,
  341. "cost_center_id": cost_center["id"],
  342. "amount": 75.0,
  343. "description": "Updated deposit (Admin edit)",
  344. },
  345. headers=auth_headers,
  346. )
  347. assert edit_response.status_code == 200
  348. edited_tx_data = edit_response.json()
  349. # Verify transaction was updated
  350. assert edited_tx_data["user_id"] == user2_db.id
  351. assert edited_tx_data["cost_center_id"] == cost_center["id"]
  352. assert edited_tx_data["amount"] == 75.0
  353. # Description should have "(Admin edit)" appended
  354. assert "(Admin edit)" in edited_tx_data["description"]
  355. @pytest.mark.asyncio
  356. @pytest.mark.integration
  357. async def test_create_manual_print_and_recalculates_ledger(
  358. self,
  359. async_client: AsyncClient,
  360. auth_headers: dict[str, str],
  361. db_session,
  362. ):
  363. """Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
  364. await self._enable_basic_user_creation(db_session)
  365. created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
  366. # Get user DB record
  367. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  368. assert user is not None
  369. # Private cost center for user
  370. private_cc = await db_session.scalar(
  371. select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
  372. )
  373. assert private_cc is not None
  374. # Post manual print affecting the cost center
  375. payload = {
  376. "user_id": user.id,
  377. "cost_center_id": private_cc.id,
  378. "amount": -4.0,
  379. "description": "Manual adjustment for a print",
  380. "created_at": "2026-05-12T12:00:00Z",
  381. }
  382. response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
  383. assert response.status_code == 200
  384. resp_json = response.json()
  385. assert "transaction" in resp_json or "id" in resp_json
  386. # Response contains the created transaction details
  387. assert resp_json["transaction_type"] == "manual_adjustment"
  388. assert resp_json["amount"] == -4.0
  389. assert resp_json["cost_center_id"] == private_cc.id
  390. # The response includes the computed running balance for the transaction
  391. assert resp_json.get("balance_after") == -4.0
  392. class TestPartialPrintChargesIntegration:
  393. """Integration tests for partial print charge calculation."""
  394. @pytest.fixture
  395. async def admin_user(self, db_session):
  396. user = User(
  397. username="partial-admin",
  398. email="partial-admin@example.com",
  399. password_hash=get_password_hash("AdminPass1!"),
  400. role="admin",
  401. is_active=True,
  402. )
  403. db_session.add(user)
  404. await db_session.commit()
  405. await db_session.refresh(user)
  406. return user
  407. @pytest.fixture
  408. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  409. db_session.add(Settings(key="auth_enabled", value="true"))
  410. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  411. # Ensure billing is enabled for these partial-charge integration tests
  412. existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  413. if existing is None:
  414. db_session.add(Settings(key="billing_enabled", value="true"))
  415. else:
  416. existing.value = "true"
  417. await db_session.commit()
  418. response = await async_client.post(
  419. "/api/v1/auth/login",
  420. json={"username": admin_user.username, "password": "AdminPass1!"},
  421. )
  422. assert response.status_code == 200
  423. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  424. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  425. response = await async_client.post(
  426. "/api/v1/users",
  427. json={
  428. "username": username,
  429. "password": "Regularpass1!",
  430. "email": f"{username}@example.com",
  431. "role": "user",
  432. },
  433. headers=auth_headers,
  434. )
  435. assert response.status_code == 201
  436. return response.json()
  437. @pytest.mark.asyncio
  438. @pytest.mark.integration
  439. async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
  440. self,
  441. async_client: AsyncClient,
  442. auth_headers: dict[str, str],
  443. db_session,
  444. ):
  445. """Verify aborted prints are included in recalculate and charged proportionally."""
  446. created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
  447. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  448. assert user is not None
  449. # Wallet is already created by ensure_user_finance_defaults during user creation
  450. # Archive: completed print (100% charge)
  451. completed = PrintArchive(
  452. printer_id=None,
  453. filename="completed.3mf",
  454. file_path="archives/test/completed.3mf",
  455. file_size=100,
  456. content_hash="partial-complete",
  457. status="completed",
  458. cost=10.0,
  459. created_by_id=user.id,
  460. )
  461. # Archive: aborted print (50% filament used = 50% charge)
  462. aborted = PrintArchive(
  463. printer_id=None,
  464. filename="aborted.3mf",
  465. file_path="archives/test/aborted.3mf",
  466. file_size=100,
  467. content_hash="partial-aborted",
  468. status="aborted",
  469. cost=8.0,
  470. filament_used_grams=50.0,
  471. extra_data={"filament_grams_total": 100.0},
  472. created_by_id=user.id,
  473. )
  474. # Archive: failed print (0% filament used = no charge)
  475. failed = PrintArchive(
  476. printer_id=None,
  477. filename="failed.3mf",
  478. file_path="archives/test/failed.3mf",
  479. file_size=100,
  480. content_hash="partial-failed",
  481. status="failed",
  482. cost=5.0,
  483. filament_used_grams=0.0,
  484. created_by_id=user.id,
  485. )
  486. db_session.add_all([completed, aborted, failed])
  487. await db_session.commit()
  488. @pytest.mark.asyncio
  489. @pytest.mark.integration
  490. async def test_partial_charges_appear_in_transaction_ledger(
  491. self,
  492. async_client: AsyncClient,
  493. auth_headers: dict[str, str],
  494. db_session,
  495. ):
  496. """Verify transaction descriptions indicate partial charges."""
  497. created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
  498. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  499. assert user is not None
  500. # Wallet is already created by ensure_user_finance_defaults during user creation
  501. cancelled = PrintArchive(
  502. printer_id=None,
  503. filename="cancelled.3mf",
  504. file_path="archives/test/cancelled.3mf",
  505. file_size=100,
  506. content_hash="partial-cancel",
  507. status="cancelled",
  508. cost=12.0,
  509. filament_used_grams=25.0,
  510. extra_data={"filament_grams_total": 100.0},
  511. print_name="Partially Cancelled Print",
  512. created_by_id=user.id,
  513. )
  514. db_session.add(cancelled)
  515. await db_session.commit()
  516. from backend.app.services.finance_billing import apply_print_charge_for_archive
  517. changed = await apply_print_charge_for_archive(db_session, cancelled.id)
  518. assert changed is True
  519. await db_session.commit()
  520. tx_response = await async_client.get(
  521. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  522. )
  523. assert tx_response.status_code == 200
  524. transactions = tx_response.json()
  525. assert len(transactions) == 1
  526. tx = transactions[0]
  527. assert tx["transaction_type"] == "print_charge"
  528. assert tx["amount"] == -3.0 # 25% of 12.0
  529. assert "cancelled" in tx["description"].lower()
  530. assert "25.0g/100.0" in tx["description"] # filament amounts in description
  531. @pytest.mark.asyncio
  532. @pytest.mark.integration
  533. async def test_partial_charges_with_cost_center_override(
  534. self,
  535. async_client: AsyncClient,
  536. auth_headers: dict[str, str],
  537. db_session,
  538. ):
  539. """Verify partial charges respect cost_center_id when present."""
  540. created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
  541. user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
  542. assert user is not None
  543. # Create cost centers
  544. default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
  545. lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
  546. db_session.add_all([default_cc, lab_cc])
  547. await db_session.flush()
  548. # Wallet is already created by ensure_user_finance_defaults during user creation
  549. # Archive assigned to default_cc
  550. aborted = PrintArchive(
  551. printer_id=None,
  552. filename="aborted_cc.3mf",
  553. file_path="archives/test/aborted_cc.3mf",
  554. file_size=100,
  555. content_hash="partial-cc",
  556. status="aborted",
  557. cost=6.0,
  558. filament_used_grams=30.0,
  559. extra_data={"filament_grams_total": 100.0},
  560. cost_center_id=default_cc.id,
  561. created_by_id=user.id,
  562. )
  563. db_session.add(aborted)
  564. await db_session.commit()
  565. # Manually apply charge with override
  566. from backend.app.services.finance_billing import apply_print_charge_for_archive
  567. changed = await apply_print_charge_for_archive(
  568. db_session,
  569. aborted.id,
  570. cost_center_id=lab_cc.id,
  571. )
  572. await db_session.commit()
  573. assert changed is True
  574. tx_response = await async_client.get(
  575. f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
  576. )
  577. assert tx_response.status_code == 200
  578. transactions = tx_response.json()
  579. assert len(transactions) == 1
  580. tx = transactions[0]
  581. assert tx["cost_center_id"] == lab_cc.id # Overridden to lab_cc
  582. assert tx["amount"] == pytest.approx(-1.8, abs=0.01) # 30% of 6.0
  583. class TestFinanceUserDefaults:
  584. """Tests for user creation and finance defaults initialization."""
  585. @pytest.fixture
  586. async def admin_user(self, db_session):
  587. user = User(
  588. username="billing-admin",
  589. email="billing-admin@example.com",
  590. password_hash=get_password_hash("AdminPass1!"),
  591. role="admin",
  592. is_active=True,
  593. )
  594. db_session.add(user)
  595. await db_session.commit()
  596. await db_session.refresh(user)
  597. return user
  598. @pytest.fixture
  599. async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
  600. db_session.add(Settings(key="auth_enabled", value="true"))
  601. db_session.add(Settings(key="advanced_auth_enabled", value="false"))
  602. await db_session.commit()
  603. response = await async_client.post(
  604. "/api/v1/auth/login",
  605. json={"username": admin_user.username, "password": "AdminPass1!"},
  606. )
  607. assert response.status_code == 200
  608. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  609. async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
  610. response = await async_client.post(
  611. "/api/v1/users",
  612. json={
  613. "username": username,
  614. "password": "Regularpass1!",
  615. "email": f"{username}@example.com",
  616. "role": "user",
  617. },
  618. headers=auth_headers,
  619. )
  620. assert response.status_code == 201
  621. return response.json()
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_create_user_initializes_wallet_and_private_cost_center(
  625. self,
  626. async_client: AsyncClient,
  627. auth_headers: dict[str, str],
  628. db_session,
  629. ):
  630. """Verify user creation initializes wallet, private cost center, and membership."""
  631. result = await async_client.post(
  632. "/api/v1/users",
  633. json={
  634. "username": "alice",
  635. "password": "Regularpass1!",
  636. "email": "alice@example.com",
  637. "role": "user",
  638. },
  639. headers=auth_headers,
  640. )
  641. assert result.status_code == 201
  642. created = result.json()
  643. assert created["username"] == "alice"
  644. user = await db_session.scalar(select(User).where(User.username == "alice"))
  645. assert user is not None
  646. from backend.app.models.finance import CostCenterMember
  647. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  648. assert wallet is not None
  649. assert wallet.balance == 0.0
  650. private_center = await db_session.scalar(
  651. select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
  652. )
  653. assert private_center is not None
  654. assert private_center.name == "alice"
  655. membership = await db_session.scalar(
  656. select(CostCenterMember).where(
  657. CostCenterMember.cost_center_id == private_center.id,
  658. CostCenterMember.user_id == user.id,
  659. )
  660. )
  661. assert membership is not None
  662. assert membership.can_print is True
  663. @pytest.mark.asyncio
  664. @pytest.mark.integration
  665. async def test_update_user_keeps_private_cost_center_in_sync(
  666. self,
  667. async_client: AsyncClient,
  668. auth_headers: dict[str, str],
  669. db_session,
  670. ):
  671. """Verify user updates keep private cost center name in sync."""
  672. created = await self._create_user_via_api(async_client, auth_headers, "bob")
  673. response = await async_client.patch(
  674. f"/api/v1/users/{created['id']}",
  675. json={"username": "bobby"},
  676. headers=auth_headers,
  677. )
  678. assert response.status_code == 200
  679. assert response.json()["username"] == "bobby"
  680. user = await db_session.scalar(select(User).where(User.id == created["id"]))
  681. assert user is not None
  682. private_centers = (
  683. (
  684. await db_session.execute(
  685. select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
  686. )
  687. )
  688. .scalars()
  689. .all()
  690. )
  691. assert len(private_centers) == 1
  692. assert private_centers[0].name == "bobby"
  693. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  694. assert wallet is not None