test_finance_service_billing.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  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. async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
  193. await enable_billing(db_session)
  194. user = User(username="missing_plan", role="user", is_active=True)
  195. cost_center = CostCenter(name="Missing Plan CC", is_active=True, is_private=False)
  196. db_session.add_all([user, cost_center])
  197. await db_session.commit()
  198. await db_session.refresh(user)
  199. await db_session.refresh(cost_center)
  200. archive = PrintArchive(
  201. printer_id=None,
  202. filename="missing-plan.3mf",
  203. file_path="archives/test/missing-plan.3mf",
  204. file_size=100,
  205. content_hash="missing-plan-hash",
  206. status="aborted",
  207. cost=12.0,
  208. filament_used_grams=80.0,
  209. created_by_id=user.id,
  210. cost_center_id=cost_center.id,
  211. )
  212. db_session.add(archive)
  213. await db_session.commit()
  214. await db_session.refresh(archive)
  215. changed = await apply_print_charge_for_archive(db_session, archive.id)
  216. await db_session.commit()
  217. assert changed is False
  218. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  219. assert wallet is None
  220. @pytest.mark.asyncio
  221. async def test_invalid_transaction_type_is_rejected(self, db_session):
  222. user = User(username="invalid_tx", role="user", is_active=True)
  223. db_session.add(user)
  224. await db_session.commit()
  225. await db_session.refresh(user)
  226. with pytest.raises(ValueError, match="Invalid transaction type"):
  227. WalletTransaction(
  228. user_id=user.id,
  229. transaction_type="not-a-real-type",
  230. amount=1.0,
  231. )
  232. @pytest.mark.asyncio
  233. async def test_aborted_print_with_partial_filament_charges_proportionally(self, db_session):
  234. """Verify aborted print charges proportionally based on filament used."""
  235. await enable_billing(db_session)
  236. user = User(username="abort_test", role="user", is_active=True)
  237. cost_center = CostCenter(name="Abort CC", is_active=True, is_private=False)
  238. db_session.add_all([user, cost_center])
  239. await db_session.commit()
  240. await db_session.refresh(user)
  241. await db_session.refresh(cost_center)
  242. # Archive with 100g planned, but only 50g used (50% filament)
  243. archive = PrintArchive(
  244. printer_id=None,
  245. filename="abort.3mf",
  246. file_path="archives/test/abort.3mf",
  247. file_size=100,
  248. content_hash="abort-hash",
  249. status="aborted",
  250. cost=10.0, # Full cost would be 10.0
  251. filament_used_grams=50.0,
  252. extra_data={"filament_grams_total": 100.0},
  253. created_by_id=user.id,
  254. cost_center_id=cost_center.id,
  255. )
  256. db_session.add(archive)
  257. await db_session.commit()
  258. await db_session.refresh(archive)
  259. changed = await apply_print_charge_for_archive(db_session, archive.id)
  260. await db_session.commit()
  261. assert changed is True
  262. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  263. assert wallet is not None
  264. assert wallet.balance == -5.0 # 50% of 10.0
  265. tx = await db_session.scalar(
  266. select(WalletTransaction)
  267. .where(WalletTransaction.user_id == user.id)
  268. .where(WalletTransaction.transaction_type == "print_charge")
  269. )
  270. assert tx is not None
  271. assert tx.amount == -5.0
  272. assert "aborted" in tx.description.lower()
  273. assert "50.0" in tx.description # filament used
  274. @pytest.mark.asyncio
  275. async def test_cancelled_print_with_no_filament_data_is_not_charged(self, db_session):
  276. """Verify cancelled print with no filament data is skipped."""
  277. await enable_billing(db_session)
  278. user = User(username="cancel_no_data", role="user", is_active=True)
  279. cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
  280. db_session.add_all([user, cost_center])
  281. await db_session.commit()
  282. await db_session.refresh(user)
  283. await db_session.refresh(cost_center)
  284. archive = PrintArchive(
  285. printer_id=None,
  286. filename="cancel.3mf",
  287. file_path="archives/test/cancel.3mf",
  288. file_size=100,
  289. content_hash="cancel-hash",
  290. status="cancelled",
  291. cost=5.0,
  292. filament_used_grams=None, # No data
  293. created_by_id=user.id,
  294. cost_center_id=cost_center.id,
  295. )
  296. db_session.add(archive)
  297. await db_session.commit()
  298. await db_session.refresh(archive)
  299. reservation = BudgetReservation(
  300. cost_center_id=cost_center.id,
  301. amount=5.0,
  302. status="active",
  303. source_type="background_dispatch",
  304. source_id=99,
  305. print_archive_id=archive.id,
  306. )
  307. db_session.add(reservation)
  308. await db_session.commit()
  309. await db_session.refresh(reservation)
  310. changed = await apply_print_charge_for_archive(db_session, archive.id)
  311. await db_session.commit()
  312. assert changed is False
  313. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  314. assert wallet is None # No wallet created
  315. await db_session.refresh(reservation)
  316. assert reservation.status == "released"
  317. assert reservation.released_at is not None
  318. @pytest.mark.asyncio
  319. async def test_failed_print_with_minimal_filament_charges_small_amount(self, db_session):
  320. """Verify failed print with minimal filament usage charges proportionally."""
  321. await enable_billing(db_session)
  322. user = User(username="fail_min", role="user", is_active=True)
  323. cost_center = CostCenter(name="Fail Min CC", is_active=True, is_private=False)
  324. db_session.add_all([user, cost_center])
  325. await db_session.commit()
  326. await db_session.refresh(user)
  327. await db_session.refresh(cost_center)
  328. # 5% filament used out of 100g planned
  329. archive = PrintArchive(
  330. printer_id=None,
  331. filename="fail_min.3mf",
  332. file_path="archives/test/fail_min.3mf",
  333. file_size=100,
  334. content_hash="fail-min-hash",
  335. status="failed",
  336. cost=20.0,
  337. filament_used_grams=5.0,
  338. extra_data={"filament_grams_total": 100.0},
  339. failure_reason="Filament runout",
  340. created_by_id=user.id,
  341. cost_center_id=cost_center.id,
  342. )
  343. db_session.add(archive)
  344. await db_session.commit()
  345. await db_session.refresh(archive)
  346. changed = await apply_print_charge_for_archive(db_session, archive.id)
  347. await db_session.commit()
  348. assert changed is True
  349. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  350. assert wallet is not None
  351. assert wallet.balance == pytest.approx(-1.0, abs=0.01) # 5% of 20.0
  352. @pytest.mark.asyncio
  353. async def test_completed_print_still_charges_full_cost(self, db_session):
  354. """Verify completed prints ignore filament ratio and charge full cost."""
  355. await enable_billing(db_session)
  356. user = User(username="completed_full", role="user", is_active=True)
  357. cost_center = CostCenter(name="Completed Full CC", is_active=True, is_private=False)
  358. db_session.add_all([user, cost_center])
  359. await db_session.commit()
  360. await db_session.refresh(user)
  361. await db_session.refresh(cost_center)
  362. archive = PrintArchive(
  363. printer_id=None,
  364. filename="complete.3mf",
  365. file_path="archives/test/complete.3mf",
  366. file_size=100,
  367. content_hash="complete-hash",
  368. status="completed",
  369. cost=15.0,
  370. filament_used_grams=100.0,
  371. extra_data={"filament_grams_total": 100.0},
  372. created_by_id=user.id,
  373. cost_center_id=cost_center.id,
  374. )
  375. db_session.add(archive)
  376. await db_session.commit()
  377. await db_session.refresh(archive)
  378. changed = await apply_print_charge_for_archive(db_session, archive.id)
  379. await db_session.commit()
  380. assert changed is True
  381. wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
  382. assert wallet.balance == -15.0 # Full cost, not proportional
  383. @pytest.mark.asyncio
  384. async def test_partial_charge_with_cost_center_override(self, db_session):
  385. """Verify partial charges respect cost_center_id override."""
  386. await enable_billing(db_session)
  387. user = User(username="partial_cc", role="user", is_active=True)
  388. default_cc = CostCenter(name="Default", is_active=True, is_private=False)
  389. override_cc = CostCenter(name="Override", is_active=True, is_private=False)
  390. db_session.add_all([user, default_cc, override_cc])
  391. await db_session.commit()
  392. await db_session.refresh(user)
  393. await db_session.refresh(default_cc)
  394. await db_session.refresh(override_cc)
  395. archive = PrintArchive(
  396. printer_id=None,
  397. filename="partial_cc.3mf",
  398. file_path="archives/test/partial_cc.3mf",
  399. file_size=100,
  400. content_hash="partial-cc-hash",
  401. status="aborted",
  402. cost=8.0,
  403. filament_used_grams=25.0,
  404. extra_data={"filament_grams_total": 100.0},
  405. cost_center_id=default_cc.id,
  406. created_by_id=user.id,
  407. )
  408. db_session.add(archive)
  409. await db_session.commit()
  410. await db_session.refresh(archive)
  411. changed = await apply_print_charge_for_archive(db_session, archive.id, cost_center_id=override_cc.id)
  412. await db_session.commit()
  413. assert changed is True
  414. tx = await db_session.scalar(
  415. select(WalletTransaction)
  416. .where(WalletTransaction.user_id == user.id)
  417. .where(WalletTransaction.transaction_type == "print_charge")
  418. )
  419. assert tx is not None
  420. assert tx.cost_center_id == override_cc.id
  421. assert tx.amount == -2.0 # 25% of 8.0