diff --git a/backend/app/main.py b/backend/app/main.py index 77bc2e42c..422fb7a11 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4970,6 +4970,28 @@ async def on_print_complete(printer_id: int, data: dict): except Exception as e: logger.warning("[BED-COOL] Failed to register waiter: %s", e) + # Capture the slicer estimate before usage tracking runs. The tracker may + # update archive.cost with this run's measured cost; billing partial runs + # against that already-partial value would discount the charge twice. + billing_planned_grams: float | None = None + billing_base_cost: float | None = None + if archive_id: + try: + async with async_session() as db: + from backend.app.models.archive import PrintArchive + + billing_archive = await db.get(PrintArchive, archive_id) + if billing_archive: + billing_path = ( + app_settings.base_dir / billing_archive.file_path if billing_archive.file_path else None + ) # SEC-PATH-OK: archive.file_path is DB-stored, internally generated + billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate( + billing_archive, + billing_path, + ) + except Exception as e: + logger.warning("[FINANCE] Failed to capture planned usage for archive %s: %s", archive_id, e) + # --- Track filament consumption (must run before archive_id early-return so usage # is recorded even when auto-archive is disabled) --- usage_results: list[dict] = [] @@ -5192,7 +5214,11 @@ async def on_print_complete(printer_id: int, data: dict): log_timing("Archive status update") - # Apply finance wallet charge or release reservations once + # Apply finance wallet charge or release reservations once. For all partial + # terminal states (failed, aborted at the printer display, or cancelled via + # Bambuddy) use this run's measured spool delta, falling back to the last + # valid printer progress. PrintArchive.filament_used_grams is the slicer + # estimate and therefore cannot represent an interrupted run. try: if data.get("status") in ("completed", "failed", "aborted", "cancelled"): async with async_session() as db: @@ -5200,12 +5226,29 @@ async def on_print_complete(printer_id: int, data: dict): from backend.app.services.finance_billing import apply_print_charge_for_archive archive = await db.get(PrintArchive, archive_id) + if archive and archive.created_by_id is None and _print_user_info: + archive.created_by_id = _print_user_info.get("user_id") + await db.flush() + + run_status = data.get("status", "completed") + last_progress = data.get("last_progress") + if last_progress is None: + last_progress = data.get("progress") + actual_run_grams = _compute_run_filament_grams( + run_status, + billing_planned_grams, + last_progress, + usage_results, + ) + filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None cost_center_id = _print_cost_center_ids.pop(archive_id, None) charged = await apply_print_charge_for_archive( db, archive_id, cost_center_id=cost_center_id, print_run_id=archive.subtask_id if archive else None, + base_cost_override=billing_base_cost, + filament_usage=filament_usage, ) await db.commit() if charged: @@ -5254,7 +5297,7 @@ async def on_print_complete(printer_id: int, data: dict): _run_grams = _compute_run_filament_grams( _run_status, _est_grams, - data.get("progress"), + data.get("last_progress", data.get("progress")), usage_results, ) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index b160c3b2d..0a5920ffd 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -2986,9 +2986,10 @@ class BambuMQTTClient: self.state.subtask_id = data["subtask_id"] if "mc_percent" in data: # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel) - if self.state.progress > 0: - self._last_valid_progress = self.state.progress - self.state.progress = float(data["mc_percent"]) + new_progress = float(data["mc_percent"]) + if new_progress > 0: + self._last_valid_progress = new_progress + self.state.progress = new_progress if "mc_remaining_time" in data: self.state.remaining_time = int(data["mc_remaining_time"]) if "mc_print_sub_stage" in data: diff --git a/backend/app/services/finance_billing.py b/backend/app/services/finance_billing.py index 6dafca64d..de35b1e7e 100644 --- a/backend/app/services/finance_billing.py +++ b/backend/app/services/finance_billing.py @@ -54,6 +54,8 @@ async def _get_balance_after_for_transaction( def _calculate_partial_charge( archive: PrintArchive, base_cost: float, + *, + filament_usage: tuple[float | None, float | None] | None = None, ) -> tuple[float, str]: """Calculate proportional charge for partial prints based on filament usage. @@ -66,13 +68,23 @@ def _calculate_partial_charge( if archive.status == "completed": return round(float(base_cost), 2), "" - filament_used = float(archive.filament_used_grams or 0.0) - filament_planned = None + if filament_usage is not None: + actual_grams, planned_grams = filament_usage + filament_used = float(actual_grams or 0.0) + filament_planned = float(planned_grams) if planned_grams is not None else None + else: + # Backwards-compatible fallback for recalculation and callers that + # do not have per-run telemetry. At print completion main.py passes + # the measured/progress-scaled run usage explicitly: the archive + # field is the slicer's planned amount and must not be mistaken for + # the amount consumed by an aborted run. + filament_used = float(archive.filament_used_grams or 0.0) + filament_planned = None - if archive.extra_data and isinstance(archive.extra_data, dict): - filament_planned = archive.extra_data.get("filament_grams_total") - if filament_planned is not None: - filament_planned = float(filament_planned) + if archive.extra_data and isinstance(archive.extra_data, dict): + filament_planned = archive.extra_data.get("filament_grams_total") + if filament_planned is not None: + filament_planned = float(filament_planned) # If we don't have reliable planned filament data, do not guess a partial charge. # Charging a failed/aborted print without an estimated baseline can overcharge users. @@ -99,6 +111,8 @@ async def apply_print_charge_for_archive( *, cost_center_id: int | None = None, print_run_id: str | None = None, + base_cost_override: float | None = None, + filament_usage: tuple[float | None, float | None] | None = None, ) -> bool: """Apply an idempotent wallet charge for a print archive. @@ -133,7 +147,7 @@ async def apply_print_charge_for_archive( logger.warning(f"Archive ID {archive_id} has no creator ID.") return False - base_cost = float(archive.cost or 0.0) + base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0)) if base_cost <= 0: logger.info(f"Base cost for archive ID {archive_id} is zero or negative.") return False @@ -150,7 +164,11 @@ async def apply_print_charge_for_archive( return False # Calculate charge (full for completed, partial for others) - charge, reason_suffix = _calculate_partial_charge(archive, base_cost) + charge, reason_suffix = _calculate_partial_charge( + archive, + base_cost, + filament_usage=filament_usage, + ) if charge <= 0: await release_budget_reservation(db, print_archive_id=archive.id, status="released") logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.") diff --git a/backend/tests/integration/test_print_lifecycle.py b/backend/tests/integration/test_print_lifecycle.py index 7da7305ea..9ced8517a 100644 --- a/backend/tests/integration/test_print_lifecycle.py +++ b/backend/tests/integration/test_print_lifecycle.py @@ -396,6 +396,10 @@ class TestTimelapseTracking: } ) + # A later status update records the last non-zero progress before + # firmware resets it during a display-side abort. + client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 25}}) + # User cancels (goes to IDLE) client._process_message( { @@ -409,6 +413,7 @@ class TestTimelapseTracking: assert completion_data["status"] == "aborted" assert "hms_errors" in completion_data + assert completion_data["last_progress"] == 25 @pytest.mark.asyncio async def test_timelapse_detected_from_ipcam_data(self): diff --git a/backend/tests/unit/services/test_finance_service_billing.py b/backend/tests/unit/services/test_finance_service_billing.py index cd907c0d1..997bef8a5 100644 --- a/backend/tests/unit/services/test_finance_service_billing.py +++ b/backend/tests/unit/services/test_finance_service_billing.py @@ -218,6 +218,79 @@ class TestFinanceBilling: class TestPartialPrintCharges: """Tests for proportional charge calculation on aborted/failed/cancelled prints.""" + @pytest.mark.asyncio + @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"]) + async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation( + self, + db_session, + status, + ): + """Bambuddy stop, display abort, and printer failure share one billing path.""" + await enable_billing(db_session) + user = User(username=f"partial_{status}", role="user", is_active=True) + cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False) + db_session.add_all([user, cost_center]) + await db_session.commit() + await db_session.refresh(user) + await db_session.refresh(cost_center) + + archive = PrintArchive( + printer_id=None, + filename=f"{status}.3mf", + file_path=f"archives/test/{status}.3mf", + file_size=100, + content_hash=f"partial-{status}-override", + status=status, + # The usage tracker may already have replaced archive.cost with the + # measured partial cost. Completion billing must use the estimate + # captured before tracking, not discount this value a second time. + cost=3.0, + filament_used_grams=100.0, + extra_data={"filament_grams_total": 100.0}, + created_by_id=user.id, + cost_center_id=cost_center.id, + ) + db_session.add(archive) + await db_session.commit() + await db_session.refresh(archive) + + reservation = BudgetReservation( + cost_center_id=cost_center.id, + amount=12.0, + status="active", + source_type="print_queue", + source_id=archive.id, + print_archive_id=archive.id, + ) + db_session.add(reservation) + await db_session.commit() + await db_session.refresh(reservation) + + changed = await apply_print_charge_for_archive( + db_session, + archive.id, + base_cost_override=12.0, + filament_usage=(25.0, 100.0), + ) + await db_session.commit() + + assert changed is True + wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id)) + assert wallet is not None + assert wallet.balance == -3.0 + + transaction = await db_session.scalar( + select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id) + ) + assert transaction is not None + assert transaction.amount == -3.0 + assert status in transaction.description.lower() + assert "25.0g/100.0g" in transaction.description + + await db_session.refresh(reservation) + assert reservation.status == "consumed" + assert reservation.released_at is not None + @pytest.mark.asyncio async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session): await enable_billing(db_session) @@ -314,8 +387,8 @@ class TestPartialPrintCharges: assert "50.0" in tx.description # filament used @pytest.mark.asyncio - async def test_cancelled_print_with_no_filament_data_is_not_charged(self, db_session): - """Verify cancelled print with no filament data is skipped.""" + async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session): + """A slicer estimate alone is not mistaken for actual run consumption.""" await enable_billing(db_session) user = User(username="cancel_no_data", role="user", is_active=True) cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False) @@ -332,7 +405,8 @@ class TestPartialPrintCharges: content_hash="cancel-hash", status="cancelled", cost=5.0, - filament_used_grams=None, # No data + filament_used_grams=100.0, + extra_data={"filament_grams_total": 100.0}, created_by_id=user.id, cost_center_id=cost_center.id, ) @@ -351,7 +425,11 @@ class TestPartialPrintCharges: await db_session.commit() await db_session.refresh(reservation) - changed = await apply_print_charge_for_archive(db_session, archive.id) + changed = await apply_print_charge_for_archive( + db_session, + archive.id, + filament_usage=(None, 100.0), + ) await db_session.commit() assert changed is False