test_finance_service_billing.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. """Unit tests for billing charges applied to print archives."""
  2. import pytest
  3. from sqlalchemy import select
  4. from backend.app.models.archive import PrintArchive
  5. from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
  6. from backend.app.models.settings import Settings
  7. from backend.app.models.user import User
  8. from backend.app.services.finance_billing import apply_print_charge_for_archive
  9. async def enable_billing(db_session):
  10. setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  11. if setting is None:
  12. db_session.add(Settings(key="billing_enabled", value="true"))
  13. else:
  14. setting.value = "true"
  15. await db_session.commit()
  16. class TestFinanceBilling:
  17. @pytest.mark.asyncio
  18. async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, db_session):
  19. await enable_billing(db_session)
  20. user = User(username="printer", role="user", is_active=True)
  21. archive_cost_center = CostCenter(name="Archive CC", is_active=True, is_private=False)
  22. override_cost_center = CostCenter(name="Override CC", is_active=True, is_private=False)
  23. db_session.add_all([user, archive_cost_center, override_cost_center])
  24. await db_session.commit()
  25. await db_session.refresh(user)
  26. await db_session.refresh(archive_cost_center)
  27. await db_session.refresh(override_cost_center)
  28. archive = PrintArchive(
  29. printer_id=None,
  30. filename="test.3mf",
  31. file_path="archives/test/test.3mf",
  32. file_size=123,
  33. content_hash="hash-1",
  34. status="completed",
  35. cost=7.5,
  36. created_by_id=user.id,
  37. cost_center_id=archive_cost_center.id,
  38. )
  39. db_session.add(archive)
  40. await db_session.commit()
  41. await db_session.refresh(archive)
  42. changed = await apply_print_charge_for_archive(
  43. db_session,
  44. archive.id,
  45. cost_center_id=override_cost_center.id,
  46. print_run_id="run-1",
  47. )
  48. await db_session.commit()
  49. assert changed is True
  50. assert archive.cost_center_id == archive_cost_center.id
  51. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  52. assert wallet is not None
  53. assert wallet.balance == -7.5
  54. tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-1"))
  55. assert tx is not None
  56. assert tx.cost_center_id == override_cost_center.id
  57. assert tx.print_archive_id == archive.id
  58. duplicate = await apply_print_charge_for_archive(
  59. db_session,
  60. archive.id,
  61. cost_center_id=override_cost_center.id,
  62. print_run_id="run-1",
  63. )
  64. assert duplicate is False
  65. second_run = await apply_print_charge_for_archive(
  66. db_session,
  67. archive.id,
  68. cost_center_id=override_cost_center.id,
  69. print_run_id="run-2",
  70. )
  71. await db_session.commit()
  72. assert second_run is True
  73. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  74. assert wallet is not None
  75. assert wallet.balance == -15.0
  76. rows = (
  77. (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
  78. .scalars()
  79. .all()
  80. )
  81. assert len(rows) == 2
  82. assert {row.print_run_id for row in rows} == {"run-1", "run-2"}
  83. @pytest.mark.asyncio
  84. async def test_apply_print_charge_consumes_matching_budget_reservation(self, db_session):
  85. await enable_billing(db_session)
  86. user = User(username="reserved", role="user", is_active=True)
  87. cost_center = CostCenter(name="Reserved CC", is_active=True, is_private=False)
  88. db_session.add_all([user, cost_center])
  89. await db_session.commit()
  90. await db_session.refresh(user)
  91. await db_session.refresh(cost_center)
  92. archive = PrintArchive(
  93. printer_id=None,
  94. filename="reserved.3mf",
  95. file_path="archives/test/reserved.3mf",
  96. file_size=123,
  97. content_hash="hash-reserved",
  98. status="completed",
  99. cost=4.0,
  100. created_by_id=user.id,
  101. cost_center_id=cost_center.id,
  102. )
  103. db_session.add(archive)
  104. await db_session.commit()
  105. await db_session.refresh(archive)
  106. reservation = BudgetReservation(
  107. cost_center_id=cost_center.id,
  108. amount=4.0,
  109. status="active",
  110. source_type="background_dispatch",
  111. source_id=42,
  112. print_archive_id=archive.id,
  113. )
  114. db_session.add(reservation)
  115. await db_session.commit()
  116. await db_session.refresh(reservation)
  117. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-reserved")
  118. await db_session.commit()
  119. assert changed is True
  120. await db_session.refresh(reservation)
  121. assert reservation.status == "consumed"
  122. assert reservation.released_at is not None
  123. @pytest.mark.asyncio
  124. async def test_apply_print_charge_rejects_ineligible_archive(self, db_session):
  125. await enable_billing(db_session)
  126. user = User(username="skipped", role="user", is_active=True)
  127. db_session.add(user)
  128. await db_session.commit()
  129. await db_session.refresh(user)
  130. # Reject print with unknown status
  131. archive = PrintArchive(
  132. printer_id=None,
  133. filename="unknown.3mf",
  134. file_path="archives/test/unknown.3mf",
  135. file_size=123,
  136. content_hash="hash-2",
  137. status="unknown",
  138. cost=1.0,
  139. created_by_id=user.id,
  140. )
  141. db_session.add(archive)
  142. await db_session.commit()
  143. await db_session.refresh(archive)
  144. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-unknown")
  145. assert changed is False
  146. @pytest.mark.asyncio
  147. async def test_apply_print_charge_skips_when_billing_disabled(self, db_session):
  148. user = User(username="billing_disabled", role="user", is_active=True)
  149. cost_center = CostCenter(name="Disabled Billing CC", is_active=True, is_private=False)
  150. db_session.add_all([user, cost_center])
  151. await db_session.commit()
  152. await db_session.refresh(user)
  153. await db_session.refresh(cost_center)
  154. archive = PrintArchive(
  155. printer_id=None,
  156. filename="billing-disabled.3mf",
  157. file_path="archives/test/billing-disabled.3mf",
  158. file_size=123,
  159. content_hash="hash-disabled-billing",
  160. status="completed",
  161. cost=7.5,
  162. created_by_id=user.id,
  163. cost_center_id=cost_center.id,
  164. )
  165. db_session.add(archive)
  166. await db_session.commit()
  167. await db_session.refresh(archive)
  168. reservation = BudgetReservation(
  169. cost_center_id=cost_center.id,
  170. amount=7.5,
  171. status="active",
  172. source_type="background_dispatch",
  173. source_id=123,
  174. print_archive_id=archive.id,
  175. )
  176. db_session.add(reservation)
  177. await db_session.commit()
  178. await db_session.refresh(reservation)
  179. changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-disabled")
  180. await db_session.commit()
  181. assert changed is False
  182. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  183. tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-disabled"))
  184. assert wallet is None
  185. assert tx is None
  186. await db_session.refresh(reservation)
  187. assert reservation.status == "released"
  188. assert reservation.released_at is not None
  189. class TestPartialPrintCharges:
  190. """Tests for proportional charge calculation on aborted/failed/cancelled prints."""
  191. @pytest.mark.asyncio
  192. @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"])
  193. async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation(
  194. self,
  195. db_session,
  196. status,
  197. ):
  198. """Bambuddy stop, display abort, and printer failure share one billing path."""
  199. await enable_billing(db_session)
  200. user = User(username=f"partial_{status}", role="user", is_active=True)
  201. cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False)
  202. db_session.add_all([user, cost_center])
  203. await db_session.commit()
  204. await db_session.refresh(user)
  205. await db_session.refresh(cost_center)
  206. archive = PrintArchive(
  207. printer_id=None,
  208. filename=f"{status}.3mf",
  209. file_path=f"archives/test/{status}.3mf",
  210. file_size=100,
  211. content_hash=f"partial-{status}-override",
  212. status=status,
  213. # The usage tracker may already have replaced archive.cost with the
  214. # measured partial cost. Completion billing must use the estimate
  215. # captured before tracking, not discount this value a second time.
  216. cost=3.0,
  217. filament_used_grams=100.0,
  218. extra_data={"filament_grams_total": 100.0},
  219. created_by_id=user.id,
  220. cost_center_id=cost_center.id,
  221. )
  222. db_session.add(archive)
  223. await db_session.commit()
  224. await db_session.refresh(archive)
  225. reservation = BudgetReservation(
  226. cost_center_id=cost_center.id,
  227. amount=12.0,
  228. status="active",
  229. source_type="print_queue",
  230. source_id=archive.id,
  231. print_archive_id=archive.id,
  232. )
  233. db_session.add(reservation)
  234. await db_session.commit()
  235. await db_session.refresh(reservation)
  236. changed = await apply_print_charge_for_archive(
  237. db_session,
  238. archive.id,
  239. base_cost_override=12.0,
  240. filament_usage=(25.0, 100.0),
  241. )
  242. await db_session.commit()
  243. assert changed is True
  244. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  245. assert wallet is not None
  246. assert wallet.balance == -3.0
  247. transaction = await db_session.scalar(
  248. select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
  249. )
  250. assert transaction is not None
  251. assert transaction.amount == -3.0
  252. assert status in transaction.description.lower()
  253. assert "25.0g/100.0g" in transaction.description
  254. await db_session.refresh(reservation)
  255. assert reservation.status == "consumed"
  256. assert reservation.released_at is not None
  257. @pytest.mark.asyncio
  258. async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
  259. await enable_billing(db_session)
  260. user = User(username="missing_plan", role="user", is_active=True)
  261. cost_center = CostCenter(name="Missing Plan CC", is_active=True, is_private=False)
  262. db_session.add_all([user, cost_center])
  263. await db_session.commit()
  264. await db_session.refresh(user)
  265. await db_session.refresh(cost_center)
  266. archive = PrintArchive(
  267. printer_id=None,
  268. filename="missing-plan.3mf",
  269. file_path="archives/test/missing-plan.3mf",
  270. file_size=100,
  271. content_hash="missing-plan-hash",
  272. status="aborted",
  273. cost=12.0,
  274. filament_used_grams=80.0,
  275. created_by_id=user.id,
  276. cost_center_id=cost_center.id,
  277. )
  278. db_session.add(archive)
  279. await db_session.commit()
  280. await db_session.refresh(archive)
  281. changed = await apply_print_charge_for_archive(db_session, archive.id)
  282. await db_session.commit()
  283. assert changed is False
  284. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  285. assert wallet is None
  286. @pytest.mark.asyncio
  287. async def test_invalid_transaction_type_is_rejected(self, db_session):
  288. user = User(username="invalid_tx", role="user", is_active=True)
  289. db_session.add(user)
  290. await db_session.commit()
  291. await db_session.refresh(user)
  292. with pytest.raises(ValueError, match="Invalid transaction type"):
  293. WalletTransaction(
  294. user_id=user.id,
  295. transaction_type="not-a-real-type",
  296. amount=1.0,
  297. )
  298. @pytest.mark.asyncio
  299. async def test_aborted_print_with_partial_filament_charges_proportionally(self, db_session):
  300. """Verify aborted print charges proportionally based on filament used."""
  301. await enable_billing(db_session)
  302. user = User(username="abort_test", role="user", is_active=True)
  303. cost_center = CostCenter(name="Abort CC", is_active=True, is_private=False)
  304. db_session.add_all([user, cost_center])
  305. await db_session.commit()
  306. await db_session.refresh(user)
  307. await db_session.refresh(cost_center)
  308. # Archive with 100g planned, but only 50g used (50% filament)
  309. archive = PrintArchive(
  310. printer_id=None,
  311. filename="abort.3mf",
  312. file_path="archives/test/abort.3mf",
  313. file_size=100,
  314. content_hash="abort-hash",
  315. status="aborted",
  316. cost=10.0, # Full cost would be 10.0
  317. filament_used_grams=50.0,
  318. extra_data={"filament_grams_total": 100.0},
  319. created_by_id=user.id,
  320. cost_center_id=cost_center.id,
  321. )
  322. db_session.add(archive)
  323. await db_session.commit()
  324. await db_session.refresh(archive)
  325. changed = await apply_print_charge_for_archive(db_session, archive.id)
  326. await db_session.commit()
  327. assert changed is True
  328. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  329. assert wallet is not None
  330. assert wallet.balance == -5.0 # 50% of 10.0
  331. tx = await db_session.scalar(
  332. select(WalletTransaction)
  333. .where(WalletTransaction.user_id == user.id)
  334. .where(WalletTransaction.transaction_type == "print_charge")
  335. )
  336. assert tx is not None
  337. assert tx.amount == -5.0
  338. assert "aborted" in tx.description.lower()
  339. assert "50.0" in tx.description # filament used
  340. @pytest.mark.asyncio
  341. async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session):
  342. """A slicer estimate alone is not mistaken for actual run consumption."""
  343. await enable_billing(db_session)
  344. user = User(username="cancel_no_data", role="user", is_active=True)
  345. cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
  346. db_session.add_all([user, cost_center])
  347. await db_session.commit()
  348. await db_session.refresh(user)
  349. await db_session.refresh(cost_center)
  350. archive = PrintArchive(
  351. printer_id=None,
  352. filename="cancel.3mf",
  353. file_path="archives/test/cancel.3mf",
  354. file_size=100,
  355. content_hash="cancel-hash",
  356. status="cancelled",
  357. cost=5.0,
  358. filament_used_grams=100.0,
  359. extra_data={"filament_grams_total": 100.0},
  360. created_by_id=user.id,
  361. cost_center_id=cost_center.id,
  362. )
  363. db_session.add(archive)
  364. await db_session.commit()
  365. await db_session.refresh(archive)
  366. reservation = BudgetReservation(
  367. cost_center_id=cost_center.id,
  368. amount=5.0,
  369. status="active",
  370. source_type="background_dispatch",
  371. source_id=99,
  372. print_archive_id=archive.id,
  373. )
  374. db_session.add(reservation)
  375. await db_session.commit()
  376. await db_session.refresh(reservation)
  377. changed = await apply_print_charge_for_archive(
  378. db_session,
  379. archive.id,
  380. filament_usage=(None, 100.0),
  381. )
  382. await db_session.commit()
  383. assert changed is False
  384. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  385. assert wallet is None # No wallet created
  386. await db_session.refresh(reservation)
  387. assert reservation.status == "released"
  388. assert reservation.released_at is not None
  389. @pytest.mark.asyncio
  390. async def test_failed_print_with_minimal_filament_charges_small_amount(self, db_session):
  391. """Verify failed print with minimal filament usage charges proportionally."""
  392. await enable_billing(db_session)
  393. user = User(username="fail_min", role="user", is_active=True)
  394. cost_center = CostCenter(name="Fail Min CC", is_active=True, is_private=False)
  395. db_session.add_all([user, cost_center])
  396. await db_session.commit()
  397. await db_session.refresh(user)
  398. await db_session.refresh(cost_center)
  399. # 5% filament used out of 100g planned
  400. archive = PrintArchive(
  401. printer_id=None,
  402. filename="fail_min.3mf",
  403. file_path="archives/test/fail_min.3mf",
  404. file_size=100,
  405. content_hash="fail-min-hash",
  406. status="failed",
  407. cost=20.0,
  408. filament_used_grams=5.0,
  409. extra_data={"filament_grams_total": 100.0},
  410. failure_reason="Filament runout",
  411. created_by_id=user.id,
  412. cost_center_id=cost_center.id,
  413. )
  414. db_session.add(archive)
  415. await db_session.commit()
  416. await db_session.refresh(archive)
  417. changed = await apply_print_charge_for_archive(db_session, archive.id)
  418. await db_session.commit()
  419. assert changed is True
  420. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  421. assert wallet is not None
  422. assert wallet.balance == pytest.approx(-1.0, abs=0.01) # 5% of 20.0
  423. @pytest.mark.asyncio
  424. async def test_completed_print_still_charges_full_cost(self, db_session):
  425. """Verify completed prints ignore filament ratio and charge full cost."""
  426. await enable_billing(db_session)
  427. user = User(username="completed_full", role="user", is_active=True)
  428. cost_center = CostCenter(name="Completed Full CC", is_active=True, is_private=False)
  429. db_session.add_all([user, cost_center])
  430. await db_session.commit()
  431. await db_session.refresh(user)
  432. await db_session.refresh(cost_center)
  433. archive = PrintArchive(
  434. printer_id=None,
  435. filename="complete.3mf",
  436. file_path="archives/test/complete.3mf",
  437. file_size=100,
  438. content_hash="complete-hash",
  439. status="completed",
  440. cost=15.0,
  441. filament_used_grams=100.0,
  442. extra_data={"filament_grams_total": 100.0},
  443. created_by_id=user.id,
  444. cost_center_id=cost_center.id,
  445. )
  446. db_session.add(archive)
  447. await db_session.commit()
  448. await db_session.refresh(archive)
  449. changed = await apply_print_charge_for_archive(db_session, archive.id)
  450. await db_session.commit()
  451. assert changed is True
  452. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  453. assert wallet.balance == -15.0 # Full cost, not proportional
  454. @pytest.mark.asyncio
  455. async def test_partial_charge_with_cost_center_override(self, db_session):
  456. """Verify partial charges respect cost_center_id override."""
  457. await enable_billing(db_session)
  458. user = User(username="partial_cc", role="user", is_active=True)
  459. default_cc = CostCenter(name="Default", is_active=True, is_private=False)
  460. override_cc = CostCenter(name="Override", is_active=True, is_private=False)
  461. db_session.add_all([user, default_cc, override_cc])
  462. await db_session.commit()
  463. await db_session.refresh(user)
  464. await db_session.refresh(default_cc)
  465. await db_session.refresh(override_cc)
  466. archive = PrintArchive(
  467. printer_id=None,
  468. filename="partial_cc.3mf",
  469. file_path="archives/test/partial_cc.3mf",
  470. file_size=100,
  471. content_hash="partial-cc-hash",
  472. status="aborted",
  473. cost=8.0,
  474. filament_used_grams=25.0,
  475. extra_data={"filament_grams_total": 100.0},
  476. cost_center_id=default_cc.id,
  477. created_by_id=user.id,
  478. )
  479. db_session.add(archive)
  480. await db_session.commit()
  481. await db_session.refresh(archive)
  482. changed = await apply_print_charge_for_archive(db_session, archive.id, cost_center_id=override_cc.id)
  483. await db_session.commit()
  484. assert changed is True
  485. tx = await db_session.scalar(
  486. select(WalletTransaction)
  487. .where(WalletTransaction.user_id == user.id)
  488. .where(WalletTransaction.transaction_type == "print_charge")
  489. )
  490. assert tx is not None
  491. assert tx.cost_center_id == override_cc.id
  492. assert tx.amount == -2.0 # 25% of 8.0