test_finance_service_billing.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. """Unit tests for billing charges applied to print archives."""
  2. from unittest.mock import AsyncMock
  3. import pytest
  4. from sqlalchemy import select
  5. from sqlalchemy.exc import IntegrityError
  6. from backend.app.models.archive import PrintArchive
  7. from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
  8. from backend.app.models.print_queue import PrintQueueItem
  9. from backend.app.models.settings import Settings
  10. from backend.app.models.user import User
  11. from backend.app.services.finance_billing import BillingRunIdCollisionError, apply_print_charge_for_archive
  12. async def enable_billing(db_session):
  13. setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  14. if setting is None:
  15. db_session.add(Settings(key="billing_enabled", value="true"))
  16. else:
  17. setting.value = "true"
  18. await db_session.commit()
  19. class TestFinanceBilling:
  20. @pytest.mark.asyncio
  21. async def test_run_context_charges_initiator_and_consumes_only_its_reservation(self, db_session):
  22. """Concurrent reprints of one archive keep owner, center and hold run-scoped."""
  23. await enable_billing(db_session)
  24. archive_owner = User(username="archive_owner", role="user", is_active=True)
  25. first_user = User(username="first_reprinter", role="user", is_active=True)
  26. second_user = User(username="second_reprinter", role="user", is_active=True)
  27. first_center = CostCenter(name="First run CC", is_active=True, is_private=False)
  28. second_center = CostCenter(name="Second run CC", is_active=True, is_private=False)
  29. db_session.add_all([archive_owner, first_user, second_user, first_center, second_center])
  30. await db_session.flush()
  31. archive = PrintArchive(
  32. filename="shared-source.3mf",
  33. file_path="archives/test/shared-source.3mf",
  34. file_size=123,
  35. content_hash="shared-source-runs",
  36. status="completed",
  37. cost=4.0,
  38. created_by_id=archive_owner.id,
  39. )
  40. db_session.add(archive)
  41. await db_session.flush()
  42. first_item = PrintQueueItem(
  43. archive_id=archive.id,
  44. cost_center_id=first_center.id,
  45. estimated_cost=4.0,
  46. position=1,
  47. status="printing",
  48. created_by_id=first_user.id,
  49. billing_run_id="first-reprint-run",
  50. plate_id=1,
  51. )
  52. second_item = PrintQueueItem(
  53. archive_id=archive.id,
  54. cost_center_id=second_center.id,
  55. estimated_cost=4.0,
  56. position=1,
  57. status="printing",
  58. created_by_id=second_user.id,
  59. billing_run_id="second-reprint-run",
  60. plate_id=2,
  61. )
  62. db_session.add_all([first_item, second_item])
  63. await db_session.flush()
  64. first_reservation = BudgetReservation(
  65. cost_center_id=first_center.id,
  66. amount=4.0,
  67. status="active",
  68. source_type="print_queue",
  69. source_id=first_item.id,
  70. print_archive_id=archive.id,
  71. )
  72. second_reservation = BudgetReservation(
  73. cost_center_id=second_center.id,
  74. amount=4.0,
  75. status="active",
  76. source_type="print_queue",
  77. source_id=second_item.id,
  78. print_archive_id=archive.id,
  79. )
  80. db_session.add_all([first_reservation, second_reservation])
  81. await db_session.commit()
  82. changed = await apply_print_charge_for_archive(
  83. db_session,
  84. archive.id,
  85. charged_user_id=first_user.id,
  86. cost_center_id=first_center.id,
  87. print_queue_id=first_item.id,
  88. print_run_id=first_item.billing_run_id,
  89. )
  90. await db_session.commit()
  91. assert changed is True
  92. tx = await db_session.scalar(
  93. select(WalletTransaction).where(WalletTransaction.print_run_id == first_item.billing_run_id)
  94. )
  95. assert tx is not None
  96. assert tx.user_id == first_user.id
  97. assert tx.user_id != archive_owner.id
  98. assert tx.cost_center_id == first_center.id
  99. assert tx.print_queue_id == first_item.id
  100. await db_session.refresh(first_reservation)
  101. await db_session.refresh(second_reservation)
  102. assert first_reservation.status == "consumed"
  103. assert second_reservation.status == "active"
  104. @pytest.mark.asyncio
  105. async def test_run_id_collision_with_another_archive_is_loud(self, db_session):
  106. await enable_billing(db_session)
  107. user = User(username="collision", role="user", is_active=True)
  108. db_session.add(user)
  109. await db_session.flush()
  110. first = PrintArchive(
  111. filename="first.3mf",
  112. file_path="archives/test/first.3mf",
  113. file_size=123,
  114. content_hash="collision-first",
  115. status="completed",
  116. cost=2.0,
  117. created_by_id=user.id,
  118. billing_run_id="same-run-id",
  119. )
  120. second = PrintArchive(
  121. filename="second.3mf",
  122. file_path="archives/test/second.3mf",
  123. file_size=123,
  124. content_hash="collision-second",
  125. status="completed",
  126. cost=3.0,
  127. created_by_id=user.id,
  128. billing_run_id="same-run-id",
  129. )
  130. db_session.add_all([first, second])
  131. await db_session.commit()
  132. assert await apply_print_charge_for_archive(db_session, first.id, print_run_id="same-run-id") is True
  133. await db_session.commit()
  134. with pytest.raises(BillingRunIdCollisionError, match="already assigned to another archive"):
  135. await apply_print_charge_for_archive(db_session, second.id, print_run_id="same-run-id")
  136. transactions = (
  137. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.print_run_id == "same-run-id")))
  138. .scalars()
  139. .all()
  140. )
  141. assert len(transactions) == 1
  142. assert transactions[0].print_archive_id == first.id
  143. @pytest.mark.asyncio
  144. async def test_concurrent_charge_conflict_preserves_callers_pending_changes(self, db_session, monkeypatch):
  145. await enable_billing(db_session)
  146. user = User(username="concurrent_charge", role="user", is_active=True)
  147. archive = PrintArchive(
  148. filename="concurrent.3mf",
  149. file_path="archives/test/concurrent.3mf",
  150. file_size=123,
  151. content_hash="concurrent-charge",
  152. status="completed",
  153. cost=2.0,
  154. created_by_id=None,
  155. )
  156. db_session.add_all([user, archive])
  157. await db_session.commit()
  158. archive_id = archive.id
  159. user_id = user.id
  160. # Mirrors on_print_complete's owner backfill immediately before it
  161. # hands the still-open session to the billing service.
  162. archive.created_by_id = user_id
  163. original_flush = db_session.flush
  164. original_rollback = db_session.rollback
  165. async def conflict_on_transaction_flush(objects=None):
  166. if any(isinstance(obj, WalletTransaction) for obj in db_session.new):
  167. raise IntegrityError("duplicate print charge", {}, Exception("unique violation"))
  168. return await original_flush(objects)
  169. rollback = AsyncMock()
  170. monkeypatch.setattr(db_session, "flush", conflict_on_transaction_flush)
  171. monkeypatch.setattr(db_session, "rollback", rollback)
  172. with pytest.raises(IntegrityError, match="unique violation"):
  173. await apply_print_charge_for_archive(db_session, archive_id, print_run_id="concurrent-run")
  174. rollback.assert_not_awaited()
  175. # Restore normal session methods so the caller can commit its own work.
  176. monkeypatch.setattr(db_session, "flush", original_flush)
  177. monkeypatch.setattr(db_session, "rollback", original_rollback)
  178. await db_session.commit()
  179. db_session.expire_all()
  180. persisted_archive = await db_session.get(PrintArchive, archive_id)
  181. assert persisted_archive.created_by_id == user_id
  182. assert (
  183. await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "concurrent-run"))
  184. is None
  185. )
  186. @pytest.mark.asyncio
  187. async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, db_session):
  188. await enable_billing(db_session)
  189. user = User(username="printer", role="user", is_active=True)
  190. archive_cost_center = CostCenter(name="Archive CC", is_active=True, is_private=False)
  191. override_cost_center = CostCenter(name="Override CC", is_active=True, is_private=False)
  192. db_session.add_all([user, archive_cost_center, override_cost_center])
  193. await db_session.commit()
  194. await db_session.refresh(user)
  195. await db_session.refresh(archive_cost_center)
  196. await db_session.refresh(override_cost_center)
  197. archive = PrintArchive(
  198. printer_id=None,
  199. filename="test.3mf",
  200. file_path="archives/test/test.3mf",
  201. file_size=123,
  202. content_hash="hash-1",
  203. status="completed",
  204. cost=7.5,
  205. created_by_id=user.id,
  206. cost_center_id=archive_cost_center.id,
  207. )
  208. db_session.add(archive)
  209. await db_session.commit()
  210. await db_session.refresh(archive)
  211. changed = await apply_print_charge_for_archive(
  212. db_session,
  213. archive.id,
  214. cost_center_id=override_cost_center.id,
  215. print_run_id="run-1",
  216. )
  217. await db_session.commit()
  218. assert changed is True
  219. assert archive.cost_center_id == archive_cost_center.id
  220. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  221. assert wallet is not None
  222. assert wallet.balance == 0.0
  223. tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-1"))
  224. assert tx is not None
  225. assert tx.cost_center_id == override_cost_center.id
  226. assert tx.print_archive_id == archive.id
  227. duplicate = await apply_print_charge_for_archive(
  228. db_session,
  229. archive.id,
  230. cost_center_id=override_cost_center.id,
  231. print_run_id="run-1",
  232. )
  233. assert duplicate is False
  234. second_run = await apply_print_charge_for_archive(
  235. db_session,
  236. archive.id,
  237. cost_center_id=override_cost_center.id,
  238. print_run_id="run-2",
  239. )
  240. await db_session.commit()
  241. assert second_run is True
  242. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  243. assert wallet is not None
  244. assert wallet.balance == 0.0
  245. rows = (
  246. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
  247. .scalars()
  248. .all()
  249. )
  250. assert len(rows) == 2
  251. assert {row.print_run_id for row in rows} == {"run-1", "run-2"}
  252. @pytest.mark.asyncio
  253. async def test_apply_print_charge_consumes_matching_budget_reservation(self, db_session):
  254. await enable_billing(db_session)
  255. user = User(username="reserved", role="user", is_active=True)
  256. cost_center = CostCenter(name="Reserved CC", is_active=True, is_private=False)
  257. db_session.add_all([user, cost_center])
  258. await db_session.commit()
  259. await db_session.refresh(user)
  260. await db_session.refresh(cost_center)
  261. archive = PrintArchive(
  262. printer_id=None,
  263. filename="reserved.3mf",
  264. file_path="archives/test/reserved.3mf",
  265. file_size=123,
  266. content_hash="hash-reserved",
  267. status="completed",
  268. cost=4.0,
  269. created_by_id=user.id,
  270. cost_center_id=cost_center.id,
  271. )
  272. db_session.add(archive)
  273. await db_session.commit()
  274. await db_session.refresh(archive)
  275. reservation = BudgetReservation(
  276. cost_center_id=cost_center.id,
  277. amount=4.0,
  278. status="active",
  279. source_type="background_dispatch",
  280. source_id=42,
  281. print_archive_id=archive.id,
  282. )
  283. db_session.add(reservation)
  284. await db_session.commit()
  285. await db_session.refresh(reservation)
  286. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-reserved")
  287. await db_session.commit()
  288. assert changed is True
  289. await db_session.refresh(reservation)
  290. assert reservation.status == "consumed"
  291. assert reservation.released_at is not None
  292. @pytest.mark.asyncio
  293. async def test_apply_print_charge_rejects_ineligible_archive(self, db_session):
  294. await enable_billing(db_session)
  295. user = User(username="skipped", role="user", is_active=True)
  296. db_session.add(user)
  297. await db_session.commit()
  298. await db_session.refresh(user)
  299. # Reject print with unknown status
  300. archive = PrintArchive(
  301. printer_id=None,
  302. filename="unknown.3mf",
  303. file_path="archives/test/unknown.3mf",
  304. file_size=123,
  305. content_hash="hash-2",
  306. status="unknown",
  307. cost=1.0,
  308. created_by_id=user.id,
  309. )
  310. db_session.add(archive)
  311. await db_session.commit()
  312. await db_session.refresh(archive)
  313. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-unknown")
  314. assert changed is False
  315. @pytest.mark.asyncio
  316. async def test_apply_print_charge_skips_when_billing_disabled(self, db_session):
  317. user = User(username="billing_disabled", role="user", is_active=True)
  318. cost_center = CostCenter(name="Disabled Billing CC", is_active=True, is_private=False)
  319. db_session.add_all([user, cost_center])
  320. await db_session.commit()
  321. await db_session.refresh(user)
  322. await db_session.refresh(cost_center)
  323. archive = PrintArchive(
  324. printer_id=None,
  325. filename="billing-disabled.3mf",
  326. file_path="archives/test/billing-disabled.3mf",
  327. file_size=123,
  328. content_hash="hash-disabled-billing",
  329. status="completed",
  330. cost=7.5,
  331. created_by_id=user.id,
  332. cost_center_id=cost_center.id,
  333. )
  334. db_session.add(archive)
  335. await db_session.commit()
  336. await db_session.refresh(archive)
  337. reservation = BudgetReservation(
  338. cost_center_id=cost_center.id,
  339. amount=7.5,
  340. status="active",
  341. source_type="background_dispatch",
  342. source_id=123,
  343. print_archive_id=archive.id,
  344. )
  345. db_session.add(reservation)
  346. await db_session.commit()
  347. await db_session.refresh(reservation)
  348. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-disabled")
  349. await db_session.commit()
  350. assert changed is False
  351. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  352. tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-disabled"))
  353. assert wallet is None
  354. assert tx is None
  355. await db_session.refresh(reservation)
  356. assert reservation.status == "released"
  357. assert reservation.released_at is not None
  358. class TestPartialPrintCharges:
  359. """Tests for proportional charge calculation on aborted/failed/cancelled prints."""
  360. @pytest.mark.asyncio
  361. @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"])
  362. async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation(
  363. self,
  364. db_session,
  365. status,
  366. ):
  367. """Bambuddy stop, display abort, and printer failure share one billing path."""
  368. await enable_billing(db_session)
  369. user = User(username=f"partial_{status}", role="user", is_active=True)
  370. cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False)
  371. db_session.add_all([user, cost_center])
  372. await db_session.commit()
  373. await db_session.refresh(user)
  374. await db_session.refresh(cost_center)
  375. archive = PrintArchive(
  376. printer_id=None,
  377. filename=f"{status}.3mf",
  378. file_path=f"archives/test/{status}.3mf",
  379. file_size=100,
  380. content_hash=f"partial-{status}-override",
  381. status=status,
  382. # The usage tracker may already have replaced archive.cost with the
  383. # measured partial cost. Completion billing must use the estimate
  384. # captured before tracking, not discount this value a second time.
  385. cost=3.0,
  386. filament_used_grams=100.0,
  387. extra_data={"filament_grams_total": 100.0},
  388. created_by_id=user.id,
  389. cost_center_id=cost_center.id,
  390. )
  391. db_session.add(archive)
  392. await db_session.commit()
  393. await db_session.refresh(archive)
  394. reservation = BudgetReservation(
  395. cost_center_id=cost_center.id,
  396. amount=12.0,
  397. status="active",
  398. source_type="print_queue",
  399. source_id=archive.id,
  400. print_archive_id=archive.id,
  401. )
  402. db_session.add(reservation)
  403. await db_session.commit()
  404. await db_session.refresh(reservation)
  405. changed = await apply_print_charge_for_archive(
  406. db_session,
  407. archive.id,
  408. base_cost_override=12.0,
  409. filament_usage=(25.0, 100.0),
  410. )
  411. await db_session.commit()
  412. assert changed is True
  413. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  414. assert wallet is not None
  415. assert wallet.balance == 0.0
  416. transaction = await db_session.scalar(
  417. select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
  418. )
  419. assert transaction is not None
  420. assert transaction.amount == -3.0
  421. assert status in transaction.description.lower()
  422. assert "25.0g/100.0g" in transaction.description
  423. await db_session.refresh(reservation)
  424. assert reservation.status == "consumed"
  425. assert reservation.released_at is not None
  426. @pytest.mark.asyncio
  427. async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
  428. await enable_billing(db_session)
  429. user = User(username="missing_plan", role="user", is_active=True)
  430. cost_center = CostCenter(name="Missing Plan CC", is_active=True, is_private=False)
  431. db_session.add_all([user, cost_center])
  432. await db_session.commit()
  433. await db_session.refresh(user)
  434. await db_session.refresh(cost_center)
  435. archive = PrintArchive(
  436. printer_id=None,
  437. filename="missing-plan.3mf",
  438. file_path="archives/test/missing-plan.3mf",
  439. file_size=100,
  440. content_hash="missing-plan-hash",
  441. status="aborted",
  442. cost=12.0,
  443. filament_used_grams=80.0,
  444. created_by_id=user.id,
  445. cost_center_id=cost_center.id,
  446. )
  447. db_session.add(archive)
  448. await db_session.commit()
  449. await db_session.refresh(archive)
  450. changed = await apply_print_charge_for_archive(db_session, archive.id)
  451. await db_session.commit()
  452. assert changed is False
  453. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  454. assert wallet is None
  455. @pytest.mark.asyncio
  456. async def test_invalid_transaction_type_is_rejected(self, db_session):
  457. user = User(username="invalid_tx", role="user", is_active=True)
  458. db_session.add(user)
  459. await db_session.commit()
  460. await db_session.refresh(user)
  461. with pytest.raises(ValueError, match="Invalid transaction type"):
  462. WalletTransaction(
  463. user_id=user.id,
  464. transaction_type="not-a-real-type",
  465. amount=1.0,
  466. )
  467. @pytest.mark.asyncio
  468. async def test_aborted_print_with_partial_filament_charges_proportionally(self, db_session):
  469. """Verify aborted print charges proportionally based on filament used."""
  470. await enable_billing(db_session)
  471. user = User(username="abort_test", role="user", is_active=True)
  472. cost_center = CostCenter(name="Abort CC", is_active=True, is_private=False)
  473. db_session.add_all([user, cost_center])
  474. await db_session.commit()
  475. await db_session.refresh(user)
  476. await db_session.refresh(cost_center)
  477. # Archive with 100g planned, but only 50g used (50% filament)
  478. archive = PrintArchive(
  479. printer_id=None,
  480. filename="abort.3mf",
  481. file_path="archives/test/abort.3mf",
  482. file_size=100,
  483. content_hash="abort-hash",
  484. status="aborted",
  485. cost=10.0, # Full cost would be 10.0
  486. filament_used_grams=50.0,
  487. extra_data={"filament_grams_total": 100.0},
  488. created_by_id=user.id,
  489. cost_center_id=cost_center.id,
  490. )
  491. db_session.add(archive)
  492. await db_session.commit()
  493. await db_session.refresh(archive)
  494. changed = await apply_print_charge_for_archive(db_session, archive.id)
  495. await db_session.commit()
  496. assert changed is True
  497. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  498. assert wallet is not None
  499. assert wallet.balance == 0.0
  500. tx = await db_session.scalar(
  501. select(WalletTransaction)
  502. .where(WalletTransaction.user_id == user.id)
  503. .where(WalletTransaction.transaction_type == "print_charge")
  504. )
  505. assert tx is not None
  506. assert tx.amount == -5.0
  507. assert "aborted" in tx.description.lower()
  508. assert "50.0" in tx.description # filament used
  509. @pytest.mark.asyncio
  510. async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session):
  511. """A slicer estimate alone is not mistaken for actual run consumption."""
  512. await enable_billing(db_session)
  513. user = User(username="cancel_no_data", role="user", is_active=True)
  514. cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
  515. db_session.add_all([user, cost_center])
  516. await db_session.commit()
  517. await db_session.refresh(user)
  518. await db_session.refresh(cost_center)
  519. archive = PrintArchive(
  520. printer_id=None,
  521. filename="cancel.3mf",
  522. file_path="archives/test/cancel.3mf",
  523. file_size=100,
  524. content_hash="cancel-hash",
  525. status="cancelled",
  526. cost=5.0,
  527. filament_used_grams=100.0,
  528. extra_data={"filament_grams_total": 100.0},
  529. created_by_id=user.id,
  530. cost_center_id=cost_center.id,
  531. )
  532. db_session.add(archive)
  533. await db_session.commit()
  534. await db_session.refresh(archive)
  535. reservation = BudgetReservation(
  536. cost_center_id=cost_center.id,
  537. amount=5.0,
  538. status="active",
  539. source_type="background_dispatch",
  540. source_id=99,
  541. print_archive_id=archive.id,
  542. )
  543. db_session.add(reservation)
  544. await db_session.commit()
  545. await db_session.refresh(reservation)
  546. changed = await apply_print_charge_for_archive(
  547. db_session,
  548. archive.id,
  549. filament_usage=(None, 100.0),
  550. )
  551. await db_session.commit()
  552. assert changed is False
  553. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  554. assert wallet is None # No wallet created
  555. await db_session.refresh(reservation)
  556. assert reservation.status == "released"
  557. assert reservation.released_at is not None
  558. @pytest.mark.asyncio
  559. async def test_failed_print_with_minimal_filament_charges_small_amount(self, db_session):
  560. """Verify failed print with minimal filament usage charges proportionally."""
  561. await enable_billing(db_session)
  562. user = User(username="fail_min", role="user", is_active=True)
  563. cost_center = CostCenter(name="Fail Min CC", is_active=True, is_private=False)
  564. db_session.add_all([user, cost_center])
  565. await db_session.commit()
  566. await db_session.refresh(user)
  567. await db_session.refresh(cost_center)
  568. # 5% filament used out of 100g planned
  569. archive = PrintArchive(
  570. printer_id=None,
  571. filename="fail_min.3mf",
  572. file_path="archives/test/fail_min.3mf",
  573. file_size=100,
  574. content_hash="fail-min-hash",
  575. status="failed",
  576. cost=20.0,
  577. filament_used_grams=5.0,
  578. extra_data={"filament_grams_total": 100.0},
  579. failure_reason="Filament runout",
  580. created_by_id=user.id,
  581. cost_center_id=cost_center.id,
  582. )
  583. db_session.add(archive)
  584. await db_session.commit()
  585. await db_session.refresh(archive)
  586. changed = await apply_print_charge_for_archive(db_session, archive.id)
  587. await db_session.commit()
  588. assert changed is True
  589. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  590. assert wallet is not None
  591. assert wallet.balance == 0.0
  592. @pytest.mark.asyncio
  593. async def test_completed_print_still_charges_full_cost(self, db_session):
  594. """Verify completed prints ignore filament ratio and charge full cost."""
  595. await enable_billing(db_session)
  596. user = User(username="completed_full", role="user", is_active=True)
  597. cost_center = CostCenter(name="Completed Full CC", is_active=True, is_private=False)
  598. db_session.add_all([user, cost_center])
  599. await db_session.commit()
  600. await db_session.refresh(user)
  601. await db_session.refresh(cost_center)
  602. archive = PrintArchive(
  603. printer_id=None,
  604. filename="complete.3mf",
  605. file_path="archives/test/complete.3mf",
  606. file_size=100,
  607. content_hash="complete-hash",
  608. status="completed",
  609. cost=15.0,
  610. filament_used_grams=100.0,
  611. extra_data={"filament_grams_total": 100.0},
  612. created_by_id=user.id,
  613. cost_center_id=cost_center.id,
  614. )
  615. db_session.add(archive)
  616. await db_session.commit()
  617. await db_session.refresh(archive)
  618. changed = await apply_print_charge_for_archive(db_session, archive.id)
  619. await db_session.commit()
  620. assert changed is True
  621. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  622. assert wallet.balance == 0.0
  623. @pytest.mark.asyncio
  624. async def test_partial_charge_with_cost_center_override(self, db_session):
  625. """Verify partial charges respect cost_center_id override."""
  626. await enable_billing(db_session)
  627. user = User(username="partial_cc", role="user", is_active=True)
  628. default_cc = CostCenter(name="Default", is_active=True, is_private=False)
  629. override_cc = CostCenter(name="Override", is_active=True, is_private=False)
  630. db_session.add_all([user, default_cc, override_cc])
  631. await db_session.commit()
  632. await db_session.refresh(user)
  633. await db_session.refresh(default_cc)
  634. await db_session.refresh(override_cc)
  635. archive = PrintArchive(
  636. printer_id=None,
  637. filename="partial_cc.3mf",
  638. file_path="archives/test/partial_cc.3mf",
  639. file_size=100,
  640. content_hash="partial-cc-hash",
  641. status="aborted",
  642. cost=8.0,
  643. filament_used_grams=25.0,
  644. extra_data={"filament_grams_total": 100.0},
  645. cost_center_id=default_cc.id,
  646. created_by_id=user.id,
  647. )
  648. db_session.add(archive)
  649. await db_session.commit()
  650. await db_session.refresh(archive)
  651. changed = await apply_print_charge_for_archive(db_session, archive.id, cost_center_id=override_cc.id)
  652. await db_session.commit()
  653. assert changed is True
  654. tx = await db_session.scalar(
  655. select(WalletTransaction)
  656. .where(WalletTransaction.user_id == user.id)
  657. .where(WalletTransaction.transaction_type == "print_charge")
  658. )
  659. assert tx is not None
  660. assert tx.cost_center_id == override_cc.id
  661. assert tx.amount == -2.0 # 25% of 8.0