test_finance_billing_deleted_user.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. """A print still running when its user is deleted is billed to its cost centre.
  2. At completion neither the queue row's billing user nor the archive's creator is
  3. left, and the charge returned "has no creator ID" before it released the budget
  4. reservation: the cost centre's reservation stayed active for good and its ledger
  5. missed the print. It is now charged to its cost centre without a user (shown
  6. as "No user", as is a print that never had one with login off), also the
  7. user's own private one their personal prints go through. Only a print with no cost centre at all has nobody to charge. Deleting
  8. the user together with their items releases what their prints still held.
  9. """
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.finance import BudgetReservation, CostCenter, WalletTransaction
  14. from backend.app.models.print_queue import PrintQueueItem
  15. from backend.app.services.finance_billing import apply_print_charge_for_archive
  16. from backend.tests.unit.services.test_finance_service_billing import enable_billing
  17. pytestmark = pytest.mark.asyncio
  18. async def _orphaned_print(db_session, *, cost_center: bool):
  19. """A completed print whose user was deleted while it ran: no creator, no
  20. billing user, and an active reservation from its dispatch."""
  21. await enable_billing(db_session)
  22. center = None
  23. if cost_center:
  24. center = CostCenter(name="Lab", is_active=True, is_private=False)
  25. db_session.add(center)
  26. await db_session.flush()
  27. archive = PrintArchive(
  28. filename="part.3mf",
  29. file_path="archives/test/part.3mf",
  30. file_size=1,
  31. status="completed",
  32. cost=5.0,
  33. created_by_id=None,
  34. cost_center_id=center.id if center else None,
  35. )
  36. db_session.add(archive)
  37. await db_session.flush()
  38. item = PrintQueueItem(
  39. archive_id=archive.id,
  40. cost_center_id=center.id if center else None,
  41. estimated_cost=5.0,
  42. position=1,
  43. status="printing",
  44. created_by_id=None,
  45. billing_run_id="run-of-a-deleted-user",
  46. )
  47. db_session.add(item)
  48. await db_session.flush()
  49. reservation = None
  50. if center: # budgets, and so reservations, exist per cost centre only
  51. reservation = BudgetReservation(
  52. cost_center_id=center.id,
  53. amount=5.0,
  54. status="active",
  55. source_type="print_queue",
  56. source_id=item.id,
  57. print_archive_id=archive.id,
  58. )
  59. db_session.add(reservation)
  60. await db_session.commit()
  61. return archive, item, reservation, center
  62. async def _charge(db_session, archive, item, center):
  63. return await apply_print_charge_for_archive(
  64. db_session,
  65. archive.id,
  66. charged_user_id=None,
  67. cost_center_id=center.id if center else None,
  68. print_queue_id=item.id,
  69. print_run_id="run-of-a-deleted-user",
  70. )
  71. async def _charges(db_session) -> list[WalletTransaction]:
  72. return list((await db_session.scalars(select(WalletTransaction))).all())
  73. async def test_a_cost_centre_print_of_a_deleted_user_is_charged_to_the_cost_centre(db_session):
  74. archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
  75. assert await _charge(db_session, archive, item, center) is True
  76. await db_session.commit()
  77. (charge,) = await _charges(db_session)
  78. assert charge.user_id is None
  79. assert charge.cost_center_id == center.id
  80. assert charge.amount == -5.0
  81. assert charge.balance_after == -5.0
  82. assert charge.print_archive_id == archive.id
  83. await db_session.refresh(reservation)
  84. assert reservation.status == "consumed"
  85. async def test_a_personal_print_of_a_deleted_user_charges_nobody(db_session):
  86. """No cost centre at all (started while billing was off): nobody is left to
  87. charge, and it holds no reservation."""
  88. archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=False)
  89. assert await _charge(db_session, archive, item, center) is False
  90. await db_session.commit()
  91. assert await _charges(db_session) == []
  92. async def test_the_same_run_is_not_charged_twice(db_session):
  93. archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
  94. await _charge(db_session, archive, item, center)
  95. await db_session.commit()
  96. assert await _charge(db_session, archive, item, center) is False
  97. await db_session.commit()
  98. assert len(await _charges(db_session)) == 1
  99. async def test_a_private_cost_centre_of_a_deleted_user_is_charged(db_session):
  100. """With billing on a "personal" print goes through the user's own private
  101. cost centre, which stays after the user (its owner set to none)."""
  102. from backend.app.models.finance import CostCenter
  103. archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
  104. private = await db_session.get(CostCenter, center.id)
  105. private.is_private = True
  106. private.owner_user_id = None
  107. await db_session.commit()
  108. assert await _charge(db_session, archive, item, center) is True
  109. await db_session.commit()
  110. (charge,) = await _charges(db_session)
  111. assert charge.user_id is None and charge.cost_center_id == center.id
  112. await db_session.refresh(reservation)
  113. assert reservation.status == "consumed"
  114. async def test_deleting_a_user_with_their_items_releases_their_reservations(async_client, db_session):
  115. """Their archives and queue items are deleted, so no completion can settle
  116. the reservations (it finds no archive): they held budget for good, and the
  117. cost centre could not be deleted."""
  118. from backend.app.models.user import User
  119. user = User(username="leaver", password_hash="x", role="user")
  120. db_session.add(user)
  121. await db_session.commit()
  122. archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
  123. archive.created_by_id = user.id
  124. item.created_by_id = user.id
  125. item.status = "pending" # not printing yet: it goes, and so does what it held
  126. user_id, reservation_id = user.id, reservation.id
  127. await db_session.commit()
  128. response = await async_client.delete(f"/api/v1/users/{user_id}", params={"delete_items": "true"})
  129. assert response.status_code in (200, 204), response.text
  130. db_session.expire_all()
  131. reservation = await db_session.get(BudgetReservation, reservation_id)
  132. assert reservation.status == "released"
  133. assert reservation.released_at is not None
  134. async def test_a_reprint_by_a_deleted_user_is_not_put_on_the_archives_owner(db_session):
  135. """A queue print's user is the one who queued it. A reprint reuses the
  136. source archive, whose creator is someone else: that owner did not print it."""
  137. from backend.app.models.user import User
  138. owner = User(username="archive_owner", password_hash="x", role="user")
  139. db_session.add(owner)
  140. await db_session.commit()
  141. archive, item, _reservation, center = await _orphaned_print(db_session, cost_center=True)
  142. archive.created_by_id = owner.id # the source archive; the queue row's user was deleted
  143. await db_session.commit()
  144. assert await _charge(db_session, archive, item, center) is True
  145. await db_session.commit()
  146. (charge,) = await _charges(db_session)
  147. assert charge.user_id is None
  148. async def test_deleting_a_user_with_their_items_keeps_a_print_still_running(async_client, db_session):
  149. """A running reprint of another user's archive: its queue row went, and the
  150. completion then charged the archive's owner, who never printed it. Running,
  151. it is detached (no user) instead, and billed to its cost centre as such."""
  152. from backend.app.models.user import User
  153. owner = User(username="archive_owner", password_hash="x", role="user")
  154. leaver = User(username="leaver", password_hash="x", role="user")
  155. db_session.add_all([owner, leaver])
  156. await db_session.commit()
  157. archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
  158. archive.created_by_id = owner.id # the source archive of the reprint
  159. item.created_by_id = leaver.id # printing
  160. leaver_id, item_id, reservation_id = leaver.id, item.id, reservation.id
  161. archive_id, center_id = archive.id, center.id
  162. await db_session.commit()
  163. response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
  164. assert response.status_code in (200, 204), response.text
  165. db_session.expire_all()
  166. kept = await db_session.get(PrintQueueItem, item_id)
  167. assert kept is not None and kept.created_by_id is None
  168. assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
  169. archive = await db_session.get(PrintArchive, archive_id)
  170. center = await db_session.get(CostCenter, center_id)
  171. assert await _charge(db_session, archive, kept, center) is True
  172. await db_session.commit()
  173. (charge,) = await _charges(db_session)
  174. assert charge.user_id is None
  175. assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"
  176. async def test_deleting_a_user_with_their_items_keeps_their_own_archive_still_printing(async_client, db_session):
  177. """Their own archive of a running print: deleted, the completion found no
  178. archive and the print went unbilled. Detached, it is billed as "No user"."""
  179. from backend.app.models.user import User
  180. leaver = User(username="leaver", password_hash="x", role="user")
  181. db_session.add(leaver)
  182. await db_session.commit()
  183. archive, item, _reservation, _center = await _orphaned_print(db_session, cost_center=True)
  184. archive.status = "printing"
  185. archive.created_by_id = leaver.id
  186. item.created_by_id = leaver.id
  187. leaver_id, archive_id = leaver.id, archive.id
  188. await db_session.commit()
  189. response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
  190. assert response.status_code in (200, 204), response.text
  191. db_session.expire_all()
  192. kept = await db_session.get(PrintArchive, archive_id)
  193. assert kept is not None and kept.created_by_id is None
  194. async def test_a_print_whose_cost_comes_out_as_zero_releases_its_reservation(db_session):
  195. """An older or imported archive with grams but no stored cost is reserved
  196. from the estimate; at completion its cost is none, and the charge returned
  197. before releasing the reservation, which then held budget for good."""
  198. archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
  199. archive.cost = None
  200. reservation_id = reservation.id
  201. await db_session.commit()
  202. assert await _charge(db_session, archive, item, center) is False
  203. await db_session.commit()
  204. assert await _charges(db_session) == []
  205. db_session.expire_all()
  206. assert (await db_session.get(BudgetReservation, reservation_id)).status == "released"
  207. async def test_deleting_a_user_with_their_items_keeps_their_archive_another_user_is_printing(async_client, db_session):
  208. """Another user's reprint of their archive, dispatched (queue row printing)
  209. before the printer's start marked the archive printing: deleted, the archive
  210. took that user's queue row with it and their print was never billed."""
  211. from backend.app.models.user import User
  212. leaver = User(username="leaver", password_hash="x", role="user")
  213. printer_user = User(username="reprinter", password_hash="x", role="user")
  214. db_session.add_all([leaver, printer_user])
  215. await db_session.commit()
  216. archive, item, reservation, _center = await _orphaned_print(db_session, cost_center=True)
  217. archive.created_by_id = leaver.id # still "completed": the start has not come yet
  218. item.created_by_id = printer_user.id # their row, printing
  219. leaver_id, archive_id, item_id, reservation_id = leaver.id, archive.id, item.id, reservation.id
  220. printer_user_id = printer_user.id
  221. await db_session.commit()
  222. response = await async_client.delete(f"/api/v1/users/{leaver_id}", params={"delete_items": "true"})
  223. assert response.status_code in (200, 204), response.text
  224. db_session.expire_all()
  225. kept = await db_session.get(PrintArchive, archive_id)
  226. assert kept is not None and kept.created_by_id is None
  227. assert (await db_session.get(PrintQueueItem, item_id)).created_by_id == printer_user_id
  228. assert (await db_session.get(BudgetReservation, reservation_id)).status == "active"
  229. async def test_a_billing_user_deleted_just_before_the_charge_counts_as_none(db_session):
  230. """The completion read the queue row's user, then the user was deleted
  231. before the charge (seconds of usage and archive work in between): the id
  232. names nobody. PostgreSQL refused the wallet for it (FK) and the reservation
  233. stayed active; SQLite wrote a wallet and a charge for a missing user."""
  234. from backend.app.models.finance import UserWallet
  235. archive, item, reservation, center = await _orphaned_print(db_session, cost_center=True)
  236. reservation_id = reservation.id
  237. gone_user_id = 987654 # no such user any more
  238. assert (
  239. await apply_print_charge_for_archive(
  240. db_session,
  241. archive.id,
  242. charged_user_id=gone_user_id,
  243. cost_center_id=center.id,
  244. print_queue_id=item.id,
  245. print_run_id="run-of-a-deleted-user",
  246. )
  247. is True
  248. )
  249. await db_session.commit()
  250. (charge,) = await _charges(db_session)
  251. assert charge.user_id is None
  252. assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == gone_user_id)) is None
  253. db_session.expire_all()
  254. assert (await db_session.get(BudgetReservation, reservation_id)).status == "consumed"