test_finance_api.py 33 KB

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