finance_billing.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import logging
  2. import uuid
  3. from sqlalchemy import func, select
  4. from sqlalchemy.exc import IntegrityError, SQLAlchemyError
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from backend.app.models.archive import PrintArchive
  7. from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
  8. from backend.app.services.finance_balance import sync_personal_wallet_balance
  9. from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
  10. logger = logging.getLogger(__name__)
  11. class BillingRunIdCollisionError(RuntimeError):
  12. """A billing idempotency key points at a different physical print run."""
  13. async def _get_balance_after_for_transaction(
  14. db: AsyncSession,
  15. user_id: int,
  16. cost_center_id: int | None,
  17. amount: float,
  18. ) -> float:
  19. """Calculate balance_after for a transaction.
  20. For cost-center transactions: sum of ALL transactions for that cost center (global).
  21. For personal transactions (cost_center_id=None): user's wallet balance (personal).
  22. Args:
  23. user_id: The user making the transaction
  24. cost_center_id: The cost center (None for personal)
  25. amount: The transaction amount (positive/negative)
  26. Returns:
  27. The balance after this transaction would be applied
  28. """
  29. try:
  30. if cost_center_id is None:
  31. # Personal transaction: use user wallet balance
  32. wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
  33. if wallet is None:
  34. return float(amount)
  35. return float(wallet.balance) + amount
  36. else:
  37. # Cost-center transaction: sum of ALL transactions for this cost center (global, not per-user)
  38. result = await db.execute(
  39. select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
  40. WalletTransaction.cost_center_id == cost_center_id,
  41. WalletTransaction.is_voided.is_(False),
  42. )
  43. )
  44. current_balance = float(result.scalar() or 0.0)
  45. return current_balance + amount
  46. except SQLAlchemyError as e:
  47. logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
  48. raise
  49. def _calculate_partial_charge(
  50. archive: PrintArchive,
  51. base_cost: float,
  52. *,
  53. filament_usage: tuple[float | None, float | None] | None = None,
  54. ) -> tuple[float, str]:
  55. """Calculate proportional charge for partial prints based on filament usage.
  56. Returns (charge_amount, description_suffix) where:
  57. - charge_amount: absolute cost to charge (0 if insufficient data)
  58. - description_suffix: reason/details for transaction description
  59. """
  60. try:
  61. # Only apply proportional calculation for non-completed prints
  62. if archive.status == "completed":
  63. return round(float(base_cost), 2), ""
  64. if filament_usage is not None:
  65. actual_grams, planned_grams = filament_usage
  66. filament_used = float(actual_grams or 0.0)
  67. filament_planned = float(planned_grams) if planned_grams is not None else None
  68. else:
  69. # Backwards-compatible fallback for recalculation and callers that
  70. # do not have per-run telemetry. At print completion main.py passes
  71. # the measured/progress-scaled run usage explicitly: the archive
  72. # field is the slicer's planned amount and must not be mistaken for
  73. # the amount consumed by an aborted run.
  74. filament_used = float(archive.filament_used_grams or 0.0)
  75. filament_planned = None
  76. if archive.extra_data and isinstance(archive.extra_data, dict):
  77. filament_planned = archive.extra_data.get("filament_grams_total")
  78. if filament_planned is not None:
  79. filament_planned = float(filament_planned)
  80. # If we don't have reliable planned filament data, do not guess a partial charge.
  81. # Charging a failed/aborted print without an estimated baseline can overcharge users.
  82. if filament_planned is None or filament_planned <= 0:
  83. return 0.0, f"[{archive.status}: insufficient filament data]"
  84. # Calculate proportional cost
  85. filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
  86. charge = float(base_cost) * filament_ratio
  87. # Round charges to 2 decimals for consistent persistence
  88. charge = round(charge, 2)
  89. suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
  90. return charge, suffix
  91. except ValueError as e:
  92. logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
  93. raise
  94. async def apply_print_charge_for_archive(
  95. db: AsyncSession,
  96. archive_id: int,
  97. *,
  98. charged_user_id: int | None = None,
  99. cost_center_id: int | None = None,
  100. print_queue_id: int | None = None,
  101. print_run_id: str | None = None,
  102. base_cost_override: float | None = None,
  103. filament_usage: tuple[float | None, float | None] | None = None,
  104. ) -> bool:
  105. """Apply an idempotent wallet charge for a print archive.
  106. Charges completed prints at full cost, and partial/failed prints proportionally
  107. based on actual filament used vs. planned filament.
  108. Returns True when a new wallet transaction was created.
  109. """
  110. try:
  111. if not await is_billing_enabled(db):
  112. if print_queue_id is not None:
  113. await release_budget_reservation(
  114. db, source_type="print_queue", source_id=print_queue_id, status="released"
  115. )
  116. else:
  117. await release_budget_reservation(db, print_archive_id=archive_id, status="released")
  118. logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
  119. return False
  120. archive = (
  121. await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
  122. ).scalar_one_or_none()
  123. if archive is None:
  124. logger.warning(f"Archive with ID {archive_id} not found.")
  125. return False
  126. effective_run_id = print_run_id or archive.billing_run_id
  127. # The archive-level flag is retained only for legacy deleted charges.
  128. # A new scheduler dispatch clears it while persisting its new run UUID;
  129. # current deletions are represented by a voided transaction instead.
  130. if archive.wallet_charge_skipped:
  131. logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
  132. return False
  133. # Accept completed, aborted, cancelled, and failed prints
  134. if archive.status not in ("completed", "aborted", "cancelled", "failed"):
  135. logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
  136. return False
  137. actual_user_id = charged_user_id if charged_user_id is not None else archive.created_by_id
  138. if actual_user_id is None:
  139. logger.warning(f"Archive ID {archive_id} has no creator ID.")
  140. return False
  141. base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
  142. if base_cost <= 0:
  143. logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
  144. return False
  145. # New dispatches persist a UUID before sending the printer command.
  146. # Generate one here only for legacy/in-flight rows created before that
  147. # migration; the locked archive row makes this fallback durable.
  148. if not effective_run_id:
  149. effective_run_id = str(uuid.uuid4())
  150. archive.billing_run_id = effective_run_id
  151. tx_conditions = [
  152. WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
  153. WalletTransaction.print_run_id == effective_run_id,
  154. ]
  155. existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
  156. if existing_tx is not None:
  157. if existing_tx.print_archive_id != archive.id:
  158. logger.critical(
  159. "BILLING RUN ID COLLISION: run %s belongs to archive %s, not archive %s; charge aborted",
  160. effective_run_id,
  161. existing_tx.print_archive_id,
  162. archive.id,
  163. )
  164. raise BillingRunIdCollisionError(
  165. f"Billing run ID {effective_run_id} is already assigned to another archive"
  166. )
  167. logger.info(f"Transaction already exists for archive ID {archive_id}.")
  168. if existing_tx.is_voided:
  169. logger.info("Print charge for run %s was voided by an administrator.", effective_run_id)
  170. return False
  171. # Calculate charge (full for completed, partial for others)
  172. charge, reason_suffix = _calculate_partial_charge(
  173. archive,
  174. base_cost,
  175. filament_usage=filament_usage,
  176. )
  177. if charge <= 0:
  178. if print_queue_id is not None:
  179. await release_budget_reservation(
  180. db, source_type="print_queue", source_id=print_queue_id, status="released"
  181. )
  182. else:
  183. await release_budget_reservation(db, print_archive_id=archive.id, status="released")
  184. logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
  185. return False
  186. actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
  187. wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
  188. if wallet is None:
  189. wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
  190. db.add(wallet)
  191. await db.flush()
  192. logger.info("Created new wallet for user ID %s.", actual_user_id)
  193. label = archive.print_name or archive.filename or f"Archive {archive.id}"
  194. description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
  195. balance_after = await _get_balance_after_for_transaction(db, actual_user_id, actual_cost_center_id, -charge)
  196. if balance_after is not None:
  197. balance_after = round(float(balance_after), 2)
  198. tx = WalletTransaction(
  199. user_id=actual_user_id,
  200. cost_center_id=actual_cost_center_id,
  201. transaction_type=TransactionType.PRINT_CHARGE.value,
  202. amount=-charge,
  203. balance_after=balance_after,
  204. description=description,
  205. created_by_user_id=None,
  206. print_run_id=effective_run_id,
  207. print_archive_id=archive.id,
  208. print_queue_id=print_queue_id,
  209. )
  210. # Limit a concurrent deduplication conflict to a savepoint. The caller
  211. # owns the outer transaction, which may already contain archive-owner
  212. # backfills and other completion updates that must survive this race.
  213. try:
  214. async with db.begin_nested():
  215. db.add(tx)
  216. # Flush inside the savepoint to detect unique/index conflicts.
  217. await db.flush()
  218. except IntegrityError as e:
  219. # Distinguish a legitimate concurrent retry of this exact run from
  220. # a collision or an unrelated constraint failure. Only the former
  221. # is an idempotent no-op; everything else must remain loud so the
  222. # caller rolls back and the budget reservation stays active.
  223. concurrent_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
  224. if concurrent_tx is not None and concurrent_tx.print_archive_id == archive.id:
  225. logger.info("Transaction already exists for archive ID %s (concurrent), skipping", archive_id)
  226. return False
  227. logger.critical(
  228. "Failed to persist billing charge for archive %s and run %s: %s",
  229. archive_id,
  230. effective_run_id,
  231. e,
  232. exc_info=True,
  233. )
  234. if concurrent_tx is not None:
  235. raise BillingRunIdCollisionError(
  236. f"Billing run ID {effective_run_id} is already assigned to another archive"
  237. ) from e
  238. raise
  239. # Rebuild from the canonical personal-ledger definition. A shared cost
  240. # center charge must not debit the user's personal wallet.
  241. new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
  242. # Consume matching budget reservations after the transaction is persisted
  243. if print_queue_id is not None:
  244. await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status="consumed")
  245. else:
  246. await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
  247. logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
  248. return True
  249. except SQLAlchemyError as e:
  250. logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
  251. raise
  252. except ValueError as e:
  253. logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
  254. return False