finance_billing.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import logging
  2. from sqlalchemy import func, select
  3. from sqlalchemy.exc import IntegrityError, SQLAlchemyError
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from backend.app.models.archive import PrintArchive
  6. from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
  7. from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
  8. logger = logging.getLogger(__name__)
  9. async def _get_balance_after_for_transaction(
  10. db: AsyncSession,
  11. user_id: int,
  12. cost_center_id: int | None,
  13. amount: float,
  14. ) -> float:
  15. """Calculate balance_after for a transaction.
  16. For cost-center transactions: sum of ALL transactions for that cost center (global).
  17. For personal transactions (cost_center_id=None): user's wallet balance (personal).
  18. Args:
  19. user_id: The user making the transaction
  20. cost_center_id: The cost center (None for personal)
  21. amount: The transaction amount (positive/negative)
  22. Returns:
  23. The balance after this transaction would be applied
  24. """
  25. try:
  26. if cost_center_id is None:
  27. # Personal transaction: use user wallet balance
  28. wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
  29. if wallet is None:
  30. return float(amount)
  31. return float(wallet.balance) + amount
  32. else:
  33. # Cost-center transaction: sum of ALL transactions for this cost center (global, not per-user)
  34. result = await db.execute(
  35. select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
  36. WalletTransaction.cost_center_id == cost_center_id,
  37. )
  38. )
  39. current_balance = float(result.scalar() or 0.0)
  40. return current_balance + amount
  41. except SQLAlchemyError as e:
  42. logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
  43. raise
  44. def _calculate_partial_charge(
  45. archive: PrintArchive,
  46. base_cost: float,
  47. *,
  48. filament_usage: tuple[float | None, float | None] | None = None,
  49. ) -> tuple[float, str]:
  50. """Calculate proportional charge for partial prints based on filament usage.
  51. Returns (charge_amount, description_suffix) where:
  52. - charge_amount: absolute cost to charge (0 if insufficient data)
  53. - description_suffix: reason/details for transaction description
  54. """
  55. try:
  56. # Only apply proportional calculation for non-completed prints
  57. if archive.status == "completed":
  58. return round(float(base_cost), 2), ""
  59. if filament_usage is not None:
  60. actual_grams, planned_grams = filament_usage
  61. filament_used = float(actual_grams or 0.0)
  62. filament_planned = float(planned_grams) if planned_grams is not None else None
  63. else:
  64. # Backwards-compatible fallback for recalculation and callers that
  65. # do not have per-run telemetry. At print completion main.py passes
  66. # the measured/progress-scaled run usage explicitly: the archive
  67. # field is the slicer's planned amount and must not be mistaken for
  68. # the amount consumed by an aborted run.
  69. filament_used = float(archive.filament_used_grams or 0.0)
  70. filament_planned = None
  71. if archive.extra_data and isinstance(archive.extra_data, dict):
  72. filament_planned = archive.extra_data.get("filament_grams_total")
  73. if filament_planned is not None:
  74. filament_planned = float(filament_planned)
  75. # If we don't have reliable planned filament data, do not guess a partial charge.
  76. # Charging a failed/aborted print without an estimated baseline can overcharge users.
  77. if filament_planned is None or filament_planned <= 0:
  78. return 0.0, f"[{archive.status}: insufficient filament data]"
  79. # Calculate proportional cost
  80. filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
  81. charge = float(base_cost) * filament_ratio
  82. # Round charges to 2 decimals for consistent persistence
  83. charge = round(charge, 2)
  84. suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
  85. return charge, suffix
  86. except ValueError as e:
  87. logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
  88. raise
  89. async def apply_print_charge_for_archive(
  90. db: AsyncSession,
  91. archive_id: int,
  92. *,
  93. cost_center_id: int | None = None,
  94. print_run_id: str | None = None,
  95. base_cost_override: float | None = None,
  96. filament_usage: tuple[float | None, float | None] | None = None,
  97. ) -> bool:
  98. """Apply an idempotent wallet charge for a print archive.
  99. Charges completed prints at full cost, and partial/failed prints proportionally
  100. based on actual filament used vs. planned filament.
  101. Returns True when a new wallet transaction was created.
  102. """
  103. try:
  104. if not await is_billing_enabled(db):
  105. await release_budget_reservation(db, print_archive_id=archive_id, status="released")
  106. logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
  107. return False
  108. archive = (
  109. await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
  110. ).scalar_one_or_none()
  111. if archive is None:
  112. logger.warning(f"Archive with ID {archive_id} not found.")
  113. return False
  114. if archive.wallet_charge_skipped:
  115. logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
  116. return False
  117. # Accept completed, aborted, cancelled, and failed prints
  118. if archive.status not in ("completed", "aborted", "cancelled", "failed"):
  119. logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
  120. return False
  121. if archive.created_by_id is None:
  122. logger.warning(f"Archive ID {archive_id} has no creator ID.")
  123. return False
  124. base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
  125. if base_cost <= 0:
  126. logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
  127. return False
  128. tx_conditions = [WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value]
  129. if print_run_id:
  130. tx_conditions.append(WalletTransaction.print_run_id == print_run_id)
  131. else:
  132. tx_conditions.append(WalletTransaction.print_archive_id == archive.id)
  133. existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
  134. if existing_tx is not None:
  135. logger.info(f"Transaction already exists for archive ID {archive_id}.")
  136. return False
  137. # Calculate charge (full for completed, partial for others)
  138. charge, reason_suffix = _calculate_partial_charge(
  139. archive,
  140. base_cost,
  141. filament_usage=filament_usage,
  142. )
  143. if charge <= 0:
  144. await release_budget_reservation(db, print_archive_id=archive.id, status="released")
  145. logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
  146. return False
  147. actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
  148. wallet = (
  149. await db.execute(select(UserWallet).where(UserWallet.user_id == archive.created_by_id))
  150. ).scalar_one_or_none()
  151. if wallet is None:
  152. wallet = UserWallet(user_id=archive.created_by_id, balance=0.0, currency="EUR")
  153. db.add(wallet)
  154. await db.flush()
  155. logger.info(f"Created new wallet for user ID {archive.created_by_id}.")
  156. # Persist wallet balances rounded to cents
  157. new_wallet_balance = round(float(wallet.balance) - charge, 2)
  158. wallet.balance = new_wallet_balance
  159. label = archive.print_name or archive.filename or f"Archive {archive.id}"
  160. description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
  161. balance_after = await _get_balance_after_for_transaction(
  162. db, archive.created_by_id, actual_cost_center_id, -charge
  163. )
  164. if balance_after is not None:
  165. balance_after = round(float(balance_after), 2)
  166. tx = WalletTransaction(
  167. user_id=archive.created_by_id,
  168. cost_center_id=actual_cost_center_id,
  169. transaction_type=TransactionType.PRINT_CHARGE.value,
  170. amount=-charge,
  171. balance_after=balance_after,
  172. description=description,
  173. created_by_user_id=None,
  174. print_run_id=print_run_id or archive.subtask_id,
  175. print_archive_id=archive.id,
  176. )
  177. db.add(tx)
  178. # Ensure the transaction is flushed to detect unique/index constraint violations
  179. try:
  180. await db.flush()
  181. except IntegrityError as e:
  182. # Another concurrent worker likely created the same transaction
  183. logger.info("Transaction already exists for archive ID %s (concurrent), skipping: %s", archive_id, e)
  184. await db.rollback()
  185. return False
  186. # Consume matching budget reservations after the transaction is persisted
  187. await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
  188. logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
  189. return True
  190. except SQLAlchemyError as e:
  191. logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
  192. return False
  193. except ValueError as e:
  194. logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
  195. return False